Skip to content

perf(cpu): stop routing Tanh/Sigmoid through MLAS - #1124

Merged
justinchuby merged 2 commits into
mainfrom
deckard/mlas-tanh-sigmoid-regression
Aug 17, 2026
Merged

justinchuby merged 2 commits into
mainfrom
deckard/mlas-tanh-sigmoid-regression

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 17, 2026 •

Copy link
Copy Markdown
Owner

What

Delete the MLAS route for Tanh and Sigmoid. Both ops now run the pure-Rust AVX2 kernel in every build, mlas on or off.

Root cause

#1111 added MLAS routes for Tanh, Sigmoid, Erf and exact Gelu because MLAS was measurably faster. That was true at the time. Two things have changed since:

  1. The polynomial was never the win. Reading mlas/lib/tanh.cpp and logistic.cpp showed both are Eigen rational approximations P(v²)·v / Q(v²) with the range clamp fused into the same pass. Our avx2::tanh_ps/sigmoid_ps already used the identical rational and the identical constants. The route was buying MLAS's loop, not a better approximation.
  2. perf(cpu): drop provably-redundant saturation from AVX2 tanh/sigmoid #1121 removed the loop deficit. Our kernels carried ~6 redundant vector ops (a result clamp plus two saturation cmpps+blendvps pairs) that were proven bit-identically redundant over 2 086 666 240 values. With those gone the pure-Rust kernel is no longer the slower loop.

Meanwhile the MLAS route always had a tax the native path does not: MlasComputeTanh/MlasComputeLogistic are not range-preserving. Over a dense sweep of [-20, 20], tanh lands outside [-1, 1] for 2934 of 1048576 points (up to 2 ulp, tanh(-8.990999) = -1.0000001) and logistic outside [0, 1] for 2. Our kernels guarantee the range, monotonicity_within_documented_slack asserts it, and downstream code relies on it — sqrt(1 - tanh²) turns a 1-ulp overshoot into a NaN. So the route had to re-read every block MLAS had just written and clamp it.

Net: the fix-up pass now costs more than the polynomial saves.

Benchmark

Same machine (AMD EPYC 9V74, AVX2+FMA, AVX-512 masked), same harness, 1 thread, --iters 30 --reps 5, medians of 3 interleaved rounds alternating the two builds, EP assignment asserted from ORT's own profiler (zero NOT-ASSIGNED). p50 µs, lower is better.

op n mlas off mlas on (before) on/off
Tanh 4 Ki 7.58 7.68 1.01x slower
Tanh 16 Ki 11.93 12.91 1.08x slower
Tanh 64 Ki 32.54 36.20 1.11x slower
Tanh 256 Ki 105.64 146.73 1.39x slower
Tanh 1 Mi 438.43 469.44 1.07x slower
Tanh 4 Mi 1896.32 2226.19 1.17x slower
Sigmoid 4 Ki 7.27 7.49 1.03x slower
Sigmoid 16 Ki 11.99 13.35 1.11x slower
Sigmoid 64 Ki 31.16 38.26 1.23x slower
Sigmoid 256 Ki 113.69 129.34 1.14x slower
Sigmoid 1 Mi 408.89 525.83 1.29x slower
Sigmoid 4 Mi 1887.40 2268.40 1.20x slower

The route lost at every size measured, for both ops.

The route was reachable for any length >= SIMD_MIN_LEN, which is 32, so the table above left the band [32, 4096) unmeasured. Opus review flagged that "no crossover to preserve" therefore rested on extrapolation, so it was measured too:

op n mlas off mlas on (before) on/off
Tanh 64 5.61 5.84 1.04x slower
Tanh 256 5.46 5.73 1.05x slower
Tanh 1024 5.99 6.23 1.04x slower
Sigmoid 64 5.37 5.64 1.05x slower
Sigmoid 256 5.45 5.60 1.03x slower
Sigmoid 1024 6.09 6.27 1.03x slower

Still no crossover — MLAS loses across the entire reachable range, 64 to 4 Mi. (Below ~4 Ki both builds sit on a ~5.4 µs per-node floor, so these differences are small in relative terms; the point is only that the sign never flips.) This is therefore a deletion rather than a threshold.

Why this matters now

Since #1115 the wheel ships with mlas enabled on x86_64 (python/nxrt-ep-cpu/setup.py::_mlas_features). This was not a measurement-only artefact — it was the shipped configuration. That is exactly the failure mode docs/performance/ABSORBING_MLAS.md names: "the configuration we measured was not the configuration we shipped".

Effect against ORT

Restated as ORT p50 ÷ ours (>1 = we win), 1 thread:

op n shipped before shipped after
Tanh 256 Ki 0.71 0.99
Tanh 4 Mi 0.99 1.16
Sigmoid 64 Ki 0.75 0.93
Sigmoid 1 Mi 0.75 0.96

Still short of parity at several sizes — that work continues — but this recovers most of a self-inflicted gap.

What is kept

Erf and exact Gelu keep their MLAS routes, because the arithmetic is different:

  • Erf needs no fix-up at all — MLAS's result is used verbatim — and still wins 1.40x at 64 Ki and 1.59x at 4 Mi (5654 → 3551 µs).
  • exact Gelu's fix-up is a non-writing compare scan (repairing MLAS's -inf → NaN, which is -inf · 0), not a read-modify-write pass, and it still wins 1.17x at 4 Mi (6485 → 5550 µs).

So the rule this establishes is narrower than "MLAS is slower": an MLAS route that needs a writing fix-up pass no longer pays for itself; one that needs no fix-up, or only a scan, still does.

Correctness

Nothing about the numerics of the shipped path changes for Tanh/Sigmoid — it becomes the pure-Rust path, which is what a default (mlas-off) build has always run and what every dense numeric sweep in the file already tests. This strictly reduces cross-build divergence: mlas-on and mlas-off builds are now bit-identical for these two ops on every host, which they were not while MLAS's ISA-dispatched kernel was in play.

The clamped MLAS reference is preserved verbatim inside the mlas_ab test module as tanh_mlas_ref/sigmoid_mlas_ref, so all three comparisons keep running against it:

  • mlas_matches_rust_simd_on_special_values — bit-equality on NaN (both signs), ±Inf, ±0, ±MIN_POSITIVE, smallest subnormals, MAX/MIN, ±88.5, ±1e-30, padded past SIMD_MIN_LEN and repeated so every value lands at every lane offset.
  • mlas_vs_rust_dense_ulp_sweep — dense ULP sweep over [-20, 20].
  • mlas_vs_rust_simd — the benchmark that produced the table above.

Had these been deleted along with the route, a future MLAS bump could have diverged silently.

Tests

  • cargo test -p onnx-runtime-ep-cpu --features mlas --lib → 1309 passed, 0 failed
  • cargo build -p onnx-runtime-ep-cpu (default, no mlas) → clean
  • cargo fmt --all -- --check → clean

Limitations

  • Measured on one host (AMD EPYC 9V74, Zen 4, AVX2+FMA, AVX-512 masked off). On a machine where MLAS dispatches an AVX-512 kernel and we stay on AVX2, the balance could differ — but the fix-up pass is pure memory traffic and does not get cheaper with a wider ISA, and the clamp is required regardless, so the direction should hold.
  • Only 1-thread numbers are quoted. The 16-thread picture is dominated by a much larger threading gap that this PR does not touch and that is tracked separately.
  • This does not make Tanh/Sigmoid beat ORT at every size. It removes a regression; the remaining gap is real and open.

Review

Independent Opus review: GO WITH FINDINGS, three findings, all addressed in 4b47896:

  1. (MINOR) The rationale comment had merged into MLAS_FIXUP_BLOCK's doc comment, so rustdoc rendered "Why Tanh and Sigmoid do not have an MLAS route" as the summary of an 8192-element block-size constant — and, being #[cfg(feature = "mlas")]-gated, the explanation for the route's absence disappeared from a default build. It is now a free-standing comment.
  2. (NIT) dispatch_mlas!'s doc still said "these four ops" when the macro now has two callers. Reworded, with a pointer to the removal note.
  3. (NIT) "lost at every size" was every size measured, leaving [32, 4096) to extrapolation. Measured — see the second table above.

The reviewer independently verified the route removal is complete (mlas_sys::compute_tanh/compute_logistic are unreachable from shipped code), that _mlas_features really does ship mlas on linux-x86_64, that erf_gelu_mlas's scan is genuinely non-writing on the all-finite path, that mlas_clamped_ref is faithful to the deleted code rather than a straw man, and that the 1.39x / 1.59x / 1.17x arithmetic checks out.

CI

Fast (Linux x86_64) (the required check), EP conformance (Linux x86_64), Rust quality, Miri unsafe-crate soundness, Rust coverage (Linux x86_64), audit and both codecov lanes are green.

The red lanes — CLI ORT (Linux/Windows x86_64), CUDA compile (Linux/Windows x86_64), Rust coverage (macOS arm64), Rust (Windows ARM64) — are red on pristine origin/main too, at every one of the last five commits. Within CLI ORT the activation-relevant step fails on shape_inference_coverage::every_registered_op_has_a_shape_rule_or_is_a_known_gap, which reproduces verbatim on an untouched origin/main checkout and names six Nchwc* ops (NchwcConv, NchwcMaxPool, ...) with no shape rule. Nothing to do with activations, and not introduced here.

Worth flagging for whoever owns those ops: that test failing means GetCapability is silently handing six registered ops to ORT's CPU EP, which the project's architecture policy forbids. The two activation-side falsifiers in the same file — no_activation_or_norm_op_is_left_to_ort and activation_and_norm_ops_clear_every_capability_filter — pass.

The MLAS route for these two has been a net loss since #1121. Both kernels
already used the same Eigen rational MLAS does, so the route never bought a
better polynomial -- it bought MLAS's loop. Once the redundant saturation
blends came out of our AVX2 kernels the loop stopped being the bottleneck,
and the clamp pass the route needs (MLAS's rational is not range-preserving)
now costs more than it saves.

Measured on this host, mlas-off vs mlas-on p50 at 1 thread, medians of 3
interleaved rounds with EP assignment asserted:

  Tanh     64 Ki  32.5 / 36.2   256 Ki  105.6 / 146.7   4 Mi  1896 / 2226
  Sigmoid  64 Ki  31.2 / 38.3   256 Ki  113.7 / 129.3   4 Mi  1887 / 2268

The route lost at every size, by up to 1.39x. Since #1115 the wheel ships
with `mlas` on, so this was shipping.

Erf and exact Gelu keep their routes: Erf needs no fix-up and still wins
1.4-1.6x, Gelu's fix-up is a non-writing compare scan and it still wins
1.17x at 4 Mi.

The clamped MLAS reference moves into the test module, so the benchmark,
special-value and dense-ULP comparisons all keep running against it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@codecov

codecov Bot commented Aug 17, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.53%. Comparing base (c7af138) to head (4b47896).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1124      +/-   ##
==========================================
+ Coverage   79.85%   80.53%   +0.67%     
==========================================
  Files         366      366              
  Lines      157049   157082      +33     
  Branches   157049   157082      +33     
==========================================
+ Hits       125412   126500    +1088     
+ Misses      26935    25877    -1058     
- Partials     4702     4705       +3     
Flag Coverage Δ
cli-ort-linux 83.79% <ø> (ø)
cli-ort-windows 83.31% <ø> (-0.10%) ⬇️
offline 80.41% <100.00%> (+0.69%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...nnx-runtime-ep-cpu/src/kernels/simd_activations.rs 97.41% <100.00%> (ø)

... and 8 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Opus review of #1124 found the rationale comment had merged into
MLAS_FIXUP_BLOCK's doc comment, so rustdoc showed "Why Tanh and Sigmoid do
not have an MLAS route" as the summary of an 8192-element block-size
constant -- and, being cfg-gated, the explanation for the route's absence
vanished from a default no-mlas build. It is now a free-standing comment.

Also corrects the dispatch_mlas! doc, which still said "these four ops"
when the macro now has two callers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby marked this pull request as ready for review August 17, 2026 12:55
@justinchuby
justinchuby merged commit 233a636 into main Aug 17, 2026
12 of 18 checks passed
@justinchuby
justinchuby deleted the deckard/mlas-tanh-sigmoid-regression branch August 17, 2026 12:55
@github-actions

Copy link
Copy Markdown

🔴 Benchmark Regression Detected

Comparison of criterion micro-benchmarks: PR head vs merge-base, measured on the same runner in the same job (base first → PR second).

ℹ️ Absolute times are informational only — they vary with runner load. The % change column is the reliable signal because both sides ran under identical conditions.

Status Scenario Base PR Change
🔴 block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 748.82 µs 1.47 ms +95.8%
🔴 block_quantized_matmul_cached_dense/mxfp4_preexpanded_dense_oncelock_like_proxy/1x1024x1024 68.49 µs 123.02 µs +79.6%
🔴 grammar_masking/llguidance_compute_mask/32 74.77 µs 130.68 µs +74.8%
🔴 add/large_f16_threads=1-internal/4194304 1.69 ms 2.91 ms +72.2%
🔴 matmul/large_generic_f32_threads=8/32x1024x1024 3.90 ms 6.25 ms +60.2%
🔴 block_quantized_matmul_cached_dense/mxfp4_cached_dense_repeated_call/1x1024x1024 94.05 µs 150.42 µs +59.9%
🔴 add/medium_bf16_threads=1-internal/262144 108.67 µs 144.61 µs +33.1%
⚠️ matmul/medium_generic_bf16_threads=8/32x512x512 518.21 µs 664.75 µs +28.3%
⚠️ add/large_bf16_threads=1-internal/4194304 1.74 ms 2.17 ms +24.6%
⚠️ kv_cache/alloc_dealloc_pages 39.82 µs 49.37 µs +24.0%
⚠️ logit_processing/seven_processor_chain_per_step 321.54 µs 392.04 µs +21.9%
⚠️ block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 595.89 µs 722.69 µs +21.3%
⚠️ matmul/large_generic_f32_threads=1/32x1024x1024 9.77 ms 11.76 ms +20.3%
⚠️ add/large_f32_threads=1-internal/4194304 780.48 µs 938.73 µs +20.3%
⚠️ qwen3_sampling_processors/top_k_full_sort_baseline 2.30 ms 2.75 ms +19.7%
⚠️ sampling_latency/min_p_per_token 226.59 µs 268.12 µs +18.3%
⚠️ matmul/medium_generic_f32_threads=8/32x512x512 1.38 ms 1.64 ms +18.3%
⚠️ gather/small_bf16_threads=1-internal/4096 495.2 ns 582.3 ns +17.6%
⚠️ matmul/medium_generic_f16_threads=8/32x512x512 40.69 µs 47.20 µs +16.0%
⚠️ matmul/medium_generic_f16_threads=1/32x512x512 36.20 µs 41.87 µs +15.7%
⚠️ gather/medium_f32_threads=1-internal/32768 3.74 µs 4.33 µs +15.7%
⚠️ tokenization/encode_tokens_per_second 421.94 µs 486.49 µs +15.3%
✅ qwen3_sampling_processors/top_k_partial_selection 157.81 µs 181.27 µs +14.9%
✅ tokenization/decode_tokens_per_second 8.08 ms 9.28 ms +14.9%
✅ qwen3_sampling_processors/top_k_top_p_full_sort_baseline 5.88 ms 6.70 ms +13.9%
✅ qwen3_sampling_processors/top_p_fast_after_top_k 518.72 µs 589.83 µs +13.7%
✅ qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline 3.62 ms 4.09 ms +12.9%
✅ matmul/large_generic_bf16_threads=8/32x1024x1024 2.23 ms 2.51 ms +12.6%
✅ reduce_mean/large_f32_threads=1-internal/262144 996.70 µs 1.12 ms +12.4%
✅ sampling_latency/top_p_per_token 436.99 µs 488.25 µs +11.7%
✅ gather/large_f16_threads=1-internal/131072 14.07 µs 15.49 µs +10.1%
✅ block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 180.96 µs 197.45 µs +9.1%
✅ sampling_latency/greedy_per_token 3.48 µs 3.80 µs +9.0%
✅ sampling_latency/top_k_per_token 59.80 µs 63.88 µs +6.8%
✅ gather/small_f16_threads=1-internal/4096 473.5 ns 502.9 ns +6.2%
✅ gather/small_f32_threads=1-internal/4096 681.2 ns 695.5 ns +2.1%
✅ reduce_mean/medium_f32_threads=1-internal/65536 284.07 µs 280.92 µs -1.1%
✅ gather/large_bf16_threads=1-internal/131072 13.45 µs 13.26 µs -1.4%
✅ gather/medium_f16_threads=1-internal/32768 2.74 µs 2.70 µs -1.4%
✅ reduce_mean/small_f32_threads=1-internal/4096 17.45 µs 17.19 µs -1.5%
✅ matmul/medium_generic_bf16_threads=1/32x512x512 625.79 µs 614.19 µs -1.9%
✅ matmul/large_generic_f16_threads=1/32x1024x1024 107.37 µs 100.92 µs -6.0%
✅ add/small_bf16_threads=1-internal/1024 591.1 ns 550.6 ns -6.8%
✅ qwen3_sampling_processors/top_k_top_p_fast 726.75 µs 672.15 µs -7.5%
✅ gather/medium_bf16_threads=1-internal/32768 2.87 µs 2.63 µs -8.2%
✅ add/medium_f16_threads=1-internal/262144 136.47 µs 120.97 µs -11.4%
✅ matmul/medium_generic_f32_threads=1/32x512x512 2.71 ms 2.36 ms -13.1%
🟢 gather/large_f32_threads=1-internal/131072 37.68 µs 31.58 µs -16.2%
🟢 matmul/small_generic_f16_threads=1/1x256x256 37.41 µs 30.88 µs -17.5%
🟢 matmul/large_generic_f16_threads=8/32x1024x1024 135.82 µs 109.88 µs -19.1%
🟢 matmul/small_generic_bf16_threads=1/1x256x256 40.86 µs 32.50 µs -20.5%
🟢 add/small_f32_threads=1-internal/1024 281.2 ns 221.9 ns -21.1%
🟢 matmul/large_generic_bf16_threads=1/32x1024x1024 3.04 ms 2.39 ms -21.3%
🟢 matmul/small_generic_f32_threads=1/1x256x256 51.82 µs 40.28 µs -22.3%
🟢 matmul/small_generic_f16_threads=8/1x256x256 41.13 µs 30.22 µs -26.5%
🟢 matmul/small_generic_f32_threads=8/1x256x256 46.02 µs 33.35 µs -27.5%
🟢 add/medium_f32_threads=1-internal/262144 34.44 µs 24.82 µs -27.9%
🟢 matmul/small_generic_bf16_threads=8/1x256x256 46.02 µs 31.70 µs -31.1%
🟢 add/small_f16_threads=1-internal/1024 691.9 ns 471.7 ns -31.8%

Visual flags: ⚠️ ≥ 15% slower, 🔴 ≥ 30% slower — calibrated against measured runner noise (~27% worst-case on multi-threaded matmul)

Host info
CPU: Apple M1 (Virtual)
Cores: 3
OS: Darwin 25.5.0 arm64
Rust: rustc 1.97.1 (8bab26f4f 2026-07-14)
Load avg: { 6.88 4.30 6.09 }
What this cannot catch
  • Regressions in code paths not covered by these benchmarks (e.g., end-to-end decode with a real model)
  • Sub-threshold regressions that compound over multiple PRs
  • Performance changes that only manifest under GPU execution
  • Latency changes in the ORT integration path (these benchmarks exercise the native Rust kernels)

justinchuby added a commit that referenced this pull request Aug 17, 2026
## What

Make the MLAS activation routes go through `run_chunked`, the shared
chunking/parallel seam every pure-Rust route already uses.

One line:

```rust
-                f(input, output);
+                run_chunked(input, output, f);
```

## Root cause

`dispatch_mlas!` called its kernel on the whole tensor and returned:

```rust
if input.len() >= SIMD_MIN_LEN {
    let f: fn(&[f32], &mut [f32]) = $mlas;
    f(input, output);   // <-- straight past run_chunked
    return;
}
```

`run_chunked` was the only thing that split work across the pool. So
**every MLAS-routed op ran single-threaded, regardless of how many
threads the session had.**

This was harmless when it was written — `run_chunked` was a plain loop
then. #1105 taught it to parallelise above `PAR_MIN_LEN`, and from that
moment the two builds diverged: an `mlas`-off build scaled with the
pool, an `mlas`-on build did not. Since #1115 the wheel ships `mlas` on
x86_64, so the shipped configuration was the non-scaling one.

Nothing caught it because the results were still correct, and
correctness is all the existing tests checked. `thread_invariance`
asserts a kernel gives the same answer serial and parallel — which a
route that *never* goes parallel satisfies trivially.

## Benchmark

Same machine (AMD EPYC 9V74, 32 vCPU, AVX2+FMA), `ab_ort.py`, `--iters
30 --reps 5`, medians of 3 interleaved rounds alternating the two
builds, EP assignment asserted from ORT's own profiler (zero
`NOT-ASSIGNED`). Both arms built `--features mlas --release` from the
same base. p50 µs.

### 16 threads — the ops that take the MLAS route

| op | n | before | after | speedup |
|---|---:|---:|---:|---:|
| Erf | 1 Mi | 897.3 | **393.9** | **2.28x** |
| Erf | 4 Mi | 3558.7 | **561.6** | **6.34x** |
| Gelu (exact) | 1 Mi | 1304.0 | **552.8** | **2.36x** |
| Gelu (exact) | 4 Mi | 5458.7 | **1134.3** | **4.81x** |
| Tanh | 1 Mi | 480.8 | **300.4** | **1.60x** |
| Tanh | 4 Mi | 2219.6 | **425.9** | **5.21x** |
| Sigmoid | 1 Mi | 503.5 | **298.6** | **1.69x** |
| Sigmoid | 4 Mi | 1991.6 | **430.0** | **4.63x** |

(`Tanh`/`Sigmoid` measured on a base that predates #1124; they no longer
take the MLAS route, but they exercised the same mechanism and are kept
here as evidence of it.)

### 16 threads — ops with no MLAS route, as the control

Unchanged within noise, which is what says the win is the routing change
and not drift:

| op | n | before | after |
|---|---:|---:|---:|
| FastGelu | 1 Mi | 413.9 | 395.5 |
| FastGelu | 4 Mi | 600.2 | 605.4 |
| QuickGelu | 1 Mi | 329.7 | 347.1 |
| QuickGelu | 4 Mi | 529.1 | 545.9 |
| Sqrt | 1 Mi | 255.3 | 232.3 |
| Sqrt | 4 Mi | 352.8 | 375.1 |

### 1 thread — no regression

`run_chunked` returns before touching rayon below `PAR_MIN_LEN`, and
`par_chunk_len` declines to split when there is nothing to gain, so the
single-threaded path is untouched:

| op | n | before | after |
|---|---:|---:|---:|
| Erf | 4 Mi | 3628.0 | 3643.8 |
| Gelu | 4 Mi | 5466.7 | 5479.0 |
| Erf | 65 Ki | 62.2 | 63.5 |
| Gelu | 65 Ki | 86.7 | 86.4 |

Every cell is inside run-to-run noise (<0.5%).

## Correctness

`run_chunked` splits a slice into disjoint sub-slices and calls the same
function on each. The MLAS activation entry points (`MlasComputeErf`,
`MlasComputeGeluErf`) are elementwise and take no threadpool argument,
so splitting cannot change a result: element `i` depends only on input
`i`. The existing
`thread_invariance::unary_kernels_are_thread_count_invariant` already
covers `erf` and `erf_gelu` and asserts serial and parallel agree **bit
for bit** — it now actually exercises the parallel branch for them,
where before it compared serial against serial.

## Regression test

`parallel_reachability` asserts the mechanism rather than the output: a
tensor over `PAR_MIN_LEN`, submitted from outside the pool, must
increment `run_chunked`'s parallel-branch counter.

Proven to falsify — reverting just the one line makes it fail with:

```
erf: a 1052675-element call did not reach run_chunked's parallel branch, so it
runs single-threaded no matter how large the pool is. A kernel that calls its
backend directly and returns will fail here.
```

while `pure_rust_kernels_go_through_run_chunked` keeps passing, so it is
not a blanket assertion that would pass on anything. It also guards the
two preconditions that could make it vacuous (pool must have ≥2 threads;
must not already be inside the pool).

The counter is `#[cfg(test)]` and **thread-local**, not a global atomic,
so concurrently running tests cannot bump each other's count. Non-test
builds get an empty `#[inline(always)]` function.

## Tests

- `cargo test -p onnx-runtime-ep-cpu --features mlas --lib` → **1312
passed, 0 failed**
- `cargo test -p onnx-runtime-ep-cpu --lib` (no `mlas`) → **1294 passed,
0 failed**
- `cargo clippy -p onnx-runtime-ep-cpu --features mlas --lib --tests` →
clean
- `cargo fmt --all -- --check` → clean

### A pre-existing intermittent crash, ruled out as mine

While validating I hit an intermittent `SIGSEGV` in the full
`onnx-runtime-ep-cpu` lib test binary. It is **not** from this change:

- Activation tests alone, 40 consecutive runs on this branch: **0
failures**.
- Full suite, 14 consecutive runs on this branch: **0 segfaults**.
- Full suite, 14 consecutive runs on pristine `origin/main`: **1
segfault**.
- `origin/main` also fails
`kernels::sdpa::tests::sdpa_dispatch_matches_scalar_oracle_across_shapes`
intermittently (~1 in 8 full-suite runs), unrelated to activations.

Flagging for whoever owns `sdpa`/`qmoe`; not chased here as it is
outside this PR's scope and reproduces without it.

## Limitations

- The thresholds are still `PAR_MIN_LEN` = 1 Mi and `PAR_MIN_CHUNK` =
256 Ki. #1105 recorded that one global constant cannot serve kernels
with different per-element costs, and this PR does not change that — it
only makes the MLAS routes obey whatever the constants say. Both are now
on the same footing, which is the precondition for tuning them per
kernel.
- This closes a self-inflicted gap; it does not by itself make these ops
beat ORT at 16 threads. At 4 Mi, `Erf` goes from 0.11x to 0.66x of ORT
and `Gelu` from 0.09x to 0.42x. Real wins, still short of parity, and
the remaining multi-thread gap stays open.
- Measured on one 32-vCPU host.

## Review

Independent Opus review: **GO WITH FINDINGS**.

The reviewer independently confirmed the parts that could have been
wrong: that `run_chunked` splits into disjoint sub-slices at identical
input/output offsets (so `erf_gelu_mlas`'s internal 8192-element
blocking and its `(x, y)`-paired NaN repair still line up inside a
chunk, trailing partial block included); that
`MlasComputeErf`/`MlasComputeGeluErf` are elementwise with no threadpool
argument and no shared mutable state, so concurrent calls from rayon
workers are sound; that the `SIMD_MIN_LEN` (32) / `PAR_MIN_LEN` (1 Mi)
composition has no gap and short tensors still return before touching
rayon; that the counter increments on the calling thread and cannot be
cross-contaminated by concurrent tests; that `note_parallel_dispatch`
compiles to nothing in a non-test build; and that the speedup arithmetic
and the "serial against serial" claim are accurate.

Two findings:

1. *(MAJOR, pre-existing, outside this file)* **The same pathology
exists in three sibling elementwise activations.** `silu_f32_slice`
(`kernels/activations.rs:332`), `relu_contiguous_f32_mlas`
(`kernels/relu.rs:144`) and the two Clip paths
(`kernels/selection.rs:182`, `kernels/conv.rs:732`) all call
`mlas_sys::compute_*` on the whole tensor with no chunking seam — and
`mlas-sys` documents those entry points as "Single threaded; callers
shard across threads themselves". `silu_f32_slice` is worse than the ops
fixed here: it follows the MLAS call with a *second* full-tensor serial
correction loop. SiLU is the SwiGLU activation, so this is a hot path.

Confirmed by grep: `run_chunked`/`run_chunked_rows` exist only in
`simd_activations.rs`, and every entry point *inside* that file does
route through the seam — so this PR is complete for its stated scope.
Being a different subsystem with its own correctness wrinkle (the
correction pass), it gets its own PR rather than being folded in here.

2. *(NIT)* `parallel_reachability` hard-fails rather than skips on a
single-threaded pool. This is deliberate and mirrors the pre-existing
`thread_invariance::assert_same` guard, so it adds no environmental
assumption the suite did not already make — a test that silently skipped
would be worse, since the bug it guards is invisible in the output.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant