Skip to content

Add compact fixed-page CPU KV offload for packed heterogeneous caches - #32

Merged
jasl merged 6 commits into
jasl:codex/ds4-sm120-min-enablefrom
coltonottley:compact-cpu-kv-offload
Jul 21, 2026
Merged

Add compact fixed-page CPU KV offload for packed heterogeneous caches#32
jasl merged 6 commits into
jasl:codex/ds4-sm120-min-enablefrom
coltonottley:compact-cpu-kv-offload

Conversation

@coltonottley

@coltonottley coltonottley commented Jul 20, 2026

Copy link
Copy Markdown

Summary

This adds an optional compact CPU KV offload layout for packed heterogeneous KV caches.

The implementation was developed and validated on two independent TP=2 hosts, each with 2x RTX PRO 6000 Blackwell GPUs. In the deployed DeepSeek V4 Flash geometry, compact per-group fixed-page storage reduced CPU payload amplification by about 19x versus storing full packed rows. Combined with separate TP2 shared-primary rank dedup work in vllm-project#48906, the measured end-to-end opportunity is about 38-40x. This PR contains the compact-layout part only; it does not duplicate vllm-project#48906.

Compact mode is disabled by default and activates only with:

kv_connector_extra_config:
  enable_compact_layout: true

What is included

The six commits are intentionally layered:

  1. Foundation — transport-neutral compact accounting/address types, fixed-page scatter allocation, and variable-size LRU/ARC eviction.
  2. Geometry transport — carries a two-field scheduler-safe signature and worker-side packed slice geometry through the existing normalized offloading config.
  3. Transfer planner — maps actual GPU block IDs and packed slice offsets to compact CPU addresses, including fragmented physical spans and partial first chunks.
  4. Manager storage — extends the existing CPUOffloadingManager with atomic batch allocation/eviction, pending/committed ownership, load refcounts, and exact counters.
  5. Worker/spec wiring — uses the existing CPU spec, worker, and async descriptor lifecycle for both GPU-to-CPU stores and CPU-to-GPU loads.
  6. Safe compact copy submission — keeps the existing stream/event/job lifecycle, but compact scatter selects ordered per-descriptor cudaMemcpyAsync; ordinary swaps retain the cuMemcpyBatchAsync default.

The storage allocator uses fixed 64 KiB pages, reduced by gcd(64 KiB, per-rank budget) when necessary. Logical payloads can span arbitrary physical pages, so long-history churn does not require a contiguous free extent.

Scope boundaries

This PR deliberately does not include:

Current compact support is explicit for PP=1. TP>1 with PP=1 is supported. Compact mode with PP>1 fails with ValueError rather than silently constructing incomplete geometry.

Correctness details

  • Compact charge units are real bytes per native GPU block per worker. blocks_per_chunk remains separate and is applied exactly once when sizing an offload address.
  • Packed slice offsets and real payload sizes are validated against the transported group signature.
  • Store preparation produces complete physical spans before DMA.
  • Pending keys cannot be loaded.
  • Active loads own canonical BlockStatus references and cannot be evicted or reused until completion.
  • Legacy CPU offloading remains inert and unchanged when compact geometry is absent.

Validation

Final source head: 2e75dbf24ef0bbc0415f6890777e4c4990098790
Base: 832775efd136968c0b241006a6113486e5ceedc5
Tree: 11cca2ccf51f482b1efb1a743acffb3e584e6187

Why source tests were not treated as sufficient

The previous exact image passed the source suite and still exposed two real integration defects on 2x RTX PRO 6000 Blackwell GPUs:

  1. Startup failed because scheduler projection had collapsed rich heterogeneous worker geometry to a representative spec before physical slice derivation.
  2. After that was repaired, the first request failed because scheduler-side aggregate accounting and worker-side physical slice ownership were split inconsistently.

The final series fixes the ownership boundary rather than weakening validation:

  • scheduler manager construction consumes only transported aggregate per-group payload charges;
  • rich worker KVCacheConfig owns physical packed-slice derivation before scheduler projection;
  • scheduler projection explicitly clears worker-only physical slices;
  • worker creation fails loud if required physical metadata is absent;
  • the CPU manager accepts aggregate payload maps, while transfer handlers alone own physical offsets.

Source regression coverage

The final rebased source passed 223/223 tests across compact transport/accounting, allocator and eviction policy, transfer planning, manager lifecycle, real bidirectional compact transfer, worker/spec wiring, and legacy/shared-region behavior.

Also clean:

  • Ruff check and format check;
  • py_compile for changed Python files;
  • git diff --check;
  • DCO trailers on all six commits;
  • exact six-commit ancestry on the current target base;
  • clean worktree.

These tests are regression coverage. Hardware qualification below is the acceptance proof.

Exact-image hardware qualification

The exact rebased tree was built into immutable image:

  • image ID: sha256:b2b79283a8aa48de32a0ac988ddc0b6c5497e2b0bbfd5d9f6d34c7273c70c59a
  • PyTorch 2.11.0+cu130, CUDA 13.0, FlashInfer 0.6.14

Qualification used one independent TP=2 endpoint with 2x RTX PRO 6000 Blackwell GPUs, DeepSeek V4 Flash, MTP2, FP8 KV, 1,000,000-token model length, and a 64 GiB compact CPU tier. No source overlays or bind-mounted code were present.

Both exact-image startups reported:

  • 11.71 GiB available GPU KV memory;
  • 1,229,713 GPU KV-cache tokens;
  • 1.23x maximum concurrency at a 1,000,000-token request;
  • zero restarts.

Runtime gates passed:

  • normal text completion;
  • parsed tool call with correct arguments and finish_reason=tool_calls;
  • exact 1,000,000-token admission;
  • canonical decode: 190.95 output tok/s scored over runs 2-4;
  • canonical unique cold ~100K prefill: 6,764 tok/s on scored run 2 (run 1: 6,879 tok/s).

The CPU-hop proof used a cold 18,877-token target, 30 seconds of quiescence, then fifteen byte-distinct 198,184-token GPU-only pressure requests before exact replay:

  • cold store: 2,557,243,520 bytes;
  • replay output: exact QUIESCENT_CPU_HIT_OK;
  • external CPU hit: 18,688 tokens;
  • local GPU hits: 0;
  • recomputed tail: 189 tokens;
  • CPU-to-GPU load: 174,897,280 bytes in 0.0103 s;
  • all fifteen pressure requests completed;
  • zero accepted-run tracebacks, assertions, 5xx responses, or process restarts.

This exact-image hardware run is the acceptance gate for the final series; the earlier source-only green run is not presented as sufficient evidence.

TP4 copy-submission defect and repair

A subsequent exact-image TP=4 qualification found a third hardware-only integration defect. A compact CPU-to-GPU replay with about 19,634 copy descriptors per rank left one rank spinning inside execute_model while the other ranks slept. The engine timed out after two minutes. Source comparison localized the difference to compact scatter using cuMemcpyBatchAsync; the accepted product path used ordered per-descriptor cudaMemcpyAsync because large/repeated batch submissions were unsafe.

The sixth commit adds one explicit use_batch_api argument through the existing stable native op. Generic callers keep the batch API default. Compact scatter alone selects use_batch_api=False; stream, event, buffer, and job ownership remain on the canonical lifecycle. There is no descriptor-count threshold.

Native CUDA tests on the rebuilt extension passed 5/5, covering ordinary batch mode, explicit per-copy mode on default and dedicated streams, and a 20,000-descriptor regression.

The repaired immutable image is:

  • image ID: sha256:7407ea290680afcd8212c31238fda950be7637d340d17102884867f9c9b5b751
  • source: 2e75dbf24ef0bbc0415f6890777e4c4990098790
  • tree: 11cca2ccf51f482b1efb1a743acffb3e584e6187
  • PyTorch 2.11.0+cu130, CUDA 13.0, FlashInfer 0.6.14

Final TP=4 qualification used 4x RTX PRO 6000 Blackwell GPUs, DeepSeek V4 Flash, MTP2, FP8 KV, full 1,000,000-token context, and a 64 GiB compact CPU tier. Startup reported 48.67 GiB GPU KV and 5,111,043 GPU-KV tokens. Text and parsed-tool smokes passed.

The strict semantic harness obtained one complete qualified cold-control-plus-CPU-hop chain for all six probes across a primary run and one disclosed stochastic supplement. Every accepted CPU hop had:

  • zero local GPU-cache hits;
  • 48,128 external CPU-KV tokens/hits;
  • 803,200,000 CPU-to-GPU load bytes;
  • exact identity with the probe's three stable cold answers.

The formerly fatal probe passed. The same TP=4 process survived both runs with zero restarts, zero SHM starvation/RPC timeout/fatal/Xid, zero queued requests, and all four ranks/GPUs returning to sleeping/P8 idle state. One duplicate supplemental probe produced a null model final despite successful transfer; it is preserved as stochastic output and was not used as an accepted chain.

Public hardware evidence and related ownership:

@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Once the PR is approved and ready to go, your PR reviewer(s) can run CI to test the changes comprehensively before merging.

To run CI, PR reviewers can either: Add ready label to the PR or enable auto-merge.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

@coltonottley
coltonottley marked this pull request as draft July 20, 2026 19:06
Colton Ottley added 5 commits July 20, 2026 15:30
Add transport-neutral compact accounting and address types, a fixed-page
scatter allocator, and variable-size eviction support for the built-in LRU
and ARC policies.

The legacy CachePolicy.evict extension point remains unchanged. Compact
callers use a new fail-loud evict_until contract, while unsupported external
policies remain instantiable until compact mode is requested.

This commit contains no manager, worker, transfer, shared-memory, rank-
ownership, bounded-tail, or model-specific runtime behavior.

Signed-off-by: Colton Ottley <colton@ottleyengineering.com>
Carry an optional two-field compact signature through KVCacheConfig and the
normalized offloading configuration. The signature records group identity
and real payload bytes per native GPU block per worker; blocks_per_chunk
remains a separate runtime dimension.

Worker-side packed tensor metadata is converted into immutable per-slice
geometry and checked against the scheduler-safe signature. Compact behavior
is disabled when enable_compact_layout is absent, and pipeline parallelism
greater than one fails explicitly.

Signed-off-by: Colton Ottley <colton@ottleyengineering.com>
Add a pure direction-neutral planner that maps actual GPU block IDs and
packed slice offsets to compact CPU addresses, including fragmented physical
spans and partial first chunks.

The planner validates group ordering, bounds, slice payload units, full GPU
ID and CPU address consumption, and exact per-address payload geometry. It
returns immutable pointer and size descriptors without performing DMA.

Signed-off-by: Colton Ottley <colton@ottleyengineering.com>
Extend the existing CPUOffloadingManager with an optional compact path backed
by one global FixedPageAllocator coordinate space. Store preparation performs
atomic batch-fit simulation and variable-size eviction, then returns complete
fragmented physical spans before DMA.

Pending and committed allocations retain the canonical lifecycle. Loads own
BlockStatus references and are non-evictable while active; completion restores
evictability. Write-pending and evictable counters remain exact across store
success, failure, load, eviction, and reset. The legacy manager path is
unchanged when compact geometry is absent.

Signed-off-by: Colton Ottley <colton@ottleyengineering.com>
Activate the compact path through the existing normalized config, CPU spec,
manager, worker, and asynchronous descriptor submission lifecycle. The spec
uses fixed 64 KiB scatter pages reduced by the per-rank budget GCD. The worker
allocates one rank-local pinned region and executes planner descriptors for
both GPU-to-CPU stores and CPU-to-GPU loads.

Compact payload units are per native GPU block per worker and are multiplied
by blocks_per_chunk exactly once. Tests exercise the real compact transfer
method in both directions with packed slice offsets, actual GPU block IDs,
fragmented CPU spans, partial chunks, pointer reversal, and exact byte counts.
The legacy path remains inert when compact geometry is absent.

Signed-off-by: Colton Ottley <colton@ottleyengineering.com>
@coltonottley
coltonottley force-pushed the compact-cpu-kv-offload branch from b3f462c to 420f108 Compare July 20, 2026 22:43
@coltonottley
coltonottley marked this pull request as ready for review July 20, 2026 22:44
@coltonottley
coltonottley marked this pull request as draft July 20, 2026 22:49
Signed-off-by: Colton Ottley <colton@ottleyengineering.com>
@coltonottley
coltonottley marked this pull request as ready for review July 21, 2026 02:38
jasl added a commit that referenced this pull request Jul 21, 2026
…date page test

PR #32 multiplied build_offloading_config's tokens_per_block by
prefill_context_parallel_size (context_parallel_factor = dcp * pcp). That breaks the
upstream invariant that KV-cache sharding ignores PCP (parallel.py documents PCP does
not increase shard count), which the upstream test
test_offloading_spec_kv_sharding_ignores_prefill_context_parallel (added by vllm-project#46570)
asserts (tokens_per_block == (16,) at PCP=2, not (32,)). Restore the upstream
computation (block_size * decode_context_parallel_size). No-op on GB10 (PCP=1).

Also update test_spec_compact_page_size_non_divisible_budget to assert the new
budget-rounding page-size behavior instead of the removed gcd behavior.

Both found by running PR #32's full suite (not just the compact tests) post-merge.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@jasl
jasl merged commit 5a305ad into jasl:codex/ds4-sm120-min-enable Jul 21, 2026
1 of 2 checks passed
@jasl

jasl commented Jul 21, 2026

Copy link
Copy Markdown
Owner

@coltonottley Merged into our fork's codex/ds4-sm120-min-enable (tag sm120-pr-41834-stable-preview-20260721b). Thank you — this is careful, well-layered work, and the opt-in isolation holds: we verified the default path (compact off, PCP=1) is byte-identical and there's no interaction with our two offload deltas (vllm-project#48959 pending-bound, vllm-project#48911 reachable-tails).

We deep-reviewed the six commits and then ran the full tests/v1/kv_offload/ suite (not just the compact tests). Three things came out that are worth folding back / upstreaming — the first two are real bugs, the third a test that encoded a bug:

1. spec.py — fixed-page size can collapse → init OOM/hang (compact-ON). Deriving the page size from math.gcd(64*1024, per_rank_budget) means a budget with low 2-adic valuation collapses it to a few bytes. A round decimal cpu_bytes_to_use=100 GB gives per_rank = 50000000000 = 2^10 * 48828125, so gcd = 1024 and FixedPageAllocator eagerly builds a a ~48.8M-entry free-page list (~1.7 GB of ints); a 2*odd budget yields page size 2 and a multi-billion-entry list → effective OOM/hang at worker init. We fixed it by rounding the per-rank budget down to a whole number of fixed 64 KiB pages (your own stated "stable 64 KiB fixed-page target"), which keeps the page count bounded and wastes < 64 KiB/rank.

2. offloading/config.py — breaks the upstream "KV sharding ignores PCP" invariant. build_offloading_config now multiplies tokens_per_block by prefill_context_parallel_size (context_parallel_factor = dcp * pcp). Upstream's PCP PR vllm-project#46570 explicitly established that KV-cache sharding does not scale with PCP, and ships a test asserting it — test_offloading_spec_kv_sharding_ignores_prefill_context_parallel expects tokens_per_block == (16,) at prefill_context_parallel_size=2, and your change makes it (32,). It's a no-op at PCP=1 (so it passed your qualification), but it fails that upstream test and diverges for any PCP>1 offload user. We reverted to block_size * decode_context_parallel_size. If the compact accounting genuinely needs a PCP-aware size, it probably belongs in the compact-specific charge rather than the shared tokens_per_block.

3. test_spec_compact_page_size_non_divisible_budget asserted the gcd page size (== 64 for budget 123456), i.e. it locked in the behavior from (1); we rewrote it to validate the budget-rounding fix.

None of this affects our target (GB10 never enables CPU offload), and the feature is cleanly opt-in — we merged it as a community preview. The two real fixes are small and I'm happy to open them as a PR against your branch, or you can fold them in; either way they'd be worth carrying upstream since vllm-project#46570's invariant is upstream's.

For reference, our validation: tests/v1/kv_offload/ 538 passed, our offload-connector regression 162 passed, and the default serving path stayed clean (coherence c=1 2/2, GSM8K full-set 0.956, IMA-clean) with your csrc changes built in.

allenh1 pushed a commit to allenh1/vllm that referenced this pull request Jul 27, 2026
spec.py derived the compact page size from gcd(64 KiB, per_rank_budget). A budget
with low 2-adic valuation (e.g. a round decimal cpu_bytes_to_use like 100 GB, whose
per-rank value factors as 2^10 * odd) collapses the page size to ~1 KiB or less, so
FixedPageAllocator eagerly builds a free-page list of tens of millions to billions of
entries and OOM/hangs at worker init. Reachable only with enable_compact_layout=true.

Round the per-rank budget down to a whole number of fixed 64 KiB pages instead (its
own design intent — 'stable 64 KiB fixed-page target'), so the page size always divides
the budget, the free-page count stays bounded (budget/64 KiB), and < 64 KiB/rank is
wasted. Reject a sub-page budget with a clear error rather than constructing a zero-page
allocator. Found in review of PR jasl#32; inert for GB10 (never enables CPU offload).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.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