Skip to content

fix(cpu-ep): close the SDPA/MHA coverage hole behind #1685, and the concat it hid - #1714

Merged
justinchuby merged 3 commits into
mainfrom
squad/leon-1685-sdpa-mlas-row-hole
Aug 22, 2026
Merged

justinchuby merged 3 commits into
mainfrom
squad/leon-1685-sdpa-mlas-row-hole

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 22, 2026 •

Copy link
Copy Markdown
Owner

Closes #1685.

The defect is already fixed on main — and nothing was guarding it

#1685 reports an intermittent SDPA failure under --features mlas: ~3% of runs leave an 8-row hole of exact 0.0 in the output, or die with a SIGSEGV, tripping identity specialization diverged.

The fix landed incidentally in 0f40538b2 (PR #828, "Redesign inference metadata as a generic control-flow IR"), which added 12 lines to work_stealing_pool.rs as a side effect. The reported-bad SHA 8aed77a17 has 0 occurrences of wait_for_workers/observed; current main has 7.

Mechanism. wait_for_completion returns when remaining hits zero — when the last block has run. The worker that ran it is still inside run_job, holding a by-value copy of the old Job and looping in claim_iterations against the shared counters. Pre-fix the dispatcher released dispatch_lock at that moment; MLAS fans out under rayon in sdpa_f32_fast, so the next dispatch republished the loop bounds and bumped the epoch while the straggler was live. It then did two things at once:

  1. decremented the new job's remaining without running the new closure → wait_for_completion returned early → a partition never executed → a beta = 0 SGEMM left rows of C unwritten (the 8-row hole: 8 · dv contiguous zeros at a tile boundary — MLAS had split the 128-row probs·V GEMM 16 ways);
  2. invoked the old closure with an index from the new range, writing through raw pointers past the end of the previous GEMM's C → the SIGSEGV.

Reproduction failed; falsification worked

attempt result
80 runs of the exact test 0 failures
40 runs, sdpa filter, --test-threads 8 0
pool widths 4 / 8 / 16 / 32, 40 runs each 0
3000 iterations, nested rayon × sgemm, NaN-prefilled C 0
6000 sdpa_f32 invocations at the issue's shape 0

The race needs two dispatches to overlap, and dispatch_lock serialises them — so the window is only the gap between the last block finishing and the straggler leaving run_job. That is ~3% per process, not per invocation.

So I stopped trying to trigger it and tried to break the fix instead. crates/mlas-sys/tests/concurrent_dispatch.rs runs 4 dispatcher threads against one 8-thread pool. On main it passes in 0.10 s. With only wait_for_workers removed:

  • overlapping_dispatches_never_return_with_work_unexecuted → SIGSEGV
  • parallel_for_returns_only_after_every_worker_has_left_the_closure → the straggler drives remaining below zero, usize wraps, and the pool deadlocks; a watchdog reports that instead of hanging CI

(A third test passed under the falsifier and was deleted rather than shipped as false assurance.)

The audit — the actual deliverable

Instrument State before this PR
backend_ab.rs AB_COVERED AttentionTranspose absent, while its PLAN entry claims Graduation::Partial — and the graduation rule reads AB_COVERED
tests/native_vs_mlas_differential.rs no attention row at all
benches/native_vs_mlas.rs no attention row at all
identity_hook_specialization_matches_the_general_epilogue compared two zero-prefilled buffers
scripts/ort_ab/gen_mha.py 7 cells, all bidirectional encoder shapes; unidirectional supported but never set; no q_seq = 1; no past-KV
mha_parity/cases.rs 12 goldens; the only past-KV case has q_seq = 1

The family with a known reference-route defect was the family with no same-binary A/B.

Production-route coverage was already sound — mha_ort_parity.rs, msft_attention_ort_parity.rs and qwen35_ort_parity.rs all run ORT goldens in the default MLAS-free build. The hole was entirely in the research/reference route and in the shape grid.

Zero-prefill cannot see a dropped write

Falsified by skipping 8 rows of the probs·V GEMM — 6144 of 98304 elements, #1685's exact signature:

  • old zero-prefill assertion → passes (an unwritten element is indistinguishable from a legitimate 0.0, and a hole landing identically in both compared runs cancels out)
  • new NaN prefill → left 6144 of 98304 output elements unwritten; first at index 7680 = (tile 0, row 120, column 0)

Two real defects the new coverage found

1. concat_cache walked a row-major buffer column-major

for d in 0..dim {            // head dim OUTSIDE
    for j in 0..past.seq {   // sequence INSIDE
        data[((b * heads + h) * total + j) * dim + d] = past.at(b, h, j, d);

Bnsh is contiguous [b][h][s][d], so consecutive stores were dim floats — 512 B at Llama's head size — apart: every store touched a fresh cache line and the tensor was traversed dim times. ~4.2 M near-certain misses per tensor, ~20 ms of a ~28 ms decode node. The operator spent most of a decode step copying its own cache. The two sibling transforms document this exact rule in their doc comments; this was the one place that broke it, and no benchmark row supplied a past-KV cache, so nothing measured it.

Now two copy_from_slice calls per (b, h) plane, fanned out on the same MIN_PARALLEL_TRANSPOSE_ELEMENTS threshold:

cell (t=8) before after
llama_decode_past1023 27.2× 13.8×
llama_chunk8_past1016 24.8× 13.1×
llama_chunk32_past992 27.7× 18.5×

Parity preserved on all three.

2. Nothing checked the causal offset

past_seq is load-bearing only when q_seq > 1 and the cache is non-empty. Every self-attention golden has past == 0; the one past-KV golden has q_seq == 1, so causal = unidirectional && q_seq > 1 is false.

Hard-coding past_seq = 0 leaves all 12 pre-existing goldens passing. The new past_kv_chunked_prefill_causal is the only case that fails (max abs diff 2.22). Our convention was already correct; it is now pinned. Regenerating with ORT 1.26.0 was byte-stable for the existing 12 cases (+19 lines only).

An undefined benchmark cell is not a slow one

Adding causal/decode rows produced 24/384 parity failures on q_seq=8, kv_seq=1024, unidirectional=1, no past-KV. That looked like a kernel bug and was not one:

reference vs ORT
unidirectional = 0, no mask 1.2e-7 ✅
causal, offset swept over every value in 0..=kv_seq matches at none
causal at offset past_seq, with a real past-KV cache 2.4e-7 ✅

ORT's unidirectional is simply undefined when q_seq != kv_seq with no past input — neither runtime computes a defined answer, so no timing from that cell is meaningful. It is replaced by past-KV cells (llama_decode_past1023, llama_chunk8_past1016, llama_chunk32_past992), which is how the runtime actually emits chunked prefill, and build_mha now raises on the invalid combination.

Complete results — production default build, 18 cells × 4 thread counts

Default MLAS-free bench_generic: nm, nm -D, strings, ldd → 0 MLAS symbols, no libstdc++. Positive control: the same probes on a --features mlas build report 842 symbols / 105 strings / libstdc++ linked, so the probe demonstrably sees MLAS when present. MultiHeadAttention executes natively at 99.97% of node time, 1 call, no ORT fallback. 432/432 trials parity PASS.

native/ort p50, lower is better; (…) = same-invocation A/A null control:

cell t=1 t=4 t=8 t=16 native ms t=1 → t=16
bert_base_b8_s128 5.36 (5.31) 13.01 (8.97) 15.50 (14.46) 19.93 (20.97) 38.2 → 42.3
bert_base_decode_kv1024 1.51 (1.54) 0.78 (0.76) 0.55 (0.46) 0.62 (0.58) 1.2 → 1.3
bert_base_s128 5.71 (5.73) 9.99 (8.56) 11.63 (10.64) 11.65 (12.29) 4.8 → 7.8
bert_base_s384 7.04 (7.14) 22.89 (15.69) 23.36 (23.78) 28.18 (32.36) 52.7 → 59.7
bert_large_s128 5.65 (5.67) 11.29 (8.89) 12.75 (14.01) 15.75 (15.92) 6.3 → 11.0
clip_l14_s257 5.79 (5.74) 14.00 (13.25) 24.49 (25.95) 24.83 (24.52) 25.0 → 38.1
llama_chunk32_past992 4.11 (4.18) 11.69 (12.31) 17.93 (17.64) 24.29 (23.75) 42.4 → 42.9
llama_chunk8_past1016 3.41 (3.59) 8.10 (7.59) 11.77 (11.99) 17.16 (17.15) 17.6 → 21.9
llama_decode_b8_kv1024 1.50 (1.50) 1.44 (1.45) 1.55 (1.53) 1.53 (1.55) 79.9 → 51.4
llama_decode_kv1024 1.93 (1.88) 1.14 (1.46) 1.33 (1.22) 1.24 (1.68) 13.1 → 8.8
llama_decode_kv128 1.50 (1.49) 0.69 (0.75) 0.63 (0.65) 0.72 (0.63) 0.6 → 0.8
llama_decode_kv4096 1.59 (1.59) 1.32 (1.35) 1.40 (1.39) 1.40 (1.68) 42.2 → 31.8
llama_decode_past1023 3.48 (3.49) 8.21 (8.00) 11.00 (9.65) 15.03 (13.60) 10.5 → 13.8
llama_prefill_s128_causal 3.31 (3.35) 5.67 (6.86) 6.74 (7.67) 9.96 (8.04) 15.5 → 22.5
llama_prefill_s512_causal 3.75 (3.73) 12.34 (12.49) 19.34 (19.30) 21.86 (21.60) 237.8 → 232.5
phi35_prefill_s256_causal 4.02 (3.99) 11.11 (10.67) 15.20 (15.04) 18.18 (17.25) 51.8 → 52.8
vit_b16_s197 5.64 (5.66) 11.60 (11.33) 11.37 (11.54) 12.09 (16.70) 10.9 → 11.3
whisper_cross_s1500 6.16 (6.16) 20.42 (21.55) 31.52 (31.68) 40.29 (40.46) 182.6 → 181.3

Regressions are included, not filtered. The null arm tracks the native arm within a few percent on every cell, so these are the measurement and not the instrument.

What the table actually says

Read the last column. native ms is flat from 1 → 16 threads on every cell. The ratio degrades with thread count purely because ORT scales and we do not (llama_decode_past1023: ORT 3.05 ms → 1.06 ms across the same sweep).

The code agrees: sdpa_f32_simd — the route a default build takes on x86 and aarch64 — is a plain for b { for n { … } } with no rayon fan-out at all. sdpa_f32_fast, the MLAS research route, does use par_chunks_mut. The shipped route is the serial one.

At t = 1 the grid is 1.5–7.0×, which is a per-core efficiency gap. Everything above that is unclaimed parallelism worth roughly the thread count. The cells that already win (bert_base_decode_kv1024 0.55×, llama_decode_kv128 0.63×) are the ones small enough that one core suffices.

This is filed separately rather than folded into a coverage PR — it is a large change and it touches pool ownership, which is Sebastian's lane. Now tracked as #1718.

Gates

gate result
cargo fmt --all --check clean across all 11 files in this diff (main carries 3 pre-existing unformatted files in onnx-genai-engine / onnx-genai-metadata, untouched here)
clippy --locked -p onnx-runtime-ep-cpu -p mlas-sys --all-targets clean
clippy --no-default-features --features mlas --all-targets clean
clippy --target aarch64-unknown-linux-gnu --all-targets -- -D warnings clean (caught a real needless_return in my new sdpa_f32_native cfg ladder)
scripts/check_cross_compile.sh ✓ full offline set
cargo test -p onnx-runtime-ep-cpu (default) 1604 passed, 0 failed
cargo test -p onnx-runtime-ep-cpu --features mlas 1552 passed, 0 failed
cargo test -p mlas-sys 41 passed, 0 failed
Miri task_runtime / strided / provider / dtype 29 / 8 / 16 / 12 passed, no UB
default_artifacts_are_mlas_free 9 passed
default cdylib MLAS symbols nm 0, nm -D 0, strings 0, ldd no libstdc++ — positive control: the same probes on a --features mlas build see 842 symbols, so the probe demonstrably detects MLAS when present

Note: onnx-runtime-ep-cuda has a pre-existing approximate value of PI clippy error (a CUDA C++ kernel string in kernels/window.rs). This diff touches 0 files in that crate.

Review

Reviewed by Opus (claude-opus-4.8): no blocking issues, with hand-verified index algebra for the concat_cache rewrite and confirmation that the new tests are non-vacuous.

Two non-blocking robustness notes were raised and both are now addressed in 9ae8f7b26:

  • the peak > 1 overlap guard could misfire on a single-vCPU runner where workers may serialise; it is now gated on available_parallelism() > 1, so it still fails loudly wherever overlap is possible;
  • concurrent_sdpa_sessions_lose_no_work had no watchdog, so a reintroduced pool deadlock would have hung the suite rather than failing it. It now runs on a 120 s watchdog thread with the same diagnosis message as concurrent_dispatch.

Neither touches production code. All gates re-run green after both changes and after the rebase onto d3688e7e0.

@codecov

codecov Bot commented Aug 22, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.46875% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.72%. Comparing base (af8e000) to head (903ea5e).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...runtime-ep-cpu/src/kernels/multi_head_attention.rs 56.52% 10 Missing ⚠️
crates/onnx-runtime-ep-cpu/src/kernels/sdpa.rs 62.50% 8 Missing and 1 partial ⚠️
crates/onnx-runtime-ep-cpu/src/backend_ab.rs 92.59% 6 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff            @@
##             main    #1714    +/-   ##
========================================
  Coverage   79.71%   79.72%            
========================================
  Files         408      408            
  Lines      192432   192545   +113     
  Branches   192432   192545   +113     
========================================
+ Hits       153406   153499    +93     
- Misses      33684    33704    +20     
  Partials     5342     5342            
Flag Coverage Δ
cli-ort-linux 72.47% <ø> (ø)
cli-ort-windows 72.06% <ø> (ø)
mlas 85.20% <ø> (+0.09%) ⬆️
offline 79.83% <80.46%> (-0.01%) ⬇️

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

Files with missing lines Coverage Δ
crates/mlas-sys/src/work_stealing_pool.rs 92.28% <ø> (+1.00%) ⬆️
crates/onnx-runtime-ep-cpu/src/backend_ab.rs 95.31% <92.59%> (-1.99%) ⬇️
crates/onnx-runtime-ep-cpu/src/kernels/sdpa.rs 97.32% <62.50%> (-0.62%) ⬇️
...runtime-ep-cpu/src/kernels/multi_head_attention.rs 78.50% <56.52%> (-1.30%) ⬇️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@justinchuby
justinchuby force-pushed the squad/leon-1685-sdpa-mlas-row-hole branch from 1bb11ff to bb724e0 Compare August 22, 2026 03:33
justinchuby added a commit that referenced this pull request Aug 22, 2026
)

## What

`cargo fmt --all` output. Three files, 4 insertions, 6 deletions, all
whitespace and line wrapping. No semantic change.

## Why

#1715 (`d3688e7e0`) landed with these three files unformatted. That
turns two **required** checks red on `main` itself:

- `Rust quality` — fails on `cargo fmt --all --check`
- `Fast (Linux x86_64)` — same check, same three diffs

Because they fail on `main`, they also fail on every PR branched from
it, so no PR can currently show a green required set. Confirmed by
running the check against pristine `origin/main` locally and by reading
both job logs, which cite exactly:

```
crates/onnx-genai-engine/src/engine/load.rs:1172
crates/onnx-genai-metadata/src/lib.rs:54
crates/onnx-genai-metadata/tests/metadata_fixtures.rs:1106
```

## Scope

Split out of #1714 deliberately. That PR is CPU-EP attention work and
owns none of these files; unblocking `main` should not have to wait on
an unrelated review, and an unrelated review should not have to carry
someone else's formatting fix.

`cargo check -p onnx-genai-metadata -p onnx-genai-engine` passes.

Note: `Mobius metadata packages` is **also** red on `main`, for an
unrelated reason (`validation/generated/diffusion` and friends fail
pipeline-spec validation: `workflow image output 'image' must declare
value_range`, and an `unknown field 'access'`). That one is a real
content/schema mismatch rather than formatting, it is outside my lane,
and it is **not** addressed here — it needs whoever owns the metadata
schema.
justinchuby and others added 3 commits August 22, 2026 04:13
…oncat it hid

#1685 reported an intermittent 8-row hole of exact `0.0` in SDPA output under
`--features mlas`, plus a SIGSEGV and `identity specialization diverged`. The
defect is already fixed on main — incidentally, by PR #828's unrelated IR
redesign, which added `wait_for_workers` to the MLAS work-stealing pool — and
nothing guarded it. So this closes the coverage hole rather than the kernel.

Root cause, now documented on `wait_for_workers`: `wait_for_completion` returns
when the last *block* has run, but the worker that ran it is still inside
`run_job` holding a by-value copy of the old `Job`. Pre-#828 the dispatcher
released `dispatch_lock` there, so the next dispatch republished the loop
bounds while that straggler was live; it then decremented the new job's
`remaining` without running the new closure (a partition never executes, and a
`beta = 0` SGEMM leaves rows of `C` unwritten) and invoked the *old* closure
with an index from the *new* range (the SIGSEGV).

The race needs two dispatches to overlap, so it is ~3% per process, not per
call: 6000 `sdpa_f32` invocations and 3000 nested rayon x sgemm iterations
reproduced nothing. `crates/mlas-sys/tests/concurrent_dispatch.rs` instead
falsifies the fix — 4 dispatchers on an 8-thread pool. It passes in 0.10s on
main and, with `wait_for_workers` removed, one test SIGSEGVs and the other
reports the resulting deadlock through a watchdog.

Coverage closed:
- `AB_COVERED` gains `AttentionTranspose`, whose `PLAN` entry already claimed
  `Graduation::Partial` while the graduation rule reads `AB_COVERED`.
- `sdpa_f32_native` / `sdpa_f32_mlas` name the two routes, since `sdpa_f32`
  short-circuits to MLAS and cannot be the native half of an A/B.
- `native_vs_mlas_differential` gains 8 SDPA shapes x causal, a NaN-prefill
  fail-loud check that decodes a hole to (tile, row, column), a convex-
  combination oracle that still holds in a default MLAS-free build, and a
  concurrent-sessions test.
- `identity_hook_specialization_matches_the_general_epilogue` prefills NaN.
  Falsified: dropping 8 rows/tile (6144 of 98304 elements, #1685's exact
  signature) leaves the old zero-prefill version *passing*.

Two real defects the new coverage found:

1. `concat_cache` looped head-dim outside sequence, striding 512B per store
   through a contiguous `[b][h][s][d]` buffer and traversing it `dim` times —
   ~20ms of a ~28ms decode node. It is now two `copy_from_slice` calls per
   plane with the same fan-out threshold the sibling transforms use.
   `llama_decode_past1023` 27.2x -> 13.8x ORT, `chunk8` 24.8x -> 13.1x,
   `chunk32` 27.7x -> 18.5x, parity preserved.

2. No golden exercised the causal offset: every self-attention case has
   `past == 0` and the one past-KV case has `q_seq == 1`, so
   `causal = unidirectional && q_seq > 1` is false. Hard-coding
   `past_seq = 0` leaves all 12 pre-existing goldens green; the new
   `past_kv_chunked_prefill_causal` is the only one that fails (2.22 abs).

`gen_mha.py` gains causal, decode and past-KV rows, and now *rejects*
`unidirectional` with `q_seq != kv_seq` and no past-KV: a NumPy oracle swept
over every offset in `0..=kv_seq` matches ORT at none of them, so that cell has
no defined answer and its 24/384 parity failures were not a kernel bug.

Ledger section 45 records the audit, the falsifier method, the 18-cell x 4-thread
production-default matrix (432/432 parity PASS, A/A null control per cell), and
the finding it exposed: `native_p50` is flat across 1..16 threads on every cell
because `sdpa_f32_simd` — the route a default build actually takes — has no
rayon fan-out at all, while the MLAS research route does. That is filed
separately rather than folded in here.

Gates: fmt; clippy default + `--features mlas` + aarch64 `-D warnings`;
`check_cross_compile.sh`; full ep-cpu suite both feature configs; mlas-sys;
Miri task_runtime/strided/provider/dtype; default cdylib 0 MLAS symbols by
`nm`/`nm -D`/`strings`/`ldd` with an 842-symbol positive control.

Closes #1685

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Opus review raised both of these as non-blocking robustness notes.

The `peak > 1` overlap guard asserted that two workers were inside the
closure at once. That is the property the test exists to exercise, but on
a single-vCPU runner the workers can be serialised end to end and the
absence of overlap is not a defect. Gate the assertion on
`available_parallelism() > 1` so it still fails loudly where overlap is
possible and does not misfire where it is not.

`concurrent_sdpa_sessions_lose_no_work` scanned for lost work but had no
watchdog, so the other failure mode -- a pool that stops making progress
entirely -- would have hung the suite until CI's own timeout killed it
with no diagnosis. Run the sessions on a watchdog thread with the same
120s bound and message used in `concurrent_dispatch`.

Neither change touches production code.
@justinchuby
justinchuby force-pushed the squad/leon-1685-sdpa-mlas-row-hole branch from bb724e0 to 903ea5e Compare August 22, 2026 04:13
@justinchuby
justinchuby merged commit a544a6f into main Aug 22, 2026
22 of 32 checks passed
@justinchuby
justinchuby deleted the squad/leon-1685-sdpa-mlas-row-hole branch August 22, 2026 05:20
justinchuby pushed a commit that referenced this pull request Aug 22, 2026
Incorporate #1714 so the PR is validated with current CPU SDPA/MHA and concat coverage.

Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 22, 2026
…#1737)

## What

One docs change: a measurement-conditions block in §45.7 of the CPU-EP
benchmark ledger, plus a rule. No code.

## Why

§45 landed with an 18-cell × 4-thread grid of **absolute** milliseconds
measured on the shared 16p/32l host, and I did not record host occupancy
for that window.

Roy has since demonstrated that contention on this box moved the *same*
benchmark cell between **197.2 and 22.8 tok/s** — an 8.6× swing — while
intra-run spreads stayed as tight as **6%** in the corrupted samples.
That is the part worth writing down: a tight spread only says contention
was *steady* during the run, not that the host was quiet. An A/A null
control detects drift, not a steady offset.

So the ledger's §45 numbers needed splitting into what that threatens
and what it does not.

**Survives:**
- **Ratios** — arms are interleaved, so a steady occupancy tax inflates
both and cancels. Covers the parity grid and the `concat_cache` figures
in §45.6.
- **The scaling shape in §45.8** — ORT improved 3.05 → 1.06 ms across `t
= 1 → 16` *in the same interleaved window* in which the native arm
stayed flat. Contention cannot starve one interleaved arm while the
other scales 2.9×, so ORT acts as a positive control for core
availability — structurally the same move as the 842-symbol MLAS build
being the positive control for the symbol probe. The finding also has a
timing-free leg: `sdpa_f32_simd` contains no fan-out at all.

**Does not survive:**
- The **absolute millisecond columns**. A steady tax is invisible in the
spread *and* in the ratio. Now labelled shape rather than throughput,
with the mild `t = 16` regression (9.2 → 14.4 ms) explicitly called out
as a possible artifact that nothing in §45.8 depends on.

## The rule added

Record host occupancy alongside the numbers, and gate on the
**instantaneous runnable count** (`cut -d' ' -f4 /proc/loadavg | cut -d/
-f1`) rather than the 1-minute load average. The EMA misleads in both
directions — it stays high for a minute after a heavy run ends and reads
low while a burst is still in flight. (Credit to Sebastian for that one;
I had read `loadavg 2.48` as calm while the runnable count was spiking.)

An unrecorded window cannot be defended after the fact. It can only be
re-run.

## Scope

Docs only — no kernel, no test, no behaviour. The conclusions of #1714
and #1718 are unchanged; this constrains how §45's numbers may be
quoted. Cross-referenced from a comment on #1718 so anyone picking that
up sees the same constraint.
justinchuby added a commit that referenced this pull request Aug 23, 2026
…he tax (#1793)

## What

Retracts the ratio defence I added to §45.7 yesterday (#1737) and adds
§45.9 explaining why it was wrong. Docs only.

## The retraction

§45.7 argued that the native-vs-ORT ratios survived host contention
because (a) the arms are interleaved, so a steady tax cancels, and (b)
ORT scaling 2.9× in the same window proved cores were available to both
arms.

(b) is wrong, and (a) does not apply. **The ORT arm is not an
independent control — it is the source of the tax.**

In a paired run `bench_generic` builds `ort_session` once and holds it
open for the entire loop, alternating `measure_native()` and
`measure_ort()` inside it. ORT's intra-op pool **spin-waits between its
own runs**, so it is burning cores *during* the native timing.

This was already documented — in my own harness. `ab.py`'s docstring:

> ORT's intra-op pool spin-waits, so a paired run steals cores from the
native arm -- measured at up to 6x depression on long cells

which is precisely why `--native-only` exists. I used paired mode
correctly (the comparison *is* native-vs-ORT), then reasoned about the
output as though the paired-mode tax were not there.

## Why this is structural, not just noise

`ab.py` passes the same width to both arms — `--native-threads T`
**and** `--ort-intra-threads T`. So at `T = 16` there are ~16 ORT
threads spinning during each native sample; at `T = 1`, ~1.

**The tax is one-directional and grows with `T`** — which is the exact
shape §45.8 reported as a native scaling failure. Interleaving cancels
drift over time. It does nothing about a tax one arm imposes on the
other.

## Second asymmetry (flagged, not yet verified)

Sebastian's #1729 shows the EP's default 16-wide pool pins to cpus 0–15
— on this host 8 physical cores, two workers per core, one L3 domain —
and that cpu0 carries a permanent external competitor (0.503 relative
throughput). ORT's pool is unpinned across all 32 logical CPUs.

Whether that reaches the single-node MHA graphs measured in §45 is **not
yet verified**; it needs a `/proc` read against a live bench process,
which is queued behind the current host owner. It is marked as
unverified in the text. It cannot be assumed away, because
`ONNX_GENAI_CPU_DECODE_THREADS` is both the knob `bench_generic` sets
for the native arm and the knob governing the pool `decode_affinity`
places.

## What survives, and what does not

**Survives** — the #1718 finding, because it never rested on a timing:
`sdpa_f32_simd` is a plain `for b { for n { … } }` with no fan-out while
`sdpa_f32_fast` beside it uses `par_chunks_mut`. The native arm cannot
scale with `T` whatever the host does.

**Does not survive** — the size of the gap at high `T`, the 13.6× at `t
= 16`, and the 9.2 → 14.4 ms regression between `t = 8` and `t = 16`.
That regression now has a mechanism pointing at the **instrument**: `t =
16` is where the spinning pool is widest. I had already flagged it as a
suspected artifact; it now has a cause.

Suggested re-measurement: `--native-only` arms against a fixed
reference, or hold `--ort-intra-threads` at 1 while `--native-threads`
sweeps.

## Rules added

1. An A/B is a controlled experiment only if the **harness** treats the
arms symmetrically. A spin-waiting pool in the comparison arm is a tax,
not a control.
2. "The control scaled, so cores were available" is valid only when the
control is independent of the treatment. Here the control *was* the
competitor. A positive control must be something that cannot be causing
the effect it is being used to rule out.

## Scope

Docs only. No kernel, no test, no behaviour change. #1714's correctness
results (432/432 parity, zero-MLAS artifact, numerics) are untouched —
those are not timing measurements. Cross-posted to #1718.
justinchuby added a commit that referenced this pull request Aug 23, 2026
…d a knob that no longer exists (#1822)

Follow-up to #1173, correcting two defects I shipped in it and repairing
the rule they undermined. Docs, one ledger string, one new test, one new
script. No production kernel or routing change.

## 1. The ledger named a route gate that had already been deleted

`PLAN[MatMulF32].shape_gate` said the native `SimdX86` route "gates M=1
on `ONNX_GENAI_CPU_MM_SIMD_M1_GEMV` (default off, #1116)".

#1183 shipped that GEMV on by default and removed the probe. `git
merge-base --is-ancestor 5417d04 bdb4599` confirms it landed
**before** #1173 merged — so the ledger was wrong the day it landed.
Today `sgemm_simd` calls `sgemm_simd_variant(a, b, c, m, k, n, true)`
unconditionally and `use_m1_gemv` is a plain parameter that only the
in-process A/B harness passes as `false`. No environment variable
reaches that route.

`docs/performance/CPU_MATMUL_ASSIGNMENT.md:559` already recorded the
correct fact ("It is measured now, and the route is the default. There
is no env probe on the dispatch any more"). Two files in the same
directory disagreed and nothing compared them.

**Now guarded.**
`ledger_prose_only_names_environment_variables_that_still_exist`
requires every `NXRT_*` / `ONNX_GENAI_*` token in the ledger's prose to
still exist as a string literal in the crate's sources. It cannot check
that the description is *right*, only that the knob is *real* — which is
the half that goes stale silently.

Mutation-verified, not just observed green:

```
matmul_f32: ledger prose names environment variable `ONNX_GENAI_CPU_MM_SIMD_M1_GEMV`,
but no source file in this crate contains the literal "ONNX_GENAI_CPU_MM_SIMD_M1_GEMV".
```

## 2. The doc published a toggle A/B that could not have been run

#1173 carried a table captioned **"same binary, same session, toggle the
only difference"**, reporting `decode 1×2048×2048` at 0.146 with
`ONNX_GENAI_CPU_MM_SIMD_M1_GEMV` off against 0.337 with it on, and
called turning it on "the obvious next slice".

Nothing reads that variable. Setting it measures the same route twice;
it cannot produce two different columns. The table is withdrawn and the
retraction kept in the text rather than quietly deleted.

This is the failure mode the document's own graduation rule warns about
— **an arm that was not on the route it was labelled with** — committed
by the document that wrote the rule. It survived review because a
plausible number in a well-formed table is not self-evidently
unmeasured. Readers are pointed at `bench_f32_gemm_ab`, which holds the
route as a function parameter and carries the M≥2 rows as a built-in
control.

## 3. The gap table is re-measured and the ≥5% rule is repaired

The old table was one unguarded invocation per row at an unstated width,
taken before the decode-placement corrections (#1729, #1794, #1811) —
i.e. when the decode pool put 16 workers on 8 physical cores.

New harness: `scripts/bench_native_vs_mlas_width.py`. Arms interleaved
rep by rep so host drift lands on both equally; per-rep `os.wait4`
CPU-efficiency guard adapted from #1809; six reps per arm; two widths.
Raw verdicts, spreads and discards are all reported rather than
summarised away.

**Three findings, all about method rather than kernels.**

| | narrow (6 cores, 1 L3) | wide (32 logical CPUs) |
|---|---|---|
| `matmul_f32 16×512×512` | 1.581, spread 41% | 0.866, spread 134% |
| `matmul_f32 decode 1×2048×2048` | 1.117, spread 21% | 0.934, spread
13% |

- **Two cases change verdict on width alone.** Same binary, same
half-hour, only the CPU mask differs. `x86_sgemm` parallelises over
column strips and MLAS declines to parallelise some shapes, so
interleaving the two *routes* inside one process does not protect the
ratio — it changes both at once.
- **`16×512×512` disagrees with itself on both arms**, alternating
`keep-mlas` / `native-graduates` from a byte-identical binary. **One
more run of the old table could have graduated a route on this row.**
- **The narrow arm is more trustworthy despite having fewer cores** —
spreads 4–42% against 5–134%, and it lost no reps to the guard.
Isolation beat parallelism.

**Softmax now decomposes cleanly**, because no vendored MLAS kernel has
changed since #1173 (the only `mlas-sys` edits are the additive
straggler handshake in `work_stealing_pool.rs`, #828/#1714, which adds
waiting). At matched width the MLAS control arm is stationary to within
4% while native improved **1.24–1.27×** — matching #1416's claim for the
row kernel. The f32 GEMM rows get no such attribution and now say so
explicitly: their control moved **2.0× the wrong way**, so only the
current ratio at a stated width is defensible.

**The rule gains what it lacked**: spread must be smaller than the
claimed win; reps that did not get the CPU are discarded rather than
averaged; a verdict is valid only at a stated width. Under it, `decode
1×2048×2048` — the first f32 GEMM case to show a real native win —
**still does not graduate**: it costs more CPU (cpu_ratio 0.875), does
not hold at 32 threads, and its 21% spread exceeds its 12% win.

## The width claim is verified, not asserted

#1815 landed while this was in progress and observed the neighbouring
`bench_generic` harness spawning its ORT arm *outside* the affinity
confinement it applied to the native arm. That hazard applies to any
`taskset` claim, including mine, so I checked it instead of trusting it
— sampling `Cpus_allowed_list` from `/proc/<pid>/task/*/status` 40×
across a live narrow-arm run:

```
'16,20,22,26,28,30': 478 observations
  native_vs_mlas- 273, mlas-sys-ws-0..4 39 each, nxrt-task-0..4 2 each
'0-31': 1  (the taskset process itself, before exec)
```

Both routes confined identically; no thread escaped. The rule now
requires this check.

## Validation

- `dispatch_ledger` **17/17**, including the new falsifier, after
merging latest `main`.
- `default_artifacts_are_mlas_free` **9/9** — the no-MLAS-in-defaults
invariant is untouched.
- `cargo clippy -p onnx-runtime-ep-cpu --lib --all-targets` clean;
`cargo fmt --check` clean.
- Normal merge of `origin/main` (`aee2b9d11`), no rebase, no conflicts.

## Limitations

- The narrow arm is six cores on one L3 of one x86-64 host. Nothing here
transfers to aarch64 or to a two-socket box.
- The `activations erf 1 Mi` row shows native 13.5% slower at matched
width. The nearest scatter figure is the wide arm's 8% spread, but that
is a spread of *ratios* against a move in a *native time*, so the two
are not strictly commensurable. Its MLAS control also moved 11%.
**Flagged for pinned re-measurement, not reported as a regression.**
- The wide arm was taken with ~4–5 cores of unrelated load present. That
is stated in the doc rather than hidden, and it is why its spreads are
wider; the guard reports which reps were discarded instead of pretending
the host was quiet.
- No production behaviour changes here, so there is no performance claim
to make about the shipped artifact.

Refs #1173, #1183, #1809, #1815, #1416.



## Independent review, and what it changed

An independent adversarial review of the full diff returned **no
blockers** — it confirmed the ancestry argument behind the retraction,
the stationary-control premise for the softmax attribution, and that the
headline case is correctly *refused* by the rule (21% spread against a
12% win). It also found seven real defects, all now fixed in
`f0323f9ed`.

The one that mattered most was in the new test. It only proved the
variable name appeared *somewhere* in the crate, so a variable whose
read site had been deleted but whose name survived in an
`EnvVarGuard::set(...)` line would still have passed — which is the
precise shape of the defect this PR exists to correct. The test now
requires the matching line to be an `env::var(` / `env::var_os(` read or
an `_ENV: &str =` binding.

Verified by mutation in **both** directions:

| mutation | before | after |
|---|---|---|
| reinsert retired `ONNX_GENAI_CPU_MM_SIMD_M1_GEMV` into ledger prose |
fails ✅ | fails ✅ |
| retire the two real `NXRT_CPU_GEMM_BACKEND` reads, leaving the literal
only in test guards | **passes ❌** | fails ✅ |

The remaining six were prose defects in the doc: a stated spread range
that contradicted its own table's 82% row, "within 4%" against a table
reading −4.2%, a narrow-arm ratio fused with a wide-arm attribution, a
spread quoted as 7.5% that was 8% *and* compared against an
incommensurable quantity, the CPU-efficiency guard oversold as "what
makes this table measurable at all" (in-process interleaving is what
protects the ratio; the guard catches only *differential* descheduling),
and a one-directional provenance argument standing in for the direct
control measurement that actually carries the softmax attribution.

**Two further defects I found myself while checking the tables against
each other**, neither raised by the review:

- The `ratio` column is a median of per-rep ratios while the `ns/unit`
columns are medians of times. Medians do not distribute over division,
so every row looked internally inconsistent to anyone who tried to
divide it out (`0.0684 / 0.0617 = 1.109` against a stated `1.117`). Now
documented, along with why the per-rep form is the correct one to quote:
it pairs each MLAS invocation with the native invocation it was
interleaved against, which is the entire point of interleaving. The
then→now figures are relabelled as quotients of medians.
- "wider than nine of the twelve wide-arm rows" was eleven of twelve.

## Adopting #1814

`aee2b9d11` (#1814) landed on `main` while this was in review, and it
closes the exact hole the review found in the guard this document
recommends. A differential CPU-efficiency check cannot see contention
that lands evenly on both arms; #1814's confined-set meter reads busy
jiffies on the process's own `Cpus_allowed_list` and subtracts the
process's own CPU, so foreign load shows up directly. The rule now
points at it, and the tables here are explicitly marked as predating it
and guarded by the weaker method.

---------

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.

sdpa MLAS fast path intermittently leaves an 8-row hole in the output (SIGSEGV + 'identity specialization diverged'), ~3% on main under --features mlas

1 participant