Skip to content

Page offloaded weights under a stable VA so offload keeps CUDA graph capture (#716) - #796

Merged
justinchuby merged 2 commits into
mainfrom
squad/716-stable-va-offload
Aug 11, 2026
Merged

justinchuby merged 2 commits into
mainfrom
squad/716-stable-va-offload

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

Summary

Weight offload previously forced whole-step CUDA graph capture OFF: the pager returned a different device pointer on every page-in (alloc_raw/free_raw), and a captured graph bakes pointers into its recorded nodes, so the two were mutually exclusive (resolve_graph_capture_enabled returned false whenever offload was on). The models that most need offload — those that do not fit in VRAM — were exactly the ones that lost graph capture and all of the #708/#728 capture-fragmentation wins (35B-A3B decode 154 -> 34 segments).

This PR gives each retained weight key a reserved-once device virtual address and remaps physical granules underneath on page-in/eviction, so a captured graph replays correctly across evict->repage. It then removes the offload/capture mutual exclusion, gated on the paging path actually being stable-VA (the managed no-spill authority).

Hard prerequisite for #755 (managed no-spill VMM as the default with automatic weight streaming).

What changed, by step

Step 1 - prove the mechanism in isolation first (crates/onnx-runtime-cuda-memory/tests/vmm_stable_va_weight_slot_gpu.rs, passes on this HW): a captured graph that reads from a stable VA still produces correct results after the pages under that VA are swapped for different physical granules holding different weight values. Extends the #727 remap-at-same-VA proof to the weight-value-swap case.

Step 2 - stable-VA slot conversion (crates/onnx-runtime-ep-cuda/src/weight_paging.rs):

  • Per-key StableWeightSlot { va, len } reserved once through the existing Pool CUDA VMM physical handles across reservations #740 authority-scoped pool via allocate_committed/carve() suballocation. No second allocator, no per-weight reservation (the mistake that made Back weight offload pages with stable VMM slots #733 net-negative). Charging stays on the global granule-ref 0->1 / 1->0 attribution.
  • CudaWeightPage::Drop decommits granules but keeps the VA for stable slots (deallocate_span only for transient bypass pages). Never asserts in Drop.
  • Respects committed-granule admission (Use committed granules for VMM weight admission #745): reserving VA is free; only committed physical bytes are charged. 2 MiB granule sizing per Probe: dummy-page mapping primitive for VMM KV cache (#759) #776.
  • Enforceable ordering rule (not a comment): page-ins - and thus eviction-driven decommit of any stable slot - run entirely under the residency mutex; capture is capture-once/replay-many with no page-ins during replay; the engine additionally declines capture when a step reports a bypass/eviction (GLOBAL_BYPASSED_PAGE_INS). So no decommit of a baked VA can occur while a replay is in flight - the case Probe CUDA graph replay across VMM remap #727 explicitly did not prove safe. No cuMemMap/grow inside a captured region.
  • Residency GPU test vmm_retained_weight_key_keeps_a_stable_virtual_address_across_repage proves the VA is identical across evict->repage with one reused granule and no accounting regression. The existing vmm_weight_admission_reuses_owned_granules_without_runtime_alloc_free and weight_offload_gpu suite still pass (no regression).

Step 3 - remove the exclusion, gated on stable-VA (native_decode/{cuda,mod,load}.rs): resolve_graph_capture_enabled now takes weight_offload_stable_va and keeps capture OFF only when weight_offload_enabled && !weight_offload_stable_va. The signal is policy.enabled && policy.managed_no_spill (the explicit byte --vram-limit authority that installs the VMM arena + physical pool). The pointer-unstable alloc_raw/free_raw compatibility path keeps the old capture-off exclusion. #723's StableResident eviction policy is unchanged - this changes only the addressing mechanism underneath it.

Step 4 - measure (crates/onnx-genai-engine/tests/stable_va_capture_offload_e2e.rs): drives the managed no-spill VMM stable-VA authority and verifies token-identical greedy decode with capture OFF and ON in one process, dumping the deterministic offload counters (page_ins, hits, evictions, bypassed_page_ins, vram_alloc_ns/vram_free_ns).

Honest measurement caveat

The full sustained-paging throughput/counter comparison the issue asks for could not be completed in this environment:

What is verified end-to-end here: under the managed no-spill VMM stable-VA authority, decode with capture OFF and capture ON is byte-identical (32/32 greedy tokens), i.e. capture is transparent and no longer force-disabled. The addressing mechanism itself is proven deterministically by the two GPU tests in steps 1-2. A negative/partial result was an acceptable deliverable per the task; this is a positive mechanism result with a partial end-to-end result.

Validation (local; CI not waited on)

  • cargo fmt -p onnx-runtime-ep-cuda -p onnx-genai-engine -p onnx-runtime-cuda-memory -- --check PASS
  • cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings PASS (no --all-targets, the CI gate)
  • cargo clippy --locked --all-targets -p onnx-genai-engine --features native-backend -- -D warnings PASS (also linted the new cuda-gated test under --features cuda,native-backend)
  • cargo test -p onnx-runtime-session PASS (full suite)
  • GPU tests single-threaded PASS: vmm_stable_va_weight_slot_gpu, vmm_retained_weight_key_keeps_a_stable_virtual_address_across_repage, vmm_weight_admission_reuses_owned_granules_without_runtime_alloc_free, weight_offload_gpu (5/5)
  • engine native_decode unit tests PASS incl. new weight_offload_on_stable_va_path_keeps_graph_capture and updated weight_offload_forces_graph_capture_off

Refs #716, #755, #727, #740, #723. Do not merge; do not close #716.

Copilot AI added 2 commits August 11, 2026 16:35
…capture (#716)

Weight offload previously forced whole-step CUDA graph capture OFF: the pager
returned a different device pointer on every page-in (alloc_raw/free_raw), and a
captured graph bakes pointers into its recorded nodes, so the two were mutually
exclusive. Large models that most need offload therefore got none of the
capture-fragmentation wins from #708/#728 (35B-A3B decode 154 -> 34 segments).

Give each retained weight key a reserved-once device virtual address and remap
physical granules underneath on page-in/eviction, so a captured graph replays
correctly across evict->repage. Then remove the offload/capture exclusion, gated
on the paging path actually being stable-VA (managed no-spill authority).

Mechanism (step 1, proven first, in isolation):
- crates/onnx-runtime-cuda-memory/tests/vmm_stable_va_weight_slot_gpu.rs: a
  captured graph reading from a stable VA still produces correct results after
  the pages under that VA are swapped for different physical granules holding
  different weight values. Extends the #727 remap-at-same-VA proof.

Stable-VA slot conversion (step 2, weight_paging.rs):
- Per-key StableWeightSlot { va, len } reserved once via the existing #740
  authority-scoped pool through allocate_committed/carve() suballocation; no
  second allocator, no per-weight reservation (the #733 mistake), charging stays
  on the global 0->1 / 1->0 granule-ref attribution.
- CudaWeightPage::Drop decommits granules but KEEPS the VA for stable slots
  (deallocate_span only for transient bypass pages); never asserts in Drop.
- Page-ins run entirely under the residency mutex, so eviction-driven decommit
  is serialized and cannot race a capture replay; capture is capture-once /
  replay-many with no page-ins during replay. GLOBAL_BYPASSED_PAGE_INS records
  non-retained page-ins as the engine's decline signal.
- Residency GPU test vmm_retained_weight_key_keeps_a_stable_virtual_address_across_repage
  proves the VA is identical across an evict->repage cycle with one reused
  granule and no accounting regression.

Exclusion removal (step 3):
- resolve_graph_capture_enabled takes weight_offload_stable_va; keeps capture
  OFF only when weight_offload_enabled && !weight_offload_stable_va. Signal is
  policy.enabled && policy.managed_no_spill, threaded through
  NativeDecodeCudaOptions from the resolved offload policy.

Measurement (step 4):
- crates/onnx-genai-engine/tests/stable_va_capture_offload_e2e.rs drives the
  managed no-spill VMM stable-VA authority and verifies token-identical greedy
  decode with capture OFF and ON in one process, dumping the deterministic
  counters. On the 0.5B native model the managed no-spill window is pin-or-fail
  (weights fit resident, or a step cannot commit), so sustained-paging counters
  under a fully-successful decode were not obtainable here; the genuinely-
  offloaded 15.5GB model is not native-loadable (blocked on the #384 load-chain
  gap). The addressing mechanism itself is proven deterministically by the two
  GPU tests above.

Refs #716, #755, #727, #740, #723.

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

codecov Bot commented Aug 11, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.02%. Comparing base (2359067) to head (cc675ec).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #796      +/-   ##
==========================================
+ Coverage   79.43%   80.02%   +0.59%     
==========================================
  Files         331      331              
  Lines      137783   137783              
  Branches   137783   137783              
==========================================
+ Hits       109442   110266     +824     
+ Misses      23994    23164     -830     
- Partials     4347     4353       +6     
Flag Coverage Δ
cli-ort-linux 83.50% <ø> (ø)
cli-ort-windows 82.99% <ø> (ø)
mlas 81.24% <ø> (+0.32%) ⬆️
offline 79.86% <ø> (+0.62%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 8 files with indirect coverage changes

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

@justinchuby

Copy link
Copy Markdown
Owner Author

Coordinator pre-merge validation on the merged branch (Windows/RTX box, merged
current main, no conflicts):

  • cargo fmt -p onnx-runtime-ep-cuda -p onnx-genai-engine -- --check clean
  • cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings clean (exact CI gate, no --all-targets)
  • full cargo test -p onnx-runtime-session: 151 passed, 0 failed
  • cargo test -p onnx-genai-engine --features native-backend --lib native_decode: 69 passed
  • vmm_stable_va_weight_slot_gpu::captured_read_from_stable_va_tracks_repaged_physical_granules: passes on device

This closes the largest structural defect on the offload line

Until now weight offload and CUDA graph capture were mutually exclusive:
resolve_graph_capture_enabled returned false whenever offload was on, because
the pager's alloc_raw/free_raw returned a different device pointer on
every page-in and a captured graph bakes pointers into its recorded nodes.

The consequence was backwards: the models that most need offload — those that do
not fit in VRAM — were exactly the ones that lost graph capture, and therefore got
none of the 154 -> 34 segment collapse won by #708/#728/#757.

The crux is now answered positively in isolation before any integration, which
is the sequencing I asked for: a captured graph reading a stable VA stays correct
after the physical granules beneath it are swapped for granules holding
different weight values. That is the exact property the design depends on, and
it extends #727 (which proved remap-at-same-VA replays correctly) to the case that
matters for paging.

The residency test then shows VA identity preserved across evict -> repage with
one reused granule and no accounting regression — i.e. it goes through the
existing #740 pool rather than around it, which is what made #733 net-negative
when it was attempted differently.

Removing the exclusion is correctly gated on policy.enabled && policy.managed_no_spill, so the legacy path keeps its old behavior and unit tests
pin both arms: capture stays ON on the stable-VA path and OFF on the legacy path.

On the missing large-model measurement

Reported honestly as not measured, with the reason, rather than estimated. I
verified the claim rather than accepting it: C:\Users\justinchu\dev\models\qwen14b-zp
contains config.json, genai_config.json, model.onnx, model.onnx.data,
tokenizer.json and tokenizer_config.json — and no inference_metadata.yaml,
so it is genuinely not native-loadable (the #384 gap). That is the right call; a
fabricated or extrapolated throughput number would have been worse than a stated
gap, especially on a box whose identical runs have ranged 3.9-28 tok/s and which
was running three concurrent CUDA agents.

What was verified end to end is the property that matters most for correctness:
capture OFF versus capture ON is byte-identical, 32/32 greedy tokens, under the
stable-VA authority.

What this unblocks

This is the hard prerequisite for #755 (managed no-spill VMM as the default
with automatic weight streaming). The reason #755 was sequenced behind it is now
resolved: flipping the default no longer silently disables capture for every
offloaded model.

It also removes the limitation #794 ran into an hour ago, where the 14B-scale run
could not produce segment or re-capture counters because offload disabled
capture.

Merging.

@justinchuby
justinchuby merged commit 06130d8 into main Aug 11, 2026
11 of 13 checks passed
@justinchuby
justinchuby deleted the squad/716-stable-va-offload branch August 11, 2026 23:59
@github-actions

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/small_generic_bf16_threads=1/1x256x256 29.51 µs 47.81 µs +62.0%
🔴 gather/large_f32_threads=1-internal/131072 29.14 µs 46.17 µs +58.4%
🔴 matmul/small_generic_f16_threads=8/1x256x256 28.56 µs 44.73 µs +56.6%
🔴 matmul/medium_generic_f32_threads=8/32x512x512 898.11 µs 1.37 ms +52.8%
🔴 matmul/small_generic_bf16_threads=8/1x256x256 29.93 µs 45.56 µs +52.2%
🔴 matmul/medium_generic_f16_threads=8/32x512x512 27.90 µs 38.07 µs +36.4%
🔴 qwen3_sampling_processors/top_k_partial_selection 140.38 µs 187.13 µs +33.3%
🔴 gather/medium_f32_threads=1-internal/32768 3.49 µs 4.63 µs +32.7%
🔴 matmul/medium_generic_bf16_threads=8/32x512x512 362.21 µs 473.99 µs +30.9%
⚠️ matmul/small_generic_f32_threads=8/1x256x256 32.86 µs 42.59 µs +29.6%
⚠️ qwen3_sampling_processors/top_k_full_sort_baseline 2.09 ms 2.68 ms +28.3%
⚠️ matmul/large_generic_f32_threads=8/32x1024x1024 3.55 ms 4.43 ms +24.6%
⚠️ matmul/medium_generic_f16_threads=1/32x512x512 28.08 µs 34.31 µs +22.2%
⚠️ matmul/small_generic_f16_threads=1/1x256x256 31.64 µs 37.93 µs +19.9%
⚠️ matmul/medium_generic_f32_threads=1/32x512x512 2.14 ms 2.55 ms +18.8%
⚠️ matmul/large_generic_f16_threads=8/32x1024x1024 83.40 µs 98.80 µs +18.5%
⚠️ gather/medium_bf16_threads=1-internal/32768 2.25 µs 2.65 µs +17.9%
⚠️ matmul/large_generic_bf16_threads=8/32x1024x1024 1.26 ms 1.47 ms +15.9%
⚠️ matmul/small_generic_f32_threads=1/1x256x256 35.14 µs 40.56 µs +15.4%
✅ block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 506.91 µs 579.02 µs +14.2%
✅ gather/medium_f16_threads=1-internal/32768 2.22 µs 2.54 µs +14.1%
✅ gather/large_bf16_threads=1-internal/131072 12.82 µs 14.31 µs +11.6%
✅ matmul/large_generic_f32_threads=1/32x1024x1024 8.61 ms 9.59 ms +11.5%
✅ reduce_mean/small_f32_threads=1-internal/4096 14.02 µs 15.58 µs +11.2%
✅ sampling_latency/min_p_per_token 218.83 µs 243.08 µs +11.1%
✅ matmul/large_generic_f16_threads=1/32x1024x1024 73.21 µs 81.14 µs +10.8%
✅ add/large_f16_threads=1-internal/4194304 38.99 ms 42.84 ms +9.9%
✅ reduce_mean/large_f32_threads=1-internal/262144 925.43 µs 1.01 ms +9.0%
✅ add/large_bf16_threads=1-internal/4194304 39.58 ms 43.12 ms +8.9%
✅ gather/large_f16_threads=1-internal/131072 12.55 µs 13.67 µs +8.9%
✅ gather/small_bf16_threads=1-internal/4096 454.7 ns 490.2 ns +7.8%
✅ gather/small_f32_threads=1-internal/4096 638.9 ns 688.5 ns +7.8%
✅ add/small_f32_threads=1-internal/1024 193.8 ns 208.0 ns +7.3%
✅ matmul/medium_generic_bf16_threads=1/32x512x512 481.72 µs 516.07 µs +7.1%
✅ add/medium_f16_threads=1-internal/262144 2.45 ms 2.63 ms +7.1%
✅ add/large_f32_threads=1-internal/4194304 38.92 ms 41.28 ms +6.1%
✅ block_quantized_matmul_cached_dense/mxfp4_preexpanded_dense_oncelock_like_proxy/1x1024x1024 42.01 µs 44.49 µs +5.9%
✅ add/medium_bf16_threads=1-internal/262144 2.57 ms 2.69 ms +4.7%
✅ add/small_bf16_threads=1-internal/1024 12.72 µs 13.25 µs +4.2%
✅ reduce_mean/medium_f32_threads=1-internal/65536 239.47 µs 249.21 µs +4.1%
✅ qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline 3.73 ms 3.82 ms +2.5%
✅ matmul/large_generic_bf16_threads=1/32x1024x1024 1.82 ms 1.85 ms +1.8%
✅ sampling_latency/top_p_per_token 385.92 µs 390.98 µs +1.3%
✅ qwen3_sampling_processors/top_p_fast_after_top_k 529.30 µs 532.97 µs +0.7%
✅ qwen3_sampling_processors/top_k_top_p_fast 682.98 µs 676.51 µs -0.9%
✅ kv_cache/alloc_dealloc_pages 40.96 µs 40.34 µs -1.5%
✅ sampling_latency/greedy_per_token 3.23 µs 3.12 µs -3.6%
✅ qwen3_sampling_processors/top_k_top_p_full_sort_baseline 5.95 ms 5.66 ms -5.0%
✅ grammar_masking/llguidance_compute_mask/32 80.69 µs 76.09 µs -5.7%
✅ block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 77.86 µs 72.74 µs -6.6%
✅ logit_processing/seven_processor_chain_per_step 349.95 µs 323.81 µs -7.5%
✅ add/small_f16_threads=1-internal/1024 14.24 µs 13.17 µs -7.5%
✅ sampling_latency/top_k_per_token 53.73 µs 49.58 µs -7.7%
✅ tokenization/encode_tokens_per_second 395.25 µs 363.55 µs -8.0%
✅ tokenization/decode_tokens_per_second 6.39 ms 5.87 ms -8.1%
✅ block_quantized_matmul_cached_dense/mxfp4_cached_dense_repeated_call/1x1024x1024 50.11 µs 44.01 µs -12.2%
🟢 gather/small_f16_threads=1-internal/4096 591.3 ns 487.1 ns -17.6%
🟢 block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 435.89 µs 355.92 µs -18.3%
🟢 add/medium_f32_threads=1-internal/262144 3.21 ms 2.62 ms -18.5%

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: { 4.17 3.42 5.69 }
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 pushed a commit that referenced this pull request Aug 12, 2026
…ering

The seq_major_fixed_stride_grows_under_captured_replay_without_recapture
test in vmm_kv_layout_residency_gpu.rs failed ~50% of the time when run in
isolation on a cold CUDA context (all-zero or partial readbacks with no CUDA
error). Root cause is a test-harness ordering defect, not a product defect
and not a merge interaction with #796 (which added no cuda-memory source):

The baseline zero-fill and the residency readback issued synchronous copies
on the legacy default stream, while the captured graph memset ran on a
created non-blocking stream. Those two streams are mutually exempt from
implicit synchronization, so on a cold context the readback raced the
just-launched graph memset. It only appeared to pass because the sibling
subtest runs first alphabetically and warms the context.

Fix: route every device operation (baseline fill, captured memset, readback,
tail write/read) through the same stream so a single cuStreamSynchronize is a
total order. No assertion was relaxed; the product property (a captured graph
replays unchanged across tail-granule growth at a stable VA — the #782/#794
0-re-captures win) is genuinely demonstrated and now reliable (40/40 in
isolation).

Also:
- Replace cascading result.unwrap() (in commit_and_measure and the growth
  test) with std::panic::resume_unwind so the first failure reports cleanly
  instead of panicking again during teardown with 'Any { .. }'.
- Add a mismatch_report diagnostic that names the failure mode (never-visible
  vs torn write) and a cuGraphGetNodes node-count assertion proving the
  captured graph holds exactly the memset node.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 12, 2026
…#755) (#798)

Closes #755.

Makes the authority-scoped **managed no-spill VMM** the **default** on
the native CUDA path — no flag required — with **automatic weight
streaming** when a model exceeds the resolved device budget. Being
larger than the budget becomes a supported configuration rather than an
error.

Builds directly on the prerequisite #716 (PR #796), which made weight
offload capture-compatible by paging weights under a stable virtual
address, so flipping the default no longer disables CUDA graph capture
for offloaded models.

### What "default" now means
1. **Managed VMM is on without a flag.** Native CUDA loads run
memory-strategy inference (#752) unconditionally and it *drives* policy:
allocation goes through the physical-handle pool (#740) with
committed-granule admission (#745), governed workspace planning
(#747/#753/#795), transactional growth grants (#748), against one
server-owned authority per device (#744).
2. **Over-budget is not an error.** When the resolved budget cannot hold
the weights, the runtime auto-enables weight streaming/offload instead
of failing.
3. **`--vram-limit` is an override**, not the trigger.
4. **The legacy allocator stays reachable** for one release via
`ONNX_GENAI_LEGACY_ALLOCATOR=1` (back-compat:
`ONNX_GENAI_DYNAMIC_KV_WEIGHT_LENDING=0`).

### Observability
The resolved budget, chosen strategy and offload state are printed at
startup and exposed in `/v1/resources` under a new `memory_strategy`
block, so the new default is observable rather than mysterious.

### Invariants preserved
- **No silent WDDM spill** — committed physical bytes stay within the
managed cap; `cuMemCreate` structurally cannot spill (#704).
- **Capacity refusal stays a pre-header HTTP 429** (#743);
committed-granule admission (#745) and growth grants (#748) unchanged.
- **A model that fits does not start paging** under the new default —
tested explicitly (the most likely regression).
- **Capture stays ON when offload is active on the stable-VA path**
(#796 property), pinned by the existing unit tests.
- No prefetch re-introduced (#715); no assert inside any `Drop`.

### Measurement (native CUDA, deterministic counters lead)
Ignored live e2e `qwen2_5_0_5b_managed_vmm_default_e2e` (Qwen2.5-0.5B
int4 mobius, **same process**, interleaved managed-default vs. legacy
opt-out):

| scenario | strategy | offload | managed_no_spill | resolved budget |
committed device bytes | page-ins / evictions |
|---|---|---|---|---|---|---|
| **A** fits, managed default (no flag) | `FullResident` | false | true
| 7,730,940,928 | 381,681,664 | **0 / 0** (does not page) |
| **B** synthetic over-budget (`--vram-limit 268,435,456`) |
`DynamicWeightResidency` | true | true | 268,435,456 | 90,177,536 («
cap; no spill) | **434 / 432** |
| **C** legacy opt-out (`ONNX_GENAI_LEGACY_ALLOCATOR=1`) |
`FullResident` | — | **false** | — | — | — |
| **D** explicit `--vram-limit 12 GiB` | override applied | — | — |
12,884,901,888 | — | — |

In **B**, at that extreme synthetic cap the residency arithmetic
**refused cleanly** ("no page is evictable") rather than spilling — the
no-spill contract holds.

**Caveats (measurement discipline):**
- Wall-clock / tok/s is **not** reported: this box ranged 3.9–28 tok/s
across identical runs and other agents build concurrently. Deterministic
counters only.
- A genuine native large-model run that exceeds VRAM was **not
measured**: `qwen14b-zp` lacks `inference_metadata.yaml` (#384) so it is
not native-loadable. The over-budget condition was **synthesized** with
a small explicit budget, as stated.
- **CUDA-graph segment count** is not a public counter;
capture-ON-under-offload on the stable-VA path is pinned by the #796
unit tests, not re-measured here.

### Validation
- `cargo fmt -p onnx-genai-engine -p onnx-genai-server -- --check` clean
(`--all-targets --all` fmt hits Windows os error 206 on this box's long
worktree paths — a command-line-length artifact, not a formatting
issue).
- clippy `-D warnings`: engine (`native-backend`; also
`cuda,native-backend`), server, `onnx-runtime-ep-cuda --features cuda` —
all clean.
- Full `cargo test -p onnx-runtime-session` passes.
- Engine `memory_strategy` unit tests (12, incl. 4 new) and server
resources tests pass; cuda-gated engine tests incl. #796 capture arms
pass.
- **Pre-existing unrelated failure:**
`failed_runtime_shrink_preserves_policy_and_ledger_limit` (server) fails
identically on `origin/main` (confirmed via stash); its error is on the
`host` authority, untouched by this native-CUDA-only change. Not fixed
here (out of scope).

Do **not** merge and do **not** close the issue via this PR beyond the
`Closes #755` reference; leaving final disposition to review.

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 12, 2026
)

## Headline result

`qwen2.5-0.5b-q4_0-mobius` does **not** structurally decline native CUDA
graph capture on current `main`. The `captures=0` result in #801 was a
policy artifact in its test harness: the capture-OFF phase set
`ONNX_GENAI_CUDA_GRAPH=0` before the process-wide `RuntimeConfig`
`OnceLock` was initialized, then the later capture-ON phase mutated the
environment in the same process. That mutation cannot change the cached
runtime policy, so predicate `ONNX_GENAI_CUDA_GRAPH` continued to
decline capture.

With each policy phase isolated in its own process, the real model
captures successfully. The forced-growth run measured deterministic
counters:

- capture ON, head-major: `growth_events=3`, `captures=4`,
`invalidations=4`, `replays=39`, no decline reason
- capture ON, seq-major: `growth_events=3`, `captures=4`,
`invalidations=4`, `replays=39`, no decline reason
- capture OFF: `captures=0` now reports `predicate ONNX_GENAI_CUDA_GRAPH
declined capture`

This also supplies the missing end-to-end reproduction discussed in
#801/#782/#797: growth currently triggers re-capture for both layouts.
Fixed-stride seq-major removes VMM KV-copy bytes (`688,576 -> 0`) but
does not remove the decoder's current growth invalidation. Changing that
invalidation is correctness-sensitive because captured launches may bake
the capacity geometry; this PR does not change eligibility or
invalidation semantics.

## Reporting change

- Replaces the decode-level boolean resolution with an
invariant-carrying decision: every disabled decision owns a named
predicate and reason.
- Covers policy, structural, binding-shape, attention-mask,
auxiliary-output, and runtime capture-audit declines.
- Emits one warning per decoder session and exposes `decline_reason`
through CUDA debug stats.
- Prints `cuda_graph_decline_reason` beside the existing `cuda_graph`
counters in `profile_native`.
- Updates the #801 Mobius regression to run ON/OFF phases in separate
processes and assert capture ON actually captures.
- Adds an anti-regression test that every decode-level decline names its
predicate.

#796/#798 are reflected in the decision precedence: stable-VA managed
no-spill offload does not decline capture; only `weight_offload_enabled
&& !weight_offload_stable_va` does.

## Validation

- `cargo fmt -p onnx-genai-engine -p onnx-genai-bench -- --check`
- `cargo clippy --locked --all-targets -p onnx-genai-engine --features
native-backend -- -D warnings`
- `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings`
- full `cargo test -p onnx-runtime-session` (153 primary tests; all
secondary binaries passed)
- targeted engine capture-decision tests (7 passed)
- real-model Mobius seq-major growth regression (all three isolated
phases passed)

Wall-clock is intentionally not compared: the post-rebase real-model run
was heavily contended (individual model runs ranged roughly 69-259
seconds). Only deterministic counters are used above.

Refs #801, #782, #797, #796, #798. Does not close any issue.

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 12, 2026
## What

Documentation-only sync of five memory/KV design docs to the merged,
executable-test-backed facts about VMM commit geometry, KV layout,
weight offload, CUDA-graph capture, and plugin export. No code or tests
changed.

`MEMORY_ARCHITECTURE.md` is treated as the single source for KV geometry
and the audit table; the other four link into it rather than restating
(per #791).

## Most misleading claim found

`MEMORY_ARCHITECTURE.md` — the document explicitly designated the single
source of truth — still asserted the engine *"explicitly invalidates the
graph on every KV bucket growth for both layouts"* and that *"the engine
does not yet use that fact to retain the captured graph,"* with a table
showing seq-major at `captures=4, invalidations=4`. That was falsified
by **#811**: conditional invalidation has landed, seq-major fixed-stride
growth now **keeps** the captured graph (growth-attributable
invalidations **3 → 0**, `captures=1/invalidations=1`, matching the
no-growth control), verified by
`mobius_seqmajor_growth_parity_native_cuda.rs` including a negative
oracle. Because this doc is the single source others link to, the stale
claim had the widest blast radius.

## Changes

- **MEMORY_ARCHITECTURE.md** — correct the #811 conditional-invalidation
prose + table (verified against `cuda.rs`
`binding_growth_signature`/`record_growth_decision` and the parity
test); add the ORT-backend KV-routing + #762 plugin-export note
(verified `allocator.rs:232` `for_session_device` selects by device, not
implementation); thread #822 (granule queried), #812 (engine-side
consumer gate), and a #800/#802 record correcting this doc's earlier
fp16 sizing of Phase-2a scores (verified `attention.rs:368` uses
`dtype.element_size()`).
- **CUDA_GRAPH_CAPTURE.md** — replace the mutual-exclusion result with
#796 (stable VA removes it), #798 (managed no-spill default), #811
(conditional invalidation), and the #804/#807 `captures=0`
process-artifact correction.
- **WEIGHT_OFFLOAD.md** — correct the "mutually exclusive today" claim
(#796) and the north-star status for the managed no-spill default
(#798).
- **KV_INSERTION_DESIGN.md** (most stale, 2026-07-15) — add a
synchronization banner linking to `MEMORY_ARCHITECTURE.md`; note the
layout line post-dates the memo, prefix sharing is a landed primitive
(#777), and block-table paged attention remains a proposal (verified: no
`block_table` in any CUDA attention kernel).
- **DESIGN.md** — forward-pointer in the already-superseded §39.5 noting
layout (seq-major/token-major, #787/#792) is now the primary residency
lever.

## Method

Every retained performance claim cites the PR that measured it; measured
fact / design intent / not-yet-built are distinguished; superseded
designs are labelled, not deleted. The "still open" item (engine commits
flat buckets, not the dense live-prefix ranges `kv_commit.rs` computes,
so the `layers×2` floor is not yet realised through the engine) is
described as in-flight, not delivered.

## Note on #762

The task framing had #762 (EP plugin export) as open; `gh pr view 762`
shows it **merged** today. It is described accurately per that ground
truth: the plugin-EP mechanism landed for the CPU EP (validated
end-to-end), while the CUDA plugin shim compiles but is unvalidated on
hardware — so plugin-EP-hosted VMM KV remains in-progress design intent,
not a shipped capability.

## Validation

Documentation only — `git diff --stat` touches exactly the five `.md`
files; working tree otherwise clean. **Not merged; no issue closed.**

Refs #736, #762, #776, #777, #783, #787, #791, #792, #794, #796, #797,
#798, #801, #802, #804, #807, #811, #812, #822

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 12, 2026
…ative) + #384 metadata generator (#834)

## Summary

Demonstrates the KV-cache committed-**physical**-bytes floor end-to-end
on **qwen14b** via the native CUDA decode path, comparing **head-major
(BNSH)** vs **seq-major (BSNH)** layouts in the same session, swept
across context capacities that bracket the 2 MiB-per-stripe granule
threshold (#776). Token IDs are **byte-identical** between layouts at
every point.

This is the first time this line (chased since #797) has been driven
through the *engine* on a model whose head stripe actually reaches a
full granule (qwen14b @ 8192 ctx: `8192 x 128 x 2 = 2,097,152 B =
exactly 2 MiB`), rather than the sub-granule qwen0.5b of #827.

## Headline result — honest negative (per #794 / #812 / #827 precedent)

Head-major and seq-major commit **byte-identical** physical KV bytes at
every swept capacity:

| KV capacity (tokens) | committed bytes (both layouts) | granules |
ratio seq/head |
|---:|---:|---:|---:|
| 1024 | 402,653,184 | 192 | 1.00x |
| 2048 | 603,979,776 | 288 | 1.00x |
| 4096 | 1,006,632,960 | 480 | 1.00x |
| 8192 | 1,811,939,328 | 864 | 1.00x |

Expected-if-it-separated: head-major `layers x 2 x kv_heads` = **768
granules (~1.5 GiB)** vs seq-major `layers x 2` = **96 granules (~192
MiB)** — the 8x that never appeared.

## Why it does not separate

`growth_events == 0` and `committed_len == capacity` for **both**
layouts: the engine commits the full KV bucket **eagerly** on
reservation — reservation *is* the commit, so layout cannot change the
total. Seq-major's fixed full-context stride *is* active and correct
(`max_len == 8192` at every capacity vs head-major `max_len ==
capacity`, tokens match), which proves the seq-major kernel/layout
resolution works — but a fixed stride only makes **growth** free (#797's
0 bytes moved) and preserves the captured graph (#811/#812); it does not
shrink an eagerly-committed full bucket.

The 768-vs-96 (8x) floor stays a **driver-level** property
(`vmm_kv_layout_residency_gpu`) realized only by a **live-prefix
commit-on-demand** path, which the engine does not do on initial
reservation. The new guardrail assertions (`growth_events==0`,
`committed_len==capacity`, `head==seq committed`) will **turn red** the
moment a future change makes seq-major commit less — i.e. they are a
tripwire for the desired win.

Measurement used the in-place **VMM** allocator
(`ONNX_GENAI_CUDA_VMM=1`), which reports granule-rounded **physical**
bytes; the legacy/governor allocator reports nominal bytes and was
deliberately avoided. Capture was declined throughout
(`ONNX_GENAI_CUDA_GRAPH=0`); offload/weight-streaming (16.6 GB model vs
7.7 GB budget, #796) was active and did not perturb the KV commit
counters.

## Deliverables

1.
**`crates/onnx-genai-engine/tests/qwen14b_kv_floor_sweep_native_cuda.rs`**
— child-process-isolated sweep harness (mirrors
`mobius_seqmajor_growth_parity_native_cuda`), honoring process-frozen
config (#804/#807). Self-execs per `(layout, capacity)`; asserts token
parity + layout resolution + the deterministic guardrails above.
`#[ignore]`, gated on `cuda,native-backend`.
2. **`scripts/gen_inference_metadata.py`** (#384) — emits
`inference_metadata.yaml` from `genai_config.json` for any such export.
Reproduces the hand-written qwen14b metadata **byte-for-byte**. Turns
the one-off local `qwen14b-zp` fix (which unblocked native loading for
this whole measurement) into a reproducible artifact; the model file
itself stays local (models live outside the repo).
3. **Docs** — `MEMORY_ARCHITECTURE.md` floor-progression table +
eager-full-bucket explanation; `MODEL_METADATA.md` #384 generator
section.

## Method notes

- Seq-major was obtained by patching a **copy** of the model
(`kv_layout=1` on all 48 GQA nodes), as #801/#805 did for mobius — the
original model directory was never modified.
- Reported committed **physical** bytes throughout, never nominal
content bytes.
- Deterministic counters lead; wall-clock is omitted (this box's
identical runs ranged 3.9-28 tok/s) and no number was extrapolated that
was not measured.

## Unrelated pre-existing fix (disclosed)

`crates/onnx-runtime-ep-cpu/src/kernels/matmul_nbits.rs`: added a
`#[cfg(not(target_arch = "aarch64"))] let _ = dot_kernel;` guard in
`borrowed_affine_int4_matmul` to silence a pre-existing x86
unused-variable lint that blocked `-D warnings` (matches sibling
functions at 3779/4272/4448). Behavior-neutral; the mobius ignored test
still passes (2 passed).

## Validation

- `cargo fmt -p onnx-genai-engine -p onnx-runtime-ep-cpu -- --check`
(per-package; `--all` hits Windows `os error 206`)
- `cargo clippy --locked --all-targets -p onnx-genai-engine --features
native-backend -- -D warnings` — pass
- `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings`
— pass
- `cargo test -p onnx-runtime-session` (full) — pass
- `cargo test -p onnx-genai-engine --features cuda,native-backend --test
mobius_seqmajor_growth_parity_native_cuda -- --ignored --test-threads=1`
(#814 policy) — 2 passed
- New sweep harness run twice; all guardrails passed.

Refs #797 #787 #827 #384 #777. Do **not** merge; does **not** close any
issue.

---------

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
justinchuby added a commit that referenced this pull request Aug 12, 2026
## Summary

- extend `profile_native --steady` observability with resolved memory
strategy, CUDA graph counters/declines, residency activity, and
committed VMM physical bytes
- document the first genuinely over-budget Qwen 14B native CUDA
measurement enabled by #384
- verify #796 and #798 at scale; record an honest weaker-than-historical
#723 residency result with #704 no-spill evidence

## Measured result

Capture was varied in isolated child processes (`ON -> OFF -> ON`)
because `RuntimeConfig` is process-frozen.

- **Capture ON:** `captures=2`, `replays=11`, `fallbacks=0`,
`invalidations=2`
- **Streaming in the same session:** `page_ins=5299`, `hits=8573`,
`hit_rate=61.80%`, `evictions=3850`, `bypassed_page_ins=691`
- **Default selection:** `DynamicWeightResidency`,
`fits_resolved_device_budget=false`, `weight_offload_enabled=true`,
`managed_no_spill=true`; no explicit `--vram-limit`
- **Physical ceiling:** VMM peak committed physical bytes `6,996,099,072
/ 7,730,940,928` (90.49%), zero oversubscription and zero unaccounted
committed bytes
- **Correctness:** identical greedy token IDs across both runs in every
process and across capture ON/OFF
- **Negative worth preserving:** the scale workload measured 61.80% hits
and 3,850 evictions, not #723's earlier 74.18% / zero-eviction result.
No old-policy A/B was run, so the document does not claim a regression
ratio.

Wall-clock was contended by concurrent GPU work and is intentionally not
claimed.

References #796, #798, #723, #704, and #384.

## Validation

- `cargo fmt -p onnx-genai-bench -- --check`
- `cargo clippy --locked --all-targets -p onnx-genai-engine --features
native-backend -- -D warnings`
- `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings`
- `cargo test -p onnx-runtime-session`
- `cargo test -p onnx-genai-engine --features cuda,native-backend --test
mobius_seqmajor_growth_parity_native_cuda -- --ignored --test-threads=1`
- `cargo test -p onnx-genai-bench --features bench-native,cuda --bin
profile_native`

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 12, 2026
…verdict for native batching (#750) (#844)

Stage 1 of native batched decode (#750). Per the reprioritization
comment, stage 1 is **(1) validate the premise, (2) scope the smallest
honest increment, (3) land it or state the staging plainly.** This PR
does not deliver batching — it lands the measurement instrument the
effort needs and reports the verdict below. **Do not merge / do not
close #750.**

## 1. Verdict on the premise: does native batching amortize the weight
stream?

**CONFIRMED — conditional on a single-fused-forward implementation.**
Established by code reading plus the existing #837 counters (GPU is
contended by three sibling agents; per the protocol I did the CPU-side
work first and did not run a contended headline).

**Evidence the weight-residency cache is *not* per-sequence:**
- `CudaWeightResidency`
(`crates/onnx-runtime-ep-cuda/src/weight_paging.rs`) is owned
per-EP/per-device and keyed purely by weight identity: `pages:
HashMap<u64, Arc<CudaWeightPage>>`. There is no sequence or batch
dimension anywhere in the cache, its policy, or its stable-VA slot map.
- The page-in entry point `resident_mapped(key, ...)` /
`page_lazy_weight(key, ...)` (`provider.rs`) returns a cache **hit** for
a resident key regardless of which row/sequence triggered it. A miss
streams once and admits under the LRU/stable-resident budget.
- The ORT batched path already exploits exactly this:
`Engine::continuous_batch_manager` (`batched.rs:649`) binds `batch_size
= N` into **one** `BatchedStaticCacheDecodeSession` run per step (GEMM
`M = N` rows). Each weight op executes once per step and streams once
for N tokens. The native path simply has no equivalent — it structurally
pins batch to 1.

**Corroborating counters (#837 baseline, qwen14b-zp, 16 tokens):**
`htod_bytes = 65,772,419,072` ⇒ 4.11 GB/token; `page_ins = 5,535` ⇒ ~346
page-ins/step. ~346 distinct weight tensors streamed once per forward is
consistent with "one forward per decode step, each offloaded weight
paged exactly once." A single-fused batch-N forward keeps ~346
page-ins/step while emitting N tokens ⇒ `htod_bytes/token` and
`page_ins/token` fall ~1/B.

**The premise FAILS only if** batch-N is implemented as N independent
forwards (or N per-sequence graph replays). On this over-budget model
(16.65 GB weights vs 7.73 GB budget) the working set is evicted every
step, so a per-sequence loop would miss each weight N times per step and
amortize nothing. So the premise is a statement about **implementation
discipline**: batch must be one fused forward with `M = N`, never a
per-sequence loop.

**The knee is unlocated.** As B grows, per-step compute grows and KV
grows `B × context`; at some B the step becomes compute-bound (or
KV+activations shrink the resident weight budget enough to lower
`hit_rate`) and scaling flattens. Honest claim: "scales with B up to a
knee we have not located," never "batch 8 = 8x."

## 2. Chosen scope, and why the batch-N increment itself is multi-PR

The issue's suggested first increment (make the native path report
`batch_supported = false`) is **already landed and tested** (#758 —
`driver.rs:697-713`, `routes/admin.rs`, server test at
`server/tests.rs:1876`). It is not available to re-land.

The smallest *structural* increment — uniform-length batch-N (all
sequences stepping together, no ragged admission, sidestepping the paged
path) — is **genuinely multi-PR** on the native path.
`native_decode/cuda.rs` (3,963 lines) assumes batch=1 structurally in at
least four coupled places, each of which must change together to keep a
valid decode:
1. **Input binding** collapses batch/query-seq to 1 (single-token
`[1,1,·]` device bindings, `cuda.rs:574`).
2. **KV cache** is a single-sequence "batch-0 addressing" contiguous/VMM
buffer with no row dimension (`cuda.rs:1782`, `ensure_capacity_*`); N
sequences need N reservations or a batched KV layout.
3. **CUDA-graph capture** bakes the batch=1 shape; batch-N forces a
recapture per batch shape (must be quantified against the `captures > 0
/ fallbacks == 0` gate, #796).
4. **Symbolic-axis resolution** pins axis 0 to 1 (`cuda.rs:2371-2432`),
rejecting any non-collapsible batch axis.

Given the hard constraints (batch-1 byte-identical; capture preserved;
`peak_committed < managed_limit` with KV now `B × context`; ledger
counters at 0), landing a *correct* batch-N in one PR would be large and
risky. Per the explicit guidance ("prefer a smaller increment that lands
over a larger one that does not," and "a well-evidenced staging plan is
a good stage-1 outcome"), I chose to land the **measurement instrument**
now and stage the implementation:

- **Stage 2a** (next): batch-N *input binding + fused forward* behind
the existing admission contracts, uniform length only, with a batch-1
byte-identical guard test. No KV layout change yet — validate the
fused-forward amortization on a 2-row synthetic before touching KV
growth.
- **Stage 2b**: batched KV (N reservations under the #740 pool, or
batched layout) + recapture accounting.
- **Stage 2c**: wire `continuous_batch_manager` to a native
uniform-batch manager; ragged/continuous batching follows.

## 3. What this PR lands

`weight_offload_amortization: emitted_tokens=… htod_bytes_per_token=…
page_ins_per_token=…` on both native report paths in `profile_native`.
These are the batch-invariant ratios the protocol mandates leading every
batch comparison; `profile_native` previously printed only the raw
accumulated counters, leaving the ratio to hand-computation and to be
masked by wall-clock variance. Pure bench-only reporting — **runtime
decode is byte-identical**. Unit test pins the ratio against the #837
baseline (65,772,419,072 / 16 = 4.11 GB/token).

**Baseline (from #837, to be re-emitted by the new line once the GPU
frees):**

| metric | value |
|---|---|
| htod_bytes_per_token | 4.11 GB (65,772,419,072 / 16) |
| page_ins_per_token | ~346 (5,535 / 16) |

An "after (batch-N)" row is deliberately absent — this PR lands no
batching. Stage 2a will populate it, and the ~1/B drop in these two rows
is the pass/fail signal.

## Validation
- `cargo fmt -p onnx-genai-bench -- --check` ✓
- `cargo clippy -p onnx-genai-bench --features bench-native,cuda --bin
profile_native -- -D warnings` ✓
- `cargo test -p onnx-genai-bench --features bench-native,cuda --bin
profile_native` (new `per_emitted_token_divides_counter_by_tokens`) ✓

No memory/workspace-governance code touched, so the mobius parity gate
is N/A for this PR.

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 13, 2026
…#866)

Closes #857 — do not merge yet (opened for review per the issue).

## TL;DR (read this first — the framing changed after #863/#864)

This change is **correct, byte-identical, and safe**, and within the
managed weight-offload path it recovers the idle full-context KV
reservation and cuts weight streaming **1.68×** (`htod_bytes_per_token`
3.94 GB → 2.35 GB, `page_ins_per_token` 372 → 188, `hit_rate` 57% →
78%). But two hardware findings landed while this was in flight and they
bound how much it is worth:

- **#863:** WDDM pages out *our own* VMM-mapped granules under
system-wide pressure, not just `cuMemAlloc`. So `oversubscribed_bytes ==
0` / `peak_committed < managed_limit` are **ledger** guarantees, not
proof of physical residency. I now measure `nvidia-smi` device-resident
alongside the ledger (below) and treat residency as real only where the
two agree.
- **#864:** the managed *copying* offload path is ~30× slower than the
legacy **WDDM zero-copy** path (5.53 vs 0.18 tok/s, n=5/4), because each
weight is read exactly once per decode step, so copying it into VRAM
pays alloc/map/evict/sync overhead that WDDM avoids. This PR makes a
path that trails WDDM trail it by less; it does **not** close the
path-level gap.

So I am **not manufacturing a win**. I present this as a correct,
well-evidenced *within-path* improvement plus an explicit statement
(bottom) of where the real leverage now sits. If the reviewer prefers to
hold it until the hybrid direction is decided, that is reasonable.

## What this does

At load the weight-residency budget was derived once as
`resolved_device_budget − kv_bytes_per_token × max_context` and never
revisited, statically withholding the **full declared-max-context** KV
reservation (1.611 GB on qwen14b-zp, ctx 8192) from weights even for
short requests. This lends that reservation to weights and lets **KV
growth reclaim it on demand**, still guaranteeing a sequence reaches its
declared `max_context`, and **leaves a tunable device headroom**
(load-bearing per #863).

## Design (points 1–4)

**1. Reclaim path.** Budget/accounting change that unlocks already-built
machinery. `reconcile_cuda_offload_budget_after_native_load` withholds
only an **initial KV floor** (`kv_capacity_bucket(1, max)`, ~50 MB)
instead of the full reservation. Bytes above the floor (less headroom)
are lent to the weight cache. As KV grows,
`ensure_capacity_seq_major_fixed → prepare_mapped_growth(KvCache)`
drives `MappedGrowthAuthority`: it consumes KV's allowance, then
unassigned capacity, then **transfers allowance from the weight holder**
(`reclaim_priority()=0`) and calls `reclaim_mapped()` (LRU eviction of
unreferenced pages). Transactional with rollback, serialized (no
deadlock), requester filtered from its own victim list. Weights shed
**in step** with KV growth; no refusal path.

**2. Hysteresis.** The existing **monotonic KV bucket growth**
(power-of-two) gives O(log max_context) reclaim events, each permanently
lowering the weight ceiling. The lending high-water mark is a fixed
budget set once at load, not re-lent per event. No thrash.

**3. Capture.** Budget/limit changes move **no VA and no physical
mapping**. Stable-VA weight paging (#716/#796) keeps captured graphs
valid. **Measured:** `captures=2, fallbacks=0`.

**4. Scope.** Single-sequence only. Serving/admission (#750) out of
scope.

**Gating.** Only when `managed_no_spill && commits_on_demand &&
dynamic_kv_weight_lending_enabled()` — the exact condition registering
the weight cache as a reclaimable holder. Otherwise the static
reservation is kept. Operator override unaffected. The non-override auto
budget is recomputed from the elastic `available` (the plan-time
`device_budget_bytes` carried the full reservation and would otherwise
clamp the budget straight back — the one real trap, fixed).

## Safety: WDDM headroom (question RESOLVED by #863)

#863 proved single-process and deterministically that this card pages
out our own `cuMemCreate`/`cuMemMap` granules: an arena-mirroring
allocator committed and *touched* all of 9,984 MiB on an 8,188 MiB card,
but device-resident capped at ~7,942 MiB while host working set hit 9.49
GB — ~2 GB of our granules spilled to host RAM. `cuMemCreate` confers no
pin. So this PR **does not lend to the last byte**: it keeps a tunable
**device headroom** unlent below the managed limit
(`ONNX_GENAI_ELASTIC_LENDING_HEADROOM_BYTES`, **default 512 MiB**),
never dropping below the static-reservation baseline (never a
regression). The headroom is now load-bearing, not precautionary.

**Two nuances (from #863).** (a) The spill is a **latency** effect,
transparent to kernel correctness — the driver makes a granule resident
before a kernel uses it, so it costs tok/s, not correctness; it is not
the #851 illegal address. (b) It is very likely **WDDM-specific**: on
TCC / datacenter GPUs `cuMemCreate` should *fail* at the physical limit
rather than spill, so the headroom is a WDDM-deployment safeguard and
the right default may differ (smaller/zero on TCC, larger under
concurrent GPU use). The tunable exists precisely so the default can be
set per target.

## Results — qwen14b-zp, ctx 8192, 16 tokens, greedy (byte-identical)

All runs solo, `nvidia-smi` verified clear before each (card coordinated
with the #851 agent). `nvidia-smi peak` = max device-resident
`memory.used` sampled at 300 ms during the run (includes ~229 MiB
OS/display baseline).

| metric | baseline (main, managed) | elastic 512 MiB (default) |
elastic headroom=0 | **legacy WDDM** (`LEGACY_ALLOCATOR=1`) |
|---|---:|---:|---:|---:|
| htod_bytes_per_token | 3,943,690,240 | **2,349,010,944** |
2,349,010,944 | 0 (zero-copy) |
| page_ins_per_token | 372 | 188 | 188 | 0 |
| hit_rate | 57.09% | 78.32% | 78.32% | n/a |
| evictions | 5,248 | 2,672 | 2,672 | 0 |
| weight budget_bytes | 6,116,140,442 | 7,139,550,618 | 7,676,421,530 |
— (offload off) |
| weight mapped_physical | 5,662,310,400 | 6,406,799,360 | 6,406,799,360
| — |
| peak_committed (ledger) | 6,983,516,160 | 7,725,907,968 |
7,725,907,968 | — |
| **nvidia-smi peak resident MiB** | — | **7,479** | — | **7,951** |
| oversubscribed / underflows | 0 | 0 | 0 | 0 |
| throughput tok/s (single, noisy) | — | 0.72–1.42 | — | **7.42** |

### Reading the table honestly
- **The byte win is real and byte-identical.** Token IDs identical
across every column: `[96347, 3375, 724, 11, 358, 2776, 14589, 311,
6723, 429, 498, 3003, 2581, 6617, 315, 752]`. Elastic lending cuts
managed-path streaming 1.68×.
- **Solo, the lent residency is physically real.** `nvidia-smi` peaked
7,479 MiB while the ledger `peak_committed` was 7,368 MiB (7,725,907,968
B) and `physical_owned` 7,194 MiB — device-resident **tracked** our
commitment rather than capping below it. No spill solo (7,368 MiB fits
under the 7,959 MiB free with ~590 MiB to spare). The #863 spill is a
**contention** effect our ledger cannot see; on this shared box that is
the realistic exposure, hence the headroom and this caveat. Precise
statement (refined jointly with #863): **#798 no-spill holds
*physically* solo under managed_limit** — nvidia-smi tracks our
commitment 1:1; **spill is specifically the *system-wide over-commit*
case** (a 2nd process, or engine-at-limit + anything else), which is
invisible to our ledger because our own committed bytes do not change.
- **The right baseline is WDDM, and it wins decisively.** WDDM moves
*zero* explicit HtoD bytes (it reads cold weights zero-copy from shared
host memory, `nvidia-smi` sitting at the 7,951 MiB physical ceiling) and
still runs 7.42 tok/s here vs the managed path's 0.72–1.42 —
corroborating #864's 30.7×. **Wall-clock is not a headline** on this box
(documented 3.9–28 tok/s spread), but the direction is unambiguous and
structural, not noise.
- The 512 MiB headroom costs nothing at 16 tokens (resident working set
6.4 GB sits below both ceilings); it caps the ceiling for high-residency
runs, bounding our footprint under the contention where #863 spill
occurs.

## max_context reachability (the property under risk)
New governor test
`kv_reclaims_the_full_lent_budget_from_weights_across_bucketed_growth`
drives the **production reclaim code** (`prepare_mapped_growth`,
allowance transfer, `reclaim_mapped`): weights hold the full budget, KV
grows bucket-by-bucket and reclaims its **entire** reservation, reaching
full reservation with `oversubscribed_bytes == 0` throughout, weights
shedding exactly the reclaimed bytes. The mandatory GPU gate
`mobius_seqmajor_growth_parity_native_cuda` passes **solo** with dynamic
KV/weight lending active, head-major == seq-major byte-identical,
`captures>0`, `fallbacks=0`.

## Where the real leverage sits (per #864)
Weights are read **once per decode step**, so within a step there is no
reuse to amortize a copy against; the only reuse is *across* steps,
which is exactly what a resident cache captures — and what this PR
enlarges. But the copying cache pays alloc/map/evict/sync per miss that
WDDM's zero-copy reads avoid, so at the whole-path level WDDM dominates
for the streamed (cold) fraction. The durable direction is a **hybrid**:
keep a resident **hot** set (where a larger budget — this PR — genuinely
helps) and serve the **cold** fraction by **zero-copy direct reads**
rather than copy-then-discard. Lending more budget to a copying cache
has real but bounded value: it helps the resident fraction, not the
streamed one. This PR is a correct, safe increment for the resident
fraction and an honest data point that the streamed fraction wants a
different mechanism, not more copy budget.

## Validation (all local, per issue)
- `cargo fmt -p {onnx-genai-engine, onnx-runtime-ep-cuda,
onnx-runtime-memory-governor} -- --check` ✓
- `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings`
✓
- `cargo clippy --locked --all-targets -p onnx-genai-engine --features
native-backend` and `--features cuda,native-backend -- -D warnings` ✓
- `cargo test -p onnx-runtime-session` ✓; `cargo test -p
onnx-runtime-memory-governor` ✓ (new reclaim test); `cargo test -p
onnx-genai-engine --features cuda,native-backend --lib elastic` ✓ (4
tests)
- **MANDATORY** `mobius_seqmajor_growth_parity_native_cuda --ignored
--test-threads=1`, solo, GPU clear ✓

## Files
- `crates/onnx-runtime-ep-cuda/src/{provider,lib}.rs`: public
`dynamic_kv_weight_lending_enabled()`.
- `crates/onnx-genai-engine/src/engine/load.rs`: elastic KV floor,
tunable headroom high-water mark, non-regression clamp, gating; unit
tests.
- `crates/onnx-runtime-memory-governor/src/lib.rs`:
reclaim-to-full-reservation test.

---------

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 13, 2026
…tructural cause (#874) (#876)

Replaces the stale `27x slower than doing nothing` row with the measured
result from #874 and, more importantly, the mechanism behind it.

Measured on `qwen14b-zp`, solo with `nvidia-smi` verified empty before
every run, byte-identical output: **WDDM 8.09/7.78 tok/s with
`htod_bytes_per_token = 0`** (true zero-copy) against **managed
streaming 0.11 tok/s**. `main` measured 0.05-0.08 tok/s on the same
model minutes earlier, so the end-to-end effect on this box is ~100x.

The row now carries the structural cause rather than a bare ratio: each
weight is read **exactly once per decode step**, so both paths move the
same bytes over the same link, but ours adds a CPU memcpy into pinned
staging, a VRAM allocation, a `cuMemMap`, an eviction and a synchronize
-- to buy VRAM residency discarded before it is ever re-read. **Copying
to VRAM only pays off when the data is re-read from VRAM before
eviction, and there is no intra-step reuse.**

Also records three things a future reader needs and would otherwise have
to rediscover:

- **Keeping the no-spill arena while merely disabling offload is not an
option.** The physical-handle pool is a hard cap and `cuMemCreate`
cannot spill, so with nothing paging the over-budget remainder the load
fails outright.
- **The `Compatibility` arm has no device budget at all**, so on a host
short of RAM an over-budget model can thrash and the governor cannot see
system-wide pressure (#863).
- **Linux is unchanged and must stay so** -- with no shared-memory
fallback an over-budget model simply fails there, so managed streaming
competes with 'does not run', not with something faster (#783's lesson
about not inheriting a platform-specific conclusion).

Supersedes the historical figures the row carried (6.01 tok/s WDDM, 27x
behind, the h2d/staging/vram_free/vram_alloc split, capture disabled
under offload): capture now runs under offload (#796) and the
eviction-policy defect was fixed (#723).

Docs only.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
justinchuby added a commit that referenced this pull request Aug 13, 2026
#880)

## What this answers (#864)

The task: the **11.4 GB/s** zero-copy figure on #864/#877 is an **upper
bound** — it was measured with `cuMemcpyDtoD` *from* mapped host memory
(a large, perfectly sequential, driver-optimised pull). A real decode
kernel does strided, vectorised int4 GEMV reads. **Measure what a real
kernel actually achieves reading a weight in place from host-mapped
memory vs from VRAM, with outputs verified identical.** Then: does CUDA
graph capture work with a host-mapped device pointer?

This PR adds **measurement only** — two `#[ignore]`d tests in the
`MatMulNBits` kernel module. **No engine behavior changes.**

## Method (only the weight pointer differs)

`zerocopy_kernel_host_mapped_vs_vram_bandwidth` launches the **real**
int4/fp16 decode GEMV — the exact kernel + launch config the engine
selects for `qwen14b-zp` (block-32, fp16 scales, asymmetric zero-points)
— twice per size, with **only the `packed` weight pointer differing**:

- **VRAM arm:** weights from `cuMemAlloc` (what the managed path streams
into).
- **Host-mapped arm:** weights from `cuMemHostGetDevicePointer` over a
`cuMemHostRegister(DEVICEMAP|READ_ONLY)` host buffer (the zero-copy cold
path).

Activation, scales, zero-points and output stay resident in VRAM in both
arms, so the time delta is purely the weight read. Effective GB/s =
`packed_bytes / kernel_time`, best-of-7, CUDA-event timed on the compute
stream. Packed size is **swept** to separate L2-cached reads from cold
reads (the ~11.9 MB average per-tensor page-in fits this GPU's L2; the
real 8.33 GB layer walk reads each weight once, far exceeding L2).
Outputs are asserted **bit-identical** between arms. `nvidia-smi
--query-compute-apps` verified empty before every run.

## Results (RTX 4060 Laptop, 8 GiB, WDDM; best-of-7, two runs)

| packed | N | VRAM GB/s | host GB/s | ratio | outputs |
|---:|---:|---:|---:|---:|:--:|
| 12 MiB (realistic per-op) | 4915 | 8–26 *(noisy)* | 3.1–3.4 | ~8× (run
1) | identical |
| 64 MiB | 26214 | 6.5–34 *(noisy)* | 5.0–5.4 | — | identical |
| 128 MiB | 52428 | 62–121 | 5.4–5.7 | ~11–21× | identical |
| 256 MiB | 104857 | 95–125 | 5.7–6.1 | ~17–21× | identical |
| 384 MiB (cold, ≫L2) | 157286 | 100–112 | 5.6–5.7 | ~18–20× | identical
|

**The reproducible signal is the host (PCIe-bound) arm: ~5.6 GB/s
steady-state, stable across both runs and all sizes ≥64 MiB.** The VRAM
arm at small (realistic-op) N is latency-bound and noisy on this WDDM
box (#863) — I do **not** pin it; at large N it reaches the DRAM ceiling
(~100–125 GB/s), matching #877's 111 GB/s resident figure and validating
the harness.

### CUDA graph capture

`zerocopy_kernel_capture_with_host_mapped_pointer`: capture
**SUPPORTED**; the real GEMV with a host-mapped weight pointer captured
into a CUDA graph and **replayed bit-identically** to the eager run (4
replays). So a host-mapped cold-path pointer is compatible with capture
(relevant to the `captures > 0`, `fallbacks == 0` gates, #796).

## The number, and the recommendation

**A real strided GEMV reads host-mapped weights at ~5.6 GB/s
steady-state — about *half* the 11.3 GB/s sequential proxy, not a tenth
of it.**

- The proxy **was** optimistic: the strided/vectorised access pattern
costs ~2× versus the ideal sequential pull. That is a genuine penalty
#877's number masked, and **the planning figure for the hybrid's cold
path should be revised from 11.3 GB/s down to ~5.6 GB/s.**
- But it is **~2×, not the ~10× that would kill the design.** Zero-copy
is **not dead.** It moves weights at PCIe rate, correctly (bit-identical
every time), and is capture-compatible.
- **Cross-check that this isn't an artifact:** ~5.6 GB/s is exactly
consistent with the WDDM result on #874. WDDM reaches ~8 tok/s on
`qwen14b-zp` by keeping ~7.7 GB resident and reading only the ~0.6 GB
over-budget remainder from host RAM per step — 0.6 GB × 8/s ≈ 4.8 GB/s
of PCIe traffic, the same order as this measurement. If zero-copy were
really 10× worse (~1 GB/s), WDDM could not achieve 8 tok/s. The two
numbers corroborate.
- **The hot-set prize is confirmed and is the larger lever:** ~8× at the
realistic per-op shape (matching the probe's 9.7×), rising toward
~18–20× as the resident read approaches DRAM bandwidth. Maximising the
resident fraction remains priority #1, exactly as #877 concluded.

**Verdict: build the hybrid.** Both halves are sound — but size the cold
path against **~5.6 GB/s**, not 11.3, and treat the resident hot set as
the dominant win.

## Honest caveats

- **VRAM small-N is noisy** on this WDDM box; the clean signals are the
host (PCIe) arm and the large-N VRAM ceiling. I report ranges, not a
single VRAM tok-equivalent.
- **`READ_ONLY` registration** was used deliberately (weights are
immutable) — that is the flag the design would use, so it is the right
thing to measure.
- **Synthetic packed bytes** (deterministic LCG), not the real blob —
but the physical location (host RAM over PCIe vs VRAM) is what governs
bandwidth, and correctness is verified by arm-vs-arm bit-identity, not
against an oracle.
- Single device, WDDM, `can_map_host_memory=1`.

## Validation

- `cargo fmt -p onnx-runtime-ep-cuda -- --check` ✅
- `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings`
✅ (exact CI gate)
- `cargo clippy -p onnx-runtime-ep-cuda --features cuda --tests` ✅ **for
the changed file** — my new code is lint-clean. The run also surfaces
pre-existing `-D warnings` errors in files I did **not** touch
(`standard_attention.rs`, `optimizer.rs`,
`tests/gqa_shared_prefix_parity_gpu.rs`, `tests/index_share_gpu.rs`);
these exist on `origin/main` and are why the CI gate is lib-only / not
`--all-targets` (the instructions note `--all-targets` fails here).
- Both tests pass `--ignored` on the live device; outputs bit-identical
in every arm.

Measurement task per #864 — **not** merging, reporting back.

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 13, 2026
…#880) (#881)

I published 11.41 GB/s for host-mapped weight reads on #877 and labelled
it an upper bound. #880 measured the real thing and it was a
meaningfully loose bound — correcting it here rather than leaving the
optimistic number in the single-source doc.

**What changed.** #877 used a sequential `cuMemcpyDtoD` *from* mapped
host memory. #880 ran the **real strided int4 GEMV** — the exact kernel
and launch config the engine selects for `qwen14b-zp` — with **only the
weight pointer differing**, activation/scales/zero-points/output
resident in both arms. Result: **~5.6 GB/s steady-state** host-mapped
against **~100–133 GB/s** resident, outputs **bit-identical at every
size**. I reproduced it independently before merging #880.

**Size the hybrid's cold path at 5.6 GB/s, not 11.4.**

**Why the correction is trustworthy.** It cross-checks against a
measurement taken for an entirely different purpose: WDDM reaches ~8
tok/s on `qwen14b-zp` by keeping ~7.7 GB resident and reading the ~0.6
GB over-budget remainder from host each step — ~4.8 GB/s of PCIe
traffic, the same order. If zero-copy were really ~1 GB/s, #874's result
would be impossible. Two independent paths landing on the same order is
much stronger than either alone.

**Also recorded:**
- **Sweeping the packed size mattered.** The realistic ~12 MiB
per-tensor read fits this GPU's L2, so a single-point measurement there
compares a *cached* read against a PCIe read and yields a badly wrong
ratio. The real 8.33 GB layer walk reads each weight once and vastly
exceeds L2, so the ≥64 MiB points are the honest ones.
- **CUDA graph capture with a host-mapped weight pointer is SUPPORTED**
and replays bit-identically to eager — measured, not assumed. The cold
path therefore does not forfeit the `captures > 0` / `fallbacks == 0`
gates (#796), which I had flagged as an open design risk.

**The design survives.** A ~2× penalty is not the ~10× that would have
killed it. Residency remains the dominant lever (~9.7× at the realistic
per-op shape, ~15–24× at large N), so the priority order from #877
stands: maximise the resident fraction first, de-copy the remainder
second.

Docs only.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
justinchuby added a commit that referenced this pull request Aug 13, 2026
…ing amortization (#750) (#884)

## Stage 2a of #750 — batch-N fused forward + weight-streaming
amortization (scoped by #844)

Refs #750. Continues the staging plan in #844. **Do not merge; #750
stays open.**

### What landed
A batch-N **input binding + fused forward**
(`NativeDecodeSession::run_fused_batch_prefill`) that isolates the
weight-streaming amortization mechanism (#844's premise) from the
batch-1-pinned persistent KV bindings, plus the byte-identical guard
#844 named and a profiler mode that leads with the deterministic
counters.

- `run_fused_batch_prefill(&[TokenId]) -> Vec<Vec<f32>>`: one eager
`session.run` binding `input_ids`/`attention_mask`/`position_ids`
`[N,1]` and an **empty past** `[N, heads, 0, head_dim]`; returns one
`[vocab]` logits row per row. **Stateless** — never touches `self.past`,
`current_len`, or the CUDA persistent bindings.
- `make_empty_input_tensor_batched` (existing `make_empty_input_tensor`
now delegates with `batch=1`, **byte-identical**) and
`extract_batch_row_logits`.
- `from_session_with_io` — public loader taking an explicit
`ModelIoSpec` for graphs whose ports can't be disambiguated by
shape/dtype.
- `profile_native --fused-forward-amortization N,...` — resets the
global weight-offload counters around one fused forward per batch size,
with a per-run foreign-compute-app contention check (#851).

### Staging correction (well-evidenced, per #844's invitation)
A genuine batched **decode step** is **inseparable** from a batched KV
layout (stage 2b), so coupled assumptions **#1 (input binding)** and
**#2 (KV batch)** cannot be split for any attention decoder:
`persistent_state_shapes` pins KV batch axis 0 = 1, the mask is `[1,
max_len]`, and ONNX attention couples the batch dim across
QKV/mask/past-KV/present-KV. `cuda.rs` is **untouched** in this PR.

The escape hatch #844 already anticipated ("validate the fused-forward
amortization on a 2-row synthetic before touching KV growth"): the
weight-offload residency is **EP-level, keyed purely by weight identity
with no batch axis**, so a **stateless fused forward over N independent
length-1 rows with an empty past** exercises the genuine batch axis and
the weight stream **without any KV growth**. That is exactly what this
PR measures. Assumptions #1 and #4 (symbolic-axis) are handled inside
this probe; #2 and #3 remain stage 2b.

### Batch-1 byte-identical guard (#844) — PASS
`cargo test -p onnx-genai-bench --features bench-native --test
fused_batch_prefill` (CPU, synthetic cached decoder):
- `native_fused_batch_prefill_row_identical_to_batch_one` — row `i` of a
batch-N forward is **bit-for-bit** identical (`f32::to_bits`) to a
batch-1 forward of `token_ids[i]`.
- `native_fused_batch_prefill_is_stateless` — `current_len` stays 0; a
subsequent ordinary `decode` is unaffected.
- `native_fused_batch_prefill_rejects_empty`.

### Amortization table — LED BY `htod_bytes_per_token` and
`page_ins_per_token`
Model `qwen14b-zp` (16.65 GB weights, over-budget), streaming arm
`ONNX_GENAI_WEIGHT_OFFLOAD=1` (4 GiB device budget),
`ONNX_GENAI_CUDA_GRAPH=1`, RTX 4060 Laptop (8188 MiB). One stateless
fused forward per batch size:

| batch N | htod_bytes (total) | page_ins (total) |
**htod_bytes_per_token** | **page_ins_per_token** | hit_rate | evictions
|

|--------:|-------------------:|-----------------:|-------------------------:|-----------------------:|---------:|----------:|
| 1 | 3,575,949,312 | 301 | **3,575,949,312** | **301.0** | 65.28% | 0 |
| 2 | 3,575,949,312 | 301 | **1,787,974,656** | **150.5** | 65.28% | 0 |
| 4 | 3,575,949,312 | 301 | **893,987,328** | **75.2** | 65.28% | 0 |
| 8 | 3,575,949,312 | 301 | **446,993,664** | **37.6** | 65.28% | 0 |

The **totals are batch-invariant** (identical `htod_bytes`, `page_ins`,
`hit_rate`, `evictions=0`) and the **per-token figures fall exactly
`1/N`** — the precise signature that "amortization happened." One fused
forward pages each weight in at most once (residency has no batch axis)
and emits N rows.

**Why the streaming arm, not the WDDM zero-copy default:** since #874
the Windows default is WDDM zero-copy (`htod_bytes = 0`), so there is no
explicit copy for the counters to show shrinking — nothing to *measure*
amortize in htod terms. The `ONNX_GENAI_WEIGHT_OFFLOAD=1` streaming arm
is the arm where `htod_bytes` is nonzero and its `1/N` collapse is
directly observable. The same batching amortizes the WDDM path's
in-place PCIe reads identically in kind; the streaming arm is simply
where it is instrumented.

**Guarding against the #834/#851 error class (a knob producing its own
number):** the `ONNX_GENAI_WEIGHT_OFFLOAD` knob sets *whether* weights
stream, **not** the amortization ratio. `htod_bytes` is constant across
all N; the `1/N` comes solely from dividing a batch-invariant stream by
N rows. The knob cannot manufacture that.

### Contention disclosure (#851)
A sibling agent's `profile_native` (wt-residency, a second 14B model)
intermittently shared the 8 GB GPU. Runs are **labeled contended** by
the built-in per-run check. Crucially, **`htod_bytes` and `page_ins` are
byte-identical across a fully-contended run, a `batch=1`-solo run, and
intermittently-contended runs** — direct empirical proof these counters
are contention-invariant **process-local EP accounting**. Only
wall-clock `effective_htod_gbps` moved with contention (2.1–3.9 GB/s),
which is deliberately **not** headlined. The `batch=1` anchor of the
`1/N` claim was captured with `gpu_contention_check: clear`.

### Capture behavior — explicit statement
The stateless probe is an **eager** forward and by design **does not
engage CUDA-graph capture**; it is not the decode path. The normal
batch-1 **decode** path's capture is **provably unchanged** — `cuda.rs`
is untouched (0 lines) — so the `captures > 0 && fallbacks == 0` gate
(#796) for real decode is unaffected by this PR. Batch-N *decode*
capture (a recapture per batch shape, to be **quantified**) is stage
2b/3 work, not exercised here.

### Memory gates
`peak/oversubscribed` governance is untouched (empty past ⇒ no KV
growth; `evictions=0`, `unaccounted`/`underflows` not perturbed). The
mandatory Mobius parity gate applies only to memory/workspace-governance
changes; none here.

### Validation
- `cargo fmt -p onnx-genai-engine -p onnx-genai-bench -- --check` ✅
- `cargo clippy --locked --all-targets -p onnx-genai-engine --features
native-backend -- -D warnings` ✅
- `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings`
✅
- `cargo test -p onnx-genai-engine --features native-backend` ✅ except
one **pre-existing** failure,
`optional_modality_pipeline_e2e::undeclared_required_audio_input_never_receives_a_fallback`
— a debug UB precondition in `onnx-genai-ort/value.rs` (audio pipeline,
untouched here). **Reproduced on the clean base with my changes
stashed**, so it is not introduced by this PR.
- `cargo test -p onnx-runtime-session` ✅
- `cargo test -p onnx-genai-bench --features bench-native --test
fused_batch_prefill` ✅ (byte-identical guard)

Do not wait for CI; validated locally.

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 13, 2026
…residency (#887)

Two updates so PROGRESS carries the measurements rather than just the
verdicts.

**Batching stage 2a (#884) is measured.** A stateless batch-N fused
forward over length-1 rows with an empty past gives **batch-invariant
totals** — `htod_bytes` 3,575,949,312 and `page_ins` 301 identical at N
= 1/2/4/8, `evictions` 0 — with **per-token figures falling exactly
1/N**. That is the signature of amortization, and the record now says
*why the knob cannot have produced it*: `ONNX_GENAI_WEIGHT_OFFLOAD`
controls whether weights stream, not the ratio, so the 1/N comes solely
from dividing a batch-invariant stream by the row count.

Also records the **staging correction**: assumptions #1 (input binding)
and #2 (KV batch) are inseparable for an attention decoder —
`persistent_state_shapes` pins KV batch axis 0 = 1, the mask is `[1,
max_len]`, and ONNX attention couples the batch dim across
QKV/mask/past/present — so stage 2a used the stateless escape hatch #844
had anticipated and left `cuda.rs` untouched, which is what keeps the
#796 capture gates provably unaffected.

**A rejected approach recorded under item 3, so nobody repeats it.**
Byte-aware residency **corrupts decode output** — token identity
violated, generation collapsing to 3 tokens — whenever offload actually
engages on the managed streaming path, with and without CUDA graph; the
default-off path is unaffected. The byte-identical token constraint is
what caught it. A rejected design with its measurements attached is far
more useful than one carrying only a verdict, and this is the first time
today that constraint has fired on a real proposal.

Docs only.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
justinchuby added a commit that referenced this pull request Aug 13, 2026
…ch-1 baseline (#905)

## #750 stage 2b-impl — scope + on-hardware batch-1 baseline

**This is a well-evidenced scoping result, not the full rewrite.** After
reading
the persistent decode path end-to-end and measuring the batch-1 baseline
on
hardware, the honest conclusion is: **the smallest *correct*
(token-producing)
batch-N persistent-binding decode step does not fit in one reviewable
PR.** This
PR lands the split and the regression anchor that makes each following
increment
safe to attempt — the same deliverable shape you accepted for stage 1
(#844) and
stage 2b (#891), and explicitly invited ("bindings first, mask second…
take it";
"a truthful negative result is acceptable and valuable").

New doc: `docs/NATIVE_BATCH_DECODE_2B_IMPL_SCOPING.md` (+ a pointer from
`docs/NATIVE_CUDA_DECODE.md`). **No runtime code changed** — so batch-1
is
structurally byte-identical and the #796 capture gates are untouched.

### Batch-1 baseline — measured solo (`nvidia-smi` verified empty before
the run)

`qwen14b-zp`, RTX 4060, `ONNX_GENAI_CUDA_GRAPH=1`,
`ONNX_GENAI_MANAGED_WEIGHT_STREAMING=1`, 16 tokens, current `main`:

| metric | value |
|---|---:|
| `generated_token_ids` | `[96347, 3375, 724, 11, 358, 2776, 14589, 311,
6723, 429, 498, 3003, 2581, 6617, 315, 752]` — **exact** reference match
✓ |
| `htod_bytes_per_token` | 2,349,010,944 |
| `page_ins_per_token` | 188.0 |
| `hit_rate` | 78.32% |
| `cuda_graph_measured` | `captures=1 replays=14 fallbacks=0
invalidations=1` → **recapture ≈ 1** (confirms #891) |
| `peak_committed_physical_bytes` | 7,725,907,968 `< managed_limit
7,726,753,178` ✓ |
| `oversubscribed_bytes` / `ref_underflows` / `byte_underflows` /
`unaccounted_committed_bytes` | 0 / 0 / 0 / 0 ✓ |

Not headlining wall-clock (`1.72 GB/s`, `23.8 s/token`) — WDDM-paging
noise
(#863). Per-token bytes differ from #844's 4.11 GB because current
`main` runs the
elastic-budget (#866) managed path at the 7.73 GB auto budget, not the
earlier
fixed 4 GiB synthetic; this is the correct current-main anchor.

### Why one PR isn't achievable

- The stage 2a/2b guards exercise the **stateless** `session.run`
fused-forward
(`run_fused_batch_forward`), which already takes `[N,…]`. The path
2b-impl must
  change is `DecodeCudaState` — **GPU-only, no CPU test seam**.
- **No partial guard exists.** A token-producing decode needs *all* of:
batched
input binding, KV allocation, KV growth/commit, mask, logits read,
greedy
  argmax, and capture-symbol pinning. Drop one → hard-fail or silent row
corruption (#892). So increments are validated by batch-1 non-regression
until
  the final one flips batch-N on with a row-identity guard.
- The batch-aware **caller** (present N sequences, consume N rows) is
the
  generation driver / `continuous_batch_manager` = **2c, out of scope**.

### Key finding

The KV growth **stride** math (`kv_growth_byte_layout`,
`copy_kv_prefix_device_to_device*`) is **already batch-general**
(block-count
based). The real blockers are shape allocation
(`persistent_state_shapes` pins
axis 0→1), the mask, the input/logits bindings, the **device argmax**
(in
`onnx-runtime-ep-cuda`, separate clippy gate), the seq-major commit
scatter
(`live_prefix_ranges` in `onnx-genai-kv`), and **capture-symbol
pinning**
(`collect_unit_symbols` collapses the batch symbol to 1; it must instead
pin to
N). Full file/line inventory in the doc.

### Proposed split (each holds batch-1 byte-identical + passes mobius
solo)

1. **2b-impl-1** shapes & IO bindings (thread `batch`, constructor-fixed
to 1).
2. **2b-impl-2** batched KV growth/commit (+ pure range-math unit
tests).
3. **2b-impl-3** batched device argmax (`onnx-runtime-ep-cuda`).
4. **2b-impl-4** batch symbol pinning + entry points + batch-N
row-identity guard
   + measured recapture-per-shape.

### KV-multiplication trade (restated vs #891)

`htod/token ∝ 1/N` but with a never-amortizing floor `L ×
kv_bytes_per_token` and
`N_max ≈ 19 @ 2048 ctx`. Under #866's elastic budget, batched KV growth
reclaims
weight residency, so 2b-impl-4 must report both the 1/N fall **and** the
`hit_rate`-driven offset, with a crossover if batched decode ever loses
at a
reachable N.

### Validation

`cargo fmt`/`clippy`/tests are unaffected (docs-only). The baseline was
produced
by the protocol's own `profile_native` build (`--features
bench-native,cuda`),
run solo. Not merging; #750 stays open.

Refs #750 #844 #884 #891 #796 #798 #866 #892.

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 14, 2026
…#931)

Third instance of failure mode 1 today, and the first where the "knob"
was not something anyone set on purpose.

#930 concluded that **capture and weight streaming are mutually
exclusive on this build**, from a real decline message naming a real
predicate:

```
predicate `weight_offload_enabled && !weight_offload_stable_va` declined capture
```

It was `!stable_va` that declined it. The sweep harness called
`load_with_resolved_io`, which passes `cuda_offload_policy: None`, so
`weight_offload_stable_va` fell to its deliberately conservative
`unwrap_or(false)`. **The harness asserted pointer-instability; the
runtime was never asked.**

A run on the normal engine path, same box and model, had `captures=2
fallbacks=0` alongside `htod_bytes_per_token = 1,714,132,992`. They
coexist — which is exactly what #796 built (stable-VA VMM paging keeps a
baked weight pointer valid across evict → repage) and #836 verified at
scale.

Had it merged it would have been a load-bearing **false negative**,
closing the most valuable open question on the #750 line: whether a
captured batch-N decode can also stream weights.

Adds the general check it implies, which is cheap and would have caught
this at the source: **when a subsystem declines, print the inputs to the
predicate rather than only its name.** `!stable_va` and "this hardware
cannot do it" look identical in a log and mean opposite things.

Docs only.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
justinchuby pushed a commit that referenced this pull request Aug 14, 2026
… so capture and streaming coexist (2b-impl-4, #750)

Correction to the 2b-impl-4 measurement harness. The first sweep loaded
the native decode session via `load_with_resolved_io`, which passes
`cuda_offload_policy: None`; `load_with_cuda_options_and_io` then leaves
`weight_offload_stable_va` at its conservative `false` default
(`load.rs:793`), so the CUDA-graph capture predicate
`weight_offload_enabled && !weight_offload_stable_va` declined capture by
construction whenever offload was on. The reported "capture and weight
streaming are mutually exclusive" was therefore a harness artifact of the
entry point, not a runtime property.

Fix: the sweep now builds the fully governed `Engine::from_dir` and drives
`decode_greedy_batch` on `Engine::native_decode_session_mut()`. That path
resolves the effective offload policy from the memory-strategy plan
(managed no-spill + stable-VA authority), so a captured graph that baked a
weight pointer stays valid across evict->repage on the reserved-once VMM
arena (#716/#796/#836). Measured on qwen14b-zp with managed streaming
forced: capture stays enabled (captures=1, fallbacks=0, invalidations=0)
while weights stream (htod ~= 3.0 GB/token at N=1), and the 1/N
amortization and the within-sweep #866 byte-hit-rate offset now come from
the real captured decode path.

- Add `Engine::native_decode_session_mut()` (native-backend) so a harness
  can drive the governed session's batch-N entry points.
- Rewire `run_native_decode_batch_sweep` to construct the governed engine
  per batch shape instead of the bare loader; drop the ad-hoc
  `set_weight_residency_budget` override (the governor now owns the budget).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
justinchuby added a commit that referenced this pull request Aug 14, 2026
…n batch-N on (2b-impl-4, #750) (#930)

## What this is

Increment **4 of 4** of native batch-N decode (issue #750), stage
2b-impl-4:
pin the batch symbol, wire the token writer, turn batch-N on. Increments
1-3 (#913, #920, #924) were byte-identical by construction with `batch`
constructor-fixed to `1`. **This is the increment that changes
behaviour**,
so it is measured rather than asserted. **#750 stays open** for the 2c
caller.

> **Correction (2nd commit).** The first version of this PR reported
that
> "capture and weight-streaming are mutually exclusive on this build."
**That
> was a harness artifact, not a runtime property**, and it has been
corrected
> in place. The sweep loaded the native session via
`load_with_resolved_io`,
> which passes `cuda_offload_policy: None`;
`load_with_cuda_options_and_io`
> then leaves `weight_offload_stable_va` at its conservative `false`
default
> (`native_decode/load.rs:793`), so the capture predicate
> `weight_offload_enabled && !weight_offload_stable_va` declined capture
*by
> construction* whenever offload was on. The knob I set (the entry
point) set
> the flag that produced the decline that I read as a finding. The
harness now
> drives the **fully governed** `Engine` session, whose effective
offload
> policy carries the managed no-spill stable-VA authority — and
**capture and
> streaming coexist**, exactly as #796/#836 built and verified. All the
> capture/streaming numbers below are re-measured on that governed path.

## What is on and under what flag

- **`ONNX_GENAI_NATIVE_DECODE_BATCH`** (default `1`). At the default the
decode path is **byte-identical to `main`**. Setting `N>1` binds a
stable
  batch extent at session construction and drives N rows through the
  persistent captured decode path.
- Batch symbol pinned for aux outputs via `persistent_output_shape`
  (axis-0 → `batch`); KV seq-axis pinning unchanged.
- Token writer wired: `write_decode_inputs_batch` (N token IDs → N
slots),
`write_position_binding_batch` (rank-R mrope per sequence),
`extend_mask`
  (N mask rows). Single-token entry points delegate to the batch writers
  (byte-identical at batch 1).
- `decode_cuda_greedy_batch` /
`NativeDecodeSession::decode_greedy_batch` is
the batch-N greedy entry point; `native_decode_batch()` exposes the
extent.
- `Engine::native_decode_session_mut()` (new, native-backend) lets the
  measurement harness drive batch-N decode against the governed session.
- `profile_native --native-decode-batch-sweep <list>
[--native-decode-batch-context L]`
builds the governed `Engine` per batch shape and exercises the captured
  decode path. **The batch-N generation driver (real per-sequence prompt
  seeding) is stage 2c and out of scope here** — the harness fans one
identical token to all N rows, which is a strict correctness probe and,
because the weight/page/capture counters are content-invariant (#884),
the
  trustworthy measurement vehicle.

## Mandatory gates — all re-run locally after the correction

| Gate | Result |
|------|--------|
| `cargo fmt -p onnx-genai-engine -- --check` (+ `-p onnx-genai-bench`)
| **pass** |
| `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings`
| **exit 0** |
| `cargo test -p onnx-genai-engine --features native-backend,cuda --lib
native_decode` | **96 passed, 0 failed** |
| **mobius parity** `--test mobius_seqmajor_growth_parity_native_cuda --
--ignored --nocapture --test-threads=1` | **2 passed** (SOLO) |
| **batch-1 byte-identity on hardware** | **exact match** (below) |

### Batch-1 byte-identity with counters

Reference token IDs matched **exactly** with the feature compiled in and
batch configured to 1 (`qwen14b-zp`, `--steady --tokens 16`):

```
[96347, 3375, 724, 11, 358, 2776, 14589, 311, 6723, 429, 498, 3003, 2581, 6617, 315, 752]
```

Counters matched the documented baseline: `captures=2`, `replays=28`,
`fallbacks=0`, `htod_bytes_per_token=2349010944`,
`page_ins_per_token=188`,
`hit_rate=78.32%`, `byte_hit_rate=70.16%`.

## Correctness of batch-N (row identity)

`qwen14b-zp`, all N, every step, on the governed captured+streaming arm:
**all N rows == row 0** (the #892 cross-row-corruption detector) and
**row 0
== the batch-1 stream** at N=2,4,8 (the scoping-doc §3.8 guard). Token
*values* at N>1 are content-arbitrary (real per-sequence seeding is 2c);
the
counters are not.

## Recapture per shape (#891) — **measured, capture survives batch-N
WHILE streaming**

`qwen14b-zp`, governed engine, managed streaming forced
(`ONNX_GENAI_MANAGED_WEIGHT_STREAMING=1`, `ONNX_GENAI_CUDA_GRAPH=1`),
empty
past, measured window, per batch shape. **Non-zero htod confirms weights
are
actually streaming during these captured steps:**

| N | graph enabled | captures | replays | fallbacks | invalidations |
htod_bytes_per_token |

|---|---------------|----------|---------|-----------|---------------|----------------------|
| 1 | true | 1 | 6 | 0 | 0 | 2,994,222,848 |
| 2 | true | 1 | 6 | 0 | 0 | 1,502,098,688 |
| 4 | true | 1 | 6 | 0 | 0 | 736,831,040 |
| 8 | true | 1 | 6 | 0 | 0 | 424,642,624 |

**Capture survives batch-N as implemented, on the real streaming decode
path** — one capture per shape, zero invalidations, zero fallbacks at
every N,
with ~3 GB/token of weights streaming underneath. This is the positive
resolution of #891's open risk the line has been waiting for, and it is
the
part the original PR got wrong by measuring the wrong arm.

## 1/N weight-stream amortization on the captured decode path —
**measured**

Same governed captured+streaming arm as above (managed path, #874 note:
the
WDDM default is the OS shared-memory path with htod=0, so managed
streaming
is forced). Model `qwen14b-zp` (8.3 GB weights), empty past, `--tokens
8`.
Emitted tokens = steps × N.

| N | htod_bytes_per_token | page_ins_per_token | abs page_ins/forward |
evictions | byte_hit_rate |

|---|----------------------|--------------------|----------------------|-----------|---------------|
| 1 | 2,994,222,848 | 276.8 | 2214 | 1273 | 61.96% |
| 2 | 1,502,098,688 | 139.9 | 2239 | 1293 | 61.83% |
| 4 | 736,831,040 | 66.6 | 2132 | 1185 | 62.55% |
| 8 | 424,642,624 | 41.6 | 2662 | 1678 | 56.84% |

`htod_bytes_per_token` falls **~1/N** (2.99 GB → 1.50 → 0.74 → 0.42; a
7.05×
reduction N=1→8 against the ideal 8×). Amortization vs the N=1 ideal
extrapolation: N=2 **+0.3%**, N=4 **−1.6%**, N=8 **+13.5%**. The N=8
shortfall
is not noise — it is the #866 offset (next section). All four shapes ran
SOLO
with `nvidia-smi` verified empty; no contention flags.

**The amortization is approximate here, not byte-exact.** On this
governed
managed arm the evictor's working set and page order vary per session
load,
so the per-forward `abs page_ins` drifts (2214 / 2239 / 2132 / 2662)
rather
than staying identical. That is the honest behaviour of the *captured
streaming* path. For contrast, the earlier *eager* offload arm (offload
on,
capture declined, fixed `DEVICE_BYTES=6e9`) gave **byte-exact** 1/N with
an
identical `abs page_ins=1791` at every N — because a fixed budget with
no
capture is fully deterministic. Both arms agree that the weight stream
is
paged once per forward and the per-produced-token cost falls ~1/N; the
captured arm is the one that matters for #750 and it now measures
cleanly.

Per the task instruction, **no throughput delta is reported as a
finding**
(this box ranges 3.9–28 tok/s across identical configs). The
deterministic
counters above are the evidence.

## The #866 offset — **measured within-sweep on the governed arm**

#866 makes the weight budget elastic against occupancy, so batch-N (N×
the
working set) should shrink the weight residency and push `byte_hit_rate`
down.
The governed captured arm shows exactly this **within a single sweep**:

- N = 1, 2, 4: `byte_hit_rate` ≈ **62%** (61.96 / 61.83 / 62.55, flat
within
  run-to-run noise), evictions ≈ 1200–1300.
- N = 8: `byte_hit_rate` drops to **56.84% — a −5.12-point offset**,
evictions
rise to 1678, and `abs page_ins/forward` rise 2214 → 2662. That offset
is
precisely why the N=8 per-token htod runs **+13.5% above** the ideal 1/N
line: the N× working set ate into the elastic weight budget, so each
forward
  had to re-stream more.

This is the #866 offset the line predicted, measured on the real decode
path
rather than inferred. (The earlier PR could only show it as an
*arm-to-arm*
budget difference because the un-governed sweep never let the elastic
budget
respond to batch; the governed engine does.)

## Honest summary of what is and isn't proven

- **Proven:** batch symbol pinned; token/mask/position writer wired;
batch-N
  runs end-to-end N=1,2,4,8; batch-1 byte-identity; row identity and
cross-batch identity; **capture survives batch-N *while weights
stream***
(captures=1, invalidations=0, htod≈3 GB/token) on the governed engine
path;
~1/N weight-stream amortization on the captured decode path; the within-
  sweep #866 byte-hit-rate offset (−5.12 pts at N=8).
- **Corrected:** the original "capture and streaming are mutually
exclusive"
  claim was a harness artifact of `load_with_resolved_io`'s conservative
`weight_offload_stable_va=false` default; the governed path proves they
  coexist.
- **Named limitation (in the spirit of #920's seq-major `bail!`):** on
the
governed managed-streaming arm the 1/N amortization is *approximate*,
not
byte-exact, because the evictor's per-load working set varies and the
#866
offset grows the stream at N=8. This is a property of managed streaming,
not
  a batch-N defect.
- **Out of scope (2c):** real per-sequence prompt seeding / the batch-N
generation driver. The harness fans one identical token to all rows, so
  token *values* at N>1 are content-arbitrary; the counters are content-
  invariant (#884) and are the measurement.

**Do not merge** — leaving #750 open for the 2c caller.

---------

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

Make CUDA weight offload graph-capture-compatible with stable slots

2 participants