[Model Runner V2] support mamba hybrid models align prefix cache (#42406)
Signed-off-by: zhuhaoran <zhuhaoran.zhr@alibaba-inc.com>
zhrrr committed
Jun 30, 2026 at 05:09 UTC
61ab70ec3bd13dd422b86f3b80207d322994a5e7
9 files changed
+1045
-132
tests/kernels/mamba/test_precopy_mamba_align.py
new
+180
@@ -0,0 +1,180 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Equivalence test for ``precopy_mamba_align_fused_kernel``.
4
+
5
+The V2 "align" pre-copy must migrate mamba state across block boundaries with
6
+byte-identical semantics to the V1 copy specs (``get_conv_copy_spec`` /
7
+``get_temporal_copy_spec``):
8
+
9
+* conv state (SD layout, conv_width > 0): shift the sliding window by
10
+ ``token_bias`` tokens -- ``state[bt[src_col], token_bias:]`` ->
11
+ ``state[bt[dst_col], :conv_width - token_bias]``.
12
+* temporal state (conv_width == 0): ``token_bias`` selects the accepted
13
+ speculative column -- ``state[bt[src_col + token_bias]]`` ->
14
+ ``state[bt[dst_col]]``.
15
+
16
+The kernel must also no-op when ``src_col < 0`` (fresh request) or
17
+``src_col == dst_col`` (no boundary crossed).
18
+"""
19
+
20
+from __future__ import annotations
21
+
22
+import torch
23
+
24
+from vllm.platforms import current_platform
25
+from vllm.v1.worker.mamba_utils import precopy_mamba_align_fused_kernel
26
+
27
+try:
28
+ import pytest
29
+
30
+ pytestmark = pytest.mark.skipif(
31
+ not current_platform.is_cuda(),
32
+ reason="precopy_mamba_align_fused_kernel needs CUDA/Triton",
33
+ )
34
+ _parametrize = pytest.mark.parametrize
35
+except ModuleNotFoundError: # allow running directly as ``python <thisfile>``
36
+ pytest = None
37
+
38
+ def _parametrize(_name, _values):
39
+ def _deco(fn):
40
+ return fn
41
+
42
+ return _deco
43
+
44
+
45
+NUM_LAYERS = 3
46
+CONV_WIDTH = 4 # conv_kernel - 1 + num_spec
47
+CONV_DIM = 96
48
+SSM_SHAPE = (4, 16, 16)
49
+MAX_COLS = 8
50
+
51
+
52
+def _build_state(num_blocks, device):
53
+ """Per-layer (conv SD [nb, width, dim] bf16, ssm [nb, *shape] fp32) pools."""
54
+ convs, ssms = [], []
55
+ for _ in range(NUM_LAYERS):
56
+ convs.append(
57
+ torch.randn(
58
+ num_blocks, CONV_WIDTH, CONV_DIM, dtype=torch.bfloat16, device=device
59
+ )
60
+ )
61
+ ssms.append(
62
+ torch.randn(num_blocks, *SSM_SHAPE, dtype=torch.float32, device=device)
63
+ )
64
+ return convs, ssms
65
+
66
+
67
+def _build_meta(convs, ssms, device):
68
+ """Flattened per-(layer, state-type) metadata, ordered conv, ssm per layer."""
69
+ n = NUM_LAYERS * 2
70
+ base = torch.zeros(n, dtype=torch.int64, device=device)
71
+ blk_stride = torch.zeros(n, dtype=torch.int64, device=device)
72
+ elem = torch.zeros(n, dtype=torch.int32, device=device)
73
+ inner = torch.zeros(n, dtype=torch.int64, device=device)
74
+ width = torch.zeros(n, dtype=torch.int32, device=device)
75
+ group = torch.zeros(n, dtype=torch.int32, device=device)
76
+ drc = torch.zeros(n, dtype=torch.int32, device=device) # DS rows (unused, SD)
77
+ drs = torch.zeros(n, dtype=torch.int64, device=device)
78
+ i = 0
79
+ for layer in range(NUM_LAYERS):
80
+ conv, ssm = convs[layer], ssms[layer]
81
+ # conv (SD): width = size(1), inner = stride(1)
82
+ base[i] = conv.data_ptr()
83
+ blk_stride[i] = conv.stride(0) * conv.element_size()
84
+ elem[i] = conv.element_size()
85
+ width[i] = conv.size(1)
86
+ inner[i] = conv.stride(1)
87
+ i += 1
88
+ # ssm (temporal): width = 0, inner = elems per block
89
+ base[i] = ssm.data_ptr()
90
+ blk_stride[i] = ssm.stride(0) * ssm.element_size()
91
+ elem[i] = ssm.element_size()
92
+ width[i] = 0
93
+ inner[i] = ssm[0].numel()
94
+ i += 1
95
+ return base, blk_stride, elem, inner, width, group, drc, drs
96
+
97
+
98
+def _reference(convs, ssms, bt, src_col, dst_col, bias, num_reqs):
99
+ """Apply the V1 copy semantics on clones, reading from the pre-copy state."""
100
+ conv_pre = [c.clone() for c in convs]
101
+ ssm_pre = [s.clone() for s in ssms]
102
+ conv_ref = [c.clone() for c in convs]
103
+ ssm_ref = [s.clone() for s in ssms]
104
+ for r in range(num_reqs):
105
+ sc, dc, tb = int(src_col[r]), int(dst_col[r]), int(bias[r])
106
+ if sc < 0 or sc == dc:
107
+ continue
108
+ sblk, dblk = int(bt[r, sc]), int(bt[r, dc])
109
+ tblk = int(bt[r, sc + tb]) # temporal src column shifted by bias
110
+ for layer in range(NUM_LAYERS):
111
+ conv_ref[layer][dblk, : CONV_WIDTH - tb] = conv_pre[layer][sblk, tb:]
112
+ ssm_ref[layer][dblk] = ssm_pre[layer][tblk]
113
+ return conv_ref, ssm_ref
114
+
115
+
116
+@_parametrize("num_reqs", [1, 4, 16])
117
+@_parametrize("token_bias", [0, 1, 2])
118
+def test_precopy_matches_v1_copy_specs(num_reqs, token_bias):
119
+ device = torch.device("cuda")
120
+ torch.manual_seed(0)
121
+ # Distinct physical block per (req, col) so copies never alias.
122
+ num_blocks = num_reqs * MAX_COLS + 1
123
+ bt = torch.empty(num_reqs, MAX_COLS, dtype=torch.int32, device=device)
124
+ for r in range(num_reqs):
125
+ bt[r] = torch.arange(
126
+ 1 + r * MAX_COLS, 1 + (r + 1) * MAX_COLS, dtype=torch.int32, device=device
127
+ )
128
+
129
+ # Per-req columns: req 0 fresh (src=-1, skip), req 1 same block (skip),
130
+ # the rest cross from col 1 -> col 0 with the given spec token bias.
131
+ src_col = torch.full((num_reqs,), 1, dtype=torch.int32, device=device)
132
+ dst_col = torch.zeros(num_reqs, dtype=torch.int32, device=device)
133
+ bias = torch.full((num_reqs,), token_bias, dtype=torch.int32, device=device)
134
+ if num_reqs >= 1:
135
+ src_col[0] = -1 # fresh -> no copy
136
+ if num_reqs >= 2:
137
+ dst_col[1] = 1 # src_col == dst_col -> no copy
138
+
139
+ convs, ssms = _build_state(num_blocks, device)
140
+ conv_ref, ssm_ref = _reference(
141
+ convs, ssms, bt.cpu(), src_col.cpu(), dst_col.cpu(), bias.cpu(), num_reqs
142
+ )
143
+
144
+ base, blk_stride, elem, inner, width, group, drc, drs = _build_meta(
145
+ convs, ssms, device
146
+ )
147
+ bt_ptrs = torch.tensor([bt.data_ptr()], dtype=torch.int64, device=device)
148
+ idx_mapping = torch.arange(num_reqs, dtype=torch.int32, device=device)
149
+ grid = (num_reqs, NUM_LAYERS * 2)
150
+ precopy_mamba_align_fused_kernel[grid](
151
+ dst_col,
152
+ src_col,
153
+ bias,
154
+ bt_ptrs,
155
+ bt.stride(0),
156
+ base,
157
+ blk_stride,
158
+ elem,
159
+ inner,
160
+ width,
161
+ group,
162
+ drc,
163
+ drs,
164
+ idx_mapping,
165
+ num_reqs,
166
+ COPY_BLOCK_SIZE=1024,
167
+ CONV_STATE_DIM_FIRST=False,
168
+ )
169
+ torch.accelerator.synchronize()
170
+
171
+ for layer in range(NUM_LAYERS):
172
+ torch.testing.assert_close(convs[layer], conv_ref[layer], rtol=0, atol=0)
173
+ torch.testing.assert_close(ssms[layer], ssm_ref[layer], rtol=0, atol=0)
174
+
175
+
176
+if __name__ == "__main__":
177
+ for nr in (1, 4, 16):
178
+ for tb in (0, 1, 2):
179
+ test_precopy_matches_v1_copy_specs(nr, tb)
180
+ print(f"OK num_reqs={nr} token_bias={tb}")
tests/v1/e2e/general/test_mamba_prefix_cache.py
+280
-16
@@ -11,6 +11,7 @@ import datasets
11
import pytest
12
import torch
13
14
+import vllm.envs as envs
15
from tests.utils import create_new_process_for_each_test
16
from vllm import LLM, SamplingParams, TokensPrompt
17
from vllm.config import CacheConfig
@@ -494,12 +495,7 @@ def apply_patch(monkeypatch: pytest.MonkeyPatch):
495
monkeypatch.setattr(mamba_utils, "do_mamba_copy_block", fake_copy_fn)
496
497
497
-@create_new_process_for_each_test()
498
-def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch):
499
- run_ref_mamba_state_in_subprocess()
500
- apply_patch(monkeypatch)
501
- prompt_dataset = datasets.load_dataset("heheda/a_long_article")
502
- full_prompt = prompt_dataset["train"][0]["text"]
498
+def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
499
tests = {
500
"accept_1": TestConfig(
501
num_prompt_tokens=554,
@@ -731,6 +727,27 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch):
727
),
728
}
729
730
+ return tests
731
+
732
+
733
+def fill_following_kv_cache_block_ids(test_config: TestConfig) -> None:
734
+ for step_action_prev, step_action_next in zip(
735
+ test_config.step_actions[:-1], test_config.step_actions[1:]
736
+ ):
737
+ if len(step_action_next.kv_cache_block_ids) == 0:
738
+ step_action_next.kv_cache_block_ids = (
739
+ step_action_prev.kv_cache_block_ids.copy()
740
+ )
741
+
742
+
743
+@create_new_process_for_each_test()
744
+def test_mamba_prefix_cache_mrv1(monkeypatch: pytest.MonkeyPatch):
745
+ run_ref_mamba_state_in_subprocess()
746
+ apply_patch(monkeypatch)
747
+ prompt_dataset = datasets.load_dataset("heheda/a_long_article")
748
+ full_prompt = prompt_dataset["train"][0]["text"]
749
+ tests = get_mamba_prefix_cache_step_configs()
750
+
751
engine = LLM(
752
model=MODEL,
753
enable_prefix_caching=True,
@@ -758,16 +775,7 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch):
775
)
776
global cur_step_action_idx
777
cur_step_action_idx = 0
761
- for step_action_prev, step_action_next in zip(
762
- test_config.step_actions[:-1], test_config.step_actions[1:]
763
- ):
764
- if (
765
- step_action_next.kv_cache_block_ids is not None
766
- and len(step_action_next.kv_cache_block_ids) == 0
767
- ):
768
- prev_block_ids = step_action_prev.kv_cache_block_ids
769
- if prev_block_ids is not None:
770
- step_action_next.kv_cache_block_ids = prev_block_ids.copy()
778
+ fill_following_kv_cache_block_ids(test_config)
779
global step_actions
780
step_actions = test_config.step_actions
781
_ = engine.generate(
@@ -787,3 +795,259 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch):
795
del engine
796
torch.accelerator.empty_cache()
797
cleanup_dist_env_and_memory()
798
+
799
+
800
+@create_new_process_for_each_test()
801
+def test_mamba_prefix_cache_mrv2(monkeypatch: pytest.MonkeyPatch):
802
+ monkeypatch.setenv("VLLM_ENABLE_V1_MULTIPROCESSING", "0")
803
+ monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1")
804
+ envs.disable_envs_cache()
805
+
806
+ from vllm.v1.worker.gpu.model_runner import GPUModelRunner as MRV2GPUModelRunner
807
+ from vllm.v1.worker.gpu.model_states.mamba_hybrid import (
808
+ MambaHybridModelState,
809
+ )
810
+ from vllm.v1.worker.gpu.sample.output import SamplerOutput as MRV2SamplerOutput
811
+
812
+ events: list[int] = []
813
+ original_execute_model = MRV2GPUModelRunner.execute_model
814
+ original_sample = MRV2GPUModelRunner.sample
815
+ original_preprocess_state = MambaHybridModelState.preprocess_state
816
+ original_postprocess_state = MambaHybridModelState.postprocess_state
817
+ original_step_action_fn = InprocClient.get_output
818
+ original_allocate_slots = KVCacheManager.allocate_slots
819
+ captured: dict[str, Any] = {}
820
+
821
+ def temporal_states(model_state, block_tables, kv_cache_config):
822
+ # Qwen3-Next keeps the temporal (ssm) state as the last Mamba cache.
823
+ forward_context = (
824
+ model_state.vllm_config.compilation_config.static_forward_context
825
+ )
826
+ group_ids, _ = get_mamba_groups(kv_cache_config)
827
+ for group_id in group_ids:
828
+ block_table = block_tables[group_id]
829
+ for layer_name in kv_cache_config.kv_cache_groups[group_id].layer_names:
830
+ yield forward_context[layer_name].kv_cache[-1], block_table
831
+
832
+ def temporal_block(temporal_state, block_table, col):
833
+ return temporal_state[int(block_table[0, col].item())]
834
+
835
+ def wrapped_preprocess_state(
836
+ self: MambaHybridModelState,
837
+ input_batch: Any,
838
+ block_tables: tuple[torch.Tensor, ...],
839
+ kv_cache_config: KVCacheConfig,
840
+ num_computed_tokens: torch.Tensor,
841
+ ) -> None:
842
+ captured["block_tables"] = block_tables
843
+ captured["kv_cache_config"] = kv_cache_config
844
+ expected = (
845
+ None if cur_step_action is None else cur_step_action.preprocess_copy_idx
846
+ )
847
+ snapshots = []
848
+ if expected is not None and expected != (-1, -1):
849
+ for temporal, bt in temporal_states(self, block_tables, kv_cache_config):
850
+ snapshots.append(
851
+ (temporal, bt, temporal_block(temporal, bt, expected[0]).clone())
852
+ )
853
+ ret = original_preprocess_state(
854
+ self, input_batch, block_tables, kv_cache_config, num_computed_tokens
855
+ )
856
+ if cur_step_action is not None:
857
+ req_idx = int(input_batch.idx_mapping[0].item())
858
+ src_col = int(self._mamba_src_col_gpu[req_idx].item())
859
+ off = int(self._mamba_src_off_gpu[req_idx].item())
860
+ dst = int(self._mamba_state_idx_gpu[req_idx].item())
861
+ actual = (-1, -1) if src_col < 0 or src_col == dst else (src_col + off, dst)
862
+ assert actual == expected, (
863
+ f"V2 align preprocess copy: expected={expected}, "
864
+ f"actual={actual}, {cur_step_action=}"
865
+ )
866
+ for temporal, bt, src_state in snapshots:
867
+ torch.testing.assert_close(
868
+ temporal_block(temporal, bt, expected[1]), src_state
869
+ )
870
+ return ret
871
+
872
+ def wrapped_postprocess_state(
873
+ self: MambaHybridModelState,
874
+ idx_mapping: torch.Tensor,
875
+ num_sampled: torch.Tensor | int,
876
+ num_computed_tokens: torch.Tensor | None = None,
877
+ ) -> None:
878
+ action = cur_step_action
879
+ block_tables = captured.get("block_tables")
880
+ kv_cache_config = captured.get("kv_cache_config")
881
+ # The postprocess kernel does not expose its indices, so only the copy
882
+ # case is checked, by effect: snapshot the src block, expect dst == src.
883
+ if (
884
+ action is None
885
+ or num_computed_tokens is None
886
+ or block_tables is None
887
+ or action.postprocess_copy_idx == (-1, -1)
888
+ ):
889
+ return original_postprocess_state(
890
+ self, idx_mapping, num_sampled, num_computed_tokens
891
+ )
892
+ expected = action.postprocess_copy_idx
893
+ snapshots = [
894
+ (temporal, bt, temporal_block(temporal, bt, expected[0]).clone())
895
+ for temporal, bt in temporal_states(self, block_tables, kv_cache_config)
896
+ ]
897
+ ret = original_postprocess_state(
898
+ self, idx_mapping, num_sampled, num_computed_tokens
899
+ )
900
+ for temporal, bt, src_state in snapshots:
901
+ torch.testing.assert_close(
902
+ temporal_block(temporal, bt, expected[1]), src_state
903
+ )
904
+ return ret
905
+
906
+ def wrapped_execute_model(
907
+ self: MRV2GPUModelRunner,
908
+ scheduler_output: SchedulerOutput,
909
+ *args: Any,
910
+ **kwargs: Any,
911
+ ):
912
+ events.extend(
913
+ req.num_computed_tokens for req in scheduler_output.scheduled_new_reqs
914
+ )
915
+ events.extend(scheduler_output.scheduled_cached_reqs.num_computed_tokens)
916
+ if cur_step_action is not None:
917
+ num_scheduled_tokens = next(
918
+ iter(scheduler_output.num_scheduled_tokens.values())
919
+ )
920
+ assert num_scheduled_tokens == cur_step_action.num_scheduled_tokens
921
+ ret = original_execute_model(self, scheduler_output, *args, **kwargs)
922
+ if cur_step_action is not None and self.execute_model_state is not None:
923
+ input_batch = self.execute_model_state.input_batch
924
+ assert (
925
+ cur_step_action.num_computed_tokens_start
926
+ == input_batch.positions[input_batch.query_start_loc[0]].item()
927
+ )
928
+ return ret
929
+
930
+ def fake_sample(
931
+ self: MRV2GPUModelRunner,
932
+ hidden_states: torch.Tensor,
933
+ input_batch: Any,
934
+ grammar_output: Any,
935
+ ):
936
+ if cur_step_action is None:
937
+ return original_sample(self, hidden_states, input_batch, grammar_output)
938
+
939
+ num_reqs = input_batch.num_reqs
940
+ sampled_token_ids = torch.ones(
941
+ (num_reqs, self.num_speculative_steps + 1),
942
+ device=hidden_states.device,
943
+ dtype=torch.int64,
944
+ )
945
+ num_logits = torch.tensor(
946
+ input_batch.cu_num_logits_np[1 : num_reqs + 1]
947
+ - input_batch.cu_num_logits_np[:num_reqs],
948
+ device=hidden_states.device,
949
+ dtype=torch.int32,
950
+ )
951
+ accepted = torch.full_like(num_logits, num_accepted_tokens)
952
+ num_sampled = torch.minimum(accepted, num_logits)
953
+ prefill_lens = self.req_states.prefill_len.gpu[input_batch.idx_mapping]
954
+ is_chunked_prefill = input_batch.seq_lens[:num_reqs] < prefill_lens
955
+ num_sampled = torch.where(is_chunked_prefill, 0, num_sampled)
956
+ num_rejected = torch.where(is_chunked_prefill, 0, num_logits - num_sampled)
957
+ sampler_output = MRV2SamplerOutput(
958
+ sampled_token_ids=sampled_token_ids,
959
+ logprobs_tensors=None,
960
+ num_nans=None,
961
+ num_sampled=num_sampled,
962
+ )
963
+ return sampler_output, num_sampled, num_rejected
964
+
965
+ monkeypatch.setattr(
966
+ InprocClient,
967
+ "get_output",
968
+ get_fake_step_action_fn(original_step_action_fn),
969
+ )
970
+ monkeypatch.setattr(
971
+ KVCacheManager,
972
+ "allocate_slots",
973
+ get_fake_allocate_slots_fn(original_allocate_slots),
974
+ )
975
+ monkeypatch.setattr(MRV2GPUModelRunner, "execute_model", wrapped_execute_model)
976
+ monkeypatch.setattr(MRV2GPUModelRunner, "sample", fake_sample)
977
+ monkeypatch.setattr(
978
+ MambaHybridModelState, "preprocess_state", wrapped_preprocess_state
979
+ )
980
+ monkeypatch.setattr(
981
+ MambaHybridModelState, "postprocess_state", wrapped_postprocess_state
982
+ )
983
+
984
+ engine = LLM(
985
+ model=MODEL,
986
+ load_format="dummy",
987
+ enforce_eager=True,
988
+ skip_tokenizer_init=True,
989
+ enable_prefix_caching=True,
990
+ block_size=BLOCK_SIZE,
991
+ mamba_cache_mode="align",
992
+ speculative_config={
993
+ "method": "qwen3_next_mtp",
994
+ "num_speculative_tokens": num_speculative_tokens,
995
+ },
996
+ max_num_batched_tokens=3072,
997
+ max_model_len=BLOCK_SIZE * 12,
998
+ hf_overrides={"num_hidden_layers": NUM_HIDDEN_LAYERS},
999
+ seed=42,
1000
+ )
1001
+
1002
+ try:
1003
+ tests = get_mamba_prefix_cache_step_configs()
1004
+
1005
+ global step_actions
1006
+ global cur_step_action_idx
1007
+ global num_accepted_tokens
1008
+ for test_name, test_config in tests.items():
1009
+ num_accepted_tokens = test_config.num_accepted_tokens
1010
+ cur_step_action_idx = 0
1011
+ fill_following_kv_cache_block_ids(test_config)
1012
+ step_actions = test_config.step_actions
1013
+ sampling_params = SamplingParams(
1014
+ temperature=0.0,
1015
+ max_tokens=test_config.num_generated_tokens,
1016
+ ignore_eos=True,
1017
+ )
1018
+ _ = engine.generate(
1019
+ [TokensPrompt(prompt_token_ids=[1] * test_config.num_prompt_tokens)],
1020
+ sampling_params=sampling_params,
1021
+ )
1022
+ assert cur_step_action_idx == len(test_config.step_actions), test_name
1023
+ assert (
1024
+ engine.llm_engine.engine_core.engine_core.scheduler.reset_prefix_cache()
1025
+ )
1026
+
1027
+ step_actions = []
1028
+ cur_step_action_idx = 0
1029
+ num_accepted_tokens = 1
1030
+ prompt = TokensPrompt(prompt_token_ids=[1] * (BLOCK_SIZE * 2))
1031
+ sampling_params = SamplingParams(
1032
+ temperature=0.0,
1033
+ max_tokens=1,
1034
+ ignore_eos=True,
1035
+ )
1036
+ _ = engine.generate([prompt], sampling_params=sampling_params)
1037
+ first_event_count = len(events)
1038
+ _ = engine.generate([prompt], sampling_params=sampling_params)
1039
+ second_events = events[first_event_count:]
1040
+ prefix_hits = [
1041
+ num_computed_tokens
1042
+ for num_computed_tokens in second_events
1043
+ if num_computed_tokens >= BLOCK_SIZE
1044
+ ]
1045
+ assert prefix_hits, (
1046
+ "Expected the second identical prompt to hit prefix cache, "
1047
+ f"got events={second_events!r}"
1048
+ )
1049
+ assert engine.llm_engine.engine_core.engine_core.scheduler.reset_prefix_cache()
1050
+ finally:
1051
+ del engine
1052
+ torch.accelerator.empty_cache()
1053
+ cleanup_dist_env_and_memory()
vllm/config/vllm.py
-11
@@ -1997,13 +1997,6 @@ class VllmConfig:
1997
model_config = self.model_config
1998
speculative_config = self.speculative_config
1999
2000
- if (
2001
- model_config is not None
2002
- and model_config.has_inner_state
2003
- and self.cache_config.mamba_cache_mode == "align"
2004
- ):
2005
- unsupported.append("hybrid/mamba models with align cache mode")
2006
-
2000
if self.parallel_config.prefill_context_parallel_size > 1:
2001
unsupported.append("prefill context parallelism")
2002
@@ -2152,10 +2145,6 @@ class VllmConfig:
2145
"to schedule a multiple of block_size tokens even if they are "
2146
"in the middle of a mm input"
2147
)
2155
- # TODO: support align mamba cache mode for model runner v2
2156
- assert not envs.VLLM_USE_V2_MODEL_RUNNER, (
2157
- "Model Runner V2 has not yet supported mamba_cache_mode='align'. "
2158
- )
2148
2149
@model_validator(mode="after")
2150
def validate_nvfp4_kv_cache_with_mla(self) -> "VllmConfig":
vllm/model_executor/models/diffusion_gemma.py
+3
-1
@@ -973,7 +973,9 @@ class DiffusionGemmaModelState(ModelState):
973
# so the captured graph and runtime point to identical addresses.
974
return {"inputs_embeds": self._inputs_embeds_buf[:num_tokens]}
975
976
- def postprocess_state(self, idx_mapping, num_sampled) -> None:
976
+ def postprocess_state(
977
+ self, idx_mapping, num_sampled, num_computed_tokens=None
978
+ ) -> None:
979
return None
980
981
def prepare_attn(
vllm/v1/worker/gpu/model_runner.py
+13
-1
@@ -1111,7 +1111,9 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1111
self.req_states.total_len.gpu,
1112
)
1113
1114
- self.model_state.postprocess_state(idx_mapping, num_sampled)
1114
+ self.model_state.postprocess_state(
1115
+ idx_mapping, num_sampled, self.req_states.num_computed_tokens.gpu
1116
+ )
1117
1118
@torch.inference_mode()
1119
def execute_model(
@@ -1176,6 +1178,16 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1178
# Prepare all the inputs and copy to the input buffers.
1179
input_batch = self.prepare_inputs(scheduler_output, batch_desc)
1180
block_tables, slot_mappings = self.prepare_attn(input_batch)
1181
+ # Mamba "align" pre-copy: migrate recurrent state across block
1182
+ # boundaries before the forward. Runs only on real batches, and
1183
+ # before model_state.prepare_attn gathers num_accepted_tokens so the
1184
+ # boundary reset is visible to the attention metadata.
1185
+ self.model_state.preprocess_state(
1186
+ input_batch,
1187
+ block_tables,
1188
+ self.kv_cache_config,
1189
+ self.req_states.num_computed_tokens.gpu,
1190
+ )
1191
1192
if self.lora_config:
1193
# Activate LoRA adapters.
vllm/v1/worker/gpu/model_states/interface.py
+16
-1
@@ -95,8 +95,23 @@ class ModelState(ABC):
95
def apply_staged_writes(self) -> None:
96
return None
97
98
+ def preprocess_state(
99
+ self,
100
+ input_batch: InputBatch,
101
+ block_tables: tuple[torch.Tensor, ...],
102
+ kv_cache_config: KVCacheConfig,
103
+ num_computed_tokens: torch.Tensor,
104
+ ) -> None:
105
+ """Hook run on real batches before the forward pass (after block tables
106
+ are gathered). Used by mamba "align" prefix caching to pre-copy state
107
+ across block boundaries. No-op by default."""
108
+ return None
109
+
110
def postprocess_state(
99
- self, idx_mapping: torch.Tensor, num_sampled: torch.Tensor
111
+ self,
112
+ idx_mapping: torch.Tensor,
113
+ num_sampled: torch.Tensor,
114
+ num_computed_tokens: torch.Tensor | None = None,
115
) -> None:
116
return None
117
vllm/v1/worker/gpu/model_states/mamba_hybrid.py
+174
-8
@@ -9,15 +9,25 @@ import torch.nn as nn
9
10
from vllm.config import VllmConfig
11
from vllm.config.compilation import CUDAGraphMode
12
+from vllm.model_executor.layers.mamba.mamba_utils import (
13
+ get_conv_copy_spec,
14
+ is_conv_state_dim_first,
15
+)
16
from vllm.triton_utils import tl, triton
17
from vllm.v1.attention.backends.gdn_attn import GDNAttentionMetadataBuilder
18
from vllm.v1.attention.backends.mamba2_attn import Mamba2AttentionMetadataBuilder
15
-from vllm.v1.kv_cache_interface import KVCacheConfig
19
+from vllm.v1.core.sched.output import NewRequestData
20
+from vllm.v1.kv_cache_interface import KVCacheConfig, MambaSpec
21
+from vllm.v1.utils import CpuGpuBuffer
22
from vllm.v1.worker.gpu.attn_utils import build_attn_metadata
23
from vllm.v1.worker.gpu.input_batch import InputBatch
24
from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
25
from vllm.v1.worker.gpu.model_states.default import DefaultModelState
26
from vllm.v1.worker.gpu.model_states.interface import ModelSpecificAttnMetadata
27
+from vllm.v1.worker.mamba_utils import (
28
+ MambaSpecDecodeGPUContext,
29
+ preprocess_mamba_align_fused_kernel,
30
+)
31
from vllm.v1.worker.utils import AttentionGroup
32
33
@@ -65,9 +75,142 @@ class MambaHybridModelState(DefaultModelState):
75
device: torch.device,
76
) -> None:
77
super().__init__(vllm_config, model, encoder_cache, device)
78
+ self.cache_config = vllm_config.cache_config
79
self.num_accepted_tokens_gpu = torch.ones(
80
self.max_num_reqs, dtype=torch.int32, device=self.device
81
)
82
+ # Pre-copy "align" prefix-cache state (V2). The migration of each
83
+ # request's mamba state across block boundaries runs as a fused GPU
84
+ # kernel reusing the postprocess copy machinery, so the per-step src
85
+ # columns and the running state_idx are kept GPU-resident.
86
+ self._align_mode = self.cache_config.mamba_cache_mode == "align"
87
+ if self._align_mode:
88
+ self._mamba_state_idx_gpu = torch.zeros(
89
+ self.max_num_reqs, dtype=torch.int32, device=self.device
90
+ )
91
+ self._mamba_src_col_gpu = torch.full(
92
+ (self.max_num_reqs,), -1, dtype=torch.int32, device=self.device
93
+ )
94
+ self._mamba_src_off_gpu = torch.zeros(
95
+ self.max_num_reqs, dtype=torch.int32, device=self.device
96
+ )
97
+ self._mamba_ctx: MambaSpecDecodeGPUContext | None = None
98
+ self._mamba_group_ids: list[int] = []
99
+ self._mamba_spec: MambaSpec | None = None
100
+
101
+ def add_request(self, req_index: int, new_req_data: NewRequestData) -> None:
102
+ super().add_request(req_index, new_req_data)
103
+ if self._align_mode:
104
+ # Seed the running state block from the resumed/prefilled position.
105
+ self._mamba_state_idx_gpu[req_index] = (
106
+ new_req_data.num_computed_tokens - 1
107
+ ) // self.cache_config.block_size
108
+ self.num_accepted_tokens_gpu[req_index] = 1
109
+
110
+ def _get_mamba_group_info(
111
+ self, kv_cache_config: KVCacheConfig
112
+ ) -> tuple[list[int], MambaSpec]:
113
+ if self._mamba_spec is None:
114
+ group_ids: list[int] = []
115
+ specs: list[MambaSpec] = []
116
+ for i, group in enumerate(kv_cache_config.kv_cache_groups):
117
+ spec = group.kv_cache_spec
118
+ if isinstance(spec, MambaSpec):
119
+ group_ids.append(i)
120
+ specs.append(spec)
121
+ assert specs, "no mamba layers in the model"
122
+ assert all(specs[0] == s for s in specs)
123
+ self._mamba_group_ids = group_ids
124
+ self._mamba_spec = specs[0]
125
+ return self._mamba_group_ids, self._mamba_spec
126
+
127
+ def _ensure_align_ctx(
128
+ self,
129
+ kv_cache_config: KVCacheConfig,
130
+ mamba_group_ids: list[int],
131
+ block_tables: tuple[torch.Tensor, ...],
132
+ ) -> MambaSpecDecodeGPUContext:
133
+ if self._mamba_ctx is None:
134
+ copy_funcs = self.model.get_mamba_state_copy_func()
135
+ # The fused copy kernels shift conv windows assuming the SD layout;
136
+ # the DS layout cannot express a >0 spec-decode shift as a single
137
+ # contiguous copy (mirrors get_conv_copy_spec's NotImplementedError).
138
+ if get_conv_copy_spec in copy_funcs and is_conv_state_dim_first():
139
+ assert self.vllm_config.speculative_config is None, (
140
+ "DS conv state layout does not support mamba align state "
141
+ "copies with speculative decoding"
142
+ )
143
+ self._mamba_ctx = MambaSpecDecodeGPUContext.create(
144
+ max_num_reqs=self.max_num_reqs,
145
+ kv_cache_config=kv_cache_config,
146
+ num_state_types=len(copy_funcs),
147
+ device=self.device,
148
+ make_buffer=lambda n, dtype: CpuGpuBuffer(
149
+ n, dtype=dtype, device=self.device
150
+ ),
151
+ )
152
+ ctx = self._mamba_ctx
153
+ if not ctx.is_initialized:
154
+ forward_context = self.vllm_config.compilation_config.static_forward_context
155
+ # block_tables are batch-order slices of the persistent
156
+ # input_block_tables (stable data_ptr), so the metadata is captured
157
+ # once here and reused across steps.
158
+ ctx.initialize_from_forward_context(
159
+ kv_cache_config,
160
+ forward_context,
161
+ self.model.get_mamba_state_copy_func(),
162
+ [block_tables[gid] for gid in mamba_group_ids],
163
+ )
164
+ return ctx
165
+
166
+ def preprocess_state(
167
+ self,
168
+ input_batch: InputBatch,
169
+ block_tables: tuple[torch.Tensor, ...],
170
+ kv_cache_config: KVCacheConfig,
171
+ num_computed_tokens: torch.Tensor,
172
+ ) -> None:
173
+ """Migrate each request's mamba state across block boundaries before the
174
+ forward (V1 align semantics, done on GPU). Runs on real batches only
175
+ (dummy DP/profiling runs skip preprocess_state), and before
176
+ ``prepare_attn`` gathers ``num_accepted_tokens``, so the boundary reset
177
+ is visible to the forward kernels.
178
+ """
179
+ if not self._align_mode:
180
+ return
181
+ num_reqs = input_batch.num_reqs
182
+ if num_reqs == 0:
183
+ return
184
+ mamba_group_ids, mamba_spec = self._get_mamba_group_info(kv_cache_config)
185
+ ctx = self._ensure_align_ctx(kv_cache_config, mamba_group_ids, block_tables)
186
+
187
+ # The state-advance + pre-copy kernels run every step; they fast-exit per
188
+ # request when src_col < 0 or src_col == dst_col, so no copy happens on
189
+ # steps that don't cross a block boundary. (Skipping the launch entirely
190
+ # would need a V1-style async-D2H of the actual num_computed, since
191
+ # num_computed_tokens_np is an optimistic mirror under async scheduling;
192
+ # the launch cost is ~0.3% of TPOT, so the GPU fast-exit suffices.)
193
+ block = 256
194
+ grid = (triton.cdiv(num_reqs, block),)
195
+ preprocess_mamba_align_fused_kernel[grid](
196
+ input_batch.idx_mapping,
197
+ self._mamba_state_idx_gpu,
198
+ num_computed_tokens,
199
+ input_batch.query_start_loc,
200
+ self.num_accepted_tokens_gpu,
201
+ self._mamba_src_col_gpu,
202
+ self._mamba_src_off_gpu,
203
+ num_reqs,
204
+ BLOCK_SIZE=block,
205
+ MAMBA_BLOCK_SIZE=mamba_spec.block_size,
206
+ )
207
+ ctx.run_fused_precopy(
208
+ num_reqs,
209
+ self._mamba_state_idx_gpu,
210
+ self._mamba_src_col_gpu,
211
+ self._mamba_src_off_gpu,
212
+ input_batch.idx_mapping,
213
+ )
214
215
def prepare_attn(
216
self,
@@ -145,22 +288,45 @@ class MambaHybridModelState(DefaultModelState):
288
)
289
290
def postprocess_state(
148
- self, idx_mapping: torch.Tensor, num_sampled: torch.Tensor | int
291
+ self,
292
+ idx_mapping: torch.Tensor,
293
+ num_sampled: torch.Tensor | int,
294
+ num_computed_tokens: torch.Tensor | None = None,
295
) -> None:
296
# Chunked prefill does not sample a token, so num_sampled can be 0.
297
# Mamba treats num_accepted_tokens=1 as the neutral non-spec value.
298
if not isinstance(num_sampled, int):
299
# idx_mapping may contain -1 sentinels (filtered rows) under PP; the
300
# kernel skips them rather than scattering with a host-side gather.
155
- num_reqs = idx_mapping.shape[0]
156
- if num_reqs:
157
- _scatter_num_accepted_kernel[(num_reqs,)](
301
+ n = idx_mapping.shape[0]
302
+ if n:
303
+ _scatter_num_accepted_kernel[(n,)](
304
idx_mapping, num_sampled, self.num_accepted_tokens_gpu
305
)
160
- return
306
+ else:
307
+ # Fill with single value.
308
+ self.num_accepted_tokens_gpu.index_fill_(
309
+ 0, idx_mapping, max(num_sampled, 1)
310
+ )
311
162
- # Fill with single value.
163
- self.num_accepted_tokens_gpu.index_fill_(0, idx_mapping, max(num_sampled, 1))
312
+ # Align: save the running state to the block-aligned position when
313
+ # spec-decode acceptance leaves the sequence non-block-aligned (mirrors
314
+ # the V1 align postprocess). num_computed_tokens already holds the
315
+ # post-step advanced count.
316
+ if (
317
+ self._align_mode
318
+ and num_computed_tokens is not None
319
+ and self._mamba_ctx is not None
320
+ ):
321
+ num_reqs = idx_mapping.shape[0]
322
+ if num_reqs:
323
+ self._mamba_ctx.run_fused_postprocess_align(
324
+ num_reqs,
325
+ self.num_accepted_tokens_gpu,
326
+ self._mamba_state_idx_gpu,
327
+ num_computed_tokens,
328
+ idx_mapping,
329
+ )
330
331
332
@triton.jit
vllm/v1/worker/gpu/warmup.py
+12
-3
@@ -17,6 +17,7 @@ from vllm.v1.core.sched.output import (
17
NewRequestData,
18
SchedulerOutput,
19
)
20
+from vllm.v1.kv_cache_interface import MambaSpec
21
from vllm.v1.request import Request
22
from vllm.v1.worker.gpu.model_runner import GPUModelRunner
23
@@ -177,9 +178,17 @@ def warmup_kernels(
178
num_kv_cache_groups = len(kv_cache_groups)
179
180
# Compute per-request block counts for each KV cache group.
180
- group_block_sizes = [g.kv_cache_spec.block_size for g in kv_cache_groups]
181
- prefill_block_counts = [cdiv(prompt_len, bs) for bs in group_block_sizes]
182
- decode_block_counts = [cdiv(decode_len, bs) for bs in group_block_sizes]
181
+ def _warmup_block_count(num_tokens: int, spec: Any) -> int:
182
+ num_blocks = cdiv(num_tokens, spec.block_size)
183
+ if isinstance(spec, MambaSpec) and spec.mamba_cache_mode == "align":
184
+ # Align mode reserves extra blocks beyond the token range for the
185
+ # speculative-decode running-state snapshots.
186
+ num_blocks += spec.num_speculative_blocks
187
+ return num_blocks
188
+
189
+ kv_cache_specs = [g.kv_cache_spec for g in kv_cache_groups]
190
+ prefill_block_counts = [_warmup_block_count(prompt_len, s) for s in kv_cache_specs]
191
+ decode_block_counts = [_warmup_block_count(decode_len, s) for s in kv_cache_specs]
192
decode_block_deltas = [
193
d - p for d, p in zip(decode_block_counts, prefill_block_counts)
194
]
vllm/v1/worker/mamba_utils.py
+367
-91
@@ -23,6 +23,112 @@ from vllm.v1.worker.gpu_input_batch import CachedRequestState
23
from vllm.v1.worker.lora_model_runner_mixin import GPUInputBatch
24
25
26
+@triton.jit
27
+def _copy_mamba_state_block(
28
+ state_idx,
29
+ bt_row_idx,
30
+ src_col,
31
+ dst_col,
32
+ token_bias,
33
+ block_table_ptrs_ptr,
34
+ block_table_stride_req,
35
+ state_base_addrs_ptr,
36
+ state_block_strides_ptr,
37
+ state_elem_sizes_ptr,
38
+ state_inner_sizes_ptr,
39
+ state_conv_widths_ptr,
40
+ state_group_indices_ptr,
41
+ # DS conv row metadata. Zero keeps the single-region copy path.
42
+ state_dim_row_count_ptr,
43
+ state_dim_row_stride_ptr,
44
+ COPY_BLOCK_SIZE: tl.constexpr,
45
+ CONV_STATE_DIM_FIRST: tl.constexpr,
46
+):
47
+ """Copy one (layer, state-type) mamba state block between block columns.
48
+
49
+ Shared copy body of ``postprocess_mamba_fused_kernel`` and
50
+ ``precopy_mamba_align_fused_kernel``, mirroring the V1 copy specs
51
+ (``get_conv_copy_spec`` / ``get_temporal_copy_spec``):
52
+ - conv state (conv_width > 0): shift the window by ``token_bias`` tokens,
53
+ ``state[bt[src_col], token_bias:] ->
54
+ state[bt[dst_col], :conv_width - token_bias]``
55
+ - temporal state: ``token_bias`` selects the accepted speculative column,
56
+ ``state[bt[src_col + token_bias]] -> state[bt[dst_col]]``
57
+
58
+ The caller owns the decision logic (which columns, whether to copy); this
59
+ device function only performs the byte copy for the given metadata slot.
60
+ """
61
+ state_base_addr = tl.load(state_base_addrs_ptr + state_idx)
62
+ state_block_stride = tl.load(state_block_strides_ptr + state_idx)
63
+ state_elem_size = tl.load(state_elem_sizes_ptr + state_idx)
64
+ state_inner_size = tl.load(state_inner_sizes_ptr + state_idx)
65
+ conv_width = tl.load(state_conv_widths_ptr + state_idx)
66
+
67
+ # Load the group index for this state, then index into the correct
68
+ # group's block table. Each mamba group has independently allocated
69
+ # physical blocks. Reinterpret as int32* since block ids are int32.
70
+ group_idx = tl.load(state_group_indices_ptr + state_idx).to(tl.int64)
71
+ group_base_addr = tl.load(block_table_ptrs_ptr + group_idx)
72
+ block_table_typed = group_base_addr.to(tl.pointer_type(tl.int32))
73
+ block_table_base = block_table_typed + bt_row_idx * block_table_stride_req
74
+
75
+ # Widen block ids to int64 before they reach `block_id * state_block_stride`
76
+ # below: state_block_stride can exceed 2**31 bytes for large mamba caches,
77
+ # and Triton would otherwise do the multiply in int32 and wrap.
78
+ dest_block_id = tl.load(block_table_base + dst_col).to(tl.int64)
79
+ dst_addr = state_base_addr + dest_block_id * state_block_stride
80
+
81
+ is_conv_state = conv_width > 0
82
+
83
+ if CONV_STATE_DIM_FIRST and is_conv_state:
84
+ # DS conv layout: state_len is the slide axis; copy per dim row.
85
+ src_block_id = tl.load(block_table_base + src_col).to(tl.int64)
86
+ dim_rows = tl.load(state_dim_row_count_ptr + state_idx)
87
+ row_stride = tl.load(state_dim_row_stride_ptr + state_idx)
88
+ per_row_bytes = (conv_width - token_bias).to(tl.int64) * state_elem_size
89
+ bias_bytes = token_bias.to(tl.int64) * state_elem_size
90
+ src_block_addr = state_base_addr + src_block_id * state_block_stride
91
+ offsets = tl.arange(0, COPY_BLOCK_SIZE)
92
+ for d in range(0, dim_rows):
93
+ row_src = src_block_addr + d * row_stride + bias_bytes
94
+ row_dst = dst_addr + d * row_stride
95
+ for i in range(0, per_row_bytes, COPY_BLOCK_SIZE):
96
+ mask = (i + offsets) < per_row_bytes
97
+ curr_src = (row_src + i + offsets).to(tl.pointer_type(tl.uint8))
98
+ curr_dst = (row_dst + i + offsets).to(tl.pointer_type(tl.uint8))
99
+ data = tl.load(curr_src, mask=mask)
100
+ tl.store(curr_dst, data, mask=mask)
101
+ return
102
+
103
+ if is_conv_state:
104
+ # SD conv: copy
105
+ # state[bt[src_col], token_bias:] ->
106
+ # state[bt[dst_col], :conv_width - token_bias]
107
+ src_block_id = tl.load(block_table_base + src_col).to(tl.int64)
108
+ src_offset = token_bias.to(tl.int64) * state_inner_size * state_elem_size
109
+ src_addr = state_base_addr + src_block_id * state_block_stride + src_offset
110
+ num_elems_to_copy = (conv_width - token_bias).to(tl.int64) * state_inner_size
111
+ copy_size = num_elems_to_copy * state_elem_size
112
+ else:
113
+ # Temporal state: copy state[bt[src_col + token_bias]] -> state[bt[dst_col]]
114
+ actual_src_block_id = tl.load(block_table_base + src_col + token_bias).to(
115
+ tl.int64
116
+ )
117
+ src_addr = state_base_addr + actual_src_block_id * state_block_stride
118
+ # Use natural block data size (inner_size * elem_size), NOT
119
+ # state_block_stride which is the page stride and can exceed the
120
+ # actual data when the state tensor uses as_strided page padding.
121
+ copy_size = state_inner_size * state_elem_size
122
+
123
+ offsets = tl.arange(0, COPY_BLOCK_SIZE)
124
+ for i in range(0, copy_size, COPY_BLOCK_SIZE):
125
+ mask = (i + offsets) < copy_size
126
+ curr_src = (src_addr + i + offsets).to(tl.pointer_type(tl.uint8))
127
+ curr_dst = (dst_addr + i + offsets).to(tl.pointer_type(tl.uint8))
128
+ data = tl.load(curr_src, mask=mask)
129
+ tl.store(curr_dst, data, mask=mask)
130
+
131
+
132
@triton.jit
133
def postprocess_mamba_fused_kernel(
134
# Decision inputs (per-request)
@@ -49,6 +155,10 @@ def postprocess_mamba_fused_kernel(
155
state_dim_row_stride_ptr, # int64: bytes between rows for DS conv
156
# Output: num_accepted_tokens update (for src==dst case)
157
num_accepted_tokens_out_ptr,
158
+ # Optional: batch_idx -> req_idx mapping (V2 model runner / PP). The
159
+ # per-request decision arrays are in req-state-slot order; the block table
160
+ # is in batch order, so HAS_IDX_MAPPING splits the two indexings.
161
+ idx_mapping_ptr,
162
# Runtime parameter (varies per batch - NOT constexpr to avoid recompilation)
163
num_reqs,
164
# Compile-time constants (fixed after model initialization)
@@ -57,35 +167,53 @@ def postprocess_mamba_fused_kernel(
167
# COPY_BLOCK_SIZE: fixed tuning parameter for memory copy loop
168
COPY_BLOCK_SIZE: tl.constexpr,
169
CONV_STATE_DIM_FIRST: tl.constexpr,
170
+ # HAS_IDX_MAPPING: when True, program_id(0) is a batch index resolved to a
171
+ # req-state slot via idx_mapping_ptr (V2). When False, it is the req index.
172
+ HAS_IDX_MAPPING: tl.constexpr = False,
173
+ # PRECOMPUTED_NEW_COMPUTED: when True, num_computed_tokens_ptr already holds
174
+ # the post-step new_num_computed value (V2 supplies the advanced count).
175
+ PRECOMPUTED_NEW_COMPUTED: tl.constexpr = False,
176
):
177
"""
178
Fused GPU kernel for postprocess_mamba that computes decisions AND performs
179
mamba state copies without any CPU-GPU synchronization.
180
181
Grid: (num_reqs, num_layers * num_state_types)
66
- - program_id(0) = request index
182
+ - program_id(0) = request/batch index
183
- program_id(1) = state_idx (flattened index into layer/state_type metadata)
184
185
Note: num_layers and num_state_types are not passed as kernel parameters
186
because the kernel indexes directly into pre-flattened metadata arrays
187
using program_id(1). The grid dimensions encode the total state count.
188
"""
73
- req_idx = tl.program_id(0)
189
+ batch_idx = tl.program_id(0)
190
state_idx = tl.program_id(1)
191
192
# Bounds check
77
- if req_idx >= num_reqs:
193
+ if batch_idx >= num_reqs:
194
return
195
196
+ if HAS_IDX_MAPPING:
197
+ req_idx = tl.load(idx_mapping_ptr + batch_idx)
198
+ if req_idx < 0:
199
+ return
200
+ else:
201
+ req_idx = batch_idx
202
+
203
# Compute decision logic (mirrors postprocess_mamba Python reference)
204
num_accepted = tl.load(num_accepted_tokens_ptr + req_idx)
205
src_block_idx = tl.load(mamba_state_idx_ptr + req_idx)
83
- num_scheduled = tl.load(num_scheduled_tokens_ptr + req_idx)
84
- num_computed = tl.load(num_computed_tokens_ptr + req_idx)
85
- num_draft = tl.load(num_draft_tokens_ptr + req_idx)
206
87
- num_tokens_running_state = num_computed + num_scheduled - num_draft
88
- new_num_computed = num_tokens_running_state + num_accepted - 1
207
+ if PRECOMPUTED_NEW_COMPUTED:
208
+ new_num_computed = tl.load(num_computed_tokens_ptr + req_idx)
209
+ num_tokens_running_state = new_num_computed - num_accepted + 1
210
+ else:
211
+ num_scheduled = tl.load(num_scheduled_tokens_ptr + req_idx)
212
+ num_computed = tl.load(num_computed_tokens_ptr + req_idx)
213
+ num_draft = tl.load(num_draft_tokens_ptr + req_idx)
214
+ num_tokens_running_state = num_computed + num_scheduled - num_draft
215
+ new_num_computed = num_tokens_running_state + num_accepted - 1
216
+
217
aligned_new_computed = (new_num_computed // block_size) * block_size
218
219
needs_copy = aligned_new_computed >= num_tokens_running_state
@@ -97,99 +225,158 @@ def postprocess_mamba_fused_kernel(
225
accept_token_bias = aligned_new_computed - num_tokens_running_state
226
dest_block_idx = aligned_new_computed // block_size - 1
227
100
- # Load state metadata for this layer/state_type
101
- state_base_addr = tl.load(state_base_addrs_ptr + state_idx)
102
- state_block_stride = tl.load(state_block_strides_ptr + state_idx)
103
- state_elem_size = tl.load(state_elem_sizes_ptr + state_idx)
104
- state_inner_size = tl.load(state_inner_sizes_ptr + state_idx)
105
- conv_width = tl.load(state_conv_widths_ptr + state_idx)
228
+ # Update accepted-token count before early exits (per-request, so only
229
+ # state_idx == 0 writes). V2 updates in place; V1 writes the _out buffer.
230
+ if src_block_idx == dest_block_idx and state_idx == 0:
231
+ if HAS_IDX_MAPPING:
232
+ tl.store(num_accepted_tokens_ptr + req_idx, 1)
233
+ else:
234
+ tl.store(num_accepted_tokens_out_ptr + req_idx, 1)
235
107
- # Load the group index for this state, then index into the correct
108
- # group's block table. Each mamba group has independently allocated
109
- # physical blocks.
110
- group_idx = tl.load(state_group_indices_ptr + state_idx).to(tl.int64)
236
+ # Skip no-op self-copy.
237
+ if src_block_idx == dest_block_idx and accept_token_bias == 0:
238
+ return
239
112
- # block_table_ptrs_ptr holds one pointer per group (each group owns its own
113
- # block table). Reinterpret as int32* since block ids are int32.
114
- group_base_addr = tl.load(block_table_ptrs_ptr + group_idx)
115
- block_table_typed = group_base_addr.to(tl.pointer_type(tl.int32))
116
- block_table_base = block_table_typed + req_idx * block_table_stride_req
240
+ bt_row_idx = batch_idx if HAS_IDX_MAPPING else req_idx
241
+ _copy_mamba_state_block(
242
+ state_idx,
243
+ bt_row_idx,
244
+ src_block_idx,
245
+ dest_block_idx,
246
+ accept_token_bias,
247
+ block_table_ptrs_ptr,
248
+ block_table_stride_req,
249
+ state_base_addrs_ptr,
250
+ state_block_strides_ptr,
251
+ state_elem_sizes_ptr,
252
+ state_inner_sizes_ptr,
253
+ state_conv_widths_ptr,
254
+ state_group_indices_ptr,
255
+ state_dim_row_count_ptr,
256
+ state_dim_row_stride_ptr,
257
+ COPY_BLOCK_SIZE,
258
+ CONV_STATE_DIM_FIRST,
259
+ )
260
118
- # Widen block ids to int64 before they reach `block_id * state_block_stride`
119
- # below: state_block_stride can exceed 2**31 bytes for large mamba caches,
120
- # and Triton would otherwise do the multiply in int32 and wrap.
121
- src_block_id = tl.load(block_table_base + src_block_idx).to(tl.int64)
122
- dest_block_id = tl.load(block_table_base + dest_block_idx).to(tl.int64)
261
124
- # Compute source and destination addresses based on state type
125
- # conv_width > 0 means this is a conv state (get_conv_copy_spec logic)
126
- # conv_width == 0 means this is a temporal state (get_temporal_copy_spec logic)
127
- is_conv_state = conv_width > 0
262
+@triton.jit
263
+def preprocess_mamba_align_fused_kernel(
264
+ idx_mapping_ptr,
265
+ state_idx_ptr,
266
+ num_computed_tokens_ptr,
267
+ query_start_loc_ptr,
268
+ num_accepted_tokens_ptr,
269
+ src_col_ptr,
270
+ src_off_ptr,
271
+ num_reqs,
272
+ BLOCK_SIZE: tl.constexpr,
273
+ MAMBA_BLOCK_SIZE: tl.constexpr,
274
+):
275
+ """Fused align preprocess: emit the pre-copy src column/offset AND advance
276
+ state_idx (with accepted-token reset) in a single launch (V2 align).
277
+
278
+ Per batch_idx (0..num_reqs-1), resolving req slot via idx_mapping:
279
+ 1. Read pre-advance state_idx and num_accepted (last step's values).
280
+ 2. Store the pre-copy src columns for ``precopy_mamba_align_fused_kernel``:
281
+ - src_col = state_idx (the previous running block column)
282
+ - src_off = max(num_accepted - 1, 0) (the accepted-token bias)
283
+ 3. Advance state_idx to the new running block, and reset num_accepted to 1
284
+ when a block boundary is crossed (so the migrated state, now at the
285
+ start of the new block, is read with the neutral bias).
286
+ """
287
+ offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
288
+ mask = offsets < num_reqs
289
+ req_indices = tl.load(idx_mapping_ptr + offsets, mask=mask, other=0)
290
129
- # Update accepted-token count before early exits.
130
- if src_block_idx == dest_block_idx and state_idx == 0:
131
- tl.store(num_accepted_tokens_out_ptr + req_idx, 1)
291
+ state_idx = tl.load(state_idx_ptr + req_indices, mask=mask, other=-1)
292
+ num_accepted = tl.load(num_accepted_tokens_ptr + req_indices, mask=mask, other=1)
293
133
- # Skip no-op self-copy.
134
- if src_block_idx == dest_block_idx and accept_token_bias == 0:
135
- return
294
+ src_off = tl.maximum(num_accepted - 1, 0)
295
+ tl.store(src_col_ptr + req_indices, state_idx, mask=mask)
296
+ tl.store(src_off_ptr + req_indices, src_off, mask=mask)
297
137
- if CONV_STATE_DIM_FIRST and is_conv_state:
138
- dim_rows = tl.load(state_dim_row_count_ptr + state_idx)
139
- row_stride = tl.load(state_dim_row_stride_ptr + state_idx)
140
- per_row_bytes = (conv_width - accept_token_bias).to(tl.int64) * state_elem_size
141
- bias_bytes = accept_token_bias.to(tl.int64) * state_elem_size
142
- src_block_addr = state_base_addr + src_block_id * state_block_stride
143
- dst_block_addr = state_base_addr + dest_block_id * state_block_stride
144
- offsets = tl.arange(0, COPY_BLOCK_SIZE)
145
- for d in range(0, dim_rows):
146
- row_src = src_block_addr + d * row_stride + bias_bytes
147
- row_dst = dst_block_addr + d * row_stride
148
- for i in range(0, per_row_bytes, COPY_BLOCK_SIZE):
149
- mask = (i + offsets) < per_row_bytes
150
- curr_src = (row_src + i + offsets).to(tl.pointer_type(tl.uint8))
151
- curr_dst = (row_dst + i + offsets).to(tl.pointer_type(tl.uint8))
152
- data = tl.load(curr_src, mask=mask)
153
- tl.store(curr_dst, data, mask=mask)
298
+ num_computed = tl.load(num_computed_tokens_ptr + req_indices, mask=mask, other=0)
299
+ query_start = tl.load(query_start_loc_ptr + offsets, mask=mask, other=0)
300
+ query_end = tl.load(query_start_loc_ptr + offsets + 1, mask=mask, other=0)
301
+ computed_after = num_computed + query_end - query_start
302
+ new_state_idx = (computed_after + MAMBA_BLOCK_SIZE - 1) // MAMBA_BLOCK_SIZE - 1
303
+ tl.store(state_idx_ptr + req_indices, new_state_idx, mask=mask)
304
+ should_reset = (state_idx >= 0) & (state_idx != new_state_idx)
305
+ tl.store(num_accepted_tokens_ptr + req_indices, 1, mask=mask & should_reset)
306
+
307
+
308
+@triton.jit
309
+def precopy_mamba_align_fused_kernel(
310
+ # Per-request-slot inputs (indexed by req_idx via idx_mapping), produced by
311
+ # the V2 fused align preprocess kernel for the current step:
312
+ mamba_state_idx_ptr, # post-advance dst block column
313
+ src_col_ptr, # pre-advance src block column (-1 = fresh)
314
+ token_bias_ptr, # accepted-token bias = num_accepted - 1 (pre-reset)
315
+ # Same flattened state-layout metadata as postprocess_mamba_fused_kernel
316
+ block_table_ptrs_ptr,
317
+ block_table_stride_req: tl.int64,
318
+ state_base_addrs_ptr,
319
+ state_block_strides_ptr,
320
+ state_elem_sizes_ptr,
321
+ state_inner_sizes_ptr,
322
+ state_conv_widths_ptr,
323
+ state_group_indices_ptr,
324
+ state_dim_row_count_ptr,
325
+ state_dim_row_stride_ptr,
326
+ idx_mapping_ptr, # [num_reqs] batch_idx -> req_state_idx (-1 to skip)
327
+ num_reqs,
328
+ COPY_BLOCK_SIZE: tl.constexpr,
329
+ CONV_STATE_DIM_FIRST: tl.constexpr,
330
+):
331
+ """Pre-copy mamba "align" state across block boundaries on the V2 runner.
332
+
333
+ Before the forward pass, copy each request's last SSM/conv state from its
334
+ previous block column into the new window block column, so the kernels read
335
+ the initial state from the write-side block as usual (V1 align semantics).
336
+ Same per-(layer, state) copy semantics as ``postprocess_mamba_fused_kernel``
337
+ (shared ``_copy_mamba_state_block`` body, i.e. the V1 ``preprocess_mamba``
338
+ copy specs), but driven by the GPU-resident src columns so it needs no
339
+ CPU-GPU sync (async-scheduling safe).
340
+
341
+ Grid: (num_reqs, num_layers * num_state_types); block tables are indexed by
342
+ batch row, per-request state by req_idx via idx_mapping (V2 layout).
343
+ """
344
+ batch_idx = tl.program_id(0)
345
+ state_idx = tl.program_id(1)
346
+ if batch_idx >= num_reqs:
347
+ return
348
+ req_idx = tl.load(idx_mapping_ptr + batch_idx)
349
+ if req_idx < 0:
350
return
351
156
- if is_conv_state:
157
- # SD conv: copy
158
- # state[block_table[req_idx, src_block_idx], accept_token_bias:]
159
- # to
160
- # state[block_table[req_idx, dest_block_idx], :conv_width - accept_token_bias]
161
- src_offset = accept_token_bias.to(tl.int64) * state_inner_size * state_elem_size
162
- src_addr = state_base_addr + src_block_id * state_block_stride + src_offset
163
- dst_addr = state_base_addr + dest_block_id * state_block_stride
164
- # Number of elements to copy:
165
- # (conv_width - accept_token_bias) * inner_size
166
- num_elems_to_copy = (conv_width - accept_token_bias).to(
167
- tl.int64
168
- ) * state_inner_size
169
- copy_size = num_elems_to_copy * state_elem_size
170
- else:
171
- # Temporal state: copy
172
- # state[block_table[req_idx, src_block_idx + accept_token_bias]]
173
- # to
174
- # state[block_table[req_idx, dest_block_idx]]
175
- actual_src_block_idx = src_block_idx + accept_token_bias
176
- actual_src_block_id = tl.load(block_table_base + actual_src_block_idx).to(
177
- tl.int64
178
- )
179
- src_addr = state_base_addr + actual_src_block_id * state_block_stride
180
- dst_addr = state_base_addr + dest_block_id * state_block_stride
181
- # Use natural block data size (inner_size * elem_size), NOT
182
- # state_block_stride which is the page stride and can exceed the
183
- # actual data when the state tensor uses as_strided page padding.
184
- copy_size = state_inner_size * state_elem_size
352
+ src_col = tl.load(src_col_ptr + req_idx)
353
+ dst_col = tl.load(mamba_state_idx_ptr + req_idx)
354
+ # Fresh state, or still writing the same block: kernels locate the initial
355
+ # state in-block via num_accepted (preserved when no boundary is crossed),
356
+ # so there is nothing to copy.
357
+ if src_col < 0 or src_col == dst_col:
358
+ return
359
186
- offsets = tl.arange(0, COPY_BLOCK_SIZE)
187
- for i in range(0, copy_size, COPY_BLOCK_SIZE):
188
- mask = (i + offsets) < copy_size
189
- curr_src = (src_addr + i + offsets).to(tl.pointer_type(tl.uint8))
190
- curr_dst = (dst_addr + i + offsets).to(tl.pointer_type(tl.uint8))
191
- data = tl.load(curr_src, mask=mask)
192
- tl.store(curr_dst, data, mask=mask)
360
+ token_bias = tl.load(token_bias_ptr + req_idx)
361
+ _copy_mamba_state_block(
362
+ state_idx,
363
+ batch_idx,
364
+ src_col,
365
+ dst_col,
366
+ token_bias,
367
+ block_table_ptrs_ptr,
368
+ block_table_stride_req,
369
+ state_base_addrs_ptr,
370
+ state_block_strides_ptr,
371
+ state_elem_sizes_ptr,
372
+ state_inner_sizes_ptr,
373
+ state_conv_widths_ptr,
374
+ state_group_indices_ptr,
375
+ state_dim_row_count_ptr,
376
+ state_dim_row_stride_ptr,
377
+ COPY_BLOCK_SIZE,
378
+ CONV_STATE_DIM_FIRST,
379
+ )
380
381
382
@triton.jit
@@ -559,10 +746,99 @@ class MambaSpecDecodeGPUContext:
746
self.state_dim_row_count,
747
self.state_dim_row_stride,
748
self.num_accepted_tokens_out,
749
+ None, # idx_mapping: V1 decision arrays are already in req order
750
+ num_reqs,
751
+ block_size=self.block_size,
752
+ COPY_BLOCK_SIZE=1024,
753
+ CONV_STATE_DIM_FIRST=is_conv_state_dim_first(),
754
+ )
755
+
756
+ def run_fused_precopy(
757
+ self,
758
+ num_reqs: int,
759
+ state_idx_gpu: torch.Tensor,
760
+ src_col_gpu: torch.Tensor,
761
+ token_bias_gpu: torch.Tensor,
762
+ idx_mapping: torch.Tensor,
763
+ ) -> None:
764
+ """Pre-copy each request's previous running block into its new window
765
+ block before the forward pass (V2 align boundary migration).
766
+
767
+ Args:
768
+ num_reqs: Number of active requests (batch order).
769
+ state_idx_gpu: [max_reqs] post-advance dst block column per req slot.
770
+ src_col_gpu: [max_reqs] pre-advance src block column (-1 = fresh).
771
+ token_bias_gpu: [max_reqs] accepted-token bias (num_accepted - 1).
772
+ idx_mapping: [num_reqs] batch_idx -> req_state_idx (-1 to skip).
773
+ """
774
+ if num_reqs == 0 or not self.is_initialized:
775
+ return
776
+ total_states = self.num_layers * self.num_state_types
777
+ grid = (num_reqs, total_states)
778
+ precopy_mamba_align_fused_kernel[grid](
779
+ state_idx_gpu,
780
+ src_col_gpu,
781
+ token_bias_gpu,
782
+ self.block_table_ptrs,
783
+ self.block_table_stride_req,
784
+ self.state_base_addrs,
785
+ self.state_block_strides,
786
+ self.state_elem_sizes,
787
+ self.state_inner_sizes,
788
+ self.state_conv_widths,
789
+ self.state_group_indices,
790
+ self.state_dim_row_count,
791
+ self.state_dim_row_stride,
792
+ idx_mapping,
793
+ num_reqs,
794
+ COPY_BLOCK_SIZE=1024,
795
+ CONV_STATE_DIM_FIRST=is_conv_state_dim_first(),
796
+ )
797
+
798
+ def run_fused_postprocess_align(
799
+ self,
800
+ num_reqs: int,
801
+ num_accepted_tokens_gpu: torch.Tensor,
802
+ state_idx_gpu: torch.Tensor,
803
+ new_num_computed_tokens_gpu: torch.Tensor,
804
+ idx_mapping: torch.Tensor,
805
+ ) -> None:
806
+ """V2 align postprocess: save the running state to the block-aligned
807
+ position after spec-decode acceptance leaves the sequence non-aligned.
808
+
809
+ ``num_accepted_tokens_gpu`` is updated in place (reset to 1 when the
810
+ accepted position stays in the running block); ``new_num_computed_tokens``
811
+ already holds the post-step computed count (PRECOMPUTED_NEW_COMPUTED).
812
+ ``idx_mapping`` maps batch row -> req-state slot (HAS_IDX_MAPPING).
813
+ """
814
+ if num_reqs == 0 or not self.is_initialized:
815
+ return
816
+ total_states = self.num_layers * self.num_state_types
817
+ grid = (num_reqs, total_states)
818
+ postprocess_mamba_fused_kernel[grid](
819
+ num_accepted_tokens_gpu,
820
+ state_idx_gpu,
821
+ None, # num_scheduled: unused under PRECOMPUTED_NEW_COMPUTED
822
+ new_num_computed_tokens_gpu,
823
+ None, # num_draft: unused under PRECOMPUTED_NEW_COMPUTED
824
+ self.block_table_ptrs,
825
+ self.block_table_stride_req,
826
+ self.state_base_addrs,
827
+ self.state_block_strides,
828
+ self.state_elem_sizes,
829
+ self.state_inner_sizes,
830
+ self.state_conv_widths,
831
+ self.state_group_indices,
832
+ self.state_dim_row_count,
833
+ self.state_dim_row_stride,
834
+ None, # num_accepted_out: V2 updates num_accepted in place
835
+ idx_mapping,
836
num_reqs,
837
block_size=self.block_size,
838
COPY_BLOCK_SIZE=1024,
839
CONV_STATE_DIM_FIRST=is_conv_state_dim_first(),
840
+ HAS_IDX_MAPPING=True,
841
+ PRECOMPUTED_NEW_COMPUTED=True,
842
)
843
844