main
py 346 lines 11.2 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Mamba2 ReplaySSM decode write-position derivation in
4 BaseMambaAttentionMetadataBuilder: write_pos and is_flush computed from the
5 per-request ring origin (replayssm_decode_base) and num_computed.
6 """
7
8 from dataclasses import dataclass
9
10 import pytest
11 import torch
12
13 from tests.v1.attention.utils import (
14 BatchSpec,
15 MockMambaBuilder,
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
24 BLOCK_SIZE = 16
25 DEVICE = torch.device("cpu")
26
27
28 @dataclass
29 class ReplaySSMBuildCase:
30 """A decode batch and its expected per-row write_pos / is_flush.
31
32 num_computed = seq_len - query_len; write_pos =
33 (num_computed - decode_base) % buffer_len; is_flush = write_pos ==
34 buffer_len - 1 (or a forced one-token flush when num_computed < decode_base).
35 """
36
37 seq_lens: list[int]
38 query_lens: list[int]
39 is_prefilling: list[bool]
40 decode_base: list[int]
41 buffer_len: int
42 expected_write_pos: list[int]
43 expected_is_flush: list[int]
44 mamba_cache_mode: str = "none"
45
46
47 REPLAYSSM_BUILD_CASES = {
48 # decode_base == num_prompt (fresh request).
49 "fresh_decode": ReplaySSMBuildCase(
50 seq_lens=[106],
51 query_lens=[1],
52 is_prefilling=[False],
53 decode_base=[100],
54 buffer_len=16,
55 expected_write_pos=[5],
56 expected_is_flush=[0],
57 ),
58 # decode_base > num_prompt anchors write_pos at the resume point.
59 "resumed_reanchors_to_zero": ReplaySSMBuildCase(
60 seq_lens=[106],
61 query_lens=[1],
62 is_prefilling=[False],
63 decode_base=[105],
64 buffer_len=16,
65 expected_write_pos=[0],
66 expected_is_flush=[0],
67 ),
68 # write_pos == buffer_len - 1 flushes.
69 "flush_boundary": ReplaySSMBuildCase(
70 seq_lens=[116],
71 query_lens=[1],
72 is_prefilling=[False],
73 decode_base=[100],
74 buffer_len=16,
75 expected_write_pos=[15],
76 expected_is_flush=[1],
77 ),
78 # Resumed request landing on a flush boundary.
79 "resumed_flush_boundary": ReplaySSMBuildCase(
80 seq_lens=[121],
81 query_lens=[1],
82 is_prefilling=[False],
83 decode_base=[105],
84 buffer_len=16,
85 expected_write_pos=[15],
86 expected_is_flush=[1],
87 ),
88 # Per-row write_pos / is_flush are independent.
89 "mixed_rows": ReplaySSMBuildCase(
90 seq_lens=[104, 106, 216],
91 query_lens=[1, 1, 1],
92 is_prefilling=[False, False, False],
93 decode_base=[100, 105, 200],
94 buffer_len=16,
95 expected_write_pos=[3, 0, 15],
96 expected_is_flush=[0, 0, 1],
97 ),
98 # write_pos wraps within the buffer (6 % 4 == 2).
99 "small_buffer_wrap": ReplaySSMBuildCase(
100 seq_lens=[112],
101 query_lens=[1],
102 is_prefilling=[False],
103 decode_base=[105],
104 buffer_len=4,
105 expected_write_pos=[2],
106 expected_is_flush=[0],
107 ),
108 # Single-token prefill-as-decode still in the prompt (num_computed <
109 # decode_base): forced one-token flush.
110 "leftover_prompt_one_token_flush": ReplaySSMBuildCase(
111 seq_lens=[100],
112 query_lens=[1],
113 is_prefilling=[True],
114 decode_base=[100],
115 buffer_len=16,
116 expected_write_pos=[0],
117 expected_is_flush=[1],
118 ),
119 # Align mode (block_size 16). Past the first boundary the ring re-anchors at
120 # the block start: num_computed 117 -> block_start 112, write_pos 5 (vs 1 in
121 # none mode).
122 "align_reanchor_past_boundary": ReplaySSMBuildCase(
123 seq_lens=[118],
124 query_lens=[1],
125 is_prefilling=[False],
126 decode_base=[100],
127 buffer_len=16,
128 expected_write_pos=[5],
129 expected_is_flush=[0],
130 mamba_cache_mode="align",
131 ),
132 # First-block boundary (num_computed+1 == 112) forces a flush even though
133 # write_pos (11) != buffer_len - 1.
134 "align_first_block_boundary_flush": ReplaySSMBuildCase(
135 seq_lens=[112],
136 query_lens=[1],
137 is_prefilling=[False],
138 decode_base=[100],
139 buffer_len=16,
140 expected_write_pos=[11],
141 expected_is_flush=[1],
142 mamba_cache_mode="align",
143 ),
144 # First step of a new block re-anchors write_pos to 0.
145 "align_new_block_start_zero": ReplaySSMBuildCase(
146 seq_lens=[113],
147 query_lens=[1],
148 is_prefilling=[False],
149 decode_base=[100],
150 buffer_len=16,
151 expected_write_pos=[0],
152 expected_is_flush=[0],
153 mamba_cache_mode="align",
154 ),
155 # block_size % buffer_len == 0: a later boundary lands on write_pos ==
156 # buffer_len - 1, so the boundary flush coincides with the natural flush.
157 "align_boundary_coincides_natural_flush": ReplaySSMBuildCase(
158 seq_lens=[128],
159 query_lens=[1],
160 is_prefilling=[False],
161 decode_base=[100],
162 buffer_len=16,
163 expected_write_pos=[15],
164 expected_is_flush=[1],
165 mamba_cache_mode="align",
166 ),
167 # block_size % buffer_len != 0 (buffer_len 6): the boundary step still flushes
168 # although write_pos (3) != buffer_len - 1.
169 "align_unaligned_buffer_forces_flush": ReplaySSMBuildCase(
170 seq_lens=[128],
171 query_lens=[1],
172 is_prefilling=[False],
173 decode_base=[100],
174 buffer_len=6,
175 expected_write_pos=[3],
176 expected_is_flush=[1],
177 mamba_cache_mode="align",
178 ),
179 # Per-row independence in align mode: partial-block / new-block / boundary.
180 "align_mixed_rows": ReplaySSMBuildCase(
181 seq_lens=[105, 113, 112],
182 query_lens=[1, 1, 1],
183 is_prefilling=[False, False, False],
184 decode_base=[100, 100, 100],
185 buffer_len=16,
186 expected_write_pos=[4, 0, 11],
187 expected_is_flush=[0, 0, 1],
188 mamba_cache_mode="align",
189 ),
190 }
191
192
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 + (
199 1 + num_speculative_tokens
200 if mamba_backend == MambaBackendEnum.FLASHINFER
201 else 0
202 )
203 shapes = (
204 (1, 1),
205 (1, 1, 1),
206 (1, ring_buffer_len, 1),
207 (1, ring_buffer_len),
208 (1, ring_buffer_len, 1),
209 )
210 return MambaSpec(
211 block_size=BLOCK_SIZE,
212 shapes=shapes,
213 dtypes=(torch.float32,),
214 )
215
216
217 def _create_replayssm_builder(
218 buffer_len: int,
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
226 )
227 # Set the flags after construction to skip validate_mamba_cached_kernel
228 # (it requires a real SupportsReplaySSM model) on the mock model.
229 vllm_config.cache_config.use_replayssm = True
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(
241 _make_mamba_spec(buffer_len, mamba_backend, num_speculative_tokens),
242 ["layer0"],
243 vllm_config,
244 DEVICE,
245 )
246
247
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 )
258 return builder.build(0, common, num_accepted_tokens=num_accepted_tokens)
259
260
261 @pytest.mark.parametrize(
262 "case", REPLAYSSM_BUILD_CASES.values(), ids=REPLAYSSM_BUILD_CASES.keys()
263 )
264 def test_replayssm_write_pos(case: ReplaySSMBuildCase):
265 builder = _create_replayssm_builder(case.buffer_len, case.mamba_cache_mode)
266 meta = _build(builder, case)
267
268 assert meta.write_pos_d is not None
269 assert meta.is_flush_d is not None
270 n = len(case.expected_write_pos)
271 assert meta.write_pos_d[:n].tolist() == case.expected_write_pos
272 assert meta.is_flush_d[:n].tolist() == case.expected_is_flush
273
274
275 def test_resumed_request_differs_from_fresh():
276 """Same token count, different decode_base: fresh (base 100) -> write_pos 5,
277 resumed (base 105) -> write_pos 0."""
278 builder = _create_replayssm_builder(16)
279 batch = BatchSpec(seq_lens=[106, 106], query_lens=[1, 1])
280 common = create_common_attn_metadata(batch, BLOCK_SIZE, DEVICE).replace(
281 is_prefilling=torch.tensor([False, False]),
282 replayssm_decode_base_cpu=torch.tensor([100, 105], dtype=torch.int32),
283 )
284 meta = builder.build(0, common)
285
286 assert meta.write_pos_d.tolist()[:2] == [5, 0]
287 assert meta.is_flush_d.tolist()[:2] == [0, 0]
288
289
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"):
305 pytest.skip("requires FlashInfer ReplaySSM autotuning support")
306
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
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])