Skip to content

Commit a9e3c95

Browse files
committed
accelerate cpu single-op kernels
CPU standalone elementwise and normalization paths were lagging PyTorch and ONNX Runtime because heavy activations used narrow or scalar transcendental paths, parallelization was inconsistent across ops, and the dispatch layer was not carrying the x86/NEON/scalar split through the full single-op surface. The benchmark evidence was also hard to reproduce: the old single-process Python runs could mix allocator and cache state between operations, and the local CI path did not mirror the benchmark and feature gates closely enough before push. Fix the compute path by routing activations, exp/tanh/sigmoid/silu/GELU, softmax/log-softmax, binary same-shape ops, and norm kernels through explicit runtime-dispatched SIMD paths with scalar fallbacks. The x86 side now uses the available AVX/AVX2/AVX-512 lanes where appropriate, aarch64 keeps NEON/scalar coverage, and large standalone ops use the shared parallel chunk dispatcher instead of ad hoc threads. The transposed-A matmul route now stays on the native direct path and avoids unsafe BLAS dispatch when OpenBLAS exposes a 64-bit int interface through the default feature build. Tighten the surrounding correctness and CI surface at the same time: isolate the QDQ calibration round-trip test names so parallel test runs cannot contaminate collected stats, add the missing VAAPI safety comments, move the model hub cache env to YSCV_CACHE_DIR, and rename trend CI variables from RUSTCV_* to YSCV_*. The Metal-only crates are also gated to macOS dependencies so cross-target checks do not pull platform-only libraries unnecessarily. Add a reproducible single-compute benchmark harness that records yscv, PyTorch, and ONNX Runtime in isolated per-op processes and writes a Markdown snapshot with git/toolchain/dependency metadata plus raw logs under ignored artifacts/. The local CI wrapper mirrors the Linux workflow gates, with optional full, cross-target, and all-features modes, and the trend baselines are refreshed from the same local benchmark run so freshness checks no longer warn. Zen 4 single-compute snapshot, 12 threads, 2000 iterations, p50 us: add 21 vs PyTorch 22 / ORT 29; mul 21 vs 21 / 30; exp 26 vs 130 / 35; relu 13 vs 13 / 19; sigmoid 15 vs 50 / 51; tanh 34 vs 372 / 45; GELU sigmoid approximation 31 vs 1011 / 71; SiLU 30 vs 62 / 45; softmax 32x1000 3 vs 5 / 8; log_softmax 32x1000 3 vs 5 / 7; softmax 512x256 7 vs 11 / 11; layer_norm 512x256 11 vs 12 / 17; batch_norm 1x64x64x3 2 vs 11 / 5. Validation: cargo fmt --check; cargo clippy --workspace --all-targets -- -D warnings; cargo test -p yscv-model --lib; bash scripts/check-ci-local.sh; bash scripts/check-ci-local.sh --all-features; bash scripts/check-ci-local.sh --cross; bash scripts/check-ci-local.sh --full; regenerated benchmarks/single-compute-zen4-2026-06-05.md and refreshed the trend baselines from the full local benchmark pass.
1 parent 68b549c commit a9e3c95

48 files changed

Lines changed: 3832 additions & 531 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ jobs:
349349
350350
- name: Compare Runtime Trend Snapshot
351351
env:
352-
RUSTCV_TREND_MAX_REGRESSION_PCT_RUNTIME: ${{ vars.RUSTCV_TREND_MAX_REGRESSION_PCT_RUNTIME }}
352+
YSCV_TREND_MAX_REGRESSION_PCT_RUNTIME: ${{ vars.YSCV_TREND_MAX_REGRESSION_PCT_RUNTIME }}
353353
run: |
354354
bash benchmarks/compare-trend-snapshots.sh \
355355
runtime \
@@ -472,7 +472,7 @@ jobs:
472472
473473
- name: Compare Microbench Trend Snapshot
474474
env:
475-
RUSTCV_TREND_MAX_REGRESSION_PCT_MICRO: ${{ vars.RUSTCV_TREND_MAX_REGRESSION_PCT_MICRO }}
475+
YSCV_TREND_MAX_REGRESSION_PCT_MICRO: ${{ vars.YSCV_TREND_MAX_REGRESSION_PCT_MICRO }}
476476
run: |
477477
bash benchmarks/compare-trend-snapshots.sh \
478478
micro \
@@ -482,15 +482,15 @@ jobs:
482482
483483
- name: Check Trend Baseline Freshness
484484
env:
485-
RUSTCV_TREND_BASELINE_MAX_AGE_DAYS: ${{ vars.RUSTCV_TREND_BASELINE_MAX_AGE_DAYS }}
486-
RUSTCV_TREND_BASELINE_ENFORCE: ${{ vars.RUSTCV_TREND_BASELINE_ENFORCE }}
485+
YSCV_TREND_BASELINE_MAX_AGE_DAYS: ${{ vars.YSCV_TREND_BASELINE_MAX_AGE_DAYS }}
486+
YSCV_TREND_BASELINE_ENFORCE: ${{ vars.YSCV_TREND_BASELINE_ENFORCE }}
487487
run: |
488488
bash benchmarks/check-trend-baseline-freshness.sh \
489489
benchmarks/trend-baseline-micro.tsv \
490-
"${RUSTCV_TREND_BASELINE_MAX_AGE_DAYS:-14}"
490+
"${YSCV_TREND_BASELINE_MAX_AGE_DAYS:-14}"
491491
bash benchmarks/check-trend-baseline-freshness.sh \
492492
benchmarks/trend-baseline-runtime.tsv \
493-
"${RUSTCV_TREND_BASELINE_MAX_AGE_DAYS:-14}"
493+
"${YSCV_TREND_BASELINE_MAX_AGE_DAYS:-14}"
494494
495495
- name: Upload Benchmark Artifact
496496
uses: actions/upload-artifact@v4
@@ -536,4 +536,3 @@ jobs:
536536
# validated separately via ThreadSanitizer (nightly
537537
# -Zsanitizer=thread build-std) and Valgrind helgrind in the
538538
# section-race-fix work — both more useful here than Miri.
539-
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
use std::time::Instant;
2+
3+
use yscv_kernels::{
4+
BatchNorm2dParams, LayerNormLastDimParams, ParallelElementwiseConfig, add_out,
5+
batch_norm2d_nhwc, exp_with_config, gelu, layer_norm_last_dim, log_softmax_last_dim,
6+
log_softmax_last_dim_out, mul_out, relu, relu_out_with_config, relu_to_slice_dispatch, sigmoid,
7+
sigmoid_with_config, silu, silu_inplace, softmax_last_dim, softmax_last_dim_out, tanh_act,
8+
tanh_act_with_config,
9+
};
10+
use yscv_tensor::Tensor;
11+
12+
#[derive(Debug, Clone, Copy)]
13+
struct Stats {
14+
min_us: u128,
15+
p50_us: u128,
16+
avg_us: u128,
17+
}
18+
19+
#[derive(Debug, Clone)]
20+
struct BenchArgs {
21+
warmup: usize,
22+
iters: usize,
23+
filter: Option<String>,
24+
}
25+
26+
impl BenchArgs {
27+
fn parse() -> Self {
28+
let mut warmup = 10usize;
29+
let mut iters = 200usize;
30+
let mut filter = None;
31+
let mut args = std::env::args().skip(1);
32+
while let Some(arg) = args.next() {
33+
match arg.as_str() {
34+
"--warmup" => {
35+
let value = args.next().expect("--warmup needs a value");
36+
warmup = value.parse().expect("--warmup must be a usize");
37+
}
38+
"--iters" => {
39+
let value = args.next().expect("--iters needs a value");
40+
iters = value.parse().expect("--iters must be a usize");
41+
}
42+
"--filter" => {
43+
filter = args.next();
44+
}
45+
value => {
46+
filter = Some(value.to_string());
47+
}
48+
}
49+
}
50+
Self {
51+
warmup,
52+
iters,
53+
filter,
54+
}
55+
}
56+
57+
fn should_run(&self, name: &str) -> bool {
58+
self.filter
59+
.as_ref()
60+
.is_none_or(|needle| name.contains(needle))
61+
}
62+
}
63+
64+
fn bench<F>(args: &BenchArgs, name: &str, mut f: F)
65+
where
66+
F: FnMut(),
67+
{
68+
if !args.should_run(name) {
69+
return;
70+
}
71+
72+
for _ in 0..args.warmup {
73+
f();
74+
}
75+
76+
let mut samples = Vec::with_capacity(args.iters);
77+
for _ in 0..args.iters {
78+
let start = Instant::now();
79+
f();
80+
samples.push(start.elapsed().as_micros());
81+
}
82+
samples.sort_unstable();
83+
let sum: u128 = samples.iter().copied().sum();
84+
let stats = Stats {
85+
min_us: samples[0],
86+
p50_us: samples[samples.len() / 2],
87+
avg_us: sum / samples.len() as u128,
88+
};
89+
90+
println!(
91+
"{name:<32} min={:>6}us p50={:>6}us avg={:>6}us",
92+
stats.min_us, stats.p50_us, stats.avg_us
93+
);
94+
}
95+
96+
fn input_1m() -> Tensor {
97+
Tensor::from_vec(
98+
vec![1_000_000],
99+
(0..1_000_000)
100+
.map(|i| ((i as f32 * 0.001).sin() * 6.0) - 3.0)
101+
.collect(),
102+
)
103+
.unwrap()
104+
}
105+
106+
fn input_921k() -> Tensor {
107+
Tensor::from_vec(
108+
vec![921_600],
109+
(0..921_600)
110+
.map(|i| ((i as f32 * 0.001).cos() * 6.0) - 3.0)
111+
.collect(),
112+
)
113+
.unwrap()
114+
}
115+
116+
fn main() {
117+
let args = BenchArgs::parse();
118+
let cpu = yscv_kernels::host_cpu();
119+
println!("host_cpu={cpu:?}");
120+
println!(
121+
"available_parallelism={} RAYON_NUM_THREADS={}",
122+
std::thread::available_parallelism()
123+
.map(|n| n.get())
124+
.unwrap_or(1),
125+
std::env::var("RAYON_NUM_THREADS").unwrap_or_else(|_| "unset".to_string())
126+
);
127+
println!("operation min p50 avg");
128+
println!("{}", "-".repeat(62));
129+
130+
let a1m = input_1m();
131+
let b1m = Tensor::from_vec(
132+
vec![1_000_000],
133+
(0..1_000_000)
134+
.map(|i| ((i as f32 * 0.0007).cos() * 4.0) + 1.0)
135+
.collect(),
136+
)
137+
.unwrap();
138+
let a921k = input_921k();
139+
140+
println!(
141+
"iters={} warmup={} filter={}",
142+
args.iters,
143+
args.warmup,
144+
args.filter.as_deref().unwrap_or("none")
145+
);
146+
bench(&args, "add_1M", || {
147+
let _ = yscv_kernels::add(&a1m, &b1m).unwrap();
148+
});
149+
let mut add_out_1m = Tensor::zeros(vec![1_000_000]).unwrap();
150+
bench(&args, "add_1M_out_reuse", || {
151+
add_out(&a1m, &b1m, &mut add_out_1m).unwrap();
152+
});
153+
bench(&args, "mul_1M", || {
154+
let _ = yscv_kernels::mul(&a1m, &b1m).unwrap();
155+
});
156+
let mut mul_out_1m = Tensor::zeros(vec![1_000_000]).unwrap();
157+
bench(&args, "mul_1M_out_reuse", || {
158+
mul_out(&a1m, &b1m, &mut mul_out_1m).unwrap();
159+
});
160+
bench(&args, "exp_1M", || {
161+
let _ = exp_with_config(&a1m, ParallelElementwiseConfig::default());
162+
});
163+
bench(&args, "relu_921K", || {
164+
let _ = relu(&a921k);
165+
});
166+
let mut relu_out_921k = Tensor::zeros(vec![921_600]).unwrap();
167+
bench(&args, "relu_921K_out_reuse", || {
168+
relu_out_with_config(
169+
&a921k,
170+
&mut relu_out_921k,
171+
ParallelElementwiseConfig::default(),
172+
)
173+
.unwrap();
174+
});
175+
let mut relu_raw = vec![0.0f32; a921k.data().len()];
176+
bench(&args, "relu_921K_raw_slice", || {
177+
relu_to_slice_dispatch(a921k.data(), &mut relu_raw);
178+
});
179+
bench(&args, "sigmoid_921K", || {
180+
let _ = sigmoid(&a921k);
181+
});
182+
bench(&args, "sigmoid_921K_disabled", || {
183+
let _ = sigmoid_with_config(&a921k, ParallelElementwiseConfig::disabled());
184+
});
185+
bench(&args, "tanh_1M", || {
186+
let _ = tanh_act(&a1m);
187+
});
188+
bench(&args, "tanh_1M_disabled", || {
189+
let _ = tanh_act_with_config(&a1m, ParallelElementwiseConfig::disabled());
190+
});
191+
bench(&args, "gelu_1M", || {
192+
let _ = gelu(&a1m);
193+
});
194+
bench(&args, "silu_1M", || {
195+
let _ = silu(&a1m);
196+
});
197+
let silu_base = input_1m();
198+
bench(&args, "silu_1M_inplace_clone", || {
199+
let mut t = silu_base.clone();
200+
silu_inplace(&mut t);
201+
});
202+
203+
let sm_32_1000 = Tensor::from_vec(
204+
vec![32, 1000],
205+
(0..32_000)
206+
.map(|i| ((i as f32 * 0.013).sin() * 2.0) - 1.0)
207+
.collect(),
208+
)
209+
.unwrap();
210+
bench(&args, "softmax_32x1000", || {
211+
let _ = softmax_last_dim(&sm_32_1000).unwrap();
212+
});
213+
let mut sm_32_1000_out = Tensor::zeros(vec![32, 1000]).unwrap();
214+
bench(&args, "softmax_32x1000_out_reuse", || {
215+
softmax_last_dim_out(&sm_32_1000, &mut sm_32_1000_out).unwrap();
216+
});
217+
bench(&args, "log_softmax_32x1000", || {
218+
let _ = log_softmax_last_dim(&sm_32_1000).unwrap();
219+
});
220+
let mut lsm_32_1000_out = Tensor::zeros(vec![32, 1000]).unwrap();
221+
bench(&args, "log_softmax_32x1000_out_reuse", || {
222+
log_softmax_last_dim_out(&sm_32_1000, &mut lsm_32_1000_out).unwrap();
223+
});
224+
225+
let sm_512_256 = Tensor::from_vec(
226+
vec![512, 256],
227+
(0..131_072)
228+
.map(|i| ((i as f32 * 0.007).cos() * 2.0) - 1.0)
229+
.collect(),
230+
)
231+
.unwrap();
232+
bench(&args, "softmax_512x256", || {
233+
let _ = softmax_last_dim(&sm_512_256).unwrap();
234+
});
235+
let mut sm_512_256_out = Tensor::zeros(vec![512, 256]).unwrap();
236+
bench(&args, "softmax_512x256_out_reuse", || {
237+
softmax_last_dim_out(&sm_512_256, &mut sm_512_256_out).unwrap();
238+
});
239+
240+
let ln_input = Tensor::from_vec(
241+
vec![512, 256],
242+
(0..131_072)
243+
.map(|i| ((i as f32 * 0.005).sin() * 2.0) - 1.0)
244+
.collect(),
245+
)
246+
.unwrap();
247+
let ln_gamma = Tensor::ones(vec![256]).unwrap();
248+
let ln_beta = Tensor::zeros(vec![256]).unwrap();
249+
bench(&args, "layer_norm_512x256", || {
250+
let _ = layer_norm_last_dim(
251+
&ln_input,
252+
LayerNormLastDimParams {
253+
gamma: &ln_gamma,
254+
beta: &ln_beta,
255+
epsilon: 1e-5,
256+
},
257+
)
258+
.unwrap();
259+
});
260+
261+
let bn_input = Tensor::from_vec(
262+
vec![1, 64, 64, 3],
263+
(0..12_288)
264+
.map(|i| ((i as f32 * 0.011).sin() * 2.0) - 1.0)
265+
.collect(),
266+
)
267+
.unwrap();
268+
let bn_gamma = Tensor::ones(vec![3]).unwrap();
269+
let bn_beta = Tensor::zeros(vec![3]).unwrap();
270+
let bn_mean = Tensor::zeros(vec![3]).unwrap();
271+
let bn_var = Tensor::ones(vec![3]).unwrap();
272+
bench(&args, "batch_norm_1x64x64x3", || {
273+
let _ = batch_norm2d_nhwc(
274+
&bn_input,
275+
BatchNorm2dParams {
276+
gamma: &bn_gamma,
277+
beta: &bn_beta,
278+
mean: &bn_mean,
279+
variance: &bn_var,
280+
epsilon: 1e-5,
281+
},
282+
)
283+
.unwrap();
284+
});
285+
}

benchmarks/check-trend-baseline-freshness.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ if [[ "$age_days" -gt "$max_age_days" ]]; then
8686

8787
enforce="${YSCV_TREND_BASELINE_ENFORCE:-0}"
8888
if [[ "$enforce" == "1" ]]; then
89-
echo "failing due to YSCV_TREND_BASELINE_ENFORCE=1" >&2
89+
echo "failing due to trend baseline freshness enforcement" >&2
9090
exit 1
9191
fi
9292
fi

0 commit comments

Comments
 (0)