[K3] support recoverssm for K3 (#51855)

Signed-off-by: zjy0516 <riverclouds.zhu@qq.com> Signed-off-by: Benjamin Chislett <bchislett@nvidia.com> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: Benjamin Chislett <bchislett@nvidia.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Jiangyun Zhu committed Aug 17, 2026 at 19:34 UTC 70afdedc1081d28c3eaae53bece8292298484c86
19 files changed +2235 -75
tests/models/kimi_k3/test_kda.py
+342
@@ -6,6 +6,8 @@ Compares chunk_kda against a naive recurrent reference (float32).
6 Uses torch.rand for q/k/v to match FLA's test pattern.
7 """
8
9 +from types import SimpleNamespace
10 +
11 import pytest
12 import torch
13 import torch.nn.functional as F
@@ -22,6 +24,12 @@ from vllm.models.kimi_k3.nvidia.kda import (
24 is_flashkda_supported,
25 is_fused_kda_decode_supported,
26 )
27 +from vllm.models.kimi_k3.nvidia.model import KimiLinearForCausalLM
28 +from vllm.models.kimi_k3.nvidia.ops import recoverssm as recoverssm_ops
29 +from vllm.models.kimi_k3.nvidia.ops.recoverssm import (
30 + KDARecoverSSMCommitContext,
31 + kda_recoverssm_verify,
32 +)
33 from vllm.models.kimi_k3.nvidia.ops.third_party.kda import (
34 chunk_kda,
35 chunk_kda_with_fused_gate,
@@ -48,6 +56,38 @@ PACKED_DECODE_IMPLS = {
56 }
57
58
59 +def test_kda_recoverssm_config_state_layout():
60 + vllm_config = SimpleNamespace(
61 + model_config=SimpleNamespace(
62 + dtype=torch.bfloat16,
63 + hf_config=SimpleNamespace(
64 + linear_attn_config={
65 + "num_heads": 4,
66 + "head_dim": 32,
67 + "short_conv_kernel_size": 4,
68 + }
69 + ),
70 + ),
71 + cache_config=SimpleNamespace(
72 + mamba_cache_dtype="auto",
73 + use_kda_recoverssm=True,
74 + ),
75 + parallel_config=SimpleNamespace(tensor_parallel_size=1),
76 + speculative_config=SimpleNamespace(num_speculative_tokens=2),
77 + )
78 +
79 + assert KimiLinearForCausalLM.get_mamba_state_dtype_from_config(vllm_config) == (
80 + torch.bfloat16,
81 + torch.float32,
82 + torch.float32,
83 + torch.bfloat16,
84 + )
85 + assert KimiLinearForCausalLM.get_mamba_state_shape_from_config(vllm_config)[2:] == (
86 + (4, 3, 32),
87 + (4, 3, 64),
88 + )
89 +
90 +
91 @torch.inference_mode()
92 def test_gather_initial_states_correctness():
93 row_size = 8 * 128 * 128
@@ -535,6 +575,308 @@ def test_kda_spec_decode_correctness(
575 assert torch.isnan(output_storage[..., H * D :]).all()
576
577
578 +@pytest.mark.parametrize(
579 + (
580 + "conv_state_dim_first",
581 + "use_request_indices",
582 + "lower_bound",
583 + "align_mode",
584 + ),
585 + [
586 + pytest.param(False, False, None, False, id="baseline"),
587 + pytest.param(True, True, -5.0, True, id="all-features"),
588 + pytest.param(False, True, -5.0, False, id="request-indexed"),
589 + pytest.param(True, False, None, True, id="aligned"),
590 + ],
591 +)
592 +@torch.inference_mode()
593 +def test_kda_recoverssm_verify_and_group_commit(
594 + monkeypatch: pytest.MonkeyPatch,
595 + lower_bound: float | None,
596 + use_request_indices: bool,
597 + conv_state_dim_first: bool,
598 + align_mode: bool,
599 +):
600 + monkeypatch.setattr(
601 + recoverssm_ops,
602 + "is_conv_state_dim_first",
603 + lambda: conv_state_dim_first,
604 + )
605 + num_layers, num_seqs, query_len = 2, 2, 8
606 + num_blocks, num_heads, dim = (7 if align_mode else 3), 4, 128
607 + total_tokens = num_seqs * query_len
608 + torch.manual_seed(20260808)
609 +
610 + q, k, v, raw_g = [
611 + torch.randn(
612 + 1,
613 + total_tokens,
614 + num_heads,
615 + dim,
616 + dtype=torch.bfloat16,
617 + device=DEVICE,
618 + )
619 + for _ in range(4)
620 + ]
621 + raw_beta = torch.randn(
622 + 1,
623 + total_tokens,
624 + num_heads,
625 + dtype=torch.bfloat16,
626 + device=DEVICE,
627 + )
628 + query_start_loc = torch.arange(
629 + 0,
630 + total_tokens + 1,
631 + query_len,
632 + dtype=torch.int32,
633 + device=DEVICE,
634 + )
635 + state_indices = torch.tensor(
636 + [5, 6] if align_mode else [1, 2], dtype=torch.int32, device=DEVICE
637 + )
638 + accepted = [2, 8]
639 + if use_request_indices:
640 + global_num_accepted = torch.tensor(
641 + [0, accepted[0], 0, accepted[1]],
642 + dtype=torch.int32,
643 + device=DEVICE,
644 + )
645 + request_indices = torch.tensor([1, 3], dtype=torch.int32, device=DEVICE)
646 + else:
647 + global_num_accepted = torch.tensor(accepted, dtype=torch.int32, device=DEVICE)
648 + request_indices = None
649 +
650 + block_table = None
651 + num_computed_tokens = None
652 + mamba_block_size = None
653 + if align_mode:
654 + batch_size = 4 if use_request_indices else num_seqs
655 + block_table = torch.full((batch_size, 2), -1, dtype=torch.int32, device=DEVICE)
656 + rows = (
657 + request_indices
658 + if request_indices is not None
659 + else torch.arange(num_seqs, device=DEVICE)
660 + )
661 + block_table[rows] = torch.tensor(
662 + [[1, 5], [2, 6]],
663 + dtype=torch.int32,
664 + device=DEVICE,
665 + )
666 + num_computed_tokens = torch.zeros(batch_size, dtype=torch.int32, device=DEVICE)
667 + num_computed_tokens[rows] = 4
668 + mamba_block_size = 8
669 +
670 + layers = []
671 + expected_outputs = []
672 + expected_states = []
673 + initial_states = []
674 + initial_conv_states = []
675 + history_len, conv_dim = 3, 12
676 + for layer_idx in range(num_layers):
677 + A_log = (
678 + 0.2 * torch.randn(num_heads, dtype=torch.float32, device=DEVICE)
679 + + layer_idx * 0.03
680 + ).contiguous()
681 + dt_bias = (
682 + 0.1 * torch.randn(num_heads, dim, dtype=torch.float32, device=DEVICE)
683 + ).contiguous()
684 + checkpoint = 0.01 * torch.randn(
685 + num_blocks,
686 + num_heads,
687 + dim,
688 + dim,
689 + dtype=torch.float32,
690 + device=DEVICE,
691 + )
692 + conv_shape = (
693 + (num_blocks, conv_dim, history_len + query_len - 1)
694 + if conv_state_dim_first
695 + else (num_blocks, history_len + query_len - 1, conv_dim)
696 + )
697 + conv_state = torch.randn(conv_shape, dtype=torch.bfloat16, device=DEVICE)
698 + correction_cache = torch.empty(
699 + num_blocks,
700 + num_heads,
701 + query_len,
702 + dim,
703 + dtype=torch.float32,
704 + device=DEVICE,
705 + )
706 + kg_cache = torch.empty(
707 + num_blocks,
708 + num_heads,
709 + query_len,
710 + 2 * dim,
711 + dtype=torch.bfloat16,
712 + device=DEVICE,
713 + )
714 + layer = SimpleNamespace(
715 + kv_cache=(
716 + conv_state,
717 + checkpoint,
718 + correction_cache,
719 + kg_cache,
720 + ),
721 + A_log=A_log,
722 + dt_bias=dt_bias,
723 + local_num_heads=num_heads,
724 + head_dim=dim,
725 + gate_lower_bound=lower_bound,
726 + )
727 + layers.append(layer)
728 + initial_states.append(checkpoint.clone())
729 + initial_conv_states.append(conv_state.clone())
730 +
731 + actual_output = kda_recoverssm_verify(
732 + q=q,
733 + k=k,
734 + v=v,
735 + raw_g=raw_g,
736 + raw_beta=raw_beta,
737 + A_log=A_log,
738 + dt_bias=dt_bias,
739 + lower_bound=lower_bound,
740 + checkpoint_state=checkpoint,
741 + correction_cache=correction_cache,
742 + kg_cache=kg_cache,
743 + query_start_loc=query_start_loc,
744 + state_indices=state_indices,
745 + spec_query_len=query_len,
746 + )
747 +
748 + normalized_q = q.float() * torch.rsqrt(
749 + q.float().square().sum(dim=-1, keepdim=True) + 1e-6
750 + )
751 + normalized_k = k.float() * torch.rsqrt(
752 + k.float().square().sum(dim=-1, keepdim=True) + 1e-6
753 + )
754 + gate_input = raw_g.float() + dt_bias.view(1, 1, num_heads, dim)
755 + if lower_bound is None:
756 + gate = -A_log.exp().view(1, 1, num_heads, 1) * F.softplus(gate_input)
757 + else:
758 + gate = lower_bound * torch.sigmoid(
759 + A_log.exp().view(1, 1, num_heads, 1) * gate_input
760 + )
761 + beta = raw_beta.float().sigmoid()
762 +
763 + reference_output = []
764 + committed_states = checkpoint.clone()
765 + for seq_idx, commit_len in enumerate(accepted):
766 + start = seq_idx * query_len
767 + end = start + query_len
768 + output, _ = naive_recurrent_kda(
769 + normalized_q[:, start:end],
770 + normalized_k[:, start:end],
771 + v[:, start:end],
772 + gate[:, start:end],
773 + beta[:, start:end],
774 + initial_state=checkpoint[state_indices[seq_idx]].transpose(-1, -2),
775 + )
776 + reference_output.append(output)
777 + _, committed_state = naive_recurrent_kda(
778 + normalized_q[:, start : start + commit_len],
779 + normalized_k[:, start : start + commit_len],
780 + v[:, start : start + commit_len],
781 + gate[:, start : start + commit_len],
782 + beta[:, start : start + commit_len],
783 + initial_state=checkpoint[state_indices[seq_idx]].transpose(-1, -2),
784 + output_final_state=True,
785 + )
786 + assert committed_state is not None
787 + final_block = state_indices[seq_idx]
788 + if align_mode:
789 + assert block_table is not None
790 + row = request_indices[seq_idx] if use_request_indices else seq_idx
791 + final_block = block_table[row, (4 + commit_len) // 8]
792 + committed_states[final_block] = committed_state.transpose(-1, -2)
793 + if align_mode and 4 + commit_len >= 8:
794 + _, boundary_state = naive_recurrent_kda(
795 + normalized_q[:, start : start + 4],
796 + normalized_k[:, start : start + 4],
797 + v[:, start : start + 4],
798 + gate[:, start : start + 4],
799 + beta[:, start : start + 4],
800 + initial_state=checkpoint[state_indices[seq_idx]].transpose(-1, -2),
801 + output_final_state=True,
802 + )
803 + assert boundary_state is not None
804 + assert block_table is not None
805 + row = request_indices[seq_idx] if use_request_indices else seq_idx
806 + committed_states[block_table[row, 0]] = boundary_state.transpose(-1, -2)
807 + expected_outputs.append(torch.cat(reference_output, dim=1))
808 + expected_states.append(committed_states)
809 + torch.testing.assert_close(checkpoint, initial_states[-1])
810 + torch.testing.assert_close(
811 + actual_output,
812 + expected_outputs[-1],
813 + atol=3e-2,
814 + rtol=3e-2,
815 + )
816 +
817 + context = KDARecoverSSMCommitContext.create(
818 + layers,
819 + spec_query_len=query_len,
820 + max_num_reqs=global_num_accepted.shape[0],
821 + )
822 + context.commit(
823 + global_num_accepted,
824 + state_indices,
825 + query_start_loc,
826 + request_indices=request_indices,
827 + block_table=block_table,
828 + num_computed_tokens=num_computed_tokens,
829 + mamba_block_size=mamba_block_size,
830 + )
831 +
832 + for layer_idx, layer in enumerate(layers):
833 + torch.testing.assert_close(
834 + layer.kv_cache[1],
835 + expected_states[layer_idx],
836 + atol=3e-3,
837 + rtol=3e-3,
838 + )
839 + for seq_idx, commit_len in enumerate(accepted):
840 + block = state_indices[seq_idx]
841 + if align_mode:
842 + assert block_table is not None
843 + row = request_indices[seq_idx] if use_request_indices else seq_idx
844 + block = block_table[row, (4 + commit_len) // 8]
845 + source_block = state_indices[seq_idx] if align_mode else block
846 + if conv_state_dim_first:
847 + actual_conv = layer.kv_cache[0][block, :, :history_len]
848 + expected_conv = initial_conv_states[layer_idx][
849 + source_block,
850 + :,
851 + commit_len - 1 : commit_len - 1 + history_len,
852 + ]
853 + else:
854 + actual_conv = layer.kv_cache[0][block, :history_len]
855 + expected_conv = initial_conv_states[layer_idx][
856 + source_block,
857 + commit_len - 1 : commit_len - 1 + history_len,
858 + ]
859 + torch.testing.assert_close(actual_conv, expected_conv)
860 + if align_mode and 4 + commit_len >= 8:
861 + assert block_table is not None
862 + boundary_block = block_table[row, 0]
863 + if conv_state_dim_first:
864 + actual_boundary_conv = layer.kv_cache[0][
865 + boundary_block, :, :history_len
866 + ]
867 + expected_boundary_conv = initial_conv_states[layer_idx][
868 + state_indices[seq_idx], :, 3 : 3 + history_len
869 + ]
870 + else:
871 + actual_boundary_conv = layer.kv_cache[0][
872 + boundary_block, :history_len
873 + ]
874 + expected_boundary_conv = initial_conv_states[layer_idx][
875 + state_indices[seq_idx], 3 : 3 + history_len
876 + ]
877 + torch.testing.assert_close(actual_boundary_conv, expected_boundary_conv)
878 +
879 +
880 @pytest.mark.parametrize(
881 ("num_heads", "num_seqs", "lower_bound", "fuse_output_norm"),
882 [
tests/models/kimi_k3/test_kda_metadata.py
+145 -4
@@ -2,6 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from dataclasses import fields
5 +from unittest.mock import Mock
6
7 import pytest
8 import torch
@@ -26,6 +27,9 @@ from vllm.v1.attention.backends.gdn_attn import (
27 GDNAttentionMetadata,
28 GDNAttentionMetadataBuilder,
29 )
30 +from vllm.v1.attention.backends.recoverssm_metadata import (
31 + RecoverSSMPostprocessMetadata,
32 +)
33 from vllm.v1.attention.backends.utils import (
34 NULL_BLOCK_ID,
35 mamba_get_block_table_tensor,
@@ -44,8 +48,12 @@ PRUNED_METADATA_FIELDS = {
48 }
49
50
47 -def _assert_matches_shared_gdn(reference, actual: KimiK3KDAMetadata):
48 - for field in fields(KimiK3KDAMetadata):
51 +def _assert_matches_shared_gdn(
52 + reference: GDNAttentionMetadata, actual: KimiK3KDAMetadata
53 +):
54 + assert actual.recoverssm_commit is None
55 + assert actual.recoverssm_context is None
56 + for field in fields(GDNAttentionMetadata):
57 actual_value = getattr(actual, field.name)
58 expected_value = getattr(reference, field.name)
59 if field.name in PRUNED_METADATA_FIELDS:
@@ -78,6 +86,7 @@ def _make_builder(
86 full_cuda_graph: bool,
87 device: torch.device = DEVICE,
88 mamba_cache_mode: str = "none",
89 + use_recoverssm: bool = False,
90 ) -> AttentionMetadataBuilder:
91 vllm_config = create_vllm_config(
92 model_name="Qwen/Qwen3.5-0.8B",
@@ -92,17 +101,24 @@ def _make_builder(
101 CUDAGraphMode.FULL_AND_PIECEWISE if full_cuda_graph else CUDAGraphMode.NONE
102 )
103 vllm_config.cache_config.mamba_cache_mode = mamba_cache_mode
95 - return builder_cls(
104 + vllm_config.cache_config.use_replayssm = use_recoverssm
105 + vllm_config.cache_config.use_kda_recoverssm = use_recoverssm
106 + builder = builder_cls(
107 kv_cache_spec=MambaSpec(
108 block_size=BLOCK_SIZE,
109 shapes=((16, 64),),
110 dtypes=(torch.float16,),
100 - num_speculative_blocks=num_speculative_tokens,
111 + mamba_cache_mode=mamba_cache_mode,
112 + num_speculative_blocks=(0 if use_recoverssm else num_speculative_tokens),
113 ),
114 layer_names=["layer.0"],
115 vllm_config=vllm_config,
116 device=device,
117 )
118 + if use_recoverssm:
119 + assert isinstance(builder, KimiK3KDAMetadataBuilder)
120 + builder.recoverssm_context = Mock()
121 + return builder
122
123
124 @pytest.mark.parametrize(
@@ -244,6 +260,99 @@ def test_mixed_regular_and_spec_decode_excludes_request_padding():
260 torch.testing.assert_close(actual.spec_token_indx, torch.tensor([1, 2, 3]))
261
262
263 +@pytest.mark.parametrize("mamba_cache_mode", ["none", "align"])
264 +def test_recoverssm_spec_uses_one_state_slot_and_current_window(
265 + mamba_cache_mode: str,
266 +):
267 + if mamba_cache_mode == "align" and not torch.cuda.is_available():
268 + pytest.skip("align metadata construction requires CUDA")
269 + device = torch.device("cuda") if mamba_cache_mode == "align" else DEVICE
270 + batch = BatchSpec(seq_lens=[100, 65, 20], query_lens=[1, 1, 3])
271 + common_attn_metadata = create_common_attn_metadata(
272 + batch, BLOCK_SIZE, device
273 + ).replace(is_prefilling=torch.tensor([True, True, False]))
274 + builder = _make_builder(
275 + KimiK3KDAMetadataBuilder,
276 + num_speculative_tokens=2,
277 + full_cuda_graph=False,
278 + device=device,
279 + mamba_cache_mode=mamba_cache_mode,
280 + use_recoverssm=True,
281 + )
282 + assert isinstance(builder, KimiK3KDAMetadataBuilder)
283 + context = builder.recoverssm_context
284 + assert context is not None
285 + actual = builder.build(
286 + 0,
287 + common_attn_metadata,
288 + num_decode_draft_tokens_cpu=torch.tensor([-1, -1, 2], dtype=torch.int32),
289 + num_accepted_tokens=torch.tensor([3, 2, 2], dtype=torch.int32, device=device),
290 + )
291 +
292 + assert actual.spec_state_indices_tensor is not None
293 + assert actual.spec_state_indices_tensor.shape == (1, 1)
294 + torch.testing.assert_close(
295 + actual.num_accepted_tokens,
296 + torch.ones(1, dtype=torch.int32, device=device),
297 + )
298 + commit_metadata = actual.recoverssm_commit
299 + assert commit_metadata is not None
300 + torch.testing.assert_close(
301 + commit_metadata.request_indices,
302 + torch.tensor([2], dtype=torch.int32, device=device),
303 + )
304 + assert actual.recoverssm_context is context
305 + num_accepted_tokens = torch.tensor([3, 2, 1], dtype=torch.int32, device=device)
306 +
307 + postprocess = actual.commit_recoverssm_state(num_accepted_tokens)
308 +
309 + if mamba_cache_mode == "none":
310 + assert commit_metadata.align is None
311 + assert postprocess is None
312 + else:
313 + assert isinstance(postprocess, RecoverSSMPostprocessMetadata)
314 + assert postprocess.num_spec_decodes == 1
315 + assert postprocess.request_indices is commit_metadata.request_indices
316 + assert postprocess.block_table is common_attn_metadata.block_table_tensor
317 + assert (
318 + postprocess.num_computed_tokens
319 + is common_attn_metadata.compute_num_computed_tokens()
320 + )
321 + assert postprocess.block_size == BLOCK_SIZE
322 + args = context.commit.call_args.args
323 + assert args[0] is num_accepted_tokens
324 + torch.testing.assert_close(args[1], commit_metadata.state_indices[:, 0])
325 + torch.testing.assert_close(args[2], commit_metadata.query_start_loc)
326 +
327 +
328 +def test_recoverssm_distinguishes_draftless_decode_from_one_token_prefill():
329 + batch = BatchSpec(seq_lens=[40, 30], query_lens=[1, 1])
330 + common_attn_metadata = create_common_attn_metadata(
331 + batch, BLOCK_SIZE, DEVICE
332 + ).replace(is_prefilling=torch.tensor([False, True]))
333 + actual = _make_builder(
334 + KimiK3KDAMetadataBuilder,
335 + num_speculative_tokens=2,
336 + full_cuda_graph=False,
337 + use_recoverssm=True,
338 + ).build(
339 + 0,
340 + common_attn_metadata,
341 + num_decode_draft_tokens_cpu=torch.full((2,), -1, dtype=torch.int32),
342 + num_accepted_tokens=torch.ones(2, dtype=torch.int32),
343 + )
344 +
345 + assert actual.num_spec_decodes == 1
346 + assert actual.num_decodes == 0
347 + assert actual.num_prefills == 1
348 + assert actual.spec_state_indices_tensor is not None
349 + assert actual.spec_state_indices_tensor.shape == (1, 1)
350 + torch.testing.assert_close(
351 + actual.spec_query_start_loc,
352 + torch.tensor([0, 1], dtype=torch.int32),
353 + )
354 +
355 +
356 @pytest.mark.parametrize(
357 ("seq_len", "expected_has_initial_state"),
358 [
@@ -307,6 +416,38 @@ def test_kimi_k3_kda_cudagraph_capture_matches_shared_gdn():
416 _assert_matches_shared_gdn(reference, actual)
417
418
419 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
420 +def test_recoverssm_spec_cudagraph_stages_one_checkpoint_per_request():
421 + device = torch.device("cuda")
422 + batch = BatchSpec(seq_lens=[50, 30], query_lens=[3, 3])
423 + common_attn_metadata = create_common_attn_metadata(
424 + batch, BLOCK_SIZE, device
425 + ).replace(is_prefilling=torch.tensor([False, False]))
426 + builder = _make_builder(
427 + KimiK3KDAMetadataBuilder,
428 + num_speculative_tokens=2,
429 + full_cuda_graph=True,
430 + device=device,
431 + use_recoverssm=True,
432 + )
433 + assert isinstance(builder, KimiK3KDAMetadataBuilder)
434 + assert builder.spec_state_indices_tensor.shape == (
435 + builder.vllm_config.scheduler_config.max_num_seqs,
436 + 1,
437 + )
438 + actual = builder.build_for_cudagraph_capture(common_attn_metadata)
439 +
440 + assert actual.spec_state_indices_tensor is not None
441 + assert actual.spec_state_indices_tensor.shape == (batch.batch_size, 1)
442 + assert actual.num_accepted_tokens is not None
443 + torch.testing.assert_close(
444 + actual.num_accepted_tokens,
445 + torch.ones(batch.batch_size, dtype=torch.int32, device=device),
446 + )
447 + assert actual.recoverssm_commit is not None
448 + assert actual.recoverssm_commit.request_indices is None
449 +
450 +
451 def test_kimi_k3_kda_backend_uses_private_metadata_builder():
452 assert KimiK3KDAAttentionBackend.get_builder_cls() is KimiK3KDAMetadataBuilder
453 assert KimiK3KDAAttentionBackend.is_ssm()
tests/models/test_registry.py
+3 -1
@@ -143,8 +143,10 @@ def test_registry_is_pp(model_arch, is_pp, init_cuda):
143 @pytest.mark.parametrize(
144 "model_arch,supported",
145 [
146 - # ReplaySSM is opt-in per model; only Nemotron-H sets the flag today.
146 + # ReplaySSM is opt-in per model.
147 ("NemotronHForCausalLM", True),
148 + ("KimiLinearForCausalLM", not current_platform.is_rocm()),
149 + ("KimiK3ForConditionalGeneration", not current_platform.is_rocm()),
150 ("Mamba2ForCausalLM", False),
151 ("Zamba2ForCausalLM", False),
152 ],
tests/test_config.py
+47
@@ -29,6 +29,7 @@ from vllm.config import (
29 from vllm.config.compilation import CompilationMode, CUDAGraphMode
30 from vllm.config.kernel import IrOpPriorityConfig
31 from vllm.config.load import LoadConfig
32 +from vllm.config.mamba import MambaBackendEnum
33 from vllm.config.utils import get_field
34 from vllm.config.vllm import OPTIMIZATION_LEVEL_TO_CONFIG, OptimizationLevel
35 from vllm.platforms import current_platform
@@ -37,6 +38,52 @@ from vllm.v1.attention.backend import AttentionCGSupport
38 DEVICE_TYPE = current_platform.device_type
39
40
41 +def test_kda_recoverssm_derivation_is_revalidated():
42 + config = SimpleNamespace(
43 + cache_config=SimpleNamespace(
44 + use_replayssm=True,
45 + use_kda_recoverssm=False,
46 + mamba_cache_mode="none",
47 + ),
48 + num_speculative_tokens=3,
49 + model_config=SimpleNamespace(
50 + supports_replayssm=True,
51 + architecture="KimiLinearForCausalLM",
52 + ),
53 + mamba_config=SimpleNamespace(
54 + backend=MambaBackendEnum.TRITON,
55 + enable_stochastic_rounding=False,
56 + ),
57 + parallel_config=SimpleNamespace(pipeline_parallel_size=1),
58 + kv_transfer_config=None,
59 + use_v2_model_runner=True,
60 + )
61 +
62 + VllmConfig.validate_mamba_cached_kernel(config)
63 + assert config.cache_config.use_replayssm
64 + assert config.cache_config.use_kda_recoverssm
65 +
66 + config.cache_config.mamba_cache_mode = "align"
67 + VllmConfig.validate_mamba_cached_kernel(config)
68 + config.use_v2_model_runner = False
69 + with pytest.raises(ValueError, match="VLLM_USE_V2_MODEL_RUNNER=1"):
70 + VllmConfig.validate_mamba_cached_kernel(config)
71 + config.use_v2_model_runner = True
72 + config.cache_config.mamba_cache_mode = "all"
73 + with pytest.raises(ValueError, match="only none and align"):
74 + VllmConfig.validate_mamba_cached_kernel(config)
75 + config.cache_config.mamba_cache_mode = "none"
76 +
77 + config.model_config.architecture = "NemotronHForCausalLM"
78 + with pytest.raises(ValueError, match="only supported for Kimi-K3 KDA"):
79 + VllmConfig.validate_mamba_cached_kernel(config)
80 +
81 + config.model_config.architecture = "KimiLinearForCausalLM"
82 + config.parallel_config.pipeline_parallel_size = 2
83 + with pytest.raises(ValueError, match="pipeline_parallel_size=1"):
84 + VllmConfig.validate_mamba_cached_kernel(config)
85 +
86 +
87 def test_compile_config_repr_succeeds():
88 # setup: VllmBackend mutates the config object
89 config = VllmConfig()
tests/v1/worker/test_mamba_hybrid_model_state.py
+67
@@ -1,11 +1,19 @@
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.platforms import current_platform
11 +from vllm.v1.attention.backends.recoverssm_metadata import (
12 + RecoverSSMMetadata,
13 + RecoverSSMPostprocessMetadata,
14 +)
15 from vllm.v1.worker.gpu.model_states.mamba_hybrid import MambaHybridModelState
16 +from vllm.v1.worker.gpu.model_states.recoverssm import RecoverSSMState
17
18
19 @pytest.mark.skipif(not current_platform.is_cuda(), reason="Requires CUDA")
@@ -18,6 +26,7 @@ def test_postprocess_state_scalar_with_int32_mapping(
26 (4,), 9, dtype=torch.int32, device="cuda"
27 )
28 state._align_mode = False
29 + state.recoverssm = None
30 state._mamba_ctx = None
31 idx_mapping = torch.tensor([2, -1, 0], dtype=torch.int32, device="cuda")
32
@@ -27,3 +36,61 @@ def test_postprocess_state_scalar_with_int32_mapping(
36 [expected_value, 9, expected_value, 9], dtype=torch.int32, device="cuda"
37 )
38 torch.testing.assert_close(state.num_accepted_tokens_gpu, expected)
39 +
40 +
41 +def test_recoverssm_commits_accepted_window_after_v2_sampling() -> None:
42 + state = RecoverSSMState()
43 + metadata = Mock(spec=RecoverSSMMetadata)
44 + metadata.commit_recoverssm_state.return_value = None
45 + num_sampled = torch.tensor([3, 1], dtype=torch.int32)
46 + idx_mapping = torch.tensor([0, 1], dtype=torch.int32)
47 + num_accepted_tokens = torch.ones(2, dtype=torch.int32)
48 + group = SimpleNamespace(layer_names=["layer"])
49 +
50 + state.record_step({"layer": metadata}, [[group]], for_capture=False)
51 + state.commit_step(
52 + num_sampled,
53 + idx_mapping,
54 + state_indices=None,
55 + num_accepted_tokens=num_accepted_tokens,
56 + )
57 + state.commit_step(
58 + num_sampled,
59 + idx_mapping,
60 + state_indices=None,
61 + num_accepted_tokens=num_accepted_tokens,
62 + )
63 +
64 + metadata.commit_recoverssm_state.assert_called_once_with(num_sampled)
65 +
66 +
67 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="Requires CUDA")
68 +def test_recoverssm_align_tracks_mixed_batch_state_and_neutralizes_copy_bias() -> None:
69 + state = object.__new__(MambaHybridModelState)
70 + state._align_mode = True
71 + state._mamba_ctx = None
72 + state._mamba_state_idx_gpu = torch.full((5,), -1, dtype=torch.int32, device="cuda")
73 + state.recoverssm = RecoverSSMState()
74 + state.num_accepted_tokens_gpu = torch.full(
75 + (5,), 9, dtype=torch.int32, device="cuda"
76 + )
77 + metadata = Mock(spec=RecoverSSMMetadata)
78 + metadata.commit_recoverssm_state.return_value = RecoverSSMPostprocessMetadata(
79 + num_spec_decodes=1,
80 + request_indices=torch.tensor([1], dtype=torch.int32, device="cuda"),
81 + num_computed_tokens=torch.tensor([6, 7], dtype=torch.int32, device="cuda"),
82 + block_size=8,
83 + block_table=torch.zeros((2, 4), dtype=torch.int32, device="cuda"),
84 + )
85 + num_sampled = torch.tensor([2, 3], dtype=torch.int32, device="cuda")
86 + idx_mapping = torch.tensor([3, 1], dtype=torch.int32, device="cuda")
87 + group = SimpleNamespace(layer_names=["layer"])
88 +
89 + state.recoverssm.record_step({"layer": metadata}, [[group]], for_capture=False)
90 +
91 + state.postprocess_state(idx_mapping, num_sampled)
92 +
93 + expected_state_indices = [-1, 1, -1, -1, -1]
94 + assert state._mamba_state_idx_gpu.tolist() == expected_state_indices
95 + expected_accepted = [9, 1, 9, 2, 9]
96 + assert state.num_accepted_tokens_gpu.tolist() == expected_accepted
tests/v1/worker/test_mamba_utils.py
+36
@@ -490,6 +490,42 @@ def test_stage_postprocess_inputs_to_gpu_asserts_on_missing_state_idx():
490 )
491
492
493 +def test_gpu_context_ignores_auxiliary_cache_tensors() -> None:
494 + device = torch.device("cpu")
495 + config = _TestConfig(num_layers=1)
496 + layer_names = ["layer_0"]
497 + kv_cache_config = _make_kv_cache_config(config, layer_names)
498 + conv_state = torch.empty(
499 + config.num_blocks,
500 + config.conv_width,
501 + config.conv_inner_dim,
502 + dtype=config.dtype,
503 + )
504 + temporal_state = torch.empty(
505 + config.num_blocks, config.temporal_state_dim, dtype=config.dtype
506 + )
507 + attention = MagicMock()
508 + attention.kv_cache = [
509 + conv_state,
510 + temporal_state,
511 + *(torch.empty(config.num_blocks, 1) for _ in range(4)),
512 + ]
513 + context = _make_gpu_ctx(config, kv_cache_config, device)
514 +
515 + context.initialize_from_forward_context(
516 + kv_cache_config,
517 + {"layer_0": attention},
518 + _COPY_FUNCS,
519 + [torch.zeros(1, 1, dtype=torch.int32)],
520 + )
521 +
522 + assert context.is_initialized
523 + assert context.state_base_addrs.tolist() == [
524 + conv_state.data_ptr(),
525 + temporal_state.data_ptr(),
526 + ]
527 +
528 +
529 def _run_gpu_postprocess(
530 gpu_ctx: MambaSpecDecodeGPUContext,
531 *,
vllm/config/cache.py
+4 -3
@@ -144,9 +144,8 @@ class CacheConfig:
144 caching is enabled.
145 """
146 replayssm_buffer_len: int = Field(default=16, gt=0)
147 - """ReplaySSM history buffer length B: with use_replayssm, standard decode
148 - caches recent SSM inputs in a size-B ring buffer and flushes the checkpoint
149 - state to HBM every B steps. Default 16."""
147 + """ReplaySSM history buffer length B for standard Mamba2 decode. Kimi-K3
148 + speculative decoding does not use B. Default 16."""
149 use_replayssm: bool = False
150 """Use the ReplaySSM Mamba2 decode kernel: cache recent SSM inputs and skip
151 the per-step full-state store, writing the checkpoint back only on flush.
@@ -154,6 +153,8 @@ class CacheConfig:
153 mamba backend; standard (non-speculative) decode only. In align mode flushes
154 are most efficient when mamba_block_size is a multiple of replayssm_buffer_len,
155 but this is not required."""
156 + use_kda_recoverssm: bool = field(default=False, init=False)
157 + """Whether Kimi-K3 KDA uses RecoverSSM speculative decode."""
158
159 # Will be set after profiling.
160 num_gpu_blocks: int | None = field(default=None, init=False)
vllm/config/vllm.py
+33 -8
@@ -2540,22 +2540,47 @@ class VllmConfig:
2540 @model_validator(mode="after")
2541 def validate_mamba_cached_kernel(self) -> "VllmConfig":
2542 if not self.cache_config.use_replayssm:
2543 + self.cache_config.use_kda_recoverssm = False
2544 return self
2544 - # ReplaySSM adds a 3-tensor ring to the mamba state; only models that
2545 - # opt in (supports_replayssm) build a consistent shape on both the layer
2546 - # and config paths. Reject others so the mamba page size cannot desync.
2545 + self.cache_config.use_kda_recoverssm = self.num_speculative_tokens > 0
2546 +
2547 if self.model_config is not None and not self.model_config.supports_replayssm:
2548 raise ValueError(
2549 - "--use-replayssm is only supported for Nemotron-H models "
2550 - f"(got architecture {self.model_config.architecture!r})"
2549 + "--use-replayssm is not supported for architecture "
2550 + f"{self.model_config.architecture!r}"
2551 )
2552 - if self.cache_config.mamba_cache_mode == "all":
2552 + if self.cache_config.use_kda_recoverssm:
2553 + if self.model_config is not None and self.model_config.architecture not in (
2554 + "KimiLinearForCausalLM",
2555 + "KimiK3ForConditionalGeneration",
2556 + ):
2557 + raise ValueError("RecoverSSM is only supported for Kimi-K3 KDA")
2558 + if self.mamba_config.enable_stochastic_rounding:
2559 + raise ValueError(
2560 + "RecoverSSM supports bfloat16/float32 "
2561 + "SSM state caches, not --enable-mamba-cache-stochastic-"
2562 + "rounding, which requires an explicit float16 cache"
2563 + )
2564 + if self.cache_config.mamba_cache_mode not in ("none", "align"):
2565 + raise ValueError(
2566 + "RecoverSSM supports only none and align Mamba cache modes"
2567 + )
2568 + if (
2569 + self.cache_config.mamba_cache_mode == "align"
2570 + and not self.use_v2_model_runner
2571 + ):
2572 + raise ValueError(
2573 + "RecoverSSM with align mode requires VLLM_USE_V2_MODEL_RUNNER=1"
2574 + )
2575 + if self.parallel_config.pipeline_parallel_size > 1:
2576 + raise ValueError(
2577 + "RecoverSSM currently requires pipeline_parallel_size=1"
2578 + )
2579 + elif self.cache_config.mamba_cache_mode == "all":
2580 raise ValueError(
2581 "--use-replayssm supports prefix caching only in align mode; "
2582 "pass --mamba-cache-mode align"
2583 )
2557 - if self.num_speculative_tokens > 0:
2558 - raise ValueError("--use-replayssm does not support speculative decoding")
2584 if self.mamba_config.backend != MambaBackendEnum.TRITON:
2585 raise ValueError("--use-replayssm requires --mamba-backend triton")
2586 if (
vllm/model_executor/layers/mamba/abstract.py
+5 -3
@@ -71,10 +71,12 @@ class MambaBase(AttentionLayerBase):
71 page_size_padded=page_size_padded,
72 mamba_type=self.mamba_type,
73 mamba_cache_mode=vllm_config.cache_config.mamba_cache_mode,
74 + # RecoverSSM verifies the whole window off one checkpoint, so it
75 + # never writes the baseline's per-draft-token state slots.
76 num_speculative_blocks=(
75 - vllm_config.speculative_config.num_speculative_tokens
76 - if vllm_config.speculative_config
77 - else 0
77 + 0
78 + if vllm_config.cache_config.use_kda_recoverssm
79 + else vllm_config.num_speculative_tokens
80 ),
81 )
82
vllm/model_executor/layers/mamba/mamba_utils.py
+30
@@ -136,6 +136,15 @@ class MambaStateDtypeCalculator:
136 state_dtype = get_kv_cache_torch_dtype(mamba_cache_dtype, model_dtype)
137 return (state_dtype, torch.float32)
138
139 + @classmethod
140 + def append_kda_recoverssm_record(
141 + cls,
142 + base_dtypes: tuple[torch.dtype, ...],
143 + model_dtype: ModelDType | torch.dtype,
144 + ) -> tuple[torch.dtype, ...]:
145 + activation_dtype = get_kv_cache_torch_dtype("auto", model_dtype)
146 + return (*base_dtypes, torch.float32, activation_dtype)
147 +
148
149 class MambaStateShapeCalculator:
150 @classmethod
@@ -293,6 +302,27 @@ class MambaStateShapeCalculator:
302 recurrent_state_shape = (divide(num_heads, tp_world_size), head_dim, head_dim)
303 return (conv_state_shape, recurrent_state_shape)
304
305 + @classmethod
306 + def append_kda_recoverssm_record(
307 + cls,
308 + base_shapes: tuple[tuple[int, int], tuple[int, int, int]],
309 + num_heads: int,
310 + head_dim: int,
311 + tp_world_size: int,
312 + spec_query_len: int,
313 + ) -> tuple[
314 + tuple[int, int],
315 + tuple[int, int, int],
316 + tuple[int, int, int],
317 + tuple[int, int, int],
318 + ]:
319 + local_num_heads = divide(num_heads, tp_world_size)
320 + return (
321 + *base_shapes,
322 + (local_num_heads, spec_query_len, head_dim),
323 + (local_num_heads, spec_query_len, 2 * head_dim),
324 + )
325 +
326
327 @dataclass
328 class MambaCopySpec:
vllm/model_executor/models/interfaces.py
+8 -2
@@ -75,6 +75,12 @@ MambaStateShapes: TypeAlias = (
75 | tuple[tuple[int, int, int]]
76 | tuple[tuple[int, int], tuple[int, int]]
77 | tuple[tuple[int, int], tuple[int, int, int]]
78 + | tuple[
79 + tuple[int, int],
80 + tuple[int, int, int],
81 + tuple[int, int, int],
82 + tuple[int, int, int],
83 + ]
84 )
85
86
@@ -1099,8 +1105,8 @@ def supports_mamba_prefix_caching(
1105
1106 @runtime_checkable
1107 class SupportsReplaySSM(Protocol):
1102 - """The interface for models whose Mamba2 layers support ReplaySSM cached
1103 - standard decode.
1108 + """The interface for models whose recurrent layers support ReplaySSM
1109 + cached decode.
1110
1111 This is currently experimental.
1112 """
vllm/models/kimi_k3/nvidia/kda.py
+73 -22
@@ -282,23 +282,37 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
282
283 def get_state_dtype(
284 self,
285 - ) -> tuple[torch.dtype, torch.dtype]:
285 + ) -> tuple[torch.dtype, ...]:
286 if self.model_config is None or self.cache_config is None:
287 raise ValueError("model_config and cache_config must be set")
288 - return MambaStateDtypeCalculator.kda_state_dtype(
288 + base_dtypes = MambaStateDtypeCalculator.kda_state_dtype(
289 self.model_config.dtype, self.cache_config.mamba_cache_dtype
290 )
291 + if self.cache_config.use_kda_recoverssm:
292 + return MambaStateDtypeCalculator.append_kda_recoverssm_record(
293 + base_dtypes, self.model_config.dtype
294 + )
295 + return base_dtypes
296
297 def get_state_shape(
298 self,
294 - ) -> tuple[tuple[int, ...], tuple[int, ...]]:
295 - return MambaStateShapeCalculator.kda_state_shape(
299 + ) -> tuple[tuple[int, ...], ...]:
300 + base_shapes = MambaStateShapeCalculator.kda_state_shape(
301 self.tp_size,
302 self.num_heads,
303 self.head_dim,
304 conv_kernel_size=self.conv_size,
305 num_spec=self.num_spec,
306 )
307 + if self.cache_config.use_kda_recoverssm:
308 + return MambaStateShapeCalculator.append_kda_recoverssm_record(
309 + base_shapes,
310 + self.num_heads,
311 + self.head_dim,
312 + tp_world_size=self.tp_size,
313 + spec_query_len=1 + self.num_spec,
314 + )
315 + return base_shapes
316
317 def __init__(
318 self,
@@ -308,6 +322,12 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
322 run_gemm_rs: bool = False,
323 ) -> None:
324 super().__init__(config, vllm_config, prefix)
325 + self.use_recoverssm = self.cache_config.use_kda_recoverssm
326 + if self.cache_config.use_replayssm and not self.use_recoverssm:
327 + raise ValueError(
328 + "Kimi-K3 supports --use-replayssm only with speculative decoding"
329 + )
330 + self.spec_query_len = 1 + self.num_spec
331
332 kda_config = config.linear_attn_config # type: ignore[attr-defined]
333 assert kda_config is not None, "linear_attn_config must be set"
@@ -371,7 +391,7 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
391 self.conv1d.weight.data = self.conv1d.weight.data.unsqueeze(1)
392 # Keep a width-major copy for fused decode without changing the layout
393 # consumed by the prefill and fallback decode kernels.
374 - conv_state_dtype, _ = self.get_state_dtype()
394 + conv_state_dtype = self.get_state_dtype()[0]
395 decode_conv1d_weight = None
396 if is_fused_kda_decode_supported(
397 self.local_num_heads,
@@ -567,7 +587,7 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
587 g1 = g1[:, :num_actual_tokens]
588 beta = beta[:, :num_actual_tokens]
589
570 - conv_state, recurrent_state = self.kv_cache
590 + conv_state, recurrent_state, *recoverssm_records = self.kv_cache
591 # The convolution kernels consume (..., dim, width - 1).
592 if not is_conv_state_dim_first():
593 conv_state = conv_state.transpose(-1, -2)
@@ -634,7 +654,11 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
654 assert spec_state_indices_tensor is not None
655 assert spec_query_start_loc is not None
656 spec_conv_indices = spec_state_indices_tensor[:, 0][: m.num_spec_decodes]
637 - spec_max_query_len = spec_state_indices_tensor.size(-1)
657 + spec_max_query_len = (
658 + self.spec_query_len
659 + if self.use_recoverssm
660 + else spec_state_indices_tensor.size(-1)
661 + )
662 spec_conv_out = torch.empty_like(mixed_qkv_spec)
663 mixed_qkv_spec = causal_conv1d_update(
664 mixed_qkv_spec,
@@ -659,21 +683,48 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
683 if m.num_prefills == 0 and m.num_decodes == 0
684 else None
685 )
662 - core_attn_out_spec, _ = fused_recurrent_kda(
663 - q=q_spec,
664 - k=k_spec,
665 - v=v_spec,
666 - raw_g=g1_spec,
667 - raw_beta=beta_spec,
668 - A_log=self.A_log,
669 - dt_bias=self.dt_bias,
670 - lower_bound=self.gate_lower_bound,
671 - initial_state=recurrent_state,
672 - cu_seqlens=spec_cu_seqlens,
673 - ssm_state_indices=spec_state_indices_tensor,
674 - num_accepted_tokens=num_accepted_tokens,
675 - out=spec_out,
676 - )
686 + if self.use_recoverssm:
687 + from vllm.models.kimi_k3.nvidia.ops.recoverssm import (
688 + kda_recoverssm_verify,
689 + )
690 +
691 + if len(recoverssm_records) != 2:
692 + raise ValueError(
693 + "KDA RecoverSSM requires correction and key/gate buffers"
694 + )
695 + core_attn_out_spec = kda_recoverssm_verify(
696 + q=q_spec,
697 + k=k_spec,
698 + v=v_spec,
699 + raw_g=g1_spec,
700 + raw_beta=beta_spec,
701 + A_log=self.A_log,
702 + dt_bias=self.dt_bias,
703 + lower_bound=self.gate_lower_bound,
704 + checkpoint_state=recurrent_state,
705 + correction_cache=recoverssm_records[0],
706 + kg_cache=recoverssm_records[1],
707 + query_start_loc=spec_cu_seqlens,
708 + state_indices=spec_state_indices_tensor[: m.num_spec_decodes, 0],
709 + spec_query_len=self.spec_query_len,
710 + out=spec_out,
711 + )
712 + else:
713 + core_attn_out_spec, _ = fused_recurrent_kda(
714 + q=q_spec,
715 + k=k_spec,
716 + v=v_spec,
717 + raw_g=g1_spec,
718 + raw_beta=beta_spec,
719 + A_log=self.A_log,
720 + dt_bias=self.dt_bias,
721 + lower_bound=self.gate_lower_bound,
722 + initial_state=recurrent_state,
723 + cu_seqlens=spec_cu_seqlens,
724 + ssm_state_indices=spec_state_indices_tensor,
725 + num_accepted_tokens=num_accepted_tokens,
726 + out=spec_out,
727 + )
728
729 # Prefill or plain-decode path.
730 core_attn_out_non_spec = None
vllm/models/kimi_k3/nvidia/kda_metadata.py
+176 -12
@@ -6,13 +6,18 @@ The request classification and cudagraph staging intentionally mirror
6 ``GDNAttentionMetadataBuilder``. Kimi-K3 builds the metadata required by its
7 prefill KDA kernel internally, so this builder omits the shared FLA chunk
8 metadata construction.
9 +
10 +For Kimi-K3 speculative decoding, ``--use-replayssm`` selects the simplified
11 +RecoverSSM path implemented here instead of the Mamba2 ReplaySSM kernel.
12 """
13
11 -from dataclasses import dataclass
14 +from dataclasses import dataclass, field
15 from functools import cache
16 +from typing import TYPE_CHECKING
17
18 import torch
19
20 +from vllm.config import VllmConfig
21 from vllm.platforms import current_platform
22 from vllm.triton_utils import tl, triton
23 from vllm.utils.torch_utils import async_tensor_h2d
@@ -22,6 +27,10 @@ from vllm.v1.attention.backends.gdn_attn import (
27 GDNAttentionMetadata,
28 GDNAttentionMetadataBuilder,
29 )
30 +from vllm.v1.attention.backends.recoverssm_metadata import (
31 + RecoverSSMMetadata,
32 + RecoverSSMPostprocessMetadata,
33 +)
34 from vllm.v1.attention.backends.utils import (
35 NULL_BLOCK_ID,
36 compute_causal_conv1d_metadata,
@@ -29,6 +38,11 @@ from vllm.v1.attention.backends.utils import (
38 )
39 from vllm.v1.kv_cache_interface import MambaSpec
40
41 +if TYPE_CHECKING:
42 + from vllm.models.kimi_k3.nvidia.ops.recoverssm import (
43 + KDARecoverSSMCommitContext,
44 + )
45 +
46
47 @cache
48 def _metadata_launch_pdl() -> bool:
@@ -229,11 +243,103 @@ def stage_spec_decode_metadata(
243
244
245 @dataclass
232 -class KimiK3KDAMetadata(GDNAttentionMetadata):
233 - pass
246 +class KDARecoverSSMAlignMetadata:
247 + block_table: torch.Tensor
248 + num_computed_tokens: torch.Tensor
249 + block_size: int
250 +
251 +
252 +@dataclass
253 +class KDARecoverSSMCommitMetadata:
254 + state_indices: torch.Tensor
255 + query_start_loc: torch.Tensor
256 + request_indices: torch.Tensor | None
257 + align: KDARecoverSSMAlignMetadata | None
258 +
259 +
260 +@dataclass
261 +class KimiK3KDAMetadata(GDNAttentionMetadata, RecoverSSMMetadata):
262 + recoverssm_commit: KDARecoverSSMCommitMetadata | None = None
263 + recoverssm_context: "KDARecoverSSMCommitContext | None" = field(
264 + default=None, repr=False, compare=False
265 + )
266 +
267 + def commit_recoverssm_state(
268 + self, num_accepted_tokens: torch.Tensor
269 + ) -> RecoverSSMPostprocessMetadata | None:
270 + commit = self.recoverssm_commit
271 + if commit is None:
272 + return None
273 + context = self.recoverssm_context
274 + assert context is not None
275 + align = commit.align
276 + context.commit(
277 + num_accepted_tokens,
278 + commit.state_indices[: self.num_spec_decodes, 0],
279 + commit.query_start_loc[: self.num_spec_decodes + 1],
280 + request_indices=commit.request_indices,
281 + block_table=align.block_table if align is not None else None,
282 + num_computed_tokens=(
283 + align.num_computed_tokens if align is not None else None
284 + ),
285 + mamba_block_size=align.block_size if align is not None else None,
286 + )
287 + if align is None:
288 + return None
289 + return RecoverSSMPostprocessMetadata(
290 + num_spec_decodes=self.num_spec_decodes,
291 + request_indices=commit.request_indices,
292 + block_table=align.block_table,
293 + num_computed_tokens=align.num_computed_tokens,
294 + block_size=align.block_size,
295 + )
296
297
298 class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
299 + def __init__(
300 + self,
301 + kv_cache_spec: MambaSpec,
302 + layer_names: list[str],
303 + vllm_config: VllmConfig,
304 + device: torch.device,
305 + ) -> None:
306 + super().__init__(kv_cache_spec, layer_names, vllm_config, device)
307 + self.use_recoverssm = vllm_config.cache_config.use_kda_recoverssm
308 + self.spec_state_slots = 1 if self.use_recoverssm else self.num_spec + 1
309 + self.recoverssm_num_accepted_tokens: torch.Tensor | None = None
310 + self.recoverssm_context: KDARecoverSSMCommitContext | None = None
311 + if self.use_recoverssm:
312 + max_num_reqs = vllm_config.scheduler_config.max_num_seqs
313 + self.spec_state_indices_tensor = torch.empty(
314 + (max_num_reqs, 1),
315 + dtype=torch.int32,
316 + device=device,
317 + )
318 + self.recoverssm_num_accepted_tokens = torch.ones(
319 + max_num_reqs,
320 + dtype=torch.int32,
321 + device=device,
322 + )
323 +
324 + def _get_recoverssm_context(self) -> "KDARecoverSSMCommitContext":
325 + context = self.recoverssm_context
326 + if context is not None:
327 + return context
328 +
329 + from vllm.models.kimi_k3.nvidia.ops.recoverssm import (
330 + KDARecoverSSMCommitContext,
331 + )
332 +
333 + forward_context = self.vllm_config.compilation_config.static_forward_context
334 + layers = [forward_context[layer_name] for layer_name in self.layer_names]
335 + context = KDARecoverSSMCommitContext.create(
336 + layers,
337 + spec_query_len=1 + self.vllm_config.num_speculative_tokens,
338 + max_num_reqs=self.vllm_config.scheduler_config.max_num_seqs,
339 + )
340 + self.recoverssm_context = context
341 + return context
342 +
343 def build( # type: ignore[override]
344 self,
345 common_prefix_len: int,
@@ -263,14 +369,26 @@ class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
369 num_spec_decodes = 0
370 else:
371 spec_sequence_masks_cpu = num_decode_draft_tokens_cpu >= 0
266 - # A nonnegative entry identifies a spec request. If no draft token
267 - # was scheduled, process the whole batch as non-spec instead.
268 - if num_decode_draft_tokens_cpu[spec_sequence_masks_cpu].sum().item() == 0:
372 + if self.use_recoverssm:
373 + assert m.is_prefilling is not None
374 + assert m.is_prefilling.device.type == "cpu"
375 + active_decode_mask_cpu = (~m.is_prefilling) & (
376 + query_start_loc_cpu.diff() > 0
377 + )
378 + spec_sequence_masks_cpu |= active_decode_mask_cpu
379 + # Native KDA can use its regular decode path when no draft token
380 + # was scheduled. RecoverSSM must preserve its extended conv window.
381 + if (
382 + not self.use_recoverssm
383 + and num_decode_draft_tokens_cpu[spec_sequence_masks_cpu].sum().item()
384 + == 0
385 + ):
386 spec_sequence_masks_cpu = None
387 num_spec_decodes = 0
388 else:
389 num_spec_decodes = spec_sequence_masks_cpu.sum().item()
390
391 + spec_request_indices = None
392 if num_spec_decodes == 0:
393 # The runner orders ordinary decodes before prefills.
394 num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = (
@@ -289,6 +407,16 @@ class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
407 assert spec_sequence_masks_cpu is not None
408 assert num_accepted_tokens is not None
409 query_lens_cpu = query_start_loc_cpu.diff()
410 + if (
411 + self.use_recoverssm
412 + and torch.any(
413 + query_lens_cpu[spec_sequence_masks_cpu] > self.num_spec + 1
414 + ).item()
415 + ):
416 + raise ValueError(
417 + "KDA RecoverSSM speculative decode query length exceeds "
418 + f"its activation capacity ({self.num_spec + 1})"
419 + )
420 num_query_tokens = query_start_loc_cpu[-1].item()
421
422 # Exclude zero-length cudagraph padding from request-indexed
@@ -325,7 +453,7 @@ class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
453 non_spec_token_indx = None
454 # Real requests precede trailing cudagraph padding.
455 spec_state_indices_tensor = block_table_tensor[
328 - :num_spec_decodes, : self.num_spec + 1
456 + :num_spec_decodes, : self.spec_state_slots
457 ]
458 non_spec_state_indices_tensor = None
459 # Padding trails real requests, so this prefix already contains
@@ -339,6 +467,12 @@ class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
467 spec_sequence_masks_gpu = async_tensor_h2d(
468 spec_sequence_masks_cpu, device=query_start_loc.device
469 )
470 + if self.use_recoverssm:
471 + spec_request_indices = async_tensor_h2d(
472 + spec_sequence_masks_cpu.nonzero(as_tuple=True)[0],
473 + dtype=torch.int32,
474 + device=query_start_loc.device,
475 + )
476 spec_token_masks = torch.repeat_interleave(
477 spec_sequence_masks_gpu,
478 query_lens,
@@ -351,10 +485,10 @@ class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
485 non_spec_token_indx = index[:num_non_spec_tokens]
486 spec_token_indx = index[num_non_spec_tokens:]
487
354 - # Spec requests carry one state slot per speculative step;
355 - # non-spec requests use only their current state slot.
488 + # Native spec uses one state slot per step. RecoverSSM keeps
489 + # only the current checkpoint slot.
490 spec_state_indices_tensor = block_table_tensor[
357 - spec_sequence_masks_cpu, : self.num_spec + 1
491 + spec_sequence_masks_cpu, : self.spec_state_slots
492 ]
493 non_spec_state_indices_tensor = block_table_tensor[
494 active_non_spec_mask_cpu, 0
@@ -400,12 +534,18 @@ class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
534
535 num_accepted_tokens = num_accepted_tokens[spec_sequence_masks_cpu]
536
537 + if self.use_recoverssm:
538 + assert self.recoverssm_num_accepted_tokens is not None
539 + num_accepted_tokens = self.recoverssm_num_accepted_tokens[
540 + :num_spec_decodes
541 + ]
542 +
543 # Unlike the shared GDN layer, Kimi-K3's prefill KDA wrapper prepares
544 # its own chunk indices. Only causal-convolution metadata is needed here.
545 nums_dict, batch_ptr, token_chunk_offset_ptr = None, None, None
546 if num_prefills > 0:
547 has_initial_state = m.compute_num_computed_tokens() > 0
408 - if spec_sequence_masks_cpu is not None:
548 + if num_spec_decodes > 0:
549 has_initial_state = has_initial_state[active_non_spec_mask_cpu]
550 assert non_spec_query_start_loc_cpu is not None
551 nums_dict, batch_ptr, token_chunk_offset_ptr = (
@@ -426,7 +566,7 @@ class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
566 and num_spec_decodes > 0
567 and num_prefills == 0
568 and num_decodes == 0
429 - and num_spec_decodes <= self.decode_cudagraph_max_bs
569 + and batch_size <= self.spec_state_indices_tensor.shape[0]
570 and num_spec_decode_tokens <= self.decode_cudagraph_max_bs
571 ):
572 # Equivalent PyTorch staging:
@@ -463,6 +603,24 @@ class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
603 :batch_size
604 ]
605
606 + recoverssm_commit = None
607 + if self.use_recoverssm and num_spec_decodes > 0:
608 + assert spec_state_indices_tensor is not None
609 + assert spec_query_start_loc is not None
610 + align = None
611 + if self.kv_cache_spec.mamba_cache_mode == "align":
612 + align = KDARecoverSSMAlignMetadata(
613 + block_table=m.block_table_tensor,
614 + num_computed_tokens=m.compute_num_computed_tokens(),
615 + block_size=self.kv_cache_spec.block_size,
616 + )
617 + recoverssm_commit = KDARecoverSSMCommitMetadata(
618 + state_indices=spec_state_indices_tensor,
619 + query_start_loc=spec_query_start_loc,
620 + request_indices=spec_request_indices,
621 + align=align,
622 + )
623 +
624 return KimiK3KDAMetadata(
625 num_prefills=num_prefills,
626 num_prefill_tokens=num_prefill_tokens,
@@ -480,6 +638,12 @@ class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
638 spec_token_indx=spec_token_indx,
639 non_spec_token_indx=non_spec_token_indx,
640 num_accepted_tokens=num_accepted_tokens,
641 + recoverssm_commit=recoverssm_commit,
642 + recoverssm_context=(
643 + self._get_recoverssm_context()
644 + if recoverssm_commit is not None
645 + else None
646 + ),
647 nums_dict=nums_dict,
648 batch_ptr=batch_ptr,
649 token_chunk_offset_ptr=token_chunk_offset_ptr,
vllm/models/kimi_k3/nvidia/model.py
+28 -5
@@ -62,12 +62,14 @@ from vllm.model_executor.models.interfaces import (
62 EagleModelMixin,
63 HasInnerState,
64 IsHybrid,
65 + MambaStateShapes,
66 MixtureOfExperts,
67 SupportsEagle3,
68 SupportsEncoderCudaGraph,
69 SupportsMultiModal,
70 SupportsPP,
71 SupportsQuant,
72 + SupportsReplaySSM,
73 )
74 from vllm.model_executor.models.kimi_k25 import KimiK25MediaPixelInputs
75 from vllm.model_executor.models.kimi_k25_vit import (
@@ -1559,7 +1561,13 @@ class KimiLinearModel(nn.Module, EagleModelMixin, SupportsQuant):
1561
1562
1563 class KimiLinearForCausalLM(
1562 - nn.Module, HasInnerState, SupportsPP, MixtureOfExperts, IsHybrid, SupportsEagle3
1564 + nn.Module,
1565 + HasInnerState,
1566 + SupportsPP,
1567 + MixtureOfExperts,
1568 + IsHybrid,
1569 + SupportsEagle3,
1570 + SupportsReplaySSM,
1571 ):
1572 def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
1573 super().__init__()
@@ -1613,15 +1621,20 @@ class KimiLinearForCausalLM(
1621 def get_mamba_state_dtype_from_config(
1622 cls,
1623 vllm_config: "VllmConfig",
1616 - ) -> tuple[torch.dtype, torch.dtype]:
1617 - return MambaStateDtypeCalculator.kda_state_dtype(
1624 + ) -> tuple[torch.dtype, ...]:
1625 + dtypes = MambaStateDtypeCalculator.kda_state_dtype(
1626 vllm_config.model_config.dtype, vllm_config.cache_config.mamba_cache_dtype
1627 )
1628 + if vllm_config.cache_config.use_kda_recoverssm:
1629 + dtypes = MambaStateDtypeCalculator.append_kda_recoverssm_record(
1630 + dtypes, vllm_config.model_config.dtype
1631 + )
1632 + return dtypes
1633
1634 @classmethod
1635 def get_mamba_state_shape_from_config(
1636 cls, vllm_config: "VllmConfig"
1624 - ) -> tuple[tuple[int, int], tuple[int, int, int]]:
1637 + ) -> MambaStateShapes:
1638 parallel_config = vllm_config.parallel_config
1639 hf_config = vllm_config.model_config.hf_config
1640 tp_size = parallel_config.tensor_parallel_size
@@ -1630,13 +1643,22 @@ class KimiLinearForCausalLM(
1643 if vllm_config.speculative_config
1644 else 0
1645 )
1633 - return MambaStateShapeCalculator.kda_state_shape(
1646 + shapes = MambaStateShapeCalculator.kda_state_shape(
1647 tp_size,
1648 hf_config.linear_attn_config["num_heads"],
1649 hf_config.linear_attn_config["head_dim"],
1650 conv_kernel_size=hf_config.linear_attn_config["short_conv_kernel_size"],
1651 num_spec=num_spec,
1652 )
1653 + if vllm_config.cache_config.use_kda_recoverssm:
1654 + return MambaStateShapeCalculator.append_kda_recoverssm_record(
1655 + shapes,
1656 + hf_config.linear_attn_config["num_heads"],
1657 + hf_config.linear_attn_config["head_dim"],
1658 + tp_world_size=tp_size,
1659 + spec_query_len=1 + num_spec,
1660 + )
1661 + return shapes
1662
1663 @classmethod
1664 def get_mamba_state_copy_func(
@@ -1697,6 +1719,7 @@ class KimiK3ForConditionalGeneration(
1719 SupportsEagle3,
1720 HasInnerState,
1721 IsHybrid,
1722 + SupportsReplaySSM,
1723 ):
1724 """Kimi-K3 model with Kimi-K2.5 vision and KimiLinear text."""
1725
vllm/models/kimi_k3/nvidia/ops/recoverssm.py new
+1067
@@ -0,0 +1,1067 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Kimi-K3 RecoverSSM speculative verify and accepted-state recovery."""
4 +
5 +from collections.abc import Sequence
6 +from dataclasses import dataclass
7 +from typing import Any
8 +
9 +import torch
10 +
11 +from vllm.model_executor.layers.mamba.mamba_utils import is_conv_state_dim_first
12 +from vllm.triton_utils import tl, triton
13 +from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
14 +
15 +
16 +@triton.jit
17 +def _kda_gate(
18 + raw_g,
19 + dt_bias,
20 + A,
21 + lower_bound,
22 + USE_LOWER_BOUND: tl.constexpr,
23 +):
24 + gate_input = raw_g + dt_bias
25 + if USE_LOWER_BOUND:
26 + return lower_bound * tl.sigmoid(A * gate_input)
27 + softplus_gate = tl.where(
28 + gate_input > 20.0,
29 + gate_input,
30 + tl.log(1.0 + tl.exp(gate_input)),
31 + )
32 + return -A * softplus_gate
33 +
34 +
35 +@triton.jit
36 +def _kda_recurrent_step(
37 + state,
38 + k,
39 + v,
40 + raw_g,
41 + raw_beta,
42 + dt_bias,
43 + A,
44 + lower_bound,
45 + USE_LOWER_BOUND: tl.constexpr,
46 +):
47 + normalized_k = k * tl.rsqrt(tl.sum(k * k) + 1e-6)
48 + gate = _kda_gate(
49 + raw_g,
50 + dt_bias,
51 + A,
52 + lower_bound,
53 + USE_LOWER_BOUND,
54 + )
55 +
56 + state *= tl.exp(gate)[None, :]
57 + correction = v - tl.sum(state * normalized_k[None, :], axis=1)
58 + correction *= tl.sigmoid(raw_beta)
59 + return state + correction[:, None] * normalized_k[None, :], correction
60 +
61 +
62 +@triton.jit
63 +def _kda_recoverssm_verify_kernel(
64 + q_ptr,
65 + k_ptr,
66 + v_ptr,
67 + raw_g_ptr,
68 + raw_beta_ptr,
69 + A_log_ptr,
70 + dt_bias_ptr,
71 + state_ptr,
72 + correction_cache_ptr,
73 + kg_cache_ptr,
74 + out_ptr,
75 + query_start_loc_ptr,
76 + state_indices_ptr,
77 + lower_bound,
78 + null_block_id,
79 + stride_q_token,
80 + stride_k_token,
81 + stride_v_token,
82 + stride_g_token,
83 + stride_beta_token,
84 + stride_state_block,
85 + stride_state_head,
86 + stride_state_v,
87 + stride_state_k,
88 + stride_correction_block,
89 + stride_correction_head,
90 + stride_correction_pos,
91 + stride_correction_dim,
92 + stride_kg_block,
93 + stride_kg_head,
94 + stride_kg_pos,
95 + stride_kg_dim,
96 + stride_out_token,
97 + stride_query_start_loc,
98 + stride_state_indices,
99 + K: tl.constexpr,
100 + V: tl.constexpr,
101 + BK: tl.constexpr,
102 + BV: tl.constexpr,
103 + SPEC_QUERY_LEN: tl.constexpr,
104 + USE_LOWER_BOUND: tl.constexpr,
105 +):
106 + pid_v = tl.program_id(0)
107 + pid_b = tl.program_id(1)
108 + pid_h = tl.program_id(2)
109 +
110 + bos = tl.load(query_start_loc_ptr + pid_b * stride_query_start_loc).to(tl.int64)
111 + eos = tl.load(query_start_loc_ptr + (pid_b + 1) * stride_query_start_loc).to(
112 + tl.int64
113 + )
114 + query_len = eos - bos
115 + state_idx = tl.load(state_indices_ptr + pid_b * stride_state_indices).to(tl.int64)
116 +
117 + offs_k = tl.arange(0, BK)
118 + offs_v = pid_v * BV + tl.arange(0, BV)
119 + mask_k = offs_k < K
120 + mask_v = offs_v < V
121 + mask_state = mask_v[:, None] & mask_k[None, :]
122 +
123 + if state_idx <= null_block_id:
124 + for token_offset in tl.static_range(SPEC_QUERY_LEN):
125 + token_valid = token_offset < query_len
126 + tl.store(
127 + out_ptr + (bos + token_offset) * stride_out_token + pid_h * V + offs_v,
128 + tl.zeros([BV], dtype=tl.float32),
129 + mask=token_valid & mask_v,
130 + )
131 + return
132 +
133 + state_ptrs = (
134 + state_ptr
135 + + state_idx * stride_state_block
136 + + pid_h * stride_state_head
137 + + offs_v[:, None] * stride_state_v
138 + + offs_k[None, :] * stride_state_k
139 + )
140 + state = tl.load(state_ptrs, mask=mask_state, other=0.0).to(tl.float32)
141 + A = tl.exp(tl.load(A_log_ptr + pid_h).to(tl.float32))
142 +
143 + for token_offset in tl.static_range(SPEC_QUERY_LEN):
144 + token_valid = token_offset < query_len
145 + token = bos + token_offset
146 + q = tl.load(
147 + q_ptr + token * stride_q_token + pid_h * K + offs_k,
148 + mask=token_valid & mask_k,
149 + other=0.0,
150 + ).to(tl.float32)
151 + k = tl.load(
152 + k_ptr + token * stride_k_token + pid_h * K + offs_k,
153 + mask=token_valid & mask_k,
154 + other=0.0,
155 + ).to(tl.float32)
156 + v = tl.load(
157 + v_ptr + token * stride_v_token + pid_h * V + offs_v,
158 + mask=token_valid & mask_v,
159 + other=0.0,
160 + ).to(tl.float32)
161 + raw_g = tl.load(
162 + raw_g_ptr + token * stride_g_token + pid_h * K + offs_k,
163 + mask=token_valid & mask_k,
164 + other=0.0,
165 + ).to(tl.float32)
166 + raw_beta = tl.load(
167 + raw_beta_ptr + token * stride_beta_token + pid_h,
168 + mask=token_valid,
169 + other=0.0,
170 + ).to(tl.float32)
171 +
172 + q *= tl.rsqrt(tl.sum(q * q) + 1e-6) * (K**-0.5)
173 + dt_bias = tl.load(dt_bias_ptr + pid_h * K + offs_k, mask=mask_k, other=0.0).to(
174 + tl.float32
175 + )
176 + updated_state, correction = _kda_recurrent_step(
177 + state,
178 + k,
179 + v,
180 + raw_g,
181 + raw_beta,
182 + dt_bias,
183 + A,
184 + lower_bound,
185 + USE_LOWER_BOUND,
186 + )
187 + state = tl.where(token_valid, updated_state, state)
188 +
189 + out = tl.sum(state * q[None, :], axis=1)
190 + tl.store(
191 + out_ptr + token * stride_out_token + pid_h * V + offs_v,
192 + out,
193 + mask=token_valid & mask_v,
194 + )
195 +
196 + correction_ptr = (
197 + correction_cache_ptr
198 + + state_idx * stride_correction_block
199 + + pid_h * stride_correction_head
200 + + token_offset * stride_correction_pos
201 + )
202 + tl.store(
203 + correction_ptr + offs_v * stride_correction_dim,
204 + correction,
205 + mask=token_valid & mask_v,
206 + )
207 + if pid_v == 0:
208 + kg_ptr = (
209 + kg_cache_ptr
210 + + state_idx * stride_kg_block
211 + + pid_h * stride_kg_head
212 + + token_offset * stride_kg_pos
213 + )
214 + tl.store(
215 + kg_ptr + offs_k * stride_kg_dim,
216 + k,
217 + mask=token_valid & mask_k,
218 + )
219 + tl.store(
220 + kg_ptr + (K + offs_k) * stride_kg_dim,
221 + raw_g,
222 + mask=token_valid & mask_k,
223 + )
224 +
225 +
226 +@triton.heuristics(
227 + {
228 + "HAS_REQUEST_INDICES": lambda args: args["request_indices_ptr"] is not None,
229 + "ALIGN_MODE": lambda args: args["block_table_ptr"] is not None,
230 + }
231 +)
232 +@triton.jit
233 +def _prepare_commit_plan_kernel(
234 + num_accepted_ptr,
235 + request_indices_ptr,
236 + state_indices_ptr,
237 + query_start_loc_ptr,
238 + block_table_ptr,
239 + num_computed_ptr,
240 + commit_lens_ptr,
241 + final_state_indices_ptr,
242 + boundary_state_indices_ptr,
243 + boundary_recovery_lens_ptr,
244 + null_block_id,
245 + mamba_block_size,
246 + block_table_width,
247 + stride_num_accepted,
248 + stride_request_indices,
249 + stride_state_indices,
250 + stride_query_start_loc,
251 + stride_block_table_row,
252 + stride_block_table_col,
253 + stride_num_computed,
254 + SPEC_QUERY_LEN: tl.constexpr,
255 + HAS_REQUEST_INDICES: tl.constexpr,
256 + ALIGN_MODE: tl.constexpr,
257 +):
258 + spec_idx = tl.program_id(0)
259 + source_state_idx = tl.load(state_indices_ptr + spec_idx * stride_state_indices).to(
260 + tl.int64
261 + )
262 + request_idx = spec_idx
263 + if HAS_REQUEST_INDICES:
264 + request_idx = tl.load(
265 + request_indices_ptr + spec_idx * stride_request_indices
266 + ).to(tl.int64)
267 + num_accepted = tl.load(num_accepted_ptr + request_idx * stride_num_accepted).to(
268 + tl.int32
269 + )
270 + bos = tl.load(query_start_loc_ptr + spec_idx * stride_query_start_loc).to(tl.int64)
271 + eos = tl.load(query_start_loc_ptr + (spec_idx + 1) * stride_query_start_loc).to(
272 + tl.int64
273 + )
274 + query_len = (eos - bos).to(tl.int32)
275 + commit_len = tl.minimum(tl.maximum(num_accepted, 0), query_len)
276 + commit_len = tl.minimum(commit_len, SPEC_QUERY_LEN)
277 +
278 + final_state_idx = source_state_idx
279 + boundary_state_idx = null_block_id
280 + boundary_recovery_len = 0
281 + if ALIGN_MODE:
282 + num_computed = tl.load(num_computed_ptr + request_idx * stride_num_computed).to(
283 + tl.int32
284 + )
285 + final_num_computed = num_computed + commit_len
286 + final_state_col = tl.minimum(
287 + final_num_computed // mamba_block_size, block_table_width - 1
288 + )
289 + final_state_idx = tl.load(
290 + block_table_ptr
291 + + request_idx * stride_block_table_row
292 + + final_state_col * stride_block_table_col
293 + ).to(tl.int64)
294 + next_boundary = (num_computed // mamba_block_size + 1) * mamba_block_size
295 + crosses_boundary = final_num_computed >= next_boundary
296 + boundary_recovery_len = next_boundary - num_computed
297 + boundary_state_idx = tl.load(
298 + block_table_ptr
299 + + request_idx * stride_block_table_row
300 + + (next_boundary // mamba_block_size - 1) * stride_block_table_col,
301 + mask=crosses_boundary,
302 + other=null_block_id,
303 + ).to(tl.int64)
304 + valid = (source_state_idx > null_block_id) & (commit_len > 0)
305 + tl.store(commit_lens_ptr + spec_idx, tl.where(valid, commit_len, 0))
306 + tl.store(
307 + final_state_indices_ptr + spec_idx,
308 + tl.where(valid, final_state_idx, null_block_id),
309 + )
310 + tl.store(
311 + boundary_state_indices_ptr + spec_idx,
312 + tl.where(valid, boundary_state_idx, null_block_id),
313 + )
314 + tl.store(
315 + boundary_recovery_lens_ptr + spec_idx,
316 + tl.where(valid, boundary_recovery_len, 0),
317 + )
318 +
319 +
320 +@triton.jit
321 +def _compact_conv_state_kernel(
322 + conv_state_ref_ptr,
323 + conv_state_base_addrs_ptr,
324 + conv_state_block_strides_ptr,
325 + conv_state_dim_strides_ptr,
326 + conv_state_token_strides_ptr,
327 + state_indices_ptr,
328 + commit_lens_ptr,
329 + final_state_indices_ptr,
330 + boundary_state_indices_ptr,
331 + boundary_recovery_lens_ptr,
332 + null_block_id,
333 + conv_dim,
334 + conv_history_len,
335 + stride_state_indices,
336 + BLOCK_D: tl.constexpr,
337 + BLOCK_HISTORY: tl.constexpr,
338 + ALIGN_MODE: tl.constexpr,
339 +):
340 + pid_d = tl.program_id(0)
341 + pid_b = tl.program_id(1)
342 + pid_l = tl.program_id(2)
343 + source_state_idx = tl.load(state_indices_ptr + pid_b * stride_state_indices).to(
344 + tl.int64
345 + )
346 + if source_state_idx <= null_block_id:
347 + return
348 +
349 + commit_len = tl.load(commit_lens_ptr + pid_b)
350 + if commit_len == 0:
351 + return
352 + final_state_idx = tl.load(final_state_indices_ptr + pid_b).to(tl.int64)
353 + boundary_state_idx = tl.load(boundary_state_indices_ptr + pid_b).to(tl.int64)
354 + boundary_recovery_len = tl.load(boundary_recovery_lens_ptr + pid_b)
355 +
356 + if final_state_idx <= null_block_id:
357 + return
358 +
359 + base_addr = tl.load(conv_state_base_addrs_ptr + pid_l)
360 + block_stride = tl.load(conv_state_block_strides_ptr + pid_l)
361 + dim_stride = tl.load(conv_state_dim_strides_ptr + pid_l)
362 + token_stride = tl.load(conv_state_token_strides_ptr + pid_l)
363 + conv_state_ptr = base_addr.to(tl.pointer_type(conv_state_ref_ptr.dtype.element_ty))
364 + source_ptr = conv_state_ptr + source_state_idx * block_stride
365 + final_ptr = conv_state_ptr + final_state_idx * block_stride
366 +
367 + offs_d = pid_d * BLOCK_D + tl.arange(0, BLOCK_D)
368 + offs_h = tl.arange(0, BLOCK_HISTORY)
369 + mask = (offs_d[:, None] < conv_dim) & (offs_h[None, :] < conv_history_len)
370 + final_values = tl.load(
371 + source_ptr
372 + + offs_d[:, None] * dim_stride
373 + + (commit_len - 1 + offs_h[None, :]) * token_stride,
374 + mask=mask,
375 + )
376 + if ALIGN_MODE:
377 + boundary_values = tl.load(
378 + source_ptr
379 + + offs_d[:, None] * dim_stride
380 + + (boundary_recovery_len - 1 + offs_h[None, :]) * token_stride,
381 + mask=mask & (boundary_state_idx > null_block_id),
382 + )
383 + boundary_ptr = conv_state_ptr + boundary_state_idx * block_stride
384 + tl.store(
385 + boundary_ptr
386 + + offs_d[:, None] * dim_stride
387 + + offs_h[None, :] * token_stride,
388 + boundary_values,
389 + mask=mask & (boundary_state_idx > null_block_id),
390 + )
391 + tl.store(
392 + final_ptr + offs_d[:, None] * dim_stride + offs_h[None, :] * token_stride,
393 + final_values,
394 + mask=mask,
395 + )
396 +
397 +
398 +@triton.jit
399 +def _commit_kda_state_kernel(
400 + state_ref_ptr,
401 + state_base_addrs_ptr,
402 + state_block_strides_ptr,
403 + correction_cache_ref_ptr,
404 + correction_cache_base_addrs_ptr,
405 + correction_cache_block_strides_ptr,
406 + kg_cache_ref_ptr,
407 + kg_cache_base_addrs_ptr,
408 + kg_cache_block_strides_ptr,
409 + A_log_ptr,
410 + dt_bias_ptr,
411 + state_indices_ptr,
412 + commit_lens_ptr,
413 + final_state_indices_ptr,
414 + boundary_state_indices_ptr,
415 + boundary_recovery_lens_ptr,
416 + lower_bound,
417 + null_block_id,
418 + stride_state_head,
419 + stride_state_v,
420 + stride_state_k,
421 + stride_correction_cache_head,
422 + stride_correction_cache_pos,
423 + stride_correction_cache_dim,
424 + stride_kg_cache_head,
425 + stride_kg_cache_pos,
426 + stride_kg_cache_dim,
427 + stride_A_layer,
428 + stride_A_head,
429 + stride_dt_bias_layer,
430 + stride_dt_bias_head,
431 + stride_dt_bias_dim,
432 + stride_state_indices,
433 + K: tl.constexpr,
434 + V: tl.constexpr,
435 + BK: tl.constexpr,
436 + BV: tl.constexpr,
437 + NUM_HEADS: tl.constexpr,
438 + USE_LOWER_BOUND: tl.constexpr,
439 + ALIGN_MODE: tl.constexpr,
440 +):
441 + pid_v = tl.program_id(0)
442 + pid_b = tl.program_id(1)
443 + pid_lh = tl.program_id(2)
444 + pid_l = pid_lh // NUM_HEADS
445 + pid_h = pid_lh % NUM_HEADS
446 +
447 + source_state_idx = tl.load(state_indices_ptr + pid_b * stride_state_indices).to(
448 + tl.int64
449 + )
450 + if source_state_idx <= null_block_id:
451 + return
452 + commit_len = tl.load(commit_lens_ptr + pid_b)
453 + if commit_len == 0:
454 + return
455 + final_state_idx = tl.load(final_state_indices_ptr + pid_b).to(tl.int64)
456 + boundary_state_idx = tl.load(boundary_state_indices_ptr + pid_b).to(tl.int64)
457 + boundary_recovery_len = tl.load(boundary_recovery_lens_ptr + pid_b)
458 +
459 + if final_state_idx <= null_block_id:
460 + return
461 +
462 + state_base_addr = tl.load(state_base_addrs_ptr + pid_l)
463 + state_block_stride = tl.load(state_block_strides_ptr + pid_l)
464 + state_ptr = state_base_addr.to(tl.pointer_type(state_ref_ptr.dtype.element_ty))
465 + source_state_ptr = (
466 + state_ptr + source_state_idx * state_block_stride + pid_h * stride_state_head
467 + )
468 +
469 + correction_cache_base_addr = tl.load(correction_cache_base_addrs_ptr + pid_l)
470 + correction_cache_block_stride = tl.load(correction_cache_block_strides_ptr + pid_l)
471 + correction_cache_ptr = correction_cache_base_addr.to(
472 + tl.pointer_type(correction_cache_ref_ptr.dtype.element_ty)
473 + )
474 + correction_cache_ptr += (
475 + source_state_idx * correction_cache_block_stride
476 + + pid_h * stride_correction_cache_head
477 + )
478 + kg_cache_base_addr = tl.load(kg_cache_base_addrs_ptr + pid_l)
479 + kg_cache_block_stride = tl.load(kg_cache_block_strides_ptr + pid_l)
480 + kg_cache_ptr = kg_cache_base_addr.to(
481 + tl.pointer_type(kg_cache_ref_ptr.dtype.element_ty)
482 + )
483 + kg_cache_ptr += (
484 + source_state_idx * kg_cache_block_stride + pid_h * stride_kg_cache_head
485 + )
486 +
487 + offs_k = tl.arange(0, BK)
488 + offs_v = pid_v * BV + tl.arange(0, BV)
489 + mask_k = offs_k < K
490 + mask_v = offs_v < V
491 + mask_state = mask_v[:, None] & mask_k[None, :]
492 + state_ptrs = (
493 + source_state_ptr
494 + + offs_v[:, None] * stride_state_v
495 + + offs_k[None, :] * stride_state_k
496 + )
497 + initial_state = tl.load(state_ptrs, mask=mask_state, other=0.0).to(tl.float32)
498 + A = tl.exp(
499 + tl.load(A_log_ptr + pid_l * stride_A_layer + pid_h * stride_A_head).to(
500 + tl.float32
501 + )
502 + )
503 +
504 + dt_bias = tl.load(
505 + dt_bias_ptr
506 + + pid_l * stride_dt_bias_layer
507 + + pid_h * stride_dt_bias_head
508 + + offs_k * stride_dt_bias_dim,
509 + mask=mask_k,
510 + other=0.0,
511 + ).to(tl.float32)
512 + final_decay = tl.full([BK], 1.0, tl.float32)
513 + final_correction = tl.zeros([BV, BK], tl.float32)
514 + boundary_decay = tl.full([BK], 1.0, tl.float32)
515 + boundary_correction = tl.zeros([BV, BK], tl.float32)
516 +
517 + for reverse_offset in range(commit_len):
518 + token_offset = commit_len - reverse_offset - 1
519 + correction_ptr = (
520 + correction_cache_ptr + token_offset * stride_correction_cache_pos
521 + )
522 + kg_ptr = kg_cache_ptr + token_offset * stride_kg_cache_pos
523 + k = tl.load(
524 + kg_ptr + offs_k * stride_kg_cache_dim,
525 + mask=mask_k,
526 + other=0.0,
527 + ).to(tl.float32)
528 + correction = tl.load(
529 + correction_ptr + offs_v * stride_correction_cache_dim,
530 + mask=mask_v,
531 + other=0.0,
532 + ).to(tl.float32)
533 + raw_g = tl.load(
534 + kg_ptr + (K + offs_k) * stride_kg_cache_dim,
535 + mask=mask_k,
536 + other=0.0,
537 + ).to(tl.float32)
538 + normalized_k = k * tl.rsqrt(tl.sum(k * k) + 1e-6)
539 + gate = _kda_gate(
540 + raw_g,
541 + dt_bias,
542 + A,
543 + lower_bound,
544 + USE_LOWER_BOUND,
545 + )
546 + update = correction[:, None] * normalized_k[None, :]
547 + decay = tl.exp(gate)
548 + final_correction += update * final_decay[None, :]
549 + final_decay *= decay
550 + if ALIGN_MODE:
551 + before_boundary = token_offset < boundary_recovery_len
552 + boundary_correction += tl.where(
553 + before_boundary,
554 + update * boundary_decay[None, :],
555 + 0.0,
556 + )
557 + boundary_decay *= tl.where(before_boundary, decay, 1.0)
558 +
559 + state = initial_state * final_decay[None, :] + final_correction
560 + if ALIGN_MODE:
561 + boundary_ptrs = (
562 + state_ptr
563 + + boundary_state_idx * state_block_stride
564 + + pid_h * stride_state_head
565 + + offs_v[:, None] * stride_state_v
566 + + offs_k[None, :] * stride_state_k
567 + )
568 + boundary_state = initial_state * boundary_decay[None, :] + boundary_correction
569 + tl.store(
570 + boundary_ptrs,
571 + boundary_state,
572 + mask=mask_state & (boundary_state_idx > null_block_id),
573 + )
574 +
575 + final_ptrs = (
576 + state_ptr
577 + + final_state_idx * state_block_stride
578 + + pid_h * stride_state_head
579 + + offs_v[:, None] * stride_state_v
580 + + offs_k[None, :] * stride_state_k
581 + )
582 + tl.store(final_ptrs, state, mask=mask_state)
583 +
584 +
585 +def kda_recoverssm_verify(
586 + q: torch.Tensor,
587 + k: torch.Tensor,
588 + v: torch.Tensor,
589 + raw_g: torch.Tensor,
590 + raw_beta: torch.Tensor,
591 + A_log: torch.Tensor,
592 + dt_bias: torch.Tensor,
593 + lower_bound: float | None,
594 + checkpoint_state: torch.Tensor,
595 + correction_cache: torch.Tensor,
596 + kg_cache: torch.Tensor,
597 + query_start_loc: torch.Tensor,
598 + state_indices: torch.Tensor,
599 + spec_query_len: int,
600 + out: torch.Tensor | None = None,
601 +) -> torch.Tensor:
602 + """Verify a KDA speculative window without modifying its checkpoint."""
603 + if q.ndim != 4 or q.shape[0] != 1:
604 + raise ValueError("KDA RecoverSSM q must have shape [1, tokens, heads, dim]")
605 + _, total_tokens, num_heads, key_dim = q.shape
606 + value_dim = v.shape[-1]
607 + if k.shape != q.shape or v.shape != (1, total_tokens, num_heads, value_dim):
608 + raise ValueError("KDA RecoverSSM q, k, and v shapes are incompatible")
609 + if raw_g.shape != q.shape or raw_beta.shape != (1, total_tokens, num_heads):
610 + raise ValueError("KDA RecoverSSM gate or beta shape is incompatible")
611 + if any(tensor.stride()[2:] != (key_dim, 1) for tensor in (q, k, raw_g)):
612 + raise ValueError("KDA RecoverSSM q, k, and gate heads must be contiguous")
613 + if v.stride()[2:] != (value_dim, 1) or raw_beta.stride(2) != 1:
614 + raise ValueError("KDA RecoverSSM v and beta heads must be contiguous")
615 + num_blocks = checkpoint_state.shape[0]
616 + if checkpoint_state.shape[1:] != (
617 + num_heads,
618 + value_dim,
619 + key_dim,
620 + ):
621 + raise ValueError("KDA RecoverSSM checkpoint shape is incompatible")
622 + expected_correction_shape = (
623 + num_blocks,
624 + num_heads,
625 + spec_query_len,
626 + value_dim,
627 + )
628 + if correction_cache.shape != expected_correction_shape:
629 + raise ValueError(
630 + f"KDA RecoverSSM correction buffer needs shape {expected_correction_shape}"
631 + )
632 + expected_kg_shape = (num_blocks, num_heads, spec_query_len, 2 * key_dim)
633 + if kg_cache.shape != expected_kg_shape:
634 + raise ValueError(
635 + f"KDA RecoverSSM key/gate buffer needs shape {expected_kg_shape}"
636 + )
637 + if correction_cache.dtype != torch.float32:
638 + raise ValueError("KDA RecoverSSM correction buffer must use float32")
639 + if kg_cache.dtype != k.dtype:
640 + raise ValueError("KDA RecoverSSM key/gate buffer must match activation dtype")
641 + if A_log.shape != (num_heads,) or dt_bias.numel() != num_heads * key_dim:
642 + raise ValueError("KDA RecoverSSM gate parameters are incompatible")
643 + if not A_log.is_contiguous() or not dt_bias.is_contiguous():
644 + raise ValueError("KDA RecoverSSM gate parameters must be contiguous")
645 + batch = state_indices.shape[0]
646 + if query_start_loc.shape[0] != batch + 1:
647 + raise ValueError("KDA RecoverSSM query metadata is incompatible")
648 + if total_tokens > batch * spec_query_len:
649 + raise ValueError(
650 + "KDA RecoverSSM speculative decode input exceeds its activation capacity"
651 + )
652 + if out is None:
653 + out = torch.empty_like(v)
654 + if out.shape != v.shape:
655 + raise ValueError("KDA RecoverSSM output shape is incompatible")
656 + if out.stride()[2:] != (value_dim, 1):
657 + raise ValueError("KDA RecoverSSM output heads must be contiguous")
658 + device = q.device
659 + if any(
660 + tensor.device != device
661 + for tensor in (
662 + k,
663 + v,
664 + raw_g,
665 + raw_beta,
666 + A_log,
667 + dt_bias,
668 + checkpoint_state,
669 + correction_cache,
670 + kg_cache,
671 + query_start_loc,
672 + state_indices,
673 + out,
674 + )
675 + ):
676 + raise ValueError("KDA RecoverSSM inputs must be on the same device")
677 + if total_tokens == 0:
678 + return out
679 +
680 + block_k = triton.next_power_of_2(key_dim)
681 + block_v = min(triton.next_power_of_2(value_dim), 32)
682 + grid = (triton.cdiv(value_dim, block_v), batch, num_heads)
683 + _kda_recoverssm_verify_kernel[grid](
684 + q,
685 + k,
686 + v,
687 + raw_g,
688 + raw_beta,
689 + A_log,
690 + dt_bias,
691 + checkpoint_state,
692 + correction_cache,
693 + kg_cache,
694 + out,
695 + query_start_loc,
696 + state_indices,
697 + lower_bound or 0.0,
698 + NULL_BLOCK_ID,
699 + q.stride(1),
700 + k.stride(1),
701 + v.stride(1),
702 + raw_g.stride(1),
703 + raw_beta.stride(1),
704 + checkpoint_state.stride(0),
705 + checkpoint_state.stride(1),
706 + checkpoint_state.stride(2),
707 + checkpoint_state.stride(3),
708 + correction_cache.stride(0),
709 + correction_cache.stride(1),
710 + correction_cache.stride(2),
711 + correction_cache.stride(3),
712 + kg_cache.stride(0),
713 + kg_cache.stride(1),
714 + kg_cache.stride(2),
715 + kg_cache.stride(3),
716 + out.stride(1),
717 + query_start_loc.stride(0),
718 + state_indices.stride(0),
719 + K=key_dim,
720 + V=value_dim,
721 + BK=block_k,
722 + BV=block_v,
723 + SPEC_QUERY_LEN=spec_query_len,
724 + USE_LOWER_BOUND=lower_bound is not None,
725 + num_warps=4,
726 + num_stages=2,
727 + )
728 + return out
729 +
730 +
731 +@dataclass
732 +class KDARecoverSSMCommitContext:
733 + conv_states: tuple[torch.Tensor, ...]
734 + conv_state_base_addrs: torch.Tensor
735 + conv_state_block_strides: torch.Tensor
736 + conv_state_dim_strides: torch.Tensor
737 + conv_state_token_strides: torch.Tensor
738 + conv_history_len: int
739 + checkpoints: tuple[torch.Tensor, ...]
740 + state_base_addrs: torch.Tensor
741 + state_block_strides: torch.Tensor
742 + correction_caches: tuple[torch.Tensor, ...]
743 + correction_cache_base_addrs: torch.Tensor
744 + correction_cache_block_strides: torch.Tensor
745 + kg_caches: tuple[torch.Tensor, ...]
746 + kg_cache_base_addrs: torch.Tensor
747 + kg_cache_block_strides: torch.Tensor
748 + commit_lens: torch.Tensor
749 + final_state_indices: torch.Tensor
750 + boundary_state_indices: torch.Tensor
751 + boundary_recovery_lens: torch.Tensor
752 + A_log: torch.Tensor
753 + dt_bias: torch.Tensor
754 + lower_bound: float | None
755 + spec_query_len: int
756 +
757 + @classmethod
758 + def create(
759 + cls,
760 + layers: Sequence[Any],
761 + *,
762 + spec_query_len: int,
763 + max_num_reqs: int,
764 + ) -> "KDARecoverSSMCommitContext":
765 + if not layers:
766 + raise ValueError("KDA RecoverSSM commit requires at least one layer")
767 + if any(len(layer.kv_cache) != 4 for layer in layers):
768 + raise ValueError(
769 + "KDA RecoverSSM pages must contain conv, state, correction, "
770 + "and key/gate"
771 + )
772 +
773 + conv_states = [layer.kv_cache[0] for layer in layers]
774 + if not is_conv_state_dim_first():
775 + conv_states = [state.transpose(-1, -2) for state in conv_states]
776 + checkpoints = [layer.kv_cache[1] for layer in layers]
777 + correction_caches = [layer.kv_cache[2] for layer in layers]
778 + kg_caches = [layer.kv_cache[3] for layer in layers]
779 + A_log = [layer.A_log for layer in layers]
780 + dt_bias = [
781 + layer.dt_bias.view(layer.local_num_heads, layer.head_dim)
782 + for layer in layers
783 + ]
784 + lower_bounds = {layer.gate_lower_bound for layer in layers}
785 + if len(lower_bounds) != 1:
786 + raise ValueError("KDA RecoverSSM layers need matching gate bounds")
787 +
788 + state_ref = checkpoints[0]
789 + if state_ref.ndim != 4:
790 + raise ValueError("KDA RecoverSSM checkpoint must be four-dimensional")
791 + num_blocks, num_heads, value_dim, key_dim = state_ref.shape
792 + for state in checkpoints:
793 + if (
794 + state.shape != state_ref.shape
795 + or state.dtype != state_ref.dtype
796 + or state.device != state_ref.device
797 + or state.stride()[1:] != state_ref.stride()[1:]
798 + ):
799 + raise ValueError(
800 + "KDA RecoverSSM layers need matching checkpoint layout"
801 + )
802 + expected_correction_shape = (
803 + num_blocks,
804 + num_heads,
805 + spec_query_len,
806 + value_dim,
807 + )
808 + correction_ref = correction_caches[0]
809 + for correction_cache in correction_caches:
810 + if (
811 + correction_cache.shape != expected_correction_shape
812 + or correction_cache.dtype != torch.float32
813 + or correction_cache.device != state_ref.device
814 + or correction_cache.stride()[1:] != correction_ref.stride()[1:]
815 + ):
816 + raise ValueError(
817 + "KDA RecoverSSM correction buffers need float32 shape "
818 + f"{expected_correction_shape}"
819 + )
820 + expected_kg_shape = (num_blocks, num_heads, spec_query_len, 2 * key_dim)
821 + kg_ref = kg_caches[0]
822 + for kg_cache in kg_caches:
823 + if (
824 + kg_cache.shape != expected_kg_shape
825 + or kg_cache.dtype != kg_ref.dtype
826 + or kg_cache.device != state_ref.device
827 + or kg_cache.stride()[1:] != kg_ref.stride()[1:]
828 + ):
829 + raise ValueError(
830 + f"KDA RecoverSSM key/gate buffers need shape {expected_kg_shape}"
831 + )
832 + if any(param.shape != (num_heads,) for param in A_log):
833 + raise ValueError("KDA RecoverSSM A_log shape is incompatible")
834 + if any(param.shape != (num_heads, key_dim) for param in dt_bias):
835 + raise ValueError("KDA RecoverSSM dt_bias shape is incompatible")
836 +
837 + conv_ref = conv_states[0]
838 + if conv_ref.ndim != 3:
839 + raise ValueError("KDA RecoverSSM conv state must be three-dimensional")
840 + conv_dim, conv_state_len = conv_ref.shape[1:]
841 + conv_history_len = conv_state_len - spec_query_len + 1
842 + if conv_history_len <= 0:
843 + raise ValueError("KDA RecoverSSM conv state is shorter than its window")
844 + for conv_state in conv_states:
845 + if (
846 + conv_state.shape != conv_ref.shape
847 + or conv_state.dtype != conv_ref.dtype
848 + or conv_state.device != state_ref.device
849 + or conv_state.shape[0] != num_blocks
850 + ):
851 + raise ValueError("KDA RecoverSSM layers need matching conv state")
852 +
853 + device = state_ref.device
854 +
855 + def _base_addrs(tensors: Sequence[torch.Tensor]) -> torch.Tensor:
856 + return torch.tensor(
857 + [tensor.data_ptr() for tensor in tensors],
858 + dtype=torch.int64,
859 + device=device,
860 + )
861 +
862 + def _block_strides(tensors: Sequence[torch.Tensor]) -> torch.Tensor:
863 + return torch.tensor(
864 + [tensor.stride(0) for tensor in tensors],
865 + dtype=torch.int64,
866 + device=device,
867 + )
868 +
869 + return cls(
870 + conv_states=tuple(conv_states),
871 + conv_state_base_addrs=_base_addrs(conv_states),
872 + conv_state_block_strides=_block_strides(conv_states),
873 + conv_state_dim_strides=torch.tensor(
874 + [state.stride(1) for state in conv_states],
875 + dtype=torch.int64,
876 + device=device,
877 + ),
878 + conv_state_token_strides=torch.tensor(
879 + [state.stride(2) for state in conv_states],
880 + dtype=torch.int64,
881 + device=device,
882 + ),
883 + conv_history_len=conv_history_len,
884 + checkpoints=tuple(checkpoints),
885 + state_base_addrs=_base_addrs(checkpoints),
886 + state_block_strides=_block_strides(checkpoints),
887 + correction_caches=tuple(correction_caches),
888 + correction_cache_base_addrs=_base_addrs(correction_caches),
889 + correction_cache_block_strides=_block_strides(correction_caches),
890 + kg_caches=tuple(kg_caches),
891 + kg_cache_base_addrs=_base_addrs(kg_caches),
892 + kg_cache_block_strides=_block_strides(kg_caches),
893 + commit_lens=torch.empty(max_num_reqs, dtype=torch.int32, device=device),
894 + final_state_indices=torch.empty(
895 + max_num_reqs, dtype=torch.int32, device=device
896 + ),
897 + boundary_state_indices=torch.empty(
898 + max_num_reqs, dtype=torch.int32, device=device
899 + ),
900 + boundary_recovery_lens=torch.empty(
901 + max_num_reqs, dtype=torch.int32, device=device
902 + ),
903 + A_log=torch.stack(tuple(A_log)).contiguous(),
904 + dt_bias=torch.stack(tuple(dt_bias)).contiguous(),
905 + lower_bound=lower_bounds.pop(),
906 + spec_query_len=spec_query_len,
907 + )
908 +
909 + def commit(
910 + self,
911 + num_accepted_tokens: torch.Tensor,
912 + state_indices: torch.Tensor,
913 + query_start_loc: torch.Tensor,
914 + request_indices: torch.Tensor | None = None,
915 + block_table: torch.Tensor | None = None,
916 + num_computed_tokens: torch.Tensor | None = None,
917 + mamba_block_size: int | None = None,
918 + ) -> None:
919 + """Fold accepted KDA and convolution inputs into every layer."""
920 + batch = state_indices.shape[0]
921 + if batch == 0:
922 + return
923 + if batch > self.commit_lens.shape[0]:
924 + raise ValueError("KDA RecoverSSM commit batch exceeds its plan capacity")
925 + if query_start_loc.shape[0] != batch + 1:
926 + raise ValueError("KDA RecoverSSM commit metadata is incompatible")
927 + if request_indices is not None and request_indices.shape[0] < batch:
928 + raise ValueError("KDA RecoverSSM request mapping is too short")
929 + align_args = (block_table, num_computed_tokens, mamba_block_size)
930 + if any(arg is not None for arg in align_args) and any(
931 + arg is None for arg in align_args
932 + ):
933 + raise ValueError("KDA RecoverSSM align metadata is incomplete")
934 + if mamba_block_size is not None and mamba_block_size < self.spec_query_len:
935 + raise ValueError(
936 + "KDA RecoverSSM align block size must cover one speculative window"
937 + )
938 + if block_table is not None and block_table.ndim != 2:
939 + raise ValueError("KDA RecoverSSM block table must be two-dimensional")
940 + device = self.checkpoints[0].device
941 + if (
942 + any(
943 + tensor.device != device
944 + for tensor in (
945 + num_accepted_tokens,
946 + state_indices,
947 + query_start_loc,
948 + )
949 + )
950 + or (request_indices is not None and request_indices.device != device)
951 + or (block_table is not None and block_table.device != device)
952 + or (
953 + num_computed_tokens is not None and num_computed_tokens.device != device
954 + )
955 + ):
956 + raise ValueError("KDA RecoverSSM commit inputs must be on the same device")
957 +
958 + block_table_stride = (0, 0) if block_table is None else block_table.stride()
959 + num_computed_stride = (
960 + 0 if num_computed_tokens is None else num_computed_tokens.stride(0)
961 + )
962 +
963 + num_layers = len(self.checkpoints)
964 + conv_ref = self.conv_states[0]
965 + conv_dim = conv_ref.shape[1]
966 + block_history = triton.next_power_of_2(self.conv_history_len)
967 + _prepare_commit_plan_kernel[(batch,)](
968 + num_accepted_tokens,
969 + request_indices,
970 + state_indices,
971 + query_start_loc,
972 + block_table,
973 + num_computed_tokens,
974 + self.commit_lens,
975 + self.final_state_indices,
976 + self.boundary_state_indices,
977 + self.boundary_recovery_lens,
978 + NULL_BLOCK_ID,
979 + mamba_block_size or 1,
980 + block_table.shape[1] if block_table is not None else 1,
981 + num_accepted_tokens.stride(0),
982 + request_indices.stride(0) if request_indices is not None else 0,
983 + state_indices.stride(0),
984 + query_start_loc.stride(0),
985 + block_table_stride[0],
986 + block_table_stride[1],
987 + num_computed_stride,
988 + SPEC_QUERY_LEN=self.spec_query_len,
989 + num_warps=1,
990 + )
991 + _compact_conv_state_kernel[(triton.cdiv(conv_dim, 256), batch, num_layers)](
992 + conv_ref,
993 + self.conv_state_base_addrs,
994 + self.conv_state_block_strides,
995 + self.conv_state_dim_strides,
996 + self.conv_state_token_strides,
997 + state_indices,
998 + self.commit_lens,
999 + self.final_state_indices,
1000 + self.boundary_state_indices,
1001 + self.boundary_recovery_lens,
1002 + NULL_BLOCK_ID,
1003 + conv_dim,
1004 + self.conv_history_len,
1005 + state_indices.stride(0),
1006 + BLOCK_D=256,
1007 + BLOCK_HISTORY=block_history,
1008 + ALIGN_MODE=block_table is not None,
1009 + num_warps=4,
1010 + )
1011 +
1012 + state_ref = self.checkpoints[0]
1013 + _, num_heads, value_dim, key_dim = state_ref.shape
1014 + block_k = triton.next_power_of_2(key_dim)
1015 + block_v = min(triton.next_power_of_2(value_dim), 32)
1016 + grid = (
1017 + triton.cdiv(value_dim, block_v),
1018 + batch,
1019 + num_layers * num_heads,
1020 + )
1021 + _commit_kda_state_kernel[grid](
1022 + state_ref,
1023 + self.state_base_addrs,
1024 + self.state_block_strides,
1025 + self.correction_caches[0],
1026 + self.correction_cache_base_addrs,
1027 + self.correction_cache_block_strides,
1028 + self.kg_caches[0],
1029 + self.kg_cache_base_addrs,
1030 + self.kg_cache_block_strides,
1031 + self.A_log,
1032 + self.dt_bias,
1033 + state_indices,
1034 + self.commit_lens,
1035 + self.final_state_indices,
1036 + self.boundary_state_indices,
1037 + self.boundary_recovery_lens,
1038 + self.lower_bound or 0.0,
1039 + NULL_BLOCK_ID,
1040 + state_ref.stride(1),
1041 + state_ref.stride(2),
1042 + state_ref.stride(3),
1043 + self.correction_caches[0].stride(1),
1044 + self.correction_caches[0].stride(2),
1045 + self.correction_caches[0].stride(3),
1046 + self.kg_caches[0].stride(1),
1047 + self.kg_caches[0].stride(2),
1048 + self.kg_caches[0].stride(3),
1049 + self.A_log.stride(0),
1050 + self.A_log.stride(1),
1051 + self.dt_bias.stride(0),
1052 + self.dt_bias.stride(1),
1053 + self.dt_bias.stride(2),
1054 + state_indices.stride(0),
1055 + K=key_dim,
1056 + V=value_dim,
1057 + BK=block_k,
1058 + BV=block_v,
1059 + NUM_HEADS=num_heads,
1060 + USE_LOWER_BOUND=self.lower_bound is not None,
1061 + ALIGN_MODE=block_table is not None,
1062 + num_warps=4,
1063 + num_stages=2,
1064 + )
1065 +
1066 +
1067 +__all__ = ["KDARecoverSSMCommitContext", "kda_recoverssm_verify"]
vllm/v1/attention/backends/recoverssm_metadata.py new
+26
@@ -0,0 +1,26 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import abc
5 +from dataclasses import dataclass
6 +
7 +import torch
8 +
9 +
10 +@dataclass(frozen=True)
11 +class RecoverSSMPostprocessMetadata:
12 + """Metadata used during postprocessing for align-mode prefix caching."""
13 +
14 + num_spec_decodes: int
15 + request_indices: torch.Tensor | None
16 + block_table: torch.Tensor
17 + num_computed_tokens: torch.Tensor
18 + block_size: int
19 +
20 +
21 +class RecoverSSMMetadata(abc.ABC):
22 + @abc.abstractmethod
23 + def commit_recoverssm_state(
24 + self, num_accepted_tokens: torch.Tensor
25 + ) -> RecoverSSMPostprocessMetadata | None:
26 + raise NotImplementedError
vllm/v1/worker/gpu/model_states/mamba_hybrid.py
+37 -13
@@ -20,6 +20,7 @@ from vllm.v1.worker.gpu.input_batch import InputBatch
20 from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
21 from vllm.v1.worker.gpu.model_states.default import DefaultModelState
22 from vllm.v1.worker.gpu.model_states.interface import ModelSpecificAttnMetadata
23 +from vllm.v1.worker.gpu.model_states.recoverssm import RecoverSSMState
24 from vllm.v1.worker.mamba_utils import (
25 MambaSpecDecodeGPUContext,
26 preprocess_mamba_align_fused_kernel,
@@ -80,6 +81,9 @@ class MambaHybridModelState(DefaultModelState):
81 # kernel reusing the postprocess copy machinery, so the per-step src
82 # columns and the running state_idx are kept GPU-resident.
83 self._align_mode = self.cache_config.mamba_cache_mode == "align"
84 + self.recoverssm = (
85 + RecoverSSMState() if self.cache_config.use_kda_recoverssm else None
86 + )
87 if self._align_mode:
88 self._mamba_state_idx_gpu = torch.zeros(
89 self.max_num_reqs, dtype=torch.int32, device=self.device
@@ -267,7 +271,7 @@ class MambaHybridModelState(DefaultModelState):
271 num_accepted_tokens=num_accepted_tokens,
272 num_decode_draft_tokens_cpu=num_decode_draft_tokens_cpu,
273 )
270 - return build_attn_metadata(
274 + attn_metadata = build_attn_metadata(
275 attn_groups=attn_groups,
276 num_reqs=num_reqs,
277 num_tokens=num_tokens,
@@ -285,6 +289,13 @@ class MambaHybridModelState(DefaultModelState):
289 for_cudagraph_capture=for_capture,
290 rswa_prefix_lens=input_batch.prompt_lens,
291 )
292 + if self.recoverssm is not None:
293 + self.recoverssm.record_step(
294 + attn_metadata,
295 + attn_groups,
296 + for_capture=for_capture,
297 + )
298 + return attn_metadata
299
300 def postprocess_state(
301 self,
@@ -295,21 +306,34 @@ class MambaHybridModelState(DefaultModelState):
306 # Chunked prefill does not sample a token, so num_sampled can be 0.
307 # Mamba treats num_accepted_tokens=1 as the neutral non-spec value.
308 num_reqs = idx_mapping.shape[0]
298 - if not num_reqs:
299 - return
309 + if num_reqs:
310 + if not isinstance(num_sampled, int):
311 + # idx_mapping may contain -1 sentinels (filtered rows) under PP; the
312 + # kernel skips them rather than scattering with a host-side gather.
313 + _scatter_num_accepted_kernel[(num_reqs,)](
314 + idx_mapping,
315 + num_sampled,
316 + self.num_accepted_tokens_gpu,
317 + )
318 + else:
319 + # Fill with single value.
320 + _fill_num_accepted_kernel[(num_reqs,)](
321 + idx_mapping,
322 + self.num_accepted_tokens_gpu,
323 + max(num_sampled, 1),
324 + )
325
301 - if not isinstance(num_sampled, int):
302 - # idx_mapping may contain -1 sentinels (filtered rows) under PP; the
303 - # kernel skips them rather than scattering with a host-side gather.
304 - _scatter_num_accepted_kernel[(num_reqs,)](
305 - idx_mapping, num_sampled, self.num_accepted_tokens_gpu
306 - )
307 - else:
308 - # Fill with single value.
309 - _fill_num_accepted_kernel[(num_reqs,)](
310 - idx_mapping, self.num_accepted_tokens_gpu, max(num_sampled, 1)
326 + if self.recoverssm is not None:
327 + self.recoverssm.commit_step(
328 + num_sampled,
329 + idx_mapping,
330 + state_indices=(self._mamba_state_idx_gpu if self._align_mode else None),
331 + num_accepted_tokens=self.num_accepted_tokens_gpu,
332 )
333
334 + if not num_reqs:
335 + return
336 +
337 # Align: save the running state to the block-aligned position when
338 # spec-decode acceptance leaves the sequence non-block-aligned (mirrors
339 # the V1 align postprocess). num_computed_tokens already holds the
vllm/v1/worker/gpu/model_states/recoverssm.py new
+101
@@ -0,0 +1,101 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from typing import Any
5 +
6 +import torch
7 +
8 +from vllm.triton_utils import tl, triton
9 +from vllm.v1.attention.backends.recoverssm_metadata import RecoverSSMMetadata
10 +from vllm.v1.worker.utils import AttentionGroup
11 +
12 +
13 +class RecoverSSMState:
14 + """Coordinates RecoverSSM metadata between attention and postprocessing."""
15 +
16 + def __init__(self) -> None:
17 + self._step: tuple[RecoverSSMMetadata, ...] | None = None
18 +
19 + def record_step(
20 + self,
21 + attn_metadata: dict[str, Any],
22 + attn_groups: list[list[AttentionGroup]],
23 + *,
24 + for_capture: bool,
25 + ) -> None:
26 + if for_capture:
27 + self._step = None
28 + return
29 +
30 + step: list[RecoverSSMMetadata] = []
31 + for group_list in attn_groups:
32 + for group in group_list:
33 + metadata = attn_metadata[group.layer_names[0]]
34 + if isinstance(metadata, RecoverSSMMetadata):
35 + step.append(metadata)
36 + self._step = tuple(step)
37 +
38 + def commit_step(
39 + self,
40 + num_sampled: torch.Tensor | int,
41 + idx_mapping: torch.Tensor,
42 + *,
43 + state_indices: torch.Tensor | None,
44 + num_accepted_tokens: torch.Tensor,
45 + ) -> None:
46 + step = self._step
47 + self._step = None
48 + if isinstance(num_sampled, int) or step is None:
49 + return
50 +
51 + for metadata in step:
52 + postprocess_meta = metadata.commit_recoverssm_state(num_sampled)
53 + if postprocess_meta is None:
54 + continue
55 + assert state_indices is not None
56 + # RecoverSSM already restored the accepted state. Update its running
57 + # column and reset the next-step copy bias to the neutral value.
58 + _postprocess_recoverssm_align_kernel[(postprocess_meta.num_spec_decodes,)](
59 + idx_mapping,
60 + num_sampled,
61 + postprocess_meta.request_indices,
62 + postprocess_meta.num_computed_tokens,
63 + state_indices,
64 + num_accepted_tokens,
65 + MAMBA_BLOCK_SIZE=postprocess_meta.block_size,
66 + BLOCK_TABLE_WIDTH=postprocess_meta.block_table.shape[1],
67 + )
68 +
69 +
70 +@triton.heuristics(
71 + {"HAS_REQUEST_INDICES": lambda args: args["request_indices_ptr"] is not None}
72 +)
73 +@triton.jit
74 +def _postprocess_recoverssm_align_kernel(
75 + idx_mapping_ptr,
76 + num_sampled_ptr,
77 + request_indices_ptr,
78 + num_computed_ptr,
79 + state_idx_ptr,
80 + num_accepted_ptr,
81 + HAS_REQUEST_INDICES: tl.constexpr,
82 + MAMBA_BLOCK_SIZE: tl.constexpr,
83 + BLOCK_TABLE_WIDTH: tl.constexpr,
84 +):
85 + spec_idx = tl.program_id(0)
86 + batch_idx = spec_idx
87 + if HAS_REQUEST_INDICES:
88 + batch_idx = tl.load(request_indices_ptr + spec_idx)
89 + req_state_idx = tl.load(idx_mapping_ptr + batch_idx)
90 + if req_state_idx < 0:
91 + return
92 + num_sampled = tl.load(num_sampled_ptr + batch_idx)
93 + num_computed = tl.load(num_computed_ptr + batch_idx)
94 + tl.store(
95 + state_idx_ptr + req_state_idx,
96 + tl.minimum(
97 + (num_computed + num_sampled) // MAMBA_BLOCK_SIZE,
98 + BLOCK_TABLE_WIDTH - 1,
99 + ),
100 + )
101 + tl.store(num_accepted_ptr + req_state_idx, 1)
vllm/v1/worker/mamba_utils.py
+7 -2
@@ -828,7 +828,13 @@ class MambaSpecDecodeGPUContext:
828 attention = forward_context[layer_name]
829 kv_caches: list[torch.Tensor] = attention.kv_cache
830
831 - for state_type_idx, state in enumerate(kv_caches):
831 + if len(kv_caches) < self.num_state_types:
832 + raise ValueError(
833 + f"Expected at least {self.num_state_types} Mamba state "
834 + f"tensors, got {len(kv_caches)}"
835 + )
836 + for state_type_idx, copy_func in enumerate(mamba_state_copy_funcs):
837 + state = kv_caches[state_type_idx]
838 # Base address
839 self.state_base_addrs[idx] = state.data_ptr()
840
@@ -845,7 +851,6 @@ class MambaSpecDecodeGPUContext:
851 # Element size
852 self.state_elem_sizes[idx] = state.element_size()
853
848 - copy_func = mamba_state_copy_funcs[state_type_idx]
854 assert (
855 copy_func is get_conv_copy_spec
856 or copy_func is get_temporal_copy_spec