[Mamba] Add FlashInfer ReplaySSM support for MTP (#52928)

Signed-off-by: Andrii Skliar <askliar@nvidia.com> Co-authored-by: Andrii Skliar <askliar@nvidia.com>

Andrii Skliar committed Sep 30, 2026 at 23:41 UTC deb148e730225c64427f8ccf1f313cb4b33325b0
18 files changed +1026 -110
.buildkite/test_areas/engine.yaml
+21
@@ -133,6 +133,27 @@ steps:
133 - pytest -v -s v1/e2e/general/test_kv_sharing_fast_prefill.py
134 - pytest -v -s v1/e2e/general/test_mamba_prefix_cache.py::test_mamba_prefix_cache_mrv2 v1/e2e/general/test_mamba_prefix_cache.py::test_mamba_prefix_cache_mrv2_async
135
136 +- label: ":nvidia: (H100) ReplaySSM E2E"
137 + device: h100
138 + num_devices: 2
139 + key: replayssm-e2e
140 + timeout_in_minutes: 90
141 + working_dir: "/vllm-workspace/tests"
142 + source_file_dependencies:
143 + - tests/v1/e2e/test_replayssm_decode.py
144 + - vllm/config/cache.py
145 + - vllm/config/vllm.py
146 + - vllm/model_executor/layers/mamba/
147 + - vllm/model_executor/models/nemotron_h.py
148 + - vllm/model_executor/warmup/replayssm_warmup.py
149 + - vllm/v1/attention/backends/mamba_attn.py
150 + - vllm/v1/spec_decode/
151 + - vllm/v1/worker/gpu/
152 + - vllm/v1/worker/gpu_model_runner.py
153 + - vllm/v1/worker/gpu_worker.py
154 + commands:
155 + - pytest -v -s v1/e2e/test_replayssm_decode.py
156 +
157 - label: ":nvidia: (L4) V1 E2E"
158 key: v1-e2e-2-gpus
159 timeout_in_minutes: 25 # TODO: Fix timeout after we have more confidence in the test stability
tests/kernels/mamba/test_ssu_dispatch.py
+393 -5
@@ -1,24 +1,30 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +from types import SimpleNamespace
5 from unittest.mock import Mock
6
7 import pytest
8 import torch
9
10 from vllm.config.mamba import MambaBackendEnum, MambaConfig, MambaSSUAlgorithm
11 +from vllm.model_executor.layers.mamba.mamba_mixer2 import MambaMixer2
12 from vllm.model_executor.layers.mamba.mamba_utils import MambaStateShapeCalculator
13 from vllm.model_executor.layers.mamba.ops.ssu_dispatch import (
14 FlashInferSSUBackend,
15 TritonSSUBackend,
16 + commit_replayssm_ring_trackers,
17 get_mamba_ssu_backend,
18 initialize_mamba_ssu_backend,
19 reset_replayssm_ring_trackers,
20 selective_state_update,
21 + selective_state_update_replayssm_flashinfer,
22 update_replayssm_ring_trackers,
23 )
24 from vllm.utils.torch_utils import set_random_seed
25 +from vllm.v1.attention.backends.mamba2_attn import Mamba2AttentionMetadata
26 from vllm.v1.attention.backends.registry import MambaAttentionBackendEnum
27 +from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
28 from vllm.v1.kv_cache_interface import (
29 KVCacheConfig,
30 KVCacheGroupSpec,
@@ -32,6 +38,13 @@ try:
38 except ImportError:
39 HAS_FLASHINFER = False
40
41 +try:
42 + from flashinfer.mamba.checkpointing_ssu import CheckpointingSSURunner
43 +
44 + HAS_FLASHINFER_CHECKPOINTING_SSU = CheckpointingSSURunner is not None
45 +except ImportError:
46 + HAS_FLASHINFER_CHECKPOINTING_SSU = False
47 +
48
49 @pytest.fixture(autouse=True)
50 def restore_backend_state():
@@ -47,6 +60,7 @@ def restore_backend_state():
60 def test_flashinfer_replayssm_ring_tracker_lifecycle():
61 ring_start = torch.zeros(2, dtype=torch.int32, device="cuda")
62 prev_num_accepted = torch.zeros(2, dtype=torch.int32, device="cuda")
63 + prev_query_len = torch.zeros(2, dtype=torch.int32, device="cuda")
64 state_batch_indices = torch.tensor([1], dtype=torch.int32, device="cuda")
65
66 observed = []
@@ -54,6 +68,7 @@ def test_flashinfer_replayssm_ring_tracker_lifecycle():
68 update_replayssm_ring_trackers(
69 ring_start,
70 prev_num_accepted,
71 + prev_query_len,
72 state_batch_indices,
73 logical_window=16,
74 ring_buffer_len=17,
@@ -69,9 +84,205 @@ def test_flashinfer_replayssm_ring_tracker_lifecycle():
84 reset_replayssm_ring_trackers(
85 ring_start,
86 prev_num_accepted,
87 + prev_query_len,
88 state_batch_indices,
89 )
74 - assert (ring_start[1].item(), prev_num_accepted[1].item()) == (0, 0)
90 + assert (
91 + ring_start[1].item(),
92 + prev_num_accepted[1].item(),
93 + prev_query_len[1].item(),
94 + ) == (0, 0, 0)
95 +
96 +
97 +@pytest.mark.parametrize(
98 + ("accepted_sequence", "expected"),
99 + [
100 + pytest.param(
101 + [4] * 22,
102 + [
103 + (0, 0, 4),
104 + (0, 4, 4),
105 + (0, 8, 4),
106 + (0, 12, 4),
107 + (0, 16, 4),
108 + (16, 4, 4),
109 + (16, 8, 4),
110 + (16, 12, 4),
111 + (16, 16, 4),
112 + (12, 4, 4),
113 + (12, 8, 4),
114 + (12, 12, 4),
115 + (12, 16, 4),
116 + (8, 4, 4),
117 + (8, 8, 4),
118 + (8, 12, 4),
119 + (8, 16, 4),
120 + (4, 4, 4),
121 + (4, 8, 4),
122 + (4, 12, 4),
123 + (4, 16, 4),
124 + (0, 4, 4),
125 + ],
126 + id="all-accepted",
127 + ),
128 + pytest.param(
129 + [4, 4, 0, 3, 4, 2, 4, 1],
130 + [
131 + (0, 0, 4),
132 + (0, 4, 4),
133 + (0, 4, 4),
134 + (0, 7, 4),
135 + (0, 11, 4),
136 + (0, 13, 4),
137 + (13, 4, 4),
138 + (13, 5, 4),
139 + ],
140 + id="mixed",
141 + ),
142 + ],
143 +)
144 +def test_replayssm_commit_tracker_acceptance_sequence(accepted_sequence, expected):
145 + logical_window = 16
146 + num_speculative_tokens = 3
147 + query_len = 1 + num_speculative_tokens
148 + ring_buffer_len = logical_window + 1 + num_speculative_tokens
149 + ring_start = torch.zeros(2, dtype=torch.int32, device="cuda")
150 + prev_num_accepted = torch.zeros(2, dtype=torch.int32, device="cuda")
151 + prev_query_len = torch.zeros(2, dtype=torch.int32, device="cuda")
152 + state_batch_indices = torch.tensor([1], dtype=torch.int32, device="cuda")
153 + query_start_loc = torch.tensor([0, query_len], dtype=torch.int32, device="cuda")
154 +
155 + observed = []
156 + for accepted in accepted_sequence:
157 + commit_replayssm_ring_trackers(
158 + ring_start,
159 + prev_num_accepted,
160 + prev_query_len,
161 + state_batch_indices,
162 + torch.tensor([accepted], dtype=torch.int32, device="cuda"),
163 + query_start_loc,
164 + logical_window,
165 + ring_buffer_len,
166 + )
167 + snapshot = (
168 + ring_start[1].item(),
169 + prev_num_accepted[1].item(),
170 + prev_query_len[1].item(),
171 + )
172 + observed.append(snapshot)
173 + assert snapshot[1] + snapshot[2] <= ring_buffer_len
174 +
175 + assert observed == expected
176 +
177 +
178 +def test_replayssm_resume_resets_commit_history():
179 + ring_start = torch.tensor([0, 13], dtype=torch.int32, device="cuda")
180 + prev_num_accepted = torch.tensor([0, 13], dtype=torch.int32, device="cuda")
181 + prev_query_len = torch.tensor([0, 4], dtype=torch.int32, device="cuda")
182 + state_batch_indices = torch.tensor([1], dtype=torch.int32, device="cuda")
183 +
184 + reset_replayssm_ring_trackers(
185 + ring_start,
186 + prev_num_accepted,
187 + prev_query_len,
188 + state_batch_indices,
189 + )
190 + assert (
191 + ring_start[1].item(),
192 + prev_num_accepted[1].item(),
193 + prev_query_len[1].item(),
194 + ) == (0, 0, 0)
195 +
196 + commit_replayssm_ring_trackers(
197 + ring_start,
198 + prev_num_accepted,
199 + prev_query_len,
200 + state_batch_indices,
201 + torch.tensor([3], dtype=torch.int32, device="cuda"),
202 + torch.tensor([0, 4], dtype=torch.int32, device="cuda"),
203 + logical_window=16,
204 + ring_buffer_len=20,
205 + )
206 + assert (
207 + ring_start[1].item(),
208 + prev_num_accepted[1].item(),
209 + prev_query_len[1].item(),
210 + ) == (0, 0, 4)
211 +
212 +
213 +def test_replayssm_commit_tracker_ragged_query_lengths():
214 + ring_start = torch.zeros(3, dtype=torch.int32, device="cuda")
215 + prev_num_accepted = torch.zeros(3, dtype=torch.int32, device="cuda")
216 + prev_query_len = torch.zeros(3, dtype=torch.int32, device="cuda")
217 + state_batch_indices = torch.tensor([1, 2], dtype=torch.int32, device="cuda")
218 + query_start_loc = torch.tensor([0, 4, 6], dtype=torch.int32, device="cuda")
219 +
220 + observed = []
221 + for accepted in ([4, 2], [3, 1]):
222 + commit_replayssm_ring_trackers(
223 + ring_start,
224 + prev_num_accepted,
225 + prev_query_len,
226 + state_batch_indices,
227 + torch.tensor(accepted, dtype=torch.int32, device="cuda"),
228 + query_start_loc,
229 + logical_window=16,
230 + ring_buffer_len=20,
231 + )
232 + observed.append(
233 + [
234 + (
235 + ring_start[slot].item(),
236 + prev_num_accepted[slot].item(),
237 + prev_query_len[slot].item(),
238 + )
239 + for slot in (1, 2)
240 + ]
241 + )
242 +
243 + assert observed == [[(0, 0, 4), (0, 0, 2)], [(0, 3, 4), (0, 1, 2)]]
244 +
245 +
246 +@pytest.mark.parametrize("operation", ["commit", "reset"])
247 +def test_replayssm_tracker_kernels_mask_invalid_slots(operation):
248 + num_states = 3
249 + ring_start = torch.tensor([11, 2, 33], dtype=torch.int32, device="cuda")
250 + prev_num_accepted = torch.tensor([11, 3, 33], dtype=torch.int32, device="cuda")
251 + prev_query_len = torch.tensor([11, 4, 33], dtype=torch.int32, device="cuda")
252 + state_batch_indices = torch.tensor(
253 + [-1, num_states, NULL_BLOCK_ID, 1], dtype=torch.int32, device="cuda"
254 + )
255 +
256 + if operation == "commit":
257 + commit_replayssm_ring_trackers(
258 + ring_start,
259 + prev_num_accepted,
260 + prev_query_len,
261 + state_batch_indices,
262 + torch.tensor([4, 4, 4, 2], dtype=torch.int32, device="cuda"),
263 + torch.tensor([0, 4, 8, 12, 16], dtype=torch.int32, device="cuda"),
264 + logical_window=16,
265 + ring_buffer_len=20,
266 + )
267 + expected_valid = (2, 5, 4)
268 + else:
269 + reset_replayssm_ring_trackers(
270 + ring_start,
271 + prev_num_accepted,
272 + prev_query_len,
273 + state_batch_indices,
274 + )
275 + expected_valid = (0, 0, 0)
276 +
277 + assert (
278 + ring_start.tolist(),
279 + prev_num_accepted.tolist(),
280 + prev_query_len.tolist(),
281 + ) == (
282 + [11, expected_valid[0], 33],
283 + [11, expected_valid[1], 33],
284 + [11, expected_valid[2], 33],
285 + )
286
287
288 def _kv_cache_config_with_ssu(
@@ -231,14 +442,190 @@ def test_triton_basic_call():
442 assert not torch.isnan(out).any()
443
444
445 +@pytest.mark.parametrize("layout", ["packed", "dense"])
446 +def test_replayssm_flashinfer_call_forwards_mtp_layout(monkeypatch, layout):
447 + import vllm.model_executor.layers.mamba.ops.ssu_dispatch as mod
448 +
449 + kernel = Mock(return_value=torch.empty(0))
450 + monkeypatch.setattr(mod, "_flashinfer_replayssm_kernel", kernel)
451 +
452 + batch, max_seqlen, nheads, dim, dstate, ngroups = 2, 4, 2, 4, 8, 1
453 + state = torch.empty(2, nheads, dim, dstate)
454 + x_shape: tuple[int, ...]
455 + B_shape: tuple[int, ...]
456 + expected_x_shape: tuple[int, ...]
457 + expected_B_shape: tuple[int, ...]
458 + if layout == "packed":
459 + x_shape = (6, nheads, dim)
460 + B_shape = (6, ngroups, dstate)
461 + expected_x_shape = (1, 6, nheads, dim)
462 + expected_B_shape = (1, 6, ngroups, dstate)
463 + cu_seqlens = torch.tensor([0, 4, 6], dtype=torch.int32)
464 + kernel_max_seqlen = max_seqlen
465 + else:
466 + x_shape = (batch, max_seqlen, nheads, dim)
467 + B_shape = (batch, max_seqlen, ngroups, dstate)
468 + expected_x_shape = x_shape
469 + expected_B_shape = B_shape
470 + cu_seqlens = None
471 + kernel_max_seqlen = None
472 + x = torch.empty(x_shape)
473 + dt = torch.empty_like(x)
474 + A = torch.empty(nheads, dim, dstate)
475 + B = torch.empty(B_shape)
476 + C = torch.empty_like(B)
477 + out = torch.empty_like(x)
478 + x_cache = torch.empty(2, nheads, 20, dim)
479 + dt_cache = torch.empty(2, nheads, 20)
480 + B_cache = torch.empty(2, ngroups, 20, dstate)
481 + ring_start = torch.zeros(2, dtype=torch.int32)
482 + prev_num_accepted = torch.zeros(2, dtype=torch.int32)
483 + prev_query_len = torch.zeros(2, dtype=torch.int32)
484 + selective_state_update_replayssm_flashinfer(
485 + state,
486 + x,
487 + dt,
488 + A,
489 + B,
490 + C,
491 + out,
492 + x_cache,
493 + B_cache,
494 + dt_cache,
495 + ring_start,
496 + prev_num_accepted,
497 + prev_query_len,
498 + logical_window=16,
499 + state_batch_indices=torch.tensor([0, 1], dtype=torch.int32),
500 + cu_seqlens=cu_seqlens,
501 + max_seqlen=kernel_max_seqlen,
502 + update_trackers=False,
503 + )
504 +
505 + args = kernel.call_args.args
506 + assert args[6].shape == expected_x_shape
507 + assert args[7].shape == expected_x_shape
508 + assert args[9].shape == expected_B_shape
509 + assert args[10].shape == expected_B_shape
510 + assert args[11].shape == expected_x_shape
511 + assert kernel.call_args.kwargs["cu_seqlens"] is cu_seqlens
512 + assert kernel.call_args.kwargs["max_seqlen"] == kernel_max_seqlen
513 +
514 +
515 +@pytest.mark.parametrize(
516 + ("query_start_loc", "expected_shape", "expected_max_seqlen"),
517 + [
518 + pytest.param([0, 4, 8], (2, 4, 2, 4), None, id="dense"),
519 + pytest.param([0, 4, 6], (6, 2, 4), 4, id="packed"),
520 + ],
521 +)
522 +def test_replayssm_mixer_selects_mtp_layout(
523 + monkeypatch, query_start_loc, expected_shape, expected_max_seqlen
524 +):
525 + import vllm.model_executor.layers.mamba.mamba_mixer2 as mod
526 +
527 + mixer = MambaMixer2.__new__(MambaMixer2)
528 + torch.nn.Module.__init__(mixer)
529 + mixer.prefix = "mixer"
530 + mixer.tped_intermediate_size = 0
531 + mixer.tped_conv_size = 1
532 + mixer.tped_dt_size = 2
533 + mixer.num_heads = 2
534 + mixer.head_dim = 4
535 + mixer.n_groups = mixer.tp_size = 1
536 + mixer.ssm_state_size = 8
537 + mixer.num_spec = 3
538 + mixer.use_replayssm = True
539 + mixer.replayssm_buffer_len = 16
540 + mixer._commits_replayssm_trackers = True
541 + mixer._updates_replayssm_trackers = False
542 + mixer.mamba_config = MambaConfig(backend=MambaBackendEnum.FLASHINFER)
543 + mixer.cache_config = SimpleNamespace(mamba_block_size=16, mamba_cache_mode="none")
544 + mixer.conv_weights = torch.empty(0)
545 + mixer.conv1d = SimpleNamespace(bias=None)
546 + mixer.activation = "silu"
547 + mixer.A = torch.empty(2)
548 + mixer.dt_bias = torch.empty(2)
549 + mixer.D = torch.empty(2)
550 + mixer._replayssm_ring_start = torch.zeros(3, dtype=torch.int32)
551 + mixer._replayssm_prev_num_accepted = torch.zeros(3, dtype=torch.int32)
552 + mixer._replayssm_prev_query_len = torch.zeros(3, dtype=torch.int32)
553 + mixer.kv_cache = (
554 + torch.empty(3, 1),
555 + torch.empty(3, 2, 4, 8),
556 + torch.empty(3, 2, 20, 4),
557 + torch.empty(3, 2, 20),
558 + torch.empty(3, 1, 20, 8),
559 + )
560 +
561 + num_decode_tokens = query_start_loc[-1]
562 + query_start_loc_d = torch.tensor(query_start_loc, dtype=torch.int32)
563 + metadata = Mamba2AttentionMetadata(
564 + num_prefills=0,
565 + num_prefill_tokens=0,
566 + num_decodes=2,
567 + num_decode_tokens=num_decode_tokens,
568 + num_reqs=2,
569 + has_initial_states_p=None,
570 + query_start_loc_p=None,
571 + num_computed_tokens_p=None,
572 + state_indices_tensor_p=None,
573 + state_indices_tensor_d=torch.tensor([[1], [2]], dtype=torch.int32),
574 + query_start_loc_d=query_start_loc_d,
575 + num_accepted_tokens=torch.tensor([4, 2], dtype=torch.int32),
576 + block_idx_last_scheduled_token=None,
577 + block_idx_first_scheduled_token_p=None,
578 + block_idx_last_computed_token=None,
579 + block_idx_last_scheduled_token_prev_step=None,
580 + seq_lens=torch.tensor([104, 102], dtype=torch.int32),
581 + replayssm_scratch=(torch.empty(0), torch.empty(0), torch.empty(0)),
582 + replayssm_state_indices_d=torch.tensor([1, 2], dtype=torch.int32),
583 + )
584 +
585 + def split_hidden_states_B_C(values):
586 + tokens = values.size(0)
587 + return (
588 + torch.empty(tokens, 8),
589 + torch.empty(tokens, 8),
590 + torch.empty(tokens, 8),
591 + )
592 +
593 + mixer.split_hidden_states_B_C_fn = split_hidden_states_B_C
594 + kernel = Mock()
595 + monkeypatch.setattr(
596 + mod,
597 + "get_forward_context",
598 + lambda: SimpleNamespace(attn_metadata={mixer.prefix: metadata}),
599 + )
600 + monkeypatch.setattr(mod, "commit_replayssm_ring_trackers", Mock())
601 + monkeypatch.setattr(
602 + mod, "causal_conv1d_update", lambda values, *args, **kwargs: values
603 + )
604 + monkeypatch.setattr(mod, "selective_state_update_replayssm_flashinfer", kernel)
605 +
606 + mixer.conv_ssm_forward(
607 + torch.empty(num_decode_tokens, 3), torch.empty(num_decode_tokens, 8)
608 + )
609 +
610 + assert kernel.call_args.args[1].shape == expected_shape
611 + if expected_max_seqlen is None:
612 + assert kernel.call_args.kwargs["cu_seqlens"] is None
613 + else:
614 + assert kernel.call_args.kwargs["cu_seqlens"] is query_start_loc_d
615 + assert kernel.call_args.kwargs["max_seqlen"] == expected_max_seqlen
616 +
617 +
618 @pytest.mark.parametrize(
235 - ("backend", "expected_ring_len"),
619 + ("backend", "num_speculative_tokens", "expected_ring_len"),
620 [
237 - (MambaBackendEnum.TRITON, 16),
238 - (MambaBackendEnum.FLASHINFER, 17),
621 + (MambaBackendEnum.TRITON, 0, 16),
622 + (MambaBackendEnum.FLASHINFER, 0, 17),
623 + (MambaBackendEnum.FLASHINFER, 3, 20),
624 ],
625 )
241 -def test_replayssm_physical_ring_shape(backend, expected_ring_len):
626 +def test_replayssm_physical_ring_shape(
627 + backend, num_speculative_tokens, expected_ring_len
628 +):
629 base_shapes = ((64, 3), (8, 4, 16))
630
631 shapes = MambaStateShapeCalculator.append_replayssm_ring(
@@ -247,6 +634,7 @@ def test_replayssm_physical_ring_shape(backend, expected_ring_len):
634 tp_world_size=2,
635 logical_window=16,
636 backend=backend,
637 + num_speculative_tokens=num_speculative_tokens,
638 )
639
640 assert shapes[2:] == (
tests/model_executor/test_replayssm_warmup.py
+19 -4
@@ -67,8 +67,13 @@ def test_replayssm_autotune_decode_kwargs(runner_kwargs, expected_num_reqs):
67 with patch.object(
68 warmup, "flashinfer_replayssm_autotune_supported", return_value=True
69 ):
70 - result = warmup._replayssm_autotune_kwargs(_autotune_runner(**runner_kwargs))
70 + result = warmup._replayssm_autotune_kwargs(
71 + _autotune_runner(**runner_kwargs), PREFILL_KWARGS
72 + )
73
74 + assert result is not None
75 + max_num_reqs, decode_kwargs = result
76 + assert max_num_reqs == expected_num_reqs
77 expected_kwargs = {
78 **PREFILL_KWARGS,
79 "num_tokens": expected_num_reqs * query_len,
@@ -82,7 +87,7 @@ def test_replayssm_autotune_decode_kwargs(runner_kwargs, expected_num_reqs):
87 force_attention=True,
88 profile_seq_lens=query_len + 1,
89 )
85 - assert result == (expected_num_reqs, expected_kwargs)
90 + assert decode_kwargs == expected_kwargs
91
92
93 @pytest.mark.parametrize(
@@ -91,8 +96,14 @@ def test_replayssm_autotune_decode_kwargs(runner_kwargs, expected_num_reqs):
96 (dict(use_replayssm=False), True),
97 (dict(backend=MambaBackendEnum.TRITON), True),
98 ({}, False),
99 + (dict(num_blocks=1), True),
100 + ],
101 + ids=[
102 + "replayssm_disabled",
103 + "non_flashinfer_backend",
104 + "kernel_unavailable",
105 + "zero_non_padding_slots",
106 ],
95 - ids=["replayssm_disabled", "non_flashinfer_backend", "kernel_unavailable"],
107 )
108 def test_replayssm_autotune_kwargs_skipped(runner_kwargs, flashinfer_supported):
109 with patch.object(
@@ -100,7 +111,9 @@ def test_replayssm_autotune_kwargs_skipped(runner_kwargs, flashinfer_supported):
111 "flashinfer_replayssm_autotune_supported",
112 return_value=flashinfer_supported,
113 ):
103 - result = warmup._replayssm_autotune_kwargs(_autotune_runner(**runner_kwargs))
114 + result = warmup._replayssm_autotune_kwargs(
115 + _autotune_runner(**runner_kwargs), PREFILL_KWARGS
116 + )
117 assert result is None
118
119
@@ -116,10 +129,12 @@ def test_replayssm_autotune_slots_restore_state_and_trackers():
129 )
130 mixer._replayssm_ring_start = torch.full((4,), 3, dtype=torch.int32)
131 mixer._replayssm_prev_num_accepted = torch.full((4,), 3, dtype=torch.int32)
132 + mixer._replayssm_prev_query_len = torch.full((4,), 3, dtype=torch.int32)
133 tracked = (
134 *mixer.kv_cache,
135 mixer._replayssm_ring_start,
136 mixer._replayssm_prev_num_accepted,
137 + mixer._replayssm_prev_query_len,
138 )
139
140 block_ids = np.arange(10, 14, dtype=np.int32).reshape(4, 1)
tests/test_config.py
+4 -2
@@ -153,6 +153,7 @@ def test_kda_recoverssm_derivation_is_revalidated():
153 use_replayssm=True,
154 use_kda_recoverssm=False,
155 mamba_cache_mode="none",
156 + replayssm_buffer_len=16,
157 ),
158 num_speculative_tokens=3,
159 model_config=SimpleNamespace(
@@ -184,8 +185,9 @@ def test_kda_recoverssm_derivation_is_revalidated():
185 config.cache_config.mamba_cache_mode = "none"
186
187 config.model_config.architecture = "NemotronHForCausalLM"
187 - with pytest.raises(ValueError, match="only supported for Kimi-K3 KDA"):
188 - VllmConfig.validate_mamba_cached_kernel(config)
188 + config.mamba_config.backend = MambaBackendEnum.FLASHINFER
189 + VllmConfig.validate_mamba_cached_kernel(config)
190 + assert not config.cache_config.use_kda_recoverssm
191
192 config.model_config.architecture = "KimiLinearForCausalLM"
193 config.parallel_config.pipeline_parallel_size = 2
tests/v1/attention/test_attention_backends_selection.py
+28
@@ -5,7 +5,9 @@
5 from types import SimpleNamespace
6
7 import pytest
8 +import torch
9
10 +from vllm.model_executor.layers.mamba.abstract import MambaBase
11 from vllm.model_executor.layers.mamba.linear.minimax_linear_attn import (
12 MiniMaxText01LinearAttention,
13 )
@@ -19,6 +21,32 @@ from vllm.v1.attention.backends.registry import MambaAttentionBackendEnum
21 from vllm.v1.attention.backends.short_conv_attn import ShortConvAttentionBackend
22
23
24 +@pytest.mark.parametrize(("use_replayssm", "expected_blocks"), [(False, 3), (True, 0)])
25 +def test_replayssm_does_not_reserve_speculative_state_blocks(
26 + use_replayssm, expected_blocks
27 +):
28 + layer = SimpleNamespace(
29 + get_state_shape=lambda: ((2,),),
30 + get_state_dtype=lambda: (torch.float32,),
31 + mamba_type=MambaAttentionBackendEnum.MAMBA2,
32 + is_kv_cache_tp_replicated=False,
33 + )
34 + vllm_config = SimpleNamespace(
35 + cache_config=SimpleNamespace(
36 + mamba_block_size=1,
37 + mamba_page_size_padded=None,
38 + mamba_cache_mode="none",
39 + use_replayssm=use_replayssm,
40 + ),
41 + num_speculative_tokens=3,
42 + )
43 +
44 + spec = MambaBase.get_kv_cache_spec(layer, vllm_config)
45 +
46 + assert spec is not None
47 + assert spec.num_speculative_blocks == expected_blocks
48 +
49 +
50 @pytest.mark.parametrize(
51 "layer_class, init_kwargs, expected_backend, expected_mamba_type",
52 [
tests/v1/attention/test_replayssm_metadata_builder.py
+74 -18
@@ -16,6 +16,8 @@ from tests.v1.attention.utils import (
16 create_common_attn_metadata,
17 create_vllm_config,
18 )
19 +from vllm.config import SpeculativeConfig
20 +from vllm.config.compilation import CUDAGraphMode
21 from vllm.config.mamba import MambaBackendEnum
22 from vllm.v1.kv_cache_interface import MambaSpec
23
@@ -191,9 +193,12 @@ REPLAYSSM_BUILD_CASES = {
193 def _make_mamba_spec(
194 buffer_len: int,
195 mamba_backend: MambaBackendEnum,
196 + num_speculative_tokens: int = 0,
197 ) -> MambaSpec:
198 ring_buffer_len = buffer_len + (
196 - 1 if mamba_backend == MambaBackendEnum.FLASHINFER else 0
199 + 1 + num_speculative_tokens
200 + if mamba_backend == MambaBackendEnum.FLASHINFER
201 + else 0
202 )
203 shapes = (
204 (1, 1),
@@ -214,6 +219,7 @@ def _create_replayssm_builder(
219 mamba_cache_mode: str = "none",
220 *,
221 mamba_backend: MambaBackendEnum = MambaBackendEnum.TRITON,
222 + num_speculative_tokens: int = 0,
223 ) -> MockMambaBuilder:
224 vllm_config = create_vllm_config(
225 model_name="Qwen/Qwen3.5-0.8B", block_size=BLOCK_SIZE
@@ -224,21 +230,32 @@ def _create_replayssm_builder(
230 vllm_config.cache_config.replayssm_buffer_len = buffer_len
231 vllm_config.cache_config.mamba_cache_mode = mamba_cache_mode
232 vllm_config.mamba_config.backend = mamba_backend
233 + if num_speculative_tokens > 0:
234 + # Triton ReplaySSM requires exact synchronous CPU metadata.
235 + vllm_config.scheduler_config.async_scheduling = False
236 + vllm_config.speculative_config = SpeculativeConfig(
237 + method="ngram",
238 + num_speculative_tokens=num_speculative_tokens,
239 + )
240 return MockMambaBuilder(
228 - _make_mamba_spec(buffer_len, mamba_backend),
241 + _make_mamba_spec(buffer_len, mamba_backend, num_speculative_tokens),
242 ["layer0"],
243 vllm_config,
244 DEVICE,
245 )
246
247
235 -def _build(builder: MockMambaBuilder, case: ReplaySSMBuildCase):
248 +def _build(
249 + builder: MockMambaBuilder,
250 + case: ReplaySSMBuildCase,
251 + num_accepted_tokens: torch.Tensor | None = None,
252 +):
253 batch = BatchSpec(seq_lens=case.seq_lens, query_lens=case.query_lens)
254 common = create_common_attn_metadata(batch, BLOCK_SIZE, DEVICE).replace(
255 is_prefilling=torch.tensor(case.is_prefilling, dtype=torch.bool),
256 replayssm_decode_base_cpu=torch.tensor(case.decode_base, dtype=torch.int32),
257 )
241 - return builder.build(0, common)
258 + return builder.build(0, common, num_accepted_tokens=num_accepted_tokens)
259
260
261 @pytest.mark.parametrize(
@@ -270,21 +287,60 @@ def test_resumed_request_differs_from_fresh():
287 assert meta.is_flush_d.tolist()[:2] == [0, 0]
288
289
273 -def test_flashinfer_replayssm_scratch_metadata_fresh_decode():
290 +def test_spec_decode_single_token_chunk_synthesizes_acceptance_metadata():
291 + builder = _create_replayssm_builder(16, num_speculative_tokens=3)
292 + case = REPLAYSSM_BUILD_CASES["leftover_prompt_one_token_flush"]
293 +
294 + meta = _build(builder, case)
295 +
296 + assert meta.query_start_loc_d is not None
297 + assert meta.query_start_loc_d.tolist() == [0, 1]
298 + assert meta.num_accepted_tokens is not None
299 + assert meta.num_accepted_tokens.tolist() == [1]
300 +
301 +
302 +def test_flashinfer_replayssm_state_indices_are_stable_for_full_cudagraph():
303 checkpointing_ssu = pytest.importorskip("flashinfer.mamba.checkpointing_ssu")
304 if not hasattr(checkpointing_ssu, "allocate_checkpointing_ssu_scratch"):
276 - pytest.skip("FlashInfer does not expose ReplaySSM scratch allocation")
305 + pytest.skip("requires FlashInfer ReplaySSM autotuning support")
306
278 - builder = _create_replayssm_builder(16, mamba_backend=MambaBackendEnum.FLASHINFER)
279 - case = REPLAYSSM_BUILD_CASES["fresh_decode"]
280 - meta = _build(builder, case)
307 + builder = _create_replayssm_builder(
308 + 16,
309 + mamba_backend=MambaBackendEnum.FLASHINFER,
310 + num_speculative_tokens=3,
311 + )
312 + builder.compilation_config.cudagraph_mode = CUDAGraphMode.FULL
313
282 - assert meta.write_pos_d is None
283 - assert meta.is_flush_d is None
284 - assert meta.bc_pre_scratch is None
285 - assert meta.replayssm_scratch is not None
286 - assert [tensor.shape for tensor in meta.replayssm_scratch] == [
287 - (1, 1, 32, 8),
288 - (1, 1, 16),
289 - (1, 1, 32, 8),
290 - ]
314 + first = _build(
315 + builder,
316 + ReplaySSMBuildCase(
317 + seq_lens=[106, 106],
318 + query_lens=[1, 1],
319 + is_prefilling=[False, False],
320 + decode_base=[100, 100],
321 + buffer_len=16,
322 + expected_write_pos=[],
323 + expected_is_flush=[],
324 + ),
325 + )
326 + first_indices = first.replayssm_state_indices_d
327 + assert first_indices is not None
328 + assert first_indices.is_contiguous()
329 + first_ptr = first_indices.data_ptr()
330 +
331 + second = _build(
332 + builder,
333 + ReplaySSMBuildCase(
334 + seq_lens=[122, 122],
335 + query_lens=[1, 1],
336 + is_prefilling=[False, False],
337 + decode_base=[116, 116],
338 + buffer_len=16,
339 + expected_write_pos=[],
340 + expected_is_flush=[],
341 + ),
342 + )
343 + second_indices = second.replayssm_state_indices_d
344 + assert second_indices is not None
345 + assert second_indices.data_ptr() == first_ptr
346 + assert torch.equal(second_indices, second.state_indices_tensor_d[:, 0])
tests/v1/e2e/test_replayssm_decode.py
+118 -24
@@ -10,11 +10,16 @@ from vllm.v1.metrics.reader import Counter
10 from ...models.utils import check_logprobs_close
11 from ...utils import large_gpu_mark, multi_gpu_test
12
13 -# Mamba2 (Nemotron-3) hybrid.
13 +# Mamba2 (Nemotron-3) hybrids.
14 MAMBA2_MODEL = "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
15 +# FlashInfer 0.7.0's standard SSU cannot compile Nano's DIM=80 configuration.
16 +MAMBA2_FLASHINFER_MODEL = "nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4"
17 MODELS = [
18 pytest.param(MAMBA2_MODEL, marks=large_gpu_mark(min_gb=40)),
19 ]
20 +FLASHINFER_MODELS = [
21 + pytest.param(MAMBA2_FLASHINFER_MODEL, marks=large_gpu_mark(min_gb=40)),
22 +]
23
24 PROMPTS = [
25 "The capital of France is",
@@ -22,6 +27,16 @@ PROMPTS = [
27 ]
28
29
30 +@pytest.fixture(autouse=True)
31 +def _use_v1_model_runner_by_default(monkeypatch):
32 + # Triton ReplaySSM is V1-only. FlashInfer V2 tests override this locally.
33 + with monkeypatch.context() as patch:
34 + patch.setenv("VLLM_USE_V2_MODEL_RUNNER", "0")
35 + envs.disable_envs_cache()
36 + yield
37 + envs.disable_envs_cache()
38 +
39 +
40 def _check_replayssm_parity(
41 vllm_runner,
42 model_name,
@@ -29,17 +44,11 @@ def _check_replayssm_parity(
44 tensor_parallel_size=1,
45 mamba_backend: str = "triton",
46 name_1: str = "replayssm",
32 - require_v2: bool = False,
33 - monkeypatch: pytest.MonkeyPatch | None = None,
47 + expected_v2: bool | None = None,
48 ):
49 # Compare logprobs, not greedy ids: ReplaySSM's fp arithmetic can flip a
36 - # near-tie. Baseline and ReplaySSM run at the same TP, so TP numerics are
37 - # common-mode and only ReplaySSM varies.
38 - if require_v2:
39 - assert monkeypatch is not None
40 - monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1")
41 - envs.disable_envs_cache()
42 -
50 + # near-tie. Baseline and ReplaySSM use the same backend so only ReplaySSM
51 + # behavior varies.
52 common = dict(
53 max_model_len=1024,
54 trust_remote_code=True,
@@ -49,14 +58,17 @@ def _check_replayssm_parity(
58 mamba_backend=mamba_backend,
59 )
60 with vllm_runner(model_name, **common) as llm:
52 - if require_v2:
53 - assert llm.llm.llm_engine.vllm_config.use_v2_model_runner
61 + if expected_v2 is not None:
62 + assert llm.llm.llm_engine.vllm_config.use_v2_model_runner is expected_v2
63 baseline = llm.generate_greedy_logprobs(PROMPTS, max_tokens=32, num_logprobs=5)
64 with vllm_runner(
56 - model_name, use_replayssm=True, replayssm_buffer_len=16, **common
65 + model_name,
66 + use_replayssm=True,
67 + replayssm_buffer_len=16,
68 + **common,
69 ) as llm:
58 - if require_v2:
59 - assert llm.llm.llm_engine.vllm_config.use_v2_model_runner
70 + if expected_v2 is not None:
71 + assert llm.llm.llm_engine.vllm_config.use_v2_model_runner is expected_v2
72 replay = llm.generate_greedy_logprobs(PROMPTS, max_tokens=32, num_logprobs=5)
73
74 check_logprobs_close(
@@ -80,21 +92,103 @@ def test_replayssm_decode_matches_baseline_tp2(vllm_runner, model_name):
92 _check_replayssm_parity(vllm_runner, model_name, tensor_parallel_size=2)
93
94
83 -@pytest.mark.parametrize("model_name", MODELS)
84 -def test_replayssm_flashinfer_decode_matches_baseline_v2(
85 - vllm_runner, model_name, monkeypatch
95 +@pytest.mark.parametrize("model_name", FLASHINFER_MODELS)
96 +@pytest.mark.parametrize("use_v2_model_runner", [False, True], ids=["v1", "v2"])
97 +def test_replayssm_flashinfer_decode_matches_baseline(
98 + vllm_runner, model_name, monkeypatch, use_v2_model_runner
99 ):
87 - pytest.importorskip("flashinfer.mamba.checkpointing_ssu")
88 - _check_replayssm_parity(
89 - vllm_runner,
100 + try:
101 + with monkeypatch.context() as patch:
102 + patch.setenv("VLLM_USE_V2_MODEL_RUNNER", str(int(use_v2_model_runner)))
103 + envs.disable_envs_cache()
104 + _check_replayssm_parity(
105 + vllm_runner,
106 + model_name,
107 + mamba_backend="flashinfer",
108 + name_1="replayssm_flashinfer",
109 + expected_v2=use_v2_model_runner,
110 + )
111 + finally:
112 + # The context restores the environment before the final cache reset.
113 + envs.disable_envs_cache()
114 +
115 +
116 +@pytest.mark.parametrize("model_name", FLASHINFER_MODELS)
117 +def test_replayssm_flashinfer_spec_decode_matches_baseline(vllm_runner, model_name):
118 + common = dict(
119 + max_model_len=1024,
120 + trust_remote_code=True,
121 + enable_prefix_caching=False,
122 + mamba_cache_mode="none",
123 + speculative_config={
124 + "method": "ngram",
125 + "num_speculative_tokens": 3,
126 + "prompt_lookup_max": 3,
127 + },
128 + )
129 + with vllm_runner(model_name, mamba_backend="flashinfer", **common) as llm:
130 + baseline = llm.generate_greedy_logprobs(PROMPTS, max_tokens=32, num_logprobs=5)
131 + with vllm_runner(
132 model_name,
133 + use_replayssm=True,
134 + replayssm_buffer_len=16,
135 mamba_backend="flashinfer",
92 - name_1="replayssm_flashinfer_v2",
93 - require_v2=True,
94 - monkeypatch=monkeypatch,
136 + **common,
137 + ) as llm:
138 + replay = llm.generate_greedy_logprobs(PROMPTS, max_tokens=32, num_logprobs=5)
139 +
140 + check_logprobs_close(
141 + outputs_0_lst=baseline,
142 + outputs_1_lst=replay,
143 + name_0="baseline_spec",
144 + name_1="replayssm_flashinfer_spec",
145 )
146
147
148 +@multi_gpu_test(num_gpus=2)
149 +@large_gpu_mark(min_gb=40)
150 +@pytest.mark.parametrize("use_v2_model_runner", [False, True], ids=["v1", "v2"])
151 +def test_replayssm_flashinfer_mtp(vllm_runner, monkeypatch, use_v2_model_runner):
152 + common = dict(
153 + max_model_len=1024,
154 + trust_remote_code=True,
155 + enable_prefix_caching=False,
156 + mamba_cache_mode="none",
157 + mamba_backend="flashinfer",
158 + tensor_parallel_size=2,
159 + disable_log_stats=False,
160 + speculative_config={"method": "mtp", "num_speculative_tokens": 3},
161 + )
162 + try:
163 + with monkeypatch.context() as patch:
164 + patch.setenv("VLLM_USE_V2_MODEL_RUNNER", str(int(use_v2_model_runner)))
165 + envs.disable_envs_cache()
166 + with vllm_runner(
167 + MAMBA2_FLASHINFER_MODEL,
168 + use_replayssm=True,
169 + replayssm_buffer_len=16,
170 + **common,
171 + ) as llm:
172 + assert (
173 + llm.llm.llm_engine.vllm_config.use_v2_model_runner
174 + is use_v2_model_runner
175 + )
176 + outputs = llm.generate_greedy(PROMPTS, max_tokens=32)
177 + draft_count = sum(
178 + metric.value
179 + for metric in llm.llm.get_metrics()
180 + if isinstance(metric, Counter)
181 + and metric.name == "vllm:spec_decode_num_drafts"
182 + )
183 + finally:
184 + envs.disable_envs_cache()
185 +
186 + # At least one request must run past the 16-token replay window; another
187 + # may legitimately stop early on EOS.
188 + assert any(len(token_ids) > 16 for token_ids, _ in outputs)
189 + assert draft_count > 0
190 +
191 +
192 # Prefix spans several mamba blocks; prefix caching only reuses full blocks.
193 _PC_SENTENCE = (
194 "In a detailed survey of state space models, the authors compared many "
tests/v1/worker/test_utils.py
+14 -12
@@ -1260,6 +1260,8 @@ class _TestReplaySSMMixer(MambaMixer2):
1260 self.mamba_config = MambaConfig(backend=MambaBackendEnum.FLASHINFER)
1261 self._replayssm_ring_start = torch.empty(0, dtype=torch.int32)
1262 self._replayssm_prev_num_accepted = torch.empty(0, dtype=torch.int32)
1263 + self._replayssm_prev_query_len = torch.empty(0, dtype=torch.int32)
1264 + self._commits_replayssm_trackers = True
1265 self._updates_replayssm_trackers = True
1266
1267 def get_state_shape(self) -> tuple[tuple[int, ...], ...]:
@@ -1294,19 +1296,19 @@ def test_bind_kv_cache_shares_replayssm_trackers_by_cache_group(layers_only):
1296 else:
1297 bind_kv_cache(kv_cache, ctx, [], kv_cache_groups=kv_cache_groups)
1298
1297 - assert (
1298 - mixers[0]._replayssm_ring_start.data_ptr()
1299 - == mixers[2]._replayssm_ring_start.data_ptr()
1300 - )
1301 - assert (
1302 - mixers[0]._replayssm_prev_num_accepted.data_ptr()
1303 - == mixers[2]._replayssm_prev_num_accepted.data_ptr()
1299 + tracker_names = (
1300 + "_replayssm_ring_start",
1301 + "_replayssm_prev_num_accepted",
1302 + "_replayssm_prev_query_len",
1303 )
1305 - assert (
1306 - mixers[1]._replayssm_ring_start.data_ptr()
1307 - != mixers[0]._replayssm_ring_start.data_ptr()
1308 - )
1309 - # Group {0, 2} shares trackers; layer 2 (not 0) updates after both run.
1304 + for tracker_name in tracker_names:
1305 + group_tracker = getattr(mixers[0], tracker_name)
1306 + assert group_tracker.data_ptr() == getattr(mixers[2], tracker_name).data_ptr()
1307 + assert group_tracker.data_ptr() != getattr(mixers[1], tracker_name).data_ptr()
1308 + assert group_tracker.shape == (4,)
1309 + assert torch.count_nonzero(group_tracker) == 0
1310 +
1311 + assert [m._commits_replayssm_trackers for m in mixers] == [True, True, False]
1312 assert [m._updates_replayssm_trackers for m in mixers] == [False, True, True]
1313
1314
tools/pre_commit/test_tethering_allowlist.txt
-1
@@ -60,7 +60,6 @@ tests/models/test_language_model_cache_is_weak.py # no models_*.yaml job collec
60 tests/models/transformers/test_layer_registry.py # no models_*.yaml job collects this path
61
62 # tests/v1/ - the v1 subtree is otherwise covered by many `pytest v1/...` jobs.
63 -tests/v1/e2e/test_replayssm_decode.py # ReplaySSM decode test (#52506) wired into no job
63 tests/v1/ec_connector/integration/test_epd_correctness.py # run_epd_correctness_test.sh exists but no Buildkite job invokes it
64 tests/v1/ec_connector/integration/test_nixl_failure.py # 2-GPU + NIXL E/PD integration test (#55290); no Buildkite job collects ec_connector/integration/
65 tests/v1/kv_connector/nixl_integration/test_disagg_accuracy.py # run by intel_jobs/misc_intel.yaml's run_xpu_disagg_accuracy_test.sh, which names it via $EXP_ROOT (the script's own dir) - not statically resolvable
vllm/config/cache.py
+6 -5
@@ -204,15 +204,16 @@ class CacheConfig:
204 Mamba block size."""
205 replayssm_buffer_len: int = Field(default=16, gt=0)
206 """ReplaySSM logical history length B for Mamba2. Triton uses B physical
207 - rows and FlashInfer uses B+1. Kimi-K3 speculative decode does not use B.
208 - Default 16."""
207 + rows and FlashInfer uses B+T, where T is the target verification length.
208 + Kimi-K3 speculative decode does not use B. Default 16."""
209 use_replayssm: bool = False
210 """Use the ReplaySSM Mamba2 decode kernel: cache recent SSM inputs and skip
211 the per-step full-state store, writing the checkpoint back only on flush.
212 Requires mamba_cache_mode 'none' or 'align' (prefix caching) and the Triton
213 - or FlashInfer mamba backend; standard (non-speculative) decode only. In align
214 - mode flushes are most efficient when mamba_block_size is a multiple of
215 - replayssm_buffer_len, but this is not required."""
213 + or FlashInfer mamba backend. Mamba2 speculative decode requires FlashInfer
214 + and mamba_cache_mode 'none'. In align mode flushes are most efficient when
215 + mamba_block_size is a multiple of replayssm_buffer_len, but this is not
216 + required."""
217 use_kda_recoverssm: bool = field(default=False, init=False)
218 """Whether Kimi-K3 KDA uses RecoverSSM speculative decode."""
219
vllm/config/vllm.py
+41 -6
@@ -3458,19 +3458,35 @@ class VllmConfig:
3458 if not self.cache_config.use_replayssm:
3459 self.cache_config.use_kda_recoverssm = False
3460 return self
3461 - self.cache_config.use_kda_recoverssm = self.num_speculative_tokens > 0
3461 +
3462 + kda_architectures = (
3463 + "KimiLinearForCausalLM",
3464 + "KimiK3ForConditionalGeneration",
3465 + )
3466 + is_kda_model = (
3467 + self.model_config is not None
3468 + and self.model_config.architecture in kda_architectures
3469 + )
3470 + self.cache_config.use_kda_recoverssm = (
3471 + self.num_speculative_tokens > 0 and is_kda_model
3472 + )
3473 + use_mamba_replayssm_spec = (
3474 + self.num_speculative_tokens > 0 and not self.cache_config.use_kda_recoverssm
3475 + )
3476
3477 if self.model_config is not None and not self.model_config.supports_replayssm:
3478 raise ValueError(
3479 "--use-replayssm is not supported for architecture "
3480 f"{self.model_config.architecture!r}"
3481 )
3482 + if (
3483 + self.mamba_config.backend == MambaBackendEnum.FLASHINFER
3484 + and self.cache_config.replayssm_buffer_len > 16
3485 + ):
3486 + raise ValueError(
3487 + "FlashInfer ReplaySSM requires --replayssm-buffer-len <= 16"
3488 + )
3489 if self.cache_config.use_kda_recoverssm:
3469 - if self.model_config is not None and self.model_config.architecture not in (
3470 - "KimiLinearForCausalLM",
3471 - "KimiK3ForConditionalGeneration",
3472 - ):
3473 - raise ValueError("RecoverSSM is only supported for Kimi-K3 KDA")
3490 if self.mamba_config.enable_stochastic_rounding:
3491 raise ValueError(
3492 "RecoverSSM supports bfloat16/float32 "
@@ -3494,6 +3510,25 @@ class VllmConfig:
3510 )
3511 if self.mamba_config.backend != MambaBackendEnum.TRITON:
3512 raise ValueError("RecoverSSM requires --mamba-backend triton")
3513 + elif use_mamba_replayssm_spec:
3514 + if self.cache_config.mamba_cache_mode != "none":
3515 + raise ValueError(
3516 + "FlashInfer ReplaySSM speculative decoding requires "
3517 + "--mamba-cache-mode none"
3518 + )
3519 + query_len = 1 + self.num_speculative_tokens
3520 + if self.cache_config.replayssm_buffer_len < query_len:
3521 + raise ValueError(
3522 + "FlashInfer ReplaySSM speculative decoding requires "
3523 + "--replayssm-buffer-len >= 1 + num_speculative_tokens "
3524 + f"({query_len}); got "
3525 + f"{self.cache_config.replayssm_buffer_len}"
3526 + )
3527 + if self.mamba_config.backend != MambaBackendEnum.FLASHINFER:
3528 + raise ValueError(
3529 + "Mamba2 ReplaySSM speculative decoding requires "
3530 + "--mamba-backend flashinfer"
3531 + )
3532 elif self.cache_config.mamba_cache_mode == "all":
3533 raise ValueError(
3534 "--use-replayssm supports prefix caching only in align mode; "
vllm/model_executor/layers/mamba/abstract.py
+2 -2
@@ -74,11 +74,11 @@ class MambaBase(AttentionLayerBase):
74 mamba_type=self.mamba_type,
75 tp_replicated=self.is_kv_cache_tp_replicated,
76 mamba_cache_mode=vllm_config.cache_config.mamba_cache_mode,
77 - # RecoverSSM verifies the whole window off one checkpoint, so it
77 + # ReplaySSM verifies the whole window off one checkpoint, so it
78 # never writes the baseline's per-draft-token state slots.
79 num_speculative_blocks=(
80 0
81 - if vllm_config.cache_config.use_kda_recoverssm
81 + if vllm_config.cache_config.use_replayssm
82 else vllm_config.num_speculative_tokens
83 ),
84 )
vllm/model_executor/layers/mamba/mamba_mixer2.py
+100 -9
@@ -43,6 +43,7 @@ from vllm.model_executor.layers.mamba.ops.ssd_combined import (
43 mamba_chunk_scan_combined_varlen,
44 )
45 from vllm.model_executor.layers.mamba.ops.ssu_dispatch import (
46 + commit_replayssm_ring_trackers,
47 reset_replayssm_ring_trackers,
48 selective_state_update,
49 selective_state_update_replayssm_flashinfer,
@@ -69,6 +70,13 @@ from vllm.v1.kv_cache_interface import KVCacheGroupSpec
70
71 logger = init_logger(__name__)
72
73 +
74 +def _view_mtp_decode_tensor(
75 + tensor: torch.Tensor, decode_batch: int, spec_query_len: int
76 +) -> torch.Tensor:
77 + return tensor.view(decode_batch, spec_query_len, *tensor.shape[1:])
78 +
79 +
80 # Added by the IBM Team, 2024
81
82
@@ -536,6 +544,8 @@ class MambaMixer2(MambaBase, PluggableLayer):
544 self.kv_cache = tuple(torch.tensor([]) for _ in range(_n_state))
545 self._replayssm_ring_start = torch.empty(0, dtype=torch.int32)
546 self._replayssm_prev_num_accepted = torch.empty(0, dtype=torch.int32)
547 + self._replayssm_prev_query_len = torch.empty(0, dtype=torch.int32)
548 + self._commits_replayssm_trackers = True
549 self._updates_replayssm_trackers = True
550
551 self.num_spec = vllm_config.num_speculative_tokens
@@ -724,7 +734,8 @@ class MambaMixer2(MambaBase, PluggableLayer):
734 assert self.cache_config is not None
735 mamba_block_size = self.cache_config.mamba_block_size
736 is_mamba_cache_all = self.cache_config.mamba_cache_mode == "all"
727 - ring_start = prev_num_accepted = None
737 + use_spec_decode = self.num_spec > 0
738 + ring_start = prev_num_accepted = prev_query_len = None
739
740 attn_metadata: AttentionMetadata | None = None
741 if attn_metadata_raw is not None:
@@ -746,6 +757,7 @@ class MambaMixer2(MambaBase, PluggableLayer):
757 if self.mamba_config.backend == MambaBackendEnum.FLASHINFER:
758 ring_start = self._replayssm_ring_start
759 prev_num_accepted = self._replayssm_prev_num_accepted
760 + prev_query_len = self._replayssm_prev_query_len
761 else:
762 x_cache = dt_cache = B_cache = None
763 has_initial_states_p = attn_metadata.has_initial_states_p
@@ -757,6 +769,7 @@ class MambaMixer2(MambaBase, PluggableLayer):
769 last_chunk_indices_p = attn_metadata.last_chunk_indices_p
770 state_indices_tensor_p = attn_metadata.state_indices_tensor_p
771 state_indices_tensor_d = attn_metadata.state_indices_tensor_d
772 + replayssm_state_indices_d = attn_metadata.replayssm_state_indices_d
773 num_accepted_tokens = attn_metadata.num_accepted_tokens
774 query_start_loc_d = attn_metadata.query_start_loc_d
775 num_decodes = attn_metadata.num_decodes
@@ -996,6 +1009,7 @@ class MambaMixer2(MambaBase, PluggableLayer):
1009 reset_replayssm_ring_trackers(
1010 ring_start,
1011 prev_num_accepted,
1012 + prev_query_len,
1013 state_indices_tensor_p,
1014 )
1015
@@ -1003,7 +1017,7 @@ class MambaMixer2(MambaBase, PluggableLayer):
1017 if has_decode:
1018 assert state_indices_tensor_d is not None
1019 if is_mamba_cache_all:
1006 - if self.num_spec > 0:
1020 + if use_spec_decode:
1021 assert block_idx_last_scheduled_token_prev_step_d is not None
1022 input_indices = (
1023 block_idx_last_scheduled_token_prev_step_d.unsqueeze(1)
@@ -1031,6 +1045,34 @@ class MambaMixer2(MambaBase, PluggableLayer):
1045 state_indices_tensor_d_input = state_indices_tensor_d
1046 state_indices_tensor_d_output = state_indices_tensor_d
1047
1048 + if (
1049 + self.use_replayssm
1050 + and self.mamba_config.backend == MambaBackendEnum.FLASHINFER
1051 + and use_spec_decode
1052 + and self._commits_replayssm_trackers
1053 + ):
1054 + assert ring_start is not None
1055 + assert prev_num_accepted is not None
1056 + assert prev_query_len is not None
1057 + assert replayssm_state_indices_d is not None
1058 + assert num_accepted_tokens is not None
1059 + assert query_start_loc_d is not None
1060 + assert x_cache is not None
1061 + assert self.replayssm_buffer_len is not None
1062 + # The previous MTP query's accepted prefix is known only now.
1063 + # Commit it once, before this cache group's layers evaluate the
1064 + # current speculative query with the shared ring positions.
1065 + commit_replayssm_ring_trackers(
1066 + ring_start,
1067 + prev_num_accepted,
1068 + prev_query_len,
1069 + replayssm_state_indices_d,
1070 + num_accepted_tokens,
1071 + query_start_loc_d,
1072 + logical_window=self.replayssm_buffer_len,
1073 + ring_buffer_len=x_cache.size(2),
1074 + )
1075 +
1076 # 2. Convolution sequence transformation
1077 hidden_states_B_C_d = causal_conv1d_update(
1078 hidden_states_B_C_d,
@@ -1043,7 +1085,13 @@ class MambaMixer2(MambaBase, PluggableLayer):
1085 initial_state_idx=block_idx_last_computed_token_d,
1086 num_accepted_tokens=num_accepted_tokens,
1087 query_start_loc=query_start_loc_d,
1046 - max_query_len=state_indices_tensor_d.size(-1),
1088 + # ReplaySSM keeps one physical state block while a speculative
1089 + # decode call still processes the full target + draft window.
1090 + max_query_len=(
1091 + 1 + self.num_spec
1092 + if self.use_replayssm and use_spec_decode
1093 + else state_indices_tensor_d.size(-1)
1094 + ),
1095 )
1096
1097 hidden_states_d, B_d, C_d = self.split_hidden_states_B_C_fn(
@@ -1079,7 +1127,35 @@ class MambaMixer2(MambaBase, PluggableLayer):
1127 if self.mamba_config.backend == MambaBackendEnum.FLASHINFER:
1128 assert ring_start is not None
1129 assert prev_num_accepted is not None
1130 + assert prev_query_len is not None
1131 assert attn_metadata.replayssm_scratch is not None
1132 + fi_cu_seqlens = query_start_loc_d
1133 + fi_max_seqlen = None
1134 + if use_spec_decode:
1135 + spec_query_len = 1 + self.num_spec
1136 + fi_max_seqlen = spec_query_len
1137 + assert replayssm_state_indices_d is not None
1138 + decode_batch = replayssm_state_indices_d.size(0)
1139 + if num_decode_tokens == decode_batch * spec_query_len:
1140 + hidden_states_d = _view_mtp_decode_tensor(
1141 + hidden_states_d, decode_batch, spec_query_len
1142 + )
1143 + dt_d = _view_mtp_decode_tensor(
1144 + dt_d, decode_batch, spec_query_len
1145 + )
1146 + B_d = _view_mtp_decode_tensor(
1147 + B_d, decode_batch, spec_query_len
1148 + )
1149 + C_d = _view_mtp_decode_tensor(
1150 + C_d, decode_batch, spec_query_len
1151 + )
1152 + preallocated_ssm_out_d = _view_mtp_decode_tensor(
1153 + preallocated_ssm_out_d,
1154 + decode_batch,
1155 + spec_query_len,
1156 + )
1157 + fi_cu_seqlens = None
1158 + fi_max_seqlen = None
1159 selective_state_update_replayssm_flashinfer(
1160 ssm_state,
1161 hidden_states_d,
@@ -1093,19 +1169,25 @@ class MambaMixer2(MambaBase, PluggableLayer):
1169 dt_cache,
1170 ring_start,
1171 prev_num_accepted,
1172 + prev_query_len,
1173 logical_window=self.replayssm_buffer_len,
1174 D=D_d,
1175 dt_bias=dt_bias,
1176 dt_softplus=True,
1100 - state_batch_indices=state_indices_tensor_d_input,
1177 + state_batch_indices=replayssm_state_indices_d,
1178 scratch=attn_metadata.replayssm_scratch,
1102 - update_trackers=self._updates_replayssm_trackers,
1179 + update_trackers=(
1180 + self._updates_replayssm_trackers and not use_spec_decode
1181 + ),
1182 enable_stochastic_rounding=(
1183 self.mamba_config.enable_stochastic_rounding
1184 ),
1185 stochastic_rounding_philox_rounds=(
1186 self.mamba_config.stochastic_rounding_philox_rounds
1187 ),
1188 + cu_seqlens=fi_cu_seqlens,
1189 + max_seqlen=fi_max_seqlen,
1190 + enable_pdl=False,
1191 )
1192 else:
1193 selective_state_update_replayssm_output_only(
@@ -1187,6 +1269,7 @@ class MambaMixer2(MambaBase, PluggableLayer):
1269 tp_world_size=tp_world_size,
1270 logical_window=self.replayssm_buffer_len,
1271 backend=self.mamba_config.backend,
1272 + num_speculative_tokens=self.num_spec,
1273 )
1274 return base_shape
1275
@@ -1204,9 +1287,10 @@ def share_replayssm_ring_trackers(
1287
1288 Layers backed by one KV-cache group use the same physical block indices and
1289 can therefore share cursors. Different KV-cache groups may assign different
1207 - block indices to the same request and must keep separate cursor tensors.
1208 - The final local layer in each group advances its cursors after every layer
1209 - in that group has consumed the previous values.
1290 + block indices to the same request and must keep separate cursor tensors. For
1291 + speculative decode, the first local layer commits the preceding acceptance;
1292 + for standard decode, the final local layer advances after all layers consume
1293 + the previous values.
1294 """
1295 replayssm_mixers: dict[str, MambaMixer2] = {}
1296 for layer_name in ordered_layer_names:
@@ -1232,6 +1316,7 @@ def share_replayssm_ring_trackers(
1316 groups_by_namespace.setdefault(namespace, []).append(layer_name)
1317
1318 for group_layer_names in groups_by_namespace.values():
1319 + first_layer_name = group_layer_names[0]
1320 last_layer_name = group_layer_names[-1]
1321
1322 first_mixer = replayssm_mixers[group_layer_names[0]]
@@ -1246,11 +1331,17 @@ def share_replayssm_ring_trackers(
1331
1332 ring_start = torch.zeros(num_blocks, dtype=torch.int32, device=device)
1333 prev_num_accepted = torch.zeros_like(ring_start)
1334 + prev_query_len = torch.zeros_like(ring_start)
1335 for layer_name in group_layer_names:
1336 mixer = replayssm_mixers[layer_name]
1337 mixer._replayssm_ring_start = ring_start
1338 mixer._replayssm_prev_num_accepted = prev_num_accepted
1253 - mixer._updates_replayssm_trackers = layer_name == last_layer_name
1339 + mixer._replayssm_prev_query_len = prev_query_len
1340 + mixer._commits_replayssm_trackers = False
1341 + mixer._updates_replayssm_trackers = False
1342 +
1343 + replayssm_mixers[first_layer_name]._commits_replayssm_trackers = True
1344 + replayssm_mixers[last_layer_name]._updates_replayssm_trackers = True
1345
1346
1347 def mamba_mixer2(
vllm/model_executor/layers/mamba/mamba_utils.py
+3 -2
@@ -227,6 +227,7 @@ class MambaStateShapeCalculator:
227 tp_world_size: int,
228 logical_window: int,
229 backend: MambaBackendEnum,
230 + num_speculative_tokens: int = 0,
231 ) -> tuple[
232 tuple[int, int],
233 tuple[int, int, int],
@@ -241,8 +242,8 @@ class MambaStateShapeCalculator:
242 """
243 ring_buffer_len = logical_window
244 if backend == MambaBackendEnum.FLASHINFER:
244 - # FlashInfer keeps the live window and appended token together.
245 - ring_buffer_len += 1
245 + # FlashInfer keeps the live window and current verify window together.
246 + ring_buffer_len += 1 + num_speculative_tokens
247 local_nheads, head_dim, state_size = base_shapes[1]
248 local_ngroups = divide(n_groups, tp_world_size)
249 return (
vllm/model_executor/layers/mamba/ops/ssu_dispatch.py
+137 -6
@@ -31,6 +31,7 @@ logger = init_logger(__name__)
31 def _update_replayssm_ring_trackers_kernel(
32 ring_start,
33 prev_num_accepted,
34 + prev_query_len,
35 state_batch_indices,
36 state_batch_indices_stride,
37 n_slots,
@@ -52,6 +53,9 @@ def _update_replayssm_ring_trackers_kernel(
53 if RESET:
54 tl.store(ring_start + slots, 0, mask=valid)
55 tl.store(prev_num_accepted + slots, 0, mask=valid)
56 + # MTP alone reads prev_query_len. Standard decode deliberately leaves
57 + # it unchanged; reset clears the pending MTP window after prefill.
58 + tl.store(prev_query_len + slots, 0, mask=valid)
59 else:
60 prev = tl.load(prev_num_accepted + slots, mask=valid, other=0)
61 start = tl.load(ring_start + slots, mask=valid, other=0)
@@ -66,9 +70,70 @@ def _update_replayssm_ring_trackers_kernel(
70 tl.store(prev_num_accepted + slots, next_prev, mask=valid)
71
72
73 +@triton.jit(
74 + do_not_specialize=["n_slots", "state_batch_indices_stride"],
75 + do_not_specialize_on_alignment=["state_batch_indices"],
76 +)
77 +def _commit_replayssm_ring_trackers_kernel(
78 + ring_start,
79 + prev_num_accepted,
80 + prev_query_len,
81 + state_batch_indices,
82 + num_accepted_tokens,
83 + query_start_loc,
84 + state_batch_indices_stride,
85 + n_slots,
86 + num_states,
87 + logical_window: tl.constexpr,
88 + ring_buffer_len: tl.constexpr,
89 + pad_slot_id: tl.constexpr,
90 + BLOCK: tl.constexpr,
91 +) -> None:
92 + offsets = tl.program_id(0) * BLOCK + tl.arange(0, BLOCK)
93 + mask = offsets < n_slots
94 + slots = tl.load(
95 + state_batch_indices + offsets * state_batch_indices_stride,
96 + mask=mask,
97 + other=pad_slot_id,
98 + )
99 + valid = mask & (slots != pad_slot_id) & (slots >= 0) & (slots < num_states)
100 + accepted_since_checkpoint = tl.load(prev_num_accepted + slots, mask=valid, other=0)
101 + current_ring_start = tl.load(ring_start + slots, mask=valid, other=0)
102 + previous_speculative_query_len = tl.load(
103 + prev_query_len + slots, mask=valid, other=0
104 + )
105 + accepted_from_previous_query = tl.load(
106 + num_accepted_tokens + offsets, mask=mask, other=0
107 + )
108 + must_checkpoint = (previous_speculative_query_len > 0) & (
109 + accepted_since_checkpoint + previous_speculative_query_len > logical_window
110 + )
111 + next_start = tl.where(
112 + must_checkpoint,
113 + (current_ring_start + accepted_since_checkpoint) % ring_buffer_len,
114 + current_ring_start,
115 + )
116 + next_prev = tl.where(
117 + previous_speculative_query_len == 0,
118 + 0,
119 + tl.where(
120 + must_checkpoint,
121 + accepted_from_previous_query,
122 + accepted_since_checkpoint + accepted_from_previous_query,
123 + ),
124 + )
125 + current_speculative_query_len = tl.load(
126 + query_start_loc + offsets + 1, mask=mask, other=0
127 + ) - tl.load(query_start_loc + offsets, mask=mask, other=0)
128 + tl.store(ring_start + slots, next_start, mask=valid)
129 + tl.store(prev_num_accepted + slots, next_prev, mask=valid)
130 + tl.store(prev_query_len + slots, current_speculative_query_len, mask=valid)
131 +
132 +
133 def update_replayssm_ring_trackers(
134 ring_start: torch.Tensor,
135 prev_num_accepted: torch.Tensor,
136 + prev_query_len: torch.Tensor,
137 state_batch_indices: torch.Tensor,
138 logical_window: int | None = None,
139 ring_buffer_len: int | None = None,
@@ -90,10 +155,15 @@ def update_replayssm_ring_trackers(
155 _update_replayssm_ring_trackers_kernel[(triton.cdiv(n_slots, block),)](
156 ring_start,
157 prev_num_accepted,
158 + prev_query_len,
159 state_batch_indices,
160 state_batch_indices.stride(0),
161 n_slots,
96 - min(ring_start.numel(), prev_num_accepted.numel()),
162 + min(
163 + ring_start.numel(),
164 + prev_num_accepted.numel(),
165 + prev_query_len.numel(),
166 + ),
167 logical_window,
168 ring_buffer_len,
169 pad_slot_id,
@@ -105,6 +175,7 @@ def update_replayssm_ring_trackers(
175 def reset_replayssm_ring_trackers(
176 ring_start: torch.Tensor,
177 prev_num_accepted: torch.Tensor,
178 + prev_query_len: torch.Tensor,
179 state_batch_indices: torch.Tensor,
180 pad_slot_id: int = NULL_BLOCK_ID,
181 ) -> None:
@@ -112,11 +183,62 @@ def reset_replayssm_ring_trackers(
183 update_replayssm_ring_trackers(
184 ring_start,
185 prev_num_accepted,
186 + prev_query_len,
187 state_batch_indices,
188 pad_slot_id=pad_slot_id,
189 )
190
191
192 +def commit_replayssm_ring_trackers(
193 + ring_start: torch.Tensor,
194 + prev_num_accepted: torch.Tensor,
195 + prev_query_len: torch.Tensor,
196 + state_batch_indices: torch.Tensor,
197 + num_accepted_tokens: torch.Tensor,
198 + query_start_loc: torch.Tensor,
199 + logical_window: int,
200 + ring_buffer_len: int,
201 + pad_slot_id: int = NULL_BLOCK_ID,
202 +) -> None:
203 + """Commit the preceding speculative window and record the current one.
204 +
205 + MTP evaluates a target token and its draft tokens together, but the number
206 + accepted from that query is available only on the next forward pass. This
207 + function then advances the per-request ring by the accepted prefix and
208 + records the current query length for the following pass. A zero
209 + ``prev_query_len`` means that reset/prefill left no prior MTP query to
210 + commit; slot validity is handled independently by the kernel mask.
211 +
212 + Standard single-token decode needs no delayed commit: its ReplaySSM kernel
213 + advances the shared ring trackers directly after every token.
214 + """
215 + if state_batch_indices.dim() > 1:
216 + state_batch_indices = state_batch_indices[:, 0]
217 + n_slots = state_batch_indices.numel()
218 + if n_slots == 0:
219 + return
220 + block = 128
221 + _commit_replayssm_ring_trackers_kernel[(triton.cdiv(n_slots, block),)](
222 + ring_start,
223 + prev_num_accepted,
224 + prev_query_len,
225 + state_batch_indices,
226 + num_accepted_tokens,
227 + query_start_loc,
228 + state_batch_indices.stride(0),
229 + n_slots,
230 + min(
231 + ring_start.numel(),
232 + prev_num_accepted.numel(),
233 + prev_query_len.numel(),
234 + ),
235 + logical_window,
236 + ring_buffer_len,
237 + pad_slot_id,
238 + BLOCK=block,
239 + )
240 +
241 +
242 class MambaSSUBackend(ABC):
243 """Abstract base class for Mamba SSU backends."""
244
@@ -385,6 +507,7 @@ def selective_state_update_replayssm_flashinfer(
507 dt_cache: torch.Tensor,
508 ring_start: torch.Tensor,
509 prev_num_accepted_tokens: torch.Tensor,
510 + prev_query_len: torch.Tensor,
511 logical_window: int,
512 D: torch.Tensor | None = None,
513 dt_bias: torch.Tensor | None = None,
@@ -395,6 +518,9 @@ def selective_state_update_replayssm_flashinfer(
518 update_trackers: bool = True,
519 enable_stochastic_rounding: bool = False,
520 stochastic_rounding_philox_rounds: int = 0,
521 + cu_seqlens: torch.Tensor | None = None,
522 + max_seqlen: int | None = None,
523 + enable_pdl: bool = False,
524 ) -> torch.Tensor:
525 """Run FlashInfer checkpointing SSU and optionally advance shared trackers."""
526 if _flashinfer_replayssm_kernel is None:
@@ -404,11 +530,12 @@ def selective_state_update_replayssm_flashinfer(
530 )
531
532 if x.dim() == 3:
407 - x = x.unsqueeze(1)
408 - dt = dt.unsqueeze(1)
409 - B = B.unsqueeze(1)
410 - C = C.unsqueeze(1)
411 - out = out.unsqueeze(1)
533 + dim = 0 if cu_seqlens is not None else 1
534 + x = x.unsqueeze(dim)
535 + dt = dt.unsqueeze(dim)
536 + B = B.unsqueeze(dim)
537 + C = C.unsqueeze(dim)
538 + out = out.unsqueeze(dim)
539
540 indices = state_batch_indices
541 if indices is not None and indices.dim() > 1:
@@ -443,6 +570,9 @@ def selective_state_update_replayssm_flashinfer(
570 pad_slot_id=null_block_id,
571 rand_seed=rand_seed,
572 philox_rounds=stochastic_rounding_philox_rounds or 10,
573 + cu_seqlens=cu_seqlens,
574 + max_seqlen=max_seqlen,
575 + enable_pdl=enable_pdl,
576 cb_scaled=cb_scaled,
577 cumAdt_vec=cumAdt_vec,
578 cb_old=cb_old,
@@ -451,6 +581,7 @@ def selective_state_update_replayssm_flashinfer(
581 update_replayssm_ring_trackers(
582 ring_start,
583 prev_num_accepted_tokens,
584 + prev_query_len,
585 indices,
586 logical_window=logical_window,
587 ring_buffer_len=x_cache.size(2),
vllm/model_executor/models/nemotron_h.py
+1
@@ -815,6 +815,7 @@ class NemotronHForCausalLM(
815 tp_world_size=parallel_config.tensor_parallel_size,
816 logical_window=cache_config.replayssm_buffer_len,
817 backend=vllm_config.mamba_config.backend,
818 + num_speculative_tokens=vllm_config.num_speculative_tokens,
819 )
820 return base_shape
821
vllm/model_executor/warmup/replayssm_warmup.py
+30 -7
@@ -21,6 +21,7 @@ logger = init_logger(__name__)
21
22 def _replayssm_autotune_kwargs(
23 runner: "GPUModelRunner",
24 + max_token_prefill_kwargs: dict[str, Any],
25 ) -> tuple[int, dict[str, Any]] | None:
26 config = runner.vllm_config
27 if not (
@@ -46,11 +47,16 @@ def _replayssm_autotune_kwargs(
47 runner.max_num_tokens // query_len,
48 runner.kv_cache_config.num_blocks - 1,
49 )
50 + if max_num_reqs <= 0:
51 + logger.warning_once(
52 + "Skipping FlashInfer ReplaySSM autotuning because no non-padding "
53 + "state slot is available."
54 + )
55 + return None
56 +
57 decode_kwargs = {
58 + **max_token_prefill_kwargs,
59 "num_tokens": max_num_reqs * query_len,
51 - "skip_eplb": True,
52 - "is_profile": True,
53 - "randomize_inputs": True,
60 "uniform_decode": True,
61 }
62 if config.use_v2_model_runner:
@@ -75,18 +81,22 @@ def _temporary_replayssm_autotune_state(
81 )
82
83 reset_tensors: dict[int, torch.Tensor] = {}
78 - tracker_specs: dict[int, tuple[torch.Tensor, torch.Tensor, int, int]] = {}
84 + tracker_specs: dict[
85 + int, tuple[torch.Tensor, torch.Tensor, torch.Tensor, int, int]
86 + ] = {}
87 for module in runner.get_model().modules():
88 if not isinstance(module, MambaMixer2) or not module.use_replayssm:
89 continue
90 assert module.replayssm_buffer_len is not None
91 ring_start = module._replayssm_ring_start
92 prev_num_accepted = module._replayssm_prev_num_accepted
93 + prev_query_len = module._replayssm_prev_query_len
94 tracker_specs.setdefault(
95 ring_start.data_ptr(),
96 (
97 ring_start,
98 prev_num_accepted,
99 + prev_query_len,
100 module.replayssm_buffer_len,
101 module.kv_cache[2].size(2),
102 ),
@@ -95,6 +105,7 @@ def _temporary_replayssm_autotune_state(
105 *module.kv_cache,
106 ring_start,
107 prev_num_accepted,
108 + prev_query_len,
109 )
110 for tensor in tensors:
111 if tensor.numel():
@@ -121,20 +132,26 @@ def _temporary_replayssm_autotune_state(
132 for (
133 ring_start,
134 prev_num_accepted,
135 + prev_query_len,
136 logical_window,
137 ring_buffer_len,
138 ) in tracker_specs.values():
139 # Compile reset (prefill) and advance (decode) before inference.
140 # The final reset leaves the decode tuning run in a clean state.
129 - reset_replayssm_ring_trackers(ring_start, prev_num_accepted, state_slots)
141 + reset_replayssm_ring_trackers(
142 + ring_start, prev_num_accepted, prev_query_len, state_slots
143 + )
144 update_replayssm_ring_trackers(
145 ring_start,
146 prev_num_accepted,
147 + prev_query_len,
148 state_slots,
149 logical_window,
150 ring_buffer_len,
151 )
137 - reset_replayssm_ring_trackers(ring_start, prev_num_accepted, state_slots)
152 + reset_replayssm_ring_trackers(
153 + ring_start, prev_num_accepted, prev_query_len, state_slots
154 + )
155
156 try:
157 yield
@@ -151,7 +168,13 @@ def _temporary_replayssm_autotune_state(
168
169
170 def replayssm_autotune_warmup(runner: "GPUModelRunner") -> None:
154 - autotune = _replayssm_autotune_kwargs(runner)
171 + max_token_prefill_kwargs = {
172 + "num_tokens": runner.scheduler_config.max_num_batched_tokens,
173 + "skip_eplb": True,
174 + "is_profile": True,
175 + "randomize_inputs": True,
176 + }
177 + autotune = _replayssm_autotune_kwargs(runner, max_token_prefill_kwargs)
178 if autotune is None:
179 return
180 max_num_reqs, decode_kwargs = autotune
vllm/v1/attention/backends/mamba_attn.py
+35 -7
@@ -87,6 +87,8 @@ class BaseMambaAttentionMetadata:
87 bc_pre_scratch: torch.Tensor | None = None
88 # ReplaySSM — FlashInfer checkpointing_ssu two-kernel scratch.
89 replayssm_scratch: tuple[torch.Tensor, torch.Tensor, torch.Tensor] | None = None
90 + # Contiguous cache-slot indices shared by all FlashInfer ReplaySSM layers.
91 + replayssm_state_indices_d: torch.Tensor | None = None
92
93
94 class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
@@ -182,6 +184,7 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
184 self.decode_replayssm_scratch: (
185 tuple[torch.Tensor, torch.Tensor, torch.Tensor] | None
186 ) = None
187 + self.decode_replayssm_state_indices_d: torch.Tensor | None = None
188 # ReplaySSM CUDA-graph buffers for the selected backend.
189 if self.use_replayssm and not self.use_flashinfer_replayssm:
190 self.decode_write_pos_d: torch.Tensor = torch.empty(
@@ -218,11 +221,17 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
221 self.decode_replayssm_scratch = allocate_checkpointing_ssu_scratch(
222 batch_size=scheduler_config.max_num_seqs,
223 num_heads=nheads,
221 - num_predicted_tokens=1,
224 + num_predicted_tokens=1 + self.num_spec_tokens,
225 max_window=self.replayssm_buffer_len,
226 dtype=vllm_config.model_config.dtype,
227 device=device,
228 )
229 + # Full CUDA graphs retain capture-time tensor addresses. Keep the
230 + # contiguous first-column view used by FlashInfer in a persistent
231 + # buffer and refresh its contents before each replay.
232 + self.decode_replayssm_state_indices_d = torch.empty(
233 + (self.decode_cudagraph_max_bs,), dtype=torch.int32, device=device
234 + )
235
236 self._init_reorder_batch_threshold(1, self.use_spec_decode)
237 if self.use_spec_decode:
@@ -592,13 +601,16 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
601 ]
602 state_indices_tensor_p = state_indices_tensor_p[:, 0]
603
595 - # Sometimes even with specdec enabled we get single-token prefill chunks that
596 - # should be treated as decodes but don't have num_accepted_tokens set.
597 - # These should be fine to process as non-spec decodes since there's only
598 - # one token, so no risk of placing accepted tokens in the wrong slot.
599 - if num_decodes > 0 and self.use_spec_decode and num_accepted_tokens is not None:
604 + if num_decodes > 0 and self.use_spec_decode:
605 query_start_loc_d = common_attn_metadata.query_start_loc[: num_decodes + 1]
601 - num_accepted_tokens = num_accepted_tokens[:num_decodes]
606 + if num_accepted_tokens is None:
607 + # Single-token prefill chunks can be reclassified as decodes before
608 + # speculative decoding has produced acceptance counts. Treat each
609 + # token as accepted so recurrent state and ReplaySSM trackers follow
610 + # the normal speculative-decode path.
611 + num_accepted_tokens = torch.diff(query_start_loc_d)
612 + else:
613 + num_accepted_tokens = num_accepted_tokens[:num_decodes]
614
615 if num_prefills > 0:
616 if num_computed_tokens is None:
@@ -788,6 +800,7 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
800 is_flush_d = metadata.is_flush_d
801 bc_pre_scratch = metadata.bc_pre_scratch
802 replayssm_scratch = metadata.replayssm_scratch
803 + replayssm_state_indices_d = None
804 if (
805 metadata.num_prefills == 0
806 and metadata.num_decodes <= self.decode_cudagraph_max_bs
@@ -876,6 +889,20 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
889 cumAdt_vec[:padded_bs],
890 cb_old[:padded_bs],
891 )
892 + assert self.decode_replayssm_state_indices_d is not None
893 + self.decode_replayssm_state_indices_d[:padded_bs].copy_(
894 + state_indices_tensor_d[:, 0], non_blocking=True
895 + )
896 + replayssm_state_indices_d = self.decode_replayssm_state_indices_d[
897 + :padded_bs
898 + ]
899 +
900 + if (
901 + self.use_flashinfer_replayssm
902 + and state_indices_tensor_d is not None
903 + and replayssm_state_indices_d is None
904 + ):
905 + replayssm_state_indices_d = state_indices_tensor_d[:, 0].contiguous()
906
907 return replace(
908 metadata,
@@ -886,6 +913,7 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
913 is_flush_d=is_flush_d,
914 bc_pre_scratch=bc_pre_scratch,
915 replayssm_scratch=replayssm_scratch,
916 + replayssm_state_indices_d=replayssm_state_indices_d,
917 block_idx_last_scheduled_token=block_idx_last_scheduled_token,
918 block_idx_last_computed_token=block_idx_last_computed_token,
919 block_idx_last_scheduled_token_prev_step=(