Skip to content

Evict MLAS SQNBit packs by owner, and key them on every operand (#1735) - #1738

Merged
justinchuby merged 3 commits into
mainfrom
roy/fix-1735
Aug 22, 2026
Merged

justinchuby merged 3 commits into
mainfrom
roy/fix-1735

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

Closes #1735.

The defect

MlasPackedCaches keys the shared SQNBit packs on the operands' addresses and holds no claim on the memory there. An address names a weight only while that weight is live: once freed, the next allocation of matching shape and pack parameters is served the previous weight's packed bytes. The route counter still increments, because the right route did run — it just multiplied another weight's rows. This is #1726's defect in the packed store rather than the transpose caches, and clear_mlas_packed_caches() at executor teardown only closed the cross-model case.

Two gaps, closed together

Lifetime. get_or_build_shards/get_or_build_packed now report whether this call installed the entry, and only the installing kernel records ownership. A retiring kernel evicts what it installed.

Ownership is install-only on purpose: a kernel that merely hit an entry must leave it alone, or a retiring prefill instance would pull the pack out from under a live decode sibling and force a re-pack — the cost the shared store (#1056) exists to remove. This is the defect Opus caught in #1732's first draft, so it is asserted directly.

Identity (not in the issue). A CompInt8 pack bakes the scales and zero points into its per-block sums, but the key covered only the quantized weight — two nodes sharing a B initializer under different scales would serve each other packs. The key now carries all three operand addresses, and a weight whose scales have no stable host address is treated as unshareable rather than shared under a partial identity. This is the "stable full weight identity where available" the directive asked for, and it also strengthens the recycled-address case, since a colliding address must now match on scales too.

Bonus. clear_mlas_packed_caches() drained MLAS_PACKED_GLOBAL directly while cfg(test) traffic goes to the thread-local store — a silent no-op in every test that called it. Now routed through with_mlas_packed_caches.

Design notes

MlasPackedOwnership exists so the Drop does not land on MatMulNBitsKernel itself: a type that implements Drop cannot be moved out of, which makes the ..test_kernel(..) functional-update syntax used throughout the suite illegal (70 compile errors on the first attempt). Moving out of a struct whose fields have Drop is fine.

Production teardown ordering is unchanged and now guarded: clear_weight_transpose_caches() and clear_mlas_packed_caches() run in Executor::drop before self.buffers.drain().

Tests

Every test was verified to fail under its own mutation and no other:

test mutation that kills it
a_recycled_weight_address_must_not_serve_the_previous_weights_mlas_pack Drop made a no-op → round 1 serves weight 0's output for weight 1
a_shared_mlas_pack_outlives_the_sibling_that_only_shared_it ownership recorded on hit too → sharer claims the entry
one_quantized_blob_under_two_scale_tensors_must_not_share_a_pack scales_addr dropped from the key → second scales tensor gets the first's pack
the_packed_store_stays_consistent_under_concurrent_install_and_evict (drives MlasPackedCaches directly; the per-test store is thread-local, so kernels on different threads cannot contend)
weight_derived_caches_are_cleared_before_their_buffers_are_freed clears moved after buffers.drain()

The falsifier runs 8 alternating rounds and requires a cross-weight address reuse before it will pass — "some address was reused" is satisfiable by a weight landing on its own former address, which proves nothing. Eight rounds are needed because glibc first serves the ~2 MiB pack by mmap; freeing one raises the dynamic mmap threshold so later same-size requests come from the brk heap, where addresses are reused. Reference outputs are taken against a drained store, so a stale hit cannot poison the reference and make the comparison a tautology.

Routing note: both kernel-level tests drive try_mlas_sqnbit under NXRT_CPU_GEMM_BACKEND=mlas at m=4. On x86_64 the native acc4 int4 route wins before MLAS is consulted, and MLAS refuses asymmetric CompInt8 at M=1 on hosts without a correct AVX2 kernel — either would have made the tests silently vacuous.

Validation

./roy_validate.sh — PASS=21 FAIL=0 SKIP=0, including H check-win-arm64 (cargo-xwin) and G2 test-aarch64-qemu (the aarch64 suite actually executed, not just type-checked).

  • default (no-mlas) build/test/clippy -D warnings, --no-default-features, --all-features
  • K no-mlas-artifacts — the default zero-MLAS artifact is untouched; the research-only feature policy is preserved
  • concurrency: RUST_TEST_THREADS=32 × 6 clean runs, 1621 lib tests each
  • Miri (-Zmiri-tree-borrows): passes on the_packed_store_stays_consistent_under_concurrent_install_and_evict. Miri cannot execute the other three — they call into MLAS through FFI. Coverage here is the safe store/ownership logic only, and that limit is stated rather than papered over.

The default (no-mlas) lane caught a real cfg regression during development: an inserted field consumed the #[cfg(feature = "mlas")] that belonged to mlas_shards at both initializer sites.

⚠️ The New weight-derived caches must be governed gate is expected to fire — it matches on diff text, so a modified field declaration reads as a new cache. No new cache is introduced; MlasPackedOwnership holds keys, not buffers.

Roy and others added 2 commits August 22, 2026 10:00
`MlasPackedCaches` keys the shared SQNBit packs on the operands' addresses
and holds no claim on the memory there. An address names a weight only
while that weight is live: once freed, the next allocation of matching
shape and pack parameters is served the previous weight's packed bytes.
The route counter still increments, because the right route did run -- it
just multiplied another weight's rows. This is #1726's defect in the
packed store rather than the transpose caches, and `clear_mlas_packed_caches()`
at executor teardown only closed the cross-model case.

Two gaps, closed together:

Lifetime. `get_or_build_shards`/`get_or_build_packed` now report whether
*this* call installed the entry, and only the installing kernel records
ownership. A retiring kernel evicts what it installed, via a small
`MlasPackedOwnership` field that owns the `Drop`. Ownership is install-only
on purpose: a kernel that merely hit an entry must leave it alone, or a
retiring prefill instance would pull the pack out from under a live decode
sibling and force a re-pack -- the cost the shared store (#1056) exists to
remove.

Identity. A CompInt8 pack bakes the scales and zero points into its
per-block sums, but the key covered only the quantized weight, so two nodes
sharing a `B` initializer under different scales would serve each other
packs. The key now carries all three operand addresses, and a weight whose
scales have no stable host address is treated as unshareable rather than
shared under a partial identity.

Also fixes `clear_mlas_packed_caches()`, which drained `MLAS_PACKED_GLOBAL`
directly while `cfg(test)` traffic goes to the thread-local store -- a
silent no-op in every test that called it.

`MlasPackedOwnership` exists so the `Drop` does not land on
`MatMulNBitsKernel` itself: a type that implements `Drop` cannot be moved
out of, which would make the `..test_kernel(..)` functional-update syntax
used throughout the suite illegal.

Tests, each verified to fail under its own mutation and no other:
 - a recycled address must not serve the previous weight's pack (8
   alternating rounds, cross-weight reuse required, references taken
   against a drained store);
 - a shared pack outlives the sibling that only shared it;
 - one quantized blob under two live scale tensors must not share a pack;
 - the store stays consistent under concurrent install/evict;
 - weight-derived caches are cleared before their buffers are freed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@codecov

codecov Bot commented Aug 22, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.06%. Comparing base (c498792) to head (951bd31).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1738      +/-   ##
==========================================
+ Coverage   79.75%   80.06%   +0.30%     
==========================================
  Files         408      408              
  Lines      193197   192932     -265     
  Branches   193197   192932     -265     
==========================================
+ Hits       154088   154468     +380     
+ Misses      33759    33109     -650     
- Partials     5350     5355       +5     
Flag Coverage Δ
cli-ort-linux 72.47% <ø> (ø)
cli-ort-windows 72.06% <ø> (ø)
mlas 85.33% <ø> (+0.13%) ⬆️
offline 80.18% <100.00%> (+0.31%) ⬆️

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 82.26% <100.00%> (+2.52%) ⬆️

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

Opus review found the recycled-address test weaker than it presented. Two
fixes, both to the test:

The vacuousness guard tracked only the packed weight's address, but with
the extended key a stale hit needs the packed, scales and zero-point
addresses to all collide. On an allocator whose size classes do not recycle
in lockstep the run could report `recycled=true` while the key never
matched -- passing even with per-owner eviction reverted. It now tracks the
whole key tuple.

More importantly the test no longer depends on recycling at all: each round
now asserts the store is empty before it starts, which is what the previous
round's retiring kernel guarantees. That holds on every allocator, and it
kills a no-op `Drop` deterministically at round 1 rather than waiting for an
address to be reused.

The final recycling check is consequently no longer an assertion. Address
reuse is the allocator's choice, not the code's: under ASan, valgrind, or
some musl/macOS configurations no reuse occurs and a correct implementation
would have failed. It is reported loudly instead, so a run that skipped that
arm can never be read as one that exercised it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby marked this pull request as ready for review August 22, 2026 10:32
@justinchuby

Copy link
Copy Markdown
Owner Author

Independent Opus review — disposition

Reviewed adversarially against six attack surfaces. Five came back unfounded after the reviewer traced the concrete interleavings; both actionable findings were test-robustness issues and are fixed in bfb6b1e-range follow-up commit.

Unfounded, with the reasoning worth recording:

  • Install-only ownership is complete. The worry was that one kernel could OnceLock::set mlas_owned.packed twice under different keys, silently dropping the second and leaking that entry forever. It cannot: shared_mlas_packed and shared_mlas_prepacked are mutually exclusive (self.weight_prepacked returns before the non-prepacked path), each is memoized behind self.mlas_packed, and comp is a pure function of self.accuracy_level so it cannot vary between calls. A kernel can install both a shards entry and a packed entry — those are different OnceLocks into different maps, and each remove_* hits its own.
  • remove_* vs a concurrent get_or_build_* is perf-only. Two kernels can never both own one entry: installed=true requires a Vacant entry under the mutex, and the prior owner removes before a later kernel can re-install. If an owner evicts an entry a sharer is using, the sharer already memoized its Arc and never consults the store again; a third kernel merely re-packs, and always from its own operands at its own key's addresses, never from foreign memory.
  • The extended key does not break prefill/decode sharing. Every store path requires can_prepack, which requires the scales and zero points to be constant initializers — delivered as views into the same shared buffers for both instances. The key is taken from the raw inputs before any densification, so that cannot desync the addresses either.
  • Drop order inside the kernel is not observable. mlas_shards/mlas_packed drop before mlas_owned, but both the memo and the store hold Arcs of the same data; refcounting means eviction never frees memory another holder references, in either order.
  • contiguous_host_addr not using contiguous_host_slice::<u8> is correct (that helper forms a numel-length u8 slice, understating a wider dtype), and None → treated as unshareable is what every path actually does — there is no partial-key fallback.

Fixed:

  • The vacuousness guard tracked only the packed weight's address while a stale hit now needs all three to collide, so on an allocator whose size classes don't recycle in lockstep the test could report recycled=true without the key ever matching — passing even with eviction reverted. Now tracks the whole key tuple.
  • More importantly the falsifier no longer leans on the allocator: each round asserts the store is empty before it starts, which the retiring previous kernel guarantees on every allocator. Re-verified — a no-op Drop is now killed deterministically at round 1 (round 1: the retired kernel left its pack in the store) instead of waiting for an address to be reused.
  • The final recycling check is no longer an assertion, because address reuse is the allocator's choice, not the code's: under ASan/valgrind/some musl and macOS configurations a correct implementation would have failed it. Reported loudly instead, so a skipped arm can't be misread as an exercised one. On this glibc host both arms run.

Noted, not changed: the env-var save/restore in the two new tests is not panic-safe — an assertion failing between set_var and the restore leaves NXRT_CPU_GEMM_BACKEND set and poisons backend_env_lock for every later test. That is the established pattern across ~8 pre-existing tests in this file, so the new tests conform rather than diverge. Fixing it properly means a scope guard for all of them; out of scope here, worth its own issue.

Final validation

./roy_validate.sh — PASS=21 FAIL=0 SKIP=0, re-run on the final tree.

Includes H check-win-arm64 (cargo-xwin, aarch64-pc-windows-msvc) and G2 test-aarch64-qemu — the aarch64 suite executed under qemu-user, not merely type-checked. No step was skipped for a missing toolchain, so the 21 is a complete matrix rather than a narrowed one.

Plus: RUST_TEST_THREADS=32 × 6 clean runs (1621 lib tests each), and Miri -Zmiri-tree-borrows on the store's concurrency test. Miri cannot execute the other three — they cross into MLAS via FFI — so Miri coverage here is the safe store/ownership logic only.

@justinchuby

Copy link
Copy Markdown
Owner Author

New weight-derived caches must be governed — judgement

The gate matched these two added declarations:

struct MlasPackedOwnership {
    shards: OnceLock<MlasPackedKey>,
    packed: OnceLock<MlasPackedKey>,
}

They match the pattern OnceLock *<.*Packed, but they are not caches and hold no buffer. MlasPackedKey is a plain Copy struct of usize/bool/enum fields — three operand addresses, n, k, bits, block_size, has_zero_points, and the compute type. Two of them is on the order of 80 bytes, fixed, independent of weight size, and it is per kernel instance rather than per process.

Its purpose is the opposite of a cache: it is the record of what this kernel installed so that its Drop can remove those entries from the shared store. It shrinks long-lived memory rather than adding any — before this PR an entry lived until executor teardown; now it is evicted when its installing kernel retires.

No new weight-derived buffer is introduced anywhere in this PR. The buffers involved (MlasPackedCaches.shards / .packed) already existed and are already accounted — SQNBIT_PACKED_LIVE_BYTES in mlas-sys counts each packed buffer once, and int4_acc0_mlas_packed_accounting_equals_actual_allocated still passes. Their byte total can only go down as a result of this change.

Recording that judgement with the weight-cache-reviewed label, per the gate's own instructions.

For completeness, the accounting is untouched because the store's contents are unchanged — the same Arc<Vec<Option<MlasShard>>> and Arc<MlasPreparedPacked> values, under a key with two more usize fields. The only lifetime change is that entries now leave the store earlier.

@justinchuby justinchuby added the weight-cache-reviewed Author confirmed a long-lived buffer does not scale with weight size (weight-cache-guard escape) label Aug 22, 2026
@justinchuby

Copy link
Copy Markdown
Owner Author

Merge record

Merging under Justin's standing direct-merge directive (2026-08-19T14:20:27: comprehensive local validation including cross-platform, then merge directly rather than waiting on queued Actions).

Local matrix: PASS=21 FAIL=0 SKIP=0, re-run on the final tree after the review hardening. No step was skipped for a missing toolchain, so the 21 is the complete matrix — including H check-win-arm64 (cargo-xwin → aarch64-pc-windows-msvc) and G2 test-aarch64-qemu, which executes the aarch64 suite under qemu-user rather than merely type-checking it.

Required-CI position, stated honestly. One check is red: Rust coverage (macOS arm64). I checked it is not mine before merging — it fails identically on unmodified main (runs 32565237825 and 32564899444, alongside the rest of the chronic set from #1600: Rust (Windows ARM64), CLI ORT (Windows x86_64), Rust coverage (Windows x86_64)). It is not attributable to this PR, and this change's aarch64 behaviour is covered locally by G2 executing the suite on aarch64. Every other completed check passes, including New weight-derived caches must be governed after the judgement label.

The remaining checks are queued, not failing. Per the directive I am not waiting on them; if any produces a real code failure afterwards I will fix it on main rather than leave it.

@justinchuby
justinchuby merged commit 6b63998 into main Aug 22, 2026
17 of 21 checks passed
@justinchuby
justinchuby deleted the roy/fix-1735 branch August 22, 2026 10:40
justinchuby added a commit that referenced this pull request Aug 24, 2026
… it died (#1889)

Closes #1745.

## What #1745 actually reports

```
SPMD parity child failed (persistent=true, workers=3,
  status: exit code: 0xc0000005 (code -1073741819, 0xc0000005))
```

`0xC0000005` is `STATUS_ACCESS_VIOLATION`. The child **faulted** — it
did not
fail the parity assertion. `child_status_detail` exists to distinguish
those and
says fault. Roy did the elimination work on his PR (#1741): the diff
didn't
touch the test, the converted env vars are inert on that lane, and an
identical
tree with one empty commit added passed. He also checked that
`Rust (Windows ARM64)` is **green on main** — it is not in the #1600
chronic-red
set, so this is a real crash rather than a known-red lane.

## The asymmetry this fixes

Three test children build the process-wide decode pool. Exactly one of
them,
`affinity_defer_routing_child`, stops it before exiting — and its
comment names
*this same* `0xC0000005` on native Windows ARM64 as the reason it was
added. The
parity child and the realized-width child never got the same treatment,
and
#1745 is reported against exactly those two.

The pool is a module-level `static`, which Rust never `Drop`s. So those
two
children exit with workers still spinning or parked on
`Arc<SharedState>`, and
on Windows `ExitProcess` terminates them at an arbitrary instruction —
including
inside a CRT or heap lock that exit-time cleanup then takes.

Applying the existing remedy to the other two is the cheapest way to
find out
whether it is the same bug.

## What I am *not* claiming

I am not claiming this is proven to be the cause. The crash is rare,
intermittent, and on a platform this workspace cannot execute; I have no
reproduction and no crash dump. This is a hypothesis with in-repo
precedent.

Which is why the other half of the change exists.

## Saying where it died

A #1745 report today says the child faulted and nothing else. Between
process
start and its single result line the child builds a pool, runs a kernel,
encodes
bytes and tears the pool down — a fault anywhere in that span produces
the same
report. "Crashed doing the work" and "crashed at exit after the work"
are
different bugs with different fixes, and nothing in the log separates
them.

Breadcrumbs (`NXRT_CHILD_STAGE: site=… stage=…`) fill that in. If the
next
occurrence shows `body-complete` with no `pools-stopped`, the crash is
*inside*
teardown and this PR's remedy is where to look. If it shows neither, it
is in
the work and this PR is a red herring. Either way the next report is
worth more
than this one.

### One thing I checked rather than assumed

The natural suspicion is that the detail was printed and lost — the
child writes
to a pipe and dies. **That is not what happens.** Rust's stdout is a
`LineWriter`
on every platform, so a newline-terminated `println!` has already
reached the
pipe before the fault.

Verified rather than reasoned about: a child that writes one
newline-terminated
line, one unterminated `print!` and one flushed stderr line, then
`abort()`s
under `Command::output()`, delivers the first and the third and loses
only the
unterminated fragment. So the gap is not lost output — it is that
nothing is
emitted between "started" and "produced the result".

I mention it because I believed the buffering story first and wrote it
into a
doc comment before testing it. It was wrong.

### Why stderr, and why the order matters

Both readers of these children key on stdout **by content**:
`is_environmental_access_violation_crash` treats the result marker's
presence as
"the child produced its result", and `parity_child_output_mode` scans
stdout for
the parity payload. Extra stdout lines are a change to two lanes'
red/green
semantics. Extra stderr lines are not — provided they never contain
`panicked at` or `assertion`, the only two strings that classifier reads
out of
stderr. That is asserted, not assumed.

The teardown call is placed **after** the result marker in the two
children that
never had one, so a crash inside our own teardown keeps the marker on
stdout,
classifies as non-environmental, and is **reported rather than retried
away**. A
bug in this code is not a flaky runner.

`affinity_defer_routing_child` keeps its existing marker-last order.
Flipping it
would change the red/green semantics of a currently-green lane, on a
platform I
cannot execute, to probe a hypothesis that is not yet confirmed. Its
breadcrumb
answers the same question without touching the verdict.

## `workers_exited`, and a test that flaked

`ready` makes "the workers have started" an observable fact that `build`
blocks
on. Nothing made "the workers have stopped" observable, so the only
available
evidence of a completed teardown was the absence of thread names in
`/proc/self/task`.

My first version of the teardown test asserted exactly that — and **it
flaked
under load**. The futex that unblocks `join` is signalled at
`mm_release`, before
the kernel unhashes the task, so a joined thread's `/proc` entry can
still exist
when the next statement runs. That is a race in the *instrument*, and an
intermittently-red teardown test would teach precisely the wrong lesson
about
intermittently-red teardown.

So the assertion moved to a counter incremented by the worker before it
returns,
which is ordered by the join that observes it. The thread counts are
still
printed — as a report, not an assertion. Same demotion Roy applied to
the
recycled-address falsifier in #1738.

Cost: one `fetch_add` per worker per process, on a `Drop` guard so a
panicking
worker is counted out too.

## Tests — 3 added, 6 mutations, 6 caught uniquely

| injected defect | caught by | over-catch |
|---|---|---|
| breadcrumb text contains `assertion` |
`…invisible_to_the_crash_classifier` | none |
| breadcrumb loses its grep prefix |
`…invisible_to_the_crash_classifier` | none |
| breadcrumb drops `site=` | `…names_both_the_site_and_the_stage` | none
|
| breadcrumb drops `stage=` | `…names_both_the_site_and_the_stage` |
none |
| `shutdown` stops joining | `shutdown_pools_is_a_barrier_not_a_request`
| none |
| worker exit stops being counted |
`shutdown_pools_is_a_barrier_not_a_request` | none |

Baseline clean. The join falsifier is **probabilistic by nature** — the
workers
do leave, just not before the next statement — measured at **18/20** on
this
host, and stated as such in the test's own doc rather than left as an
implied
certainty.

`shutdown_pools_is_a_barrier_not_a_request` runs in a child process
because
`comm` truncates at 15 bytes, so in a shared test binary another test's
pool is
indistinguishable from this one's — same reasoning as the existing
`a_failed_build_leaves_no_workers_running`. It builds with `blocktime=0`
so the
workers are **parked, not spinning**: a spinning worker would drift out
on the
stop flag alone, which would let a shutdown that never woke anyone pass.

## Adjacent, not claimed

Roy notes the crash lands at `workers=3` under the persistent pool, i.e.
inside
the only path that checks realized width, and that this is uncomfortably
close to
the t=2 dispatch anomaly I own. I have no evidence they are the same bug
and am
not asserting it. #983 and #1672 carry the same `0xC0000005` signature
on other
Windows lanes and may share a root cause; #1123 (LoggingManager race) is
not yet
ruled in or out.

## Validation

- `cargo test -p onnx-runtime-ep-cpu --lib -- spmd` — 97 passed, 3
ignored.
- `cargo test -p onnx-runtime-ep-cpu --lib -- affinity_defer
realized_width parity` — 28 passed.
- `cargo clippy -p onnx-runtime-ep-cpu --all-targets -- -D warnings`,
default and `--features mlas` — clean.
- `cargo fmt --all` — clean.

Note `main` is currently red on `Fast (Linux x86_64)`
(`the_matrix_holds_for_every_maintained_workflow`, from `7a0cb6c39`),
which is
unrelated to this change and will need to go green before this can merge
through
the gate. Waiting, not bypassing.

Co-authored-by: Sebastian <seb@example.com>
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

weight-cache-reviewed Author confirmed a long-lived buffer does not scale with weight size (weight-cache-guard escape)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MLAS SQNBit packed-weight store has the same recycled-address hazard fixed in #1726

1 participant