A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 7, 19:00 UTC
[Model][Spec Decode] Nemotron-H MTP and Mamba Speculative Decoding Support (#33726)
Signed-off-by: Shahar Mor <smor@nvidia.com> Signed-off-by: Benjamin Chislett <bchislett@nvidia.com> Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com> Co-authored-by: Shahar Mor <smor@nvidia.com> Co-authored-by: Roi Koren <roik@nvidia.com> Co-authored-by: Lucas Wilkinson <lwilkins@redhat.com>
Benjamin Chislett committed
Feb 24, 2026 at 12:49 UTC
f5972a872fa3fabd94b7a6c6f031f4b5bcee2b2d
19 files changed
+800
-158
tests/models/registry.py
+5
@@ -1200,6 +1200,11 @@ _SPECULATIVE_DECODING_EXAMPLE_MODELS = {
1200
},
1201
is_available_online=False,
1202
),
1203
+ "NemotronHMTPModel": _HfExamplesInfo(
1204
+ "nvidia/Nemotron-Super-Placeholder",
1205
+ speculative_model="nvidia/Nemotron-Super-Placeholder",
1206
+ is_available_online=False,
1207
+ ),
1208
}
1209
1210
_TRANSFORMERS_BACKEND_MODELS = {
tests/v1/attention/test_mamba_update_block_table.py
+7
-1
@@ -41,6 +41,9 @@ def _make_vllm_config(block_size, max_model_len, max_num_seqs):
41
cudagraph_mode=CUDAGraphMode.FULL,
42
max_cudagraph_capture_size=None,
43
),
44
+ speculative_config=None,
45
+ num_speculative_tokens=0,
46
+ parallel_config=SimpleNamespace(decode_context_parallel_size=1),
47
scheduler_config=SimpleNamespace(max_num_seqs=max_num_seqs),
48
model_config=SimpleNamespace(max_model_len=max_model_len),
49
)
@@ -92,7 +95,10 @@ def test_update_block_table_copies_block_idx_to_persistent_buffers():
95
has_initial_states_p=None,
96
query_start_loc_p=None,
97
num_computed_tokens_p=None,
95
- state_indices_tensor=builder_a.state_indices_tensor[:num_reqs],
98
+ state_indices_tensor_p=None,
99
+ query_start_loc_d=None,
100
+ num_accepted_tokens=None,
101
+ state_indices_tensor_d=builder_a.state_indices_tensor_d[:num_reqs],
102
block_idx_last_scheduled_token=(
103
builder_a.block_idx_last_scheduled_token[:num_reqs]
104
),
vllm/config/speculative.py
+17
-2
@@ -36,6 +36,7 @@ MTPModelTypes = Literal[
36
"glm4_moe_lite_mtp",
37
"glm_ocr_mtp",
38
"ernie_mtp",
39
+ "nemotron_h_mtp",
40
"exaone_moe_mtp",
41
"qwen3_next_mtp",
42
"qwen3_5_mtp",
@@ -255,6 +256,19 @@ class SpeculativeConfig:
256
{"n_predict": n_predict, "architectures": ["ErnieMTPModel"]}
257
)
258
259
+ if (
260
+ hf_config.model_type == "nemotron_h"
261
+ and hasattr(hf_config, "num_nextn_predict_layers")
262
+ and hf_config.num_nextn_predict_layers > 0
263
+ ):
264
+ # Check if this is an MTP variant
265
+ hf_config.model_type = "nemotron_h_mtp"
266
+ if hf_config.model_type == "nemotron_h_mtp":
267
+ n_predict = getattr(hf_config, "num_nextn_predict_layers", 1)
268
+ hf_config.update(
269
+ {"n_predict": n_predict, "architectures": ["NemotronHMTPModel"]}
270
+ )
271
+
272
if hf_config.model_type == "qwen3_next":
273
hf_config.model_type = "qwen3_next_mtp"
274
if hf_config.model_type == "qwen3_next_mtp":
@@ -325,7 +339,7 @@ class SpeculativeConfig:
339
if self.target_model_config is None:
340
raise ValueError("target_model_config must be present for mtp")
341
if self.target_model_config.hf_text_config.model_type == "deepseek_v32":
328
- # FIXME(luccafong): cudgraph with v32 MTP is not supported,
342
+ # FIXME(luccafong): cudagraph with v32 MTP is not supported,
343
# remove this when the issue is fixed.
344
self.enforce_eager = True
345
# use the draft model from the same model:
@@ -427,7 +441,7 @@ class SpeculativeConfig:
441
self.method = "mtp"
442
if self.num_speculative_tokens > 1:
443
logger.warning(
430
- "Enabling num_speculative_tokens > 1 will run"
444
+ "Enabling num_speculative_tokens > 1 will run "
445
"multiple times of forward on same MTP layer"
446
",which may result in lower acceptance rate"
447
)
@@ -712,6 +726,7 @@ class SpeculativeConfig:
726
"hunyuan_vl",
727
"hunyuan_v1_dense",
728
"afmoe",
729
+ "nemotron_h",
730
]
731
if (
732
self.method == "eagle3"
vllm/config/vllm.py
+9
@@ -395,6 +395,15 @@ class VllmConfig:
395
]
396
return hash_str
397
398
+ @property
399
+ def num_speculative_tokens(self) -> int:
400
+ if (
401
+ self.speculative_config is not None
402
+ and self.speculative_config.num_speculative_tokens is not None
403
+ ):
404
+ return self.speculative_config.num_speculative_tokens
405
+ return 0
406
+
407
@property
408
def needs_dp_coordinator(self) -> bool:
409
"""
vllm/model_executor/layers/mamba/abstract.py
-8
@@ -41,14 +41,6 @@ class MambaBase(AttentionLayerBase):
41
pass
42
43
def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec | None:
44
- if (
45
- vllm_config.speculative_config is not None
46
- and vllm_config.model_config.hf_config.model_type
47
- not in ["qwen3_next", "qwen3_5", "qwen3_5_moe"]
48
- ):
49
- raise NotImplementedError(
50
- "Mamba with speculative decoding is not supported yet."
51
- )
44
mamba_block_size = vllm_config.cache_config.mamba_block_size
45
page_size_padded = vllm_config.cache_config.mamba_page_size_padded
46
return MambaSpec(
vllm/model_executor/layers/mamba/mamba_mixer.py
+2
-18
@@ -265,7 +265,8 @@ class MambaMixer(MambaBase, PluggableLayer):
265
attn_metadata = attn_metadata[self.prefix]
266
assert isinstance(attn_metadata, Mamba1AttentionMetadata)
267
query_start_loc_p = attn_metadata.query_start_loc_p
268
- state_indices_tensor = attn_metadata.state_indices_tensor
268
+ state_indices_tensor_p = attn_metadata.state_indices_tensor_p
269
+ state_indices_tensor_d = attn_metadata.state_indices_tensor_d
270
self_kv_cache = self.kv_cache[forward_context.virtual_engine]
271
conv_state = self_kv_cache[0].transpose(-1, -2)
272
ssm_state = self_kv_cache[1]
@@ -295,17 +296,13 @@ class MambaMixer(MambaBase, PluggableLayer):
296
prefill_decode_split = split_batch_to_prefill_and_decode(
297
hidden_states_BC,
298
gate,
298
- state_indices_tensor,
299
num_prefill_tokens,
300
- num_prefills,
300
num_decode_tokens,
301
)
302
hidden_states_BC_p = prefill_decode_split.hidden_states_BC_p
303
hidden_states_BC_d = prefill_decode_split.hidden_states_BC_d
304
gate_p = prefill_decode_split.gate_p
305
gate_d = prefill_decode_split.gate_d
307
- state_indices_tensor_p = prefill_decode_split.state_indices_tensor_p
308
- state_indices_tensor_d = prefill_decode_split.state_indices_tensor_d
306
307
if is_mamba_cache_all:
308
block_idx_last_computed_token_d, block_idx_last_computed_token_p = (
@@ -477,16 +474,12 @@ class PrefillDecodeSplit(NamedTuple):
474
hidden_states_BC_d: torch.Tensor
475
gate_p: torch.Tensor
476
gate_d: torch.Tensor
480
- state_indices_tensor_p: torch.Tensor
481
- state_indices_tensor_d: torch.Tensor
477
478
479
def split_batch_to_prefill_and_decode(
480
hidden_states_BC: torch.Tensor,
481
gate: torch.Tensor,
487
- state_indices_tensor: torch.Tensor,
482
num_prefill_tokens: int,
489
- num_prefills: int,
483
num_decode_tokens: int,
484
) -> PrefillDecodeSplit:
485
num_actual_tokens = num_prefill_tokens + num_decode_tokens
@@ -501,20 +494,11 @@ def split_batch_to_prefill_and_decode(
494
gate[..., :num_actual_tokens], [num_decode_tokens, num_prefill_tokens], dim=-1
495
)
496
504
- # num_decode_tokens accounts for CUDA graph padding when applicable
505
- state_indices_tensor_d, state_indices_tensor_p = torch.split(
506
- state_indices_tensor[: num_decode_tokens + num_prefills],
507
- [num_decode_tokens, num_prefills],
508
- dim=0,
509
- )
510
-
497
return PrefillDecodeSplit(
498
hidden_states_BC_p=hidden_states_BC_p,
499
hidden_states_BC_d=hidden_states_BC_d,
500
gate_p=gate_p,
501
gate_d=gate_d,
516
- state_indices_tensor_p=state_indices_tensor_p,
517
- state_indices_tensor_d=state_indices_tensor_d,
502
)
503
504
vllm/model_executor/layers/mamba/mamba_mixer2.py
+27
-19
@@ -477,7 +477,8 @@ class MambaMixer2(MambaBase, PluggableLayer):
477
dim=-1,
478
)
479
480
- compilation_config = get_current_vllm_config().compilation_config
480
+ vllm_config = get_current_vllm_config()
481
+ compilation_config = vllm_config.compilation_config
482
if prefix in compilation_config.static_forward_context:
483
raise ValueError(f"Duplicate layer name: {prefix}")
484
compilation_config.static_forward_context[prefix] = self
@@ -488,6 +489,8 @@ class MambaMixer2(MambaBase, PluggableLayer):
489
self.cache_config = cache_config
490
self.prefix = prefix
491
492
+ self.num_spec = vllm_config.num_speculative_tokens
493
+
494
# Pre-compute sizes for forward pass
495
self.tped_intermediate_size = self.intermediate_size // self.tp_size
496
self.tped_conv_size = self.conv_dim // self.tp_size
@@ -576,7 +579,6 @@ class MambaMixer2(MambaBase, PluggableLayer):
579
# conv_state = (..., dim, width-1) yet contiguous along 'dim'
580
conv_state = self_kv_cache[0].transpose(-1, -2)
581
ssm_state = self_kv_cache[1]
579
- state_indices_tensor = attn_metadata.state_indices_tensor
582
has_initial_states_p = attn_metadata.has_initial_states_p
583
prep_initial_states = attn_metadata.prep_initial_states
584
chunk_size = attn_metadata.chunk_size
@@ -584,6 +586,12 @@ class MambaMixer2(MambaBase, PluggableLayer):
586
query_start_loc_p = attn_metadata.query_start_loc_p
587
cu_chunk_seqlen_p = attn_metadata.cu_chunk_seqlen_p
588
last_chunk_indices_p = attn_metadata.last_chunk_indices_p
589
+ state_indices_tensor_p = attn_metadata.state_indices_tensor_p
590
+ state_indices_tensor_d = attn_metadata.state_indices_tensor_d
591
+ num_accepted_tokens = attn_metadata.num_accepted_tokens
592
+ query_start_loc_d = attn_metadata.query_start_loc_d
593
+ num_decodes = attn_metadata.num_decodes
594
+ num_decode_tokens = attn_metadata.num_decode_tokens
595
596
if attn_metadata is None:
597
# profile run
@@ -593,29 +601,21 @@ class MambaMixer2(MambaBase, PluggableLayer):
601
hidden_states, _B, _C = self.split_hidden_states_B_C_fn(hidden_states_B_C)
602
return hidden_states
603
596
- num_prefills = attn_metadata.num_prefills # request count
597
- num_decodes = attn_metadata.num_decode_tokens # token count (=request)
598
- num_prefill_tokens = attn_metadata.num_prefill_tokens # token count
604
+ num_prefills = attn_metadata.num_prefills
605
+ num_prefill_tokens = attn_metadata.num_prefill_tokens
606
has_prefill = num_prefills > 0
607
has_decode = num_decodes > 0
601
- num_actual_tokens = num_prefill_tokens + num_decodes
608
+ num_actual_tokens = num_prefill_tokens + num_decode_tokens
609
603
- # Separate prefill and decode by splitting varlen input
610
# Split along token dimension
611
hidden_states_B_C_d, hidden_states_B_C_p = torch.split(
612
hidden_states_B_C[:num_actual_tokens],
607
- [num_decodes, num_prefill_tokens],
613
+ [num_decode_tokens, num_prefill_tokens],
614
dim=0,
615
)
616
dt_d, dt_p = torch.split(
617
dt[:num_actual_tokens],
612
- [num_decodes, num_prefill_tokens],
613
- dim=0,
614
- )
615
- # Split along batch dimension
616
- state_indices_tensor_d, state_indices_tensor_p = torch.split(
617
- state_indices_tensor[:num_actual_tokens],
618
- [num_decodes, num_prefills],
618
+ [num_decode_tokens, num_prefill_tokens],
619
dim=0,
620
)
621
@@ -642,16 +642,16 @@ class MambaMixer2(MambaBase, PluggableLayer):
642
)
643
num_computed_tokens_p = attn_metadata.num_computed_tokens_p
644
else:
645
- block_idx_last_computed_token_d = None
645
block_idx_last_computed_token_p = None
647
- block_idx_last_scheduled_token_d = None
646
block_idx_last_scheduled_token_p = None
647
block_idx_first_scheduled_token_p = None
648
+ block_idx_last_scheduled_token_d = None
649
+ block_idx_last_computed_token_d = None
650
num_computed_tokens_p = None
651
652
preallocated_ssm_out_d, preallocated_ssm_out_p = torch.split(
653
output[:num_actual_tokens],
654
- [num_decodes, num_prefill_tokens],
654
+ [num_decode_tokens, num_prefill_tokens],
655
dim=0,
656
)
657
@@ -709,6 +709,7 @@ class MambaMixer2(MambaBase, PluggableLayer):
709
)
710
711
# NOTE: final output is an in-place update of out tensor
712
+ assert preallocated_ssm_out_p is not None
713
varlen_states = mamba_chunk_scan_combined_varlen(
714
hidden_states_p.view(
715
num_prefill_tokens, self.num_heads // self.tp_size, self.head_dim
@@ -840,6 +841,9 @@ class MambaMixer2(MambaBase, PluggableLayer):
841
conv_state_indices=state_indices_tensor_d,
842
block_idx_last_scheduled_token=block_idx_last_scheduled_token_d,
843
initial_state_idx=block_idx_last_computed_token_d,
844
+ num_accepted_tokens=num_accepted_tokens,
845
+ query_start_loc=query_start_loc_d,
846
+ max_query_len=state_indices_tensor_d.size(-1),
847
)
848
849
hidden_states_d, B_d, C_d = self.split_hidden_states_B_C_fn(
@@ -862,6 +866,7 @@ class MambaMixer2(MambaBase, PluggableLayer):
866
-1, self.num_heads // self.tp_size, self.head_dim
867
)
868
869
+ assert preallocated_ssm_out_d is not None
870
# - the hidden is reshaped into (bs, num_heads, head_dim)
871
# - mamba_cache_params.ssm_state's slots will be selected
872
# using state_indices_tensor_d
@@ -879,7 +884,9 @@ class MambaMixer2(MambaBase, PluggableLayer):
884
dt_softplus=True,
885
state_batch_indices=state_indices_tensor_d_input,
886
dst_state_batch_indices=state_indices_tensor_d_output,
882
- out=preallocated_ssm_out_d.view(num_decodes, -1, self.head_dim),
887
+ out=preallocated_ssm_out_d.view(num_decode_tokens, -1, self.head_dim),
888
+ num_accepted_tokens=num_accepted_tokens,
889
+ cu_seqlens=query_start_loc_d,
890
is_blackwell=self.is_blackwell,
891
)
892
@@ -901,6 +908,7 @@ class MambaMixer2(MambaBase, PluggableLayer):
908
head_dim=self.head_dim,
909
state_size=self.ssm_state_size,
910
conv_kernel=self.conv_kernel_size,
911
+ num_spec=self.num_spec,
912
)
913
914
@property
vllm/model_executor/layers/mamba/mamba_utils.py
+2
-1
@@ -133,6 +133,7 @@ class MambaStateShapeCalculator:
133
head_dim: int,
134
state_size: int,
135
conv_kernel: int,
136
+ num_spec: int = 0,
137
) -> tuple[tuple[int, int], tuple[int, int, int]]:
138
# if n_groups is not divisible by world_size, need to extend the shards
139
# to ensure all groups needed by a head is sharded along with it
@@ -141,7 +142,7 @@ class MambaStateShapeCalculator:
142
conv_dim = intermediate_size + 2 * n_groups * state_size
143
144
# contiguous along 'dim' axis
144
- conv_state_shape = (conv_kernel - 1, divide(conv_dim, tp_world_size))
145
+ conv_state_shape = (conv_kernel - 1 + num_spec, divide(conv_dim, tp_world_size))
146
147
# These are not TP-ed as they depend on A, dt_bias, D
148
# - they are typically small
vllm/model_executor/layers/mamba/ops/causal_conv1d.py
+3
-1
@@ -1155,7 +1155,9 @@ def causal_conv1d_update(
1155
if conv_state_indices is None:
1156
assert conv_state.size(0) >= batch
1157
else:
1158
- assert (batch,) == conv_state_indices.shape
1158
+ assert batch == conv_state_indices.shape[0], (
1159
+ f"ERROR: conv_state_indices should have shape ({batch},*) but got {conv_state_indices.shape}"
1160
+ )
1161
1162
assert num_cache_lines >= batch
1163
assert weight.stride(1) == 1 # Need this
vllm/model_executor/layers/mamba/short_conv.py
+2
-8
@@ -119,7 +119,8 @@ class ShortConv(MambaBase, CustomOp):
119
assert isinstance(attn_metadata, ShortConvAttentionMetadata)
120
self_kv_cache = self.kv_cache[forward_context.virtual_engine]
121
conv_state = self_kv_cache[0].transpose(-1, -2)
122
- state_indices_tensor = attn_metadata.state_indices_tensor
122
+ state_indices_tensor_p = attn_metadata.state_indices_tensor_p
123
+ state_indices_tensor_d = attn_metadata.state_indices_tensor_d
124
has_initial_states_p = attn_metadata.has_initial_states_p
125
query_start_loc_p = attn_metadata.query_start_loc_p
126
@@ -163,13 +164,6 @@ class ShortConv(MambaBase, CustomOp):
164
[num_decodes, num_prefill_tokens],
165
dim=0,
166
)
166
- # Split along batch dimension
167
- state_indices_tensor_d, state_indices_tensor_p = torch.split(
168
- state_indices_tensor,
169
- [num_decodes, num_prefills],
170
- dim=0,
171
- )
172
-
167
conv_output_list = []
168
169
if has_prefill:
vllm/model_executor/models/mamba2.py
+1
@@ -228,6 +228,7 @@ class Mamba2ForCausalLM(
228
head_dim=hf_config.head_dim,
229
state_size=hf_config.state_size,
230
conv_kernel=hf_config.conv_kernel,
231
+ num_spec=vllm_config.num_speculative_tokens,
232
)
233
234
@classmethod
vllm/model_executor/models/nemotron_h.py
+8
@@ -636,6 +636,9 @@ class NemotronHModel(nn.Module):
636
hidden_states, _ = self.norm_f(hidden_states, residual)
637
return hidden_states
638
639
+ def is_spec_layer(self, config: NemotronHConfig, weight_name: str) -> bool:
640
+ return weight_name.startswith("mtp.")
641
+
642
def _get_max_n_routed_experts(self) -> int:
643
"""Get max n_routed_experts from config or block_configs for puzzle models.
644
@@ -702,6 +705,10 @@ class NemotronHModel(nn.Module):
705
if name is None:
706
continue
707
708
+ # Skip MTP/spec decode layers early (before stacked params mapping)
709
+ if name.startswith("mtp."):
710
+ continue
711
+
712
# load stacked params
713
for param_name, weight_name, shard_id in stacked_params_mapping:
714
if weight_name not in name:
@@ -845,6 +852,7 @@ class NemotronHForCausalLM(
852
head_dim=hf_config.mamba_head_dim,
853
state_size=hf_config.ssm_state_size,
854
conv_kernel=hf_config.conv_kernel,
855
+ num_spec=vllm_config.num_speculative_tokens,
856
)
857
858
@classmethod
vllm/model_executor/models/nemotron_h_mtp.py
new
+503
@@ -0,0 +1,503 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""NemotronH-MTP model with attention layers."""
4
+
5
+import typing
6
+from collections.abc import Callable, Iterable
7
+
8
+import torch
9
+import torch.nn as nn
10
+
11
+from vllm.compilation.decorators import support_torch_compile
12
+from vllm.config import CacheConfig, ModelConfig, VllmConfig
13
+from vllm.config.parallel import ParallelConfig
14
+from vllm.model_executor.layers.fused_moe import FusedMoE
15
+from vllm.model_executor.layers.layernorm import RMSNorm
16
+from vllm.model_executor.layers.linear import ColumnParallelLinear
17
+from vllm.model_executor.layers.logits_processor import LogitsProcessor
18
+from vllm.model_executor.layers.quantization import QuantizationConfig
19
+from vllm.model_executor.layers.vocab_parallel_embedding import (
20
+ ParallelLMHead,
21
+ VocabParallelEmbedding,
22
+)
23
+from vllm.model_executor.model_loader.weight_utils import default_weight_loader
24
+from vllm.model_executor.models.utils import (
25
+ make_empty_intermediate_tensors_factory,
26
+ maybe_prefix,
27
+)
28
+from vllm.sequence import IntermediateTensors
29
+from vllm.transformers_utils.configs import NemotronHConfig
30
+
31
+from .interfaces import SupportsPP
32
+from .nemotron_h import (
33
+ NemotronHAttentionDecoderLayer,
34
+ NemotronHMoEDecoderLayer,
35
+)
36
+
37
+
38
+class NemotronHMTPAttentionDecoderLayer(NemotronHAttentionDecoderLayer):
39
+ def __init__(
40
+ self,
41
+ config: NemotronHConfig,
42
+ layer_idx: int,
43
+ model_config: ModelConfig | None = None,
44
+ cache_config: CacheConfig | None = None,
45
+ quant_config: QuantizationConfig | None = None,
46
+ parallel_config: ParallelConfig | None = None,
47
+ prefix: str = "",
48
+ has_start_projections: bool = False,
49
+ has_end_norm: bool = False,
50
+ ) -> None:
51
+ super().__init__(
52
+ config=config,
53
+ layer_idx=layer_idx,
54
+ model_config=model_config,
55
+ cache_config=cache_config,
56
+ quant_config=quant_config,
57
+ parallel_config=parallel_config,
58
+ prefix=prefix,
59
+ )
60
+ self.has_start_projections = has_start_projections
61
+ self.has_end_norm = has_end_norm
62
+
63
+ if has_start_projections:
64
+ self.enorm = RMSNorm(config.hidden_size, eps=config.layer_norm_epsilon)
65
+ self.hnorm = RMSNorm(config.hidden_size, eps=config.layer_norm_epsilon)
66
+
67
+ # Fusion layer to combine embeddings with target hidden states
68
+ self.eh_proj = ColumnParallelLinear(
69
+ input_size=config.hidden_size * 2,
70
+ output_size=config.hidden_size,
71
+ bias=False,
72
+ gather_output=True,
73
+ params_dtype=config.dtype
74
+ if hasattr(config, "dtype")
75
+ else torch.bfloat16,
76
+ quant_config=quant_config,
77
+ prefix=f"{prefix}.eh_proj",
78
+ )
79
+
80
+ if has_end_norm:
81
+ self.final_layernorm = RMSNorm(
82
+ config.hidden_size,
83
+ eps=getattr(config, "layer_norm_epsilon", 1e-5),
84
+ )
85
+
86
+ def forward(
87
+ self,
88
+ inputs_embeds: torch.Tensor,
89
+ positions: torch.Tensor,
90
+ hidden_states: torch.Tensor,
91
+ residual: torch.Tensor | None = None,
92
+ ) -> tuple[torch.Tensor, torch.Tensor | None]:
93
+ # Start projections (Fusion)
94
+ if self.has_start_projections:
95
+ # Normalize both inputs before fusion
96
+ assert inputs_embeds is not None
97
+ inputs_embeds_normed = self.enorm(inputs_embeds)
98
+ previous_hidden_states_normed = self.hnorm(hidden_states)
99
+
100
+ # Fuse via concatenation and linear projection
101
+ fused = torch.cat(
102
+ [inputs_embeds_normed, previous_hidden_states_normed], dim=-1
103
+ )
104
+ hidden_states, _ = self.eh_proj(fused)
105
+
106
+ # Call parent forward (Attention)
107
+ # Parent forward expects: hidden_states, residual
108
+ hidden_states, residual = super().forward(
109
+ positions=positions,
110
+ hidden_states=hidden_states,
111
+ residual=residual,
112
+ )
113
+
114
+ # End norm
115
+ if self.has_end_norm:
116
+ if residual is not None:
117
+ hidden_states = hidden_states + residual
118
+ residual = None # Consumed residual
119
+
120
+ hidden_states = self.final_layernorm(hidden_states)
121
+
122
+ return hidden_states, residual
123
+
124
+
125
+class NemotronHMTPMoEDecoderLayer(NemotronHMoEDecoderLayer):
126
+ def __init__(
127
+ self,
128
+ config: NemotronHConfig,
129
+ layer_idx: int,
130
+ model_config: ModelConfig | None = None,
131
+ cache_config: CacheConfig | None = None,
132
+ quant_config: QuantizationConfig | None = None,
133
+ parallel_config: ParallelConfig | None = None,
134
+ prefix: str = "",
135
+ has_start_projections: bool = False,
136
+ has_end_norm: bool = False,
137
+ ) -> None:
138
+ super().__init__(
139
+ config=config,
140
+ layer_idx=layer_idx,
141
+ model_config=model_config,
142
+ cache_config=cache_config,
143
+ quant_config=quant_config,
144
+ parallel_config=parallel_config,
145
+ prefix=prefix,
146
+ )
147
+ self.has_start_projections = has_start_projections
148
+ self.has_end_norm = has_end_norm
149
+
150
+ if has_start_projections:
151
+ self.enorm = RMSNorm(config.hidden_size, eps=config.layer_norm_epsilon)
152
+ self.hnorm = RMSNorm(config.hidden_size, eps=config.layer_norm_epsilon)
153
+
154
+ # Fusion layer to combine embeddings with target hidden states
155
+ self.eh_proj = ColumnParallelLinear(
156
+ input_size=config.hidden_size * 2,
157
+ output_size=config.hidden_size,
158
+ bias=False,
159
+ gather_output=True,
160
+ params_dtype=config.dtype
161
+ if hasattr(config, "dtype")
162
+ else torch.bfloat16,
163
+ quant_config=quant_config,
164
+ prefix=f"{prefix}.eh_proj",
165
+ )
166
+
167
+ if has_end_norm:
168
+ self.final_layernorm = RMSNorm(
169
+ config.hidden_size,
170
+ eps=getattr(config, "layer_norm_epsilon", 1e-5),
171
+ )
172
+
173
+ def forward(
174
+ self,
175
+ inputs_embeds: torch.Tensor,
176
+ positions: torch.Tensor,
177
+ hidden_states: torch.Tensor,
178
+ residual: torch.Tensor | None = None,
179
+ ) -> tuple[torch.Tensor, torch.Tensor | None]:
180
+ # Start projections (Fusion)
181
+ if self.has_start_projections:
182
+ # Normalize both inputs before fusion
183
+ assert inputs_embeds is not None
184
+ inputs_embeds_normed = self.enorm(inputs_embeds)
185
+ previous_hidden_states_normed = self.hnorm(hidden_states)
186
+
187
+ # Fuse via concatenation and linear projection
188
+ fused = torch.cat(
189
+ [inputs_embeds_normed, previous_hidden_states_normed], dim=-1
190
+ )
191
+ hidden_states, _ = self.eh_proj(fused)
192
+
193
+ # Call parent forward (MoE)
194
+ hidden_states, residual = super().forward(
195
+ hidden_states=hidden_states,
196
+ residual=residual,
197
+ )
198
+
199
+ # End norm
200
+ if self.has_end_norm:
201
+ if residual is not None:
202
+ hidden_states = hidden_states + residual
203
+ residual = None # Consumed residual
204
+
205
+ hidden_states = self.final_layernorm(hidden_states)
206
+
207
+ return hidden_states, residual
208
+
209
+
210
+@support_torch_compile
211
+class NemotronHMultiTokenPredictor(nn.Module):
212
+ """MTP predictor with NemotronH layers."""
213
+
214
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
215
+ super().__init__()
216
+
217
+ config = vllm_config.model_config.hf_config
218
+
219
+ self.config = config
220
+ self.vocab_size = config.vocab_size
221
+ self.org_vocab_size = config.vocab_size
222
+
223
+ self.mtp_start_layer_idx = config.num_hidden_layers
224
+ self.num_mtp_layers = getattr(config, "num_nextn_predict_layers", 1)
225
+ assert self.num_mtp_layers == 1, (
226
+ "Only one MTP layer is supported for NemotronH-MTP"
227
+ )
228
+
229
+ self.pattern_str = config.mtp_hybrid_override_pattern
230
+ self.pattern_len = len(self.pattern_str)
231
+ assert self.pattern_len > 0
232
+
233
+ self.embed_tokens = VocabParallelEmbedding(
234
+ self.vocab_size,
235
+ config.hidden_size,
236
+ )
237
+
238
+ # Build flat list of layers
239
+ self.layers = torch.nn.ModuleDict()
240
+
241
+ # Total number of physical layers = num_steps * pattern_len
242
+ total_layers = self.num_mtp_layers * self.pattern_len
243
+ for i in range(total_layers):
244
+ step_rel_idx = i % self.pattern_len
245
+
246
+ char = self.pattern_str[step_rel_idx]
247
+
248
+ is_start_of_step = step_rel_idx == 0
249
+ is_end_of_step = step_rel_idx == self.pattern_len - 1
250
+
251
+ layer_prefix = f"{prefix}.layers.{i}"
252
+
253
+ # TODO smor- remove double layers formation
254
+ common_kwargs = dict(
255
+ config=config,
256
+ layer_idx=self.mtp_start_layer_idx + i,
257
+ model_config=vllm_config.model_config,
258
+ cache_config=vllm_config.cache_config,
259
+ quant_config=vllm_config.quant_config,
260
+ parallel_config=vllm_config.parallel_config,
261
+ prefix=layer_prefix,
262
+ has_start_projections=is_start_of_step,
263
+ has_end_norm=is_end_of_step,
264
+ )
265
+
266
+ if char == "*":
267
+ self.layers[str(i)] = NemotronHMTPAttentionDecoderLayer(**common_kwargs)
268
+ elif char == "E":
269
+ self.layers[str(i)] = NemotronHMTPMoEDecoderLayer(**common_kwargs)
270
+ else:
271
+ raise NotImplementedError(
272
+ f"Pattern char '{char}' in {self.pattern_str} not implemented"
273
+ )
274
+
275
+ self.make_empty_intermediate_tensors: Callable[..., IntermediateTensors] = (
276
+ make_empty_intermediate_tensors_factory(
277
+ ["hidden_states", "residual"], config.hidden_size
278
+ )
279
+ )
280
+
281
+ def get_input_embeddings(self, input_ids: torch.Tensor) -> torch.Tensor:
282
+ assert self.embed_tokens is not None, (
283
+ "embed_tokens not initialized - must be shared from target model"
284
+ )
285
+ return self.embed_tokens(input_ids)
286
+
287
+ def forward(
288
+ self,
289
+ input_ids: torch.Tensor,
290
+ positions: torch.Tensor,
291
+ hidden_states: torch.Tensor,
292
+ intermediate_tensors: IntermediateTensors | None = None,
293
+ inputs_embeds: torch.Tensor | None = None,
294
+ ) -> torch.Tensor | IntermediateTensors:
295
+ if inputs_embeds is None:
296
+ inputs_embeds = self.get_input_embeddings(input_ids)
297
+
298
+ residual = None
299
+
300
+ for i in range(self.pattern_len):
301
+ hidden_states, residual = self.layers[str(i)](
302
+ inputs_embeds=inputs_embeds,
303
+ positions=positions,
304
+ hidden_states=hidden_states,
305
+ residual=residual,
306
+ )
307
+ return hidden_states
308
+
309
+
310
+class NemotronHMTP(nn.Module, SupportsPP):
311
+ """NemotronH MTP model."""
312
+
313
+ packed_modules_mapping = {
314
+ "qkv_proj": [
315
+ "q_proj",
316
+ "k_proj",
317
+ "v_proj",
318
+ ],
319
+ }
320
+
321
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
322
+ super().__init__()
323
+ config = vllm_config.model_config.hf_config
324
+ self.vllm_config = vllm_config
325
+ self.config = config
326
+ self.quant_config = vllm_config.quant_config
327
+
328
+ # Needed for load_weights mapping
329
+ self.mtp_start_layer_idx = config.num_hidden_layers
330
+
331
+ # EPLB config for experts
332
+ self.num_redundant_experts = 0
333
+ if vllm_config.parallel_config and vllm_config.parallel_config.eplb_config:
334
+ self.num_redundant_experts = (
335
+ vllm_config.parallel_config.eplb_config.num_redundant_experts
336
+ )
337
+
338
+ # MTP predictor
339
+ self.model = NemotronHMultiTokenPredictor(
340
+ vllm_config=vllm_config, prefix=maybe_prefix(prefix, "mtp")
341
+ )
342
+
343
+ # LM head for generating logits
344
+ self.lm_head = ParallelLMHead(
345
+ self.config.vocab_size,
346
+ self.config.hidden_size,
347
+ prefix=maybe_prefix(prefix, "lm_head"),
348
+ )
349
+
350
+ self.logits_processor = LogitsProcessor(self.config.vocab_size)
351
+
352
+ self.make_empty_intermediate_tensors = (
353
+ self.model.make_empty_intermediate_tensors
354
+ )
355
+
356
+ def get_input_embeddings(self, input_ids: torch.Tensor) -> torch.Tensor:
357
+ return self.model.get_input_embeddings(input_ids)
358
+
359
+ def forward(
360
+ self,
361
+ input_ids: torch.Tensor,
362
+ positions: torch.Tensor,
363
+ hidden_states: torch.Tensor,
364
+ intermediate_tensors: IntermediateTensors | None = None,
365
+ inputs_embeds: torch.Tensor | None = None,
366
+ **kwargs: object,
367
+ ) -> torch.Tensor:
368
+ """Forward - applies attention-based MTP."""
369
+ hidden_states = self.model(
370
+ input_ids,
371
+ positions,
372
+ hidden_states,
373
+ intermediate_tensors,
374
+ inputs_embeds,
375
+ )
376
+ return hidden_states
377
+
378
+ def compute_logits(
379
+ self,
380
+ hidden_states: torch.Tensor,
381
+ ) -> torch.Tensor | None:
382
+ """Compute logits for DRAFT token generation."""
383
+ assert self.lm_head is not None, (
384
+ "lm_head not initialized - must be shared from target model"
385
+ )
386
+ return self.logits_processor(self.lm_head, hidden_states)
387
+
388
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
389
+ """Load MTP weights with proper name remapping."""
390
+ stacked_params_mapping = [
391
+ # (param_name, shard_name, shard_id)
392
+ ("qkv_proj", "q_proj", "q"),
393
+ ("qkv_proj", "k_proj", "k"),
394
+ ("qkv_proj", "v_proj", "v"),
395
+ ]
396
+
397
+ expert_params_mapping = []
398
+ if hasattr(self.config, "n_routed_experts") and self.config.n_routed_experts:
399
+ expert_params_mapping = FusedMoE.make_expert_params_mapping(
400
+ self,
401
+ ckpt_gate_proj_name="up_proj",
402
+ ckpt_down_proj_name="down_proj",
403
+ ckpt_up_proj_name="", # Empty - non-gated MoE
404
+ num_experts=self.config.n_routed_experts,
405
+ num_redundant_experts=self.num_redundant_experts,
406
+ )
407
+
408
+ params_dict = dict(self.named_parameters())
409
+ loaded_params: set[str] = set()
410
+
411
+ for name, loaded_weight in weights:
412
+ # Only process MTP weights - skip all non-MTP weights
413
+ if (
414
+ not name.startswith("mtp.")
415
+ and "embeddings" not in name
416
+ and "lm_head" not in name
417
+ ):
418
+ continue
419
+ # Skip rotary embeddings (computed, not loaded)
420
+ if "rotary_emb.inv_freq" in name:
421
+ continue
422
+
423
+ name = name.replace("mtp.layers.", "model.layers.")
424
+
425
+ if "embeddings" in name:
426
+ name = name.replace("embeddings", "embed_tokens")
427
+ if name.startswith("backbone."):
428
+ name = name.replace("backbone.", "model.")
429
+
430
+ # Handle stacked parameters (qkv_proj) for attention layers
431
+ is_stacked = False
432
+ for param_name, weight_name, shard_id in stacked_params_mapping:
433
+ if weight_name not in name:
434
+ continue
435
+ # Must be in a mixer (attention layer)
436
+ if ".mixer." not in name:
437
+ continue
438
+
439
+ is_stacked = True
440
+ stacked_name = name.replace(weight_name, param_name)
441
+
442
+ if stacked_name.endswith(".bias") and stacked_name not in params_dict:
443
+ continue
444
+
445
+ if stacked_name not in params_dict:
446
+ # Might be that mapping failed or param doesn't exist
447
+ continue
448
+
449
+ param = params_dict[stacked_name]
450
+ weight_loader = getattr(param, "weight_loader", None)
451
+ if weight_loader is not None:
452
+ weight_loader(param, loaded_weight, shard_id)
453
+ loaded_params.add(stacked_name)
454
+ break
455
+
456
+ if is_stacked:
457
+ continue
458
+
459
+ is_expert_weight = False
460
+ for mapping in expert_params_mapping:
461
+ param_name, weight_name, expert_id, shard_id = mapping
462
+ # weight_name is like "experts.0.up_proj."
463
+ if weight_name not in name:
464
+ continue
465
+
466
+ is_expert_weight = True
467
+
468
+ # Replace the expert-specific weight name with fused parameter name
469
+ # e.g., "experts.0.up_proj." -> "experts.w13_"
470
+ name_mapped = name.replace(weight_name, param_name)
471
+
472
+ if name_mapped not in params_dict:
473
+ continue
474
+
475
+ param = params_dict[name_mapped]
476
+ weight_loader = typing.cast(Callable[..., bool], param.weight_loader)
477
+ success = weight_loader(
478
+ param,
479
+ loaded_weight,
480
+ name_mapped,
481
+ shard_id=shard_id,
482
+ expert_id=expert_id,
483
+ return_success=True,
484
+ )
485
+ if success:
486
+ loaded_params.add(name_mapped)
487
+ break
488
+
489
+ if is_expert_weight:
490
+ continue
491
+
492
+ if name.endswith(".bias") and name not in params_dict:
493
+ continue
494
+
495
+ if name not in params_dict:
496
+ continue
497
+
498
+ param = params_dict[name]
499
+ weight_loader = getattr(param, "weight_loader", default_weight_loader)
500
+ weight_loader(param, loaded_weight)
501
+ loaded_params.add(name)
502
+
503
+ return loaded_params
vllm/model_executor/models/plamo2.py
+3
-9
@@ -266,7 +266,8 @@ class Plamo2MambaMixer(MambaBase, PluggableLayer):
266
# conv_state = (..., dim, width-1) yet contiguous along 'dim'
267
conv_state = self_kv_cache[0].transpose(-1, -2)
268
ssm_state = self_kv_cache[1]
269
- state_indices_tensor = attn_metadata.state_indices_tensor
269
+ state_indices_tensor_p = attn_metadata.state_indices_tensor_p
270
+ state_indices_tensor_d = attn_metadata.state_indices_tensor_d
271
has_initial_states_p = attn_metadata.has_initial_states_p
272
prep_initial_states = attn_metadata.prep_initial_states
273
chunk_size = attn_metadata.chunk_size
@@ -309,13 +310,6 @@ class Plamo2MambaMixer(MambaBase, PluggableLayer):
310
gate_d, gate_p = torch.split(
311
gate[:num_actual_tokens], [num_decodes, num_prefill_tokens], dim=0
312
)
312
- # Split along batch dimension
313
- state_indices_tensor_d, state_indices_tensor_p = torch.split(
314
- state_indices_tensor,
315
- [num_decodes, num_prefills],
316
- dim=0,
317
- )
318
-
313
# Preallocate output tensor to avoid memcpy cost for merging prefill
314
# and decode outputs
315
preallocated_ssm_out = torch.empty(
@@ -336,7 +330,7 @@ class Plamo2MambaMixer(MambaBase, PluggableLayer):
330
if has_prefill:
331
# 2. Convolution sequence transformation
332
# - "cache_indices" updates the conv_state cache in positions
339
- # pointed to by "state_indices_tensor"
333
+ # pointed to by "state_indices_tensor_p"
334
x = hidden_states_p.transpose(0, 1) # this is the form that causal-conv see
335
hidden_states_p = causal_conv1d_fn(
336
x,
vllm/model_executor/models/registry.py
+1
@@ -522,6 +522,7 @@ _SPECULATIVE_DECODING_MODELS = {
522
"DeepSeekMTPModel": ("deepseek_mtp", "DeepSeekMTP"),
523
"ErnieMTPModel": ("ernie_mtp", "ErnieMTP"),
524
"ExaoneMoeMTP": ("exaone_moe_mtp", "ExaoneMoeMTP"),
525
+ "NemotronHMTPModel": ("nemotron_h_mtp", "NemotronHMTP"),
526
"LongCatFlashMTPModel": ("longcat_flash_mtp", "LongCatFlashMTP"),
527
"Glm4MoeMTPModel": ("glm4_moe_mtp", "Glm4MoeMTP"),
528
"Glm4MoeLiteMTPModel": ("glm4_moe_lite_mtp", "Glm4MoeLiteMTP"),
vllm/transformers_utils/configs/nemotron_h.py
+6
-3
@@ -51,6 +51,8 @@ class NemotronHConfig(PretrainedConfig):
51
The pattern of the hybrid model. The pattern is a string of
52
characters where each character represents
53
M: Mamba2, *: Attention, -: MLP
54
+ mtp_hybrid_override_pattern (`str`, *optional*, defaults to `"*E"`):
55
+ The pattern of the MTP layers.
56
num_attention_heads (`int`, *optional*, defaults to 32):
57
Number of attention heads for each attention layer in the
58
Transformer encoder.
@@ -150,6 +152,7 @@ class NemotronHConfig(PretrainedConfig):
152
intermediate_size=21504,
153
num_hidden_layers=52,
154
hybrid_override_pattern="M-M-M-M*-M-M-M-M-M*-M-M-M-M-M*-M-M-M-M-M*-M-M-M-M-M-",
155
+ mtp_hybrid_override_pattern="*E",
156
num_attention_heads=32,
157
head_dim=128,
158
num_key_value_heads=8, # nemo: num_query_groups
@@ -203,6 +206,7 @@ class NemotronHConfig(PretrainedConfig):
206
self.intermediate_size = intermediate_size
207
self.num_hidden_layers = num_hidden_layers
208
self.hybrid_override_pattern = hybrid_override_pattern
209
+ self.mtp_hybrid_override_pattern = mtp_hybrid_override_pattern
210
self.num_attention_heads = num_attention_heads
211
self.head_dim = head_dim
212
self.sliding_window = sliding_window
@@ -215,10 +219,9 @@ class NemotronHConfig(PretrainedConfig):
219
assert len(self.hybrid_override_pattern) == self.num_hidden_layers, (
220
"hybrid_override_pattern must have same length as num_hidden_layers"
221
)
218
- assert re.match(r"^[*-M]+$", self.hybrid_override_pattern), (
219
- "hybrid_override_pattern must only contain characters 'M', '*', or '-'"
222
+ assert re.match(r"^[*-ME]+$", self.hybrid_override_pattern), (
223
+ "hybrid_override_pattern must only contain characters 'M', '*', '-', or 'E'"
224
)
221
-
225
# for backward compatibility
226
if num_key_value_heads is None:
227
num_key_value_heads = num_attention_heads
vllm/v1/attention/backends/mamba2_attn.py
+5
-1
@@ -2,6 +2,7 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
import itertools
4
from dataclasses import dataclass, replace
5
+from typing import Any
6
7
import torch
8
@@ -200,8 +201,11 @@ class Mamba2AttentionMetadataBuilder(
201
common_prefix_len: int,
202
common_attn_metadata: CommonAttentionMetadata,
203
fast_build: bool = False,
204
+ **kwargs: Any,
205
) -> Mamba2AttentionMetadata:
204
- common = self._compute_common_metadata(common_attn_metadata)
206
+ common = self._compute_common_metadata(
207
+ common_attn_metadata, num_accepted_tokens=kwargs.get("num_accepted_tokens")
208
+ )
209
210
seq_idx_p = None
211
cu_chunk_seqlen_p = None
vllm/v1/attention/backends/mamba_attn.py
+193
-85
@@ -2,9 +2,8 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
import abc
5
-import copy
6
-from dataclasses import dataclass
7
-from typing import ClassVar, TypeVar
5
+from dataclasses import dataclass, replace
6
+from typing import Any, ClassVar, TypeVar
7
8
import torch
9
@@ -35,12 +34,21 @@ class BaseMambaAttentionMetadata:
34
num_reqs: int
35
36
# The following tensors only contain prefill requests and will be None if
38
- # the batch has no prefill request.
37
+ # the batch has no prefill requests.
38
has_initial_states_p: torch.Tensor | None
39
query_start_loc_p: torch.Tensor | None
40
num_computed_tokens_p: torch.Tensor | None
41
+ state_indices_tensor_p: torch.Tensor | None
42
43
- state_indices_tensor: torch.Tensor
43
+ # The following tensors are used for decode requests and
44
+ # speculative decoding compatibility, and will be None if the batch
45
+ # has no decode requests.
46
+ state_indices_tensor_d: torch.Tensor | None
47
+ query_start_loc_d: torch.Tensor | None # shape: [num_decodes + 1,]
48
+
49
+ # Number of accepted tokens for each spec sequence (for loading correct checkpoint)
50
+ # Includes the bonus token (so minimum is 1)
51
+ num_accepted_tokens: torch.Tensor | None # shape: [batch,]
52
53
# The following tensors are only used for prefix caching in all mode and
54
# are None if disabled
@@ -60,9 +68,9 @@ class BaseMambaAttentionMetadata:
68
class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
69
metadata_cls: type[M]
70
reorder_batch_threshold: int = 1
63
- _cudagraph_support: ClassVar[AttentionCGSupport] = (
64
- AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE
65
- )
71
+ _cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.UNIFORM_BATCH
72
+
73
+ # Will be disabled if speculative decoding is used
74
supports_update_block_table: bool = True
75
76
def __init__(
@@ -74,6 +82,12 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
82
):
83
super().__init__(kv_cache_spec, layer_names, vllm_config, device)
84
85
+ # Enable speculative decoding support
86
+ self.speculative_config = vllm_config.speculative_config
87
+ self.compilation_config = vllm_config.compilation_config
88
+ self.num_spec_tokens: int = vllm_config.num_speculative_tokens
89
+ self.use_spec_decode = self.num_spec_tokens > 0
90
+
91
assert isinstance(kv_cache_spec, MambaSpec)
92
self.compilation_config = vllm_config.compilation_config
93
self.decode_cudagraph_max_bs = self.vllm_config.scheduler_config.max_num_seqs
@@ -84,13 +98,17 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
98
)
99
100
if self.vllm_config.cache_config.mamba_cache_mode == "all":
87
- self.state_indices_tensor = torch.empty(
101
+ max_num_blocks = cdiv(
102
+ self.vllm_config.model_config.max_model_len,
103
+ self.kv_cache_spec.block_size,
104
+ )
105
+ # Speculative decoding not supported with prefix caching,
106
+ # so keep shape consistent with prefill buffer
107
+ # TODO: reduce this size as needed for decode-only cudagraph capture
108
+ self.state_indices_tensor_d = torch.empty(
109
(
110
self.decode_cudagraph_max_bs,
90
- cdiv(
91
- self.vllm_config.model_config.max_model_len,
92
- self.kv_cache_spec.block_size,
93
- ),
111
+ max_num_blocks,
112
),
113
dtype=torch.int32,
114
device=device,
@@ -106,12 +124,25 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
124
device=device,
125
)
126
else:
109
- self.state_indices_tensor = torch.empty(
127
+ self.state_indices_tensor_d = torch.empty(
128
+ (self.decode_cudagraph_max_bs, 1 + self.num_spec_tokens),
129
+ dtype=torch.int32,
130
+ device=device,
131
+ )
132
+
133
+ # For speculative decoding, we need to store the following buffers
134
+ # for CUDA graph capture during decode
135
+ if self.num_spec_tokens > 0:
136
+ self.decode_num_accepted_tokens = torch.empty(
137
(self.decode_cudagraph_max_bs,),
138
dtype=torch.int32,
139
device=device,
140
)
141
142
+ self._init_reorder_batch_threshold(1, self.use_spec_decode)
143
+ if self.use_spec_decode:
144
+ self.supports_update_block_table = False
145
+
146
def build_for_cudagraph_capture(
147
self, common_attn_metadata: CommonAttentionMetadata
148
) -> M:
@@ -121,26 +152,38 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
152
"""
153
m = common_attn_metadata
154
124
- assert m.num_reqs == m.num_actual_tokens, (
155
+ assert (
156
+ m.max_query_len <= 1 + self.num_spec_tokens
157
+ and m.num_reqs <= self.decode_cudagraph_max_bs
158
+ ), (
159
"Mamba only supports decode-only full CUDAGraph capture. "
160
"Make sure all cudagraph capture sizes <= max_num_seq."
161
)
162
129
- m.max_query_len = 1 # decode-only
163
+ assert m.max_query_len == 1 + self.num_spec_tokens # decode-only
164
131
- return self.build(0, m)
165
+ num_accepted_tokens = None
166
+ if self.num_spec_tokens > 0:
167
+ num_accepted_tokens = torch.diff(m.query_start_loc)
168
+
169
+ return self.build(0, m, num_accepted_tokens=num_accepted_tokens)
170
171
def build(
172
self,
173
common_prefix_len: int,
174
common_attn_metadata: CommonAttentionMetadata,
175
fast_build: bool = False,
176
+ *,
177
+ num_accepted_tokens: torch.Tensor | None = None,
178
+ **kwargs: Any,
179
) -> M:
180
"""
181
Default build implementation for Mamba-like attention backends.
182
Subclasses (e.g., Mamba2) can override to add additional metadata.
183
"""
143
- return self._compute_common_metadata(common_attn_metadata)
184
+ return self._compute_common_metadata(
185
+ common_attn_metadata, num_accepted_tokens=num_accepted_tokens
186
+ )
187
188
def _compute_prefix_caching_block_indices(
189
self,
@@ -176,21 +219,32 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
219
def _compute_common_metadata(
220
self,
221
common_attn_metadata: CommonAttentionMetadata,
222
+ *,
223
+ num_accepted_tokens: torch.Tensor | None = None,
224
) -> M:
225
"""
226
Compute metadata common to both Mamba1 and Mamba2.
227
"""
228
num_reqs = common_attn_metadata.num_reqs
229
230
+ # Treat multi-token queries as decode requests when
231
+ # speculative decoding is enabled. Otherwise, use the
232
+ # default decode threshold to prevent misclassification
233
+ # of prefill queries as decode requests.
234
+ decode_threshold = (
235
+ self.reorder_batch_threshold if num_accepted_tokens is not None else 1
236
+ )
237
+
238
num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = (
239
split_decodes_and_prefills(
187
- common_attn_metadata, decode_threshold=self.reorder_batch_threshold
240
+ common_attn_metadata, decode_threshold=decode_threshold
241
)
242
)
243
244
# Need flags to indicate if there are initial states
245
has_initial_states_p = None
246
query_start_loc_p = None
247
+ query_start_loc_d = None
248
num_computed_tokens = None
249
num_computed_tokens_p = None
250
@@ -218,13 +272,31 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
272
common_attn_metadata, mamba_block_size
273
)
274
else:
221
- # Always return just a single block per each request:
275
state_indices_tensor = mamba_get_block_table_tensor(
276
common_attn_metadata.block_table_tensor,
277
common_attn_metadata.seq_lens,
278
self.kv_cache_spec,
279
self.vllm_config.cache_config.mamba_cache_mode,
227
- )[:, 0]
280
+ )
281
+
282
+ if state_indices_tensor.dim() == 1:
283
+ state_indices_tensor = state_indices_tensor.unsqueeze(-1)
284
+
285
+ state_indices_tensor_d, state_indices_tensor_p = torch.split(
286
+ state_indices_tensor,
287
+ [num_decodes, num_prefills],
288
+ dim=0,
289
+ )
290
+ if self.vllm_config.cache_config.mamba_cache_mode != "all":
291
+ state_indices_tensor_d = state_indices_tensor_d[
292
+ :, : 1 + self.num_spec_tokens
293
+ ]
294
+ state_indices_tensor_p = state_indices_tensor_p[:, 0]
295
+
296
+ if num_decodes > 0 and self.use_spec_decode:
297
+ assert num_accepted_tokens is not None
298
+ query_start_loc_d = common_attn_metadata.query_start_loc[: num_decodes + 1]
299
+ num_accepted_tokens = num_accepted_tokens[:num_decodes]
300
301
if num_prefills > 0:
302
if num_computed_tokens is None:
@@ -258,39 +330,18 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
330
block_idx_first_scheduled_token_p = block_idx_first_scheduled_token[
331
num_reqs - num_prefills : num_reqs
332
]
261
- elif (
262
- num_decodes <= self.decode_cudagraph_max_bs
263
- and self.compilation_config.cudagraph_mode.has_full_cudagraphs()
264
- ):
265
- self.state_indices_tensor[:num_decodes].copy_(
266
- state_indices_tensor, non_blocking=True
267
- )
268
- state_indices_tensor = self.state_indices_tensor[:num_decode_tokens]
269
- state_indices_tensor[num_decodes:] = PAD_SLOT_ID
270
-
271
- if self.vllm_config.cache_config.mamba_cache_mode == "all":
272
- self.block_idx_last_scheduled_token[:num_decodes].copy_(
273
- block_idx_last_scheduled_token, non_blocking=True
274
- )
275
- block_idx_last_scheduled_token = self.block_idx_last_scheduled_token[
276
- :num_decode_tokens
277
- ]
333
279
- self.block_idx_last_computed_token[:num_decodes].copy_(
280
- block_idx_last_computed_token, non_blocking=True
281
- )
282
- block_idx_last_computed_token = self.block_idx_last_computed_token[
283
- :num_decode_tokens
284
- ]
285
-
286
- return self.metadata_cls(
334
+ metadata = self.metadata_cls(
335
num_prefills=num_prefills,
336
num_prefill_tokens=num_prefill_tokens,
337
num_decodes=num_decodes,
338
num_decode_tokens=num_decode_tokens,
339
query_start_loc_p=query_start_loc_p,
340
has_initial_states_p=has_initial_states_p,
293
- state_indices_tensor=state_indices_tensor,
341
+ state_indices_tensor_p=state_indices_tensor_p,
342
+ state_indices_tensor_d=state_indices_tensor_d,
343
+ num_accepted_tokens=num_accepted_tokens,
344
+ query_start_loc_d=query_start_loc_d,
345
block_idx_last_scheduled_token=block_idx_last_scheduled_token,
346
block_idx_first_scheduled_token_p=block_idx_first_scheduled_token_p,
347
block_idx_last_computed_token=block_idx_last_computed_token,
@@ -302,55 +353,112 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
353
token_chunk_offset_ptr=token_chunk_offset_ptr,
354
)
355
305
- def update_block_table(
356
+ return self._update_metadata_for_cudagraph_capture(metadata)
357
+
358
+ def _update_metadata_for_cudagraph_capture(
359
self,
360
metadata: M,
308
- blk_table: torch.Tensor,
309
- slot_mapping: torch.Tensor,
361
) -> M:
311
- new_metadata = copy.copy(metadata)
312
- state_indices_t = mamba_get_block_table_tensor(
313
- blk_table,
314
- metadata.seq_lens,
315
- self.kv_cache_spec,
316
- self.vllm_config.cache_config.mamba_cache_mode,
317
- )
318
- if self.vllm_config.cache_config.mamba_cache_mode in ("none", "align"):
319
- # Only needs the block that saves the running state
320
- state_indices_t = state_indices_t[:, 0]
321
-
322
- num_reqs = blk_table.shape[0]
323
-
324
- # For CUDA graphs, copy to persistent buffer
362
+ """
363
+ Update the metadata for cudagraph capture.
364
+ Currently, only decode is supported for full cudagraphs with Mamba.
365
+ """
366
+ state_indices_tensor_d = metadata.state_indices_tensor_d
367
+ query_start_loc_d = metadata.query_start_loc_d
368
+ num_accepted_tokens = metadata.num_accepted_tokens
369
+ block_idx_last_scheduled_token = metadata.block_idx_last_scheduled_token
370
+ block_idx_last_computed_token = metadata.block_idx_last_computed_token
371
if (
372
metadata.num_prefills == 0
327
- and num_reqs <= self.decode_cudagraph_max_bs
373
+ and metadata.num_decodes <= self.decode_cudagraph_max_bs
374
and self.compilation_config.cudagraph_mode.has_full_cudagraphs()
375
):
330
- persistent_state_indices_t = self.state_indices_tensor[:num_reqs]
331
- persistent_state_indices_t.copy_(state_indices_t, non_blocking=True)
332
- state_indices_t = persistent_state_indices_t
376
+ padded_bs = metadata.num_reqs
377
+ self.state_indices_tensor_d[: metadata.num_decodes].copy_(
378
+ state_indices_tensor_d, non_blocking=True
379
+ )
380
+ state_indices_tensor_d = self.state_indices_tensor_d[:padded_bs]
381
+ state_indices_tensor_d[metadata.num_decodes :] = PAD_SLOT_ID
382
+
383
+ if self.use_spec_decode:
384
+ assert query_start_loc_d is not None
385
+ assert num_accepted_tokens is not None
386
+ query_start_loc_d = query_start_loc_d[: padded_bs + 1]
387
+ self.decode_num_accepted_tokens[: metadata.num_decodes].copy_(
388
+ num_accepted_tokens, non_blocking=True
389
+ )
390
+ num_accepted_tokens = self.decode_num_accepted_tokens[:padded_bs]
391
+ num_accepted_tokens[metadata.num_decodes :] = (
392
+ 1 # pad with 1st slot index
393
+ )
394
334
- # For 'all' mode, also update prefix caching block indices
335
- # to use this builder's persistent buffers (required for CUDA
336
- # graph replay to read from the correct memory addresses).
395
if self.vllm_config.cache_config.mamba_cache_mode == "all":
338
- assert metadata.block_idx_last_scheduled_token is not None
339
- assert metadata.block_idx_last_computed_token is not None
340
- self.block_idx_last_scheduled_token[:num_reqs].copy_(
341
- metadata.block_idx_last_scheduled_token[:num_reqs],
396
+ assert block_idx_last_scheduled_token is not None
397
+ assert block_idx_last_computed_token is not None
398
+ self.block_idx_last_scheduled_token[: metadata.num_decodes].copy_(
399
+ block_idx_last_scheduled_token[: metadata.num_decodes],
400
non_blocking=True,
401
)
344
- new_metadata.block_idx_last_scheduled_token = (
345
- self.block_idx_last_scheduled_token[: metadata.num_decode_tokens]
346
- )
347
- self.block_idx_last_computed_token[:num_reqs].copy_(
348
- metadata.block_idx_last_computed_token[:num_reqs],
402
+ block_idx_last_scheduled_token = self.block_idx_last_scheduled_token[
403
+ : metadata.num_decode_tokens
404
+ ]
405
+
406
+ self.block_idx_last_computed_token[: metadata.num_decodes].copy_(
407
+ block_idx_last_computed_token[: metadata.num_decodes],
408
non_blocking=True,
409
)
351
- new_metadata.block_idx_last_computed_token = (
352
- self.block_idx_last_computed_token[: metadata.num_decode_tokens]
353
- )
410
+ block_idx_last_computed_token = self.block_idx_last_computed_token[
411
+ : metadata.num_decode_tokens
412
+ ]
413
+
414
+ return replace(
415
+ metadata,
416
+ state_indices_tensor_d=state_indices_tensor_d,
417
+ query_start_loc_d=query_start_loc_d,
418
+ num_accepted_tokens=num_accepted_tokens,
419
+ block_idx_last_scheduled_token=block_idx_last_scheduled_token,
420
+ block_idx_last_computed_token=block_idx_last_computed_token,
421
+ )
422
+
423
+ def update_block_table(
424
+ self,
425
+ metadata: M,
426
+ blk_table: torch.Tensor,
427
+ slot_mapping: torch.Tensor,
428
+ ) -> M:
429
+ state_indices_tensor = mamba_get_block_table_tensor(
430
+ blk_table,
431
+ metadata.seq_lens,
432
+ self.kv_cache_spec,
433
+ self.vllm_config.cache_config.mamba_cache_mode,
434
+ )
435
+ if state_indices_tensor.dim() == 1:
436
+ state_indices_tensor = state_indices_tensor.unsqueeze(-1)
437
+
438
+ assert (
439
+ metadata.num_prefills + metadata.num_decodes
440
+ == state_indices_tensor.shape[0]
441
+ ), (
442
+ "Mismatch in number of requests when updating block table."
443
+ f" Expected {metadata.num_prefills + metadata.num_decodes}, "
444
+ f"got {state_indices_tensor.shape[0]}."
445
+ )
446
+
447
+ state_indices_tensor_d, state_indices_tensor_p = torch.split(
448
+ state_indices_tensor,
449
+ [metadata.num_decodes, metadata.num_prefills],
450
+ dim=0,
451
+ )
452
+ if self.vllm_config.cache_config.mamba_cache_mode != "all":
453
+ state_indices_tensor_d = state_indices_tensor_d[
454
+ :, : 1 + self.num_spec_tokens
455
+ ]
456
+ state_indices_tensor_p = state_indices_tensor_p[:, 0]
457
+
458
+ new_metadata = replace(
459
+ metadata,
460
+ state_indices_tensor_d=state_indices_tensor_d,
461
+ state_indices_tensor_p=state_indices_tensor_p,
462
+ )
463
355
- new_metadata.state_indices_tensor = state_indices_t
356
- return new_metadata
464
+ return self._update_metadata_for_cudagraph_capture(new_metadata)
vllm/v1/worker/gpu_model_runner.py
+6
-2
@@ -113,6 +113,7 @@ from vllm.v1.attention.backend import (
113
MultipleOf,
114
)
115
from vllm.v1.attention.backends.gdn_attn import GDNAttentionMetadataBuilder
116
+from vllm.v1.attention.backends.mamba2_attn import Mamba2AttentionMetadataBuilder
117
from vllm.v1.attention.backends.utils import (
118
create_fast_prefill_custom_backend,
119
get_dcp_local_seq_lens,
@@ -1852,7 +1853,9 @@ class GPUModelRunner(
1853
)
1854
1855
extra_attn_metadata_args = {}
1855
- if use_spec_decode and isinstance(builder, GDNAttentionMetadataBuilder):
1856
+ if use_spec_decode and isinstance(
1857
+ builder, (Mamba2AttentionMetadataBuilder, GDNAttentionMetadataBuilder)
1858
+ ):
1859
assert ubid is None, "UBatching not supported with GDN yet"
1860
extra_attn_metadata_args = dict(
1861
num_accepted_tokens=self.num_accepted_tokens.gpu[:num_reqs_padded],
@@ -4725,7 +4728,7 @@ class GPUModelRunner(
4728
# Set num_scheduled_tokens based on num_tokens and max_num_seqs
4729
# for dummy run with LoRA so that the num_reqs collectively
4730
# has num_tokens in total.
4728
- assert num_tokens <= self.scheduler_config.max_num_batched_tokens
4731
+ assert num_tokens <= self.max_num_tokens
4732
max_num_reqs = self.scheduler_config.max_num_seqs
4733
if create_mixed_batch:
4734
assert not uniform_decode
@@ -4849,6 +4852,7 @@ class GPUModelRunner(
4852
ubatch_slices=(ubatch_slices_padded if pad_attn else ubatch_slices),
4853
for_cudagraph_capture=is_graph_capturing,
4854
slot_mappings=slot_mappings_by_group,
4855
+ use_spec_decode=self.speculative_config is not None,
4856
)
4857
4858
with self.maybe_dummy_run_with_lora(