Skip to content

Make KV tier migration a real storage operation - #742

Merged
justinchuby merged 5 commits into
mainfrom
squad/721-real-kv-tier-migration
Aug 8, 2026
Merged

justinchuby merged 5 commits into
mainfrom
squad/721-real-kv-tier-migration

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 8, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • derive page residency from the owning KvPageStore, removing mutable Page::device
  • migrate transactionally by allocating a target store, copying f32/int8/fp8/scales, then swapping ownership
  • add factory/copy contracts for a future device-only CudaPageStore without cache-caller changes
  • route PageTable and local-tiered promote/demote paths through migration
  • reserve real RAII transient leases from the pool lease's existing governor authority before replacement allocation or any CoW snapshot clone
  • CoW reserves the maximum source, reused-free-page, eviction-victim, and replacement transient; the guard outlives every snapshot on success and rollback
  • refuse migration pressure before state mutation; transient grants return automatically after success/failure
  • make Page::migrate private so all production migration is table-mediated and cannot bypass leasing
  • roll back failed CoW copies, including free lists, stats, refcounts, resident bytes, and incidental LRU demotion
  • abort best-effort prefetch promotion when required demotion fails or hot capacity remains full

Both hot and cold stores remain host-addressable HostPageStore instances. Device::Gpu(0) is still an emulated residency label until Stage 3 adds CudaPageStore; this PR makes migration semantics and ownership real and replaceable, not device memory.

Validation

  • cargo fmt --all -- --check
  • cargo test -p onnx-runtime-memory-governor -p onnx-genai-kv (140 KV unit tests plus integrations; 28 governor tests)
  • cargo clippy --locked --all-targets -p onnx-runtime-memory-governor -p onnx-genai-kv -- -D warnings
  • cargo test -p onnx-genai-engine --features native-backend
  • cargo clippy --locked --all-targets -p onnx-genai-engine --features native-backend -- -D warnings
  • full-budget CoW tests cover both free-page and eviction branches and refuse before cloning
  • instrumented success/failure tests prove ledger peak covers both snapshots and the lease outlives snapshot drops
  • sabotaging a pre-reserve source clone makes the tracked-clone test fail; restored and passed
  • direct KV page .migrate( search finds only the private helper invocation inside PageTable::migrate_page
  • zero production page.device = writes; field removed

Stage 2 of #721.

Final CoW persistent-accounting fix

  • CoW growth beyond the original pool is preflighted separately from transient snapshots.
  • On commit, the growth lease is absorbed into the long-lived pool lease without changing the ledger total.
  • Repeated growth remains charged after calls return; copy failure removes the new page before releasing the growth reservation.
  • Reusing a free page does not grow the pool lease.
  • Tests cover single and repeated growth, failure rollback, free-page reuse, and ledger peak/lifetime. Sabotaging lease absorption made the persistent-accounting test fail as expected.

Replace mutable page residency labels with store-owned residency and explicit byte-copy migration. Keep GPU residency documented as host-backed emulation until CudaPageStore lands.

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

codecov Bot commented Aug 8, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.70954% with 124 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.94%. Comparing base (d8da1e5) to head (dad8d25).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
crates/onnx-genai-kv/src/page_table.rs 86.14% 64 Missing and 10 partials ⚠️
crates/onnx-genai-kv/src/paged_cache.rs 92.35% 37 Missing and 8 partials ⚠️
crates/onnx-genai-kv/src/local_tiered.rs 91.93% 4 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #742      +/-   ##
==========================================
+ Coverage   79.94%   80.94%   +1.00%     
==========================================
  Files         330      331       +1     
  Lines      127929   134104    +6175     
  Branches   127929   134104    +6175     
==========================================
+ Hits       102272   108557    +6285     
+ Misses      21478    21285     -193     
- Partials     4179     4262      +83     
Flag Coverage Δ
cli-ort-linux 83.37% <ø> (ø)
cli-ort-windows 82.96% <ø> (+0.09%) ⬆️
mlas 80.92% <ø> (+0.23%) ⬆️
offline 80.85% <89.70%> (+1.06%) ⬆️

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

Files with missing lines Coverage Δ
crates/onnx-genai-kv/src/lib.rs 95.48% <ø> (ø)
crates/onnx-runtime-memory-governor/src/lib.rs 91.73% <100.00%> (-1.01%) ⬇️
crates/onnx-genai-kv/src/local_tiered.rs 91.20% <91.93%> (+0.12%) ⬆️
crates/onnx-genai-kv/src/paged_cache.rs 91.46% <92.35%> (+1.58%) ⬆️
crates/onnx-genai-kv/src/page_table.rs 88.57% <86.14%> (-0.69%) ⬇️

... and 28 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 8, 2026 •

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 45.21 µs 127.57 µs +182.2%
🔴 block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 492.16 µs 1.36 ms +175.7%
🔴 block_quantized_matmul_cached_dense/mxfp4_preexpanded_dense_oncelock_like_proxy/1x1024x1024 44.88 µs 94.37 µs +110.3%
🔴 matmul/medium_generic_bf16_threads=8/32x512x512 363.91 µs 679.69 µs +86.8%
🔴 matmul/large_generic_f32_threads=8/32x1024x1024 3.69 ms 6.71 ms +82.1%
🔴 matmul/medium_generic_f32_threads=8/32x512x512 904.69 µs 1.53 ms +68.6%
🔴 matmul/medium_generic_f32_threads=1/32x512x512 2.13 ms 3.49 ms +64.0%
🔴 matmul/small_generic_f16_threads=8/1x256x256 28.88 µs 44.77 µs +55.0%
🔴 matmul/medium_generic_f16_threads=8/32x512x512 27.98 µs 42.81 µs +53.0%
🔴 matmul/large_generic_bf16_threads=8/32x1024x1024 1.52 ms 2.31 ms +52.4%
🔴 block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 363.19 µs 549.23 µs +51.2%
🔴 matmul/small_generic_bf16_threads=8/1x256x256 29.97 µs 42.64 µs +42.3%
🔴 grammar_masking/llguidance_compute_mask/32 68.79 µs 96.43 µs +40.2%
🔴 matmul/small_generic_bf16_threads=1/1x256x256 29.39 µs 40.87 µs +39.0%
🔴 qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline 3.22 ms 4.48 ms +38.8%
🔴 qwen3_sampling_processors/top_k_top_p_full_sort_baseline 5.23 ms 7.16 ms +37.0%
🔴 matmul/large_generic_f16_threads=1/32x1024x1024 74.06 µs 101.47 µs +37.0%
🔴 qwen3_sampling_processors/top_k_top_p_fast 605.58 µs 824.80 µs +36.2%
🔴 matmul/large_generic_f16_threads=8/32x1024x1024 78.58 µs 105.19 µs +33.9%
🔴 kv_cache/alloc_dealloc_pages 36.23 µs 48.13 µs +32.9%
⚠️ qwen3_sampling_processors/top_k_partial_selection 128.40 µs 164.38 µs +28.0%
⚠️ logit_processing/seven_processor_chain_per_step 298.61 µs 382.20 µs +28.0%
⚠️ matmul/medium_generic_f16_threads=1/32x512x512 28.00 µs 35.80 µs +27.8%
⚠️ sampling_latency/top_k_per_token 48.80 µs 61.85 µs +26.7%
⚠️ qwen3_sampling_processors/top_p_fast_after_top_k 479.35 µs 606.83 µs +26.6%
⚠️ matmul/large_generic_bf16_threads=1/32x1024x1024 1.87 ms 2.25 ms +20.5%
✅ matmul/medium_generic_bf16_threads=1/32x512x512 516.75 µs 586.11 µs +13.4%
✅ sampling_latency/top_p_per_token 351.34 µs 395.51 µs +12.6%
✅ matmul/large_generic_f32_threads=1/32x1024x1024 8.56 ms 9.61 ms +12.3%
✅ tokenization/encode_tokens_per_second 349.67 µs 390.63 µs +11.7%
✅ sampling_latency/greedy_per_token 2.96 µs 3.28 µs +10.8%
✅ tokenization/decode_tokens_per_second 5.74 ms 6.34 ms +10.6%
✅ block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 73.22 µs 80.41 µs +9.8%
✅ sampling_latency/min_p_per_token 195.95 µs 211.63 µs +8.0%
✅ matmul/small_generic_f16_threads=1/1x256x256 28.78 µs 30.63 µs +6.4%
✅ qwen3_sampling_processors/top_k_full_sort_baseline 2.04 ms 2.15 ms +5.3%
✅ gather/small_bf16_threads=1-internal/4096 433.8 ns 449.6 ns +3.6%
✅ gather/medium_f32_threads=1-internal/32768 3.34 µs 3.45 µs +3.5%
✅ matmul/small_generic_f32_threads=1/1x256x256 34.05 µs 35.15 µs +3.2%
✅ gather/large_bf16_threads=1-internal/131072 9.49 µs 9.78 µs +3.0%
✅ add/medium_f16_threads=1-internal/262144 2.37 ms 2.41 ms +1.5%
✅ add/medium_f32_threads=1-internal/262144 2.39 ms 2.41 ms +0.8%
✅ add/small_bf16_threads=1-internal/1024 11.84 µs 11.91 µs +0.6%
✅ add/large_f16_threads=1-internal/4194304 38.46 ms 38.30 ms -0.4%
✅ add/medium_bf16_threads=1-internal/262144 2.50 ms 2.48 ms -0.8%
✅ gather/small_f32_threads=1-internal/4096 599.3 ns 593.4 ns -1.0%
✅ gather/small_f16_threads=1-internal/4096 441.4 ns 435.8 ns -1.3%
✅ reduce_mean/small_f32_threads=1-internal/4096 14.06 µs 13.77 µs -2.1%
✅ matmul/small_generic_f32_threads=8/1x256x256 32.34 µs 31.43 µs -2.8%
✅ add/large_f32_threads=1-internal/4194304 38.72 ms 37.48 ms -3.2%
✅ add/large_bf16_threads=1-internal/4194304 40.89 ms 38.60 ms -5.6%
✅ gather/medium_bf16_threads=1-internal/32768 2.27 µs 2.14 µs -5.9%
✅ add/small_f32_threads=1-internal/1024 189.0 ns 177.5 ns -6.1%
✅ reduce_mean/medium_f32_threads=1-internal/65536 250.73 µs 229.82 µs -8.3%
✅ reduce_mean/large_f32_threads=1-internal/262144 998.46 µs 912.89 µs -8.6%
✅ gather/large_f16_threads=1-internal/131072 10.99 µs 9.90 µs -9.9%
✅ gather/medium_f16_threads=1-internal/32768 2.40 µs 2.16 µs -10.0%
🟢 gather/large_f32_threads=1-internal/131072 31.57 µs 24.13 µs -23.6%
🟢 add/small_f16_threads=1-internal/1024 16.76 µs 12.05 µs -28.1%

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: { 8.13 4.80 5.43 }
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)

Copilot AI added 4 commits August 7, 2026 19:04
Roll back failed CoW allocations and any incidental eviction, report migration replacement bytes as an unleased transient, and prevent best-effort prefetch from exceeding hot capacity after demotion failure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Reserve RAII sibling leases from the pool's existing governor authority before replacement stores or rollback snapshots are allocated. Refuse migration pressure without state changes and release transient grants on every path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Reserve the maximum source, free-page, eviction-victim, and replacement transient before cloning. Keep the guard alive until every snapshot drops, and make direct Page migration private so accounting cannot be bypassed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Split persistent page growth from transient snapshots and absorb successful growth into the pool lease. Rollback releases growth only after the allocated page is removed.

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

Copy link
Copy Markdown
Owner Author

Five review rounds closed the accounting and transactional boundaries before merge.

What changes

KV tier migration is now a real storage operation:

create target store → copy f32/int8/fp8/scales → atomically replace ownership

Page::device is gone. Residency belongs to the store; production code no longer changes a label while moving zero bytes. Current GPU-tier storage remains host-backed emulation, documented honestly. Stage 3 will supply CudaPageStore without changing cache callers.

Bugs found and fixed during review

  1. Failed CoW copy leaked a newly allocated live page; repeated retries exhausted hot capacity. Rollback now restores allocation/free-list state.
  2. Prefetch promoted after failed demotion and could exceed hot capacity. It now aborts/rechecks.
  3. UnleasedTransient(bytes) was dead metadata consumed only by tests; migration actually exceeded the lease. Replaced by real RAII transient reservation from the same authority.
  4. CoW snapshots were cloned before reservation, and lease lifetime ended before snapshots dropped. All temporary storage is pre-reserved and guard lifetime covers object lifetime.
  5. Public Page::migrate allowed bypassing table-mediated leasing. Migration helper is private; no production bypass remains.
  6. No-free-page CoW treated the new persistent page as transient, then dropped its lease while storage remained. Persistent growth now moves into pool_lease; only snapshots/copy buffers are transient.

Final tests cover free-page and no-free-page branches, repeated growth, copy failure, directional migration failure, exact ledger peak/release, mixed non-zero storage components, and sabotage of reserve enforcement. Final review found no significant issue.

Remaining limitation

Both CPU and emulated GPU locations still use HostPageStore. Host transient charging is therefore honest today. Real CUDA allocation/copy and per-tier authority are Stage 3; this PR establishes the migration/ownership contract they plug into.

Merging.

@justinchuby
justinchuby merged commit 0f0b6c0 into main Aug 8, 2026
13 of 15 checks passed
@justinchuby
justinchuby deleted the squad/721-real-kv-tier-migration branch August 8, 2026 08:09
justinchuby added a commit that referenced this pull request Aug 24, 2026
Four doc sites still told a reader that a `CudaPageStore` was on its way
and that the host-backed tiers were *"emulation until Stage 3"*. #721's
own body has said otherwise since 2026-08-18:

| stage | status |
|---|---|
| 1. Extract `KvPageStore` + `HostPageStore` | **merged** (#726) |
| 2. Make tier migration real, charged to the governor | **merged**
(#742) |
| 3. `CudaPageStore` backed by the VMM arena | **superseded** |
| 4. Native CUDA decode consumes `PageTable` | **superseded** |

On native CUDA, device KV paging is owned by the **VMM layer** —
`CudaVmmAllocator` with its physical-handle pool (#740),
committed-granule admission (#745) and growth grants (#748). It already
reserves a stable virtual range and maps committed granules behind it. A
second page allocator inside `onnx-genai-kv` would **duplicate that
ownership rather than complete it**.

## Why this is worth its own commit

Comments are where a cancelled plan survives longest. *"until Stage 3"*
reads as a scheduled prerequisite, so the next person to open the file
either builds a dead stage or spends an afternoon working out that they
shouldn't. The issue body was updated; the code that people actually
read was not.

## What changed

| site | before → after |
|---|---|
| `page_table.rs:19` `DevicePageSpan` | *"a future `CudaPageStore` …
would reserve a stable virtual range"* → the VMM layer already does
exactly that; the span stays because the trait boundary is what keeps
`head_token_row()` off the store contract and what lets an out-of-tree
store exist at all |
| `page_table.rs:94` `KvPageStoreFactory` | *"Stage 3 can supply a
factory"* → an out-of-tree extension point, not an in-crate hook |
| `tiered.rs:1` module doc | *"until Stage 3 supplies a CUDA store"* →
host-addressable tiers are this crate's **end state**, not a waypoint |
| `paged_cache.rs:658` `preempt_sequence` | *"host-backed emulation
until Stage 3"* → declared residencies that stay declared |
| `MEMORY_ARCHITECTURE.md:73` | *"Stages 2–5 in #721"* — names a stage 5
that does not exist and implies three open ones → the real per-stage
status, pointing at the issue body as authoritative |

That last row already said device KV paging belongs to the VMM layer, so
this only stops the two halves of one table cell contradicting each
other.

## Deliberately left alone

`copy_from_host`'s *"a future device store can implement this with an
explicit host-to-device transfer"* is **not** the same claim and stays.
An out-of-tree device store remains possible and supported; what is not
coming is one **in this crate**, on this repository's roadmap.

## Validation

Doc comments and one doc table cell. No behaviour change — `cargo check
-p onnx-genai-kv` and `cargo doc -p onnx-genai-kv --no-deps` both clean.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants