Skip to content

[Feat][Model] Support encoder-side SWA-bounded replay for DeepSeek-V4.1-Flash - #56227

Open
ivanium wants to merge 4 commits into
mainfrom
feat/dsv41-swa-bounded-replay
Open

ivanium wants to merge 4 commits into
mainfrom
feat/dsv41-swa-bounded-replay

Conversation

@ivanium

@ivanium ivanium commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Purpose

DeepSeek-V4.1 keeps a 128-token sliding-window (SWA) KV cache per layer beside its prefix-cacheable MLA/indexer caches. Storing that window for prefix caching and KV connectors costs more than it saves, so this implements DeepSeek's encoder-side "SWA bounded replay": the SWA groups opt out of prefix caching, and after a Main/Indexer hit ending at H the scheduler recomputes the hit's last window [H-128, H) to rebuild them. Replayed tokens keep their cached KV in every other group (their paged-KV writes are padded), their window attention ignores positions below H-128, and decode always sees a full window. Outputs after a hit are therefore not bit-identical to a cold run, by design.

  • Spec contract: SlidingWindowMLASpec(bounded_replay=True) derives prefix_cacheable=False and prefix_replay_tokens=sliding_window; UniformTypeKVCacheSpecs forwards it so the worker reads it through packed groups.
  • Scheduler: a hit records Request.replay_start = H-128 and rewinds the computed count by the window; async connector loads replay once the KV arrives; chunks ending inside the replayed range allocate nothing new; hits no longer than the window are not adopted (they would be recomputed in full).
  • Worker (model runner V2): replay_start lives in RequestState, is gathered per batch by the existing pos/seq_lens kernel and reaches attention metadata as CommonAttentionMetadata.replay_start; the slot-mapping kernel pads the replayed window's writes in prefix-cacheable groups, and compressed slot mappings (MLA and indexer) inherit that padding inside their kernel.
  • DeepSeek-V4.1 kernels clamp the window at replay_start: SWA index and gather-length kernels, the FlashInfer mixed index kernel and the FlashMLA combine kernel.
  • Switch: --swa-bounded-replay (CacheConfig.swa_bounded_replay, default on), read by the V4.1 attention layer. Needs model runner V2; on V1 it logs and keeps the SWA cache in prefix caching.
  • Separate commit: the ratio-2 compressor ring wrote into the shared null block 0 during CUDA graph capture (all-zero dummy block tables); ring slots of block 0 are now padded.

KV-connector load failures: with #50388 a hybrid request that lost blocks is recomputed from position 0. Async loads then go back through admission, which recomputes the replay boundary. A running request under kv_load_failure_policy=recompute is restarted in place and keeps its worker-side replay boundary, so it would recompute with a stale clamp and write mask; a follow-up will preempt such requests instead (in-place restart also writes sliding-window KV of positions below the old window into the shared null block, replay or not). No other open PR implements SWA replay; #53558 (KV cache config builder) is orthogonal.

Usage

Nothing to configure: DeepSeek-V4.1 replays by default on model runner V2 (the default runner). --no-swa-bounded-replay restores the previous behaviour (SWA KV stored in the prefix cache). With replay on, KV connectors transfer only the MLA/indexer caches; the SWA cache is rebuilt locally after every hit, and hits of at most 128 tokens are not adopted.

Test Plan

pytest tests/v1/core/test_prefix_replay.py tests/v1/core/test_scheduler.py tests/v1/core/test_contiguous_kv_packing.py \
  tests/v1/worker/test_gpu_block_table.py tests/v1/worker/test_gpu_input_batch_v2.py \
  tests/v1/attention/test_deepseek_v4_swa_visible.py tests/v1/attention/test_indexer_deepseek_v4_slot_mapping.py \
  tests/kernels/test_compressor_kv_cache.py tests/v1/kv_connector/unit/test_kv_load_failure_recovery.py

Accuracy: deepseek-ai/DeepSeek-V4.1-Flash on TP4 GB200 with vllm serve, gsm8k (5-shot) run twice against the same server; pass 2 re-sends every prompt, so each request is a full-prompt prefix hit that replays only its last window (92.7% prefix-cache hit rate).

Test Result

All listed tests pass; pre-commit and mypy are clean. A per-layer probe (cold prefill vs. prefix-hit replay of one prompt) confirms the replayed rows follow the clamped window and the cached Main/indexer KV is untouched.

gsm8k, n=1319 (pass 1 cold / pass 2 all hits) replay on (this PR) replay off (--no-swa-bounded-replay)
exact_match (flexible-extract) 0.9265 / 0.9265 0.9295 / 0.9295

The 0.3% gap is the same in pass 1, where almost nothing is replayed, so it is run-to-run noise rather than a replay effect. An earlier two-pass GPQA-diamond run on an internal pre-release checkpoint showed the same parity (0.6869 / 0.6919 with replay vs 0.7020 / 0.6869 without, n=198).

🤖 Developed with Claude Code; all changes reviewed and tested by the author.


Essential Elements of an Effective PR Description Checklist
  • The purpose of the PR, such as "Fix some issue (link existing issues this PR will resolve)".
  • The test plan, such as providing test command.
  • The test results, such as pasting the results comparison before and after, or e2e results
  • (Optional) The necessary documentation update, such as updating supported_models.md and examples for a new model.

@ivanium
ivanium marked this pull request as ready for review September 10, 2026 07:45
@mergify mergify Bot added deepseek Related to DeepSeek models DSv4 labels Sep 10, 2026
@mergify mergify Bot added the mrv2 Model Runner V2 specific label Sep 10, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@mergify

mergify Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @ivanium.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Sep 11, 2026
Base automatically changed from dsv41-feat to main September 11, 2026 09:11
@ivanium
ivanium force-pushed the feat/dsv41-swa-bounded-replay branch from 464ddaa to 958593a Compare September 11, 2026 18:20
@mergify mergify Bot removed the needs-rebase label Sep 11, 2026
@ivanium
ivanium force-pushed the feat/dsv41-swa-bounded-replay branch from 958593a to 49cfff3 Compare September 13, 2026 00:28
@ivanium
ivanium requested a review from zhuohan123 as a code owner September 13, 2026 00:28
@mergify

mergify Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @ivanium.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

ivanium and others added 4 commits September 15, 2026 00:46
DeepSeek-V4.1 keeps a 128-token sliding-window (SWA) KV cache next to its
prefix-cacheable MLA/indexer caches. Storing that window for prefix
caching and KV connectors costs more than it saves, so this implements
DeepSeek's "SWA bounded replay": the SWA groups opt out of prefix caching,
and after a hit ending at H the scheduler recomputes the hit's last window
[H-128, H) to rebuild them. Those tokens keep their cached KV in every
other group, and their window attention ignores positions below H-128.

- Spec: `SlidingWindowMLASpec(bounded_replay=True)` derives
  `prefix_cacheable` (False) and `prefix_replay_tokens` (the window);
  `UniformTypeKVCacheSpecs` forwards the latter so the worker reads it
  through packed groups.
- Scheduler: `_mark_prefix_replay` records the request's `replay_start` and
  rewinds its computed count by the window. Hits are replayed at fresh admission (local and sync connector)
  and after an async connector load. Chunks that end inside the replayed
  range allocate nothing past the hit. A hit no longer than the window is
  not adopted at all (it would be recomputed in full), so a replaying
  request always keeps computed tokens. All replaying groups must agree
  on one window.
- Worker: `replay_start` lives in `RequestState`, is gathered per batch by
  the existing pos/seq_lens kernel and reaches the backends through
  `CommonAttentionMetadata`; the slot-mapping kernel pads the replayed
  window's writes in prefix-cacheable groups. Compressed slot mappings
  (MLA and indexer) inherit that padding inside their kernel.
- DeepSeek-V4.1 kernels clamp the window at `replay_start`: the SWA index
  and gather-length kernels, the FlashInfer mixed index kernel and the
  FlashMLA combine kernel.
- Policy: `CacheConfig.swa_bounded_replay` (`--swa-bounded-replay`, default
  on), read by the V4.1 attention layer. Requires model runner V2; on V1 it
  logs and keeps the SWA cache in prefix caching.

Two-pass gsm8k on TP4 GB200 (pass 2 = 92.6% full-prompt hits): pass 1
0.8954, pass 2 0.8976 with replay vs 0.898 with the SWA cache stored.
GPQA-diamond two-pass (n=198): 0.6869 / 0.6919 vs 0.7020 / 0.6869.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai>
Dummy batches (profiling, CUDA graph capture) carry all-zero block tables.
Every other cache write goes through the PAD slot mapping and is skipped,
but the ratio-2 compressor derives its ring slot from the block table, so
capture wrote a FP32 ring row into block 0. In the packed layout every
group's block b overlays the same physical page, so the shared null block
came up non-zero in every layer's sliding-window and main-cache view
(layers 0-19). Nothing reads the null block in normal operation, but any
window that does reach it (e.g. a bounded replay without its clamp) then
attends garbage instead of zeros.

Pad ring slots whose block is 0. Verified with an init-only probe: block 0
of every KV cache view is zero after CUDA graph capture, where it held a
full ring row before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai>
… too

The indexer builder derives its compressed slot mapping per rank and, under
prefill context parallelism, passed no token slot mapping to inherit the
runner's write mask from, because the one it holds is in the gathered
layout. That layout is each rank's local tokens, padded, in rank order, so
this rank's segment lines up with its query_start_loc: pass that slice.
Replayed tokens then leave the indexer's cached K alone under PCP as they
already do without it, and replicated decodes on non-zero ranks stop
writing compressed slots, matching the KV write mask.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai>
Review feedback on the SWA bounded replay: the compressed slot mapping kernel
does not need a HAS_TOKEN_SLOT_MAPPING specialization. Every builder has the
tokens' slot mapping at hand, and a token whose own slot is padded closing no
compressed state holds whether or not bounded replay is on, so the kernel
always reads it. That drops the constexpr branch, the doubled warm-up keys and
the pointer aliasing for the absent case; the DeepSeek-V4 sparse MLA builder
now passes its slot mapping like the V4.1 builders do.

The input keeps vLLM's name for the tokens' slots, `slot_mapping`, and sits
with the other inputs; the output is the `compressed_slot_mapping` the
function is named after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai>
@mergify

mergify Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @ivanium.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Todo
Status: No status
Status: Backlog
Status: No status
Status: To triage

Development

Successfully merging this pull request may close these issues.

2 participants