Repository navigation
perf(cpu): symmetric int4 MatMulNBits takes the zero-copy borrowed decode path (#979) - #989
Conversation
…code path (#979) The CPU `MatMulNBits` decode dispatch has a zero-copy direct-int4 kernel that reads packed weights in place. It was gated on the *presence of a `zero_points` input*, so symmetric int4 models (no such input) fell all the way through to the resident f32 dequantisation cache (`weight_nk`) and paid ~8x their file size in RAM. The symmetric case is mathematically simpler — its zero point is the implicit midpoint 8 — yet it got the expensive fallback. Gate the borrowed path on *symmetry explicitly* instead. `borrowed_affine_int4_matmul` and its aarch64 NEON-dot sibling now accept `Option<&[u8]>` zero points and use the implicit midpoint (via the existing `NBitsLayout::zero_point`, which already returns `1 << (bits-1)` for `None`) when absent. A new `borrow_optional_int4_zero_points` helper returns `Some(None)` for symmetric, `Some(Some(zp))` for a borrowable asymmetric uint8 input, and `None` only when a present zero_points tensor cannot be borrowed in place (fall through unchanged). Asymmetric arithmetic is bit-identical (the only change is `Some(zp_row)` -> `zp_row`). Adding a zero point of 8 to symmetric weights is a mathematical identity, so symmetric output is unchanged. Measured, CPU-only native build, qwen05b-q4 (symmetric), 64-token greedy decode, same before/after harness (PeakWorkingSet64 sampled to exit): before: 2,755 MB after: 580 MB (asymmetric reference on same box: 587 MB) Output byte-identical before vs after, and identical to the explicit-zero-points oracle (qwen05b-symzp). Asymmetric qwen2.5-0.5b-q4_0-mobius unchanged: 587 MB before and after, byte-identical. New regression test `matmulnbits_symmetric_m1_borrows_instead_of_building_f32_cache` positively asserts a constant symmetric int4 kernel never populates `weight_nk` or any prepack cache. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #989 +/- ##
===========================================
+ Coverage 79.70% 83.56% +3.86%
===========================================
Files 356 12 -344
Lines 150213 5270 -144943
Branches 150213 5270 -144943
===========================================
- Hits 119721 4404 -115317
+ Misses 25833 682 -25151
+ Partials 4659 184 -4475
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
…ken (#979) Adds cfg(test)-only path counters (BORROWED_INT4_SYMMETRIC/ASYMMETRIC_TEST_CALLS) incremented at the borrowed int4 decode branch, split by symmetry. The symmetric regression test now asserts the symmetric counter strictly increases across its single execute -- positive proof the borrowed zero-copy branch is the path taken -- in addition to the existing negative proofs (weight_nk uninitialised, no prepack cache). The asymmetric borrowed test asserts the asymmetric counter to guard the split. Counters are cfg(test) only, so the release binary and its measured footprint are unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Thanks — both measurements land, and they tighten this PR nicely. Responses in your order. 1. CUDA is untouchedConfirmed on my side too: this PR changes only 2. Two-backend oracleAgreed, and I lean on exactly that: my after-binary generation for symmetric Zero-byte advantage. Worth restating your point: the model-side workaround costs the added tensors (+7.7 MB, ~2%). This kernel fix needs no tensor at all — 3. Acceptance #4 — positive proof the branch is taken (leading with this)You're right that a footprint drop alone is also consistent with a failed load or an unrelated path. I've made the proof positive and non-circular at two levels:
The test exercises ConditionsAll figures are |
🔴 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
|
Closes #979.
What
The CPU
MatMulNBitsdecode dispatch has a zero-copy direct-int4 kernel(
borrowed_affine_int4_matmul) that reads packed weights in place. It was gatedon the presence of a
zero_pointsinput:So symmetric int4 models (3 inputs, no
zero_points) fell past it — and pastthe
accuracy_level == 4packed path — into them == 1branch that builds andretains
weight_nk: OnceLock<Vec<f32>>, i.e. ~8x the file size in residentRAM. The condition expressed the wrong intent; symmetric int4 is mathematically
simpler (its zero point is the implicit midpoint 8) yet got the expensive path.
The change
borrowed_affine_int4_matmuland its#[cfg(target_arch = "aarch64")]NEON-dot sibling
borrowed_affine_int4_matmul_m1_neon_dotnow takeOption<&[u8]>zero points and use the implicit midpoint when absent, via theexisting
NBitsLayout::zero_point(which already returns1 << (bits-1)forNone). The NEON correction(8 - zero_point) * activation_sumis0forsymmetric — a no-op — so that fast path works unchanged for both cases.
borrow_optional_int4_zero_pointshelper:Some(None)for symmetric,Some(Some(zp))for a borrowable asymmetric uint8 input,Noneonly when apresent zero_points tensor can't be borrowed in place (fall through, unchanged).
Asymmetric arithmetic is bit-identical (the only diff is
Some(zp_row)→zp_row). Symmetric adds an implicit zero point of 8, a mathematical identity.Evidence
All CPU-only native build
(
cargo build --release -p onnx-genai-cli --no-default-features --features native-backend),--backend native, 64-token greedy decode, long prompt. Footprint =PeakWorkingSet64sampled to process exit on the same before/after harness(contention-immune). "before" = branch base
532ef6bc; "after" = this branch.1. Footprint (symmetric,
qwen05b-q4, 3 inputs)For reference, the asymmetric
qwen2.5-0.5b-q4_0-mobiuson the same box measures587 MB — i.e. symmetric now lands on the same borrowed path with the same
footprint as the model that already used it (580 vs 587 MB), down from 4.7x
worse. (My absolute after-number is higher than the 452 MB in #979 because the
native backend also stands up an ORT environment and the box is contended; the
contention-immune signal is the before→after drop on one harness.)
2. Numerics, symmetric — byte-identical before vs after. SHA-256 of the
64-token generation matched exactly (before == after == explicit-zero-points
oracle
qwen05b-symzp).3. Numerics, asymmetric — not regressed.
qwen2.5-0.5b-q4_0-mobius:byte-identical before vs after, footprint unchanged (587 MB → 587 MB).
4. Positive proof the path changed (not inferred from a footprint drop). New
unit test
matmulnbits_symmetric_m1_borrows_instead_of_building_f32_cache:executes a constant symmetric int4 kernel (the case that pre-#979 populated
weight_nk) and assertsweight_nk.get().is_none()and!prepack_cache_populated(...), alongside a numeric match. The pre-existing..._reuses_weight_for_new_activationstest — which asserted symmetric constantB builds and reuses a cache — is updated to assert it now borrows in place
(no cache), which is why its behavior flipped.
Gates (verbatim)
cargo fmt -p onnx-runtime-ep-cpu— clean.cargo clippy -p onnx-runtime-ep-cpu -- -D warnings—exit 0, no warnings.cargo test -p onnx-runtime-ep-cpu:test result: ok. 1066 passed; 0 failed; 10 ignored; 0 measured; 0 filtered outtest result: ok. 3 passed; 0 failedtest result: ok. 10 passed; 0 failedtest result: ok. 1 passed; 0 failedtest result: ok. 1 passed; 0 failedtest result: ok. 5 passed; 0 failedtest result: ok. 6 passed; 0 failedtest result: ok. 0 passed; 0 failed; 1 ignoredcargo test -p onnx-genai-engine --lib:test result: ok. 382 passed; 0 failed; 1 ignored; 0 measured; 0 filtered outaarch64 / NEON note
The NEON-dot path (
borrowed_affine_int4_matmul_m1_neon_dot) mattersdisproportionately — the Windows-ARM/unified-memory users hit hardest by the 8x.
Its math is unchanged for symmetric (midpoint correction is 0). This x86 box
can't execute it, but I validated it compiles for the affected target:
cargo check -p onnx-runtime-ep-cpu --target aarch64-pc-windows-msvc→exit 0.Runtime verification on aarch64 hardware is still pending.