Skip to content

Share one MLAS SQNBit packed buffer per weight (#1056) - #1098

Merged
justinchuby merged 3 commits into
mainfrom
squad/mlas-packed-dedup
Aug 17, 2026
Merged

justinchuby merged 3 commits into
mainfrom
squad/mlas-packed-dedup

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

Share one MLAS SQNBit packed buffer per weight (#1056)

Refs #1027 (MLAS SQNBit route), #1051 (packed-buffer accounting), #1056 (this dedup).

The problem

MatMulNBits int4 accuracy_level=0 nodes route to MLAS SQNBit CompFp32 (#1027). The
executor's KernelCache is shape-keyed, so each node compiles two kernel instances —
prefill (m > 1) and decode (m == 1) — and before this change each instance packed its
own full copy of the same constant weight
. The resident packed footprint was therefore 2x
the single-copy cost, held for the whole session.

The fix

A process-global, weight-identity-keyed store (MlasPackedCaches) keyed on
(address, N, K, bits, block_size, has_zero_points, compute_type). The first kernel instance to
reach a weight packs it once; the sibling instance takes the same Arc. The session now holds
one packed copy per weight.

Acceptance criteria

1. Predicted bytes == actual bytes, ratio 1.00. resident_f32_cache_bytes (plan prediction) vs
live_total (profiler actual SQNBIT_PACKED_LIVE_BYTES), measured on-model:

model / arm predicted (bytes) actual live (bytes) ratio
qwen05b after, admitted, multi-token 316,443,904 316,443,904 1.00
qwen05b before, admitted, multi-token 632,887,808 632,887,808 1.00
qwen14b after, admitted (18GiB ceiling) 8,962,744,320 8,962,744,320 1.00

The dedup test (int4_acc0_mlas_packed_accounting_equals_actual_allocated) that ties the plan's
prediction to the profiler's actual bytes stays green and now also asserts the shared Arc.

2. Pack count halves. ONNX_GENAI_PROFILE_MM=1, [mm_prepack] calls= on qwen05b-symzp
(169 weight boundaries):

run before (origin/main) after (this branch)
1-token (single activation shape) 169 169
multi-token (prefill + decode shapes) 338 169

Before, the multi-token run packed twice as many buffers as the 1-token run; after, they pack the
same count. (The 1-token run already packed 169 before, but the pre-dedup predictor still
accounted 2x = 632,887,808 for it — over-report, the safe direction; after, both the pack count
and the accounting are single-copy.)

3. Peak RSS + accounted, with ratios, both models, before/after. Every number measured on this
host (Windows, 68,535,443,456 B RAM, CPU-only, AVX2/FMA/F16C/AVX-VNNI). Peak RSS = polled
PeakWorkingSet64 while running; CPU time = TotalProcessorTime.

qwen05b-symzp (weights 366,846,066 B), multi-token autoregressive run:

arm packs accounted live peak RSS admitted
route OFF (QNBIT=0) – 0 – 485.9 MB –
route ON before 338 632,887,808 632,887,808 1135.9 MB yes
route ON after 169 316,443,904 316,443,904 818.5 MB yes

The packed accounting halved (632,887,808 -> 316,443,904) and peak RSS dropped 317.4 MB — almost
exactly the one deduplicated packed buffer (316,443,904 B).

qwen14b-symzp (weights 8,549,241,669 B). Default residency ceiling = 0.25 x RAM =
17,133,860,864 B. Admission tests the expanded footprint (on-disk weights + packed cache):

arm accounted (predicted) live expanded footprint peak RSS verdict
before, default ceiling 17,925,488,640 0 (declined) 26,474,730,309 8643.4 MB declined
after, default ceiling 8,962,744,320 0 (declined) 17,511,985,989 8669.3 MB declined
after, ceiling 18 GiB 8,962,744,320 8,962,744,320 17,511,985,989 17,606.9 MB admitted (ratio 1.00)

Does the 14B flip declined -> admitted at the default ceiling? No — but only just, and the reason
is precise.
The dedup halved the predicted packed cache (17,925,488,640 -> 8,962,744,320) and
shrank the expanded footprint from 26,474,730,309 to 17,511,985,989. But admission compares that
expanded footprint (weights + cache), not the cache alone, against the 0.25 x RAM ceiling
of 17,133,860,864 B. After the dedup the expanded footprint is 17,511,985,989 B — still 378,125,125 B
(2.2%) over
the default ceiling, so it stays declined and runs the borrowed zero-copy path
(peak ~8.6 GB, unchanged from before).

What the dedup does change is the admission threshold: admitting the 14B previously required a
ceiling >= 26,474,730,309 B = 0.386 of RAM; it now requires >= 17,511,985,989 B = 0.256 of RAM
— i.e. barely above the 0.25 default. --host-ram-limit 18GiB (0.263) now admits it at peak
17,606.9 MB, comfortably inside 68.5 GB. So the dedup moves the 14B from "unreachable without
allowing a 26.5 GB expansion" to "one notch above the default," but does not cross the 0.25 line
on its own on this box. (The original prediction that it would flip rested on comparing the ~8.4 GB
cache to the ceiling; the gate actually tests the 17.5 GB expanded footprint.)

4. Byte-identical generated text (SHA-256 of generated text), greedy decode:

prompt before after (declined) after (admitted)
qwen05b, "…relativity…" 48 tok EF7CA14F… EF7CA14F… –
qwen05b, raw "a" 8 tok FD4972FF… FD4972FF… –
qwen14b, "…relativity…" 16 tok EB88829D… EB88829D… EB88829D…

Identical across before/after and across admitted/declined (the borrowed zero-copy path and the MLAS
packed path produce the same tokens). qwen05b route ON and route OFF also match (EF7CA14F…).

5. No new process-global mutable state that tests mutate. Production writes only the
LazyLock global; tests use a cfg(test) thread-local store restored automatically by each
libtest thread ending. No env/RAII toggles were added to production.

Gates

  • cargo test -p onnx-runtime-ep-cpu --features mlas --lib matmul_nbits — 110 passed, 0 failed, 6 ignored.
  • cargo test -p onnx-runtime-ep-cpu --lib — five consecutive runs: 1269/0/11, 1269/0/11,
    1269/0/11, 1269/0/11, 1269/0/11 (passed / failed / ignored).
  • cargo clippy -p onnx-runtime-ep-cpu --lib -- -D warnings — clean (both default and --features mlas).

Cases not deduplicated

None on the constant-weight route. Every MLAS SQNBit route (weight_prepacked, static shards, and
the NO_SHARD A/B) goes through the shared store. The only non-shared fallback is a weight with no
stable contiguous host address to key on — which never occurs on the constant-weight (can_prepack)
route this touches, since the initializer is a contiguous mmap slice. A non-constant weight rebuilds
a transient pack per call and retains nothing, so there is nothing to share.


Note: Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624.

The executor's kernel cache is shape-keyed, so an autoregressive decoder
compiles two MatMulNBits kernel instances per node -- one for prefill
(m > 1) and one for decode (m == 1). Before this change each instance
packed its own full copy of the same constant weight into its own
mlas_shards OnceLock, so the resident MLAS SQNBit packed footprint was 2x
the single-copy cost (measured on qwen05b-symzp: 169 packs on a 1-token
run, 338 on a multi-token run).

Introduce a process-global, weight-identity-keyed store (MlasPackedCaches)
keyed on (address, N, K, bits, block_size, has_zero_points, compute_type).
The first kernel instance to reach a weight packs it once; the sibling
instance takes the same Arc. The session now holds one packed copy per
weight. clear_mlas_packed_caches() runs on Executor drop -- the same
lifetime boundary as weight_transpose::clear_all -- to close the
recycled-address hazard (#845/#1079).

Accounting is updated in lockstep: MLAS_PACKED_DECODE_INSTANTIATIONS goes
from 2 to 1, so resident_dequant_f32_cache_bytes (the memory plan's
prediction) equals mlas_sqnbit_packed_live_bytes (the actual allocation),
ratio 1.00. The accounting test now also asserts pointer identity of the
shared Arc across the prefill and decode instances.

Test isolation uses a cfg(test) thread-local store so each libtest thread
gets a private cache (no cross-test recycled-address contamination) while
a single test's prefill+decode still share; production reads only the
global store (no new global mutable state tests mutate, #983/#1033/#1079).

Refs #1027, #1051, #1056.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
@justinchuby

Copy link
Copy Markdown
Owner Author

Closing per owner direction: we are not bundling MLAS by default and will instead absorb its optimizations into our native kernels. Deduplicating MLAS's packed buffer would be optimizing the wrong thing. Superseded by an investigation into where MLAS SQNBit's 1.25x decode advantage comes from and how much is reachable without a resident copy.

@codecov

codecov Bot commented Aug 17, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.91%. Comparing base (9b7a458) to head (b98bc47).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1098      +/-   ##
==========================================
- Coverage   80.48%   79.91%   -0.57%     
==========================================
  Files         367      369       +2     
  Lines      157217   160014    +2797     
  Branches   157217   160014    +2797     
==========================================
+ Hits       126531   127879    +1348     
- Misses      25974    27410    +1436     
- Partials     4712     4725      +13     
Flag Coverage Δ
cli-ort-linux 83.79% <ø> (ø)
cli-ort-windows 83.31% <ø> (ø)
mlas 84.65% <ø> (?)
offline 79.69% <100.00%> (-0.68%) ⬇️

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

Files with missing lines Coverage Δ
...es/onnx-runtime-ep-cpu/src/kernels/matmul_nbits.rs 75.63% <100.00%> (-7.49%) ⬇️
crates/onnx-runtime-session/src/executor/mod.rs 52.00% <100.00%> (+0.16%) ⬆️

... and 7 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

github-actions Bot commented Aug 17, 2026 •

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
🔴 matmul/large_generic_f32_threads=1/32x1024x1024 10.93 ms 15.26 ms +39.6%
✅ matmul/medium_generic_bf16_threads=8/32x512x512 694.59 µs 727.61 µs +4.8%
✅ tokenization/encode_tokens_per_second 419.19 µs 427.12 µs +1.9%
✅ matmul/large_generic_f32_threads=8/32x1024x1024 6.70 ms 6.77 ms +1.0%
✅ matmul/large_generic_bf16_threads=1/32x1024x1024 2.35 ms 2.32 ms -1.1%
✅ matmul/large_generic_f16_threads=8/32x1024x1024 112.25 µs 110.49 µs -1.6%
✅ tokenization/decode_tokens_per_second 7.01 ms 6.78 ms -3.3%
✅ matmul/medium_generic_bf16_threads=1/32x512x512 623.92 µs 602.63 µs -3.4%
✅ grammar_masking/llguidance_compute_mask/32 79.11 µs 74.87 µs -5.4%
✅ sampling_latency/greedy_per_token 3.78 µs 3.53 µs -6.8%
✅ logit_processing/seven_processor_chain_per_step 353.30 µs 326.35 µs -7.6%
✅ reduce_mean/large_f32_threads=1-internal/262144 1.34 ms 1.24 ms -8.1%
✅ gather/small_f32_threads=1-internal/4096 967.9 ns 887.8 ns -8.3%
✅ gather/small_f16_threads=1-internal/4096 704.0 ns 636.9 ns -9.5%
✅ qwen3_sampling_processors/top_k_top_p_full_sort_baseline 6.34 ms 5.72 ms -9.9%
✅ matmul/medium_generic_f32_threads=1/32x512x512 2.59 ms 2.32 ms -10.2%
✅ qwen3_sampling_processors/top_k_top_p_fast 727.66 µs 653.03 µs -10.3%
✅ block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 196.24 µs 175.93 µs -10.3%
✅ matmul/large_generic_f16_threads=1/32x1024x1024 98.97 µs 88.03 µs -11.1%
✅ block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 701.69 µs 622.10 µs -11.3%
✅ gather/medium_bf16_threads=1-internal/32768 2.82 µs 2.50 µs -11.4%
✅ kv_cache/alloc_dealloc_pages 45.07 µs 39.06 µs -13.3%
✅ sampling_latency/min_p_per_token 239.94 µs 207.70 µs -13.4%
✅ sampling_latency/top_k_per_token 60.83 µs 52.07 µs -14.4%
🟢 gather/medium_f16_threads=1-internal/32768 3.03 µs 2.55 µs -15.8%
🟢 matmul/small_generic_bf16_threads=1/1x256x256 40.78 µs 34.04 µs -16.5%
🟢 sampling_latency/top_p_per_token 463.28 µs 381.56 µs -17.6%
🟢 gather/small_bf16_threads=1-internal/4096 712.4 ns 569.7 ns -20.0%
🟢 qwen3_sampling_processors/top_p_fast_after_top_k 652.70 µs 521.78 µs -20.1%
🟢 matmul/large_generic_bf16_threads=8/32x1024x1024 2.27 ms 1.75 ms -23.0%
🟢 add/small_f32_threads=1-internal/1024 294.3 ns 223.6 ns -24.0%
🟢 block_quantized_matmul_cached_dense/mxfp4_cached_dense_repeated_call/1x1024x1024 133.26 µs 99.86 µs -25.1%
🟢 qwen3_sampling_processors/top_k_full_sort_baseline 2.85 ms 2.14 ms -25.1%
🟢 matmul/medium_generic_f16_threads=1/32x512x512 40.93 µs 30.27 µs -26.0%
🟢 add/small_bf16_threads=1-internal/1024 606.5 ns 446.0 ns -26.5%
🟢 reduce_mean/medium_f32_threads=1-internal/65536 342.91 µs 249.36 µs -27.3%
🟢 matmul/small_generic_f16_threads=1/1x256x256 44.57 µs 32.21 µs -27.7%
🟢 add/small_f16_threads=1-internal/1024 732.6 ns 527.4 ns -28.0%
🟢 matmul/medium_generic_f16_threads=8/32x512x512 46.49 µs 33.37 µs -28.2%
🟢 matmul/small_generic_f32_threads=1/1x256x256 52.98 µs 37.29 µs -29.6%
🟢 qwen3_sampling_processors/top_k_partial_selection 203.90 µs 142.92 µs -29.9%
🟢 add/large_f32_threads=1-internal/4194304 941.16 µs 658.76 µs -30.0%
🟢 gather/large_f16_threads=1-internal/131072 25.60 µs 17.76 µs -30.6%
🟢 reduce_mean/small_f32_threads=1-internal/4096 21.94 µs 15.07 µs -31.3%
🟢 add/medium_f32_threads=1-internal/262144 36.77 µs 25.09 µs -31.8%
🟢 qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline 5.34 ms 3.63 ms -32.1%
🟢 add/medium_f16_threads=1-internal/262144 164.18 µs 103.78 µs -36.8%
🟢 gather/large_bf16_threads=1-internal/131072 23.16 µs 14.43 µs -37.7%
🟢 add/medium_bf16_threads=1-internal/262144 162.52 µs 100.52 µs -38.1%
🟢 add/large_bf16_threads=1-internal/4194304 2.60 ms 1.60 ms -38.3%
🟢 matmul/small_generic_bf16_threads=8/1x256x256 50.47 µs 30.80 µs -39.0%
🟢 block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 1.43 ms 855.34 µs -40.2%
🟢 add/large_f16_threads=1-internal/4194304 2.81 ms 1.66 ms -41.1%
🟢 matmul/small_generic_f32_threads=8/1x256x256 55.86 µs 32.56 µs -41.7%
🟢 block_quantized_matmul_cached_dense/mxfp4_preexpanded_dense_oncelock_like_proxy/1x1024x1024 107.86 µs 61.53 µs -43.0%
🟢 matmul/small_generic_f16_threads=8/1x256x256 54.07 µs 30.10 µs -44.3%
🟢 gather/medium_f32_threads=1-internal/32768 6.96 µs 3.87 µs -44.4%
🟢 matmul/medium_generic_f32_threads=8/32x512x512 1.80 ms 934.44 µs -48.0%
🟢 gather/large_f32_threads=1-internal/131072 61.95 µs 27.47 µs -55.7%

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: { 3.08 3.34 7.01 }
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)

@justinchuby

Copy link
Copy Markdown
Owner Author

Merging, with a note on how this now fits.

Validated after merging current main (which includes #1104): cargo test -p onnx-runtime-ep-cpu --lib -- 1319 passed, 0 failed, 11 ignored; --features mlas --lib matmul_nbits -- 111 passed, 0 failed.

Context change while this was in flight. The owner set the direction that we do not bundle MLAS by default and instead absorb its optimizations natively (#1091), and #1104 has now done exactly that for this route: the int4 decode advantage turned out to be register/N-blocking rather than layout, so it ported into our borrowed path with no packed buffer at all -- 1.56x on the 14B at unchanged peak RSS.

That reframes this PR rather than cancelling it. MLAS stays in the tree as a measurement reference, because keeping both paths in one binary is what made every one of these gaps measurable. A reference that costs half as much and accounts itself correctly is worth having, and leaving a validated branch to rot is worse than landing it. Merging as maintenance of the reference, not as investment in the dependency.

The honest correction in your report is the most valuable part. My hypothesis when I dispatched this was that halving the packed cost would flip the 14B from declined to admitted. You measured that it does not: admission compares the expanded footprint (weights + cache) against the ceiling, and the dedup took that from 26.47 GB to 17.51 GB against a 17.13 GB ceiling -- missing by 378 MB, 2.2%. Reporting "the thing you predicted would happen does not happen, and here is the arithmetic" is exactly right, and the derived figure -- the admit threshold dropping from 0.386 to 0.256 of RAM -- is more useful than the yes/no I asked for.

The rest reproduces as described: pack count 338 → 169 on a multi-token run, now equal to the 169 of a single-token run; accounted 632,887,808 → 316,443,904 with ratio 1.00 at both scales; the accounting constant moved 2 → 1 in the same commit as the allocation change, which is what keeps predictor and allocation from drifting.

One detail worth carrying to the remaining per-instance caches: keying the store on weight identity and clearing it at Executor drop -- the same boundary weight_transpose::clear_all uses -- is the shape that removes both the multiplier and the duplicate memory, rather than merely accounting for the duplicate. dense is still per-instance and pays 2x for nothing; that conversion is queued on #1056.

@justinchuby justinchuby reopened this Aug 17, 2026
@justinchuby
justinchuby merged commit 05b2a03 into main Aug 17, 2026
3 of 4 checks passed
@justinchuby
justinchuby deleted the squad/mlas-packed-dedup branch August 17, 2026 04:36
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.

2 participants