Skip to content

[Feature] DeepSeek-V4 unified KV pool (option-2): SWA↔compress share physical KV#1659

Open
yhl-amd wants to merge 9 commits into
ROCm:mainfrom
yhl-amd:feature/unified-kv-chunk-pool
Open

[Feature] DeepSeek-V4 unified KV pool (option-2): SWA↔compress share physical KV#1659
yhl-amd wants to merge 9 commits into
ROCm:mainfrom
yhl-amd:feature/unified-kv-chunk-pool

Conversation

@yhl-amd

@yhl-amd yhl-amd commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1640 (feature/swa-full-retain). This branch is cut from that
one, so until #1640 merges the diff here also shows its 6 commits. Only the 3
[Feature] Unified KV pool: ... commits are new in this PR
(git log main..
allocator+coordinator, BlockManager/Sequence/scheduler wiring, base-0 addressing).

Summary

Lets DeepSeek-V4 SWA and the per-type compressors (CSA k=32, HCA k=1) share one
physical KV pool so compress can reuse space SWA freed (and vice versa), instead
of the static swa_pages split that OOMs one side while the other sits idle.
This is Milestone 1 (option-2) of plan_atom_unified_kv_pool.md §11: single
SWA slot space + per-type compress pools; full per-type-SWA symmetry (approach A)
is a later milestone.

Gated behind a new env ATOM_UNIFIED_KV_SHARE (default off → byte-identical
to today). Non-V4 / non-SWA models are unaffected; free_per_req_cache_groups
(GDN/compressor state) is untouched.

Design

  • block_table stays logical (prefix cache + hashing unchanged). Each live
    logical compress block is bound to a physical CSA + HCA block via a coordinator.
  • Base-0 addressing (row = phys*k), all offsets non-negative → flydsl-safe;
    avoids the negative-stride / cross-layer-k wall documented in the plan §5.3.
  • Cross-role cache eviction: a compress alloc that borrows an SWA-cached slot
    drops the SWA hash first, else a later SWA hash-hit would double-claim a
    compress-held slot and corrupt KV. Pinned sparse-checkpoint blocks hold a ref →
    never on the free-list → never borrowed → retention (cached_tokens) preserved.
  • Flag-on per-layer tensor size equals flag-off, so no budget change needed.

Files (new commits only)

  • unified_chunk_pool.py (new): UnifiedTypePool, UnifiedKvCoordinator, unified_slot_counts.
  • block_manager.py / sequence.py / swa_pool.py / scheduler.py: wiring + per-type tables.
  • deepseek_v4_attn.py / deepseek_v4.py / v4_kernels/{csa_translate_pack,paged_prefill_indices}.py: base-0 addressing + per-type routing.
  • tests/test_unified_chunk_pool.py (new): 10 CPU tests.
  • envs.py: ATOM_UNIFIED_KV_SHARE.

Test plan

  • CPU unit tests: pytest tests/test_unified_chunk_pool.py (10 passed) — pool, coordinator, cross-role eviction, admission accuracy, sizing.
  • Flag-off regression: existing SWA sparse-pin / block-manager behavior unchanged (byte-identical path).
  • GPU DSV4-Pro bf16 TP8, eager: flag-off vs flag-on cached_tokens=6528 identical; GSM8K parity.
  • GPU cudagraph (no --enforce-eager): cached_tokens=6528; capture path works (persistent buffers).
  • Full GSM8K 1319, 3-shot local-completions (CI methodology), flag-on cudagraph bf16 = 0.9500 flexible / 0.9507 strict vs CI baseline 0.9522 ± 0.0059 (threshold 0.94) → within noise, no regression.
  • Agentic replay (c48, sparse-32k, bf16): warmup 1,015 reqs / 0 errors; server-side cache-hit parity with flag-off (~84%), Lost-to-SWA-gate <1%.
  • Skewed-load throughput benefit (the payoff; balanced loads are parity by design) — follow-up.
  • fp8 path (blocked on aiter version); TBO+unified (guarded with NotImplementedError).

Note: fp8 has a known aiter blocker, so GPU validation is bf16; bf16 ≈ fp8 accuracy.

yhl-amd and others added 9 commits July 19, 2026 22:46
…euse

DeepSeek-V4's paged-SWA prefill uses a window-only write optimization: each
prefill chunk only persists its trailing `window` tokens to the content-
addressed SWA cache (deepseek_v4.py swa_write) and only materializes the
trailing-window blocks (SlidingWindowPool.ensure_for_tokens). This is correct
for a request's own decode (SWA reads only the last `window` tokens), but the
middle SWA blocks are never written or hash-published, so a cross-request
prefix hit whose reuse boundary lands in the middle of a prior sequence gets
truncated by bounded_hit(). On agentic branch/replay workloads (long shared
prefixes, boundaries deep inside earlier requests) this collapses the actual
server prefix-cache hit rate.

Add an opt-in env flag `ATOM_SWA_FULL_RETAIN` (default off; current window-only
behavior unchanged) that makes the SWA cache retain the full history:

- deepseek_v4.py: prefill swa_write writes the whole chunk (max_seqlen_q)
  instead of min(window, max_seqlen_q).
- swa_pool.py: ensure_for_tokens materializes every block of the chunk
  (free_before=0) so the full-chunk write has a valid physical dst and every
  block gets hash-published; admission_blocks reserves the chunk-peak footprint.
- deepseek_v4_attn.py: swa_pool_num_blocks sizes the pool to the full per-seq
  history so lazily-freed-but-cached blocks survive until a replay reuses them.
- block_manager.py: plumbs the flag into SlidingWindowPool.

The live sliding-window free (free_out_of_window / free_after_prefill_chunk) is
intentionally unchanged: out-of-window refs are still released each chunk/decode
step; freeing stays lazy (hash+KV resident until the slot is overwritten), and
the enlarged pool keeps freed blocks reusable. Costs ~compressed-pool-magnitude
SWA memory, so it pairs with a smaller max_num_seqs / max_model_len when memory
is tight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>
Fix Black code-style CI: line-wrapping in the ATOM_SWA_FULL_RETAIN env entry
and the swa_write write_per_batch conditional.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>
The initial ATOM_SWA_FULL_RETAIN pool sizing reserved
max_num_seqs * ceil(max_model_len/bs) full-resolution SWA blocks. At DeepSeek-V4
Pro's max_position_embeddings=1048576 this is ~5953 GB (96 seqs * 8192 blocks *
8.13 MB), far exceeding device memory -> allocation failure / num_kvcache_blocks
collapse.

That is the wrong model. vLLM's SlidingWindowSpec accounts for SWA by the LIVE
per-request footprint (min(window-1 + max_num_batched_tokens, max_model_len)),
never seqs*max_ctx, and gets prefix-reuse retention from the shared block pool
(LRU), bounded by the total KV budget.

ATOM keeps a separate SWA pool, so size it from the shared budget instead:
under full-retain, allocate num_swa_blocks == num_kvcache_blocks from
available_for_pool // (compressed_block_bytes + swa_block_bytes) — a 1:1 lockstep
with the compressed pool (every compressed block has a paired SWA block). Live
SWA footprint stays ~window/seq (window-free is still on); the remaining pool
holds lazily-freed-but-cached tails, so retention capacity == the compressed
prefix cache. Memory-bounded regardless of max_model_len.

- model_runner.py: full-retain branch sizes both pools equally from the budget.
- deepseek_v4_attn.py: drop the max_model_len-based full-retain branch in
  swa_pool_num_blocks (sizing now lives in the runner); remove now-unused import.

Verified on DeepSeek-V4-Pro TP=8, bf16 KV, default max_model_len=1048576:
num_swa_blocks=num_kvcache_blocks=14516, swa_reserved=109.86 GB (was ~5953 GB),
boots with no OOM; middle-of-sequence prefix reuse still 98.4%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>
The 1:1 full-retain sizing (num_swa_blocks == num_kvcache_blocks) starves the
compressed prefix index because one SWA block is ~7x the bytes of one compressed
block: on DSV4-Pro it cut the compressed pool from ~119.5k to ~14.5k blocks, and
on the real AgentX workload that collapsed the actual hit rate (5.76%, below the
13.43% default) — the reuse bottleneck moved from "SWA tail lost" to "compressed
prefix evicted".

Give the SWA tail pool a small fraction `f` (env ATOM_SWA_TAIL_BUDGET_FRAC,
default 0.2, clamped (0,0.9)) of the KV budget; the rest stays with the
compressed pool:

    swa_budget         = f * available_for_pool
    num_swa_blocks     = swa_budget // swa_block_bytes
    num_kvcache_blocks = (available_for_pool - swa_budget) // compressed_block_bytes

The SWA pool uses the same eviction discipline as vLLM's FreeKVCacheBlockQueue
(free-queue, evict oldest-freed from the front, reused-on-hit blocks re-queued at
the back), so the tail pool retains the hot-boundary working set via LRU while
compressed stays near full.

Verified DSV4-Pro TP=8, bf16 KV, default max_model_len=1048576, f=0.2:
num_swa_blocks=3315 (25.09 GB), num_kvcache_blocks=93461 (vs 14516 at 1:1, vs
119500 default) — compressed only -22% instead of -88%; boots with no OOM;
middle-of-sequence prefix reuse still 98.4%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>
Instrument the prefix-cache path to separate the two failure modes when reuse
is low: reuse lost to the SWA tail gate vs reuse lost to compressed eviction.

- Sequence: record num_compressed_hit_blocks (pre-gate compressed-prefix hash
  hit) set by BlockManager.can_allocate.
- CacheStats: track total/interval compressed tokens; log per interval and
  cumulative "Hit", "Compressed-hit", and "Lost-to-SWA-gate" rates. A high
  Lost-to-SWA-gate means tails are evicted (SWA pool too small/churny); a low
  Compressed-hit means compressed prefixes are evicted (compressed pool too
  small). Guides ATOM_SWA_TAIL_BUDGET_FRAC tuning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>
Dense full-retain floods ATOM's small isolated SWA pool: at Flash c=48 the SWA
gate rejected ~91% of an otherwise-97% compressed prefix hit because freed tails
were overwritten before a branch reused them (measured; f-tuning 0.2->0.5 barely
moved it). vLLM avoids this by retaining SWA tails SPARSELY in a shared pool
(VLLM_PREFIX_CACHE_RETENTION_INTERVAL / SlidingWindowManager reachable_block_mask)
and hits ~97.5% on the identical BF16 workload.

Port the sparse policy, adapted to ATOM's separate small SWA pool where masking
alone is insufficient — the checkpoint block must be PINNED so live-window churn
cannot overwrite it:

- ATOM_SWA_RETENTION_INTERVAL (tokens, default 0 = dense). >0 designates a tail
  a checkpoint iff it is in the trailing tail_blocks of a retention_interval-
  sized segment OR of the prompt boundary (mirrors vLLM segment + reachable
  boundary tails).
- publish_hash pins checkpoint blocks (extra ref so free_out_of_window / eviction
  skip them); non-checkpoints are freed/churned normally. LRU-capped at
  ATOM_SWA_CHECKPOINT_FRAC of the pool; claim_cached refreshes recency.
- bounded_hit is unchanged and stays correct: it truncates a hit to the nearest
  present checkpoint, so a reuse boundary whose tail was evicted is safely
  shortened, never mis-served.

Default off (interval 0) → dense behavior byte-identical. Unit-tested: checkpoints
survive window-free + churn, non-checkpoints are reclaimed, LRU eviction bounded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>
…dinator

Add the physical allocator for the DeepSeek-V4 unified KV pool
(ATOM_UNIFIED_KV_SHARE, plan_atom_unified_kv_pool.md §10/§11), letting SWA and
the per-type compressors (CSA k=32, HCA k=1) draw from one 128-row-slot
free-list so compress can reuse space SWA freed, instead of the static
swa_pages split.

- UnifiedTypePool: one layer type's shared slot free-list (SWA blocks + that
  type's compress blocks, base-0 addressing, flydsl-safe non-negative offsets).
- UnifiedKvCoordinator (option-2): single SWA slot space shared across types,
  per-type compress pools, with cross-role cache eviction (a compress alloc that
  borrows an SWA-cached slot drops the SWA hash first, or a later SWA hash-hit
  would double-claim a compress-held slot) and accurate has_free_swa accounting.
- unified_slot_counts: single sizing source shared by BlockManager and the
  attention builder (flag-on tensor size == flag-off).
- New env ATOM_UNIFIED_KV_SHARE (default off = byte-identical to today).
- tests/test_unified_chunk_pool.py: 10 CPU tests (pool, coordinator, eviction,
  admission accuracy, sizing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>
…scheduler

Drive the unified KV pool from the block manager when ATOM_UNIFIED_KV_SHARE is
on (no-op / byte-identical when off). block_table stays LOGICAL (prefix cache
and hashing unchanged); each live logical compress block is bound to a physical
CSA + HCA block via the coordinator.

- Sequence: add per-type physical compress tables csa_block_table /
  hca_block_table (parallel to block_table / swa_block_table).
- BlockManager: create UnifiedKvCoordinator (flag-on, V4 only), inject it into
  the SWA pool as its chunk_pool, register the SWA evict callback; bind physical
  on true alloc / evict-reuse, retain across cache-free for lazy reuse, free on
  evict-reuse (when SWA can reclaim); admission checks per-type capacity.
- SlidingWindowPool: draw its free-list from the injected coordinator; add
  evict_slot() (drop a content-cache entry when compress overwrites its slot).
- scheduler: gather csa/hca block tables alongside swa_block_tables.

Non-V4 / flag-off paths are untouched (free_per_req_cache_groups not touched;
SWA pool disabled for non-SWA models).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>
… kernels)

Physical/kernel side of the unified KV pool (ATOM_UNIFIED_KV_SHARE). Flag-off
keeps today's swa_pages-offset layout byte-identical.

- deepseek_v4_attn.py: size unified_kv per layer type (T_L = n_type_slots*128,
  via unified_slot_counts; == flag-off size); _bind_compress_view binds the WHOLE
  tensor as base-0 compress blocks (row = phys*k), so the three compress-write
  paths need no kernel change; add csa/hca CpuGpuBuffers + _populate_compress_
  block_tables + metadata fields; route decode/prefill index builders to the
  per-type table with base 0. Guard TBO+unified (not yet wired) with a clear
  NotImplementedError; assert block_ratio==1.
- deepseek_v4.py: route the MAIN compressor write to the per-type physical table
  (_main_compress_bt); keep the indexer inner compressor on the logical table
  (it writes the separate v4_csa_idx_kv pool); csa_translate_pack reads the CSA
  table with base 0.
- csa_translate_pack.py / paged_prefill_indices.py: swa_pages=0 in unified mode
  yields the absolute unified row (phys*k), no reverse/negative offset.

Verified (bf16 TP8): flag-off/flag-on cached_tokens=6528 identical; eager +
cudagraph both correct; full GSM8K 1319 3-shot = 0.9500 flexible vs CI baseline
0.9522 (within noise); agentic c48 replay 0 errors, cache-hit parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: yihonglie <hyi@amd.com>
@github-actions

Copy link
Copy Markdown
Contributor

🏷️ CI Guide

Runs automatically on every eligible PR before approval:

  • ✅ Pre Checkin: Black, Ruff, catalog schema validation, non-GPU unit tests

Heavy model tests:

  • ✅ Run after the PR is approved and Pre Checkin passes
  • ✅ Run immediately when an approval review is submitted
  • ✅ Can be requested before approval with labels
Label Tests
ci:full Run all heavy PR model tests: native ATOM, vLLM, and SGLang
ci:atom Run native ATOM model accuracy tests
ci:vllm Run ATOM vLLM OOT model accuracy tests
ci:sglang Run ATOM SGLang model accuracy tests

Heavy jobs are skipped when the PR is not approved and no matching ci:* label is present.
Add labels via the sidebar or gh pr edit 1659 --add-label <label>

@zufayu
zufayu requested a review from jiayyu July 22, 2026 01:24
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.

1 participant