main
py 1,044 lines 36.8 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from dataclasses import fields
5 from types import SimpleNamespace
6 from unittest.mock import Mock, patch
7
8 import pytest
9 import torch
10
11 from tests.v1.attention.utils import (
12 BatchSpec,
13 create_common_attn_metadata,
14 create_vllm_config,
15 )
16 from vllm.config import SpeculativeConfig
17 from vllm.config.compilation import CUDAGraphMode
18 from vllm.models.kimi_k3.nvidia.kda_metadata import (
19 KimiK3KDAAttentionBackend,
20 KimiK3KDAMetadata,
21 KimiK3KDAMetadataBuilder,
22 _mamba_get_block_table_tensor,
23 stage_spec_decode_metadata,
24 )
25 from vllm.models.kimi_k3.nvidia.model import KimiLinearForCausalLM
26 from vllm.v1.attention.backend import AttentionMetadataBuilder
27 from vllm.v1.attention.backends.gdn_attn import (
28 GDNAttentionBackend,
29 GDNAttentionMetadata,
30 GDNAttentionMetadataBuilder,
31 )
32 from vllm.v1.attention.backends.recoverssm_metadata import (
33 RecoverSSMPostprocessMetadata,
34 )
35 from vllm.v1.attention.backends.registry import MambaAttentionBackendEnum
36 from vllm.v1.attention.backends.utils import (
37 NULL_BLOCK_ID,
38 mamba_get_block_table_tensor,
39 )
40 from vllm.v1.kv_cache_interface import MambaSpec
41 from vllm.v1.worker.mamba_utils import validate_mamba_state_copy_funcs
42
43 BLOCK_SIZE = 16
44 DEVICE = torch.device("cpu")
45 PRUNED_METADATA_FIELDS = {
46 "chunk_indices",
47 "chunk_offsets",
48 "prefill_query_start_loc",
49 "prefill_state_indices",
50 "prefill_has_initial_state",
51 "spec_sequence_masks",
52 "spec_sequence_masks_cpu",
53 "uniform_spec_sequence_length",
54 "flashinfer_prefill_query_start_loc",
55 "flashinfer_prefill_seq_order",
56 }
57
58
59 def _assert_matches_shared_gdn(
60 reference: GDNAttentionMetadata, actual: KimiK3KDAMetadata
61 ):
62 assert actual.recoverssm_commit is None
63 assert actual.recoverssm_context is None
64 for field in fields(GDNAttentionMetadata):
65 actual_value = getattr(actual, field.name)
66 if field.name in PRUNED_METADATA_FIELDS:
67 assert actual_value is None
68 continue
69 expected_value = getattr(reference, field.name)
70 if (
71 field.name in {"spec_token_indx", "non_spec_token_indx"}
72 and actual.num_spec_decodes > 0
73 and actual.num_prefills == 0
74 and actual.num_decodes == 0
75 ):
76 assert actual_value is None
77 continue
78 if isinstance(actual_value, torch.Tensor):
79 torch.testing.assert_close(actual_value, expected_value)
80 elif field.name == "nums_dict":
81 assert (actual_value is None) == (expected_value is None)
82 if actual_value is not None:
83 assert actual_value[8]["tot"] == expected_value[8]["tot"]
84 torch.testing.assert_close(
85 actual_value[8]["nums"], expected_value[8]["nums"]
86 )
87 else:
88 assert actual_value == expected_value
89
90
91 def _make_builder(
92 builder_cls: type[AttentionMetadataBuilder],
93 num_speculative_tokens: int,
94 full_cuda_graph: bool,
95 device: torch.device = DEVICE,
96 mamba_cache_mode: str = "none",
97 use_recoverssm: bool = False,
98 num_prefill_checkpoint_blocks: int = 0,
99 mamba_block_size: int = BLOCK_SIZE,
100 prefix_match_unit: int | None = None,
101 use_eagle: bool = False,
102 disable_eagle_block_drop: bool = False,
103 ) -> AttentionMetadataBuilder:
104 vllm_config = create_vllm_config(
105 model_name="Qwen/Qwen3.5-0.8B",
106 block_size=BLOCK_SIZE,
107 )
108 if num_speculative_tokens:
109 vllm_config.speculative_config = SpeculativeConfig(
110 method="ngram",
111 num_speculative_tokens=num_speculative_tokens,
112 )
113 if use_eagle:
114 vllm_config.speculative_config.method = "eagle3"
115 vllm_config.speculative_config.disable_eagle_block_drop = (
116 disable_eagle_block_drop
117 )
118 vllm_config.compilation_config.cudagraph_mode = (
119 CUDAGraphMode.FULL_AND_PIECEWISE if full_cuda_graph else CUDAGraphMode.NONE
120 )
121 vllm_config.cache_config.mamba_cache_mode = mamba_cache_mode
122 vllm_config.cache_config.use_replayssm = use_recoverssm
123 vllm_config.cache_config.use_kda_recoverssm = use_recoverssm
124 vllm_config.cache_config.prefix_match_unit = prefix_match_unit
125 builder = builder_cls(
126 kv_cache_spec=MambaSpec(
127 block_size=mamba_block_size,
128 shapes=((16, 64),),
129 dtypes=(torch.float16,),
130 mamba_cache_mode=mamba_cache_mode,
131 num_speculative_blocks=(0 if use_recoverssm else num_speculative_tokens),
132 num_prefill_checkpoint_blocks=num_prefill_checkpoint_blocks,
133 prefill_checkpoint_alignment=(
134 16 if num_prefill_checkpoint_blocks > 0 else None
135 ),
136 ),
137 layer_names=["layer.0"],
138 vllm_config=vllm_config,
139 device=device,
140 )
141 if use_recoverssm:
142 assert isinstance(builder, KimiK3KDAMetadataBuilder)
143 builder.recoverssm_context = Mock()
144 return builder
145
146
147 def test_kda_recoverssm_startup_metadata_flow_without_model(monkeypatch):
148 """Exercise KDA RecoverSSM startup without loading Kimi-K3 weights."""
149 monkeypatch.setattr("vllm.utils.torch_utils.PIN_MEMORY", False)
150 monkeypatch.setattr("vllm.v1.attention.backends.utils.PIN_MEMORY", False)
151 layout_config = SimpleNamespace(
152 model_config=SimpleNamespace(
153 dtype=torch.bfloat16,
154 hf_config=SimpleNamespace(
155 linear_attn_config={
156 "num_heads": 4,
157 "head_dim": 32,
158 "short_conv_kernel_size": 4,
159 }
160 ),
161 ),
162 cache_config=SimpleNamespace(
163 mamba_cache_dtype="auto",
164 mamba_ssm_cache_dtype="auto",
165 use_kda_recoverssm=True,
166 ),
167 parallel_config=SimpleNamespace(tensor_parallel_size=1),
168 speculative_config=SimpleNamespace(num_speculative_tokens=2),
169 )
170 kv_cache_spec = MambaSpec(
171 block_size=BLOCK_SIZE,
172 shapes=KimiLinearForCausalLM.get_mamba_state_shape_from_config(layout_config),
173 dtypes=KimiLinearForCausalLM.get_mamba_state_dtype_from_config(layout_config),
174 mamba_type=MambaAttentionBackendEnum.GDN_ATTN,
175 mamba_cache_mode="align",
176 num_prefill_checkpoint_blocks=1,
177 prefill_checkpoint_alignment=16,
178 )
179
180 # This is the same compatibility check performed while initializing the
181 # model runner. RecoverSSM adds two workspace states that are not copied.
182 validate_mamba_state_copy_funcs(
183 {kv_cache_spec: [0]},
184 {
185 MambaAttentionBackendEnum.GDN_ATTN: (
186 KimiLinearForCausalLM.get_mamba_state_copy_func()
187 )
188 },
189 )
190 assert len(kv_cache_spec.shapes) == 4
191
192 builder_config = SimpleNamespace(
193 model_config=SimpleNamespace(
194 hf_text_config=SimpleNamespace(linear_key_head_dim=32)
195 ),
196 cache_config=SimpleNamespace(
197 mamba_cache_mode="align",
198 use_kda_recoverssm=True,
199 prefix_match_unit=None,
200 ),
201 parallel_config=SimpleNamespace(decode_context_parallel_size=1),
202 speculative_config=SimpleNamespace(
203 num_speculative_tokens=2,
204 parallel_drafting=False,
205 use_eagle_block_drop=Mock(return_value=False),
206 ),
207 compilation_config=SimpleNamespace(
208 cudagraph_mode=CUDAGraphMode.NONE,
209 max_cudagraph_capture_size=None,
210 static_forward_context={},
211 ),
212 scheduler_config=SimpleNamespace(max_num_seqs=4),
213 additional_config={},
214 num_speculative_tokens=2,
215 use_v2_model_runner=False,
216 )
217 builder = KimiK3KDAMetadataBuilder(
218 kv_cache_spec=kv_cache_spec,
219 layer_names=["layer.0"],
220 vllm_config=builder_config,
221 device=DEVICE,
222 )
223
224 # An all-prefill speculative batch used to leave an all-false spec mask
225 # alive, then access active_non_spec_mask_cpu before it was initialized.
226 prefill_common = create_common_attn_metadata(
227 BatchSpec(seq_lens=[50], query_lens=[50]),
228 BLOCK_SIZE,
229 DEVICE,
230 arange_block_indices=True,
231 ).replace(is_prefilling=torch.tensor([True]))
232 builder.mamba_aligned_state_indices = mamba_get_block_table_tensor(
233 prefill_common.block_table_tensor,
234 prefill_common.seq_lens,
235 kv_cache_spec,
236 "align",
237 )
238 prefill_metadata = builder.build(
239 0,
240 prefill_common,
241 num_decode_draft_tokens_cpu=torch.tensor([-1], dtype=torch.int32),
242 num_accepted_tokens=torch.ones(1, dtype=torch.int32),
243 )
244 assert prefill_metadata.num_prefills == 1
245 assert prefill_metadata.num_spec_decodes == 0
246 assert prefill_metadata.checkpoint is not None
247
248 # Creating the commit context is the first metadata path that consumes the
249 # builder's layer_names. Mock only the GPU-cache-dependent constructor.
250 builder.recoverssm_context = None
251 forward_layer = Mock()
252 builder.vllm_config.compilation_config.static_forward_context["layer.0"] = (
253 forward_layer
254 )
255 spec_common = create_common_attn_metadata(
256 BatchSpec(seq_lens=[20], query_lens=[3]),
257 BLOCK_SIZE,
258 DEVICE,
259 arange_block_indices=True,
260 ).replace(is_prefilling=torch.tensor([False]))
261 builder.mamba_aligned_state_indices = mamba_get_block_table_tensor(
262 spec_common.block_table_tensor,
263 spec_common.seq_lens,
264 kv_cache_spec,
265 "align",
266 )
267 recoverssm_context = Mock()
268 with patch(
269 "vllm.models.kimi_k3.nvidia.ops.recoverssm.KDARecoverSSMCommitContext.create",
270 return_value=recoverssm_context,
271 ) as create_context:
272 spec_metadata = builder.build(
273 0,
274 spec_common,
275 num_decode_draft_tokens_cpu=torch.tensor([2], dtype=torch.int32),
276 num_accepted_tokens=torch.ones(1, dtype=torch.int32),
277 )
278
279 assert spec_metadata.num_spec_decodes == 1
280 assert spec_metadata.recoverssm_commit is not None
281 assert spec_metadata.recoverssm_context is recoverssm_context
282 create_context.assert_called_once_with(
283 [forward_layer],
284 spec_query_len=3,
285 max_num_reqs=builder.vllm_config.scheduler_config.max_num_seqs,
286 )
287
288
289 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
290 def test_internal_checkpoint_metadata_targets_last_aligned_boundary():
291 device = torch.device("cuda")
292 batch = BatchSpec(seq_lens=[50, 32], query_lens=[50, 16])
293 common_attn_metadata = create_common_attn_metadata(
294 batch, BLOCK_SIZE, device, arange_block_indices=True
295 ).replace(is_prefilling=torch.tensor([True, True]))
296 builder = _make_builder(
297 KimiK3KDAMetadataBuilder,
298 num_speculative_tokens=0,
299 full_cuda_graph=False,
300 mamba_cache_mode="align",
301 num_prefill_checkpoint_blocks=1,
302 device=device,
303 )
304 assert isinstance(builder, KimiK3KDAMetadataBuilder)
305 builder.mamba_aligned_state_indices = mamba_get_block_table_tensor(
306 common_attn_metadata.block_table_tensor,
307 common_attn_metadata.seq_lens,
308 builder.kv_cache_spec,
309 "align",
310 )
311 actual = builder.build(0, common_attn_metadata)
312
313 assert actual.checkpoint is not None
314 torch.testing.assert_close(
315 actual.checkpoint.state_indices,
316 torch.tensor([2, NULL_BLOCK_ID], dtype=torch.int32, device=device),
317 )
318 torch.testing.assert_close(
319 actual.checkpoint.checkpoint_offsets,
320 torch.tensor([48, 0], dtype=torch.int32, device=device),
321 )
322
323
324 @pytest.mark.parametrize(
325 ("disable_eagle_block_drop", "prefix_match_unit", "expected_offset"),
326 [(False, 16, 80), (True, 16, 96), (False, 8, None)],
327 )
328 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
329 def test_spec_internal_checkpoint_metadata_targets_replay_boundary(
330 disable_eagle_block_drop: bool,
331 prefix_match_unit: int,
332 expected_offset: int | None,
333 ) -> None:
334 device = torch.device("cuda")
335 batch = BatchSpec(seq_lens=[100], query_lens=[100])
336 common_attn_metadata = create_common_attn_metadata(
337 batch, BLOCK_SIZE, device, arange_block_indices=True
338 )
339 common_attn_metadata = common_attn_metadata.replace(
340 is_prefilling=torch.tensor([True]),
341 block_table_tensor=common_attn_metadata.block_table_tensor + 1,
342 )
343 builder = _make_builder(
344 KimiK3KDAMetadataBuilder,
345 num_speculative_tokens=3,
346 full_cuda_graph=False,
347 mamba_cache_mode="align",
348 num_prefill_checkpoint_blocks=1,
349 mamba_block_size=64,
350 prefix_match_unit=prefix_match_unit,
351 use_eagle=True,
352 disable_eagle_block_drop=disable_eagle_block_drop,
353 device=device,
354 )
355 assert isinstance(builder, KimiK3KDAMetadataBuilder)
356 builder.mamba_aligned_state_indices = mamba_get_block_table_tensor(
357 common_attn_metadata.block_table_tensor,
358 common_attn_metadata.seq_lens,
359 builder.kv_cache_spec,
360 "align",
361 )
362 actual = builder.build(0, common_attn_metadata)
363
364 if expected_offset is None:
365 assert actual.checkpoint is None
366 return
367 assert actual.checkpoint is not None
368 torch.testing.assert_close(
369 actual.checkpoint.state_indices,
370 torch.tensor([1], dtype=torch.int32, device=device),
371 )
372 torch.testing.assert_close(
373 actual.checkpoint.checkpoint_offsets,
374 torch.tensor([expected_offset], dtype=torch.int32, device=device),
375 )
376
377
378 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
379 def test_internal_checkpoint_metadata_skips_unaligned_offset():
380 device = torch.device("cuda")
381 # The checkpoint block boundary is 48, but this query starts at token 1,
382 # making the real checkpoint offset 47, which is not FlashKDA-aligned.
383 batch = BatchSpec(seq_lens=[50], query_lens=[49])
384 common_attn_metadata = create_common_attn_metadata(
385 batch, BLOCK_SIZE, device, arange_block_indices=True
386 ).replace(is_prefilling=torch.tensor([True]))
387 builder = _make_builder(
388 KimiK3KDAMetadataBuilder,
389 num_speculative_tokens=0,
390 full_cuda_graph=False,
391 mamba_cache_mode="align",
392 num_prefill_checkpoint_blocks=1,
393 device=device,
394 )
395 assert isinstance(builder, KimiK3KDAMetadataBuilder)
396 builder.mamba_aligned_state_indices = mamba_get_block_table_tensor(
397 common_attn_metadata.block_table_tensor,
398 common_attn_metadata.seq_lens,
399 builder.kv_cache_spec,
400 "align",
401 )
402 actual = builder.build(0, common_attn_metadata)
403
404 assert actual.checkpoint is None
405
406
407 @pytest.mark.parametrize(
408 (
409 "batch",
410 "num_decode_draft_tokens",
411 "num_speculative_tokens",
412 "full_cuda_graph",
413 "is_prefilling",
414 "expected_uniform_spec_sequence_length",
415 ),
416 [
417 pytest.param(
418 BatchSpec(seq_lens=[50, 30], query_lens=[3, 3]),
419 [2, 2],
420 2,
421 False,
422 [False, False],
423 3,
424 id="pure-spec-decode",
425 ),
426 pytest.param(
427 BatchSpec(seq_lens=[100, 65, 20], query_lens=[50, 1, 3]),
428 [-1, -1, 2],
429 2,
430 False,
431 [True, False, False],
432 3,
433 id="mixed-prefill-and-spec-decode",
434 ),
435 pytest.param(
436 BatchSpec(seq_lens=[40, 30], query_lens=[1, 1]),
437 None,
438 0,
439 False,
440 [False, False],
441 None,
442 id="regular-decode",
443 ),
444 pytest.param(
445 BatchSpec(seq_lens=[40, 30], query_lens=[1, 1]),
446 [0, 0],
447 2,
448 False,
449 [False, False],
450 None,
451 id="no-scheduled-draft-tokens",
452 ),
453 pytest.param(
454 BatchSpec(seq_lens=[50, 30], query_lens=[3, 2]),
455 [2, 1],
456 2,
457 False,
458 [False, False],
459 None,
460 id="ragged-spec-decode",
461 ),
462 ],
463 )
464 def test_kimi_k3_kda_metadata_matches_shared_gdn(
465 batch: BatchSpec,
466 num_decode_draft_tokens: list[int] | None,
467 num_speculative_tokens: int,
468 full_cuda_graph: bool,
469 is_prefilling: list[bool],
470 expected_uniform_spec_sequence_length: int | None,
471 ):
472 kwargs: dict[str, torch.Tensor] = {}
473 if num_decode_draft_tokens is not None:
474 kwargs = {
475 "num_decode_draft_tokens_cpu": torch.tensor(
476 num_decode_draft_tokens, dtype=torch.int32
477 ),
478 "num_accepted_tokens": torch.ones(
479 batch.batch_size, dtype=torch.int32, device=DEVICE
480 ),
481 }
482
483 common_attn_metadata = create_common_attn_metadata(
484 batch, BLOCK_SIZE, DEVICE
485 ).replace(is_prefilling=torch.tensor(is_prefilling, dtype=torch.bool))
486 reference = _make_builder(
487 GDNAttentionMetadataBuilder,
488 num_speculative_tokens,
489 full_cuda_graph,
490 ).build(
491 0,
492 common_attn_metadata,
493 **kwargs,
494 )
495 actual = _make_builder(
496 KimiK3KDAMetadataBuilder,
497 num_speculative_tokens,
498 full_cuda_graph,
499 ).build(0, common_attn_metadata, **kwargs)
500
501 assert isinstance(actual, KimiK3KDAMetadata)
502 _assert_matches_shared_gdn(reference, actual)
503 assert (
504 reference.uniform_spec_sequence_length == expected_uniform_spec_sequence_length
505 )
506
507
508 def test_mixed_regular_and_spec_decode_uses_packed_decode_metadata():
509 batch = BatchSpec(seq_lens=[100, 65, 20], query_lens=[1, 1, 3])
510 common_attn_metadata = create_common_attn_metadata(
511 batch, BLOCK_SIZE, DEVICE
512 ).replace(is_prefilling=torch.tensor([False, False, False]))
513 actual = _make_builder(
514 KimiK3KDAMetadataBuilder,
515 num_speculative_tokens=2,
516 full_cuda_graph=False,
517 ).build(
518 0,
519 common_attn_metadata,
520 num_decode_draft_tokens_cpu=torch.tensor([-1, -1, 2], dtype=torch.int32),
521 num_accepted_tokens=torch.ones(3, dtype=torch.int32, device=DEVICE),
522 )
523
524 # The K3 layer dispatches the non-spec subgroup to packed decode whenever
525 # it contains no prefill request.
526 assert actual.num_decodes == 2
527 assert actual.num_decode_tokens == 2
528 assert actual.num_prefills == 0
529 assert actual.num_prefill_tokens == 0
530 assert actual.has_initial_state is None
531 assert actual.nums_dict is None
532 assert actual.non_spec_query_start_loc is None
533 torch.testing.assert_close(actual.non_spec_token_indx, torch.tensor([0, 1]))
534 torch.testing.assert_close(actual.spec_token_indx, torch.tensor([2, 3, 4]))
535 assert actual.non_spec_token_start == 0
536 assert actual.spec_token_start == 2
537 torch.testing.assert_close(
538 actual.spec_query_start_loc,
539 torch.tensor([0, 3], dtype=torch.int32),
540 )
541
542
543 def test_mixed_regular_and_spec_decode_excludes_request_padding():
544 batch = BatchSpec(seq_lens=[16, 65, 20], query_lens=[0, 1, 3])
545 common_attn_metadata = create_common_attn_metadata(
546 batch, BLOCK_SIZE, DEVICE
547 ).replace(is_prefilling=torch.tensor([False, False, False]))
548 actual = _make_builder(
549 KimiK3KDAMetadataBuilder,
550 num_speculative_tokens=2,
551 full_cuda_graph=False,
552 ).build(
553 0,
554 common_attn_metadata,
555 num_decode_draft_tokens_cpu=torch.tensor([-1, -1, 2], dtype=torch.int32),
556 num_accepted_tokens=torch.ones(3, dtype=torch.int32, device=DEVICE),
557 )
558
559 assert actual.num_decodes == 1
560 assert actual.non_spec_state_indices_tensor is not None
561 assert actual.non_spec_state_indices_tensor.shape == (1,)
562 torch.testing.assert_close(actual.non_spec_token_indx, torch.tensor([0]))
563 torch.testing.assert_close(actual.spec_token_indx, torch.tensor([1, 2, 3]))
564 assert actual.non_spec_token_start == 0
565 assert actual.spec_token_start == 1
566
567
568 @pytest.mark.parametrize("mamba_cache_mode", ["none", "align"])
569 def test_recoverssm_spec_uses_one_state_slot_and_current_window(
570 mamba_cache_mode: str,
571 ):
572 if mamba_cache_mode == "align" and not torch.cuda.is_available():
573 pytest.skip("align metadata construction requires CUDA")
574 device = torch.device("cuda") if mamba_cache_mode == "align" else DEVICE
575 batch = BatchSpec(seq_lens=[100, 65, 20], query_lens=[1, 1, 3])
576 common_attn_metadata = create_common_attn_metadata(
577 batch, BLOCK_SIZE, device
578 ).replace(is_prefilling=torch.tensor([True, True, False]))
579 builder = _make_builder(
580 KimiK3KDAMetadataBuilder,
581 num_speculative_tokens=2,
582 full_cuda_graph=False,
583 device=device,
584 mamba_cache_mode=mamba_cache_mode,
585 use_recoverssm=True,
586 )
587 assert isinstance(builder, KimiK3KDAMetadataBuilder)
588 if mamba_cache_mode == "align":
589 builder.mamba_aligned_state_indices = mamba_get_block_table_tensor(
590 common_attn_metadata.block_table_tensor,
591 common_attn_metadata.seq_lens,
592 builder.kv_cache_spec,
593 mamba_cache_mode,
594 )
595 context = builder.recoverssm_context
596 assert context is not None
597 actual = builder.build(
598 0,
599 common_attn_metadata,
600 num_decode_draft_tokens_cpu=torch.tensor([-1, -1, 2], dtype=torch.int32),
601 num_accepted_tokens=torch.tensor([3, 2, 2], dtype=torch.int32, device=device),
602 )
603
604 assert actual.spec_state_indices_tensor is not None
605 assert actual.spec_state_indices_tensor.shape == (1, 1)
606 torch.testing.assert_close(
607 actual.num_accepted_tokens,
608 torch.ones(1, dtype=torch.int32, device=device),
609 )
610 commit_metadata = actual.recoverssm_commit
611 assert commit_metadata is not None
612 torch.testing.assert_close(
613 commit_metadata.request_indices,
614 torch.tensor([2], dtype=torch.int32, device=device),
615 )
616 assert actual.recoverssm_context is context
617 num_accepted_tokens = torch.tensor([3, 2, 1], dtype=torch.int32, device=device)
618
619 postprocess = actual.commit_recoverssm_state(num_accepted_tokens)
620
621 if mamba_cache_mode == "none":
622 assert commit_metadata.align is None
623 assert postprocess is None
624 else:
625 assert isinstance(postprocess, RecoverSSMPostprocessMetadata)
626 assert postprocess.num_spec_decodes == 1
627 assert postprocess.request_indices is commit_metadata.request_indices
628 assert postprocess.block_table is common_attn_metadata.block_table_tensor
629 assert (
630 postprocess.num_computed_tokens
631 is common_attn_metadata.compute_num_computed_tokens()
632 )
633 assert postprocess.block_size == BLOCK_SIZE
634 args = context.commit.call_args.args
635 assert args[0] is num_accepted_tokens
636 torch.testing.assert_close(args[1], commit_metadata.state_indices[:, 0])
637 torch.testing.assert_close(args[2], commit_metadata.query_start_loc)
638
639
640 def test_recoverssm_distinguishes_draftless_decode_from_one_token_prefill():
641 batch = BatchSpec(seq_lens=[40, 30], query_lens=[1, 1])
642 common_attn_metadata = create_common_attn_metadata(
643 batch, BLOCK_SIZE, DEVICE
644 ).replace(is_prefilling=torch.tensor([False, True]))
645 actual = _make_builder(
646 KimiK3KDAMetadataBuilder,
647 num_speculative_tokens=2,
648 full_cuda_graph=False,
649 use_recoverssm=True,
650 ).build(
651 0,
652 common_attn_metadata,
653 num_decode_draft_tokens_cpu=torch.full((2,), -1, dtype=torch.int32),
654 num_accepted_tokens=torch.ones(2, dtype=torch.int32),
655 )
656
657 assert actual.num_spec_decodes == 1
658 assert actual.num_decodes == 0
659 assert actual.num_prefills == 1
660 assert actual.spec_state_indices_tensor is not None
661 assert actual.spec_state_indices_tensor.shape == (1, 1)
662 torch.testing.assert_close(
663 actual.spec_query_start_loc,
664 torch.tensor([0, 1], dtype=torch.int32),
665 )
666
667
668 @pytest.mark.parametrize(
669 ("seq_len", "expected_has_initial_state"),
670 [
671 pytest.param(1, False, id="first-token-prefill"),
672 pytest.param(65, True, id="final-one-token-prefill-chunk"),
673 ],
674 )
675 def test_mixed_one_token_prefill_and_spec_decode_uses_prefill_metadata(
676 seq_len: int,
677 expected_has_initial_state: bool,
678 ):
679 batch = BatchSpec(seq_lens=[seq_len, 20], query_lens=[1, 3])
680 common_attn_metadata = create_common_attn_metadata(
681 batch, BLOCK_SIZE, DEVICE
682 ).replace(is_prefilling=torch.tensor([True, False]))
683 actual = _make_builder(
684 KimiK3KDAMetadataBuilder,
685 num_speculative_tokens=2,
686 full_cuda_graph=False,
687 ).build(
688 0,
689 common_attn_metadata,
690 num_decode_draft_tokens_cpu=torch.tensor([-1, 2], dtype=torch.int32),
691 num_accepted_tokens=torch.ones(2, dtype=torch.int32, device=DEVICE),
692 )
693
694 assert actual.num_prefills == 1
695 assert actual.num_prefill_tokens == 1
696 assert actual.num_decodes == 0
697 assert actual.num_decode_tokens == 0
698 assert actual.has_initial_state is not None
699 assert actual.has_initial_state.tolist() == [expected_has_initial_state]
700 assert actual.non_spec_query_start_loc is not None
701 torch.testing.assert_close(
702 actual.non_spec_query_start_loc,
703 torch.tensor([0, 1], dtype=torch.int32),
704 )
705
706
707 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
708 def test_kimi_k3_kda_cudagraph_capture_matches_shared_gdn():
709 device = torch.device("cuda")
710 batch = BatchSpec(seq_lens=[50, 30], query_lens=[3, 3])
711 common_attn_metadata = create_common_attn_metadata(
712 batch, BLOCK_SIZE, device
713 ).replace(is_prefilling=torch.tensor([False, False]))
714 reference = _make_builder(
715 GDNAttentionMetadataBuilder,
716 num_speculative_tokens=2,
717 full_cuda_graph=True,
718 device=device,
719 ).build_for_cudagraph_capture(common_attn_metadata)
720 actual = _make_builder(
721 KimiK3KDAMetadataBuilder,
722 num_speculative_tokens=2,
723 full_cuda_graph=True,
724 device=device,
725 ).build_for_cudagraph_capture(common_attn_metadata)
726
727 assert isinstance(actual, KimiK3KDAMetadata)
728 _assert_matches_shared_gdn(reference, actual)
729
730
731 @pytest.mark.parametrize(
732 "builder_cls",
733 [
734 GDNAttentionMetadataBuilder,
735 # The Kimi-K3 builder stages spec-decode state indices on the device.
736 pytest.param(
737 KimiK3KDAMetadataBuilder,
738 marks=pytest.mark.skipif(
739 not torch.cuda.is_available(), reason="requires CUDA"
740 ),
741 ),
742 ],
743 )
744 def test_cudagraph_capture_metadata_avoids_device_to_host_copy(
745 builder_cls: type[AttentionMetadataBuilder], monkeypatch: pytest.MonkeyPatch
746 ):
747 """`build_for_cudagraph_capture` is not capture-only: a DP rank with nothing
748 scheduled re-stages its FULL-graph metadata through it on every dummy step,
749 so it must not synchronize the device. The host-side draft counts have to
750 come from `query_start_loc_cpu` and match the device-derived values."""
751 num_speculative_tokens = 2
752 batch = BatchSpec(seq_lens=[50, 30], query_lens=[3, 3])
753 device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
754 common_attn_metadata = create_common_attn_metadata(
755 batch, BLOCK_SIZE, device
756 ).replace(is_prefilling=torch.zeros(batch.batch_size, dtype=torch.bool))
757 builder = _make_builder(
758 builder_cls, num_speculative_tokens, full_cuda_graph=True, device=device
759 )
760
761 device_to_host_copies: list[torch.Size] = []
762 original_cpu = torch.Tensor.cpu
763
764 def counting_cpu(self: torch.Tensor, *args, **kwargs):
765 device_to_host_copies.append(self.shape)
766 return original_cpu(self, *args, **kwargs)
767
768 monkeypatch.setattr(torch.Tensor, "cpu", counting_cpu)
769 actual = builder.build_for_cudagraph_capture(common_attn_metadata)
770 monkeypatch.undo()
771
772 assert not device_to_host_copies, device_to_host_copies
773
774 num_accepted_tokens = torch.diff(common_attn_metadata.query_start_loc)
775 reference = builder.build(
776 0,
777 common_attn_metadata,
778 num_accepted_tokens,
779 (num_accepted_tokens - 1).cpu(),
780 )
781 assert actual.num_spec_decodes == batch.batch_size
782 assert actual.num_decodes == 0
783 assert actual.num_prefills == 0
784 for field in fields(type(actual)):
785 actual_value = getattr(actual, field.name)
786 reference_value = getattr(reference, field.name)
787 if isinstance(actual_value, torch.Tensor):
788 torch.testing.assert_close(actual_value, reference_value)
789 else:
790 assert actual_value == reference_value, field.name
791
792
793 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
794 def test_recoverssm_spec_cudagraph_stages_one_checkpoint_per_request():
795 device = torch.device("cuda")
796 batch = BatchSpec(seq_lens=[50, 30], query_lens=[3, 3])
797 common_attn_metadata = create_common_attn_metadata(
798 batch, BLOCK_SIZE, device
799 ).replace(is_prefilling=torch.tensor([False, False]))
800 builder = _make_builder(
801 KimiK3KDAMetadataBuilder,
802 num_speculative_tokens=2,
803 full_cuda_graph=True,
804 device=device,
805 use_recoverssm=True,
806 )
807 assert isinstance(builder, KimiK3KDAMetadataBuilder)
808 assert builder.spec_state_indices_tensor.shape == (
809 builder.vllm_config.scheduler_config.max_num_seqs,
810 1,
811 )
812 actual = builder.build_for_cudagraph_capture(common_attn_metadata)
813
814 assert actual.spec_state_indices_tensor is not None
815 assert actual.spec_state_indices_tensor.shape == (batch.batch_size, 1)
816 assert actual.num_accepted_tokens is not None
817 torch.testing.assert_close(
818 actual.num_accepted_tokens,
819 torch.ones(batch.batch_size, dtype=torch.int32, device=device),
820 )
821 assert actual.recoverssm_commit is not None
822 assert actual.recoverssm_commit.request_indices is None
823
824
825 def test_kimi_k3_kda_backend_uses_private_metadata_builder():
826 assert KimiK3KDAAttentionBackend.get_builder_cls() is KimiK3KDAMetadataBuilder
827 assert KimiK3KDAAttentionBackend.is_ssm()
828 assert issubclass(KimiK3KDAAttentionBackend, GDNAttentionBackend)
829 assert issubclass(KimiK3KDAMetadata, GDNAttentionMetadata)
830 assert issubclass(KimiK3KDAMetadataBuilder, GDNAttentionMetadataBuilder)
831
832
833 def test_kimi_k3_metadata_uses_precomputed_aligned_state_indices():
834 batch = BatchSpec(seq_lens=[40, 30], query_lens=[1, 1])
835 common_attn_metadata = create_common_attn_metadata(
836 batch, BLOCK_SIZE, DEVICE
837 ).replace(is_prefilling=torch.tensor([False, False]))
838 builder = _make_builder(
839 KimiK3KDAMetadataBuilder,
840 num_speculative_tokens=2,
841 full_cuda_graph=False,
842 mamba_cache_mode="align",
843 )
844 assert isinstance(builder, KimiK3KDAMetadataBuilder)
845 precomputed_indices = torch.tensor(
846 [
847 [101, 102, 103],
848 [201, 202, 203],
849 [301, 302, 303],
850 ],
851 dtype=torch.int32,
852 )
853 builder.mamba_aligned_state_indices = precomputed_indices
854
855 metadata = builder.build(0, common_attn_metadata)
856
857 torch.testing.assert_close(
858 metadata.non_spec_state_indices_tensor,
859 precomputed_indices[: batch.batch_size, 0],
860 )
861
862
863 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
864 def test_stage_spec_decode_metadata_matches_pytorch():
865 device = torch.device("cuda")
866 num_spec_decodes = 33
867 batch_size = 65
868 num_state_slots = 3
869 state_indices = torch.arange(
870 num_spec_decodes * 32,
871 dtype=torch.int32,
872 device=device,
873 ).reshape(num_spec_decodes, 32)[:, :num_state_slots]
874 query_start_loc = (
875 torch.arange(num_spec_decodes + 1, dtype=torch.int32, device=device)
876 * num_state_slots
877 )
878 num_accepted_tokens = (
879 torch.arange(num_spec_decodes, dtype=torch.int32, device=device)
880 % num_state_slots
881 + 1
882 )
883
884 staged_state_indices = torch.empty(
885 (batch_size, num_state_slots), dtype=torch.int32, device=device
886 )
887 staged_query_start_loc = torch.empty(
888 batch_size + 1, dtype=torch.int32, device=device
889 )
890 staged_num_accepted_tokens = torch.empty(
891 batch_size, dtype=torch.int32, device=device
892 )
893 stage_spec_decode_metadata(
894 state_indices,
895 query_start_loc,
896 num_accepted_tokens,
897 staged_state_indices,
898 staged_query_start_loc,
899 staged_num_accepted_tokens,
900 num_spec_decodes=num_spec_decodes,
901 )
902
903 expected_state_indices = torch.full_like(staged_state_indices, NULL_BLOCK_ID)
904 expected_state_indices[:num_spec_decodes] = state_indices
905 expected_query_start_loc = torch.full(
906 (batch_size + 1,),
907 query_start_loc[-1],
908 dtype=torch.int32,
909 device=device,
910 )
911 expected_query_start_loc[: num_spec_decodes + 1] = query_start_loc
912 expected_num_accepted_tokens = torch.ones(
913 batch_size, dtype=torch.int32, device=device
914 )
915 expected_num_accepted_tokens[:num_spec_decodes] = num_accepted_tokens
916
917 torch.testing.assert_close(staged_state_indices, expected_state_indices)
918 torch.testing.assert_close(staged_query_start_loc, expected_query_start_loc)
919 torch.testing.assert_close(staged_num_accepted_tokens, expected_num_accepted_tokens)
920
921
922 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
923 def test_aligned_block_table_matches_shared_gdn():
924 device = torch.device("cuda")
925 seq_lens = torch.tensor(
926 [0, 1, 15, 16, 17, 31, 32, 33, 511, 512, 513],
927 dtype=torch.int32,
928 device=device,
929 ).repeat(6)[:65]
930 block_table_storage = torch.arange(
931 seq_lens.numel() * 128,
932 dtype=torch.int32,
933 device=device,
934 ).reshape(seq_lens.numel(), 128)
935 block_table = block_table_storage[:, ::2]
936 kv_cache_spec = MambaSpec(
937 block_size=BLOCK_SIZE,
938 shapes=((16, 64),),
939 dtypes=(torch.float16,),
940 num_speculative_blocks=2,
941 )
942
943 expected = mamba_get_block_table_tensor(
944 block_table,
945 seq_lens,
946 kv_cache_spec,
947 "align",
948 )
949 actual = _mamba_get_block_table_tensor(
950 block_table,
951 seq_lens,
952 kv_cache_spec,
953 "align",
954 )
955
956 torch.testing.assert_close(actual, expected)
957
958
959 def _build_non_spec(batch, is_prefilling, full_cuda_graph=False):
960 common_attn_metadata = create_common_attn_metadata(
961 batch, BLOCK_SIZE, DEVICE
962 ).replace(
963 is_prefilling=None
964 if is_prefilling is None
965 else torch.tensor(is_prefilling, dtype=torch.bool)
966 )
967 builder = _make_builder(
968 KimiK3KDAMetadataBuilder,
969 num_speculative_tokens=0,
970 full_cuda_graph=full_cuda_graph,
971 )
972 return builder, common_attn_metadata, builder.build(0, common_attn_metadata)
973
974
975 def test_one_token_first_chunk_excludes_padding():
976 """Neither padding requests nor padding tokens count as prefill work."""
977 common = create_common_attn_metadata(
978 BatchSpec(seq_lens=[100, 1, 0, 0], query_lens=[1, 1, 0, 0]),
979 BLOCK_SIZE,
980 DEVICE,
981 ).replace(
982 is_prefilling=torch.tensor([False, True, False, False], dtype=torch.bool),
983 num_actual_tokens=4,
984 )
985 builder = _make_builder(
986 KimiK3KDAMetadataBuilder, num_speculative_tokens=0, full_cuda_graph=False
987 )
988 actual = builder.build(0, common)
989
990 assert actual.num_decodes == 1
991 assert actual.num_prefills == 1
992 assert actual.num_decode_tokens == 1
993 assert actual.num_prefill_tokens == 1
994
995
996 @pytest.mark.parametrize(
997 ("seq_len", "query_len", "is_prefilling", "num_prefills"),
998 [
999 pytest.param(1, 1, True, 1, id="first-chunk"),
1000 pytest.param(65, 1, True, 0, id="resumed-chunk"),
1001 pytest.param(0, 0, True, 0, id="padding"),
1002 pytest.param(1, 1, None, 0, id="missing-prefill-flag"),
1003 ],
1004 )
1005 def test_one_token_chunk_classification(
1006 seq_len, query_len, is_prefilling, num_prefills
1007 ):
1008 """Only a real first chunk with a prefill flag needs state initialization."""
1009 _, _, actual = _build_non_spec(
1010 BatchSpec(seq_lens=[100, seq_len], query_lens=[1, query_len]),
1011 is_prefilling=None if is_prefilling is None else [False, is_prefilling],
1012 )
1013
1014 assert actual.num_prefills == num_prefills
1015 assert actual.num_decodes == 2 - num_prefills
1016 assert actual.num_prefill_tokens == num_prefills
1017 assert actual.num_decode_tokens == 1 + query_len - num_prefills
1018 if num_prefills:
1019 assert actual.has_initial_state is not None
1020 assert actual.has_initial_state.tolist() == [True, False]
1021 else:
1022 assert actual.has_initial_state is None
1023
1024
1025 def test_cudagraph_capture_batch_stays_decode_only():
1026 """Capture rows have no history, but must still select decode kernels."""
1027 batch = BatchSpec(seq_lens=[1] * 4, query_lens=[1] * 4)
1028 common_attn_metadata = create_common_attn_metadata(
1029 batch, BLOCK_SIZE, DEVICE
1030 ).replace(is_prefilling=torch.zeros(4, dtype=torch.bool))
1031 builder = _make_builder(
1032 KimiK3KDAMetadataBuilder,
1033 num_speculative_tokens=0,
1034 full_cuda_graph=True,
1035 )
1036 actual = builder.build_for_cudagraph_capture(common_attn_metadata)
1037
1038 assert actual.num_prefills == 0
1039 assert actual.num_decodes == 4
1040 assert actual.has_initial_state is None
1041 staged = actual.non_spec_state_indices_tensor
1042 assert staged is not None
1043 assert staged.data_ptr() == builder.non_spec_state_indices_tensor.data_ptr()
1044 torch.testing.assert_close(staged, common_attn_metadata.block_table_tensor[:, 0])