Skip to content

perf(cpu): retire the m == 1 half-decode handover to the fused GEBP (#1381) - #1613

Merged
justinchuby merged 3 commits into
mainfrom
squad/roy-gemm-f16-decode-gebp
Aug 20, 2026
Merged

justinchuby merged 3 commits into
mainfrom
squad/roy-gemm-f16-decode-gebp

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 20, 2026 •

Copy link
Copy Markdown
Owner

The finding: the in-tree gate was the bug, not the missing one

MatMul sent every m == 1 f16/bf16 decode with k * n >= HALF_PREFILL_GEBP_MIN_WEIGHT
(1 048 576) to the fused widen-pack GEBP. Gemm never did — its m == 1 f16 path stayed on the
decode GEMV at every weight. That divergence is #1381, and the obvious reading was that Gemm was
missing 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 MatMul that was carrying a bad gate.

This PR retires the handover. #1381 closes with MatMul adopting Gemm's route rather than
the reverse.

Evidence

All numbers through crates/onnx-runtime-ep-cpu/benches/half_decode_gemv_ab.rs, which drives
ExecutionProvider::get_kernel + Kernel::execute (dispatch and output narrowing included). Arms
are selected by ONNX_GENAI_CPU_MM_HALF_GEMV/_GEBP, which are process-wide OnceLocks, so one
arm per process. Every ratio is divided by an f32 control measured in the same two processes,
because this host is heavily shared.

The original gate's evidence was 32 threads and k <= 2048 only.

  • 8 threads — a loss at every full-set shape at or above the gate, /ctl 0.26–0.76
    (1.3x–3.8x), and in 20 of the 22 f16 cells of the threshold-neighbourhood band set over
    two independent repetitions, /ctl 0.20–1.14 (up to 5.0x). The GEBP's weight bandwidth is
    pinned at 20–34 GB/s independent of shape; the GEMV reaches 24–155 GB/s.
    • Reported against myself: the band re-run does not reproduce an earlier "no row above 1.00"
      claim. w1.05M_k1024 — exactly at the retired threshold, at k = 1024 — measured 1.14 then
      0.89, and five bf16 cells reach 1.00+ with their f32 controls drifted to 0.55–0.94, which
      makes 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.
  • 32 threads — still a loss at every shape a 7B-class decode issues. k = 4096 qkv/mlp /ctl
    0.51–0.89; a 136M lm_head 0.86. (The original table reported 1.18/1.37 for the last two; re-run
    they are 0.88/0.86.) bf16 tracks f16 at 0.41–0.88.
  • The one corner it wins — k = 1024, 1.05M–4.2M, 32 threads, /ctl 0.95–1.49 — is not a shape
    any such model emits, and its k = 2048 neighbours are not reproducible run to run (6.3M measured
    1.31 then 0.70 from the same binary).

Why there was nothing to retune

The GEBP earns its packing by reusing a KC x NR panel of B across the rows of A
(MR = 6, NR = 16, KC = 256). At m == 1, m_panels = 1 — every panel is consumed by exactly one
microkernel pass, so none of the packing is repaid.

Where the cost actually lands: the widen-pack writes k*n f32 and reads it back, but per strip
that is a bpack of at most KC * 16 * NR * 4 B = 256 KiB, i.e. L2-resident on this host — so
that traffic is not a DRAM story. What does reach DRAM is line amplification: pack_b_half
walks B column-strip-major and at the usual panels_per_strip = 1 touches NR * 2 = 32 bytes of
every 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 A cannot amortise. Both axes follow: pack work per unit of
reuse rises with k, and it can only be overlapped if there are workers to overlap it with — which
is why the arm collapses at 8 threads and merely loses at 32.

A decode-specialised GEBP that skips packing B is the GEMV. The finding is structural.

Changes

  • matmul.rs: dropped && !half_decode_prefers_gebp(..) from the decode arm; deleted
    half_decode_prefers_gebp and half_decode_prefers_gebp_when (94 lines, incl. the superseded
    evidence table).
  • gemm.rs: behaviourally unchanged — it was right. Adds a test-only route counter and the
    comment recording perf(cpu): give bf16 decode a GEMV, and split decode at the measured weight #1381's resolution.
  • Deliberately untouched: HALF_PREFILL_GEBP_MIN_WEIGHT / half_prefill_gebp_selected. They are
    the prefill gate and they still serve a batched m == 1 half MatMul, which the non-batched
    GEMV 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.
  • Coverage repaired in the same change: new PROBE_SHAPE=band puts 11 rows immediately below, at
    and above the retired threshold at three different k, so a k-dependent effect cannot hide
    inside a k * n gate again. Pins are now by execution rather than by asking a predicate — the
    predicate is what was deleted.
  • Docs: new docs/benchmarks/2026-08-21-half-decode-gebp-retired.md, new §14 in
    docs/performance/CPU_MATMUL_ASSIGNMENT.md, and a superseded banner on the 2026-08-19 bf16 record
    whose second finding this reverses.

Review

Reviewed twice by claude-opus-4.8, both times with an explicit instruction to argue the opposing
case (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 could
express — cannot see thread count, and reinstating it to capture the k = 1024 corner net-loses on
the k = 4096 and lm_head shapes that models actually issue. All five defects from the first pass
are 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_576 on the decode arm) fails exactly three
tests — 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/main 78058a4)

PASS=19 FAIL=1 over 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-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
(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 find stdlib.h without the Windows SDK headers. No code path in this diff
is 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-targets over the full 31-package quality set — green — and
-p onnx-genai-cli — green.

Out-of-scope observation: -p onnx-runtime-ep-cuda --features cuda clippy is red on main
today
(unusual_byte_groupings in device_argmax.rs/matmul_nbits.rs, type_complexity in
gqa_shared_prefix_parity_gpu.rs, a manual div_ceil). Pre-existing 1.98.0 fallout in the lane
#1609 did not cover; this diff touches no CUDA file.

Miri is not meaningful coverage for this EP's SIMD and this diff adds no unsafe — reporting it
as no coverage, not as a pass.

Remaining losses

  • The k = 1024 / 32-thread corner gives up 1.1x–1.5x. Recovering it needs a thread-count- and
    k-dependent predicate; not attempted, because the measurement that would justify it is the least
    reproducible one on this host — and §12 already records a per-op result that inverted under the
    real pool.
  • The GEMV itself is not at the roofline: 24–155 GB/s against a 75.8 GB/s DRAM ceiling puts the
    large shapes at ~77% and leaves the small ones latency-bound. Separate line of work.
  • Gemm's half fast path is still f16-only: bf16 operands return None at the dtype check
    and fall into the portable blocked half GEMM, where MatMul serves them from the same GEMV. A
    distinct, separately mergeable gap — documented as open, not addressed here.

Closes #1381.

justinchuby and others added 2 commits August 20, 2026 20:57
…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

codecov Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 68.01802% with 71 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.88%. Comparing base (78058a4) to head (0a38d2b).
⚠️ Report is 2 commits behind head on main.

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

Impacted file tree graph

@@            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     
Flag Coverage Δ
cli-ort-linux 82.60% <ø> (ø)
cli-ort-windows 82.19% <ø> (+0.09%) ⬆️
mlas ?
offline 80.82% <68.01%> (+0.01%) ⬆️

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

Files with missing lines Coverage Δ
crates/onnx-runtime-ep-cpu/src/kernels/matmul.rs 82.04% <100.00%> (-0.05%) ⬇️
crates/onnx-runtime-ep-cpu/src/kernels/gemm.rs 83.52% <59.65%> (-5.92%) ⬇️

... and 14 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.

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>
@justinchuby
justinchuby marked this pull request as ready for review August 20, 2026 21:32
@justinchuby

Copy link
Copy Markdown
Owner Author

Local validation on this head (0a38d2bbc, merged with origin/main 78058a4):

PASS: A fmt-all              PASS: I ep-cpu-no-default
PASS: B build-offline        PASS: J ep-cpu-all-features
PASS: C test-ep-cpu          PASS: K no-mlas-artifacts
PASS: D clippy-offline       PASS: L cross-compile-sh
PASS: E test-mlas-feature    PASS: S x8 (dispatch reachability/manifest,
PASS: F clippy-native-be           feature-gate coverage, documented env vars,
PASS: G clippy-aarch64-linux       publish order, profile table, platform
FAIL: H check-win-arm64            naming, workspace test packages)
PASS=19 FAIL=1

C is 1552 passed / 0 failed / 21 ignored. H (aarch64-pc-windows-msvc) is the standing environmental failure — ort-sys' bindgen cannot find stdlib.h without the Windows SDK; nothing in this diff is Windows-specific. Additionally green outside that script: clippy -D warnings --all-targets over the full 31-package quality lane, and -p onnx-genai-cli.

Mutation check re-run on this head: re-inserting && geom.k * geom.n < 1_048_576 on the decode arm fails exactly no_half_decode_is_diverted_off_the_gemv_on_weight, f16_decode_at_the_retired_weight_gate_keeps_the_gemv and gemm_and_matmul_take_the_same_decode_route.

Not covered: Miri is a no-op gate for this EP's SIMD and this diff adds no unsafe — reporting that as no coverage, not as a pass. Out of scope but observed: -p onnx-runtime-ep-cuda --features cuda clippy is red on main today (1.98 fallout #1609 did not cover); this diff touches no CUDA file.

@justinchuby
justinchuby merged commit 8185585 into main Aug 20, 2026
13 of 17 checks passed
@justinchuby
justinchuby deleted the squad/roy-gemm-f16-decode-gebp branch August 20, 2026 21:32
justinchuby added a commit that referenced this pull request Aug 20, 2026
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
justinchuby added a commit that referenced this pull request Aug 20, 2026
## 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>
justinchuby added a commit that referenced this pull request Aug 20, 2026
`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>
justinchuby added a commit that referenced this pull request Aug 20, 2026
…#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>
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