Repository navigation
perf(cpu): serve f16 Gemm decode with an [N, K] GEMV instead of the blocked half GEMM (22-65x loss to a win) - #1417
Conversation
…GEMM `GemmKernel::execute` declined both f16 fast paths whenever either transpose flag was set, reasoning that both read B in its stored `[K, N]` order and that materialising a transpose would give back what they save. The premise is right and the conclusion does not follow: a transpose is only needed if you insist on reusing the `[K, N]` kernel. A `[N, K]` weight is the *better* GEMV layout -- each output element is one contiguous k-run rather than a strided gather, so the weight streams front to back and the output partitions down to a single row. So `transB = 1` at M = 1 fell into the portable blocked half GEMM, the path the same file already calls the worst dense region measured anywhere in this EP. At K = N = 3584 it measured 32-48 ms against ORT's 0.16-1.5 ms -- 22x to 65x slower -- and did not improve with thread count at all. That is not a corner case: `transB = 1` is what every `nn.Linear` export produces. Add `half_gemv::gemv_f16_nk`: four independent 8-lane FMA chains along k so the kernel is bandwidth-bound rather than FMA-latency-bound, 8 output rows per task. M = 1 transB now reads 0.59 of ORT at 1 and 2 threads -- a win -- and 1.2-1.8x at 4-16, i.e. 36x to 70x faster in absolute terms and up to 133x on per-run minima. Every other M = 1 cell is unchanged within noise. Unlike `gemv_f16_kn` this is not bit-identical to a naive sequential loop: it contracts along the lanes rather than across them, so the sum splits into 32 partial sums. The order is fully specified in the doc comment and pinned by `dot_row_scalar` plus a bit-exactness test at every k around the loop boundaries, and by a test that the answer does not move across pool widths. Two things this does not fix, both recorded in the work list rather than left implied: transB *prefill* is still 3.7-10.7x behind and needs a packed NT half GEMM, and the residual 1.2-1.8x at higher thread counts is the pre-existing ceiling every one of our M = 1 GEMVs hits -- we do not get slower as threads are added, we stop getting faster. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The assignment matrix's seventh column is a p90 everywhere else, and quoting a min-of-minima there with a footnote was the wrong fix for not having measured one. `bench_generic` already prints a p90; `sweep_decode.py` simply was not capturing it. Capture it and tabulate the real thing, so the column means one statistic again and the footnote can go. Re-measuring turned up something the first sweep hid. Post-fix these cells run in 0.3-1.5 ms, which is short enough that this shared host's other tenants move the ratio more than the kernel does. Three sweeps at load 6-13, 12-18 and 9-11 put t=16 anywhere between 1.46 and 2.68 and t=8 between 0.76 and 1.83. A p90 taken at load 12-18 read 8.8 at two threads and 19.0 at sixteen. So both documents now tabulate the quietest, largest sample (9 trials, load 9-11) and print all three sweeps next to it, and the claim is narrowed to what survives: a consistent win at 1-8 threads, and a t=16 ratio not worth two digits on this host. The absolute change is unaffected -- 32-48 ms before, 0.3-1.5 ms after, in every sweep -- and that is what the PR rests on. Prefill was re-measured the same way: 4.04x at one thread and 16.95x at eight, long enough to be reproducible, replacing the earlier 3.7x/10.7x. Review nits: - `gemm_f16_transposed_b_keeps_the_blocked_path`'s doc comment stated the exact rationale this change refutes. Scoped it to M > 1 and pointed at the decode test that now covers M = 1. - Section 5 attributed "the worst dense region measured anywhere in this EP" to section 2, which does not contain that phrase. It is a `gemm.rs` dispatch comment; attribute it there. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Nits addressed in A. B. "section 2 already calls" was a false attribution — fixed. C. The Re-measuring changed the answer, so the tables and the claim changed with it. This is the part worth your attention. Your reproduction (load 12–18) read Three independent sweeps of the same five cells:
The absolute change is untouched by any of this and is what the PR rests on: 32–48 ms before, 0.3–1.5 ms after, in every sweep — 38x to 90x, up to 148x on minima. Three to four orders of magnitude above the noise band. Prefill re-measured the same way: 4.04x at one thread, 16.95x at eight (156 ms vs 39 ms), replacing the earlier 3.7x/10.7x. Those cells run 100–160 ms and are reproducible. On your Finding D ( On your caveat that you did not rebuild Gates re-run: |
The f16 transB work left "gemv_f16_kn's fixed STRIPE = 512 yields only 7 tasks at n = 3584" standing as the explanation for the M = 1 scaling ceiling, and named adaptive stripe width as the next fix. I built it and measured it, and it is not the cause. Making the width adaptive raises the task count from 7 to 8/16/28 at 4/8/16 threads. Two interleaved A/B runs of the same binary pair, each carrying a null control, disagree in sign: the quiet-host run reads +41% at t=16 against a 0.5% noise floor, the loaded run reads +9.2% against a 33.5% floor and -15.1% at t=4. The effect is smaller than this host's between-run variability, so the change is not shipped. The absolute numbers settle it rather than the ratios: native p50 stays at 1.15-1.64 ms in both arms at every thread count. Four times the tasks, same time. That points at the loop nest instead. gemv_f16_kn holds a w-wide f32 accumulator in memory with p outermost, so every FMA is a load-modify-store against L1 -- w overflows the 16 ymm registers at 512 lanes and at 128 alike, which is exactly why narrowing the stripe changed nothing. The fix would be to invert the nest onto a register-resident accumulator tile, as gemv_f16_nk already does for [N, K]. That is a kernel rewrite, it is unproven, and it is recorded as unproven. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Follow-up measurement: the "next step" in this PR was wrong, and I removed itWhile CI was queued I built the follow-up this PR named — adaptive stripe width in What I built. How I measured it. Two binaries (fixed vs adaptive), interleaved per trial through
The two runs disagree in sign. The effect is smaller than this host's between-run variability, so it is not a result and it is not shipped. The absolute numbers are what actually settle it, and they refute the hypothesis rather than merely failing to confirm it: native Why, mechanistically. for (p, &av) in a.iter().enumerate().take(k) {
let row = &b[p * n + j0..p * n + j0 + w];
for (slot, &bits) in acc.iter_mut().zip(row.iter()) {
*slot = half::f16::from_bits(bits).to_f32().mul_add(av, *slot);
}
}Every FMA is a load-modify-store against L1, because Pushed |
- The headline before/after mixed two invocations: 16.0x came from the reproduction run, 7.0x from the results table. ORT's own time differs between those sessions (5.99 vs 5.70 ms), which is exactly the mistake the harness README warns about. Quote the same-invocation pair, 14.0x -> 7.0x, and say where the 15.99x came from. - Disclose the one real behaviour change: on x86 transposed B now converts through F16C rather than scalar `to_f32`. `f16 -> f32` is exact for every finite value, subnormal and infinity, so the two can differ only in NaN payload bits -- and this makes the transposed path agree with the row-major path, which always used F16C. The bit-identity test's sin/cos data never generates a NaN, so note that it does not cover this. - `gen_f16_nt.py` described `M = 1` as a negative control that "routes to half_gemv, not to this GEMM". That is the post-#1417 world; on the base commit `M = 1` still falls into the blocked GEMM, as the benchmark doc itself says. - Tie the `widened` buffer size to the `panel_depth <= KC` invariant it depends on, and note the zero-init is dead but not worth `MaybeUninit`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔴 Benchmark Regression DetectedComparison of criterion micro-benchmarks: PR head vs merge-base, measured on the same runner in the same job (base first → PR second).
Visual flags: Host infoWhat this cannot catch
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1417 +/- ##
==========================================
+ Coverage 80.36% 80.50% +0.14%
==========================================
Files 378 379 +1
Lines 167774 169645 +1871
Branches 167774 169645 +1871
==========================================
+ Hits 134836 136578 +1742
- Misses 28088 28213 +125
- Partials 4850 4854 +4
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Adapts to #1381: gemv_f16_kn -> gemv_half_kn(format, ..), simd_available -> simd_available(format), test helper f16v -> halfv(format, ..). The [N, K] kernel this branch adds stays f16-only, so every adapted call site passes HalfFormat::F16 explicitly. Assignment ledger sections renumbered: main's int4-prefill section takes 5, this branch's Gemm transB section becomes 6. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Carrying a second GEMV kernel made try_half_fast_path large enough that inlining it into execute cost the M = 128 transB prefill 12% -- a path this function declines outright and never touches. Measured, not assumed: three binaries differing only in this, 10 reps each at --runs 9, median of per-run minima, ONNX Runtime not in the process. main (no new kernel, old dispatch) 69.7 ms 1.00x PR + #[inline(never)] 69.7 ms 1.00x PR 78.9 ms 0.88x Two controls say the cause is codegen, not semantics. A binary with the new kernel code but main's dispatch condition measures 1.00x, so it is not the code's presence. A binary whose condition is written 'trans_a || (trans_b && m != 1)' -- which at m = 128 evaluates to the same None without calling the function at all -- measures 0.89x, so it is not the call either. What is left is the compiler laying out execute around the larger inlined body. Also renames the two docs' references to gemv_f16_kn, which #1381 renamed to gemv_half_kn, and fixes a bullet count that said 'two things' above four bullets. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Re-validated on latest
|
| model | t | main | PR | gain | med-of-min gain | parity |
|---|---|---|---|---|---|---|
gemm_f16_m1 |
1 | 31.912 | 0.823 | 38.8x | 40.1x | PASS |
gemm_f16_m1 |
4 | 31.905 | 0.213 | 149.8x | 152.8x | PASS |
gemm_f16_m1 |
8 | 31.812 | 0.139 | 229.7x | 242.5x | PASS |
gemm_f16_m1 |
16 | 31.748 | 0.122 | 260.2x | 277.9x | PASS |
gemm_f16_m128 |
1 | 155.284 | 154.805 | 1.00x | 1.01x | PASS |
gemm_f16_m128 |
4 | 81.683 | 81.841 | 1.00x | 1.00x | PASS |
gemm_f16_m128 |
8 | 70.275 | 69.892 | 1.01x | 1.00x | PASS |
gemm_f16_m128 |
16 | 76.284 | 81.686 | 0.93x | 1.03x | PASS |
matmul_f32_m1 |
1 | 1.390 | 1.364 | 1.02x | 1.01x | PASS |
matmul_f32_m1 |
4 | 1.349 | 1.358 | 0.99x | 0.96x | PASS |
matmul_f32_m1 |
8 | 0.917 | 0.906 | 1.01x | 1.02x | PASS |
matmul_f32_m1 |
16 | 0.630 | 0.641 | 0.98x | 0.89x | PASS |
These are larger than the 37.8x–90.1x in the PR body, which was measured with ORT alternating in the same process. Removing that contention is what moved 90x to 260x. The original figures were conservative, not wrong.
The regression, and why it was not what it looked like
An earlier revision of this sweep showed gemm_f16_m128 — the transB prefill control, a path this PR declines outright — consistently at 0.87–0.90x. It reproduced across four independent sweeps, so it was not host noise.
try_half_fast_path returns Ok(None) for trans_b before touching anything, so there is no semantic difference at m = 128. Three extra binaries settled what it actually was:
| binary | what it is | m = 128 vs main |
|---|---|---|
layout |
the PR's 300 lines of new kernel, main's dispatch condition | 1.00x |
v2 |
PR, condition written trans_a || (trans_b && m != 1) — at m = 128 this is literally None without calling the function |
0.89x |
noinline |
PR + #[inline(never)] on try_half_fast_path |
1.00x |
layout rules out the new code's mere presence. v2 rules out the call, because at m = 128 it is runtime-identical to main and still regressed. What is left is the compiler laying execute out around a larger inlined body — carrying a second kernel pushed try_half_fast_path past the point where inlining it was free.
So the fix is #[inline(never)] on a once-per-execute, Option-returning dispatch probe. It has one call site and is not in any loop; the per-row work is in gemv_f16_nk, a separate #[target_feature] function that would not be inlined regardless. 10 reps at --runs 9, median of per-run minima: main 69.7 ms, PR + inline(never) 69.7 ms, PR 78.9 ms.
The measurement is in the doc comment, so the attribute cannot be deleted as decoration.
Local gate matrix — 19/20
fmt · offline build · ep-cpu tests · clippy -D warnings · --features mlas tests · clippy for a native big-endian target · clippy aarch64-unknown-linux-gnu · --no-default-features · --all-features · no-MLAS-symbol artifact scan · cross-compile script · 8 repo guard scripts.
cargo test --release -p onnx-runtime-ep-cpu --lib → 1543 passed, 0 failed.
The one failure is aarch64-pc-windows-msvc, where onnx-genai-ort-sys bindgen needs the Windows SDK ('stdlib.h' file not found). Reproduced on unmodified main — pre-existing. Windows ARM64 is therefore checked only as far as this Linux host can check it; that is the exact scope, not a pass.
This validation pass also turned up two gates already red on main, both now fixed and merged: verify_documented_env_vars (#1536, a knob left in the docs after #1527 deleted the kernel that read it) and cargo fmt (#1546, four files across two crates). Neither was mine. strict_required_status_checks_policy is false here, so a check that passed against an older base stays green against a newer one — which is how both survived.
Review
Opus review: APPROVE_WITH_COMMENTS, and its one BLOCKING finding was that the #[inline(never)] fix was still uncommitted — correct, and now committed in 287f0feea. It independently confirmed there is no semantic difference at m = 128 (the m == 1 short-circuit means b.is_contiguous(), b.validate() and self.prepack.dense() are never evaluated, so nothing is validated, allocated, or cached on a path main never took), that the v2 experiment is decisive, and that #[inline(never)] costs nothing at M = 1. It also traced gemv_f16_nk's k % 32 / k % 8 boundaries at k=33/39/40/48/64 against the five documented summation steps, confirmed every SIMD load is in bounds, and confirmed the order is genuinely independent of thread count and ROW_STRIPE. Non-blocking findings — two docs still naming the renamed gemv_f16_kn, and a "two things" introducing four bullets — are fixed in the same commit.
Still open, unchanged by this PR
transB prefill is still broken and this does not touch it: M = 128 measures 4.0x at 1 thread and 17.0x at 8 against ORT. The GEMV correctly declines M > 1, so prefill still takes the blocked path; closing it needs a packed NT half GEMM, the f16 analogue of #1176's transposed-B SGEMM. The residual loss at high thread counts is the section 1 ceiling, not this kernel — and task granularity was measured and rejected as the cause, which is recorded rather than quietly dropped.
What
An f16
GemmatM = 1withtransB = 1measured 32-48 ms against ONNX Runtime's 0.16-1.5 ms atK = N = 3584— 22x to 65x slower — and did not improve with thread count at all. It now runs in 0.3-1.5 ms: a 38x to 90x absolute speedup, up to 148x on per-run minima, and a win against ORT at 1 through 8 threads.transB = 1is not a corner case. It is the layout everynn.Linearexport produces, so this is what a QKV, an output projection and an MLP gate look like whenever a model is exported throughGemmrather thanMatMul.Why it was slow
GemmKernel::executedisqualified both f16 fast paths if either transpose flag was set:with the reasoning, in a comment directly above it, that both "read B in its stored
[K, N]order, and materialising a transpose first would give back what they save".The premise is right and the conclusion does not follow. You only need a transpose if you insist on reusing the
[K, N]kernel. A[N, K]weight does not need one — it is the better GEMV layout:[K, N](transB = 0)[N, K](transB = 1)kk-runWf32 live across the wholeksweepSo the fix is a second kernel, not a transpose. The path it replaced was the portable blocked half GEMM, which packs both operands into
MR x NRpanels — atM = 1there is no reuse to amortise that against. The same file already recorded exactly this failure for the untransposed case ("the worst dense region measured anywhere in this EP ... 10.07 ms at 1 thread, 10.26 ms at 8"). That case was fixed; the transposed one kept the note and the behaviour.Measurements
K = N = 3584(Qwen3-8B hidden), ORT and native alternating in one process so every ratio is paired. Host AMD EPYC 9V74, 32 vCPU / 16 cores, AVX2+FMA+F16C only, ORT 1.27.0, native build (nomlas).Tabulated run is the quietest and largest of three sweeps: 9 trials, load 9-11.
ours/ORTOn per-run minima: 32.203 → 0.814, 31.465 → 0.461, 31.628 → 0.294, 36.076 → 0.243, 35.394 → 0.302 ms (40x to 148x).
The before column does not move with thread count (36.6 at 1, 36.2 at 8, 48.1 at 16). The blocked path never scaled on a single-row problem; the ratio degraded purely because ORT did scale.
The ratio is now the noisy part, and the claim is narrowed accordingly
Post-fix these cells run in 0.3-1.5 ms — short enough that this shared host's other tenants move the ratio more than the kernel does. Three independent sweeps of the same five cells:
t = 16swings 1.46-2.68 andt = 8swings 0.76-1.83, so neither is worth two digits. The defensible statement is a consistent win at 1-8 threads and at = 16ratio unresolvable on this host. A p90 taken at load 12-18 read 8.8 at two threads and 19.0 at sixteen — evidence that the tail statistic measures the other tenants at these durations, not this kernel.What is not in doubt is the absolute change, three to four orders of magnitude above that noise: 32-48 ms → 0.3-1.5 ms in every sweep.
Controls — every other
M = 1cell, same binary, same session, unchanged within noise:MatMulf16MatMulf32MatMulNBits4-bitMatMulNBits8-bitEnd-to-end parity against ORT:
max_rel = 6.6e-4, PASS.Numerics — the one deliberate departure
gemv_f16_knis bit-identical to a naive sequential loop.gemv_f16_nkis not, and I would rather say so than bury it: it contracts along the lanes rather than across them, so the sum splits into 32 partial sums. Slightly more accurate, but different.The order is fully specified in the function's doc comment and pinned two ways:
dot_row_scalaris an executable statement of that order, andnk_simd_and_scalar_rows_agree_bit_for_bitasserts the vector path matches it exactly at everykaround the 32- and 8-element loop boundaries (1, 7, 8, 9, 31, 32, 33, 39, 40, 64, 65, 96, 127, 128, 3584).nk_is_independent_of_the_thread_countasserts the answer does not move across pools of 1, 2, 3 and 8 workers —ROW_STRIPEpartitions the output, never the contraction.Routing is asserted rather than inferred, via a thread-local
NK_GEMV_CALLScounter: values alone cannot tell which route ran, because the blocked fallback computes the same thing, only ~40x slower. Thread-local rather than global on purpose — the count is taken at entry, before any rayon dispatch, so it is exact without every caller in the crate having to agree on a lock.What this does not fix (recorded in the work list, not left implied)
transBf16 prefill is still badly broken. AtM = 128it measures 4.04x at 1 thread and 16.95x at 8 (156 ms vs 39 ms). Those cells run 100-160 ms and are reproducible, unlike the decode ratios. The GEMV is a decode kernel and correctly declinesM > 1—half_prefill_gemm_does_not_take_the_nk_gemvasserts exactly that — so prefill still takes the blocked path. Closing it needs a packed NT half GEMM, the f16 analogue of Native transposed-B SGEMM: eliminate the Kn dequant from default-build MatMulNBits prefill (#959, #1091) #1176's transposed-B SGEMM. Separate work, not a comment change.M = 1GEMVs flattens at ~0.7-1.1 ms past 4-8 threads while ORT keeps scaling (4-bit 3.60 → 0.79 ms over 1..16 threads vs ORT 1.59 → 0.13; f32 1.73 → 0.94 vs 1.76 → 0.17). Worth being precise about what that rules out: we do not get slower as threads are added, so it is not fork/join overhead — we stop getting faster. It is also not task granularity — I built that fix and measured it away.gemv_f16_kn's fixedSTRIPE = 512does yield only 7 tasks atn = 3584, so it was the obvious suspect. Making the width adaptive (n / (2 * threads), rounded up to a multiple of 32) raises that to 8/16/28 tasks at 4/8/16 threads and moves nothing. Two interleaved A/B runs of the same binary pair, each with a null control, disagree in sign:The effect is smaller than this host's between-run variability, so the change is not in this PR. The absolute numbers settle it rather than the ratios: native
p50stayed at 1.15–1.64 ms in both arms at every thread count — 4x the tasks, same time. Commit667e4c0a6records the rejection in the work list so the lead is not retried.The suspect it leaves standing is the loop nest:
gemv_f16_knholds aw-widef32accumulator in memory withpoutermost, so every FMA is a load-modify-store against L1 —woverflows the 16 availableymmregisters at 512 lanes and at 128 alike, which is exactly why narrowing the stripe changed nothing. Inverting the nest onto a register-resident accumulator tile (whatgemv_f16_nkalready does for[N, K]) is the next thing I intend to take. It is a kernel rewrite and it is unproven.MatMulNBits"win" in the control table is mostly ORT being slow (30.6 ms at 1 thread), not us being fast.trans_astill declines. AtM = 1transposing a single-row A is meaningless.Gates
cargo fmt --all, 1455 ep-cpu lib tests, clippy--all-targets -D warningson x86_64,verify_documented_env_vars.py,workspace_test_packages.py verify.aarch64 clippy fails with three "never used" errors — I reproduced them on a pristine detached worktree of
origin/mainbefore assuming they were mine, and filed #1415. This branch adds no new ones.Files
crates/onnx-runtime-ep-cpu/src/kernels/half_gemv.rs—gemv_f16_nk,dot_row_simd,dot_row_scalar,ROW_STRIPE, the test counter, 6 new tests.crates/onnx-runtime-ep-cpu/src/kernels/gemm.rs—try_half_fast_pathtakestrans_band picks a kernel;executeno longer disqualifies on it; 2 new routing tests.docs/benchmarks/2026-08-19-f16-gemm-transb-decode.md— full record.docs/performance/CPU_MATMUL_ASSIGNMENT.md— 7 newtransBrows (they were absent entirely) + root-cause section 5.scripts/ort_ab/gen_decode.py,scripts/ort_ab/sweep_decode.py— the harness;gen_gemm.pyhad no f16MatMulorGemmat all.