Repository navigation
perf(cpu): retire the m == 1 half-decode handover to the fused GEBP (#1381) - #1613
Conversation
…1381) MatMul sent every m == 1 f16/bf16 decode with k * n >= 1M to the fused widen-pack GEBP; Gemm never did. Re-measured through the production A/B harness the handover is a loss at every shape at 8 threads (1.3x-6.7x) and still a loss at every 7B-class decode shape at 32 (k = 4096 qkv/mlp 0.51-0.89 after dividing out the f32 control, a 136M lm_head 0.86). The original gate's evidence was 32 threads and k <= 2048 only. The mechanism is structural, not a threshold: the GEBP repays its packing by reusing a B panel across the rows of A, and m == 1 has one row, so it pays ~5x the weight traffic for a reuse factor of one. A decode-specialised GEBP that skips packing B is the GEMV. So #1381 closes with MatMul adopting Gemm's route rather than the reverse. half_decode_prefers_gebp/_when are deleted. HALF_PREFILL_GEBP_MIN_WEIGHT and half_prefill_gebp_selected are untouched -- they remain the prefill gate and still serve a batched m == 1 half MatMul, which the non-batched GEMV declines and whose only alternative is the row-blocked GEMM at 16x-21x. Coverage repaired in the same change: PROBE_SHAPE=band adds 11 rows immediately below, at and above the retired threshold at three different k, and the route pins are by execution (fail-loud counters, mutation-verified) rather than by asking a predicate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1613 +/- ##
==========================================
- Coverage 80.94% 80.88% -0.07%
==========================================
Files 382 381 -1
Lines 178395 175984 -2411
Branches 178395 175984 -2411
==========================================
- Hits 144401 142343 -2058
+ Misses 29061 28714 -347
+ Partials 4933 4927 -6
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Two independent PROBE_SHAPE=band repetitions at 8 threads are now tabulated in full. They do not reproduce the earlier 'no row above 1.00' claim: w1.05M_k1024 (exactly at the retired threshold, k = 1024) measured /ctl 1.14 then 0.89, and five bf16 cells reach 1.00+ with f32 controls drifted to 0.55-0.94, which makes them unusable rather than favourable. The supported worst case is 5.0x, not the 6.7x previously cited, and the full set's at-or-above-threshold range is 1.3x-3.8x (/ctl 0.26-0.76) once the sub-threshold 0.79M row is excluded. Also addresses the Opus review: superseded banner on the 2026-08-19 bf16 record, the packing cost restated as L2 traffic + ~2x DRAM line amplification rather than '5x the weight traffic', the sub-threshold row footnoted, the 'same route' claim scoped to f16, and half_gemm_tile returned to private. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Local validation on this head (
Mutation check re-run on this head: re-inserting Not covered: Miri is a no-op gate for this EP's SIMD and this diff adds no |
Brings in #1612 (NVRTC disk cache + the CUDA graph capture gate) and #1613 (retire the m == 1 half-decode handover). Three conflicts, all in onnx-runtime-cuda-memory, all caused by #1612 landing a capture gate on code this stack had already restructured. ## Resolutions 1. `src/lib.rs` (modify/modify) -- kept main's `pub mod capture_gate;` and this stack's `pub mod release;`, dropped `pub mod device_allocator;`. The eager allocator was deleted by this stack (VMM-only); nothing in the workspace references that module, and `capture_gate` does not depend on it. 2. `src/device_allocator.rs` (modify/delete) -- kept the deletion. #1612's only change to it was adding capture-gate guards around `cuMemAlloc`/`cuMemFree`, which is code this stack removed outright. Nothing to port. 3. `src/virtual_memory.rs` (modify/modify) -- took this stack's `release`, then ported #1612's guard *deeper* rather than to the same line. ## Why the guard moved, and one place it was missing entirely #1612's contract is that no device-synchronizing driver call may run while a CUDA graph capture is in flight on any thread. It enforced that at two trait methods, `VirtualBacking::commit` and `VirtualBacking::release`, because on main those were the only routes to the driver. This stack changed that topology, so guarding the same two lines would have preserved the diff while losing the guarantee: - `release`'s driver calls now live in `release_blocks_reporting`, reached by four callers (`virtual_memory.rs` x2, `vmm_allocator.rs` x2), not one. The guard is placed at the syscalls, so all four are covered. `commit`'s guard merged cleanly and is unchanged. - `ReservationTeardown::execute_outcome` is new in this stack and calls `cuMemUnmap`/`cuMemRelease` directly, driven by the deferred-release queue in onnx-runtime-ep-cuda. It did not exist when #1612 was written, so it had no guard at all. It is arguably the path that most needs one: it runs on a teardown queue rather than under a caller's commit, which is exactly the concurrent-with-capture case #1612 describes. Now guarded. `synchronizing_section()` refcounts via `SHARED_DEPTH` and returns a non- outermost token when nested, so pushing the guard down cannot deadlock against an outer section. The crate's own `capture_gate` nesting tests cover this and pass. Out of scope, recorded rather than fixed: `commit_offsets_with_owned_limit_and_ capacity`, `reserve_and_map_shared_prefix`, and `map_shared_prefix_readonly` all reach the driver and all predate this stack -- they are unguarded on main too. That is a gap in #1612, not a merge loss here, and closing it blind on a machine with no CUDA would be guesswork. ## Audit - Overlap set is exactly the four files above; every other file main added or changed is byte-identical to `origin/main` by blob hash. - No conflict markers remain anywhere in `crates/`. ## Verified on this macOS host - `cargo fmt --all -- --check` clean. - `cargo check --workspace --all-targets` and `cargo check -p onnx-runtime-ep-cuda --features cuda --all-targets` both OK. - `cargo clippy --locked --all-targets` over the five memory crates with `-D warnings` clean (`onnx-runtime-cuda-memory` is on CI's offline list). - `cargo test -p onnx-runtime-cuda-memory --lib`: 35 passed, 0 failed. ## Not verified `cargo test -p onnx-runtime-ep-cuda --lib` reports 8 failures, all in #1612's new `kernel_cache::tests`, all panicking inside cudarc at lib.rs:200 because this host has no CUDA driver. A clean `origin/main` worktree fails the same 8, so they are inherited, not caused here -- but it does mean the CUDA path was again never executed. Those tests appear to be missing the GPU guard the rest of the suite uses. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c80f8522-983c-47f7-8241-2155a823aabe
## The gap
`GemmKernel::try_half_fast_path` opened with
```rust
if a.dtype != DataType::Float16 || b.dtype != DataType::Float16 {
return Ok(None);
}
```
so a **`bf16` `m == 1` `Gemm` never reached the decode GEMV at any
shape** and fell into the
portable blocked half GEMM — while `MatMul` has served the identical
`bf16` decode from that same
GEMV since 2026-08-19. The same decode got a different kernel depending
on which op the exporter
emitted. This is the gap #1613 documented as open in §14.
## The measurement
`bench_gemm_half_decode_route` with `PROBE_DTYPE=bf16`. Both arms come
out of **one build**,
selected by `ONNX_GENAI_CPU_MM_HALF_GEMV` (a process-wide `OnceLock`, so
one arm per process); with
the knob off `Gemm` declines the GEMV and lands in the blocked GEMM,
which is exactly the pre-change
`bf16` route. `steady_ms` is the median of 25 executes after a cold one,
and every row also runs the
same shape in `f32` — a path neither arm can move — so `corrected` is a
route difference, not drift.
**8 threads**
| shape | `K x N` | before | after | raw | ctl | **corrected** |
|---|---|---|---|---|---|---|
| `k1024n768` | 1024x768 | 0.264 | 0.060 | 4.40x | 0.95 | **4.63x** |
| `k1024n1024` | 1024x1024 | 0.378 | 0.080 | 4.72x | 1.08 | **4.37x** |
| `k1024n2048` | 1024x2048 | 0.765 | 0.092 | 8.32x | 1.48 | **5.62x** |
| `k2048n1024` | 2048x1024 | 0.710 | 0.115 | 6.17x | 1.17 | **5.28x** |
| `k512n4096` | 512x4096 | 0.752 | 0.055 | 13.67x | 1.03 | **13.32x** |
| `qwen_qkv` | 3584x4608 | 9.974 | 0.523 | 19.07x | 0.88 | **21.66x** |
| `llama_mlp` | 4096x11008 | 36.066 | 1.798 | 20.06x | 0.83 | **24.07x**
|
| `llama_qkv` | 4096x4096 | 9.854 | 0.470 | 20.97x | 0.98 | **21.34x** |
**32 threads** — same direction, every shape: 3.79 / 4.11 / 4.11 / 6.50
/ 2.97 / 15.38 / 22.33 /
11.22x corrected, in the same row order.
The blocked GEMM holds **2.5–6.0 GB/s** of weight bandwidth regardless
of shape; the GEMV reaches
**26–76 GB/s** against a 75.8 GB/s DRAM ceiling. The `f16` arm is
unchanged and still lands on the
GEMV (`llama_mlp` 1.752 ms vs `bf16`'s 1.798 ms at 8 threads — as it
must be, both read `2*K*N`
bytes).
## Changes
- The dtype gate now calls `matmul::half_storage_format`, the same
helper `MatMul` uses, so the two
ops admit exactly the same dtype pairs. It is strictly a subset gate —
mixed and non-16-bit pairs
still return `None`.
- The resulting `HalfFormat` is threaded into
`half_gemv::simd_available` and
`half_gemv::gemv_half_kn` instead of being hard-coded to `F16`.
- **`Gemm` now honours `ONNX_GENAI_CPU_MM_HALF_GEMV`.** It did not, so
the documented field
kill-switch for this route silently covered only `MatMul`, and the
`Gemm` side of the shipped
binary could not be A/B'd at all. Bundled deliberately: it is the
instrument the table above is
measured with.
### The asymmetry that stays
A **transposed** (`[N, K]`) `bf16` decode still declines. That is kernel
coverage, not policy:
`gemv_f16_nk` reads `f16` bit patterns and has no `bf16` twin, so
admitting it would silently
reinterpret the weight. It keeps the blocked GEMM exactly as before, and
a test pins both the route
and the numerics. Writing that kernel is the obvious next increment,
with its own numerics burden.
## Correctness
`bf16_decode_takes_the_same_gemv_in_both_ops` asserts at 64x64,
1024x1024 and 2048x3072 that the
`Gemm` counter shows the GEMV, the `MatMul` counter does too, the two
outputs are **bit-identical**,
and both are within 8e-3 of an `f64` reference taken over the
**bf16-narrowed** operand values — so
the tolerance absorbs accumulation order only, not storage rounding.
Bit-identity alone would
tolerate "both wrong the same way"; the f64 reference is what excludes
that.
**Mutation-verified, both directions:**
| mutation | test that fails |
|---|---|
| restore the `Float16`-only gate |
`bf16_decode_takes_the_same_gemv_in_both_ops` |
| drop the `!trans_b \|\| format == F16` term |
`transposed_bf16_decode_declines_the_gemv_but_stays_correct`, on
numerics |
`a_multi_row_half_gemm_is_not_counted_as_decode` pins the instrument
itself, so a change that made
the counter fire for prefill cannot make the other two pass vacuously.
## Validation
Full local matrix on this head: **PASS=19 FAIL=1** — `fmt --all`,
offline build, `-p
onnx-runtime-ep-cpu --lib` (**1555 passed / 0 failed / 21 ignored**),
clippy `-D warnings`,
`--features mlas`, native-be clippy, **`aarch64-unknown-linux-gnu`
clippy**, `--no-default-features`,
`--all-features`, **no-MLAS default-artifact symbol scan**,
`cross-compile.sh`, and all eight
`scripts/` conformance checks. The one failure is
`aarch64-pc-windows-msvc`, the standing
environmental one (`ort-sys` bindgen needs the Windows SDK for
`stdlib.h`).
Miri: no coverage — this diff adds no `unsafe` (it reuses the existing
raw-`u16` read, whose
preconditions are unchanged and re-verified in review).
## Review
Reviewed by `claude-opus-4.8` with an explicit instruction to argue the
opposing side. It returned
**REQUEST-CHANGES on a real defect** the x86 lanes could not see:
`format` is read only inside the
x86-gated decode arm, so off x86 it was bound and never used and
`aarch64-unknown-linux-gnu` failed
under `-D warnings`. My own matrix caught the same thing (`G` and `L`
both red). Fixed with a
`cfg(not(x86))` discard, and both lanes are green above.
It verified independently: the `unsafe` stays sound under the widened
dtype set (`BFloat16` is
2 bytes and `u16`-transparent; `half_storage_format` admits no mixed
pair; length and alignment are
checked before the read); nothing downstream diverges by dtype
(`alpha`/`beta*C`/narrowing are all
f32); the kill-switch fallthrough is correct for both dtypes and both
`trans_b` values; and the
three opposing arguments — that the transposed asymmetry should block
the change, that the
kill-switch is scope creep, that the blocked GEMM was numerically safer
— do not survive.
## Remaining losses
- Transposed `bf16` decode: still on the blocked GEMM, still ~4–21x off.
- `bf16` **prefill** (`m > 1`) is untouched in both ops.
- The GEMV's own distance from roofline is unchanged: 26–76 GB/s against
75.8 GB/s.
Full record: `docs/benchmarks/2026-08-21-gemm-bf16-decode-gemv.md`, and
§15 of
`docs/performance/CPU_MATMUL_ASSIGNMENT.md`.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`widen16(signed: bool, ..)` -- the u8 -> i16 widen every byte of `B` passes through in `qgemm_native` -- took signedness as a runtime argument and branched on it in the innermost loop, once per 16 bytes. `Operand::signed` is fixed for the whole call, but the `#[target_feature]` boundary stops the compiler hoisting the test out. It is now a `const SIGNED: bool` on `widen16`, `fused_strip`, `accumulate_fused` and `pack_panel`, with the single runtime `match` moved out to the block dispatcher that already matched on `m`. No arithmetic changed: accumulation is still wrapping `i32`, so the output is bit-identical, which the existing both-sign-domain oracle asserts. Kernel A/B, two prebuilt test binaries alternated over three repetitions with the harness's `portable` drift control steady to 1.6%: 1.13x at 1x3584x3584, 1.14x at 1x1024x3072 and 1.03x on the packed 128x3584x3584 path, with the two m=1 ranges non-overlapping between arms. End-to-end the effect is below what this host can resolve. A first attempt across two separately built bench binaries read 1.43x and is withdrawn -- a 1.13x kernel cannot yield a 1.43x call. A null control (one binary against itself) puts the paired end-to-end floor at +/-10%, and re-running each binary against its own ORT reference gives medians of 2.43x (base) and 2.58x (new). The change is kept for the mechanism, not for an end-to-end number: the branch is provably loop-invariant and the output is bit-identical. This was found by the first `QLinearMatMul` A/B this repository could run. `scripts/ort_ab/` had no generator for the op, so the ledger's `QLinearMatMul` rows -- `--features mlas` numbers under a caveat claiming 11.8x-12.0x on the default build -- had never been re-measured after #1194 landed the native integer GEMM. `gen_qlinear.py` closes that hole, with cells straddling both of the kernel's own dispatch gates. The corrected picture: 1.13x at u8 M=128, 0.11x at i8 M=1 (a 9.4x win, because ORT's own i8 path is 35x slower than its u8 path), and a real loss of ~2.4x-3.2x confined to u8 M=1, which this change does not close. Also corrects two stale claims the measurements invalidated: the ledger's 11.8x/12.0x `QLinearMatMul` caveat, and `scripts/ort_ab/README.md`'s instruction to set `ONNX_GENAI_CPU_MM_HALF_GEBP=0` to reach the half decode GEMV, which #1613 made unnecessary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…#1616) ## What `widen16(signed: bool, ..)` — the `u8 -> i16` widen every byte of `B` passes through in `qgemm_native` (the native integer GEMM behind `QLinearMatMul`) — took signedness as a **runtime argument** and branched on it in the innermost loop, once per 16 bytes of `B`. `Operand::signed` comes from the input dtype and is fixed for the whole call, but the `#[target_feature]` boundary stops the compiler hoisting the test out. It is now a `const SIGNED: bool` on `widen16`, `fused_strip`, `accumulate_fused` and `pack_panel`, with the single runtime `match` moved out to the block dispatcher that already matched on `m`. **No arithmetic changed.** Accumulation is still wrapping `i32`, so the output is bit-identical — asserted by the existing `check(m, k, n, a_signed, b_signed)` oracle across both sign domains. ## Measurement Kernel A/B, two prebuilt test binaries alternated over three repetitions, `bench_qgemm_ab`'s `portable` arm as a drift control (steady to 1.6% across all six runs): | shape | base | new | ratio | | --- | ---: | ---: | ---: | | 1x3584x3584 | 19.97 GMACS | 22.52 | **1.13x** | | 1x1024x3072 | 17.52 | 19.89 | **1.14x** | | 128x3584x3584 | 61.92 | 64.00 | 1.03x | | *`portable` control* | 3.82 | 3.82 | *1.00x* | The two `m = 1` ranges do not overlap between arms. **End to end the effect is below what this host can resolve, and an earlier claim is retracted.** A first A/B across two separately built `bench_generic` binaries read 1.43x; that is withdrawn, because a 1.13x kernel cannot produce a 1.43x call (`1 / (f/R_k + 1 - f) <= R_k`). A null control (one binary against itself) puts the paired end-to-end floor at ±10%, and re-running each binary against its own ORT reference gives medians of 2.43x (base) and 2.58x (new). The change is kept for the mechanism — provably loop-invariant branch, bit-identical output, clean separation at the level where it acts — not for an end-to-end number. ## Why this was found now `scripts/ort_ab/` had **no `QLinearMatMul` generator**, so the ledger's `QLinearMatMul` rows (`--features mlas`, under a caveat claiming 11.8x–12.0x on the default build) had never been re-measured after #1194 landed the native integer GEMM. `gen_qlinear.py` closes that hole, with cells straddling both of the kernel's own dispatch gates (`PARALLEL_MIN_WORK` and the `m <= MR` fused/packed split). The corrected picture on the shipped build, one thread, parity `PASS` everywhere: **1.13x at u8 M=128**, **0.11x at i8 M=1** (a 9.4x win — ORT's own i8 path is 35x slower than its u8 path), and a real loss of **2.4x–3.2x confined to u8 M=1**, which this change does not close. Also localised, for the follow-ups: at `m = 1` a 1 MB L2-resident weight runs at the same 19.5 GB/s as a 12.85 MB one and every aspect ratio at equal footprint lands within 17–20 GB/s, so the kernel is **instruction-bound, not memory-bound**, up to L3. ## Also in here Two stale claims the measurements invalidated: the ledger's 11.8x/12.0x `QLinearMatMul` caveat, and `scripts/ort_ab/README.md`'s instruction to set `ONNX_GENAI_CPU_MM_HALF_GEBP=0` to reach the half decode GEMV, which #1613 made unnecessary. ## Still open (recorded, not fixed here) - The **~2.4x u8 M=1 gap** itself. - **Parallel scaling at m=1**: 8 threads buy 1.3x where ORT gets 5.8x. The fused path splits columns, handing every worker a page-crossing strided walk of `B`; at m=1 there is no `B` reuse to protect, so a `k` split with private accumulators is the shape that streams. - The single-thread residual is an instruction budget: exact full-range 8-bit needs `vpmaddwd`; `vpmaddubsw` would halve the uops but saturates unless one operand stays within ±64, which is why ORT's quantizer ships `reduce_range` for non-VNNI AVX2. Full record: `docs/benchmarks/2026-08-21-qlinearmatmul-m1-signedness.md`, ledger section 16. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The finding: the in-tree gate was the bug, not the missing one
MatMulsent everym == 1f16/bf16decode withk * n >= HALF_PREFILL_GEBP_MIN_WEIGHT(1 048 576) to the fused widen-pack GEBP.
Gemmnever did — itsm == 1f16path stayed on thedecode GEMV at every weight. That divergence is #1381, and the obvious reading was that
Gemmwasmissing a gate. I implemented that reading first, benchmarked it, and it reversed: at the model
shapes the GEBP was 19–26% slower. Escalating to the authoritative production harness showed
why — it is
MatMulthat was carrying a bad gate.This PR retires the handover.
#1381closes withMatMuladoptingGemm's route rather thanthe reverse.
Evidence
All numbers through
crates/onnx-runtime-ep-cpu/benches/half_decode_gemv_ab.rs, which drivesExecutionProvider::get_kernel+Kernel::execute(dispatch and output narrowing included). Armsare selected by
ONNX_GENAI_CPU_MM_HALF_GEMV/_GEBP, which are process-wideOnceLocks, so onearm per process. Every ratio is divided by an
f32control measured in the same two processes,because this host is heavily shared.
The original gate's evidence was 32 threads and
k <= 2048only.full-set shape at or above the gate,/ctl0.26–0.76(1.3x–3.8x), and in 20 of the 22
f16cells of the threshold-neighbourhoodbandset overtwo independent repetitions,
/ctl0.20–1.14 (up to 5.0x). The GEBP's weight bandwidth ispinned at 20–34 GB/s independent of shape; the GEMV reaches 24–155 GB/s.
claim.
w1.05M_k1024— exactly at the retired threshold, atk = 1024— measured 1.14 then0.89, and five
bf16cells reach 1.00+ with theirf32controls drifted to 0.55–0.94, whichmakes those cells unusable rather than favourable. Both are tabulated in the doc rather than
dropped. The earlier 6.7x figure is likewise not reproducible and has been walked back to the
supported 5.0x everywhere it was cited.
k = 4096qkv/mlp/ctl0.51–0.89; a 136M
lm_head0.86. (The original table reported 1.18/1.37 for the last two; re-runthey are 0.88/0.86.)
bf16tracksf16at 0.41–0.88.k = 1024, 1.05M–4.2M, 32 threads,/ctl0.95–1.49 — is not a shapeany such model emits, and its
k = 2048neighbours are not reproducible run to run (6.3M measured1.31 then 0.70 from the same binary).
Why there was nothing to retune
The GEBP earns its packing by reusing a
KC x NRpanel ofBacross the rows ofA(
MR = 6, NR = 16, KC = 256). Atm == 1,m_panels = 1— every panel is consumed by exactly onemicrokernel pass, so none of the packing is repaid.
Where the cost actually lands: the widen-pack writes
k*nf32and reads it back, but per stripthat is a
bpackof at mostKC * 16 * NR * 4 B= 256 KiB, i.e. L2-resident on this host — sothat traffic is not a DRAM story. What does reach DRAM is line amplification:
pack_b_halfwalks
Bcolumn-strip-major and at the usualpanels_per_strip = 1touchesNR * 2= 32 bytes ofevery 64-byte line, ~2x the GEMV's read traffic. The rest is L2 bandwidth, the widening compute, and
a fork/join over strips that one row of
Acannot amortise. Both axes follow: pack work per unit ofreuse rises with
k, and it can only be overlapped if there are workers to overlap it with — whichis why the arm collapses at 8 threads and merely loses at 32.
A decode-specialised GEBP that skips packing
Bis the GEMV. The finding is structural.Changes
matmul.rs: dropped&& !half_decode_prefers_gebp(..)from the decode arm; deletedhalf_decode_prefers_gebpandhalf_decode_prefers_gebp_when(94 lines, incl. the supersededevidence table).
gemm.rs: behaviourally unchanged — it was right. Adds a test-only route counter and thecomment recording perf(cpu): give bf16 decode a GEMV, and split decode at the measured weight #1381's resolution.
HALF_PREFILL_GEBP_MIN_WEIGHT/half_prefill_gebp_selected. They arethe prefill gate and they still serve a batched
m == 1half MatMul, which the non-batchedGEMV declines and whose only alternative is the row-blocked half GEMM at 16x–21x. Pinned by
a_batched_single_row_half_matmul_still_reaches_the_gebp.PROBE_SHAPE=bandputs 11 rows immediately below, atand above the retired threshold at three different
k, so ak-dependent effect cannot hideinside a
k * ngate again. Pins are now by execution rather than by asking a predicate — thepredicate is what was deleted.
docs/benchmarks/2026-08-21-half-decode-gebp-retired.md, new §14 indocs/performance/CPU_MATMUL_ASSIGNMENT.md, and a superseded banner on the 2026-08-19 bf16 recordwhose second finding this reverses.
Review
Reviewed twice by
claude-opus-4.8, both times with an explicit instruction to argue the opposingcase (that retiring the divert is wrong, or that a narrower gate should have been kept). It did, and
concluded the case does not survive: a
k * n-only gate — the only kind the retired predicate couldexpress — cannot see thread count, and reinstating it to capture the
k = 1024corner net-loses onthe
k = 4096andlm_headshapes that models actually issue. All five defects from the first passare fixed in this head; the second pass found one further Low (a loss range that no longer matched
its own table after rescoping), which is fixed, and prompted the band re-run above.
Mutation test
Re-inserting the divert (
&& geom.k * geom.n < 1_048_576on the decode arm) fails exactly threetests —
no_half_decode_is_diverted_off_the_gemv_on_weight,f16_decode_at_the_retired_weight_gate_keeps_the_gemv,gemm_and_matmul_take_the_same_decode_route— so the pins are not vacuous.Validation (local, on this head merged with
origin/main78058a4)PASS=19 FAIL=1over the full matrix:fmt --all, offline build,-p onnx-runtime-ep-cpu --lib(1552 passed / 0 failed / 21 ignored), clippy
-D warnings,--features mlas, native-beclippy,
aarch64-unknown-linux-gnuclippy,--no-default-features,--all-features, no-MLASdefault-artifact symbol scan,
cross-compile.sh, and all eightscripts/conformance checks(dispatch reachability, dispatch manifest, feature-gate coverage, documented env vars, publish
order, profile table, platform naming, workspace test packages).
The one failure is step H
aarch64-pc-windows-msvc, which is environmental and pre-existing:ort-sys' bindgen cannot findstdlib.hwithout the Windows SDK headers. No code path in this diffis Windows-specific.
Additionally, because the repo's quality lane is wider than that step's package list, I ran it
separately: clippy
-D warnings --all-targetsover the full 31-package quality set — green — and-p onnx-genai-cli— green.Miri is not meaningful coverage for this EP's SIMD and this diff adds no
unsafe— reporting itas no coverage, not as a pass.
Remaining losses
k = 1024/ 32-thread corner gives up 1.1x–1.5x. Recovering it needs a thread-count- andk-dependent predicate; not attempted, because the measurement that would justify it is the leastreproducible one on this host — and §12 already records a per-op result that inverted under the
real pool.
large shapes at ~77% and leaves the small ones latency-bound. Separate line of work.
Gemm's half fast path is stillf16-only:bf16operands returnNoneat the dtype checkand fall into the portable blocked half GEMM, where
MatMulserves them from the same GEMV. Adistinct, separately mergeable gap — documented as open, not addressed here.
Closes #1381.