Skip to content

Give the prefix-sharing primitive a first production consumer (#777) - #809

Merged
justinchuby merged 2 commits into
mainfrom
squad/777-prefix-kv-integration
Aug 12, 2026
Merged

justinchuby merged 2 commits into
mainfrom
squad/777-prefix-kv-integration

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

What this does (#777)

PR #803 landed the prefix-sharing primitive (create_shared_prefix /
commit_shared_prefix / incremental_owned_bytes_for_shared_prefix) on the
production CudaVmmAllocator, but nothing in production called it — only its
definition and GPU tests. An unconsumed contract reads as working infrastructure
while proving nothing; that is exactly the shape that left #721 stage 3 stranded.
This PR gives the primitive a first real, deliberately narrow consumer.

What is now shared in production

The API is explicit (a caller declares the shared prefix), as #793
recommended for the highest-value case (a shared system prompt), and is
restricted to prefixes that are provably read-only for the sharers' lifetime
— copy-on-write at divergence is out of scope.

Proof + measured numbers

New GPU parity test
crates/onnx-runtime-ep-cuda/tests/gqa_shared_prefix_parity_gpu.rs drives the
real seq-major GQA decode kernel over shared-prefix VMM KV and proves two
sequences sharing one pinned prefix produce byte-identical output to two
independent sequences.

Measured (KV_HEADS=8, HEAD_DIM=128, f16, 1024-token prefix + 1024-token private
tail per sequence; 2 MiB granule → 1024 tokens/granule):

quantity value
independent (2 sequences) committed physical bytes 16,777,216 B (8 granules)
shared (2 sequences) committed physical bytes 12,582,912 B (6 granules)
removed by sharing 4,194,304 B (2 granules = (C−1)×(K_prefix+V_prefix))
second-sharer admission cost 4,194,304 B — private tails only
prefix incremental_owned_bytes_for_shared_prefix 0 (charged once)
output byte-identical for both sequences

Deterministic counters lead; wall-clock is omitted (this box's identical runs
range 3.9–28 tok/s and other agents build concurrently — no number is
extrapolated).

Validation (all local; not waiting for CI, fix-forward)

  • cargo fmt -p onnx-runtime-memory-governor -p onnx-runtime-cuda-memory -p onnx-runtime-ep-cuda -- --check — clean (--all fails on this box with os error 206, a Windows command-line-length artifact, not formatting).
  • cargo clippy --locked --all-targets -p onnx-genai-engine --features native-backend -- -D warnings — 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.
  • Full cargo test -p onnx-runtime-session — pass.
  • Single-threaded GPU suites — pass: vmm_commit_shared_prefix_gpu (4), vmm_prefix_share_gpu (2), vmm_prefix_share_ledger_gpu (1), vmm_prefix_share_write_protect_gpu (1), vmm_prefix_share_compose_gpu (1), gqa_seqmajor_parity_gpu (2), and the new gqa_shared_prefix_parity_gpu (1).

Harness respects the known traps: VMM allocator built on the EP's own CUDA
context; all device ops route through the single EP stream (#797); pool env set
before any allocator is constructed and the process is isolated (#804); no
assertions inside any Drop; live reservations explicitly deallocated before
allocators drop.

What remains (structural / out of scope)

Docs updated: docs/MEMORY_ARCHITECTURE.md (concurrency-scaled prize section) and
docs/PREFIX_SHARE_INVESTIGATION.md (smallest next increment) now record the
first production consumer, the measured numbers, and the remaining structural
gap.

Refs #777 #803 #793 #782 #745 #750

Please do not merge and do not close #777 from this PR. Edits are
confined to the KV/prefix integration path to merge cleanly with concurrent work.

Copilot AI added 2 commits August 12, 2026 04:19
The merged prefix-sharing primitive (create_shared_prefix /
commit_shared_prefix / incremental_owned_bytes_for_shared_prefix on
CudaVmmAllocator, #803) had no live caller in production — only its
definition and GPU tests. An unconsumed contract reads as working
infrastructure while proving nothing, the same shape that stranded #721
stage 3. This wires a first real, deliberately narrow consumer.

- Add an allocator-agnostic seam on the DeviceAllocator trait
  (onnx-runtime-memory-governor): create_shared_prefix,
  incremental_owned_bytes_for_shared_prefix, commit_shared_prefix, plus
  an opaque `dyn SharedDevicePrefix` handle and SharedPrefixCommitInfo.
  Default impls refuse (InvalidRequest) rather than mis-map, so every
  existing non-VMM allocator keeps compiling and refuses safely.
- Implement the seam on CudaVmmAllocator by delegating to the merged
  inherent methods and downcasting the opaque handle via as_any().
  Explicit, read-only-prefix API — not hash-based detection; divergence
  /COW out of scope.
- First consumer: the real seq-major fused fp16 GQA decode kernel reads
  shared-prefix VMM KV through the `dyn DeviceAllocator` seam. New GPU
  parity test gqa_shared_prefix_parity_gpu proves two sequences sharing
  one pinned seq-major prefix (layers x 2 contiguous ranges) produce
  byte-identical output to two independent sequences.

Measured (KV_HEADS=8, HEAD_DIM=128, f16, 1024-token prefix + 1024-token
private tail per sequence; 2 MiB granule):
- independent = 8 granules (16,777,216 B); shared = 6 granules
  (12,582,912 B); sharing removes 2 granules =
  (C-1) x (K_prefix + V_prefix).
- prefix charged once: incremental_owned_bytes_for_shared_prefix = 0.
- second sharer admission = private bytes only (4,194,304 B).
- output byte-identical for both sequences.

Remaining (structural / out of scope): the engine generation loop cannot
call the seam automatically until native_decode/cuda.rs can build a
seq-major fixed-stride physical KV shape (persistent_state_shapes is
hard-coded BNSH; #794 showed seq-major changed only kernel indexing).
Also later: hash-based detection, token-major (one multi-map/sequence),
copy-on-write at divergence (~5.5 ms pooled / ~9.8 ms cold, once).

Refs #777 #803 #793 #782 #745 #750

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

codecov Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Codecov Report

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

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main     #809   +/-   ##
=======================================
  Coverage   83.55%   83.55%           
=======================================
  Files          12       12           
  Lines        5192     5192           
  Branches     5192     5192           
=======================================
  Hits         4338     4338           
  Misses        677      677           
  Partials      177      177           
Flag Coverage Δ
cli-ort-linux 83.50% <ø> (ø)
cli-ort-windows 82.99% <ø> (ø)

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

🚀 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: origin/main merged (no
conflicts); cargo fmt -p onnx-runtime-memory-governor -p onnx-runtime-cuda-memory -- --check clean; full cargo test -p onnx-runtime-session: 153 passed; and
gqa_shared_prefix_parity_gpu::two_sequences_sharing_a_pinned_prefix_match_two_independent_sequences
passes on device.

The primitive now has a production consumer

I flagged this two hours ago as the thing that mattered: #803 landed
commit_shared_prefix in the production allocator, and nothing called it —
only the definition and its tests. That is precisely the shape that left #721
stage 3 (CudaPageStore) stranded, where a contract sat unconsumed until someone
asked why. This closes that gap.

The numbers match the geometry exactly, which is what I wanted to see:

KV_HEADS=8, HEAD_DIM=128, f16, 1024-token prefix + 1024 private tail

independent : 8 granules  (16,777,216 B)
shared      : 6 granules  (12,582,912 B)
removed     : 2 granules  = (C-1) x (K+V prefix)

second-sharer admission = private bytes only (4,194,304 B); incremental_owned = 0
output byte-identical for both sequences

incremental_owned = 0 is the load-bearing one — it is what converts prefix
sharing from a memory saving into concurrency, because committed-granule
admission (#745) then charges the Nth request only what is private to it.

The seam is the right shape for an interchangeable-backend architecture

Putting create_shared_prefix / commit_shared_prefix /
incremental_owned_bytes_for_shared_prefix on the DeviceAllocator trait, with
CudaVmmAllocator implementing them and default impls that refuse, is better
than a CUDA-only inherent API. I checked the defaults rather than assuming:

"this allocator does not support shared prefixes; a pinned shared prefix
requires a physical-handle pool (the CUDA VMM arena)"

So an unsupported request faults loudly at the seam instead of silently producing
private copies. That matters for the stated goal of backends being interchangeable
and individually optimisable — a backend that cannot share says so, rather than
appearing to work while quietly costing N times the memory.

The consumer itself drives the real seq-major fused fp16 GQA decode kernel over
shared-prefix VMM KV through the dyn DeviceAllocator seam, so this is an
end-to-end path, not a mock.

What remains, correctly reported

Engine auto-use is blocked until native_decode/cuda.rs builds a seq-major
fixed-stride physical KV shape — persistent_state_shapes is hard-coded BNSH, the
same structural boundary #801 identified and explained (the CUDA GQA node reads
present_capacity from BNSH axis 2 regardless of kv_layout, and
DeviceIoBinding requires matching axis order). Hash-based detection, token-major
and copy-on-write at divergence remain later increments, as scoped.

Merging.

@justinchuby
justinchuby merged commit d0c79ee into main Aug 12, 2026
3 of 12 checks passed
@justinchuby
justinchuby deleted the squad/777-prefix-kv-integration branch August 12, 2026 11:48
@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
🔴 block_quantized_matmul_cached_dense/mxfp4_cached_dense_repeated_call/1x1024x1024 46.10 µs 128.22 µs +178.1%
🔴 block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 648.87 µs 1.21 ms +86.5%
🔴 matmul/large_generic_bf16_threads=8/32x1024x1024 1.44 ms 2.32 ms +60.7%
🔴 matmul/medium_generic_bf16_threads=8/32x512x512 432.83 µs 683.35 µs +57.9%
🔴 matmul/large_generic_f32_threads=8/32x1024x1024 4.29 ms 6.60 ms +53.7%
🔴 block_quantized_matmul_cached_dense/mxfp4_preexpanded_dense_oncelock_like_proxy/1x1024x1024 63.48 µs 96.89 µs +52.6%
🔴 sampling_latency/top_k_per_token 48.79 µs 68.31 µs +40.0%
⚠️ sampling_latency/greedy_per_token 3.00 µs 3.77 µs +25.8%
⚠️ matmul/medium_generic_f16_threads=8/32x512x512 31.94 µs 39.20 µs +22.7%
⚠️ matmul/large_generic_f16_threads=8/32x1024x1024 89.82 µs 109.60 µs +22.0%
⚠️ tokenization/decode_tokens_per_second 5.93 ms 6.89 ms +16.1%
✅ matmul/large_generic_bf16_threads=1/32x1024x1024 2.24 ms 2.55 ms +13.9%
✅ matmul/medium_generic_f16_threads=1/32x512x512 32.07 µs 35.83 µs +11.7%
✅ sampling_latency/top_p_per_token 399.40 µs 440.64 µs +10.3%
✅ matmul/small_generic_bf16_threads=8/1x256x256 36.16 µs 39.76 µs +10.0%
✅ tokenization/encode_tokens_per_second 350.44 µs 378.91 µs +8.1%
✅ matmul/small_generic_f32_threads=8/1x256x256 48.61 µs 52.29 µs +7.6%
✅ gather/medium_bf16_threads=1-internal/32768 2.45 µs 2.54 µs +3.5%
✅ sampling_latency/min_p_per_token 212.12 µs 219.09 µs +3.3%
✅ matmul/large_generic_f32_threads=1/32x1024x1024 9.88 ms 10.15 ms +2.7%
✅ gather/medium_f16_threads=1-internal/32768 2.53 µs 2.59 µs +2.5%
✅ matmul/small_generic_bf16_threads=1/1x256x256 36.10 µs 36.89 µs +2.2%
✅ matmul/medium_generic_f32_threads=8/32x512x512 1.71 ms 1.74 ms +1.9%
✅ qwen3_sampling_processors/top_k_top_p_full_sort_baseline 6.07 ms 6.19 ms +1.9%
✅ add/large_f32_threads=1-internal/4194304 42.20 ms 42.84 ms +1.5%
✅ matmul/small_generic_f16_threads=8/1x256x256 37.60 µs 38.14 µs +1.4%
✅ add/large_f16_threads=1-internal/4194304 42.87 ms 43.44 ms +1.3%
✅ matmul/medium_generic_f32_threads=1/32x512x512 2.43 ms 2.46 ms +1.2%
✅ matmul/medium_generic_bf16_threads=1/32x512x512 577.50 µs 581.81 µs +0.7%
✅ add/small_bf16_threads=1-internal/1024 13.55 µs 13.58 µs +0.2%
✅ qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline 3.77 ms 3.73 ms -1.0%
✅ qwen3_sampling_processors/top_k_top_p_fast 685.20 µs 671.67 µs -2.0%
✅ matmul/small_generic_f16_threads=1/1x256x256 36.01 µs 35.24 µs -2.1%
✅ matmul/large_generic_f16_threads=1/32x1024x1024 88.57 µs 86.45 µs -2.4%
✅ add/medium_bf16_threads=1-internal/262144 2.80 ms 2.74 ms -2.4%
✅ reduce_mean/small_f32_threads=1-internal/4096 16.26 µs 15.71 µs -3.4%
✅ gather/small_f32_threads=1-internal/4096 712.2 ns 687.9 ns -3.4%
✅ add/large_bf16_threads=1-internal/4194304 45.28 ms 43.46 ms -4.0%
✅ kv_cache/alloc_dealloc_pages 41.85 µs 39.80 µs -4.9%
✅ qwen3_sampling_processors/top_k_full_sort_baseline 2.26 ms 2.14 ms -5.3%
✅ gather/small_bf16_threads=1-internal/4096 498.3 ns 471.8 ns -5.3%
✅ qwen3_sampling_processors/top_k_partial_selection 144.49 µs 136.62 µs -5.4%
✅ block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 92.69 µs 87.62 µs -5.5%
✅ gather/large_bf16_threads=1-internal/131072 18.20 µs 17.20 µs -5.5%
✅ gather/small_f16_threads=1-internal/4096 522.1 ns 485.9 ns -6.9%
✅ add/medium_f16_threads=1-internal/262144 2.90 ms 2.69 ms -7.3%
✅ add/small_f16_threads=1-internal/1024 14.25 µs 13.20 µs -7.3%
✅ gather/large_f16_threads=1-internal/131072 17.84 µs 16.35 µs -8.3%
✅ reduce_mean/medium_f32_threads=1-internal/65536 284.45 µs 259.91 µs -8.6%
✅ reduce_mean/large_f32_threads=1-internal/262144 1.14 ms 1.03 ms -9.4%
✅ matmul/small_generic_f32_threads=1/1x256x256 44.28 µs 39.26 µs -11.3%
✅ add/medium_f32_threads=1-internal/262144 2.96 ms 2.59 ms -12.4%
✅ logit_processing/seven_processor_chain_per_step 372.02 µs 324.96 µs -12.7%
✅ grammar_masking/llguidance_compute_mask/32 87.76 µs 75.40 µs -14.1%
✅ gather/large_f32_threads=1-internal/131072 47.39 µs 40.56 µs -14.4%
✅ block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 548.28 µs 466.28 µs -15.0%
🟢 qwen3_sampling_processors/top_p_fast_after_top_k 629.46 µs 508.19 µs -19.3%
🟢 gather/medium_f32_threads=1-internal/32768 5.78 µs 4.62 µs -20.0%
🟢 add/small_f32_threads=1-internal/1024 287.9 ns 208.6 ns -27.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.83 3.69 4.71 }
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 added a commit that referenced this pull request Aug 12, 2026
…ajor-only KV consumers (#777 #801 #797 #794 #809) (#812)

# Remove the last blocker to seq-major KV being usable in production:
gate the fourth place the layout lives

**Outcome reached: #3 — "it works only under a condition."** Seq-major
decode works end-to-end and is byte-identical to head-major, but only on
the **pure decode path where our own GQA kernel is the sole consumer of
the device KV buffer**. This PR makes the runtime **refuse rather than
mis-map** when that condition does not hold, and pins down — with
citations — exactly what remains between "growth byte geometry is
seq-major" (#801) and "the `layers × 2` floor realised + prefix sharing
available."

Refs #777 #797 #794 #801 #809 (and #787 for token-major). Does **not**
merge, does **not** close any issue.

## The question this PR answers

> What remains between "growth byte geometry is seq-major" and "a real
model runs seq-major end to end with the floor realised and prefix
sharing available"? A fourth place the layout lives, or can the declared
BNSH shape and a seq-major physical layout coexist provided only our own
kernels read the buffer?

**Both.** The declared BNSH shape and a seq-major physical layout *do*
coexist on the native decode path (KV never leaves the device; only our
GQA kernel indexes it). But there **is a fourth place the layout lives**
— two engine-side *non-kernel* consumers of the device KV buffer that
still stride it with hard-coded head-major (BNSH) arithmetic:

1. **`DecodeCudaState::read_present_kv`** (`native_decode/cuda.rs`) —
the device present-KV **host mirror** used by prefix/paged read-out and
diagnostics. It compacts per head with
`capacity_head_stride = physical_shape[2] * head_dim * elem` and a
per-head fragment read.
2. **`DecodeCudaState::seed_prefix`** (`native_decode/cuda.rs`) — the
**paged prefix-reuse device seed** (#777/#809's device counterpart). It
writes each head at `head * capacity_head_stride * elem_size`, the same
head-major capacity offset.

Under a seq-major (BSNH) physical buffer, heads are **interleaved per
token** (`slot * kv_heads * head_dim + h * head_dim + d`), not laid out
as `max_len × head_dim` capacity stripes. So both offsets index the
wrong bytes: the host mirror would **silently return mis-indexed KV**,
and the prefix seed would **corrupt the cache**. Neither had a seq-major
guard — a real latent-corruption path.

This is the fourth place the layout lives, beyond the three that are
already converted:
1. kernel indexing — done (#792/#794);
2. commit geometry — done (#797);
3. growth byte geometry — done (#801);
4. **engine-side non-kernel KV consumers — gated here.**

## What this PR changes

`crates/onnx-genai-engine/src/native_decode/cuda.rs`:
- `read_present_kv` and `seed_prefix` now **error under
`kv_layout.is_seq_major()`** with a precise message stating the
condition and why (head-major byte arithmetic vs interleaved seq-major
bytes). This matches the kernel-level "unsupported readers/writers fail
rather than silently mis-index" gate
(`kv_stride.rs::require_converted_path_support`) at the engine level.
- **Head-major is untouched and byte-identical** — the gate is a pure
`if is_seq_major() { bail! }` before the existing code. Absent metadata
⇒ head-major ⇒ no gate.

`docs/MEMORY_ARCHITECTURE.md` (§ "KV layout and residency"): records the
fourth layout location and the refuse-not-mismap gate as a bullet in the
existing residency-caveat block.

## The structural constraint (#801), re-verified

The BNSH binding **metadata** shape must stay for both layouts. The CUDA
GQA node validates `past_key`/`past_value` as `[batch, kv_heads, seq,
head_dim]` and reads `present_capacity` from **axis 2 regardless of
`kv_layout`** (`group_query_attention.rs`), and `DeviceIoBinding`
requires matching axis order. `persistent_state_shapes` is therefore
*correctly* BNSH for seq-major too; seq-major lives in the byte
geometry, not a permuted shape. Confirmed still true after merging the
concurrent GQA-workspace change.

## Why the `layers × 2` floor is still not realised through the engine
(and what it needs)

Measured directly, **before** the merge broke the harness, with the
validated #801/#805 setup — stock `qwen2.5-0.5b-q4_0-mobius` (BNSH) vs a
copy whose 24 GQA nodes carry `kv_layout=1`, forcing growth with
`ONNX_GENAI_KV_MIN_BUCKET=8`, 48 greedy tokens, 3 growth events
(`mobius_seqmajor_growth_parity_native_cuda`, this branch @ pre-merge):

| Growth path | Capture | Output | Physical committed bytes (head = seq)
| d2d KV copy bytes head | d2d KV copy bytes seq |
|---|---|---|---:|---:|---:|
| in-place VMM (default) | OFF | **byte-identical** | 100,663,296 |
688,576 | **0** |
| in-place VMM (default) | ON | **byte-identical** | 100,663,296 |
688,576 | **0** |
| legacy realloc | OFF | **byte-identical** | 786,432 | 688,576 |
688,576 |

Capture-ON, per-process: head-major and seq-major both `(growth 3,
captures 4, replays 39, invalidations 4)`.

So on this branch the seq-major decode path is **byte-identical head vs
seq, capture ON and OFF, and moves 0 KV bytes on in-place growth** — but
**`physical_committed_bytes` is identical between layouts**, i.e. the
floor is *not* realised through the engine. Two cited reasons:

1. **`vmm_growth_requests` commits the full bucket** (`(index, 0,
full_bucket_bytes)`) for every KV binding, for both layouts, instead of
the dense live-prefix ranges
`native_decode/kv_commit.rs::live_prefix_ranges` computes (those helpers
are unit-tested and mirror the driver GPU test but are
`allow(dead_code)` — not wired into the engine).
2. **The `kv_heads×` difference only becomes *measurable* under a large
fixed stride.** With bucket growth the bucket ≈ 2× valid, so on 0.5b
both layouts land in **1 granule/binding** (48 granules = 96 MiB). The
`kv_heads=2` gap appears only when a head stripe crosses a granule
boundary, i.e. `capacity × head_dim × elem ≥ granule`
(`ONNX_GENAI_CUDA_KV_MAX_LEN=32768` in the driver test
`vmm_kv_layout_residency_gpu`): head-major = `layers × 2 × kv_heads`
granules, seq-major = `layers × 2` (the measured 192 MiB → 96 MiB, 2× =
`kv_heads`; 1536 MiB → 192 MiB, 8× on qwen14b).

Realising the measurable floor therefore requires **a fixed full-context
stride + dense-prefix commit + per-step commit-ahead**, which eliminates
growth-driven bucket recapture. That change lives in the
**growth/capture-invalidation decision** — the explicit lane of the
concurrent `conditional-invalidation` / `bsnh-fixed-stride` work — and
outside this PR's shape/binding-construction lane. It is left as the
documented next step (shared with token-major #787 and prefix sharing
#777), consistent with the existing doc note: *"wiring the dense-prefix
commit into the live path is the documented next step."*

## Prefix sharing (#777/#809) under seq-major

It does **not** fall out naturally. #809's device consumer is
`seed_prefix`, a head-major byte writer; a shared prefix is only
physically contiguous under seq-major, but the seed that would install
it cannot yet address the seq-major buffer. This PR makes that explicit
(the seed errors under seq-major) rather than letting prefix reuse
silently corrupt seq-major KV. Realising it means teaching `seed_prefix`
(and `read_present_kv`) the BSNH byte geometry — same dense-prefix work
as the floor.

## Validation

- `cargo fmt -p onnx-genai-engine -- --check` ✓ (`--all` fails with os
error 206 on this box — a command-line-length artifact, not formatting)
- `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`
✓ (post-merge)
- full `cargo test -p onnx-runtime-session` ✓ (post-merge)
- `mobius_seqmajor_growth_parity_native_cuda` (byte-identical head vs
seq, capture ON/OFF, 0 bytes moved) ✓ **both pre-merge and post-merge**
(post-merge re-run includes the #814 governor fix, see below); the gate
does not regress it. All four configs identical to the pre-merge numbers
above (`physical_committed_bytes` 100,663,296 / 786,432; seq
`kv_bytes_moved=0`).

### Cross-agent regression surfaced by `merge origin/main` — now fixed
by #814

Merging `origin/main` (which brought the `gqa-bnsh-transpose` GQA
transpose-scratch change #810) initially broke the mobius end-to-end
test **during prefill workspace prep**, before any decode:

```
Error: prepare native CUDA prefill workspace
Caused by:
  cannot reserve 2097152 bytes of device memory for Workspace { step_scoped: false }:
  0 of 2097152 bytes are already leased, leaving 0
```

#810 reserves a new **session-persistent** BNSH transpose-scratch
`Workspace { step_scoped: false }`
(`WorkspaceLifetime::SessionPersistent`) during prefill; the `vram-limit
dynamic KV/weight lending` strategy did not provision headroom for it
(GPU otherwise 100% free — 0 MiB used). This was a governor
role-budgeting interaction in the **GQA/memory lane**, independent of
this PR's gate (which only adds error paths in
`read_present_kv`/`seed_prefix`, never on the prefill workspace path). I
attributed it precisely and reported it to the `gqa-bnsh-transpose`
sibling rather than fixing out of lane.

**It has since been fixed forward in #814** (commit `54f89a03`: "mapped
growth now claims unassigned authority capacity before holder reclaim,
with rollback/overestimate accounting"). Merging #814 locally on top of
this branch, the mobius test **passes all four VMM/realloc × capture
configurations, byte-identical head vs seq**, confirming this PR's gate
and #814's governor fix coexist cleanly. #814 is not yet on `main`, so
this branch is left at its clean two-file state (the fix will arrive via
`main`); the post-merge validation above was run against `this branch +
#814` locally.

## Files
- `crates/onnx-genai-engine/src/native_decode/cuda.rs` — seq-major
refuse-not-mismap gate on the two head-major-only KV consumers.
- `docs/MEMORY_ARCHITECTURE.md` — fourth-layout-location note.

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

Makes prefix sharing **layout-general** and answers, definitively and
with code citations, whether the ORT backend can use it. Refs #777,
#803, #809, #793, #776, #783.

## 1. The shareability decision is now arithmetic, not a layout (core
work)

Prior framing (mine) said prefix sharing "requires seq-major". That is a
**granule-relative** claim stated absolutely, and it was wrong. The real
gate:

```
fragment_bytes                  = prefix_len x (contiguous bytes per fragment in that layout)
shareable                       = fragment_bytes >= granule
shareable_granules_per_fragment = floor(fragment_bytes / granule)
multi_map_ops                   = fragments x shareable_granules_per_fragment
wasted_boundary_bytes/sequence  = fragments x (fragment_bytes mod granule)
```

Layout sets `fragment_bytes` and the **cost**, not the **possibility**.
The genuine requirements are (a) a VMM-backed KV buffer and (b)
`fragment_bytes >= granule`.

New tested predicate
`onnx_runtime_memory_governor::shareability::evaluate_prefix_shareability`
over `ModelKvGeometry` + `KvFragmentation` (`head_major_bnsh` /
`seq_major_bsnh` / `token_major`, or an arbitrary #783 stride
descriptor). Its `PrefixShareability` result carries the full accounting
and a `refusal_reason()` so a KV path **refuses with a reason** instead
of silently making N private copies. `gqa_shared_prefix_parity_gpu` now
consults it to admit the share rather than assuming seq-major.
Granularity is **queried** (2 MiB CUDA #776, ~64 KiB Level Zero/Vulkan,
4 KiB CPU mmap), never hardcoded.

**Head-major becomes shareable when the arithmetic says so** — exactly
8,192 tokens at 2 MiB (`granule / (head_dim x dtype)`), realistic for
RAG and long system prompts.

### Correction to the issue's worked table (a term to double-check)

The 2 MiB seq-major cell reads "2 granules each". That is `ceil`
(granules the prefix *touches* / residency). The **shareable** count is
`floor(4,096,000 / 2,097,152) = 1`, giving `96 x 1 = 96` multi-map ops —
which matches the design section's own "96 multi-maps per sequence". So
the correct cell is **1 granule/fragment, 96 ops**. It is a
ceil-vs-floor slip in one cell, not a missing term; every other verified
cell matches. The invariant `fragments x contiguous_bytes_per_token =
whole-model per-token bytes` holds for all layouts (encoded as a test).

Full layout x granule x prefix-length grid for **qwen14b** and
**qwen2.5-0.5b** is in `docs/PREFIX_SHARE_INVESTIGATION.md`.

## 2. ORT answer — definitive, with citations

The question is not "can ORT share" (the primitive is layout-agnostic)
but **"does ORT KV route through an allocator we can back with the VMM
arena?"**

- **Dynamic decode** (`DecodeKvMode::ZeroCopyRebind`,
`decode/mod.rs:66`): ORT allocates `present.*` itself; no allocator of
ours is involved. **Cannot route.**
- **Shared-buffer decode** (`decode/shared_batch.rs:360`): uses
`Value::empty_in` -> `CreateTensorAsOrtValue` with an allocator from
`for_session_device` -> `CreateAllocator` (`allocator.rs:239`), which
the doc says **"wraps the session's internal EP allocator"**
(`allocator.rs:232`) — selects the *device*, not the *implementation*.
- **`RegisterAllocator`** (`governed_allocator.rs:738`) installs our
vtable but ORT reserves the arena kind for its own arenas
(`allocator.rs:65`, `governed_allocator.rs:746`) and fronts a registered
device allocator with its **own BFC arena** — each KV tensor is a
byte-range inside an arena chunk, not a granule-aligned VMM reservation.

**So `commit_shared_prefix` cannot attach to today's automatic ORT paths
— a valuable negative.** The one viable route: allocate the
shared-buffer KV on **our VMM arena** and bind it as external device
memory via `CreateTensorWithDataAsOrtValue` (`value.rs:1103`, "so a
caller can hand ORT memory it allocated itself") + `IoBinding`. Bounded
by head-major arithmetic (>= 8,192-tok prefixes at 2 MiB). **Scoped, not
implemented in this PR.**

## 3. Seam honesty preserved

The #809 refusing defaults stay; the predicate supplies the reason so a
non-shareable configuration errors rather than mis-maps. Shared regions
stay `PROT_READ`, granules charged once, `incremental_owned = 0`, no
second allocator.

## Validation (all green locally; CI not awaited, fix-forward)

- `cargo fmt -p onnx-runtime-memory-governor -p onnx-runtime-ep-cuda --
--check`
- `cargo clippy -p onnx-runtime-cuda-memory --features gpu-tests
--all-targets -- -D warnings`
- `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings`
- `cargo clippy --locked --all-targets -p onnx-genai-engine --features
native-backend -- -D warnings`
- `cargo test -p onnx-runtime-session` (all pass), predicate unit tests
(8 pass)
- GPU, single-threaded: `vmm_prefix_share_*` (5),
`vmm_commit_shared_prefix_gpu` (4), `gqa_shared_prefix_parity_gpu` (1) —
all pass
- #814 policy: `mobius_seqmajor_growth_parity_native_cuda --ignored` —
pass

Does **not** merge or close #777.

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.

VMM page-level prefix sharing: stop storing the same system prompt N times across concurrent requests

2 participants