Skip to content

Back weight offload pages with stable VMM slots - #733

Closed
justinchuby wants to merge 1 commit into
mainfrom
squad/716-vmm-weight-offload-stage1
Closed

justinchuby wants to merge 1 commit into
mainfrom
squad/716-vmm-weight-offload-stage1

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 7, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Stage 1 for Make CUDA weight offload graph-capture-compatible with stable slots #716: add an opt-in VMM-backed CUDA weight residency path (ONNX_GENAI_WEIGHT_OFFLOAD_VMM=1).
  • Reuses the existing onnx-runtime-cuda-memory VMM arena for reserve/create/map/set-access/granularity instead of adding new CUDA driver wrappers.
  • Keeps one stable virtual address per weight key and unmaps/remaps physical granules on eviction/page-in; CUDA graph capture remains declined under offload until stage 2 adds the explicit captured-segment residency guarantee.

Why VMM rather than application slots

The original stable-slot design would swap contents through fixed application slots and needs deterministic scheduling plus RAW/WAR ordering. This stage uses CUDA VMM to get the same captured-pointer stability from hardware: kernels see a stable virtual address while residency maps physical pages behind it outside captured regions. That is smaller and composes with #727's verified remap behavior.

WDDM behavior

This is opt-in because CUDA VMM physical memory (cuMemCreate) cannot oversubscribe into WDDM shared memory the way cudaMalloc can. Enabling ONNX_GENAI_WEIGHT_OFFLOAD_VMM=1 can therefore hard-OOM on Windows where the current non-VMM weight path survives by spilling.

Counters / evidence

GPU unit test proves:

  • A's device pointer is identical before and after eviction/remap.
  • Readback bytes are correct after remap.
  • runtime cuMemAlloc/cuMemFree counts are unchanged across page-ins.

14B end-to-end, same rebuilt binary, ONNX_GENAI_WEIGHT_OFFLOAD=1, ONNX_GENAI_WEIGHT_OFFLOAD_DEVICE_BYTES=6000000000, max-new-tokens=8:

arm vram_alloc vram_free page_ins hits hit rate evictions
VMM off 646.59 ms 4335.79 ms 1,791 5,145 74.18% 0
VMM on 0.00 ms 0.00 ms 4,416 2,520 36.33% 2,833

VMM granule floor on 14B: peak committed 6,050,283,520 B; peak content resident 5,132,642,304 B; granule overhead 917,641,216 B (~17.9% of content). The allocator churn result is good, but the residency counters are not neutral: accounting in 2 MiB granules forces evictions/transient bypasses under the 6 GB budget. This is the important follow-up result before merge.

This PR still does not relax resolve_graph_capture_enabled.

Validation

  • cargo fmt --all -- --check
  • cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings
  • cargo clippy --locked --all-targets -p onnx-genai-engine --features native-backend -- -D warnings
  • cargo clippy -p onnx-genai-cli --features native-cuda -- -D warnings
  • GPU test with wheel CUDA/ORT paths set: cargo test -p onnx-runtime-ep-cuda --features cuda stable_vmm_weight_slots_keep_their_virtual_address_across_eviction -- --nocapture (1 passed)

Refs #716

Stage 1 for #716: add an opt-in CUDA VMM-backed weight residency path that preserves each weight key's virtual address across eviction and page-in without relaxing graph capture yet.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby force-pushed the squad/716-vmm-weight-offload-stage1 branch from a41b279 to 34ecb06 Compare August 7, 2026 14:13
@codecov

codecov Bot commented Aug 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 50.00000% with 29 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.13%. Comparing base (ec2c14b) to head (34ecb06).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...ates/onnx-runtime-cuda-memory/src/vmm_allocator.rs 4.34% 22 Missing ⚠️
crates/onnx-genai-cli/src/generate.rs 80.00% 5 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #733      +/-   ##
==========================================
- Coverage   80.85%   80.13%   -0.73%     
==========================================
  Files         331      331              
  Lines      132743   132800      +57     
  Branches   132743   132800      +57     
==========================================
- Hits       107330   106414     -916     
- Misses      21168    22143     +975     
+ Partials     4245     4243       -2     
Flag Coverage Δ
cli-ort-linux 83.35% <80.00%> (-0.03%) ⬇️
cli-ort-windows 82.84% <80.00%> (-0.12%) ⬇️
mlas 80.92% <ø> (-0.05%) ⬇️
offline 79.98% <4.34%> (-0.77%) ⬇️

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

Files with missing lines Coverage Δ
crates/onnx-genai-cli/src/generate.rs 90.32% <80.00%> (-0.87%) ⬇️
...ates/onnx-runtime-cuda-memory/src/vmm_allocator.rs 18.45% <4.34%> (-0.54%) ⬇️

... and 7 files with indirect coverage changes

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

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

✅ Benchmarks — No Regression

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
✅ reduce_mean/small_f32_threads=1-internal/4096 15.41 µs 17.36 µs +12.6%
✅ add/medium_f32_threads=1-internal/262144 2.64 ms 2.88 ms +8.8%
✅ reduce_mean/medium_f32_threads=1-internal/65536 256.15 µs 273.85 µs +6.9%
✅ add/medium_f16_threads=1-internal/262144 2.63 ms 2.79 ms +6.2%
✅ gather/small_f16_threads=1-internal/4096 484.2 ns 504.8 ns +4.3%
✅ reduce_mean/large_f32_threads=1-internal/262144 1.02 ms 1.06 ms +4.0%
✅ add/large_bf16_threads=1-internal/4194304 44.09 ms 45.60 ms +3.4%
✅ add/small_f16_threads=1-internal/1024 13.02 µs 13.37 µs +2.7%
✅ add/large_f16_threads=1-internal/4194304 41.60 ms 42.68 ms +2.6%
✅ add/small_bf16_threads=1-internal/1024 13.00 µs 13.33 µs +2.5%
✅ gather/medium_f16_threads=1-internal/32768 2.34 µs 2.39 µs +2.1%
✅ gather/small_f32_threads=1-internal/4096 689.0 ns 689.1 ns +0.0%
✅ qwen3_sampling_processors/top_p_fast_after_top_k 541.92 µs 539.74 µs -0.4%
✅ add/large_f32_threads=1-internal/4194304 41.87 ms 41.70 ms -0.4%
✅ gather/medium_f32_threads=1-internal/32768 4.26 µs 4.21 µs -1.3%
✅ add/medium_bf16_threads=1-internal/262144 2.80 ms 2.77 ms -1.3%
✅ qwen3_sampling_processors/top_k_top_p_fast 684.16 µs 674.73 µs -1.4%
✅ sampling_latency/greedy_per_token 3.39 µs 3.32 µs -2.0%
✅ sampling_latency/min_p_per_token 211.75 µs 206.70 µs -2.4%
✅ block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 79.21 µs 77.01 µs -2.8%
✅ sampling_latency/top_p_per_token 390.24 µs 377.52 µs -3.3%
✅ gather/medium_bf16_threads=1-internal/32768 2.45 µs 2.35 µs -4.0%
✅ grammar_masking/llguidance_compute_mask/32 78.89 µs 75.65 µs -4.1%
✅ tokenization/encode_tokens_per_second 409.57 µs 392.25 µs -4.2%
✅ qwen3_sampling_processors/top_k_full_sort_baseline 2.21 ms 2.11 ms -4.4%
✅ tokenization/decode_tokens_per_second 6.62 ms 6.31 ms -4.7%
✅ qwen3_sampling_processors/top_k_top_p_full_sort_baseline 6.01 ms 5.70 ms -5.1%
✅ gather/small_bf16_threads=1-internal/4096 511.2 ns 484.3 ns -5.3%
✅ kv_cache/alloc_dealloc_pages 41.01 µs 38.81 µs -5.3%
✅ sampling_latency/top_k_per_token 54.99 µs 51.91 µs -5.6%
✅ qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline 3.72 ms 3.51 ms -5.7%
✅ matmul/medium_generic_bf16_threads=1/32x512x512 588.88 µs 547.66 µs -7.0%
✅ logit_processing/seven_processor_chain_per_step 335.87 µs 311.42 µs -7.3%
✅ qwen3_sampling_processors/top_k_partial_selection 148.55 µs 136.85 µs -7.9%
✅ matmul/large_generic_bf16_threads=1/32x1024x1024 2.24 ms 2.06 ms -8.2%
✅ matmul/small_generic_f32_threads=1/1x256x256 42.93 µs 38.85 µs -9.5%
✅ matmul/large_generic_f32_threads=1/32x1024x1024 10.92 ms 9.66 ms -11.5%
✅ matmul/large_generic_f16_threads=1/32x1024x1024 93.58 µs 82.56 µs -11.8%
✅ gather/large_f16_threads=1-internal/131072 18.58 µs 16.24 µs -12.6%
✅ matmul/medium_generic_f32_threads=1/32x512x512 2.69 ms 2.35 ms -12.6%
✅ matmul/small_generic_bf16_threads=1/1x256x256 37.61 µs 32.04 µs -14.8%
🟢 gather/large_bf16_threads=1-internal/131072 19.83 µs 16.77 µs -15.4%
🟢 add/small_f32_threads=1-internal/1024 247.4 ns 207.4 ns -16.2%
🟢 matmul/medium_generic_f16_threads=1/32x512x512 37.67 µs 31.57 µs -16.2%
🟢 matmul/large_generic_f16_threads=8/32x1024x1024 100.82 µs 83.64 µs -17.0%
🟢 matmul/small_generic_f16_threads=1/1x256x256 39.10 µs 31.78 µs -18.7%
🟢 gather/large_f32_threads=1-internal/131072 47.84 µs 38.43 µs -19.7%
🟢 matmul/small_generic_bf16_threads=8/1x256x256 40.03 µs 31.81 µs -20.5%
🟢 matmul/small_generic_f16_threads=8/1x256x256 39.90 µs 31.20 µs -21.8%
🟢 block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 538.42 µs 391.16 µs -27.4%
🟢 matmul/medium_generic_f16_threads=8/32x512x512 44.18 µs 30.83 µs -30.2%
🟢 matmul/small_generic_f32_threads=8/1x256x256 53.06 µs 35.21 µs -33.6%
🟢 matmul/medium_generic_f32_threads=8/32x512x512 1.80 ms 1.08 ms -40.1%
🟢 matmul/large_generic_bf16_threads=8/32x1024x1024 2.37 ms 1.40 ms -41.1%
🟢 matmul/medium_generic_bf16_threads=8/32x512x512 696.77 µs 400.83 µs -42.5%
🟢 matmul/large_generic_f32_threads=8/32x1024x1024 7.12 ms 4.07 ms -42.8%
🟢 block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 1.20 ms 569.75 µs -52.5%
🟢 block_quantized_matmul_cached_dense/mxfp4_preexpanded_dense_oncelock_like_proxy/1x1024x1024 99.91 µs 47.09 µs -52.9%
🟢 block_quantized_matmul_cached_dense/mxfp4_cached_dense_repeated_call/1x1024x1024 128.79 µs 52.10 µs -59.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: { 3.47 3.33 4.17 }
What this cannot catch
  • Regressions in code paths not covered by these benchmarks (e.g., end-to-end decode with a real model)
  • Sub-threshold regressions that compound over multiple PRs
  • Performance changes that only manifest under GPU execution
  • Latency changes in the ORT integration path (these benchmarks exercise the native Rust kernels)

@justinchuby

Copy link
Copy Markdown
Owner Author

Follow-up on Option A vs B:

I tried the contained shared-reservation version locally: keep the existing VMM arena as the one reservation, allocate each stable weight slot at packed offsets instead of rounding each slot to 2 MiB, and fix range decommit so a decommit releases any overlapping granule only when all touching slots are gone.

That removes the per-weight padding shape, but the real decode loop still fails under the one-ledger constraint before producing counters. The reason is the #723 policy's zero-eviction behavior: once the stable resident subset fills the 6 GB residency budget, misses outside that subset are transient bypass pages. The non-VMM path can cudaMalloc those transient pages outside the cache budget and then free them; a VMM-backed path must lease/commit the transient granule from the same device ledger. At this model's 6 GB weights + 1.6 GiB KV limit there is only ~0.7 MB headroom, less than one 2 MiB granule, so the first transient bypass commit is correctly refused.

Evicting resident weights to make room makes the run complete, but it is exactly the regression we measured: hit rate drops and evictions become non-zero. Padding is not the only problem; transient bypass headroom also needs a unified reservation/budget owner that can coordinate weights with KV/other zones. So my judgement is Option B: keep #733 as evidence/do-not-merge and let #736 subsume it.

@justinchuby

Copy link
Copy Markdown
Owner Author

Correction to my previous Option-A framing: the VMM arena already has the shared reservation, byte-granularity Spans::carve, and per-granule refcounts. The first #733 implementation bypassed that by rounding every stable weight slot to a separate granule-sized allocation.

I tried the contained fix locally (packed stable weight slots inside the existing arena). With extra headroom (--vram-limit 14GiB, same 6 GB weight budget) it does exactly what we want for residency: page_ins=1,791, hits=5,145, hit rate 74.18%, evictions=0, and vram_alloc/free=0 ms.

But under the original tight limit it still fails before counters: VMM needs to lease a 2 MiB committed granule when only ~742 KB is left in the device ledger. So the remaining blocker is not padding; it is transient/overhead headroom under the one-ledger constraint. The non-VMM path can temporarily cudaMalloc outside the residency budget (and WDDM may spill), while VMM cannot.

So I am keeping #733 draft/do-not-merge and treating this as Option B for the original acceptance bar. The useful conclusion for #736 is sharper now: reuse the existing shared suballocator, but make the unified budget owner reserve/coordinate transient headroom across zones instead of letting weights/KV independently fill their local budgets.

@justinchuby

Copy link
Copy Markdown
Owner Author

Committed-byte admission probe result:

I added the contained arena preflight query (incremental_commit_bytes) and wired stable VMM admission to use committed granules rather than tensor length. It avoids the tight-limit OOM, but the original acceptance counters do not pass.

Packed/shared with extra headroom proves the design is neutral at the policy level: 1,791 / 5,145 / 74.18% / 0, vram_alloc/free=0 ms. But that same resident set is ~6.00 GB content and ~6.51 GB committed. Under the strict 6 GB committed budget, honest admission has to evict/bypass: best tight run I measured was page_ins=4,985, hits=1,951, hit rate 28.13%, evictions=3,217, with peak committed staying under 6 GB.

Conclusion: the preflight query is contained, but the needed budget/headroom policy is not. #733 stays draft/do-not-merge; #736 should own the unified committed-byte budget decision across weights/KV/scratch.

@justinchuby

Copy link
Copy Markdown
Owner Author

Closing this draft as superseded by #736, preserving the branch and measurements.

What it proved:

  • per-weight private reservations eliminate cuMemAlloc/cuMemFree but add 17.9% granule overhead, cutting hit rate 74.18% → 36.33% and adding 2,833 evictions;
  • using the arena's shared suballocator removes that floor and, at equal physical committed bytes, matches baseline exactly: 1,791 page-ins / 5,145 hits / 74.18% / 0 evictions, with alloc/free at 0 ms;
  • the baseline's nominal 6 GB content budget physically consumes ~6.51 GB, exposing the content-vs-committed second ledger;
  • under a strict 6 GB committed budget, honest admission necessarily lowers residency, making the allocator win roughly cancel the extra staging/H2D cost.

The conclusion is not that stable VA is wrong. It is that it becomes a speed win only with the shared physical authority/pool and dynamic zone lending in #736. Landing a separate opt-in path now would duplicate ownership and preserve the wrong budget unit.

The stable-pointer mechanism is retained in this branch and #727 proves graph replay survives remap. #716 remains open for capture+offload after #736's pool/committed-byte admission lands.

@justinchuby justinchuby closed this Aug 8, 2026
justinchuby added a commit that referenced this pull request Aug 11, 2026
…capture (#716) (#796)

## 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 #740 authority-scoped pool** via
`allocate_committed`/`carve()` suballocation. No second allocator, no
per-weight reservation (the mistake that made #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 (#745): reserving VA is free;
only committed physical bytes are charged. 2 MiB granule sizing per
#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 #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:
- The genuinely-offloaded model (`qwen14b-zp`, 15.5 GB) is **not
native-loadable** - it has no `inference_metadata.yaml` (`model.io`
ports), which is the separate #384 load-chain gap. The native decode
path (where this change lives) cannot load it.
- On the native-loadable 0.5B model, the managed no-spill authority is
**pin-or-fail**: either the ~381 MB weight set fits fully resident (0
page-ins) or a step cannot commit its working set (admission/headroom
failure after heavy eviction, ~504 evictions observed). There is no
clean budget window that both pages sustainedly *and* completes a full
decode - that is exactly what #755's automatic weight streaming is for.

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.**

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 12, 2026
## Land the `commit_shared_prefix` allocator entry point (#777)

Implements the smallest next increment named by the #777 probe (PR
#793): the
explicit pinned-prefix API on the CUDA VMM arena. This is the
**allocator
primitive only** — not a kernel change, not copy-on-write, and not wired
into the
KV path or engine.

Refs #777, #793, #727, #740, #745.

### What this adds

An explicit pinned-prefix API on `CudaVmmAllocator` in
`crates/onnx-runtime-cuda-memory`:

- **`create_shared_prefix(bytes)`** — reserves a private window and
acquires N
physical granules from the existing #740 authority-scoped pool, maps
them
read/write for the owner to fill, and charges them **once** on the owned
axis
  (`governor.used(Tier::Device)`). Returns a `SharedPrefix` holding one
owner-reference (the registry-entry lifetime model, so the prefix
survives
  momentarily-zero sharers).
- **`commit_shared_prefix(&prefix, ptr, allocation_bytes,
byte_offset)`** — maps
those already-owned handles into a *different* sequence's reservation at
a
  granule-aligned `byte_offset`, **`PROT_READ`** (the Q3 non-sticky
write-protection posture). Goes through the existing #740 pool and
`carve()`
suballocation on the existing global granule-ref **0→1 / 1→0**
attribution.
**No second allocator. No per-sequence physical reservation** (what made
#733
  net-negative).
- **`incremental_owned_bytes_for_shared_prefix(&prefix)` → 0** — the Nth
sharer
bills only its private bytes, Q2's admission arithmetic made explicit on
the
  entry point (#745).

### How the invariants hold in code (not comments)

- **Union lifetime, structurally.** A pool-level `shared[handle]`
refcount
composes with the arena's per-granule allocation refcount; the physical
handle
returns to the pool only when the **last** mapping (owner or any sharer)
unmaps.
  Freeing N−1 sharers does not release it; freeing the last one does.
- **Charged once.** Shared granules are charged on
`create_shared_prefix`; each
  `commit_shared_prefix` adds **0** owned bytes.
- **Never map inside a captured region.** `enter_graph_capture()` raises
a depth
counter that `commit_shared_prefix` refuses under — the ordering rule is
  enforced, not documented.
- **Loud write-protection.** Shared granules are
`CU_MEM_ACCESS_FLAGS_PROT_READ`,
so a stray write faults rather than silently corrupting another
request's KV;
  Q3 proved this is non-sticky and this preserves that property.
- **Physical bytes only** (`committed_physical_bytes`, `mapped_bytes`),
never
nominal content bytes. **No `assert!` in any `Drop`**
(STATUS_STACK_BUFFER_OVERRUN
  on this platform).
- **Errors, never mis-maps.** Misaligned target VA, granularity
mismatch,
over-long prefix, out-of-arena pointer, non-live allocation, a target
granule
that is already committed, and any attempt under an open capture all
return
  `Err` and roll back cleanly (unmap + pool return), leaving the region
  uncommitted.

### Tests (`vmm_commit_shared_prefix_gpu.rs`)

Against the **real** `CudaVmmAllocator` + `LedgerGovernor` (no mock),
one concern
per test:

- N sequences share one pinned prefix; the ledger charges it **once**;
freeing
N−1 sharers does not release it, freeing the last one does; teardown
returns
`total_owned_bytes`, `governor.used`, and `creates − releases` all to
zero.
- Admitting an additional sharer costs only its private bytes (asserted
against
  the real ledger).
- A write into the shared region faults and the context survives (reuses
the Q3
  pattern; sync and async).
- An unsupported request errors rather than mis-mapping.

Per the #797 harness warning, all device ops route through synchronous
copies or
a single created stream (avoiding the legacy-default-stream vs
non-blocking-stream
silent partial-fill race), and each test builds its own allocator so no
alphabetically-earlier sibling warms the context.

### Validation (local; not waiting for CI)

- `cargo fmt -p onnx-runtime-cuda-memory -- --check` — clean.
- `cargo clippy -p onnx-runtime-cuda-memory --features gpu-tests
--all-targets -- -D warnings` — clean.
- `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings`
— clean.
- GPU suites, single-threaded, all pass: the new
`vmm_commit_shared_prefix_gpu`
(4/4) plus the required regression set — the four `vmm_prefix_share_*`,
  `vmm_graph_remap_gpu`, `vmm_kv_token_major_floor_gpu`,
  `vmm_kv_layout_residency_gpu`, `vmm_stable_va_weight_slot_gpu`.

### What is now possible vs. what still needs KV integration

The allocator can now physically share a pinned prefix across
independent
sequence reservations — charged once, read-only, correct union lifetime
and
admission arithmetic. The capacity mechanism (roughly doubled admissible
concurrency for a large shared system prompt) now exists at the
allocator layer.

It does **not** yet: construct a sequence's KV as `shared prefix +
private tail`
in the engine, detect that two requests share a prefix, or handle
divergence past
the shared region. The immediate next increment named from evidence is
**KV-path
integration** (validated on the output-level parity oracle), which
depends on the
token-major KV shape/stride work (#783/#787) since only token-major
reduces the
prefix to a single multi-map per sequence. Then hash-based detection
(layer 2),
then boundary copy-on-write (layer 3, cost already characterized in Q5).

`docs/PREFIX_SHARE_INVESTIGATION.md` is updated to move
`commit_shared_prefix`
from "next increment" to a "Landed this round" section and to name the
following
increment.

> Not merging and not closing #777 per the task.

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants