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

Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Woosuk Kwon <woosuk@inferact.ai>

Yifan Qiao committed Sep 18, 2026 at 15:01 UTC 1dc2d854c12099f1f08233c886368a8c7baf430f
30 files changed +1067 -38
tests/models/test_deepseek_v41_replay_start.py new
+112
@@ -0,0 +1,112 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""SWA bounded replay in DeepseekV41ModelState: the batch's replay starts reach
4 +the sliding-window builders, and the replayed tokens' slots are padded in the
5 +prefix-cacheable groups only."""
6 +
7 +from dataclasses import replace
8 +from types import SimpleNamespace
9 +from unittest.mock import MagicMock
10 +
11 +import numpy as np
12 +import pytest
13 +import torch
14 +
15 +from vllm.config import CUDAGraphMode
16 +from vllm.models.deepseek_v41.nvidia.model_state import DeepseekV41ModelState
17 +from vllm.v1.attention.backends.mla.sparse_swa import DeepseekSparseSWAMetadataBuilder
18 +from vllm.v1.attention.backends.utils import PAD_SLOT_ID
19 +from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
20 +from vllm.v1.worker.gpu.model_states.default import DefaultModelState
21 +
22 +pytestmark = pytest.mark.skipif(not torch.cuda.is_available(), reason="needs CUDA")
23 +
24 +DEVICE = torch.device("cuda")
25 +WINDOW = 4
26 +# Group 0 is prefix-cacheable (compressed KV), group 1 is the replayed window.
27 +KV_CACHE_CONFIG = SimpleNamespace(
28 + kv_cache_groups=[
29 + SimpleNamespace(
30 + kv_cache_spec=SimpleNamespace(prefix_cacheable=True, prefix_replay_tokens=0)
31 + ),
32 + SimpleNamespace(
33 + kv_cache_spec=SimpleNamespace(
34 + prefix_cacheable=False, prefix_replay_tokens=WINDOW
35 + )
36 + ),
37 + ]
38 +)
39 +
40 +
41 +@pytest.fixture
42 +def state(monkeypatch):
43 + cfg = MagicMock()
44 + cfg.model_config.enable_prompt_embeds = False
45 + cfg.model_config.uses_mrope = False
46 + cfg.model_config.is_multimodal_model = False
47 + cfg.scheduler_config.max_num_seqs = 8
48 + cfg.scheduler_config.max_num_batched_tokens = 64
49 + seen: dict = {}
50 +
51 + def prepare_attn(self, input_batch, cg_mode, block_tables, slot_mappings, *a, **kw):
52 + seen["slot_mappings"] = slot_mappings.clone()
53 + seen["extra"] = kw["model_specific_attn_metadata"]
54 + return {}
55 +
56 + monkeypatch.setattr(DefaultModelState, "prepare_attn", prepare_attn)
57 + state = DeepseekV41ModelState(
58 + cfg, SimpleNamespace(token_lookback_depth=0), None, DEVICE
59 + )
60 + state.seen = seen # type: ignore[attr-defined]
61 + return state
62 +
63 +
64 +def _batch(query_lens, seq_lens, idx_mapping, is_prefilling):
65 + num_reqs, num_tokens = len(query_lens), sum(query_lens)
66 + batch = InputBatch.make_dummy(num_reqs, num_tokens, InputBuffers(8, 64, DEVICE))
67 + return replace(
68 + batch,
69 + query_start_loc=torch.tensor(
70 + [0, *np.cumsum(query_lens)], dtype=torch.int32, device=DEVICE
71 + ),
72 + idx_mapping_np=np.array(idx_mapping, dtype=np.intp),
73 + is_prefilling_np=np.array(is_prefilling, dtype=np.bool_),
74 + positions=torch.cat(
75 + [torch.arange(s - q, s) for q, s in zip(query_lens, seq_lens)]
76 + ).to(DEVICE),
77 + )
78 +
79 +
80 +def _prepare(state, batch):
81 + slot_mappings = torch.arange(2 * batch.num_tokens, device=DEVICE).view(2, -1)
82 + state.prepare_attn(
83 + batch, CUDAGraphMode.NONE, (), slot_mappings, [], KV_CACHE_CONFIG
84 + )
85 + swa_builder = MagicMock(spec=DeepseekSparseSWAMetadataBuilder)
86 + extra = state.seen["extra"]
87 + replay_start = extra.get_extra_attn_kwargs(swa_builder, batch.num_reqs)[
88 + "replay_start"
89 + ]
90 + assert extra.get_extra_attn_kwargs(MagicMock(), batch.num_reqs) == {}
91 + return replay_start.tolist(), state.seen["slot_mappings"]
92 +
93 +
94 +def test_replayed_tokens_write_only_the_window_group(state):
95 + # Request state 3 resumes a hit at 16: positions 16..19 are replayed.
96 + state.add_request(3, SimpleNamespace(replay_start=16))
97 + state.add_request(1, SimpleNamespace(replay_start=0))
98 + batch = _batch([6, 2], [22, 2], idx_mapping=[3, 1], is_prefilling=[True, True])
99 + replay_start, slots = _prepare(state, batch)
100 + assert replay_start == [16, 0]
101 + padded = [PAD_SLOT_ID] * WINDOW + [4, 5, 6, 7]
102 + assert slots[0].tolist() == padded # compressed KV keeps its cached rows
103 + assert slots[1].tolist() == list(range(8, 16)) # the window is rebuilt
104 +
105 +
106 +def test_only_prefills_carry_a_replay_start(state):
107 + state.add_request(0, SimpleNamespace(replay_start=16))
108 + # The same request, decoding: its rows sit above the hit.
109 + batch = _batch([1], [40], idx_mapping=[0], is_prefilling=[False])
110 + replay_start, slots = _prepare(state, batch)
111 + assert replay_start == [0]
112 + assert slots.tolist() == [[0], [1]]
tests/v1/attention/test_deepseek_v4_swa_visible.py
+174 -10
@@ -11,6 +11,8 @@ at ``max(pos - (window - 1) - max(left - (window - 1), 0), 0)`` and ends at
11 ``pos + right`` (inclusive).
12 """
13
14 +import inspect
15 +
16 import pytest
17 import torch
18 from typing_extensions import TypedDict
@@ -57,10 +59,13 @@ def ref_left_right(
59 return lefts, rights
60
61
60 -def ref_swa_bounds(pos: int, window: int, left: int, right: int) -> tuple[int, int]:
61 - """Reference [start, end) window bounds for one query token."""
62 +def ref_swa_bounds(
63 + pos: int, window: int, left: int, right: int, replay_start: int = 0
64 +) -> tuple[int, int]:
65 + """Reference [start, end) window bounds for one query token. Under SWA
66 + bounded replay no window KV exists below ``replay_start``."""
67 left_add = max(left - (window - 1), 0)
63 - start = max(pos - (window - 1) - left_add, 0)
68 + start = max(pos - (window - 1) - left_add, 0, replay_start)
69 return start, pos + right + 1
70
71
@@ -97,12 +102,14 @@ def ref_swa_slot_rows(
102 window: int,
103 max_image_tokens: int,
104 width: int,
105 + replay_starts: list[int] | None = None,
106 ) -> tuple[list[list[int]], list[int]]:
107 """Reference paged slot-id rows and lens for every token in the batch."""
108 block_table_cpu = block_table.cpu()
109 lefts, rights = ref_left_right(
110 seq_lens, query_lens, spans_per_req, max_image_tokens
111 )
112 + replay_starts = replay_starts or [0] * len(seq_lens)
113 rows: list[list[int]] = []
114 lens: list[int] = []
115 token = 0
@@ -110,7 +117,9 @@ def ref_swa_slot_rows(
117 prefix_len = seq_len - query_len
118 for i in range(query_len):
119 pos = prefix_len + i
113 - start, end = ref_swa_bounds(pos, window, lefts[token], rights[token])
120 + start, end = ref_swa_bounds(
121 + pos, window, lefts[token], rights[token], replay_starts[req]
122 + )
123 row = []
124 for p in range(start, end):
125 blk = int(block_table_cpu[req, p // BLOCK_SIZE])
@@ -139,6 +148,7 @@ def run_swa_kernel(
148 swa_indices = torch.zeros(num_tokens, 1, width, dtype=torch.int32, device=device)
149 swa_lens = torch.zeros(num_tokens, dtype=torch.int32, device=device)
150 is_valid = slot_mapping >= 0
151 + replay_start_t = torch.zeros(len(seq_lens), dtype=torch.int32, device=device)
152
153 if with_image:
154 lefts, rights = ref_left_right(
@@ -164,6 +174,7 @@ def run_swa_kernel(
174 block_table,
175 block_table.stride(0),
176 BLOCK_SIZE,
177 + replay_start_t,
178 token_offset=0,
179 HAS_IMAGE=with_image,
180 TRITON_BLOCK_SIZE=1024,
@@ -289,18 +300,25 @@ def combine_case(
300 query_lens: list[int],
301 spans: list[list[tuple[int, int]]],
302 with_image: bool,
303 + replay_starts: list[int] | None = None,
304 + combine_fn=combine_topk_swa_indices,
305 ):
306 """Run combine_topk_swa_indices and return (indices, lens, expected)."""
307 device = torch.device("cuda")
308 num_reqs = len(seq_lens)
309 + replay_starts = replay_starts or [0] * num_reqs
310 query_start_loc = torch.zeros(num_reqs + 1, dtype=torch.int32, device=device)
311 query_start_loc[1:] = torch.tensor(
312 query_lens, dtype=torch.int32, device=device
313 ).cumsum(0)
314 num_tokens = int(query_start_loc[-1])
315 seq_lens_t = torch.tensor(seq_lens, dtype=torch.int32, device=device)
316 + # The builder's gather covers only the context above the replay start.
317 gather_lens = torch.tensor(
303 - [q + min(s - q, WINDOW - 1) for s, q in zip(seq_lens, query_lens)],
318 + [
319 + q + min(max(s - q - r, 0), WINDOW - 1)
320 + for s, q, r in zip(seq_lens, query_lens, replay_starts)
321 + ],
322 dtype=torch.int32,
323 device=device,
324 )
@@ -319,7 +337,14 @@ def combine_case(
337 else:
338 left_t = right_t = None
339
322 - combined_indices, combined_lens = combine_topk_swa_indices(
340 + # V4.1's combine has no image windows (causal SWA only).
341 + has_image = "left_visible" in inspect.signature(combine_fn).parameters
342 + image_kwargs = (
343 + dict(left_visible=left_t, right_visible=right_t, max_image_tokens=MAX_IMG)
344 + if has_image
345 + else {}
346 + )
347 + combined_indices, combined_lens = combine_fn(
348 topk_indices,
349 query_start_loc,
350 seq_lens_t,
@@ -329,9 +354,7 @@ def combine_case(
354 topk,
355 M,
356 N,
332 - left_visible=left_t,
333 - right_visible=right_t,
334 - max_image_tokens=MAX_IMG,
357 + **image_kwargs,
358 )
359
360 # Reference rows.
@@ -342,7 +365,7 @@ def combine_case(
365 MAX_IMG,
366 )
367 topk_cpu = topk_indices.cpu()
345 - width = WINDOW + MAX_IMG
368 + width = WINDOW + (MAX_IMG if has_image else 0)
369 combined_topk = (topk + width + 127) // 128 * 128
370 rows = []
371 lens = []
@@ -354,6 +377,8 @@ def combine_case(
377 pos = prefix_len + i
378 topk_len = min((pos + 1) // compress_ratio, topk)
379 start, end = ref_swa_bounds(pos, WINDOW, lefts[token], rights[token])
380 + # The window never reaches below the gathered buffer.
381 + start = max(start, gather_start)
382 swa_len = end - start
383 row = [-1] * combined_topk
384 for j in range(topk_len):
@@ -388,6 +413,31 @@ def test_combine_topk_swa_with_image_spans(cfg):
413 assert indices.cpu().tolist() == rows
414
415
416 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
417 +@pytest.mark.parametrize("cfg", COMBINE_CASES)
418 +def test_v41_combine_topk_swa_stops_at_replay_start(cfg):
419 + """SWA bounded replay: the gathered buffer starts at replay_start, so the
420 + window never indexes below it."""
421 + from vllm.models.deepseek_v41.common.ops.cache_utils import (
422 + combine_topk_swa_indices as combine_v41,
423 + )
424 +
425 + # Request 0 replays [16, 40): the windows of its first rows would
426 + # otherwise reach below 16.
427 + indices, lens, rows, exp_lens = combine_case(
428 + cfg["compress_ratio"],
429 + cfg["topk"],
430 + seq_lens=[40, 12],
431 + query_lens=[24, 12],
432 + spans=[[], []],
433 + with_image=False,
434 + replay_starts=[16, 0],
435 + combine_fn=combine_v41,
436 + )
437 + assert lens.cpu().tolist() == exp_lens
438 + assert indices.cpu().tolist() == rows
439 +
440 +
441 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
442 @pytest.mark.parametrize("cfg", COMBINE_CASES)
443 def test_combine_topk_swa_without_image_unchanged(cfg):
@@ -472,6 +522,7 @@ def make_builder(
522 DeepseekSparseSWAMetadataBuilder
523 ] = DeepseekSparseSWAMetadataBuilder,
524 mm_prefix_clamp_sliding_window: bool = True,
525 + bounded_replay: bool = False,
526 ) -> DeepseekSparseSWAMetadataBuilder:
527 overrides: dict = {"sliding_window": window}
528 if vision:
@@ -498,6 +549,7 @@ def make_builder(
549 sliding_window=window,
550 cache_dtype_str="auto",
551 model_version="deepseek_v4",
552 + bounded_replay=bounded_replay,
553 )
554 return builder_cls(
555 kv_cache_spec=spec,
@@ -512,11 +564,17 @@ def build_metadata(
564 seq_lens: list[int],
565 query_lens: list[int],
566 mm_req_doc_ranges: dict[int, list[tuple[int, int]]] | None,
567 + replay_starts: list[int] | None = None,
568 ):
569 device = torch.device("cuda")
570 query_start_loc, seq_lens_t, _, slot_mapping, block_table = make_batch(
571 seq_lens, query_lens, device
572 )
573 + replay_start = (
574 + None
575 + if replay_starts is None
576 + else torch.tensor(replay_starts, dtype=torch.int32, device=device)
577 + )
578 return builder.build(
579 0,
580 CommonAttentionMetadata(
@@ -533,6 +591,7 @@ def build_metadata(
591 causal=True,
592 mm_req_doc_ranges=mm_req_doc_ranges,
593 ),
594 + replay_start=replay_start,
595 )
596
597
@@ -653,3 +712,108 @@ def test_v41_image_prefill_uses_causal_swa(compress_ratio, query_len):
712 assert absolute_keys.tolist() == list(range(lo, hi))
713 assert paged_lens[token] == hi - lo
714 assert paged_indices[token, 0, : hi - lo].tolist() == list(range(lo, hi))
715 +
716 +
717 +# SWA bounded replay: every prefill index path clamps the window at replay_start.
718 +
719 +
720 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
721 +def test_builder_replay_start_bounds_prefill_window_and_gather():
722 + """Paged-direct prefill indices stop at replay_start and the FlashMLA
723 + gather only covers the context the request may see."""
724 + seq_lens = [40, 12, 30]
725 + query_lens = [24, 12, 10]
726 + replay_starts = [12, 0, 0]
727 + builder = make_builder(vision=False)
728 + md = build_metadata(builder, seq_lens, query_lens, None, replay_starts)
729 + assert md.replay_start is not None
730 + assert md.replay_start.cpu().tolist() == replay_starts
731 +
732 + _, _, _, _, block_table = make_batch(seq_lens, query_lens, torch.device("cuda"))
733 + rows, lens = ref_swa_slot_rows(
734 + seq_lens,
735 + query_lens,
736 + [[], [], []],
737 + block_table,
738 + WINDOW,
739 + MAX_IMG,
740 + WINDOW,
741 + replay_starts=replay_starts,
742 + )
743 + assert md.prefill_swa_lens.cpu().tolist() == lens
744 + assert md.prefill_swa_indices[:, 0].cpu().tolist() == rows
745 + # Request 0: the first replayed row (pos 16) sees [12, 16]; its last row
746 + # (pos 39) is a full window above the replay start and sees all of it.
747 + assert md.prefill_swa_lens[0].item() == 16 - 12 + 1
748 + assert md.prefill_swa_lens[23].item() == WINDOW
749 + # gather_len = query_len + min(prefix_len - replay_start, WINDOW - 1).
750 + assert md.prefill_gather_lens.cpu().tolist() == [
751 + 24 + min(16 - 12, WINDOW - 1),
752 + 12,
753 + 10 + min(20, WINDOW - 1),
754 + ]
755 +
756 +
757 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
758 +def test_builder_replaying_group_builds_without_replay_start():
759 + """Graph captures build without model-specific kwargs: a replaying group's
760 + metadata then bounds nothing."""
761 + builder = make_builder(vision=False, bounded_replay=True)
762 + md = build_metadata(builder, [40, 12], [24, 12], None)
763 + assert not md.replay_start.any()
764 + assert md.prefill_swa_lens[0].item() == WINDOW
765 +
766 +
767 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
768 +def test_v41_flashinfer_mixed_sparse_indices_respect_replay_start():
769 + from vllm.models.deepseek_v41.common.ops.cache_utils import (
770 + build_flashinfer_mixed_sparse_indices as build_v41,
771 + )
772 +
773 + device = torch.device("cuda")
774 + # 1 decode token (req 0) + two prefill requests; req 1 resumes at 16.
775 + seq_lens = [20, 40, 9]
776 + query_lens = [1, 24, 9]
777 + replay_starts = [0, 16, 0]
778 + query_start_loc = torch.tensor([0, 1, 25, 34], dtype=torch.int32, device=device)
779 + seq_lens_t = torch.tensor(seq_lens, dtype=torch.int32, device=device)
780 + token_to_req = torch.tensor(
781 + [0] + [1] * 24 + [2] * 9, dtype=torch.int32, device=device
782 + )
783 + block_table = torch.arange(3, dtype=torch.int32, device=device).view(3, 1)
784 + kwargs = dict(
785 + decode_swa_indices=torch.zeros((1, WINDOW), dtype=torch.int32, device=device),
786 + decode_compressed_indices=None,
787 + decode_compressed_topk_lens=None,
788 + prefill_topk_indices=torch.empty((33, 0), dtype=torch.int32, device=device),
789 + query_start_loc=query_start_loc,
790 + seq_lens=seq_lens_t,
791 + token_to_req_indices=token_to_req,
792 + swa_block_table=block_table,
793 + swa_block_size=BLOCK_SIZE,
794 + compressed_block_table=None,
795 + compressed_block_size=BLOCK_SIZE,
796 + window_size=WINDOW,
797 + compress_ratio=1,
798 + topk=0,
799 + )
800 + plain, _ = build_v41(
801 + replay_start=torch.zeros(3, dtype=torch.int32, device=device), **kwargs
802 + )
803 + bounded, _ = build_v41(
804 + replay_start=torch.tensor(replay_starts, dtype=torch.int32, device=device),
805 + **kwargs,
806 + )
807 + rows, _ = ref_swa_slot_rows(
808 + seq_lens,
809 + query_lens,
810 + [[], [], []],
811 + block_table,
812 + WINDOW,
813 + MAX_IMG,
814 + WINDOW,
815 + replay_starts=replay_starts,
816 + )
817 + # Decode row is copied through; prefill rows follow the bounded window.
818 + assert bounded[0].cpu().tolist() == plain[0].cpu().tolist()
819 + assert bounded[1:].cpu().tolist() == rows[1:]
tests/v1/attention/test_dspark_noncausal_sparse_mla.py
+1
@@ -669,6 +669,7 @@ def test_dsv41_flashinfer_dspark_window_matches_reference(
669 ),
670 block_table=torch.arange(48, device=device, dtype=torch.int32).view(3, -1),
671 block_size=128,
672 + replay_start=torch.zeros(3, device=device, dtype=torch.int32),
673 flashinfer_sparse_index_cache={},
674 max_decode_query_len=5,
675 )
tests/v1/attention/test_indexer_deepseek_v4_slot_mapping.py
+23
@@ -14,6 +14,7 @@ from vllm.models.deepseek_v41.sparse_mla import (
14 from vllm.v1.attention.backend import CommonAttentionMetadata
15 from vllm.v1.attention.backends.mla.compressor_utils import (
16 CompressedSlotMappingKernel,
17 + get_compressed_slot_mapping,
18 )
19 from vllm.v1.attention.backends.mla.indexer import (
20 BuildPrefillChunkMetadataKernel,
@@ -235,6 +236,28 @@ def test_compressed_slot_mapping_warmup_includes_index_kpool():
236 assert {(key.compress_ratio, key.block_size) for key in keys} == {(32, 2)}
237
238
239 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
240 +def test_compressed_slot_mapping_inherits_padded_token_slots():
241 + """A token whose own slot is padded (SWA bounded replay) closes no
242 + compressed state either."""
243 + device = torch.device("cuda")
244 + query_start_loc = torch.tensor([0, 8], dtype=torch.int32, device=device)
245 + seq_lens = torch.tensor([8], dtype=torch.int32, device=device)
246 + block_table = torch.tensor([[3]], dtype=torch.int32, device=device)
247 + slot_mapping = torch.arange(8, dtype=torch.int64, device=device)
248 + slot_mapping[:4] = -1
249 + compressed = get_compressed_slot_mapping(
250 + 8,
251 + slot_mapping,
252 + query_start_loc,
253 + seq_lens,
254 + block_table,
255 + block_size=4,
256 + compress_ratio=2,
257 + )
258 + assert compressed.tolist() == [-1, -1, -1, -1, -1, 3 * 4 + 2, -1, 3 * 4 + 3]
259 +
260 +
261 def test_index_conversion_warmup_uses_physical_block_stride():
262 config = SimpleNamespace(
263 cache_config=SimpleNamespace(block_size=64),
tests/v1/core/test_contiguous_kv_packing.py
+64
@@ -651,3 +651,67 @@ class TestCompressorRingGroup:
651 scheduler_block_size=128,
652 )
653 assert len(manager.coordinator.single_type_managers) == len(groups)
654 +
655 +
656 +class TestSWABoundedReplayGrouping:
657 + def test_non_cacheable_swa_leaves_hits_to_the_paged_group(self):
658 + """Under SWA bounded replay only the paged MLA group hashes (hash
659 + block = its block) and the coordinator probes one spec group."""
660 + config = _shared_layout_config()
661 + config.cache_config.enable_prefix_caching = True
662 + specs: dict[str, KVCacheSpec] = {
663 + "layers.2.attn": MLAAttentionSpec(
664 + block_size=128,
665 + num_kv_heads=1,
666 + head_size=584,
667 + dtype=torch.uint8,
668 + tokens_per_state=2,
669 + alignment=576,
670 + ),
671 + }
672 + swa = SlidingWindowMLASpec(
673 + block_size=32,
674 + num_kv_heads=1,
675 + head_size=584,
676 + dtype=torch.uint8,
677 + sliding_window=128,
678 + alignment=576,
679 + bounded_replay=True,
680 + )
681 + for layer in range(4):
682 + specs[f"layers.{layer}.attn.swa_cache"] = swa
683 +
684 + groups = get_kv_cache_groups(config, specs)
685 + # The worker reads the replay window off the (packed) group specs to
686 + # arm the window clamp; the wrapper must forward it like cacheability.
687 + assert sorted(
688 + (g.kv_cache_spec.prefix_cacheable, g.kv_cache_spec.prefix_replay_tokens)
689 + for g in groups
690 + ) == [(False, 128)] * 4 + [(True, 0)]
691 + kv_cache_config = get_kv_cache_config_from_groups(
692 + config, groups, available_memory=64 * _get_kv_cache_bytes_per_block(groups)
693 + )
694 + manager = KVCacheManager(
695 + generate_scheduler_kv_cache_config([kv_cache_config]),
696 + max_model_len=8192,
697 + enable_caching=True,
698 + hash_block_size=128,
699 + scheduler_block_size=128,
700 + )
701 + assert len(manager.coordinator.attention_groups) == 1
702 + assert isinstance(
703 + manager.coordinator.attention_groups[0].spec, MLAAttentionSpec
704 + )
705 +
706 + # Allocating and caching a request goes through every group's manager;
707 + # the 32-token SWA group must skip hashing rather than assert on the
708 + # 128-token hash block.
709 + from tests.v1.core.utils import create_requests
710 +
711 + request = create_requests(num_requests=1, num_tokens=256, block_size=128)[0]
712 + assert manager.allocate_slots(request, 256) is not None
713 + request.num_computed_tokens = 256
714 + manager.cache_blocks(request, 256)
715 + for single in manager.coordinator.single_type_managers:
716 + cached = single.num_cached_block.get(request.request_id, 0)
717 + assert cached == (2 if single.kv_cache_spec.prefix_cacheable else 0)
tests/v1/core/test_prefix_caching.py
+3 -1
@@ -6,7 +6,7 @@ import copy
6 from collections.abc import Callable
7 from dataclasses import replace
8 from math import lcm
9 -from types import SimpleNamespace
9 +from types import MethodType, SimpleNamespace
10 from unittest.mock import MagicMock
11
12 import pytest
@@ -533,7 +533,9 @@ def test_nixl_hisparse_full_block_import_keeps_a_writable_tail(num_tokens):
533 kv_cache_manager=manager,
534 failed_recving_kv_req_ids=set(),
535 finished_recving_kv_req_ids={request.request_id},
536 + prefix_replay_tokens=0,
537 )
538 + scheduler._mark_prefix_replay = MethodType(Scheduler._mark_prefix_replay, scheduler)
539 Scheduler._update_waiting_for_remote_kv(scheduler, request)
540 assert request.num_tokens - request.num_computed_tokens == 1
541 assert manager.allocate_slots(request, num_new_tokens=1) is not None
tests/v1/core/test_prefix_replay.py new
+279
@@ -0,0 +1,279 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""SWA bounded replay: after a prefix hit the scheduler recomputes the tail of
4 +the hit to rebuild the non-cacheable sliding-window group, keeps the hit's
5 +blocks, and hands the worker the replay start."""
6 +
7 +import pytest
8 +import torch
9 +
10 +from tests.v1.kv_connector.unit.utils import MockKVConfig, create_model_runner_output
11 +from vllm.v1.core.sched.scheduler import Scheduler
12 +from vllm.v1.kv_cache_interface import (
13 + FullAttentionSpec,
14 + KVCacheConfig,
15 + KVCacheGroupSpec,
16 + SlidingWindowMLASpec,
17 +)
18 +from vllm.v1.outputs import ModelRunnerOutput
19 +from vllm.v1.request import RequestStatus
20 +from vllm.v1.structured_output import StructuredOutputManager
21 +
22 +from .utils import create_requests, create_scheduler
23 +
24 +BLOCK_SIZE = 16
25 +WINDOW = 32
26 +NUM_PROMPT_TOKENS = 100
27 +# 100 tokens -> 6 full blocks cached -> 96-token hit.
28 +HIT_TOKENS = NUM_PROMPT_TOKENS // BLOCK_SIZE * BLOCK_SIZE
29 +FULL, SWA = 0, 1
30 +
31 +
32 +def _replay_scheduler(
33 + *,
34 + long_prefill_token_threshold: int = 0,
35 + use_kv_connector: MockKVConfig | None = None,
36 +) -> Scheduler:
37 + """A hybrid layout: one prefix-cacheable full-attention group and one
38 + replayed sliding-window group."""
39 + base = create_scheduler(
40 + block_size=BLOCK_SIZE,
41 + enable_prefix_caching=True,
42 + long_prefill_token_threshold=long_prefill_token_threshold,
43 + use_kv_connector=use_kv_connector,
44 + )
45 + vllm_config = base.vllm_config
46 + kv_cache_config = KVCacheConfig(
47 + num_blocks=10000,
48 + kv_cache_tensors=[],
49 + kv_cache_groups=[
50 + KVCacheGroupSpec(
51 + ["full"],
52 + FullAttentionSpec(
53 + block_size=BLOCK_SIZE,
54 + num_kv_heads=1,
55 + head_size=1,
56 + dtype=torch.float32,
57 + ),
58 + ),
59 + KVCacheGroupSpec(
60 + ["swa"],
61 + SlidingWindowMLASpec(
62 + block_size=BLOCK_SIZE,
63 + num_kv_heads=1,
64 + head_size=64,
65 + dtype=torch.bfloat16,
66 + sliding_window=WINDOW,
67 + bounded_replay=True,
68 + ),
69 + ),
70 + ],
71 + )
72 + scheduler = Scheduler(
73 + vllm_config=vllm_config,
74 + kv_cache_config=kv_cache_config,
75 + block_size=BLOCK_SIZE,
76 + log_stats=True,
77 + structured_output_manager=StructuredOutputManager(vllm_config),
78 + )
79 + scheduler.use_v2_model_runner = True
80 + assert scheduler.prefix_replay_tokens == WINDOW
81 + return scheduler
82 +
83 +
84 +def _prefill(scheduler: Scheduler, request) -> None:
85 + scheduler.add_request(request)
86 + out = scheduler.schedule()
87 + assert out.num_scheduled_tokens[request.request_id] == request.num_prompt_tokens
88 + scheduler.update_from_output(out, _step_output(out, [request]))
89 +
90 +
91 +def _step_output(out, requests) -> ModelRunnerOutput:
92 + """Model output for every scheduled request: a token once its prefill is
93 + done (non-zero, so outputs never extend the all-zero shared prompt in the
94 + cache), nothing while it is still prefilling. schedule() has already
95 + advanced num_computed_tokens past this step's chunk."""
96 + scheduled = [r for r in requests if r.request_id in out.num_scheduled_tokens]
97 + return ModelRunnerOutput(
98 + req_ids=[r.request_id for r in scheduled],
99 + req_id_to_index={r.request_id: i for i, r in enumerate(scheduled)},
100 + sampled_token_ids=[
101 + [1000] if r.num_computed_tokens >= r.num_prompt_tokens else []
102 + for r in scheduled
103 + ],
104 + logprobs=None,
105 + prompt_logprobs_dict={},
106 + pooler_output=None,
107 + )
108 +
109 +
110 +def _new_req_data(out, request):
111 + return next(r for r in out.scheduled_new_reqs if r.req_id == request.request_id)
112 +
113 +
114 +def test_hit_replays_window_without_reallocating():
115 + scheduler = _replay_scheduler()
116 + expected_replay = WINDOW
117 + first, second = create_requests(
118 + num_requests=2,
119 + num_tokens=NUM_PROMPT_TOKENS,
120 + same_prompt=True,
121 + block_size=BLOCK_SIZE,
122 + )
123 + _prefill(scheduler, first)
124 + manager = scheduler.kv_cache_manager
125 +
126 + scheduler.add_request(second)
127 + out = scheduler.schedule()
128 +
129 + replay_start = HIT_TOKENS - expected_replay
130 + new_req = _new_req_data(out, second)
131 + assert new_req.num_computed_tokens == replay_start
132 + assert new_req.replay_start == replay_start
133 + assert (
134 + out.num_scheduled_tokens[second.request_id] == NUM_PROMPT_TOKENS - replay_start
135 + )
136 +
137 + first_blocks = manager.get_blocks(first.request_id).blocks
138 + second_blocks = manager.get_blocks(second.request_id).blocks
139 + # The full-attention hit is shared (replayed tokens included); only the
140 + # tail block is new.
141 + num_hit_blocks = HIT_TOKENS // BLOCK_SIZE
142 + assert second_blocks[FULL][:num_hit_blocks] == first_blocks[FULL][:num_hit_blocks]
143 + assert all(b.ref_cnt == 2 for b in second_blocks[FULL][:num_hit_blocks])
144 + assert len(second_blocks[FULL]) == num_hit_blocks + 1
145 + # The window group never hits: blocks below the window are null, the
146 + # window (replayed and new tokens) gets fresh blocks.
147 + num_skipped_blocks = (HIT_TOKENS - WINDOW + 1) // BLOCK_SIZE
148 + assert all(b.is_null for b in second_blocks[SWA][:num_skipped_blocks])
149 + num_window_blocks = len(second_blocks[SWA]) - num_skipped_blocks
150 + assert num_window_blocks == -(-NUM_PROMPT_TOKENS // BLOCK_SIZE) - num_skipped_blocks
151 + assert not any(b.is_null for b in second_blocks[SWA][num_skipped_blocks:])
152 +
153 + scheduler.update_from_output(out, _step_output(out, [first, second]))
154 + assert second.num_computed_tokens == NUM_PROMPT_TOKENS
155 +
156 +
157 +def test_replay_only_chunks_make_progress():
158 + """A chunk cap smaller than the replay window schedules replay-only
159 + chunks; they advance the request instead of stalling it."""
160 + scheduler = _replay_scheduler(long_prefill_token_threshold=BLOCK_SIZE)
161 + first, second = create_requests(
162 + num_requests=2,
163 + num_tokens=NUM_PROMPT_TOKENS,
164 + same_prompt=True,
165 + block_size=BLOCK_SIZE,
166 + )
167 + scheduler.add_request(first)
168 + while first.num_computed_tokens < NUM_PROMPT_TOKENS:
169 + out = scheduler.schedule()
170 + scheduler.update_from_output(out, _step_output(out, [first]))
171 +
172 + scheduler.add_request(second)
173 +
174 + def full_blocks():
175 + return scheduler.kv_cache_manager.get_blocks(second.request_id).blocks[FULL]
176 +
177 + scheduled: list[int] = []
178 + while second.num_computed_tokens < NUM_PROMPT_TOKENS:
179 + out = scheduler.schedule()
180 + if not scheduled:
181 + new_req = _new_req_data(out, second)
182 + assert new_req.num_computed_tokens == HIT_TOKENS - WINDOW
183 + assert new_req.replay_start == HIT_TOKENS - WINDOW
184 + # A replay-only chunk adopts the hit and allocates nothing new.
185 + assert len(full_blocks()) == HIT_TOKENS // BLOCK_SIZE
186 + scheduled.append(out.num_scheduled_tokens[second.request_id])
187 + scheduler.update_from_output(out, _step_output(out, [first, second]))
188 + # 32 replayed + 4 new tokens in 16-token chunks: two replay-only chunks.
189 + assert scheduled == [BLOCK_SIZE, BLOCK_SIZE, NUM_PROMPT_TOKENS - HIT_TOKENS]
190 + assert len(full_blocks()) == -(-NUM_PROMPT_TOKENS // BLOCK_SIZE)
191 + assert second.status == RequestStatus.RUNNING
192 +
193 +
194 +def test_async_remote_kv_hit_replays_after_load():
195 + """A KV-connector hit loaded asynchronously carries no window state
196 + either; the replay is applied when the load completes."""
197 + matched = 64
198 + scheduler = _replay_scheduler(
199 + use_kv_connector=MockKVConfig(matched_tokens=matched, is_async=True)
200 + )
201 + request = create_requests(
202 + num_requests=1, num_tokens=NUM_PROMPT_TOKENS, block_size=BLOCK_SIZE
203 + )[0]
204 + scheduler.add_request(request)
205 + out = scheduler.schedule()
206 + assert request.status == RequestStatus.WAITING_FOR_REMOTE_KVS
207 + assert request.request_id not in out.num_scheduled_tokens
208 + scheduler.update_from_output(
209 + out, create_model_runner_output([], finished_recving={request.request_id})
210 + )
211 +
212 + out = scheduler.schedule()
213 + new_req = _new_req_data(out, request)
214 + assert new_req.num_computed_tokens == matched - WINDOW
215 + assert new_req.replay_start == matched - WINDOW
216 + assert (
217 + out.num_scheduled_tokens[request.request_id]
218 + == NUM_PROMPT_TOKENS - matched + WINDOW
219 + )
220 +
221 +
222 +def test_remote_kv_hit_is_taken_in_whole_blocks():
223 + """A hit ending one token short of a block boundary would put the replay
224 + window's first token in a block the sliding-window group retires, so a
225 + connector hit is cut back to whole blocks."""
226 + matched = 4 * BLOCK_SIZE - 1
227 + scheduler = _replay_scheduler(
228 + use_kv_connector=MockKVConfig(matched_tokens=matched, is_async=True)
229 + )
230 + request = create_requests(
231 + num_requests=1, num_tokens=NUM_PROMPT_TOKENS, block_size=BLOCK_SIZE
232 + )[0]
233 + scheduler.add_request(request)
234 + out = scheduler.schedule()
235 + scheduler.update_from_output(
236 + out, create_model_runner_output([], finished_recving={request.request_id})
237 + )
238 + out = scheduler.schedule()
239 + new_req = _new_req_data(out, request)
240 + hit = 3 * BLOCK_SIZE
241 + assert new_req.replay_start == hit - WINDOW
242 + assert new_req.num_computed_tokens == hit - WINDOW
243 + swa_manager = scheduler.kv_cache_manager.coordinator.single_type_managers[SWA]
244 + swa_blocks = scheduler.kv_cache_manager.get_blocks(request.request_id).blocks[SWA]
245 + assert swa_blocks[new_req.replay_start // BLOCK_SIZE] is not swa_manager._null_block
246 +
247 +
248 +@pytest.mark.parametrize("via_connector", [False, True])
249 +def test_hit_no_longer_than_window_is_ignored(via_connector):
250 + """Such a hit would be recomputed in full anyway. It is not adopted (an
251 + async load of it is not even started), so a request has computed tokens
252 + iff it replays."""
253 + if via_connector:
254 + scheduler = _replay_scheduler(
255 + use_kv_connector=MockKVConfig(matched_tokens=WINDOW, is_async=True)
256 + )
257 + request = create_requests(
258 + num_requests=1, num_tokens=NUM_PROMPT_TOKENS, block_size=BLOCK_SIZE
259 + )[0]
260 + else:
261 + scheduler = _replay_scheduler()
262 + first = create_requests(
263 + num_requests=1, num_tokens=WINDOW, same_prompt=True, block_size=BLOCK_SIZE
264 + )[0]
265 + _prefill(scheduler, first)
266 + request = create_requests(
267 + num_requests=1,
268 + num_tokens=NUM_PROMPT_TOKENS,
269 + same_prompt=True,
270 + block_size=BLOCK_SIZE,
271 + req_ids=["long"],
272 + )[0]
273 + scheduler.add_request(request)
274 + out = scheduler.schedule()
275 + new_req = _new_req_data(out, request)
276 + assert request.status == RequestStatus.RUNNING
277 + assert new_req.num_computed_tokens == 0
278 + assert new_req.replay_start == 0
279 + assert out.num_scheduled_tokens[request.request_id] == NUM_PROMPT_TOKENS
tests/v1/kv_connector/unit/test_nixl_connector_hma.py
+1
@@ -1505,6 +1505,7 @@ def test_failed_load_rezeroes_unwritten_skipped_blocks():
1505 scheduler.kv_cache_manager.cache_blocks = MagicMock()
1506 scheduler.failed_recving_kv_req_ids = {"req-1"}
1507 scheduler.finished_recving_kv_req_ids = {"req-1"}
1508 + scheduler.prefix_replay_tokens = 0
1509
1510 request = MagicMock()
1511 request.request_id = "req-1"
vllm/config/cache.py
+7 -1
@@ -227,6 +227,11 @@ class CacheConfig:
227 necessary for implementing this optimization in some models (e.g. Gemma3n)
228 """
229
230 + swa_bounded_replay: bool = True
231 + """Keep the sliding-window KV of models that support it (DeepSeek-V4.1)
232 + out of prefix caching and rebuild it after a prefix hit by recomputing the
233 + hit's last window. Requires model runner V2."""
234 +
235 kv_cache_memory_bytes: int | None = None
236 """Size of KV Cache per GPU in bytes. By default, this is set to None
237 and vllm can automatically infer the kv cache size based on
@@ -281,8 +286,9 @@ class CacheConfig:
286 "effective_attention_block_size",
287 "kv_cache_size_tokens",
288 "kv_cache_max_concurrency",
284 - # WIP feature toggle not impacting compiled graph shape
289 + # Feature toggles not impacting compiled graph shape
290 "kv_sharing_fast_prefill",
291 + "swa_bounded_replay",
292 }
293
294 from vllm.config.utils import get_hash_factors, hash_factors
vllm/engine/arg_utils.py
+5
@@ -781,6 +781,7 @@ class EngineArgs:
781 stream_interval: int = SchedulerConfig.stream_interval
782
783 kv_sharing_fast_prefill: bool = CacheConfig.kv_sharing_fast_prefill
784 + swa_bounded_replay: bool = CacheConfig.swa_bounded_replay
785 optimization_level: OptimizationLevel = VllmConfig.optimization_level
786 performance_mode: PerformanceMode = VllmConfig.performance_mode
787
@@ -1310,6 +1311,9 @@ class EngineArgs:
1311 cache_group.add_argument(
1312 "--kv-sharing-fast-prefill", **cache_kwargs["kv_sharing_fast_prefill"]
1313 )
1314 + cache_group.add_argument(
1315 + "--swa-bounded-replay", **cache_kwargs["swa_bounded_replay"]
1316 + )
1317 cache_group.add_argument(
1318 "--mamba-cache-dtype", **cache_kwargs["mamba_cache_dtype"]
1319 )
@@ -2118,6 +2122,7 @@ class EngineArgs:
2122 prefix_cache_retention_interval=self.prefix_cache_retention_interval,
2123 kv_cache_dtype_skip_layers=self.kv_cache_dtype_skip_layers,
2124 kv_sharing_fast_prefill=self.kv_sharing_fast_prefill,
2125 + swa_bounded_replay=self.swa_bounded_replay,
2126 mamba_cache_dtype=self.mamba_cache_dtype,
2127 mamba_ssm_cache_dtype=self.mamba_ssm_cache_dtype,
2128 mamba_block_size=self.mamba_block_size,
vllm/models/deepseek_v4/amd/rocm.py
+2
@@ -495,11 +495,13 @@ class DeepseekV4ROCMAiterSparseSWAMetadataBuilder(DeepseekSparseSWAMetadataBuild
495 common_prefix_len: int,
496 common_attn_metadata: CommonAttentionMetadata,
497 fast_build: bool = False,
498 + replay_start: torch.Tensor | None = None,
499 ) -> DeepseekV4ROCMAiterSparseSWAMetadata:
500 base = super().build(
501 common_prefix_len=common_prefix_len,
502 common_attn_metadata=common_attn_metadata,
503 fast_build=fast_build,
504 + replay_start=replay_start,
505 )
506
507 ragged_indices = None
vllm/models/deepseek_v4/sparse_mla.py
+1
@@ -195,6 +195,7 @@ class DeepseekV4SparseMLAMetadataBuilder(
195 if self.compress_ratio > 1:
196 slot_mapping = get_compressed_slot_mapping(
197 cm.num_actual_tokens,
198 + cm.slot_mapping,
199 cm.query_start_loc,
200 cm.seq_lens,
201 cm.block_table_tensor.clamp_(min=0),
vllm/models/deepseek_v41/amd/rocm.py
+2
@@ -425,11 +425,13 @@ class DeepseekV4ROCMAiterSparseSWAMetadataBuilder(DeepseekV41SparseSWAMetadataBu
425 common_prefix_len: int,
426 common_attn_metadata: CommonAttentionMetadata,
427 fast_build: bool = False,
428 + replay_start: torch.Tensor | None = None,
429 ) -> DeepseekV4ROCMAiterSparseSWAMetadata:
430 base = super().build(
431 common_prefix_len=common_prefix_len,
432 common_attn_metadata=common_attn_metadata,
433 fast_build=fast_build,
434 + replay_start=replay_start,
435 )
436
437 ragged_indices = None
vllm/models/deepseek_v41/attention.py
+20
@@ -512,6 +512,25 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
512 "decodes only on SM100."
513 )
514
515 + swa_bounded_replay = cache_config.swa_bounded_replay
516 + if swa_bounded_replay and not vllm_config.use_v2_model_runner:
517 + logger.warning_once(
518 + "SWA bounded replay needs model runner V2 (only it skips the "
519 + "paged-KV writes of replayed tokens); the sliding-window cache "
520 + "takes part in prefix caching instead."
521 + )
522 + swa_bounded_replay = False
523 + if (
524 + swa_bounded_replay
525 + and vllm_config.parallel_config.prefill_context_parallel_size > 1
526 + ):
527 + logger.warning_once(
528 + "SWA bounded replay is off under prefill context parallelism "
529 + "(the replayed tokens' slot padding knows the rank-local batch "
530 + "only); the sliding-window cache takes part in prefix caching "
531 + "instead."
532 + )
533 + swa_bounded_replay = False
534 self.swa_cache_layer = DeepseekV4SWACache(
535 head_dim=self.head_dim,
536 window_size=self.window_size,
@@ -522,6 +541,7 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
541 block_size=32,
542 packed_bytes_per_token=self.swa_bytes_per_token,
543 packed_page_alignment=self.kv_page_alignment,
544 + bounded_replay=swa_bounded_replay,
545 )
546
547 # The attention layer itself was already registered with the
vllm/models/deepseek_v41/common/ops/cache_utils.py
+13
@@ -982,6 +982,10 @@ class CombineTopkSwaIndicesKernel(
982 # turns the store index below negative).
983 topk_len = 0
984 swa_start = tl.maximum(pos - (WINDOW_SIZE - 1), 0)
985 + # gather_len already excludes context below the request's replay
986 + # start (SWA bounded replay), so the window cannot start before
987 + # the gathered buffer does.
988 + swa_start = tl.maximum(swa_start, gather_start)
989 swa_len = pos - swa_start + 1
990
991 offset = tl.arange(0, PADDED_TOP_K)
@@ -1128,6 +1132,8 @@ def build_flashinfer_mixed_sparse_indices(
1132 decode_is_valid_token: torch.Tensor | None = None,
1133 swa_block_span: int | None = None,
1134 compressed_block_span: int | None = None,
1135 + *,
1136 + replay_start: torch.Tensor,
1137 ) -> tuple[torch.Tensor, torch.Tensor]:
1138 """Build the FlashInfer DSV4 sparse-index matrix for decode-first batches.
1139
@@ -1137,6 +1143,9 @@ def build_flashinfer_mixed_sparse_indices(
1143 per token). Decode tokens read precomputed SWA/compressed indices; prefill
1144 tokens derive their SWA window from the position and translate local
1145 compressed indices to global slots via the block tables.
1146 +
1147 + ``replay_start`` ([num_reqs], SWA bounded replay) lower-bounds every
1148 + prefill token's window: positions below it hold no window KV.
1149 """
1150 assert decode_swa_indices.dtype == torch.int32
1151 assert decode_swa_indices.dim() == 2
@@ -1227,6 +1236,7 @@ def build_flashinfer_mixed_sparse_indices(
1236 query_start_loc,
1237 seq_lens,
1238 token_to_req_indices,
1239 + replay_start,
1240 swa_block_table,
1241 swa_block_table.stride(0),
1242 swa_block_size,
@@ -1295,6 +1305,7 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1305 query_start_loc_ptr,
1306 seq_lens_ptr,
1307 token_to_req_indices_ptr,
1308 + replay_start_ptr,
1309 swa_block_table_ptr,
1310 swa_block_table_stride,
1311 swa_block_size,
@@ -1394,6 +1405,8 @@ def _build_flashinfer_mixed_sparse_indices_kernel(
1405 token_idx_in_query = token_idx - query_start
1406 pos = start_pos + token_idx_in_query
1407 swa_start_pos = tl.maximum(pos - (WINDOW_SIZE - 1), 0)
1408 + # SWA bounded replay: no window KV exists below the request's replay start.
1409 + swa_start_pos = tl.maximum(swa_start_pos, tl.load(replay_start_ptr + req_idx))
1410 swa_len = pos - swa_start_pos + 1
1411 if COMPRESS_RATIO > 0:
1412 topk_len = tl.minimum((pos + 1) // COMPRESS_RATIO, TOP_K)
vllm/models/deepseek_v41/nvidia/flashinfer_sparse.py
+6 -1
@@ -200,8 +200,11 @@ class DeepseekSparseSWAFlashInferMetadataBuilder(DeepseekV41SparseSWAMetadataBui
200 common_prefix_len: int,
201 common_attn_metadata: CommonAttentionMetadata,
202 fast_build: bool = False,
203 + replay_start: torch.Tensor | None = None,
204 ) -> "DeepseekSparseSWAMetadata":
204 - metadata = super().build(common_prefix_len, common_attn_metadata, fast_build)
205 + metadata = super().build(
206 + common_prefix_len, common_attn_metadata, fast_build, replay_start
207 + )
208 num_tokens = metadata.num_decode_tokens
209 if not common_attn_metadata.causal and num_tokens > 0:
210 assert metadata.decode_swa_lens is not None
@@ -375,6 +378,7 @@ class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
378 assert swa_metadata.token_to_req_indices is not None
379 assert swa_metadata.decode_swa_indices is not None
380 assert swa_metadata.block_table is not None
381 + assert swa_metadata.replay_start is not None
382
383 decode_swa_indices = swa_metadata.decode_swa_indices.reshape(
384 num_decode_tokens, swa_metadata.decode_swa_width
@@ -454,6 +458,7 @@ class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
458 decode_is_valid_token=decode_is_valid_token,
459 swa_block_span=swa_block_span,
460 compressed_block_span=compressed_block_span,
461 + replay_start=swa_metadata.replay_start[:num_reqs],
462 )
463 if swa_only:
464 swa_metadata.flashinfer_sparse_index_cache["swa_only"] = (
vllm/models/deepseek_v41/nvidia/model_state.py
+143 -2
@@ -2,15 +2,23 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from typing import Any
4
5 +import numpy as np
6 import torch
7 import torch.nn as nn
8
8 -from vllm.config import VllmConfig
9 +from vllm.config import CUDAGraphMode, VllmConfig
10 from vllm.triton_utils import tl, triton
11 +from vllm.v1.attention.backends.mla.sparse_swa import DeepseekSparseSWAMetadataBuilder
12 +from vllm.v1.attention.backends.utils import PAD_SLOT_ID
13 +from vllm.v1.core.sched.output import NewRequestData
14 +from vllm.v1.kv_cache_interface import KVCacheConfig
15 +from vllm.v1.worker.gpu.buffer_utils import UvaBufferPool
16 from vllm.v1.worker.gpu.input_batch import InputBatch
17 from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
18 from vllm.v1.worker.gpu.model_states.default import DefaultModelState
19 +from vllm.v1.worker.gpu.model_states.interface import ModelSpecificAttnMetadata
20 from vllm.v1.worker.gpu.states import RequestState
21 +from vllm.v1.worker.utils import AttentionGroup
22
23
24 @triton.jit
@@ -41,14 +49,69 @@ def _gather_lookback_kernel(
49 tl.store(lookback_ptr + batch_idx * DEPTH + offs, ids, mask=offs < DEPTH)
50
51
52 +@triton.jit
53 +def _pad_replayed_slots_kernel(
54 + slot_mappings_ptr, # [num_groups, num_tokens_padded]
55 + group_stride,
56 + cacheable_groups_ptr, # [NUM_GROUPS] indices of the prefix-cacheable groups
57 + query_start_loc_ptr, # [num_reqs + 1]
58 + positions_ptr, # [num_tokens]
59 + replay_start_ptr, # [num_reqs]
60 + window,
61 + pad_slot_id,
62 + NUM_GROUPS: tl.constexpr,
63 + BLOCK: tl.constexpr,
64 +):
65 + req = tl.program_id(0)
66 + start = tl.load(replay_start_ptr + req)
67 + if start == 0:
68 + return
69 + begin = tl.load(query_start_loc_ptr + req)
70 + end = tl.load(query_start_loc_ptr + req + 1)
71 + for tok in range(begin, end, BLOCK):
72 + offs = tok + tl.arange(0, BLOCK)
73 + pos = tl.load(positions_ptr + offs, mask=offs < end, other=0)
74 + replayed = (offs < end) & (pos >= start) & (pos < start + window)
75 + for i in tl.static_range(NUM_GROUPS):
76 + group = tl.load(cacheable_groups_ptr + i)
77 + tl.store(
78 + slot_mappings_ptr + group * group_stride + offs,
79 + pad_slot_id,
80 + mask=replayed,
81 + )
82 +
83 +
84 +class ReplayAttnMetadata(ModelSpecificAttnMetadata):
85 + """Hands the batch's replay starts to the sliding-window builders."""
86 +
87 + def __init__(self, replay_start: torch.Tensor) -> None:
88 + self.replay_start = replay_start
89 +
90 + def get_extra_attn_kwargs(
91 + self, attn_metadata_builder: Any, num_reqs: int
92 + ) -> dict[str, Any]:
93 + if isinstance(attn_metadata_builder, DeepseekSparseSWAMetadataBuilder):
94 + return {"replay_start": self.replay_start[:num_reqs]}
95 + return {}
96 +
97 +
98 class DeepseekV41ModelState(DefaultModelState):
45 - """DefaultModelState plus the engram lookback window.
99 + """DefaultModelState plus the engram lookback window and SWA bounded replay.
100
101 The engram n-gram hash needs the ids of the ``depth`` tokens preceding
102 each request's chunk start (see ``common/engram.py``). The runner keeps
103 the full token history on device, so the window is gathered there every
104 step: exact for prompt and generated tokens alike, whatever instance
105 produced their KV.
106 +
107 + SWA bounded replay (``CacheConfig.swa_bounded_replay``) keeps the
108 + sliding-window KV out of prefix caching and rebuilds it after a prefix hit
109 + by recomputing the hit's last window; the scheduler tells each request the
110 + position from which it holds window KV (``NewRequestData.replay_start``).
111 + ``prepare_attn`` gathers those per batch, pads the replayed tokens' slots
112 + in the prefix-cacheable groups so the cached KV stays as is, and hands the
113 + starts to the sliding-window metadata builders, whose kernels read no
114 + window KV below them.
115 """
116
117 def __init__(
@@ -67,6 +130,20 @@ class DeepseekV41ModelState(DefaultModelState):
130 (self.max_num_reqs, depth), -1, dtype=torch.int32, device=device
131 )
132
133 + # Per request state index; batches gather from it (see prepare_attn).
134 + self._replay_start_np = np.zeros(self.max_num_reqs, dtype=np.int32)
135 + self._replay_start = torch.zeros(
136 + self.max_num_reqs, dtype=torch.int32, device=device
137 + )
138 + self._replay_start_staging = UvaBufferPool(self.max_num_reqs, torch.int32)
139 + # The replay window and the indices of the prefix-cacheable groups, from
140 + # the KV cache config on first use; None until then.
141 + self._replay: tuple[int, torch.Tensor] | None = None
142 +
143 + def add_request(self, req_index: int, new_req_data: NewRequestData) -> None:
144 + super().add_request(req_index, new_req_data)
145 + self._replay_start_np[req_index] = new_req_data.replay_start
146 +
147 def prepare_inputs(
148 self, input_batch: InputBatch, req_states: RequestState
149 ) -> dict[str, torch.Tensor | None]:
@@ -96,3 +173,67 @@ class DeepseekV41ModelState(DefaultModelState):
173 self.lookback_token_ids.fill_(-1)
174 model_inputs["lookback_token_ids"] = self.lookback_token_ids
175 return model_inputs
176 +
177 + def prepare_attn(
178 + self,
179 + input_batch: InputBatch,
180 + cudagraph_mode: CUDAGraphMode,
181 + block_tables: tuple[torch.Tensor, ...],
182 + slot_mappings: torch.Tensor,
183 + attn_groups: list[list[AttentionGroup]],
184 + kv_cache_config: KVCacheConfig,
185 + for_capture: bool = False,
186 + ubatch_idx: int = 0,
187 + model_specific_attn_metadata: ModelSpecificAttnMetadata | None = None,
188 + ) -> dict[str, Any]:
189 + if self._replay is None:
190 + specs = [group.kv_cache_spec for group in kv_cache_config.kv_cache_groups]
191 + self._replay = (
192 + max(spec.prefix_replay_tokens for spec in specs),
193 + torch.tensor(
194 + [i for i, spec in enumerate(specs) if spec.prefix_cacheable],
195 + dtype=torch.int32,
196 + device=self.device,
197 + ),
198 + )
199 + window, cacheable_groups = self._replay
200 + if window:
201 + num_reqs = input_batch.num_reqs
202 + # Decode rows sit above the hit, so only prefills carry a replay
203 + # start; dummy batches (captures, profiling) carry none.
204 + replay_start_np = np.where(
205 + input_batch.is_prefilling_np[:num_reqs],
206 + self._replay_start_np[input_batch.idx_mapping_np[:num_reqs]],
207 + 0,
208 + ).astype(np.int32)
209 + replay_start = self._replay_start_staging.copy_to_gpu(
210 + replay_start_np, out=self._replay_start[:num_reqs]
211 + )
212 + if replay_start_np.any():
213 + # The replayed tokens rebuild window KV only: their slots in the
214 + # prefix-cacheable groups are padded so the cached KV stays as is.
215 + _pad_replayed_slots_kernel[(num_reqs,)](
216 + slot_mappings,
217 + slot_mappings.stride(0),
218 + cacheable_groups,
219 + input_batch.query_start_loc,
220 + input_batch.positions,
221 + replay_start,
222 + window,
223 + PAD_SLOT_ID,
224 + NUM_GROUPS=cacheable_groups.numel(),
225 + BLOCK=1024,
226 + )
227 + assert model_specific_attn_metadata is None
228 + model_specific_attn_metadata = ReplayAttnMetadata(replay_start)
229 + return super().prepare_attn(
230 + input_batch,
231 + cudagraph_mode,
232 + block_tables,
233 + slot_mappings,
234 + attn_groups,
235 + kv_cache_config,
236 + for_capture=for_capture,
237 + ubatch_idx=ubatch_idx,
238 + model_specific_attn_metadata=model_specific_attn_metadata,
239 + )
vllm/models/deepseek_v41/sparse_mla.py
+1
@@ -195,6 +195,7 @@ class DeepseekV4SparseMLAMetadataBuilder(
195 if self.compress_ratio > 1:
196 slot_mapping = get_compressed_slot_mapping(
197 cm.num_actual_tokens,
198 + cm.slot_mapping,
199 cm.query_start_loc,
200 cm.seq_lens,
201 cm.block_table_tensor.clamp_(min=0),
vllm/v1/attention/backends/mla/compressor_utils.py
+26 -5
@@ -42,6 +42,8 @@ class CompressedSlotMappingKernel(
42 @triton.jit(do_not_specialize=["block_table_stride"])
43 def kernel(
44 # [num_tokens]
45 + compressed_slot_mapping_ptr,
46 + # [num_tokens] the tokens' own slots
47 slot_mapping_ptr,
48 # [num_reqs + 1]
49 query_start_loc_ptr,
@@ -69,7 +71,12 @@ class CompressedSlotMappingKernel(
71 mask = offset < query_len
72
73 pos = start_pos + i + tl.arange(0, TRITON_BLOCK_SIZE)
72 - is_valid = (pos + 1) % COMPRESS_RATIO == 0
74 + # A replayed token has a PAD slot (SWA bounded replay: its KV is
75 + # cached already); its compressed KV is cached too, so PAD here.
76 + slot = tl.load(
77 + slot_mapping_ptr + query_start + offset, mask=mask, other=PAD_ID
78 + )
79 + is_valid = ((pos + 1) % COMPRESS_RATIO == 0) & (slot != PAD_ID)
80 pos_after_compress = pos // COMPRESS_RATIO
81
82 block_ids = pos_after_compress // block_size
@@ -81,7 +88,9 @@ class CompressedSlotMappingKernel(
88
89 # NOTE
90 slot_ids = tl.where(is_valid, slot_ids, PAD_ID)
84 - tl.store(slot_mapping_ptr + query_start + offset, slot_ids, mask=mask)
91 + tl.store(
92 + compressed_slot_mapping_ptr + query_start + offset, slot_ids, mask=mask
93 + )
94
95 def dispatch( # type: ignore[override]
96 self,
@@ -121,6 +130,7 @@ class CompressedSlotMappingKernel(
130 def warmup_inputs(self, compile_key: CompileKey) -> dict[str, Any]:
131 int32_ptr = TritonWarmupTensor(torch.int32)
132 return dict(
133 + compressed_slot_mapping=TritonWarmupTensor(torch.int64),
134 slot_mapping=TritonWarmupTensor(torch.int64),
135 query_start_loc=int32_ptr,
136 seq_lens=int32_ptr,
@@ -132,6 +142,7 @@ class CompressedSlotMappingKernel(
142 @kernel_launcher
143 def __call__(
144 self,
145 + compressed_slot_mapping: torch.Tensor,
146 slot_mapping: torch.Tensor,
147 query_start_loc: torch.Tensor,
148 seq_lens: torch.Tensor,
@@ -149,6 +160,7 @@ class CompressedSlotMappingKernel(
160
161 def get_compressed_slot_mapping(
162 num_tokens: int,
163 + slot_mapping: torch.Tensor,
164 query_start_loc: torch.Tensor,
165 seq_lens: torch.Tensor,
166 block_table: torch.Tensor,
@@ -156,19 +168,28 @@ def get_compressed_slot_mapping(
168 compress_ratio: int,
169 out: torch.Tensor | None = None,
170 ) -> torch.Tensor:
171 + """Slot mapping for writing the compressed states of ``num_tokens`` tokens.
172 +
173 + Every ``compress_ratio`` tokens share one compressed state, written by the
174 + last of them: that token maps to the state's slot, the others to PAD. A
175 + token whose own ``slot_mapping`` entry is PAD maps to PAD too: SWA bounded
176 + replay recomputes tokens whose KV is cached already, and their compressed
177 + states must not be rewritten either.
178 + """
179 if out is not None:
180 # Guard: for padded / invalid sequences.
181 # Negative positions produce bogus block indices that lead to illegal memory
182 # accesses inside the block_table load.
183 # NOTE: Fill -1 to the whole tensor, not just the first `num_tokens`.
184 out.fill_(-1)
165 - slot_mapping = out[:num_tokens]
185 + compressed_slot_mapping = out[:num_tokens]
186 else:
167 - slot_mapping = torch.full(
187 + compressed_slot_mapping = torch.full(
188 (num_tokens,), -1, dtype=torch.int64, device=query_start_loc.device
189 )
190
191 _COMPRESSED_SLOT_MAPPING_KERNEL(
192 + compressed_slot_mapping,
193 slot_mapping,
194 query_start_loc,
195 seq_lens,
@@ -176,7 +197,7 @@ def get_compressed_slot_mapping(
197 block_size,
198 compress_ratio,
199 )
179 - return slot_mapping
200 + return compressed_slot_mapping
201
202
203 _COMPRESSED_SLOT_MAPPING_KERNEL = CompressedSlotMappingKernel()
vllm/v1/attention/backends/mla/indexer.py
+10 -1
@@ -795,6 +795,7 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
795 self.dcp_rank = get_dcp_group().rank_in_group if self.dcp_world_size > 1 else 0
796 self.pcp_world_size = parallel_config.prefill_context_parallel_size
797 self.use_pcp = self.pcp_world_size > 1
798 + self.pcp_rank = get_pcp_group().rank_in_group if self.use_pcp else 0
799 self.cp_kv_cache_interleave_size = parallel_config.cp_kv_cache_interleave_size
800 # NOTE(Chen):an estimated max size of flattened_kv. Need to double check.
801 self.max_prefill_buffer_size = get_max_prefill_buffer_size(self.vllm_config)
@@ -1217,10 +1218,18 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
1218 factor = self.kv_cache_spec.block_size // kernel_block_size
1219 indexer_block_table = (block_table[:, ::factor] // factor).contiguous()
1220 padded_num_tokens = num_tokens
1220 - if self.pcp_world_size > 1:
1221 + local_slot_mapping = slot_mapping
1222 + if self.use_pcp:
1223 + # The gathered layout holds each rank's local tokens, padded, in
1224 + # rank order, so this rank's segment lines up with query_start_loc.
1225 padded_num_tokens = slot_mapping.shape[0] // self.pcp_world_size
1226 + local_slot_mapping = slot_mapping[
1227 + self.pcp_rank * padded_num_tokens : (self.pcp_rank + 1)
1228 + * padded_num_tokens
1229 + ]
1230 compressed_slot_mapping = get_compressed_slot_mapping(
1231 num_tokens,
1232 + local_slot_mapping,
1233 query_start_loc,
1234 seq_lens,
1235 indexer_block_table,
vllm/v1/attention/backends/mla/sparse_swa.py
+50 -5
@@ -81,9 +81,12 @@ class DeepseekV4SWACache(torch.nn.Module, AttentionLayerBase):
81 block_size: int = 64,
82 packed_bytes_per_token: int = 584,
83 packed_page_alignment: int = 576,
84 + bounded_replay: bool = False,
85 ):
86 super().__init__()
87 self.backend_cls = backend_cls or DeepseekSparseSWABackend
88 + # DeepseekV4.1 SWA bounded replay.
89 + self.bounded_replay = bounded_replay
90 self.kv_cache = torch.tensor([])
91 self.head_dim = head_dim
92 self.window_size = window_size
@@ -117,6 +120,7 @@ class DeepseekV4SWACache(torch.nn.Module, AttentionLayerBase):
120 "nvfp4_ds_mla",
121 )
122 return SlidingWindowMLASpec(
123 + bounded_replay=self.bounded_replay,
124 block_size=self.block_size,
125 num_kv_heads=1,
126 head_size=self.head_dim,
@@ -198,6 +202,10 @@ class DeepseekSparseSWAMetadata:
202 # None when the model is text-only or the batch has no image spans.
203 prefill_left_visible: torch.Tensor | None = None
204 prefill_right_visible: torch.Tensor | None = None
205 + # SWA bounded replay: [num_reqs] lower bound of the positions a request's
206 + # window attention may read (SWA bounded replay);
207 + # zeros when nothing replays.
208 + replay_start: torch.Tensor | None = None
209
210 # Number of decode/prefill requests/tokens (batch is reordered: decodes first)
211 num_decodes: int = 0
@@ -256,10 +264,12 @@ class DeepseekSparseSWAMetadata:
264 has_compressed = compress_ratio > 1
265
266 # query_len <= max_num_batched_tokens and
259 - # gather_len = query_len + min(prefix_len, window_size - 1), so the
260 - # worst-case gathered width is bounded by
261 - # max_num_batched_tokens + window_size - 1. The compressed prefix pool
262 - # is bounded by ceil(max_model_len / compress_ratio).
267 + # gather_len <= query_len + min(prefix_len, window_size - 1) (a replay
268 + # boundary only shrinks it), so the worst-case gathered width is
269 + # bounded by max_num_batched_tokens + window_size - 1. The compressed
270 + # prefix pool is bounded by ceil(max_model_len / compress_ratio). The
271 + # CPU gather lengths below are this upper bound, for chunk planning
272 + # only; the kernels use the exact per-request values.
273 max_workspace_area = prefill_chunk_size * (
274 (cdiv(self.prefill_max_model_len, compress_ratio) if has_compressed else 0)
275 + self.prefill_window_size
@@ -331,6 +341,7 @@ class ComputePrefillMetadataKernel(
341 # Inputs
342 seq_lens_ptr,
343 query_start_loc_ptr,
344 + replay_start_ptr,
345 num_prefills,
346 num_decodes,
347 window_size,
@@ -352,7 +363,10 @@ class ComputePrefillMetadataKernel(
363
364 query_len = qsl_end - qsl_start
365 prefix_len = seq_len - query_len
355 - gather_len = query_len + tl.minimum(prefix_len, window_size - 1)
366 + # Context below replay_start has no window KV (SWA bounded replay).
367 + replay_start = tl.load(replay_start_ptr + num_decodes + safe_offset, mask=mask)
368 + visible_prefix = tl.maximum(prefix_len - replay_start, 0)
369 + gather_len = query_len + tl.minimum(visible_prefix, window_size - 1)
370
371 tl.store(prefill_gather_lens_ptr + offset, gather_len, mask=mask)
372
@@ -384,6 +398,7 @@ class ComputePrefillMetadataKernel(
398 prefill_gather_lens=int32_ptr,
399 seq_lens=int32_ptr,
400 query_start_loc=int32_ptr,
401 + replay_start=int32_ptr,
402 num_prefills=compile_key.block_size,
403 num_decodes=0,
404 window_size=1,
@@ -395,6 +410,7 @@ class ComputePrefillMetadataKernel(
410 prefill_gather_lens: torch.Tensor,
411 seq_lens: torch.Tensor,
412 query_start_loc: torch.Tensor,
413 + replay_start: torch.Tensor,
414 num_prefills: int,
415 num_decodes: int,
416 window_size: int,
@@ -550,12 +566,19 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
566 )
567 self.decode_swa_indices_noncausal: torch.Tensor | None = None
568 self._max_tokens = max_tokens
569 + # replay_start when no group replays: no lower bound.
570 + self.no_replay_start = torch.zeros(
571 + self.vllm_config.scheduler_config.max_num_seqs + 1,
572 + dtype=torch.int32,
573 + device=self.device,
574 + )
575
576 def build(
577 self,
578 common_prefix_len: int,
579 common_attn_metadata: CommonAttentionMetadata,
580 fast_build: bool = False,
581 + replay_start: torch.Tensor | None = None,
582 ) -> DeepseekSparseSWAMetadata:
583 """Build SWA metadata for mixed decode/prefill batches.
584
@@ -564,6 +587,10 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
587 separate window_topk_idxs for each portion.
588
589 For prefill, we use chunked prefill to align with the indexer's chunking.
590 +
591 + ``replay_start`` ([num_reqs], SWA bounded replay): the position from
592 + which each request holds window KV; the model state passes it for every
593 + batch of a replaying group, graph captures pass nothing.
594 """
595 seq_lens = common_attn_metadata.seq_lens
596 seq_lens_cpu = common_attn_metadata.seq_lens_cpu_upper_bound
@@ -587,6 +614,9 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
614
615 is_valid_token = self.is_valid_token[: slot_mapping.shape[0]]
616 is_valid_token.copy_(slot_mapping >= 0)
617 + if replay_start is None:
618 + # Graph captures build without it; their dummy batches replay nothing.
619 + replay_start = self.no_replay_start
620
621 non_causal = not common_attn_metadata.causal
622 decode_swa_width = (
@@ -609,6 +639,8 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
639 device=self.device,
640 )
641 decode_swa_indices = self.decode_swa_indices_noncausal
642 + # No replay_start: decode rows sit above the hit, so a
643 + # request's replayed window never reaches them.
644 _COMPUTE_DSPARK_NONCAUSAL_SWA_INDICES_KERNEL(
645 decode_swa_indices,
646 self.decode_swa_lens,
@@ -637,6 +669,7 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
669 is_valid_token,
670 block_table,
671 self.block_size,
672 + replay_start,
673 num_tokens=num_decode_tokens,
674 token_offset=0,
675 )
@@ -683,6 +716,7 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
716 is_valid_token,
717 block_table,
718 self.block_size,
719 + replay_start,
720 num_tokens=num_prefill_tokens,
721 token_offset=num_decode_tokens,
722 has_image=has_image,
@@ -696,6 +730,7 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
730 seq_lens_cpu,
731 query_start_loc,
732 query_start_loc_cpu,
733 + replay_start,
734 )
735
736 # Per-layer-type tile-scheduler plan holders. Empty FlashMLASchedMeta
@@ -727,6 +762,7 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
762 ),
763 prefill_left_visible=prefill_left_visible,
764 prefill_right_visible=prefill_right_visible,
765 + replay_start=replay_start,
766 block_size=self.block_size,
767 num_decodes=num_decodes,
768 num_prefills=num_prefills,
@@ -803,6 +839,7 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
839 assert metadata.is_valid_token is not None
840 assert metadata.decode_swa_indices is not None
841 assert metadata.decode_swa_lens is not None
842 + assert metadata.replay_start is not None
843
844 _COMPUTE_SWA_INDICES_AND_LENS_KERNEL(
845 metadata.decode_swa_indices,
@@ -817,6 +854,7 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
854 metadata.is_valid_token,
855 metadata.block_table,
856 self.block_size,
857 + metadata.replay_start,
858 num_tokens=metadata.num_decode_tokens,
859 token_offset=0,
860 )
@@ -872,6 +910,7 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
910 seq_lens_cpu: torch.Tensor | None,
911 query_start_loc: torch.Tensor,
912 query_start_loc_cpu: torch.Tensor,
913 + replay_start: torch.Tensor,
914 ) -> dict[str, torch.Tensor | int | None]:
915 """Pre-compute DeepseekV4 prefill metadata during the metadata build phase.
916
@@ -893,6 +932,7 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
932 pfx_gather_lens,
933 seq_lens,
934 query_start_loc,
935 + replay_start,
936 num_prefills,
937 num_decodes,
938 self.window_size,
@@ -982,6 +1022,7 @@ def _compute_swa_indices_and_lens_kernel(
1022 block_table_ptr,
1023 block_table_stride,
1024 block_size,
1025 + replay_start_ptr,
1026 token_offset,
1027 HAS_IMAGE: tl.constexpr,
1028 TRITON_BLOCK_SIZE: tl.constexpr,
@@ -1022,6 +1063,8 @@ def _compute_swa_indices_and_lens_kernel(
1063 right = 0
1064 left_add = tl.maximum(left - (window_size - 1), 0)
1065 start_pos = tl.maximum(pos - (window_size - 1) - left_add, 0)
1066 + # SWA bounded replay: no window KV exists below the request's replay start.
1067 + start_pos = tl.maximum(start_pos, tl.load(replay_start_ptr + req_idx))
1068 end_pos = pos + right + 1
1069
1070 swa_len = end_pos - start_pos
@@ -1111,6 +1154,7 @@ class ComputeSWAIndicesAndLensKernel(
1154 is_valid_token=TritonWarmupTensor(torch.bool),
1155 block_table=TritonWarmupTensor(torch.int32, shape=(1, 1), strides=(1, 1)),
1156 block_size=compile_key.block_size,
1157 + replay_start=int32_ptr,
1158 num_tokens=1,
1159 token_offset=0,
1160 has_image=bool(compile_key.has_image),
@@ -1131,6 +1175,7 @@ class ComputeSWAIndicesAndLensKernel(
1175 is_valid_token: torch.Tensor,
1176 block_table: torch.Tensor,
1177 block_size: int,
1178 + replay_start: torch.Tensor,
1179 *,
1180 num_tokens: int,
1181 token_offset: int,
vllm/v1/core/kv_cache_manager.py
+9 -4
@@ -465,12 +465,17 @@ class KVCacheManager:
465 A list of new allocated blocks.
466
467 """
468 - # When loading KV data asynchronously, we may have zero new tokens to
469 - # compute while still allocating slots for externally computed tokens.
470 - if num_new_tokens == 0 and num_external_computed_tokens == 0:
468 + # A step may need no slots of its own while still adopting computed
469 + # tokens: an async KV load, or a chunk that ends inside the replayed
470 + # range of a hit (SWA bounded replay).
471 + if (
472 + num_new_tokens == 0
473 + and num_external_computed_tokens == 0
474 + and num_new_computed_tokens == 0
475 + ):
476 raise ValueError(
477 "num_new_tokens must be greater than 0 when there are no "
473 - "external computed tokens"
478 + "computed tokens to adopt"
479 )
480
481 if new_computed_blocks is not None:
vllm/v1/core/sched/output.py
+3
@@ -48,6 +48,8 @@ class NewRequestData:
48
49 # Only used for v2 model runner.
50 prefill_token_ids: list[int] | None = None
51 + # DeepSeek-V4.1 only: SWA bounded replay; see Request.replay_start.
52 + replay_start: int = 0
53
54 @classmethod
55 def from_request(
@@ -73,6 +75,7 @@ class NewRequestData:
75 prompt_embeds=request.prompt_embeds,
76 prompt_is_token_ids=request.prompt_is_token_ids,
77 prefill_token_ids=prefill_token_ids,
78 + replay_start=request.replay_start,
79 )
80
81 @property
vllm/v1/core/sched/scheduler.py
+73 -6
@@ -271,6 +271,18 @@ class Scheduler(SchedulerInterface):
271 self.use_eagle_block_drop = False
272 self.num_spec_tokens = vllm_config.num_speculative_tokens
273 self.num_lookahead_tokens = vllm_config.num_lookahead_tokens
274 + # DSV41 SWA bounded replay: groups that declare a replay window are rebuilt
275 + # after a prefix hit by recomputing its trailing tokens. One window
276 + # for all such groups, so the rewind matches every group's allocation.
277 + replay_windows = {
278 + group.kv_cache_spec.prefix_replay_tokens
279 + for group in kv_cache_config.kv_cache_groups
280 + if group.kv_cache_spec.prefix_replay_tokens > 0
281 + }
282 + assert len(replay_windows) <= 1, (
283 + f"Prefix replay windows should agree: {sorted(replay_windows)}"
284 + )
285 + self.prefix_replay_tokens = replay_windows.pop() if replay_windows else 0
286 self.num_prefill_lookahead = vllm_config.num_prefill_lookahead_tokens
287 self.dynamic_sd_lookup: list[int] | None = None
288 if speculative_config is not None:
@@ -946,6 +958,17 @@ class Scheduler(SchedulerInterface):
958 step_skipped_waiting.prepend_request(request)
959 continue
960
961 + if self.prefix_replay_tokens:
962 + # SWA bounded replay recomputes the hit's last
963 + # window, from the block holding its first
964 + # token; the sliding-window groups retire
965 + # whole blocks below the window of the hit's
966 + # next token. Hits end on a block boundary, or
967 + # a hit ending one token short of one would
968 + # retire the block the replay starts in.
969 + ext_tokens -= ext_tokens % self.block_size
970 + load_kv_async = load_kv_async and ext_tokens > 0
971 +
972 if partial_tail and ext_tokens > partial_tail:
973 # Remote strictly exceeds the full local hit: drop the
974 # sub-block tail so no CoW is needed, and let the load
@@ -988,6 +1011,17 @@ class Scheduler(SchedulerInterface):
1011 num_new_local_computed_tokens + num_external_computed_tokens
1012 )
1013 assert num_computed_tokens <= request.num_tokens
1014 + if 0 < num_computed_tokens <= self.prefix_replay_tokens:
1015 + # SWA bounded replay: a hit no longer than the replayed
1016 + # window would be recomputed in full and save nothing.
1017 + new_computed_blocks = (
1018 + self.kv_cache_manager.empty_kv_cache_blocks
1019 + )
1020 + num_new_local_computed_tokens = 0
1021 + num_external_computed_tokens = 0
1022 + connector_prefix_cache_hits = 0
1023 + num_computed_tokens = 0
1024 + load_kv_async = False
1025
1026 # Skip request with pending mm encoding prefetches
1027 if self._ec_transfer_pending(request, num_computed_tokens):
@@ -1022,6 +1056,11 @@ class Scheduler(SchedulerInterface):
1056 new_encoder_compute_budget = encoder_compute_budget
1057 pad_spec_decode = False
1058
1059 + # SWA bounded replay: recompute the tail of the hit without
1060 + # rewriting its cached KV. An async load replays once the KV
1061 + # has arrived (_update_waiting_for_remote_kv).
1062 + num_replay_tokens = 0
1063 +
1064 if load_kv_async:
1065 # KVTransfer: loading remote KV, do not allocate for new work.
1066 assert num_external_computed_tokens > 0
@@ -1032,6 +1071,12 @@ class Scheduler(SchedulerInterface):
1071 break
1072 else:
1073 request_token_budget = min(token_budget, input_budget - draft_slots)
1074 + if did_prefix_cache_lookup:
1075 + # Fresh admission: local and/or sync external hit.
1076 + num_replay_tokens = self._mark_prefix_replay(
1077 + request, num_computed_tokens
1078 + )
1079 + num_computed_tokens -= num_replay_tokens
1080 # Number of tokens to be scheduled.
1081 # We use `request.num_tokens` instead of
1082 # `request.num_prompt_tokens` to consider the resumed
@@ -1159,9 +1204,12 @@ class Scheduler(SchedulerInterface):
1204 + self._spec_decode_step_blocks()
1205 )
1206
1207 + # Replayed tokens are already counted in the adopted hit; a
1208 + # chunk that ends inside the replayed range needs no new slots.
1209 + num_tokens_past_hit = max(num_new_tokens - num_replay_tokens, 0)
1210 new_blocks = self.kv_cache_manager.allocate_slots(
1211 request,
1164 - num_new_tokens,
1212 + num_tokens_past_hit,
1213 num_new_computed_tokens=num_new_local_computed_tokens,
1214 new_computed_blocks=new_computed_blocks,
1215 num_lookahead_tokens=effective_lookahead_tokens,
@@ -2963,6 +3011,20 @@ class Scheduler(SchedulerInterface):
3011 self._request_remaining_blocks(req) for req in self._inflight_prefills
3012 )
3013
3014 + def _mark_prefix_replay(self, request: Request, num_hit_tokens: int) -> int:
3015 + """Record where a prefix hit's replay starts and return the number of
3016 + hit tokens to recompute; the caller rewinds the computed count by it.
3017 +
3018 + The replayed tokens are the hit's last window (see
3019 + ``Request.replay_start``): the worker rebuilds their sliding-window KV
3020 + and leaves their cached KV alone.
3021 + """
3022 + if not self.prefix_replay_tokens:
3023 + return 0
3024 + num_replay_tokens = min(self.prefix_replay_tokens, num_hit_tokens)
3025 + request.replay_start = num_hit_tokens - num_replay_tokens
3026 + return num_replay_tokens
3027 +
3028 def _update_waiting_for_remote_kv(self, request: Request) -> None:
3029 """KV Connector: update request state after async recv is finished.
3030
@@ -2998,11 +3060,16 @@ class Scheduler(SchedulerInterface):
3060 # This will cache the blocks iff caching is enabled.
3061 self.kv_cache_manager.cache_blocks(request, request.num_computed_tokens)
3062
3001 - # on a full prompt hit, we need to re-compute the last token
3002 - # in order to be able to sample the next token
3003 - if request.num_computed_tokens == request.num_tokens:
3004 - request.num_computed_tokens = request.num_tokens - 1
3005 -
3063 + # SWA bounded replay recomputes the tail of the hit, which covers the
3064 + # last token; otherwise a full prompt hit re-computes that token so the
3065 + # next one can be sampled.
3066 + num_replay_tokens = self._mark_prefix_replay(
3067 + request, request.num_computed_tokens
3068 + )
3069 + if num_replay_tokens > 0:
3070 + request.num_computed_tokens -= num_replay_tokens
3071 + elif request.num_computed_tokens == request.num_tokens:
3072 + request.num_computed_tokens = request.num_tokens - 1
3073 self.finished_recving_kv_req_ids.remove(request.request_id)
3074
3075 def _try_promote_blocked_waiting_request(self, request: Request) -> bool:
vllm/v1/core/single_type_kv_cache_manager.py
+2
@@ -492,6 +492,8 @@ class SingleTypeKVCacheManager(ABC):
492 can resume at, from ``get_replay_boundaries``.
493
494 """
495 + if not self.kv_cache_spec.prefix_cacheable:
496 + return
497 num_cached_blocks = self.num_cached_block.get(request.request_id, 0)
498 num_full_blocks = num_tokens // self.block_size
499
vllm/v1/kv_cache_interface.py
+23 -1
@@ -164,6 +164,11 @@ class KVCacheSpec:
164 """Whether this spec's group participates in prefix caching."""
165 return True
166
167 + @property
168 + def prefix_replay_tokens(self) -> int:
169 + """DeepSeek-V4.1 only: bounded replay. Currently only for DSV41 SWA."""
170 + return 0
171 +
172 @property
173 def num_heads(self) -> int:
174 raise NotImplementedError
@@ -908,6 +913,7 @@ class SlidingWindowMLASpec(SlidingWindowSpec):
913 # DeepseekV4-only: see MLAAttentionSpec.model_version.
914 alignment: int | None = None # Default to None for no padding.
915 model_version: str | None = None
916 + bounded_replay: bool = False
917
918 # MLA stores a single latent vector per state; there is no separate V.
919 head_size_v: int = 0
@@ -919,6 +925,14 @@ class SlidingWindowMLASpec(SlidingWindowSpec):
925 super().__post_init__()
926 _apply_alignment_padding(self)
927
928 + @property
929 + def prefix_cacheable(self) -> bool:
930 + return not self.bounded_replay
931 +
932 + @property
933 + def prefix_replay_tokens(self) -> int:
934 + return self.sliding_window if self.bounded_replay else 0
935 +
936 @classmethod
937 def merge(cls, specs: list[Self]) -> Self:
938 assert all(isinstance(spec, SlidingWindowMLASpec) for spec in specs), (
@@ -930,16 +944,18 @@ class SlidingWindowMLASpec(SlidingWindowSpec):
944 model_version_set = set(spec.model_version for spec in specs)
945 sliding_window_set = set(spec.sliding_window for spec in specs)
946 extra_retained_set = set(spec.extra_retained_tokens for spec in specs)
947 + bounded_replay_set = set(spec.bounded_replay for spec in specs)
948 assert (
949 len(cache_dtype_str_set) == 1
950 and len(tokens_per_state_set) == 1
951 and len(model_version_set) == 1
952 and len(sliding_window_set) == 1
953 and len(extra_retained_set) == 1
954 + and len(bounded_replay_set) == 1
955 ), (
956 "All attention layers in the same KV cache group must use the same "
957 "quantization method, tokens per state, model version, sliding "
942 - "window size, and retained token count."
958 + "window size, retained token count, and replay policy."
959 )
960 return cls(
961 block_size=specs[0].block_size,
@@ -954,6 +970,7 @@ class SlidingWindowMLASpec(SlidingWindowSpec):
970 cache_dtype_str=cache_dtype_str_set.pop(),
971 tokens_per_state=tokens_per_state_set.pop(),
972 model_version=model_version_set.pop(),
973 + bounded_replay=bounded_replay_set.pop(),
974 )
975
976 def is_uniform_with_collection(
@@ -962,6 +979,7 @@ class SlidingWindowMLASpec(SlidingWindowSpec):
979 return all(
980 isinstance(spec, SlidingWindowMLASpec)
981 and spec.sliding_window == self.sliding_window
982 + and spec.bounded_replay == self.bounded_replay
983 for spec in kv_cache_specs.values()
984 )
985
@@ -1195,6 +1213,10 @@ class UniformTypeKVCacheSpecs(KVCacheSpec):
1213 def prefix_cacheable(self) -> bool:
1214 return all(spec.prefix_cacheable for spec in self.kv_cache_specs.values())
1215
1216 + @property
1217 + def prefix_replay_tokens(self) -> int:
1218 + return max(spec.prefix_replay_tokens for spec in self.kv_cache_specs.values())
1219 +
1220 @property
1221 def first_spec(self) -> KVCacheSpec:
1222 """Return the first spec in the group."""
vllm/v1/request.py
+3
@@ -201,6 +201,9 @@ class Request:
201 # in the (sparse) prefix cache; 0 means none. Set at admission for
202 # hybrid/Mamba models when a shared prefix is detected (Marconi-style).
203 self.shared_prefix_boundary = 0
204 + # DeepSeek-V4.1 only: SWA bounded replay. The request holds no
205 + # sliding-window KV below this position; 0 when nothing replays.
206 + self.replay_start = 0
207
208 # The number of NaNs in logits. A value greater than 0
209 # indicates that the output is corrupted
vllm/v1/worker/gpu/model_states/default.py
+6 -1
@@ -16,7 +16,10 @@ from vllm.v1.worker.gpu.attn_utils import (
16 from vllm.v1.worker.gpu.input_batch import InputBatch
17 from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
18 from vllm.v1.worker.gpu.mm.rope import get_rope_state
19 -from vllm.v1.worker.gpu.model_states.interface import ModelState
19 +from vllm.v1.worker.gpu.model_states.interface import (
20 + ModelSpecificAttnMetadata,
21 + ModelState,
22 +)
23 from vllm.v1.worker.gpu.model_states.mm_pruning import maybe_create_mm_pruner
24 from vllm.v1.worker.gpu.model_states.prompt_embeds import PromptEmbedsState
25 from vllm.v1.worker.gpu.states import RequestState
@@ -173,6 +176,7 @@ class DefaultModelState(ModelState):
176 kv_cache_config: KVCacheConfig,
177 for_capture: bool = False,
178 ubatch_idx: int = 0,
179 + model_specific_attn_metadata: ModelSpecificAttnMetadata | None = None,
180 ) -> dict[str, Any]:
181 if cudagraph_mode == CUDAGraphMode.FULL:
182 # Use padded sizes - padding is handled by model_runner.prepare_attn.
@@ -229,5 +233,6 @@ class DefaultModelState(ModelState):
233 req_idx=input_batch.idx_mapping_np,
234 ubatch_idx=ubatch_idx,
235 fast_prefill=input_batch.fast_prefill,
236 + model_specific_attn_metadata=model_specific_attn_metadata,
237 )
238 return attn_metadata
vllm/v1/worker/gpu/model_states/encoder_only.py
+3
@@ -19,6 +19,7 @@ from vllm.v1.kv_cache_interface import EncoderOnlyAttentionSpec, KVCacheConfig
19 from vllm.v1.worker.gpu.input_batch import InputBatch
20 from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
21 from vllm.v1.worker.gpu.model_states.default import DefaultModelState
22 +from vllm.v1.worker.gpu.model_states.interface import ModelSpecificAttnMetadata
23 from vllm.v1.worker.gpu.states import RequestState
24 from vllm.v1.worker.utils import AttentionGroup
25
@@ -168,6 +169,7 @@ class EncoderOnlyModelState(DefaultModelState):
169 kv_cache_config: KVCacheConfig,
170 for_capture: bool = False,
171 ubatch_idx: int = 0,
172 + model_specific_attn_metadata: ModelSpecificAttnMetadata | None = None,
173 ) -> dict[str, Any]:
174 assert ubatch_idx == 0, "DBO is not supported"
175 attn_metadata = super().prepare_attn(
@@ -178,6 +180,7 @@ class EncoderOnlyModelState(DefaultModelState):
180 attn_groups,
181 kv_cache_config,
182 for_capture,
183 + model_specific_attn_metadata=model_specific_attn_metadata,
184 )
185 attn_metadata.update(
186 self._build_encoder_attn_metadata(input_batch, cudagraph_mode, for_capture)
vllm/v1/worker/gpu/model_states/mamba_hybrid.py
+2
@@ -239,8 +239,10 @@ class MambaHybridModelState(DefaultModelState):
239 kv_cache_config: KVCacheConfig,
240 for_capture: bool = False,
241 ubatch_idx: int = 0,
242 + model_specific_attn_metadata: ModelSpecificAttnMetadata | None = None,
243 ) -> dict[str, Any]:
244 assert ubatch_idx == 0, "DBO is not supported"
245 + assert model_specific_attn_metadata is None
246 if cudagraph_mode == CUDAGraphMode.FULL:
247 num_reqs = input_batch.num_reqs_after_padding
248 num_tokens = input_batch.num_tokens_after_padding