Skip to content

perf(cpu-ep): unblock softmax's two bottlenecks (1.25x on the row kernel) - #1416

Merged
justinchuby merged 5 commits into
mainfrom
squad/leon-p28-softmax-phases
Aug 19, 2026
Merged

justinchuby merged 5 commits into
mainfrom
squad/leon-p28-softmax-phases

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 19, 2026 •

Copy link
Copy Markdown
Owner

Profiling the row kernel by phase, rather than as a whole, showed the work
is not where the shape of the code suggests. Per element at d=1024, on this
host:

pass 1 (max)        67 ps
pass 2 (exp+sum)   446 ps
pass 3 (normalize)  58 ps
whole row          608 ps

Pass 2 is 73% of the row and pass 1 is 11%, and each is limited by a
different thing. Both limits are removable, and neither removal changes a
single output bit.

Pass 1 is latency-bound. It is one vmaxps chain, and vmaxps has ~4
cycles of latency, so the chain retires 8 floats every 4 cycles while the
load ports could feed 16. Four independent chains hide the latency
completely: 67 -> 37 ps/element, 1.8x. max is associative and commutative
over the values that survive, and feeding the loaded vector as the first
max_ps operand keeps the existing NaN convention (a NaN lane returns the
accumulator) in each chain, so no accumulator can ever hold a NaN and the
final fold obeys the same rule. The result is bit-identical to one chain.

Pass 2 is throughput-bound on ports 0/1, not latency-bound: the loop issues
16 port-0/1 ops per 8 lanes, which at 2 ops/cycle is the 8 cycles/iteration
measured. Four independent sum chains, the obvious counterpart to the pass-1
fix, are therefore slower (446 -> 458 ps/element) and are not used.

Three of those 16 ops exist only to patch exp8's non-finite lanes: a
max_ps clamp, an unordered compare, and a blendv. They are unnecessary,
because both non-finite cases fall out of the arithmetic:

  • -inf poisons r into NaN, but is_zero is true for it, and the
    underflow mask that already exists is a bitwise andnot -- it produces
    the required exact 0 whatever the poisoned build contained.
  • NaN survives round, both fnmadds and the Horner chain; cvtps_epi32
    maps it to the integer indefinite 0x8000_0000, whose low nine bits
    build pow2 = 1.0, so y = NaN * 1.0 = NaN, and is_zero is
    false for it because the compare is ordered.

Dropping all three: 446 -> 370 ps/element, 1.20x.

Together, 608 -> 481 ps/element at d=1024; 1.16x at d=64 rising to 1.26x at
d>=256. Every softmax consumer shares this one kernel, including
FusedAttention through scale_mask_softmax_rows.

Not taken: folding the mul+round into one FMA with a magic-number
constant saves a fourth port-0/1 op, but it is worth only 3% (370 -> 356
ps/element) and it moves k for arguments that sit on a rounding boundary,
which would forfeit bit-identity and require re-establishing the 1-ULP bound
from scratch. Not worth 3%.

Numerics

The exp8 change is verified exhaustively rather than argued. An offline
harness evaluates the old and new forms over all 2^32 f32 bit patterns:

non-NaN inputs whose result differs in any bit: 0
NaN inputs where either side is not NaN:        0

So the 1-ULP bound over (-87.336544, 0], the exact 0 at and below the
underflow threshold, and the NaN -> NaN contract all transfer unchanged
by construction, not by re-measurement. (Control: deleting the underflow
mask makes the same harness report 1,020,022,810 mismatches, first at
-87.33654.) The only observable difference is the NaN payload, now the
quieted input rather than a canonical f32::NAN -- which the row contract,
"a poisoned row normalizes to NaN", does not distinguish.

Two tests are added because both properties are now emergent rather than
explicit:

  • exp8_maps_the_non_finite_lanes_without_an_explicit_patch pins the
    vector-level contract with quiet, signalling and both-signed NaNs and
    with five flush-to-zero inputs, placed in every lane in turn, plus the
    one-ULP-above-threshold boundary.
  • the_row_maximum_covers_every_position closes a hole the pass-1 change
    exposed: softmax is invariant to the value subtracted, so a row maximum
    that misses part of the row is numerically silent and every existing
    test passed with one of the four chains deleted
    . It only surfaces as
    overflow, so the test puts a 3e38 logit at each position of each of
    seven d values and requires the row to stay finite.

Falsified: dropping a max chain fails the new row test; dropping the
underflow mask fails the new exp8 test and large_and_masked_logits_stay_finite;
reordering the fold (a no-op) stays green. Skipping the 8-wide drain in
pass 1 also stays green, correctly -- the scalar tail still covers those
elements.

Measurements

Re-measured from a production default build at the request of the current
task: cargo build --release -p onnx-genai-bench --features bench-native, i.e.
default features on, mlas never named. No historical MLAS-linked number is
reused; every figure below comes from this run.

The artifact is proved MLAS-free four independent ways, for both arms:

probe main arm this PR
nm -C | grep -ci mlas 0 0
nm -D | grep -ci mlas 0 0
ldd | grep -ci mlas 0 0
strings -a | grep -c 'mlas-sys|MlasGemm|mlas_sys' 0 0

bench_generic measures ours and ORT inside one invocation, alternating
which runs first, so the ours/ORT ratio is drift-free by construction;
ab.py additionally reverses arm order every other trial. --null-control
runs the baseline binary a second time under another name, so every cell
carries its own A/A floor measured in the same invocation. 7 fixtures x
1/2/4/8 threads x 15 trials x 3 arms = 1260 invocations, 40 runs / 15 warmups
each.

fixture t ours/ORT before ours/ORT after Δ ratio A/A null Δ verdict
sm_bert_b8_s128 1 1.317 1.108 -15.87% -0.76% improved
sm_bert_b8_s128 2 2.539 2.192 -13.67% +2.48% improved
sm_bert_b8_s128 4 1.671 1.526 -8.68% -8.14% improved
sm_bert_b8_s128 8 2.464 1.982 -19.56% -16.11% improved
sm_decode_h32_kv1024 1 1.329 1.168 -12.11% +0.15% improved
sm_decode_h32_kv1024 2 1.874 1.835 -2.08% +2.08% improved
sm_decode_h32_kv1024 4 2.020 1.790 -11.39% +3.42% improved
sm_decode_h32_kv1024 8 1.997 1.818 -8.96% +2.05% improved
sm_decode_h32_kv2048 1 1.328 1.179 -11.22% -0.15% improved
sm_decode_h32_kv2048 2 2.294 1.986 -13.43% -0.35% improved
sm_decode_h32_kv2048 4 2.959 2.590 -12.47% -0.20% improved
sm_decode_h32_kv2048 8 3.069 2.764 -9.94% +2.44% improved
sm_decode_h32_kv4096 1 1.302 1.164 -10.60% +0.00% improved
sm_decode_h32_kv4096 2 2.402 2.134 -11.16% -0.17% improved
sm_decode_h32_kv4096 4 3.647 3.189 -12.56% +0.55% improved
sm_decode_h32_kv4096 8 4.814 4.226 -12.21% +1.64% improved
sm_decode_h32_kv8192 1 1.320 1.197 -9.32% -0.61% improved
sm_decode_h32_kv8192 2 2.467 2.176 -11.80% +0.04% improved
sm_decode_h32_kv8192 4 4.279 3.768 -11.94% +0.68% improved
sm_decode_h32_kv8192 8 6.272 5.583 -10.99% +1.20% improved
sm_prefill_h32_s512 1 1.245 1.072 -13.90% -0.08% improved
sm_prefill_h32_s512 2 1.237 1.083 -12.45% +1.13% improved
sm_prefill_h32_s512 4 1.278 1.320 +3.29% +6.18% within noise
sm_prefill_h32_s512 8 1.356 1.384 +2.06% +41.08% within noise
sm_whisper_cross 1 1.114 1.054 -5.39% -0.09% improved
sm_whisper_cross 2 1.121 1.060 -5.44% -0.18% improved
sm_whisper_cross 4 1.581 1.470 -7.02% -5.25% improved
sm_whisper_cross 8 1.837 1.741 -5.23% -3.65% improved

26 improved, 2 within noise, 0 regressed. The two within-noise
cells are reported as measured and are not claimed: sm_prefill_h32_s512 t=4
is +3.29% against a 6.18% A/A floor, and t=8 is +2.06% against a 41.08%
floor -- that cell is not measurable on this host at eight threads, in either
direction.

The headline the task asked about: at t=1, where the A/A floor is 0.00-0.76%,
the ours/ORT band moves from 1.114-1.329 to 1.054-1.197.

What this does not fix, and where the rest of the gap is

The same run localises the remaining multi-thread gap, and it is not in the
kernel this PR touches. Native p50 for this PR, by thread count:

fixture t=1 t=2 t=4 t=8
sm_bert_b8_s128 0.941 0.943 0.369 0.293
sm_decode_h32_kv1024 0.020 0.020 0.021 0.021
sm_decode_h32_kv2048 0.039 0.038 0.039 0.039
sm_decode_h32_kv4096 0.074 0.073 0.074 0.074
sm_decode_h32_kv8192 0.144 0.142 0.143 0.142
sm_prefill_h32_s512 4.559 2.343 1.650 1.571
sm_whisper_cross 28.482 14.463 11.195 9.583

The five decode fixtures are flat to within 1.4% from one thread to eight.
They are n=32 rows x d=1024..8192, and parallel_rows_per_task refuses to
fan out below MIN_PARALLEL_SOFTMAX_ROWS = 64 rows, regardless of how much
work those rows hold -- 32x8192 is 256 Ki elements, 16x the element floor that
sits right next to it. ORT does split them, which is the whole of the 2.2x-5.6x
ratio at t>=2; it is not a kernel-quality gap and no amount of per-element work
removes it. Chasing it belongs in a separate PR against the gate, not here.

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


Update on merge: this PR was consolidated, and the local gate matrix it passed

What changed since the body above was written

The body above closes by saying the fan-out gate "belongs in a separate PR". That
PR was #1484 -- and it touched the same file, so under the "consolidate overlap
first" directive its three commits were cherry-picked onto this branch and #1484
was closed. This PR is therefore all three softmax changes, validated as one
unit:

  1. exp8 -- clamp/compare/blend removed, single andnot underflow mask (pure Horner).
  2. Pass 1 -- four independent max chains instead of one serial dependency.
  3. The fan-out gate -- MIN_PARALLEL_SOFTMAX_ROWS deleted, replaced by a pure
    fan_out_is_worthwhile(n, d) that prices work (n * d), not row count. This
    is what unblocks the five flat decode fixtures the body above had written off.

Ledger sections §42 (phases) and §43 (the fan-out gate) landed separately in
#1423 (d08b1909e). softmax.rs's doc comment cites section 43; verified still
correct against merged main (upstream owns §40/§41, and stops there).

Final 28-cell softmax matrix -- main vs this branch, ours/ORT ratio

Interleaved arms, A/A null control, quiet host, production default build (no MLAS).

fixture t=1 t=2 t=4 t=8
sm_decode_h32_kv1024 1.339 → 1.109 2.067 → 1.174 2.139 → 1.096 2.113 → 1.005
sm_decode_h32_kv2048 1.375 → 1.120 2.181 → 1.174 2.836 → 1.113 2.977 → 1.433
sm_decode_h32_kv4096 1.284 → 1.087 2.248 → 1.933 3.627 → 1.900 3.962 → 3.240
sm_decode_h32_kv8192 1.297 → 1.102 2.335 → 1.165 3.938 → 1.358 5.537 → 2.162
sm_bert_b8_s128 1.348 → 1.180 1.413 → 1.240 1.652 → 1.551 2.037 → 1.814
sm_prefill_h32_s512 1.249 → 1.033 1.310 → 1.089 1.071 → 0.939 1.623 → 1.319
sm_whisper_cross 1.161 → 0.998 1.101 → 1.001 1.524 → 1.332 1.755 → 1.740

27 cells improved, 1 noise, 0 regressed.

  • Largest: kv8192 t=4 −65.5%, kv8192 t=8 −61.0%, kv2048 t=4 −60.8%.
  • The t=1 band -- the pure kernel result, no threading involved -- moves from
    1.249–1.375 → 1.033–1.180. That is the exp8 + pass-1 work.
  • Everything above t=1 on the decode fixtures is the fan-out gate. The five rows
    the old body called permanently flat now scale.
  • Three cells now beat ORT: whisper t=1 (0.998), prefill t=4 (0.939),
    kv1024 t=8 (1.005, parity).

Declared not-a-win, for honesty: whisper t=8 1.755 → 1.740 is noise
(−0.9%, inside the null control's spread). It is counted as noise above, not as
an improvement. No cell regressed. kv4096 t=2/t=4/t=8 improve but remain the
worst residual ratios in the grid (1.9–3.2x) -- that fixture is still ORT-favourable
and is not claimed as solved.

Numerics: exhaustive, not sampled

exp8 changed shape, so the contract is proven over the entire f32 domain
rather than spot-checked:

  • 4,294,967,296 inputs (every 32-bit pattern), old kernel vs new, bit-for-bit.
  • 0 differing bits. 0 NaN-propagation violations. 8.5 s.
  • Falsifier control: the same harness with the underflow mask deleted reports
    1,020,022,810 mismatches, first at -87.33654. The harness is live, not
    vacuously passing.

Local gate matrix (run on main + all three of my PRs, then re-run on merged main)

gate result
cargo fmt --all --check clean
cargo clippy --locked --all-targets -D warnings 0 warnings, 0 errors
Linux tests 1493 lib + 12 targets, 0 failed
aarch64 cross (scripts/check_cross_compile.sh) pass (full offline set, cross toolchain present)
aarch64 clippy --target aarch64-unknown-linux-gnu --all-targets -D warnings clean
Windows ARM64 not reproducible on Linux -- see below
Miri (task_runtime / strided / provider / dtype) 29 / 8 / 16 / 12, 0 failed
MLAS feature matrix (--no-default-features --features mlas) 1441 passed, 0 failed
zero-MLAS default artifact nm -C, nm -D, ldd, strings = 0 on both bench binaries; wheel_packaging guard 2/2
exhaustive numerics 2³² inputs, 0 diffs (+ control that fails)
benchmarks 28-cell, interleaved, null-controlled, pure-native

Windows ARM64 could not be executed on this host. ort-sys's build.rs runs
bindgen against the downloaded ORT headers, and clang cannot find MSVC's
stdlib.h; the crate chain onnx-runtime-ep-cpu → onnx-runtime-ep-api → onnx-genai-ort-sys is mandatory, so the EP crate cannot be isolated from it.
Mitigation, and the reason this is safe: this diff introduces zero target_os
predicates.
Every cfg it adds is target_arch = "x86"/"x86_64",
feature = "mlas", or cfg(test). Windows ARM64 adds no configuration dimension
that these changes span, and the aarch64 target_arch dimension is covered
above. CI additionally has a native windows-11-arm job.

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

@justinchuby

Copy link
Copy Markdown
Owner Author

Status: validated locally, held on required CI — not merging.

Per instruction, this waits for the required checks (Fast (Linux x86_64) and Rust quality) rather than using an admin/ruleset bypass. Those checks have not reported, and the reason is repository-wide rather than anything about this PR:

  • Across the last 200 workflow runs on this repo, none has concluded success or failure — 161 are still queued/pending and 39 were cancelled (superseded by their concurrency group while still queued).
  • The oldest queued run started at 2026-08-19T05:25Z and is still queued; CI on main itself is queued too. This is runner capacity, not this branch.

So the required checks are currently unreachable, and this PR stays open until they run. It is not blocked on review or on any known defect.

Local validation on this branch (not a substitute for CI, recorded for whoever merges):

  • cargo test -p onnx-runtime-ep-cpu --lib — all green
  • cargo clippy -p onnx-runtime-ep-cpu --lib --all-targets — 0 warnings
  • cargo fmt --check — clean
  • cargo check -p onnx-runtime-ep-cpu --features mlas — clean
  • Opus review completed; the actionable nits it raised are fixed in follow-up commits on this branch.

@justinchuby
justinchuby enabled auto-merge (squash) August 19, 2026 06:41
@justinchuby

Copy link
Copy Markdown
Owner Author

Status: auto-merge armed, waiting on required CI. Not merging by hand.

GitHub Actions has not concluded a run on this repo for some time (last 200 runs: 0
success, 0 failure; the rest queued or cancelled-while-queued). mergeStateStatus here
means checks pending, not a permissions problem. --auto --squash is set, so this lands
by itself the moment the required checks report.

#1429 has to land before this one can go green. While replicating the CI lanes locally I
found that Rust quality is red on main itself: its cross-arch step fails with three
never used errors on aarch64, introduced by bf722725a (#1363). Because PR checks run
against the merge result, every open PR inherits that failure. #1429 fixes it.

Independent replication of both required checks on main @ f8f3878ba:

lane result
Fast (Linux x86_64) full offline test set (--locked, -D warnings) 3942 tests / 188 targets, exit 0
MLAS-only lanes (feature config, kernels::moe::, kernels::qlinear_matmul::) 1 + 19 + 30 passed
cargo clippy --locked --all-targets over the 30 offline crates exit 0
cargo fmt --all --check clean
all 9 Rust quality python gates pass
shipped onnx-runtime-ep-cpu-plugin cdylib with mlas exit 0
Miri, all four onnx-runtime-ep-cpu lanes 28 + 8 + 16 + 12 passed
weight-cache guard + per-thread-buffer guard vs this PR 0 hits
bash scripts/check_cross_compile.sh FAIL — see #1429

@justinchuby
justinchuby force-pushed the squad/leon-p28-softmax-phases branch 2 times, most recently from 55f6ea7 to 0fe8efc Compare August 19, 2026 17:15
justinchuby added a commit that referenced this pull request Aug 19, 2026
…x by phase (#1423)

Ledger write-up for #1402 and #1416. No code.

Records, in order:

- **§40.1** The experiment §39.4 left open — one immutable pack shared
across row blocks — **built, measured, and lost**, at every panel width
and worse the wider the pool (geomean 0.20–1.04 vs the unpacked driver).
Full grid included so it is not rediscovered. Two causes: a fork-join
per panel, and a pack that is a partly serial Amdahl term (at phi35's
`k=6400` a 512 KB panel is a single micro-panel, so splitting the pack
by micro-panel leaves it on one thread).
- **§40.2** The win one level up: distribute whole panels, which gets
the "pack each panel exactly once" property §39.4 wanted, without a
barrier and with the panel resident in the owning core's L2.
- **§40.3** The gate, including the `k >= 1024` variant that scored
better on grid geomean and was **rejected** for regressing a production
shape.
- **§40.4** Softmax profiled by phase: pass 2 is 73% of the row and
throughput-bound, pass 1 is 11% and latency-bound, so the same fix helps
one and hurts the other. Includes the magic-number round fusion that was
measured, costed at 3%, and declined.
- **§40.5** Two method lessons: exhaustive verification over all 2^32
f32 inputs (0.6s, and the control that proves it can fail), and the
invariance-hidden test hole that let a deleted accumulator chain pass
every existing test.

Also records the mixtral cell where the §39 control arm moved +66% and
the ratio metric had to be abandoned for native-time-vs-control.


---

## Update: adds §42 as well

`main` claimed `## 40` while this branch was open, so my original
section was
renumbered to **§41** on rebase. This PR now also carries **§42**,
recording the
softmax fan-out gate finding from #1484.

§42 is deliberately a continuation of upstream's §40 rather than a new
theme.
§40 closes by asking that "the fourth instance is recognised rather than
re-derived"; §42 is that fourth instance, and it reports the way it
*differs*
from the three §40 collected: the constant was not calibrated in the
wrong
regime, it was expressed in the wrong unit. A row is not a unit of work
— `n`
prices work only if `d` is held fixed, and `d` is the key length.
§40.3's remedy
— record the regime beside the constant — would not have caught it.

§42.3 records the more transferable half: softmax rows are independent,
so the
output is bit-identical whether or not the fan-out happens, which makes
every
correctness test in the file blind to the gate by construction.
Inverting the
caller's use of the predicate undoes the entire optimisation and left
all 1448
tests green. A performance gate has no numerical signature and has to be
asserted directly.

§42.4 is a reporting note: the host was heavily contended for that run
(A/A nulls
up to 61%), and the honest read came from structure rather than the grid
— only
four of seven fixtures change gate decision at all, so the other three
cannot
have moved and their scatter calibrates the host.

#1484's doc comment cites §42, so the two are written to land
consistently in
either order.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby and others added 5 commits August 19, 2026 20:39
…nel)

Profiling the row kernel by phase, rather than as a whole, showed the work
is not where the shape of the code suggests. Per element at d=1024, on this
host:

    pass 1 (max)        67 ps
    pass 2 (exp+sum)   446 ps
    pass 3 (normalize)  58 ps
    whole row          608 ps

Pass 2 is 73% of the row and pass 1 is 11%, and each is limited by a
different thing. Both limits are removable, and neither removal changes a
single output bit.

Pass 1 is latency-bound. It is one `vmaxps` chain, and `vmaxps` has ~4
cycles of latency, so the chain retires 8 floats every 4 cycles while the
load ports could feed 16. Four independent chains hide the latency
completely: 67 -> 37 ps/element, 1.8x. `max` is associative and commutative
over the values that survive, and feeding the loaded vector as the *first*
`max_ps` operand keeps the existing NaN convention (a NaN lane returns the
accumulator) in each chain, so no accumulator can ever hold a NaN and the
final fold obeys the same rule. The result is bit-identical to one chain.

Pass 2 is throughput-bound on ports 0/1, not latency-bound: the loop issues
16 port-0/1 ops per 8 lanes, which at 2 ops/cycle is the 8 cycles/iteration
measured. Four independent sum chains, the obvious counterpart to the pass-1
fix, are therefore *slower* (446 -> 458 ps/element) and are not used.

Three of those 16 ops exist only to patch `exp8`'s non-finite lanes: a
`max_ps` clamp, an unordered compare, and a `blendv`. They are unnecessary,
because both non-finite cases fall out of the arithmetic:

  * `-inf` poisons `r` into `NaN`, but `is_zero` is true for it, and the
    underflow mask that already exists is a bitwise `andnot` -- it produces
    the required exact `0` whatever the poisoned build contained.
  * `NaN` survives `round`, both `fnmadd`s and the Horner chain; `cvtps_epi32`
    maps it to the integer indefinite `0x8000_0000`, whose low nine bits
    build `pow2` = `1.0`, so `y` = `NaN * 1.0` = `NaN`, and `is_zero` is
    false for it because the compare is ordered.

Dropping all three: 446 -> 370 ps/element, 1.20x.

Together, 608 -> 481 ps/element at d=1024; 1.16x at d=64 rising to 1.26x at
d>=256. Every softmax consumer shares this one kernel, including
`FusedAttention` through `scale_mask_softmax_rows`.

Not taken: folding the `mul`+`round` into one FMA with a magic-number
constant saves a fourth port-0/1 op, but it is worth only 3% (370 -> 356
ps/element) and it moves `k` for arguments that sit on a rounding boundary,
which would forfeit bit-identity and require re-establishing the 1-ULP bound
from scratch. Not worth 3%.

Numerics
--------

The `exp8` change is verified exhaustively rather than argued. An offline
harness evaluates the old and new forms over **all 2^32 f32 bit patterns**:

    non-NaN inputs whose result differs in any bit: 0
    NaN inputs where either side is not NaN:        0

So the 1-ULP bound over `(-87.336544, 0]`, the exact `0` at and below the
underflow threshold, and the `NaN` -> `NaN` contract all transfer unchanged
by construction, not by re-measurement. (Control: deleting the underflow
mask makes the same harness report 1,020,022,810 mismatches, first at
`-87.33654`.) The only observable difference is the `NaN` *payload*, now the
quieted input rather than a canonical `f32::NAN` -- which the row contract,
"a poisoned row normalizes to NaN", does not distinguish.

Two tests are added because both properties are now emergent rather than
explicit:

  * `exp8_maps_the_non_finite_lanes_without_an_explicit_patch` pins the
    vector-level contract with quiet, signalling and both-signed NaNs and
    with five flush-to-zero inputs, placed in every lane in turn, plus the
    one-ULP-above-threshold boundary.
  * `the_row_maximum_covers_every_position` closes a hole the pass-1 change
    exposed: softmax is invariant to the value subtracted, so a row maximum
    that misses part of the row is numerically silent and *every existing
    test passed with one of the four chains deleted*. It only surfaces as
    overflow, so the test puts a 3e38 logit at each position of each of
    seven `d` values and requires the row to stay finite.

Falsified: dropping a max chain fails the new row test; dropping the
underflow mask fails the new exp8 test and `large_and_masked_logits_stay_finite`;
reordering the fold (a no-op) stays green. Skipping the 8-wide drain in
pass 1 also stays green, correctly -- the scalar tail still covers those
elements.

Measurements
------------

End to end over all seven softmax fixtures x 1/2/4/8 threads, both arms
proved pure-native (`nm -C | grep -ci mlas` = 0), `--null-control` on every
run. 25 of 28 cells show the old kernel slower by more than that cell's
noise floor, from +2.0% to +51.8%; no cell shows a regression. The three
that landed inside the floor were re-run at 41 trials: all three then favour
the new kernel (kv1024 t=8 +10.1% > 2.5% floor; whisper t=4 +2.2%, prefill
t=4 +3.8%, both inside their floors but with native-time medians 3.7% and
7.2% below the old arm and level with the null).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Review caught: the pathological-rows test still described the canonical-NaN
blend this change removes; the pass-1 note said `vmaxpd` where the code is
`vmaxps`; and one measurement was quoted as 442 rather than 446 ps/element.

Also record the one caveat to the associativity argument that a differential
sweep over ~588k rows turned up: a row whose maximum is zero can pick +0.0 or
-0.0 depending on the grouping. It cannot reach the output, because both
signs subtract identically and exp8(±0.0) is 1.0, and the sweep confirms
every row is bit-identical -- but the comment claimed more than it had.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`parallel_rows_per_task` refused to fan out below 64 rows however much work
those rows held. Decode attention is `heads x kv_len`, so a 32-head model ran
single-threaded at every pool width -- 32x8192 is 256 Ki elements, 16x the
element floor sitting next to the row floor in the same predicate. Measured
against ORT on a pure-native default build, that is the whole of the 2.2x-5.6x
multi-thread gap on the decode softmax fixtures; native time was flat to within
1.4% from one thread to eight.

Drop the row floor and keep the element floor, which measures work in the unit
work is done in. This admits nothing new per chunk: chunks are sized by
ROW_TILE_BYTES, so the newly-admitted 32x8192 yields 32 chunks of 8192 elements,
exactly what the already-admitted 64x256 yields -- the test asserts that
equality rather than the arithmetic behind it. `n >= 2` replaces the floor and
is an impossibility rather than a threshold: a chunk is a whole number of rows.

Native time falls 29.9%-43.6% wherever the fan-out engages (kv1024/2048 from
t=4, kv4096/8192 at t=8), and the ours/ORT ratio follows: -53.9% at kv4096 t=8,
-44.1% at kv2048 t=8, -39.9% at kv8192 t=8. The three fixtures that already
cleared the old floor take the same code path before and after, and are reported
unchanged.

Split the work predicate out as `fan_out_is_worthwhile` so the policy is
testable without a multi-threaded runtime.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Review found two mutations of the gate that the whole suite tolerated.

Inverting `parallel_rows_per_task`'s use of `fan_out_is_worthwhile` pins every
large softmax back to a single thread -- undoing this change entirely -- and
1448 tests still passed. Nothing could catch it: rows are independent, so the
output is bit-identical whether or not the fan-out happens, and the new unit
test exercised the predicate in isolation rather than through its caller.
`the_fan_out_gate_is_wired_the_right_way_round` closes that by asserting the
Some/None decision itself. Its refusals need no pool, so they run everywhere;
the fan-out half returns early below two lanes, matching the existing
`parallel_output_rows_dispatches_to_the_task_runtime`.

The element floor was also pinned only at MIN and MIN-2, so relaxing it by one
element survived. 3x5461 is one element short and now straddles it exactly.

Also drop a comment reference to MIN_PARALLEL_SOFTMAX_ROWS, deleted last commit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Upstream landed a new section 41 while this branch was open, so the ledger
section recording this measurement is 43 rather than 42.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby force-pushed the squad/leon-p28-softmax-phases branch from 0fe8efc to 9e1c5f5 Compare August 19, 2026 20:39
@justinchuby
justinchuby merged commit bef2c02 into main Aug 19, 2026
6 checks passed
@justinchuby
justinchuby deleted the squad/leon-p28-softmax-phases branch August 19, 2026 20:40
@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_preexpanded_dense_oncelock_like_proxy/1x1024x1024 44.72 µs 160.94 µs +259.9%
🔴 block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 515.13 µs 1.27 ms +147.4%
🔴 block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 135.16 µs 306.78 µs +127.0%
🔴 matmul/large_generic_f32_threads=8/32x1024x1024 3.89 ms 8.42 ms +116.2%
🔴 matmul/medium_generic_bf16_threads=8/32x512x512 376.99 µs 721.78 µs +91.5%
🔴 qwen3_sampling_processors/top_k_partial_selection 154.46 µs 276.56 µs +79.1%
🔴 tokenization/encode_tokens_per_second 401.10 µs 696.08 µs +73.5%
🔴 matmul/large_generic_f16_threads=8/32x1024x1024 77.44 µs 131.92 µs +70.4%
🔴 matmul/large_generic_f16_threads=1/32x1024x1024 77.30 µs 126.75 µs +64.0%
🔴 tokenization/decode_tokens_per_second 6.59 ms 10.44 ms +58.4%
🔴 matmul/small_generic_f16_threads=1/1x256x256 28.87 µs 45.10 µs +56.2%
🔴 matmul/large_generic_bf16_threads=1/32x1024x1024 2.21 ms 3.43 ms +54.8%
🔴 qwen3_sampling_processors/top_k_top_p_full_sort_baseline 5.77 ms 8.87 ms +53.7%
🔴 qwen3_sampling_processors/top_k_full_sort_baseline 2.22 ms 3.41 ms +53.2%
🔴 block_quantized_matmul_cached_dense/mxfp4_cached_dense_repeated_call/1x1024x1024 121.62 µs 183.30 µs +50.7%
🔴 sampling_latency/min_p_per_token 236.64 µs 346.45 µs +46.4%
🔴 matmul/small_generic_f32_threads=8/1x256x256 32.84 µs 46.84 µs +42.6%
🔴 qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline 4.17 ms 5.62 ms +34.7%
🔴 matmul/medium_generic_bf16_threads=1/32x512x512 562.69 µs 742.32 µs +31.9%
⚠️ kv_cache/alloc_dealloc_pages 42.01 µs 54.01 µs +28.6%
⚠️ sampling_latency/top_p_per_token 402.04 µs 516.85 µs +28.6%
⚠️ matmul/medium_generic_f16_threads=8/32x512x512 28.18 µs 35.74 µs +26.8%
⚠️ matmul/large_generic_f32_threads=1/32x1024x1024 10.44 ms 13.19 ms +26.4%
⚠️ matmul/medium_generic_f16_threads=1/32x512x512 27.95 µs 35.29 µs +26.3%
⚠️ qwen3_sampling_processors/top_p_fast_after_top_k 553.93 µs 697.54 µs +25.9%
⚠️ matmul/medium_generic_f32_threads=8/32x512x512 899.26 µs 1.13 ms +25.7%
⚠️ qwen3_sampling_processors/top_k_top_p_fast 681.04 µs 855.65 µs +25.6%
⚠️ matmul/small_generic_f16_threads=8/1x256x256 28.38 µs 35.57 µs +25.4%
⚠️ matmul/small_generic_bf16_threads=1/1x256x256 29.15 µs 36.48 µs +25.1%
⚠️ sampling_latency/top_k_per_token 57.55 µs 71.25 µs +23.8%
⚠️ logit_processing/seven_processor_chain_per_step 328.71 µs 403.26 µs +22.7%
⚠️ sampling_latency/greedy_per_token 3.46 µs 4.22 µs +22.0%
⚠️ matmul/small_generic_bf16_threads=8/1x256x256 29.28 µs 34.64 µs +18.3%
⚠️ matmul/medium_generic_f32_threads=1/32x512x512 2.17 ms 2.56 ms +17.9%
✅ matmul/large_generic_bf16_threads=8/32x1024x1024 2.37 ms 2.69 ms +13.5%
✅ add/large_f32_threads=1-internal/4194304 652.20 µs 735.41 µs +12.8%
✅ block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 618.96 µs 650.36 µs +5.1%
✅ grammar_masking/llguidance_compute_mask/32 89.73 µs 92.16 µs +2.7%
✅ add/medium_bf16_threads=1-internal/262144 109.15 µs 111.67 µs +2.3%
✅ matmul/small_generic_f32_threads=1/1x256x256 38.31 µs 38.79 µs +1.2%
✅ add/small_f32_threads=1-internal/1024 196.6 ns 198.3 ns +0.8%
✅ add/medium_f16_threads=1-internal/262144 107.66 µs 107.16 µs -0.5%
✅ gather/small_bf16_threads=1-internal/4096 464.7 ns 451.3 ns -2.9%
✅ gather/medium_bf16_threads=1-internal/32768 2.45 µs 2.30 µs -6.1%
✅ gather/medium_f32_threads=1-internal/32768 3.69 µs 3.46 µs -6.3%
✅ add/small_bf16_threads=1-internal/1024 485.1 ns 439.4 ns -9.4%
✅ reduce_mean/large_f32_threads=1-internal/262144 1.02 ms 917.13 µs -10.4%
✅ gather/medium_f16_threads=1-internal/32768 2.47 µs 2.19 µs -11.1%
✅ gather/small_f32_threads=1-internal/4096 718.7 ns 636.1 ns -11.5%
✅ gather/small_f16_threads=1-internal/4096 513.4 ns 451.3 ns -12.1%
✅ add/large_f16_threads=1-internal/4194304 1.99 ms 1.75 ms -12.2%
✅ add/small_f16_threads=1-internal/1024 524.8 ns 459.2 ns -12.5%
✅ reduce_mean/medium_f32_threads=1-internal/65536 263.11 µs 229.13 µs -12.9%
✅ gather/large_bf16_threads=1-internal/131072 14.09 µs 12.23 µs -13.2%
🟢 add/medium_f32_threads=1-internal/262144 30.66 µs 25.13 µs -18.0%
🟢 gather/large_f16_threads=1-internal/131072 12.32 µs 9.88 µs -19.8%
🟢 reduce_mean/small_f32_threads=1-internal/4096 18.27 µs 14.19 µs -22.3%
🟢 gather/large_f32_threads=1-internal/131072 35.13 µs 23.56 µs -33.0%
🟢 add/large_bf16_threads=1-internal/4194304 2.44 ms 1.51 ms -38.1%

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.45 5.25 5.78 }
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)

@codecov

codecov Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.34884% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.21%. Comparing base (4a9f4ec) to head (9e1c5f5).
⚠️ Report is 77 commits behind head on main.

Files with missing lines Patch % Lines
crates/onnx-runtime-ep-cpu/src/kernels/softmax.rs 95.34% 6 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##             main    #1416       +/-   ##
===========================================
- Coverage   82.10%   80.21%    -1.89%     
===========================================
  Files          12      377      +365     
  Lines        5471   165927   +160456     
  Branches     5471   165927   +160456     
===========================================
+ Hits         4492   133103   +128611     
- Misses        780    27987    +27207     
- Partials      199     4837     +4638     
Flag Coverage Δ
cli-ort-linux 82.60% <ø> (?)
cli-ort-windows 82.19% <ø> (+0.09%) ⬆️
offline 80.13% <95.34%> (?)

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

Files with missing lines Coverage Δ
...onnx-runtime-ep-cpu/src/kernels/fused_attention.rs 94.87% <ø> (ø)
crates/onnx-runtime-ep-cpu/src/kernels/softmax.rs 92.07% <95.34%> (ø)

... and 363 files 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 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.

1 participant