Repository navigation
#837 item 3: attribute residency streaming gap; byte-aware policy rejected (negative result) - #886
Conversation
… policy Attribution (managed streaming, qwen14b-zp, solo, nvidia-smi clear): add byte-weighted accounting for "bypassed" page-ins - misses that are mapped, H2D-filled, handed to the kernel transiently, then freed without ever joining the resident set, so their bytes re-stream every decode step. bypassed_page_ins 336 (11% of page-in events) bypassed_page_in_bytes 16.77 GB over 16 tokens bypassed_byte_share 44.63% of streamed bytes (~1.048 GB/step) That accounts for ~90% of the 1.158 GB/step recoverable gap: the size-blind scan-resistant policy retains small tensors (avg 7.8 MB: norms/biases) that win the first-fit race and re-streams the large projections (avg bypass 49.9 MB) transiently every step. New instrumentation (safe, default path byte-identical): - GlobalOffloadStats::bypassed_page_in_bytes + bypassed_byte_share() - profile_native prints both in weight_offload_cache Byte-aware residency (EXPERIMENTAL, default-OFF, measured UNSAFE): env knob ONNX_GENAI_WEIGHT_OFFLOAD_BYTE_AWARE keeps the largest tensors resident (evict smallest-first, strictly-smaller only). On-device A/B shows that whenever it actually engages (offload active) it violates the token-identity hard constraint - greedy decode collapses to 3 tokens vs 16 - both with and without CUDA-graph capture. The failure is an early EOS, not an admission error, so size-greedy eviction corrupts decode numerics: the scan-resistant recency discipline is load-bearing for correctness on the managed path, not just for hit rate. Kept default-OFF and wired only so the rejected approach and its evidence are reviewable. Conclusion: the count-vs-byte residency gap cannot be closed by an eviction-order change alone; it needs the structural transient-staging zone deferred by #837 (hand a large tensor to the kernel without evicting a resident page). CPU unit tests confirm the size-greedy decision on an abstract model but explicitly do not validate the GPU path. Mandatory mobius_seqmajor_growth_parity gate: GREEN solo. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sharpen the byte-aware knob doc with the proven finding: the failure is
SILENT numeric corruption, not an admission error. Proven by the
profiler's error-propagation structure - generate_with_callback is
?-propagated with .context("steady measured generation") before the
bail!("generation emitted N tokens") check, so the observed bail! means
generation returned Ok with 3 tokens (early EOS), not a surfaced
WeightHandleError.
Record why a residency policy can alter values at all: under the
documented guards (evict only strong_count==1 pages after draining both
compute and copy streams; stable per-tensor VA; both hit and bypass fill
correct bytes) a change of eviction TARGET should be value-neutral, so
the corruption implicates state that depends on eviction ORDER - most
plausibly the physical granule / retained-handle pool accounting. Graph
OFF reproduction rules out captured-VA baking as the sole cause. Whether
this is a bug in the byte-aware loop or a latent defect in the existing
offload path (exposed, not caused, by reordering evictions) was not
isolated and warrants separate investigation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Validated and merging. Gates on a worktree with The attribution is the deliverable, and it is a good one. 11% of page-in events carrying 44.6% of streamed bytes is the count-vs-byte trap in its purest form: size-blind The negative result is stronger than a null result, because you isolated why. This table is what makes it conclusive:
That rules out flakiness and rules out capture (it reproduces graph-on and graph-off), and identifying the failure as a normal early-EOS from And the mechanism is the part with lasting value: the scan-resistant recency discipline is load-bearing for correctness on the managed path, not merely for hit rate — reordering evictions by size corrupts output. I would not have predicted that, and it is exactly the kind of thing that would have been shipped by someone optimizing the count-based hit rate in good faith. Two judgements I want to endorse explicitly:
The conclusion redirects effort correctly. "The count-vs-byte gap cannot be closed by an eviction-order reorder alone — it needs a transient staging zone so a large tensor can be handed to the kernel without displacing a resident page" is a real finding about the shape of the remaining work, not a shrug. It also raises the value of #864's hybrid, where a zero-copy cold read is precisely a transient hand-off that displaces nothing — measured at ~5.6 GB/s against ~100–133 GB/s resident in #880. Scope caveat correctly stated: since #874 the Windows default is WDDM zero-copy with no residency cache, so this governs Linux, forced-managed Windows, and the hybrid. All measurements on the managed arm, which is the honest arm to measure. Merging as a report. #837 stays open; item 3's remaining lever is now named (transient staging), not vague. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #886 +/- ##
==========================================
- Coverage 77.79% 77.79% -0.01%
==========================================
Files 354 354
Lines 148003 148003
Branches 148003 148003
==========================================
- Hits 115137 115134 -3
- Misses 28375 28377 +2
- Partials 4491 4492 +1
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
🔴 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
|
## Investigation of #888 — which explanation, with evidence **Verdict: explanation (1).** Decode correctness does **not** depend on eviction order. The #886 byte-aware corruption is caused by its **retain-vs-bypass flip**, not by reordering evictions. #886's speculation that "the scan-resistant recency discipline is load-bearing for correctness" is **refuted** — recency is load-bearing for *hit rate* only. The defect is in the *rejected* change, not the shipped path. This is an investigation PR. It changes **no shipped/default behaviour**; every knob added is default-OFF and byte-identical on the default size-blind path (mirroring how #886 kept byte-aware wired for reviewability). **Do not close #888.** Do not merge without a squad review. ### The discriminating experiment A residency policy changes two independent things: *which* weights are resident, and *in what order they evict*. Byte-aware changes **both** at once — it starts **retaining** large tensors the shipped path streams transiently, **and** it evicts smallest-first. To separate them, `ONNX_GENAI_WEIGHT_OFFLOAD_EVICT_ORDER` changes only the eviction **victim** while keeping the shipped always-bypass decision. Runs: qwen14b-zp, `ONNX_GENAI_MANAGED_WEIGHT_STREAMING=1`, `--tokens 16 --steady`, each arm solo with `nvidia-smi` verified clear beforehand. Baseline tokens `[96347, 3375, 724, 11, 358, 2776, 14589, 311, 6723, 429, 498, 3003, 2581, 6617, 315, 752]`. | arm | tokens | byte_hit_rate | evictions | page_ins | verdict | |---|---|---|---|---|---| | baseline (LRU, byte-aware OFF) | **16 ✓** | 70.16% | 2672 | 3008 | reference | | evict order = **MRU** (reverse recency) | **16 ✓ identical** | 79.15% | 1952 | 2160 | clean | | evict order = **Smallest** (byte-aware's exact victim) | **16 ✓ identical** | 71.77% | **10192** | — | clean | | byte-aware ON (graph ON) | **3 ✗** early EOS | — | — | >0 | corrupt | | byte-aware ON (graph OFF) | **3 ✗** early EOS | — | — | >0 | corrupt | Two independent, still-correct eviction *orders* — including byte-aware's **exact** smallest-first victim under extreme churn (10,192 evictions, hit rate driven down to ~22% in a harsher variant) — stay **byte-identical** with clean ledgers. **If any non-recency order corrupted, that would be evidence for (2); none does.** Changing eviction order alone is value-neutral. The corruption tracks *only* the retain flip. ### Mechanism — what it is NOT - **Not captured-VA baking** — graph-OFF corrupts too. - **Not a copy/compute fence / WAR hazard** — `ONNX_GENAI_WEIGHT_OFFLOAD_SYNC_BEFORE_FILL=1` (full drain of both compute and copy streams before every page-in fill) does **not** fix it. (The `htod_async_elapsed_ms` fill already host-syncs every page-in — the class of confusion flagged in #837/#851 — so fences are not the issue.) - **One concrete secondary bug found and confirmed to occur:** a *slotted* key that later bypasses gets `stable_slot = true` (Drop decommits-but-keeps-VA) yet is never re-inserted into `pages` (observed: key=20, 4.4 MB). `ONNX_GENAI_WEIGHT_OFFLOAD_RETAIN_SLOTTED=1` closes it — but closing it does **not** stop the corruption. So it is real-but-**secondary**; unreachable on the shipped size-blind path. ### Honest boundary The exact value-corruption *line* is not isolated. The primary path is byte-aware retaining/re-admitting **large stable-slot tensors served as hits** (no re-fill) across steps — most plausibly granule-ownership under multi-granule decommit + reused-slot recommit. Settling it needs **granule-level checksums across steps**; `compute-sanitizer` is unavailable here (CUDA runtime via pip wheels, no toolkit), which is the honest limit of what counters + targeted sync + code reading can prove. What *is* proven is the discrimination above: the shipped path's correctness is order-independent. ### Impact on #864 (and #866 / #750) The shipped size-blind path is **safe** — it never retains large tensors, so the buggy path is unreachable, and the probe confirms it stays byte-identical under reorder + heavy churn. Therefore **#864 is not blocked by an eviction-order invariant.** A hybrid that pins a **static** hot set (retain once, never evict/re-admit; zero-copy the cold remainder — exactly #864's stated shape) does not exercise the corrupting retain-then-churn path. The hazard is specifically **evicting and re-admitting large stable-slot residents**; any dynamic scheme that churns large weight pages (byte-aware; potentially #866 elastic reclaim, #750 admission) must validate token identity and should prefer a pinned non-churning hot set. ### What changed (all default-OFF, byte-identical on default path) - `weight_paging.rs`: `EvictOrderProbe` (lru|mru|smallest|largest) + env parse + victim selection wiring; `SYNC_BEFORE_FILL` and `RETAIN_SLOTTED` diagnostic knobs; one-shot slotted-bypass diagnostic; updated `WEIGHT_OFFLOAD_BYTE_AWARE_ENV` doc with the #888 resolution; 1 unit test. - `lib.rs`/`provider.rs`/`memory_strategy.rs`: export + wire the probe from env. - `profile_native.rs`: env-echo at start of `main`. - `.squad/decisions/inbox/copilot-eviction-order-correctness.md`: decision note for #864/#866/#750 owners. ### Gates fmt (per-package `--check`), clippy `-p onnx-runtime-ep-cuda --features cuda -D warnings`, clippy `--locked --all-targets -p onnx-genai-engine --features native-backend -D warnings`, `weight_paging::tests` (all green incl. new test), and **`mobius_seqmajor_growth_parity_native_cuda --ignored --test-threads=1` run SOLO with GPU clear → GREEN**. Final baseline re-run with the final binary is byte-identical with `captures>0`, `fallbacks==0`, `oversubscribed_bytes==0`, `peak_committed_physical_bytes < managed_limit_bytes`, and `ref_underflows/byte_underflows/unaccounted_committed_bytes == 0`. Refs #888. Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
…ne changes it (#896) Four measured findings about the CUDA weight-residency policy lived only in issue threads and PR bodies. Two of them cost a full investigation each, and all four are the kind a future change would otherwise rediscover the hard way. Adding them to §3.5, where someone editing admission or eviction will actually see them. **1. Decode correctness does *not* depend on eviction order (#888/#892).** Default LRU, MRU (reverse recency) and smallest-first all produce **byte-identical tokens** — the last driven to 10,816 page-ins with the count hit rate at 22%. The earlier speculation that *"the scan-resistant recency discipline is load-bearing for correctness"* is **refuted**; recency is load-bearing for hit rate only. **2. But evicting and *re-admitting* large stable-slot residents does corrupt output.** A byte-aware policy (#886) silently collapsed greedy generation 16 → 3 tokens whenever offload actually engaged, with **and without** graph capture. The corruption tracks the **retain flip**, not the reorder — isolated by a probe that changes only the eviction victim while keeping the shipped always-bypass decision. Not captured-VA baking (graph-OFF corrupts too), not a fence (a full drain of both streams before every fill does not fix it). The exact value-corruption line is **not** isolated, and the doc says so along with what would settle it. The design consequence is the part worth having written down: a **static** hot set — retain once, never evict, never re-admit — provably does not exercise the corrupting path, which is why #864's hybrid is safe *in that shape*. Any **dynamic** scheme that churns large weight pages (#866's elastic reclaim, #750 admission, any future byte-aware attempt) must **assert token identity rather than assume it**. **3. The residency gap is an *admission* problem, not an eviction problem (#837/#886).** ~90% of the recoverable 1.158 GB/step is bypass traffic — 11% of page-in events carrying 44.6% of streamed bytes — because `StableResident` bypasses on `!can_fit(bytes)`, which is arrival-order first-fit. Small tensors (avg 7.8 MB) win the free budget; large projections (avg 49.9 MB) then fail `can_fit` on every subsequent step and re-stream forever, **never entering the eviction population at all**. That is the mechanical reason a reorder cannot close the gap. **4. Judge policy on `byte_hit_rate`.** The count-based rate once moved 57.09% → 81.31% while the gap to the streaming floor **widened** 1.78× → 2.30×. Also records **#893** (MRU measured better than the shipped LRU: page-ins 3,008 → 2,160, `byte_hit_rate` 70.16% → 79.15%, byte-identical) as **open and explicitly not acted on** — one model, one budget, and favourable, which is the shape of the results this project has had to retract twice. Docs only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
## Summary Refs #893. Documents the forced-managed Windows MRU/LRU sweep; no runtime default or code changed. | model | budget | LRU byte hit / H2D per token | MRU byte hit / H2D per token | H2D delta | |---|---:|---:|---:|---:| | Qwen2.5 14B Q4 | 3 GB | 0.00% / 7.871 GB | 22.60% / 6.092 GB | **-22.6%** | | Qwen2.5 14B Q4 | 6 GB | 49.45% / 3.979 GB | 66.39% / 2.646 GB | **-33.5%** | | Qwen2 0.5B Q4 | 250 MB | 33.30% / 185.35 MB | 35.34% / 179.69 MB | **-3.1%** | | Qwen2 0.5B Q4 | 275 MB | 94.02% / 16.62 MB | 96.06% / 10.96 MB | **-34.1%** | ## Verdict MRU is directionally positive in all four pressured comparisons, so the advantage survives a second, materially different geometry (24 layers / hidden 896 versus 48 / 5120). Its magnitude is strongly budget-specific, however, and the second model normally fits and required forced offload. **Keep LRU as default.** MRU operates within unchanged `StableResident` admission. It complements scan resistance, but both attack the same cyclic-scan pathology, so their wins must not be added as independent effects. Eviction cannot admit a tensor refused by `!can_fit(bytes)`. Given #886/#837's attribution of ~90% of the recoverable 1.158 GB/step gap to bypass, the direct victim-order ceiling is about **0.116 GB/step (10%)** of that gap, even when cache ratios improve substantially. ## Correctness and safety - Token IDs byte-identical in every arm (14B matches the #893 reference). - `nvidia-smi --query-compute-apps` empty immediately before every run; none contended. - Managed path explicitly enabled; CUDA graph captures > 0 and fallbacks = 0. - Oversubscribed bytes = 0; peak committed physical bytes below managed limit. - `ref_underflows`, `byte_underflows`, and `unaccounted_committed_bytes` all 0. ## Validation - `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings` - `git diff --check` - No package source changed, so no package `cargo fmt` target applies. Full protocol, tables, bounds, and falsification criteria are in `docs/research/mru-eviction-sweep.md`. Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
…o retract (#911) Several confident performance claims on this project have been retracted, **including two by me on the same day**. Every one failed the same way: a number was interpreted before the conditions that produced it were established. The corrections were each worth more than the original claims, but they cost real time, and they are avoidable. There are skills for test discipline and error recovery, but nothing about making a *measurement* trustworthy — which is where the actual damage has been. ## What it records Eight failure modes, each with the incident behind it, because the abstract rule is forgettable and the incident is not: 1. **A knob you set produced the number you then interpreted.** #834 concluded the engine commits KV eagerly from `committed_len == capacity`, when the harness had set `min_bucket = capacity` and `kv_capacity_bucket` is `len.next_power_of_two().max(min_bucket).min(hard_max)`. The knob forced the observation; at the default bucket the engine commits on demand — the opposite conclusion. 2. **The instrument measured a real thing that was not the thing named.** `h2d_enqueue_copy_ms` reported 1.7% and retired a line of work; it bracketed an async enqueue returning, not a transfer completing. Properly measured: 18.8%. 3. **Arithmetic that cannot close.** `total_weight_bytes` was 2.00× too large (#853), exposed because measured traffic sat *below* its own theoretical floor. 4. **A rate over a population with wildly different member costs.** `hit_rate` rose 57.09% → 81.31% while the gap to the floor *widened* 1.78× → 2.30× (#869). 5. **Contention you did not know was there.** I filed a flaky-gate issue and retracted it — two contending loops were mine (#851). The strict re-run was 8/8. 6. **Wall-clock on a box that pages your own memory** — 3.9–28 tok/s across identical configurations (#863). 7. **A proxy standing in for the real access pattern** — 11.41 GB/s sequential proxy vs ~5.6 GB/s real strided GEMV (#877 → #880). 8. **A precondition observed rather than established** — a test that hoped the allocator would reuse an address, green on glibc and vacuous on Windows (#906). Each carries the check that catches it, and the corollary I want most: **build a control that could falsify you.** #891 measured `1/N` amortization *and* showed it identical at `past_len` 0/512/2048, ruling out a KV-pressure artifact — a result that survives a control it could have failed is worth far more than one that merely looks good. ## Also recorded **The reporting rules that made the good results good** — state the conditions with the number; report a ceiling, not just a gain (#891's `N_max ≈ 19 @ 2048 ctx`, #901's ~10% bound on eviction-order tuning, both of which stopped someone spending a week for a tenth of the prize); treat a truthful negative as first-class; and say when a favourable result rests on one measurement — #901 measured MRU as better and still recommended keeping LRU, which is the harder and more useful call. **The correctness constraints that outrank performance**, led by byte-identical token IDs — the check that caught a residency policy silently collapsing generation 16 → 3 tokens with no error at all (#886). Every issue and PR number cited was verified to exist before committing, which felt like the minimum for a document about not asserting things you have not checked. Docs only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
…60/WDDM (#912) ## Summary Implements the zero-copy hybrid weight-residency mode for the CUDA EP (#864), behind a **default-OFF** `ONNX_GENAI_ZERO_COPY_HYBRID` knob, and reports a **measured negative result**: on this box's RTX 4060 Laptop under WDDM the hybrid **cannot beat the OS**, with the mechanism nailed down. Do **not** merge, do **not** close #864 — this is a report-back for review. ## What it does Keep the size-blind `StableResident` hot set copied into VRAM; instead of transiently streaming the cold remainder each decode step, bind it in place from a `cuMemHostRegister(READ_ONLY | DEVICEMAP)` host mapping. - The bypass decision is intercepted **before any eviction**, so a retained hot page is only ever one that fit without displacing another and no large weight ever occupies an evict/re-admit stable slot — the exact pattern that silently corrupted decode in #886. - The **whole mmap is registered once** per `mapping_id` so every weight's device pointer is contiguous over its full length (a per-weight registration is only contiguous within itself → reads off the end → `ILLEGAL_ADDRESS`). ## The finding - A **single** host-mapped zero-copy read is **bit-identical** (verified correct at 1/8/16/32 cold weights per step — confirms #877/#880 for one op). - **Aggregate** distinct host-mapped read traffic above **~0.44–0.65 GB/step silently corrupts** decode: 32 cold weights (~0.44 GB) correct, 48 (~0.65 GB) collapsed generation **16 → 3 tokens** — the #886 signature, but from **stale host-mapped reads past a system-memory-aperture ceiling**, not eviction. An A/B (defer exactly as zero-copy would, but do the real copy) is byte-identical, proving the deferral/admission flow is correct and the **host-mapped READ** is the fault. - `cuMemHostRegister` of the full 16.65 GB mapping **only succeeds with READ_ONLY** (DEVICEMAP-only → `CUDA_ERROR_OUT_OF_MEMORY`), so we can't drop it. CPU pre-faulting didn't help; device pointers are 256-byte aligned (not alignment). To guarantee the hard **byte-identical** gate, the shipped opt-in path applies a conservative **256 MiB** per-residency zero-copy budget (`ZERO_COPY_SAFE_BUDGET_BYTES`, well under the observed-safe ceiling) and copies the rest. Kept as reviewable instrumentation for other hardware (larger host apertures / resizable BAR may not hit the ceiling). ## Measurements (`qwen14b-zp`, `--tokens 16 --steady`, graph ON) | Arm | tok/s | htod/token | zero_copy/token | byte_hit_rate | tokens | captures/fallbacks | |-----|-------|-----------|-----------------|---------------|--------|--------------------| | **WDDM** (`LEGACY_ALLOCATOR=1`) | **7.37** (5.61–8.32, n=3) | 0 (driver) | 0 | n/a | ✅ ref | 4/0 | | Managed streaming | 0.14 | 2.35 GB | 0 | 70.2% | ✅ ref | 2/0 | | Hybrid (safe 256 MiB budget) | 0.04 | 1.71 GB | 0.26 GB | 77.5% | ✅ ref | 2/0 | | Hybrid (full cold set) | — | — | ~0.87 GB | — | ❌ 16→3 | — | Reference tokens (all ✅ rows byte-identical): `[96347, 3375, 724, 11, 358, 2776, 14589, 311, 6723, 429, 498, 3003, 2581, 6617, 315, 752]` **Conclusion:** WDDM keeps ~7.7 GB resident and moves only ~0.6 GB/step through its own paging; our managed budget caps at ~6.1 GB and zero-copy can only safely cover ~0.44 GB/step. Both levers are worse than the OS on this hardware, so the hybrid does not beat WDDM here. A truthful negative — it settles the line of work for consumer-WDDM boxes while leaving the mechanism (a host-mapped-read aperture ceiling) documented for datacenter hardware. ## Constraints verified - Token IDs **byte-identical** to the shipped path (all working arms). - `captures>0`, `fallbacks==0` (graph capture compatible with host-mapped pointers, confirming #880 in the real integration). - `oversubscribed_bytes==0`; `ref_underflows`/`byte_underflows`/ `unaccounted_committed_bytes` all `0`. - No assert in `Drop`; registrations torn down best-effort. - **MANDATORY** `mobius_seqmajor_growth_parity_native_cuda` passed **solo** (2/2, GPU verified clear, no `ILLEGAL_ADDRESS`). ## Validation `cargo fmt` (per-package) ✅ · `clippy -p onnx-runtime-ep-cuda --features cuda` ✅ · `clippy --locked --all-targets -p onnx-genai-engine --features native-backend` ✅ · `test -p onnx-runtime-session` ✅ · `test -p onnx-runtime-ep-cuda` (366) ✅ · mobius parity (solo) ✅ Closes: none — leaving #864 open per request. --------- Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
Adds two env-gated static hot-set pin knobs to the CUDA weight residency cache (threshold+budget, and an explicit key allow-list) to test the one retention shape byte-aware admission never exercised: pin large tensors at first touch and never evict or re-admit them. Both are off by default; the shipped size-blind path stays byte-identical. Measured on qwen14b-zp (managed WDDM streaming), solo, byte-identity gated: - Threshold pin (33 already-resident larges, 1.09 GiB): byte-identical PASS but htod_bytes_per_token unchanged (2.351 vs 2.349 GB/step) — it pins the tensors LRU keeps anyway, so streaming does not move. - Targeted pin of exactly the 21 chronic bypassers (1.048 GB to the byte): decode CORRUPTS to 3 tokens [96347, 3375, 724], the identical #886 signature. Reconciliation: the budget is saturated, so pinning a tensor that currently bypasses necessarily evicts-and-re-admits an equal byte-mass of large stable -slot residents every step (reuse is uniformly 1x/step). The pin does not avoid evict-and-re-admit; it relocates it onto the victims. This CONFIRMS #892's localisation rather than refuting it, and closes item 3 as a definitive negative: no static pin is both safe and useful on a saturated budget. Also softens the reuse-vs-retention wording per review (#864). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
The reviewer's control refuted the conservation/churn explanation: pinning 67 tensors / 2.371 GB (htod +11.8%, more churn than the 21-key arm) stays byte-identical, so the corruption is selection-specific, not churn-mass-driven. Bisecting the 21 chronic-bypasser keys (via the explicit PIN_KEYS allow-list) narrows the trigger to ONE tensor: key 919, 389,283,840 B = 152064 vocab x 5120 hidden x 0.5 = the int4-quantised lm_head / vocab-projection weight. Pinning only 919 reproduces the exact #886 3-token collapse; pinning its int4 scale tensor (920) alone is safe; pinning a 35 MiB block weight (287) alone is safe. Reconciliation with the safe threshold arm: the key trace shows key 919 is never pinned by the size+arrival rule even at 2.4 GB budget (retained=0, bypass=16) because the lm_head is the last large tensor touched each step, so the budget is spent before it is reached. The threshold rule is safe because it structurally cannot pin the poisonous tensor; the bypasser rule corrupts because its allow-list contains it. Mechanism narrowed but not root-caused: CUDA graph capture is ruled out (pinning 919 with ONNX_GENAI_CUDA_GRAPH=0 still corrupts). The first three tokens match the reference before collapse, pointing at retained-page staleness rather than a first-read address error. Stated as a leading hypothesis, not a proven cause. Rewrites the Result 4 reconciliation and conclusion to match the evidence: #886 is isolated to a single tensor, not closed as a conservation wall. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
…y 919) (#944) ## #837 item 3 — and the isolation of #886 to a single tensor Closes #837 (item 3). **Do not merge without your review.** ### Headline update from round 2 Your control killed my conservation/churn explanation, and following the trigger you predicted led exactly where you hoped: **the #886 corruption is one tensor.** - Over-pinning **67 tensors / 2.371 GB** (threshold 30 MB, budget 2.4 GB) forces *more* churn than the 21-key arm (`htod` 2.349 → 2.626 GB/step, +11.8%; `page_ins_per_token` 321 → 375) and stays **byte-identical**. Mass-forced evict-and-re-admit does not corrupt. The trigger is *which* tensor, not churn. - Bisecting the 21 bypasser keys via the `PIN_KEYS` allow-list: | Pinned keys | Count | Result | |---|---:|---| | 919,920,287,247,912,914,855,874,232,304 | 10 | FAIL (3 tokens) | | 919,920,287,247,912 | 5 | FAIL | | 919,920 | 2 | FAIL | | **919** | **1** | **FAIL** — `[96347, 3375, 724]` | | 920 (its int4 scales) | 1 | PASS (16 IDs) | | 287 (a 35 MiB block weight) | 1 | PASS (16 IDs) | **Pinning only key 919 corrupts.** `919 = 389,283,840 B = 152 064 (vocab) × 5 120 (hidden) × 0.5` — the **int4-quantised lm_head / vocab-projection weight**, the one tensor that directly produces the logits deciding token identity. Its scale tensor (920) alone is safe, so it is the weight matrix itself, not the quant metadata. This turns `MEMORY_ARCHITECTURE.md` §3.5's "unisolated value-corruption" into a single-tensor bug. ### Reconciliation — why threshold-pin is safe and bypasser-pin is not Reproducing your 2.4 GB threshold arm **with the key trace on** shows key 919 `retained_page_ins=0 bypass_page_ins=16` — the lm_head is **never pinned**, even at 2.4 GB / 67 tensors. It is the *last* large tensor touched each step (logits run after every block), so the size+arrival budget is spent before it is reached. The threshold rule is safe because it **structurally cannot pin the poisonous tensor**; the explicit rule corrupts because its allow-list **contains** it. The difference between the two selection rules *is* the finding — exactly as you asked me to state it. ### Mechanism: narrowed, and a hypothesis already refuted by a control The stable-slot machinery (#716) exists for CUDA-graph pointer baking, so the obvious cause is a graph hazard when the lm_head is promoted from bypass (throwaway VA, never baked) into the stable-slot population. **Ruled out:** pinning 919 with `ONNX_GENAI_CUDA_GRAPH=0` (`captures=0 replays=0`) still corrupts. The first three tokens match the reference before the collapse, so the retained lm_head reads correctly for the first steps and then goes wrong — consistent with **retained-page staleness** (a pinned page is filled once and served as a hit thereafter, never re-filled), not a first-read address error. Stated as a leading hypothesis, **not** a proven cause. Honest state: isolated to one tensor and one transition (retain-across-steps vs stream-fresh-each-step); precise mechanism not yet root-caused. This also means #892's "evict-and-re-admit" localisation is at best incomplete: here the page is pinned and **never re-admitted**, yet it still corrupts. ### Round-1 evidence (unchanged, verified) - 867/867 keys `reads_per_step = 1.000` — no high-reuse tensor. - Gap = 23 single-read large tensors, `bypassed_page_in_bytes/step = 1.048 GB` to the byte (~90% of the 1.158 GB recoverable gap). - Fresh #886 reproduction on `29d1515a`; softened reuse-vs-retention wording per your note (#864). - Arm 4a (threshold 1.2 GB, 33 tensors): byte-identical PASS, `htod` unchanged — you reproduced this to the byte. ### On item 3 itself The practical answer is unchanged: the gap is only safely recoverable by the funded **#864 zero-copy hybrid** (never evicts a retained page for a cold weight; ~8× on Linux #925/#936; WDDM-blocked by the aperture ceiling, not admission), so no managed-path admission policy should ship. The value here is the isolation, not a policy. ### Gates (run locally; no source changed since they last passed) - `cargo fmt -p onnx-runtime-ep-cuda -p onnx-genai-engine -- --check` ✅ - `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings` ✅ - `cargo test -p onnx-runtime-ep-cuda --features cuda --lib` — **374 passed** ✅ - `cargo test -p onnx-genai-engine --features native-backend,cuda --lib native_decode` — **96 passed** ✅ - mobius parity SOLO (`--ignored --test-threads=1`) — **2 passed** ✅ All bisection runs were env-only (the `PIN_KEYS`/threshold knobs, off by default); the shipped size-blind path is untouched and byte-identical. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com> Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
…t a safety property (#946) docs(memory): retract the static-hot-set safety claim — pinning is not a safety property MEMORY_ARCHITECTURE.md §3.5 stated that a static hot set (retain once, never evict, never re-admit) "provably does not exercise the corrupting path", and the memory-design risk table recommended exactly that as the mitigation. #944 falsified it: pinning a single weight, never evicted and never re-admitted, corrupts decode with the identical #886 signature -- three correct tokens, then generation stops. What replaced the claim is a much better lead than the claim was. The trigger is one specific tensor: key 919, 389,283,840 B = 152064 x 5120 x 0.5, the int4-quantized lm_head / vocabulary projection. Its own int4 scales (adjacent key, same layer, ~1/8 the size) pinned alone are safe; so is a 35 MiB block weight; so is a 2.371 GB set chosen by size threshold, which forces strictly more evict-and-re-admit (htod +11.8%, page_ins_per_token 321 -> 375) and stays byte-identical. Churn mass is not the trigger, tensor selection is. The threshold rule is safe only because it structurally never reaches 919, the last large tensor touched each step. CUDA graph is ruled out. That lm_head is the tensor also explains the shape of the failure, which no previous hypothesis did: it is the only weight that computes token identity, so everything upstream can be exactly right and the tokens still wrong, and a stale logits projection selecting EOS gives "three correct tokens then stop" rather than degenerating output. Consequences recorded where they land. #892's evict-and-re-admit localization is incomplete -- it may be a corrupting path, it is not the only one. #912's hybrid safety argument invoked the static-hot-set claim; its measurements stand, that particular justification does not. The leading hypothesis, retained-page staleness, is stated as a hypothesis with the four experiments that would settle it filed as #945 -- two of which need no CUDA toolkit, which is what has blocked this since #886 was opened. I reproduced the 919 and 920 arms myself before writing any of this. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
#837 item 3 — residency policy gap: attribution (the deliverable) + a rejected design, measured
Refs #837 (item 3 only; items 1 & 2 landed in #842/#843). Do not merge / do not close #837 — this is a report PR. A rejected design with its measurements attached is more useful than a verdict alone, so both are here.
1. The deliverable: the 1.158 GB/step, attributed
bypassed_page_inswas 704 in the last measured run and had never been explained. It now is. A "bypass" is a page-in thatadmit_committed_spanmaps, H2D-fills, hands to the kernel transiently, then frees onDrop— it is counted inhtod_bytesbut never joins the resident set, so its bytes re-stream every decode step. I added byte-weighted accounting for it (GlobalOffloadStats::bypassed_page_in_bytes,bypassed_byte_share(), printed byprofile_native).Measured on
qwen14b-zp, managed path, solo (nvidia-smi --query-compute-appsverified empty immediately before the run), 16 tokens:byte_hit_ratehtod_bytes_per_tokenbypassed_page_insbypassed_page_in_bytesbypassed_byte_shareBypass traffic is 11 % of page-in events but 44.63 % of streamed bytes ≈ ~1.05 GB/step ≈ ~90 % of the 1.158 GB/step recoverable gap. This converts an unexplained counter into the explanation for the whole 1.97×-above-floor figure. It is exactly the count-vs-byte trap #837 warned about: size-blind
StableResidentretains the small tensors (norms/biases) that win the first-fit race for leftover budget and re-streams the large projections transiently, every step. This attribution stands on its own even with the policy change below abandoned, and is what redirects whoever picks item 3 up next.2. The obvious fix, implemented and rejected: byte-aware residency
Env knob
ONNX_GENAI_WEIGHT_OFFLOAD_BYTE_AWARE(default-OFF, opt-in). When enabled, admission keeps the largest tensors resident: retain an incoming tensor iff it is strictly larger than the smallest evictable resident, evicting smallest-first (strictly-smaller only, to avoid thrash when the large set alone exceeds budget).Before / after (managed arm, each run solo,
nvidia-smiclear beforehand)byte_hit_ratehtod/token[96347, 3375, 724, 11, 358, 2776, 14589, 311, 6723, 429, 498, 3003, 2581, 6617, 315, 752]page_ins=3008)page_ins=0)The "after" arm is being abandoned: the policy cannot be safely enabled, so no bytes were moved. The counters are attached because a measured rejection is the useful artifact.
What went wrong: silent numeric corruption, not an error
This is silent numeric corruption, not an admission error, and that is proven, not inferred, from the profiler's error-propagation structure:
generate_with_callbackis?-propagated with.context("steady measured generation")before thebail!("generation emitted N tokens")check. The message we observed is thebail!, which means generation returnedOkwith only 3 tokens (an early EOS from corrupted logits) — a surfacedWeightHandleErrorwould have produced the.context(...)error instead. The runs that stayed byte-identical are exactly those where offload never engaged (page_ins=0), i.e. the branch never ran.The token-identity hard constraint is what caught this. That check exists precisely so a plausible optimisation cannot ship while silently producing wrong output — and this is the first time it has fired on a real proposal in this line of work. Without it, byte-aware would have "improved" the count-based hit-rate report while emitting garbage.
Mechanism, as far as I got (and a defect worth flagging)
A residency policy decides what is resident, not what is computed. Both the resident-hit and bypass paths fill the correct bytes at a stable per-tensor VA; every eviction here only targets
strong_count == 1pages, and the admit loop drains both the compute and copy streams before any eviction. The only behavioural change vs baseline is the eviction target:smallest_evictablepicks the smallest-by-bytes page, whereasnext_evictable_index(used by bothLruandStableResident) picks the front-of-order oldest evictable page.Under those guards, a change of eviction target should be value-neutral. That it corrupts values implicates state that depends on eviction order — most plausibly the physical granule / retained-handle pool accounting — rather than the policy decision itself. Reproduction with CUDA graph OFF rules out captured-VA baking as the sole cause. I could not isolate, within this task, whether this is:
Possibility (b) is the more valuable one and I cannot exclude it. If it holds, it is a latent correctness defect independent of this knob, and should be filed and investigated separately (an instrumented run dumping the resident set + per-tensor checksums across steps under both eviction orders would decide it). Flagging rather than asserting.
3. Conclusion and where the effort should go
The count-vs-byte residency gap cannot be closed by an eviction-policy reorder. Closing it needs the structural lever #837 deferred: a dedicated transient staging zone so a large tensor can be handed to the kernel without displacing a resident page (today even a bypass must evict a resident for physical room). That redirects effort toward #864's hybrid and #750 batching — as the issue anticipated, and as
batch-stage2aindependently corroborated (batch-N amortizes the weight stream exactly 1/N: 301→37.6 pages/token for N=1→8, and the byte counters are contention-invariant process-local EP accounting).#864 raises the stakes for item 3, it does not lower them
Because the managed residency cache only governs Linux / forced-managed Windows / the #864 hybrid (see scope), one might think item 3 is now niche. The opposite: #880 measured the resident hot set at ~9.7× realistic / ~15–24× at large N against ~5.6 GB/s host-mapped reads. Which weights are resident is worth more than it was when this issue was filed — a well-chosen resident hot set is exactly the edge the hybrid claims over the driver's blind WDDM choice. The attribution in §1 is the input to choosing that hot set well.
Scope
Since #874 the Windows default is the WDDM zero-copy path, which has no residency cache (the driver chooses). So item 3 governs behaviour only on the managed path, which is the arm measured here (
ONNX_GENAI_MANAGED_WEIGHT_STREAMING=1): Linux (managed streaming is the only way to run over-budget), Windows withONNX_GENAI_MANAGED_WEIGHT_STREAMING=1/ explicit--vram-limit, and prospectively the #864 hybrid.Hard constraints / gates
[96347, …, 752], matches prior baselines. (Byte-aware ON deliberately fails this; that is the finding, and it is default-OFF.)captures=2(>0),fallbacks=0,oversubscribed_bytes=0,peak_committed_physical_bytes=7,725,907,968 < managed_limit=7,726,753,178,ref_underflows=0,byte_underflows=0,unaccounted_committed_bytes=0.Drop.mobius_seqmajor_growth_parity_native_cuda(--ignored --test-threads=1): GREEN, run solo withnvidia-smiverified clear (treated as binding per Latent gap: seq-major capture-retention proof covers KV binding pointers only, not weight buffers (original flaky-gate claim RETRACTED — contended measurement) #851).cargo fmt(per-package),cargo clippy -p onnx-runtime-ep-cuda --features cuda -D warnings,cargo clippy --locked --all-targets -p onnx-genai-engine --features native-backend -D warnings,cargo test -p onnx-runtime-session,cargo test -p onnx-runtime-ep-cuda --lib weight_paging::tests(18 pass): all green.Measurement hygiene
Wall-clock is deliberately not headlined (WDDM paging our own memory ranges 3.9–28 tok/s here, #863). Each GPU run verified
nvidia-smi --query-compute-appsempty immediately beforehand; GPU serialized with siblingsbatch-stage2a/zerocopy-kernel. One transient contender appeared during a short non-steady probe and is noted; it did not touch the steady A/B arms, which were clean.