[Model Runner V2][Spec Decode] Add multi-layer MTP speculator (#48892)

Signed-off-by: Giancarlo Delfin <gdelfin@inferact.ai>

Giancarlo Delfin committed Jul 30, 2026 at 15:54 UTC dec13a33b712e686b1fa26796d51aba4a2e2b3f5
9 files changed +1227 -32
vllm/config/speculative.py
+8
@@ -1410,6 +1410,14 @@ class SpeculativeConfig:
1410 def use_ngram_gpu(self) -> bool:
1411 return self.method == "ngram_gpu"
1412
1413 + def use_multi_module_mtp(self) -> bool:
1414 + if self.method != "mtp" or self.draft_model_config is None:
1415 + return False
1416 + num_mtp_layers = getattr(
1417 + self.draft_model_config.hf_config, "num_nextn_predict_layers", 1
1418 + )
1419 + return min(num_mtp_layers, self.num_speculative_tokens) > 1
1420 +
1421 def __repr__(self) -> str:
1422 method = self.method
1423 model = (
vllm/models/inkling/nvidia/mtp.py
+42 -13
@@ -2,9 +2,13 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Inkling MTP (Multi-Token Prediction) draft model (NVIDIA).
4
5 -Implements the first MTP depth from the reference ``mtp_model.py`` shipped with
6 -the checkpoint. It owns ``hidden_norm`` / ``embed_norm`` RMSNorms, a ``2H -> H``
7 -input projection, and a full Inkling transformer block with a dense bf16 MLP.
5 +Mirrors the reference ``mtp_model.py`` shipped with the checkpoint: each MTP
6 +depth ``i`` owns ``hidden_norm`` / ``embed_norm`` RMSNorms, an ``input_proj``
7 +(``2H -> H``) and a full Inkling transformer block (dense bf16 MLP, with the
8 +same short convolutions as the backbone; its attention is full or sliding-window
9 +per depth, selected by ``mtp_config.local_layer_ids``). When enabled, a shared
10 +``chain_norm`` is applied after every depth; its output is both the logits input
11 +and the previous hidden state fed to the next depth.
12
13 The draft shares the target's token embedding table and LM head
14 (``load_eagle_model`` wires those references) and applies the backbone
@@ -53,6 +57,16 @@ def _mtp_depth_from_name(name: str) -> int | None:
57 return int(m.group(1)) if m else None
58
59
60 +def _select_mtp_depth_count(n_predict: int, num_spec: int | None) -> int:
61 + num_layers = min(n_predict, num_spec) if num_spec else n_predict
62 + if num_layers <= 0:
63 + raise ValueError(
64 + "Inkling MTP requires num_nextn_predict_layers and "
65 + "num_speculative_tokens to select at least one depth layer."
66 + )
67 + return num_layers
68 +
69 +
70 class InklingMTPDepthLayer(nn.Module):
71 """One MTP depth: norm both inputs, fuse (2H->H), run a Inkling block."""
72
@@ -96,14 +110,30 @@ class InklingMultiTokenPredictor(nn.Module):
110 vllm_config.speculative_config.draft_model_config.hf_config
111 )
112 self.config = config
99 - if vllm_config.speculative_config.num_speculative_tokens != 1:
100 - raise ValueError(
101 - "Inkling MTP currently supports exactly one speculative token"
102 - )
113 + # The checkpoint ships num_nextn_predict_layers depth blocks, but only
114 + # the first ``num_speculative_tokens`` are exercised (step i uses depth
115 + # i). Build only those to save memory — each depth is a full Inkling block
116 + # with its own (large) full-history sconv caches and KV cache.
117 + n_predict = config.num_nextn_predict_layers
118 + num_spec = vllm_config.speculative_config.num_speculative_tokens
119 + self.num_mtp_layers = _select_mtp_depth_count(n_predict, num_spec)
120 self.chain_hidden_post_norm = config.chain_hidden_post_norm
121 +
122 + # Depth blocks whose attention is sliding-window (swa_* head config)
123 + # rather than full; keyed by MTP depth via the checkpoint's
124 + # mtp_config.local_layer_ids (promoted onto the draft config). Mirrors
125 + # InklingModel's local_ids split, but over MTP depths, not backbone
126 + # layers.
127 local_ids = set(config.local_layer_ids)
128 +
129 + # Keyed by depth index (str) to mirror the checkpoint layout.
130 self.layers = nn.ModuleDict(
106 - {"0": InklingMTPDepthLayer(config, f"{prefix}.layers.0", 0 in local_ids)}
131 + {
132 + str(idx): InklingMTPDepthLayer(
133 + config, f"{prefix}.layers.{idx}", idx in local_ids
134 + )
135 + for idx in range(self.num_mtp_layers)
136 + }
137 )
138 self.chain_norm = (
139 InklingRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
@@ -201,9 +231,8 @@ class InklingMultiTokenPredictor(nn.Module):
231 # auto-enumerated as a draft attention layer); its per-token metadata is
232 # built by the speculator's build_attn_metadata and read from the
233 # forward context, so nothing extra is threaded here.
204 - if spec_step_idx != 0:
205 - raise ValueError("Inkling MTP only supports spec_step_idx=0")
206 - layer = self.layers["0"]
234 + depth = spec_step_idx % self.num_mtp_layers
235 + layer = self.layers[str(depth)]
236 combined = self.fused_input_cat(
237 layer, previous_hidden_states, input_ids, inputs_embeds
238 )
@@ -355,8 +384,8 @@ def _load_inkling_mtp_weights(
384 # Only consume the MTP weights; everything else belongs to the target.
385 if ".mtp." not in name:
386 continue
358 - # Only the first checkpoint depth is used for MTP=1.
359 - if depth is not None and depth != 0:
387 + # Skip depth blocks beyond the ones we built (num_speculative_tokens).
388 + if depth is not None and depth >= module.model.num_mtp_layers:
389 continue
390 # model.mtp.chain_norm.weight -> model.chain_norm.weight
391 # model.mtp.layers.{i}.X -> model.layers.{i}.X
vllm/v1/worker/gpu/input_batch.py
+21 -4
@@ -191,6 +191,8 @@ class InputBatch:
191 def _prepare_prefill_inputs_kernel(
192 input_ids_ptr,
193 next_prefill_tokens_ptr,
194 + next_prefill_tokens_stride,
195 + num_lookahead,
196 idx_mapping_ptr,
197 query_start_loc_ptr,
198 all_token_ids_ptr,
@@ -198,6 +200,7 @@ def _prepare_prefill_inputs_kernel(
200 prefill_lens_ptr,
201 num_computed_tokens_ptr,
202 BLOCK_SIZE: tl.constexpr,
203 + LOOKAHEAD_BLOCK: tl.constexpr,
204 ):
205 batch_idx = tl.program_id(0)
206 req_state_idx = tl.load(idx_mapping_ptr + batch_idx)
@@ -218,10 +221,20 @@ def _prepare_prefill_inputs_kernel(
221 tokens = tl.load(request_ptr + num_computed + block, mask=mask)
222 tl.store(input_ids_ptr + query_start + block, tokens, mask=mask)
223
221 - next_pos = num_computed + query_len
222 - if next_pos < prefill_len:
223 - next_token = tl.load(request_ptr + next_pos)
224 - tl.store(next_prefill_tokens_ptr + req_state_idx, next_token)
224 + # Store the next num_lookahead prefill tokens.
225 + lookahead = tl.arange(0, LOOKAHEAD_BLOCK)
226 + pos = num_computed + query_len + lookahead
227 + in_lookahead = lookahead < num_lookahead
228 + tokens = tl.load(
229 + request_ptr + pos, mask=in_lookahead & (pos < prefill_len), other=0
230 + )
231 + tl.store(
232 + next_prefill_tokens_ptr
233 + + lookahead * next_prefill_tokens_stride
234 + + req_state_idx,
235 + tokens,
236 + mask=in_lookahead,
237 + )
238
239
240 def prepare_prefill_inputs(
@@ -234,9 +247,12 @@ def prepare_prefill_inputs(
247 num_computed_tokens: torch.Tensor,
248 ) -> None:
249 num_reqs = idx_mapping.shape[0]
250 + num_lookahead = next_prefill_tokens.shape[0]
251 _prepare_prefill_inputs_kernel[(num_reqs,)](
252 input_ids,
253 next_prefill_tokens,
254 + next_prefill_tokens.stride(0),
255 + num_lookahead,
256 idx_mapping,
257 query_start_loc,
258 all_token_ids,
@@ -244,6 +260,7 @@ def prepare_prefill_inputs(
260 prefill_len,
261 num_computed_tokens,
262 BLOCK_SIZE=1024,
263 + LOOKAHEAD_BLOCK=triton.next_power_of_2(num_lookahead),
264 )
265
266
vllm/v1/worker/gpu/model_runner.py
+13 -1
@@ -223,6 +223,15 @@ class GPUModelRunner(LoRAModelRunnerMixin):
223 self.is_pooling_model = self.model_config.runner_type == "pooling"
224 self.pooling_runner: PoolingRunner | None = None
225
226 + # Multi-module MTP feeds its modules the next num_speculative_steps prefill
227 + # tokens during chunked prefill. Other speculators only read the immediate
228 + # next one.
229 + num_prefill_lookahead = (
230 + self.num_speculative_steps
231 + if self.speculative_config is not None
232 + and self.speculative_config.use_multi_module_mtp()
233 + else 1
234 + )
235 # General request states.
236 self.req_states = RequestState(
237 max_num_reqs=self.max_num_reqs,
@@ -231,6 +240,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
240 num_speculative_steps=self.num_speculative_steps,
241 vocab_size=self.vocab_size,
242 device=self.device,
243 + num_prefill_lookahead=num_prefill_lookahead,
244 )
245 self.input_buffers = InputBuffers(
246 max_num_reqs=self.max_num_reqs,
@@ -628,7 +638,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
638 torch.zeros(
639 input_batch.num_tokens,
640 dtype=torch.bool,
631 - device=self.device,
641 + device="cpu",
642 ),
643 )
644
@@ -1521,6 +1531,8 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1531 # NOTE: This is done here because postprocess updates
1532 # num_computed_prefill_tokens.
1533 # The EAGLE/MTP drafter reads one position ahead of the target.
1534 + # TODO(TheEpicDolphin): Gather MM embeddings for all speculative
1535 + # steps during multi-module MTP.
1536 mm_inputs = self.model_state.gather_mm_embeddings(
1537 input_batch, draft_lookahead=1
1538 )
vllm/v1/worker/gpu/spec_decode/dflash/speculator.py
+3
@@ -3,6 +3,7 @@
3 from collections.abc import Mapping
4 from typing import Any
5
6 +import numpy as np
7 import torch
8 import torch.nn as nn
9
@@ -282,6 +283,7 @@ class DFlashSpeculator(DraftModelSpeculator):
283 step: int,
284 num_query_per_req: int | None = None,
285 causal: bool | Mapping[int, bool] = False,
286 + query_start_loc_np: np.ndarray | None = None,
287 ) -> dict[str, Any] | None:
288 if not self.draft_attn_layer_names:
289 return None
@@ -294,6 +296,7 @@ class DFlashSpeculator(DraftModelSpeculator):
296 step=step,
297 num_query_per_req=self.num_query_per_req,
298 causal=causal,
299 + query_start_loc_np=query_start_loc_np,
300 )
301
302 @torch.inference_mode()
vllm/v1/worker/gpu/spec_decode/multi_module_mtp/__init__.py new
+2
@@ -0,0 +1,2 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
vllm/v1/worker/gpu/spec_decode/multi_module_mtp/speculator.py new
+1106
@@ -0,0 +1,1106 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from typing import Any
4 +
5 +import torch
6 +import torch.nn as nn
7 +
8 +from vllm.config import VllmConfig
9 +from vllm.config.compilation import CUDAGraphMode
10 +from vllm.forward_context import BatchDescriptor, set_forward_context
11 +from vllm.logger import init_logger
12 +from vllm.multimodal import MULTIMODAL_REGISTRY
13 +from vllm.triton_utils import tl, triton
14 +from vllm.v1.attention.backends.utils import PAD_SLOT_ID
15 +from vllm.v1.worker.gpu.attn_utils import build_slot_mappings_by_layer
16 +from vllm.v1.worker.gpu.cudagraph_utils import (
17 + get_uniform_token_count,
18 +)
19 +from vllm.v1.worker.gpu.dp_utils import dispatch_cg_and_sync_dp
20 +from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
21 +from vllm.v1.worker.gpu.spec_decode.autoregressive.cudagraph_utils import (
22 + SpeculatorCudaGraphManager,
23 +)
24 +from vllm.v1.worker.gpu.spec_decode.eagle.utils import load_eagle_model
25 +from vllm.v1.worker.gpu.spec_decode.speculator import DraftModelSpeculator
26 +
27 +logger = init_logger(__name__)
28 +
29 +
30 +class MultiModuleMTPSpeculator(DraftModelSpeculator):
31 + def __init__(self, vllm_config: VllmConfig, device: torch.device):
32 + super().__init__(vllm_config, device)
33 +
34 + self.hidden_states = torch.zeros(
35 + self.max_num_tokens, self.hidden_size, dtype=self.dtype, device=device
36 + )
37 + self.current_draft_step = torch.tensor(0, dtype=torch.int64, device=device)
38 + self.last_token_indices = torch.zeros(
39 + self.max_num_reqs, dtype=torch.int64, device=device
40 + )
41 +
42 + self.supports_mm_inputs = MULTIMODAL_REGISTRY.supports_multimodal_inputs(
43 + self.draft_model_config
44 + )
45 + # HACK: the Inkling MTP draft has no MM processor of its own (its draft
46 + # config is flattened text-only), but it consumes the target's merged
47 + # embeddings at draft prefill — treat it as MM-capable whenever the
48 + # target is.
49 + if (
50 + not self.supports_mm_inputs
51 + and self.draft_model_config.hf_config.model_type == "inkling_mtp"
52 + ):
53 + self.supports_mm_inputs = MULTIMODAL_REGISTRY.supports_multimodal_inputs(
54 + vllm_config.model_config
55 + )
56 + self.inputs_embeds: torch.Tensor | None = None
57 + if self.supports_mm_inputs:
58 + self.inputs_embeds = torch.zeros(
59 + self.max_num_tokens, self.hidden_size, dtype=self.dtype, device=device
60 + )
61 +
62 + # Input id overrides for the last num_speculative_steps - 1 draft steps.
63 + # Used by chunked-prefilling requests to swap in the future prefill token
64 + # for the sampled draft token. Non-chunked-prefilling requests fill the -1
65 + # value, indicating that the sampled draft token should be used.
66 + self.draft_input_id_overrides = torch.full(
67 + (self.max_num_reqs, self.num_speculative_steps - 1),
68 + -1,
69 + dtype=torch.int64,
70 + device=device,
71 + )
72 +
73 + # Cached input ids, embeddings, and target hidden states from the last
74 + # decode step. Used to re-prefill tokens to update stale KV cache slots
75 + # in later MTP modules.
76 + self.cached_draft_input_ids = torch.zeros(
77 + self.max_num_reqs,
78 + self.num_speculative_steps - 1,
79 + dtype=torch.int64,
80 + device=self.device,
81 + )
82 + self.cached_draft_input_embeds: torch.Tensor | None = None
83 + if self.supports_mm_inputs:
84 + self.cached_draft_input_embeds = torch.zeros(
85 + self.max_num_reqs,
86 + self.num_speculative_steps - 1,
87 + self.hidden_size,
88 + dtype=self.dtype,
89 + device=self.device,
90 + )
91 + self.cached_target_hidden_states = torch.zeros(
92 + self.max_num_reqs,
93 + self.num_speculative_steps - 1,
94 + self.hidden_size,
95 + dtype=self.dtype,
96 + device=self.device,
97 + )
98 +
99 + self.cudagraph_manager: SpeculatorCudaGraphManager | None = None
100 +
101 + def load_draft_model(
102 + self,
103 + target_model: nn.Module,
104 + target_attn_layer_names: set[str],
105 + ) -> nn.Module:
106 + return load_eagle_model(target_model, self.vllm_config)
107 +
108 + def init_cudagraph_manager(self, cudagraph_mode: CUDAGraphMode) -> None:
109 + # TODO(TheEpicDolphin): Support piecewise cudagraph for multi-module MTP.
110 + if cudagraph_mode.has_piecewise_cudagraphs():
111 + cudagraph_mode = (
112 + CUDAGraphMode.FULL_DECODE_ONLY
113 + if cudagraph_mode.has_full_cudagraphs()
114 + else CUDAGraphMode.NONE
115 + )
116 + self.cudagraph_manager = SpeculatorCudaGraphManager(
117 + self.vllm_config,
118 + self.device,
119 + cudagraph_mode,
120 + self.num_speculative_steps + 1,
121 + )
122 +
123 + def capture(self) -> None:
124 + logger.info("Capturing model for multi-module MTP speculator...")
125 + # Reset indices to zeros to prevent stale values from prior
126 + # dummy runs to cause out-of-bounds indexing during capture.
127 + self.last_token_indices.zero_()
128 + assert self.cudagraph_manager is not None
129 + if self.cudagraph_manager.use_breakable_cg:
130 + self.cudagraph_manager.init_breakable_cg_runner(self.model)
131 + self.cudagraph_manager.capture(
132 + self._generate_drafts,
133 + self.model_state,
134 + self.input_buffers,
135 + self.block_tables,
136 + self.attn_groups,
137 + self.kv_cache_config,
138 + progress_bar_desc="Capturing multi-module MTP CUDA graphs",
139 + )
140 +
141 + @torch.inference_mode()
142 + def propose(
143 + self,
144 + input_batch: InputBatch,
145 + attn_metadata: dict[str, Any],
146 + slot_mappings: dict[str, torch.Tensor],
147 + # [num_tokens, hidden_size]
148 + last_hidden_states: torch.Tensor,
149 + # num_layers x [num_tokens, hidden_size]
150 + aux_hidden_states: list[torch.Tensor] | None,
151 + # [num_reqs]
152 + num_sampled: torch.Tensor,
153 + # [num_reqs]
154 + num_rejected: torch.Tensor,
155 + # [max_num_reqs]
156 + last_sampled: torch.Tensor,
157 + # [num_prefill_lookahead, max_num_reqs]
158 + next_prefill_tokens: torch.Tensor,
159 + # [max_num_reqs]
160 + temperature: torch.Tensor,
161 + # [max_num_reqs]
162 + seeds: torch.Tensor,
163 + num_tokens_across_dp: torch.Tensor | None = None,
164 + dummy_run: bool = False,
165 + skip_attn_for_dummy_run: bool = False,
166 + mm_inputs: tuple[list[torch.Tensor], torch.Tensor] | None = None,
167 + is_profile: bool = False,
168 + ) -> torch.Tensor:
169 + num_reqs = input_batch.num_reqs
170 + seq_lens_cpu_upper_bound = input_batch.seq_lens_cpu_upper_bound
171 + max_seq_len = seq_lens_cpu_upper_bound[:num_reqs].max().item()
172 + self.draft_max_seq_len = min(max_seq_len, self.max_model_len)
173 +
174 + self._copy_request_inputs(
175 + num_reqs,
176 + input_batch.idx_mapping,
177 + temperature,
178 + seeds,
179 + )
180 +
181 + num_tokens = input_batch.num_tokens
182 + max_query_len = input_batch.num_scheduled_tokens.max()
183 + self.input_buffers.query_start_loc[: num_reqs + 1].copy_(
184 + input_batch.query_start_loc[: num_reqs + 1]
185 + )
186 +
187 + self._prepare_inputs(
188 + last_hidden_states,
189 + input_batch,
190 + num_tokens,
191 + max_query_len,
192 + num_sampled,
193 + num_rejected,
194 + last_sampled,
195 + next_prefill_tokens,
196 + mm_inputs,
197 + )
198 +
199 + # When all requests are decoding (no true prefills), each has
200 + # num_speculative_steps + 1 tokens, enabling FULL graph replay.
201 + uniform_token_count = get_uniform_token_count(
202 + num_reqs,
203 + num_tokens,
204 + max_query_len,
205 + )
206 + batch_desc, num_tokens_across_dp = dispatch_cg_and_sync_dp(
207 + self.cudagraph_manager,
208 + num_reqs,
209 + input_batch.num_tokens_after_padding,
210 + uniform_token_count,
211 + dp_size=self.dp_size,
212 + dp_rank=self.dp_rank,
213 + need_eager=is_profile,
214 + )
215 +
216 + # Rebuild the slot mappings and attention metadata.
217 + skip_attn = dummy_run and skip_attn_for_dummy_run
218 + if not skip_attn:
219 + # Build the slot mappings and attention metadata.
220 + slot_mappings_tensor = self.block_tables.compute_slot_mappings(
221 + self.idx_mapping[:num_reqs],
222 + self.input_buffers.query_start_loc,
223 + self.input_buffers.positions,
224 + batch_desc.num_tokens,
225 + )
226 + # Apply padding values to slots not corresponding to real draft
227 + # tokens to prevent stale value writes.
228 + pad_trailing_draft_slots(
229 + slot_mappings_tensor,
230 + self.input_buffers.query_start_loc,
231 + self.last_token_indices[:num_reqs],
232 + num_reqs,
233 + )
234 + slot_mappings = build_slot_mappings_by_layer(
235 + slot_mappings_tensor, self.kv_cache_config
236 + )
237 + draft_attn_metadata = self._build_draft_attn_metadata(
238 + num_reqs=num_reqs,
239 + num_reqs_padded=batch_desc.num_reqs or num_reqs,
240 + num_tokens_padded=batch_desc.num_tokens,
241 + query_start_loc_np=input_batch.query_start_loc_np,
242 + seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound,
243 + step=0,
244 + )
245 + assert draft_attn_metadata is not None
246 + attn_metadata = draft_attn_metadata
247 +
248 + self._prepare_eplb_forward(num_tokens)
249 +
250 + if batch_desc.cg_mode == CUDAGraphMode.FULL:
251 + assert self.cudagraph_manager is not None
252 + self.cudagraph_manager.run_fullgraph(batch_desc)
253 + else:
254 + self._generate_drafts(
255 + num_reqs,
256 + batch_desc.num_tokens,
257 + attn_metadata,
258 + slot_mappings,
259 + num_tokens_across_dp=num_tokens_across_dp,
260 + cudagraph_runtime_mode=batch_desc.cg_mode,
261 + )
262 + return self.draft_tokens[:num_reqs]
263 +
264 + @torch.inference_mode()
265 + def _run_model(
266 + self,
267 + num_tokens: int,
268 + attn_metadata: dict[str, Any] | None,
269 + slot_mappings: dict[str, torch.Tensor] | None,
270 + num_tokens_across_dp: torch.Tensor | None,
271 + spec_module_idx: int,
272 + cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE,
273 + ) -> tuple[torch.Tensor, torch.Tensor]:
274 + batch_descriptor = BatchDescriptor(num_tokens=num_tokens)
275 + with set_forward_context(
276 + attn_metadata,
277 + self.vllm_config,
278 + num_tokens=num_tokens,
279 + cudagraph_runtime_mode=cudagraph_runtime_mode,
280 + num_tokens_across_dp=num_tokens_across_dp,
281 + slot_mapping=slot_mappings,
282 + batch_descriptor=batch_descriptor,
283 + ):
284 + model_inputs = dict(
285 + input_ids=self.input_buffers.input_ids[:num_tokens],
286 + positions=self.input_buffers.positions[:num_tokens],
287 + hidden_states=self.hidden_states[:num_tokens],
288 + inputs_embeds=(
289 + self.inputs_embeds[:num_tokens]
290 + if self.inputs_embeds is not None
291 + else None
292 + ),
293 + spec_step_idx=spec_module_idx,
294 + )
295 + if cudagraph_runtime_mode == CUDAGraphMode.PIECEWISE:
296 + # PIECEWISE cudagraph (compiled PW or breakable), chosen inside
297 + # run_pw_graph.
298 + assert self.cudagraph_manager is not None
299 + ret_hidden_states = self.cudagraph_manager.run_pw_graph(
300 + self.model, model_inputs
301 + )
302 + else:
303 + # Eager (NONE): call the raw model directly.
304 + ret_hidden_states = self.model(**model_inputs)
305 + # Some MTP models declare a single-tensor contract but return
306 + # (logits_hidden, feedback_hidden) for final-norm correctness.
307 + if isinstance(ret_hidden_states, tuple):
308 + last_hidden_states, hidden_states = ret_hidden_states
309 + else:
310 + last_hidden_states = ret_hidden_states
311 + hidden_states = ret_hidden_states
312 + return last_hidden_states, hidden_states
313 +
314 + def _prepare_inputs(
315 + self,
316 + target_hidden_states: torch.Tensor,
317 + input_batch: InputBatch,
318 + num_tokens: int,
319 + max_query_len: int,
320 + # [num_reqs]
321 + num_sampled: torch.Tensor,
322 + # [num_reqs]
323 + num_rejected: torch.Tensor,
324 + # [max_num_reqs]
325 + last_sampled: torch.Tensor,
326 + # [num_prefill_lookahead, max_num_reqs]
327 + next_prefill_tokens: torch.Tensor,
328 + mm_inputs: tuple[list[torch.Tensor], torch.Tensor] | None = None,
329 + ) -> None:
330 + num_reqs = input_batch.num_reqs
331 + prepare_input_buffers(
332 + num_reqs,
333 + input_batch,
334 + self.cached_draft_input_ids,
335 + num_sampled,
336 + num_rejected,
337 + last_sampled,
338 + next_prefill_tokens,
339 + self.draft_input_id_overrides,
340 + self.input_buffers,
341 + self.last_token_indices,
342 + self.max_num_reqs,
343 + self.num_speculative_steps,
344 + )
345 +
346 + # Compute the input embeddings with the MM embeddings merged in.
347 + # TODO(TheEpicDolphin): When the batch has no MM content (is_mm_embed
348 + # all False), skip this embed/copy and run the model with
349 + # inputs_embeds=None. Requirements:
350 + # 1. Eager steps can switch freely, but FULL cudagraphs bake the
351 + # embeds/no-embeds path at capture, so extending to decode steps
352 + # needs a uses_input_embeds property in the graph descriptors,
353 + # resulting in 2x captures.
354 + # 2. The skip condition must also verify the request's cached
355 + # re-prefill window (cached_draft_input_embeds) holds no MM
356 + # embeddings, or the rejection re-prefill gap would be re-embedded
357 + # from token ids incorrectly.
358 + if self.inputs_embeds is not None:
359 + mm_embeds, is_mm_embed = mm_inputs or (None, None)
360 + self.inputs_embeds[:num_tokens] = self.model.embed_input_ids(
361 + self.input_buffers.input_ids[:num_tokens],
362 + multimodal_embeddings=mm_embeds,
363 + is_multimodal=is_mm_embed,
364 + )
365 +
366 + prepare_input_hidden_states_and_embeddings(
367 + num_reqs,
368 + max_query_len,
369 + self.hidden_states,
370 + target_hidden_states,
371 + self.cached_target_hidden_states,
372 + self.inputs_embeds,
373 + self.cached_draft_input_embeds,
374 + input_batch,
375 + self.input_buffers,
376 + num_rejected,
377 + self.num_speculative_steps,
378 + )
379 +
380 + def _generate_drafts(
381 + self,
382 + num_reqs: int,
383 + num_tokens: int,
384 + attn_metadata: dict[str, Any] | None,
385 + slot_mappings: dict[str, torch.Tensor] | None,
386 + num_tokens_across_dp: torch.Tensor | None,
387 + cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE,
388 + ) -> None:
389 + last_token_indices = self.last_token_indices[:num_reqs]
390 + sample_positions = self.input_buffers.positions[last_token_indices]
391 + idx_mapping = self.idx_mapping[:num_reqs]
392 +
393 + # Cache the trailing token's ids, hidden states (and embeddings for
394 + # MM models), which are needed if the trailing tokens are re-prefilled
395 + # during the next decode step.
396 + cache_inputs(
397 + self.input_buffers,
398 + self.inputs_embeds,
399 + self.hidden_states,
400 + self.cached_draft_input_ids,
401 + self.cached_draft_input_embeds,
402 + self.cached_target_hidden_states,
403 + last_token_indices,
404 + idx_mapping,
405 + num_reqs,
406 + self.num_speculative_steps,
407 + use_input_embeds=self.inputs_embeds is not None,
408 + )
409 +
410 + for step in range(self.num_speculative_steps):
411 + # Update the current draft step.
412 + self.current_draft_step.fill_(step)
413 +
414 + # Run the model forward pass.
415 + last_hidden_states, hidden_states = self._run_model(
416 + num_tokens,
417 + attn_metadata,
418 + slot_mappings,
419 + num_tokens_across_dp=num_tokens_across_dp,
420 + spec_module_idx=step,
421 + cudagraph_runtime_mode=cudagraph_runtime_mode,
422 + )
423 +
424 + # Sample draft tokens for the current step.
425 + sample_hidden_states = last_hidden_states[last_token_indices]
426 + draft_tokens = self.sample_draft(
427 + sample_hidden_states,
428 + sample_positions,
429 + idx_mapping,
430 + self.temperature,
431 + self.seeds,
432 + self.current_draft_step,
433 + self.draft_logits,
434 + )
435 +
436 + self.draft_tokens[:num_reqs, step] = draft_tokens
437 + if step < self.num_speculative_steps - 1:
438 + self.hidden_states[:num_tokens] = hidden_states
439 + # Mid-prefill requests append the known future prefill token
440 + # instead of the sampled draft.
441 + overrides = self.draft_input_id_overrides[:num_reqs, step]
442 + next_input_tokens = torch.where(overrides >= 0, overrides, draft_tokens)
443 + # Shift the draft inputs left by one and append the next
444 + # input token id/embeddings.
445 + draft_embeds = (
446 + self.model.embed_input_ids(next_input_tokens)
447 + if self.inputs_embeds is not None
448 + else None
449 + )
450 + update_draft_inputs(
451 + next_input_tokens,
452 + draft_embeds,
453 + self.input_buffers,
454 + self.inputs_embeds,
455 + last_token_indices,
456 + idx_mapping,
457 + num_reqs,
458 + )
459 + sample_positions += 1
460 +
461 +
462 +@triton.jit
463 +def _prepare_input_buffers_kernel(
464 + last_token_indices_ptr,
465 + draft_input_ids_ptr,
466 + draft_positions_ptr,
467 + draft_seq_lens_ptr,
468 + target_input_ids_ptr,
469 + target_positions_ptr,
470 + cached_draft_input_ids_ptr,
471 + cached_draft_input_ids_stride0,
472 + draft_input_id_overrides_ptr,
473 + draft_input_id_overrides_stride0,
474 + idx_mapping_ptr,
475 + last_sampled_ptr,
476 + next_prefill_tokens_ptr,
477 + next_prefill_tokens_stride0,
478 + num_sampled_ptr,
479 + num_rejected_ptr,
480 + target_seq_lens_ptr,
481 + query_start_loc_ptr,
482 + max_num_reqs,
483 + num_speculative_steps,
484 + BLOCK_SIZE: tl.constexpr,
485 +):
486 + req_idx = tl.program_id(0)
487 + num_reqs = tl.num_programs(0)
488 + req_state_idx = tl.load(idx_mapping_ptr + req_idx)
489 +
490 + query_start = tl.load(query_start_loc_ptr + req_idx)
491 + query_end = tl.load(query_start_loc_ptr + req_idx + 1)
492 + query_len = query_end - query_start
493 + seq_len = tl.load(target_seq_lens_ptr + req_idx)
494 +
495 + # Get the number of rejected tokens, and the number of trailing tokens from
496 + # the last decode step that need to be re-prefilled to update the stale
497 + # KV cache slots in later MTP modules.
498 + num_rejected = tl.load(num_rejected_ptr + req_idx)
499 + num_reprefill_tokens = max(0, num_rejected - 1)
500 +
501 + # The rejected tokens are excluded as inputs to the draft model.
502 + num_input_tokens = query_len - num_rejected
503 + # Re-prefilled tokens are packed into the query window without widening
504 + # it, so the effective KV length shrinks by num_reprefill_tokens.
505 + # Example (r = rejected draft token, x = unused trailing slot, later padded)
506 + # re-prefilling t1 and t2 from the last decode step:
507 + # t0 t1 t2 [ t3 t4 r r r ] => t0 [ t1 t2 t3 t4 . ]
508 + seq_len -= num_reprefill_tokens
509 +
510 + # Write the updated sequence length.
511 + tl.store(draft_seq_lens_ptr + req_idx, seq_len)
512 +
513 + # Get the next draft input token.
514 + num_sampled = tl.load(num_sampled_ptr + req_idx)
515 + if num_sampled > 0:
516 + next_token = tl.load(last_sampled_ptr + req_state_idx).to(tl.int32)
517 + else:
518 + # Chunked prefill. Seed with the next prefill token.
519 + next_token = tl.load(next_prefill_tokens_ptr + req_state_idx)
520 +
521 + # For chunked-prefilling requests, copy the future prefill tokens that
522 + # will later override the input token ids for the last
523 + # num_speculative_steps - 1 draft steps.
524 + for i in range(1, num_speculative_steps):
525 + future_token = tl.load(
526 + next_prefill_tokens_ptr + i * next_prefill_tokens_stride0 + req_state_idx
527 + )
528 + override = tl.where(num_sampled > 0, -1, future_token).to(tl.int64)
529 + tl.store(
530 + draft_input_id_overrides_ptr
531 + + req_idx * draft_input_id_overrides_stride0
532 + + i
533 + - 1,
534 + override,
535 + )
536 +
537 + # Copy the target's input ids (read at the target offset) shifted left by 1,
538 + # and right by the number of re-prefills, into the draft buffer.
539 + for i in range(1, num_input_tokens, BLOCK_SIZE):
540 + block = i + tl.arange(0, BLOCK_SIZE)
541 + mask = block < num_input_tokens
542 + input_ids = tl.load(target_input_ids_ptr + query_start + block, mask=mask)
543 + tl.store(
544 + draft_input_ids_ptr + query_start + num_reprefill_tokens - 1 + block,
545 + input_ids,
546 + mask=mask,
547 + )
548 + last_token_index = query_start + num_reprefill_tokens + num_input_tokens - 1
549 + tl.store(last_token_indices_ptr + req_idx, last_token_index)
550 + tl.store(draft_input_ids_ptr + last_token_index, next_token)
551 +
552 + # Copy the target's positions, shifted over by the number of tokens to be
553 + # re-prefilled.
554 + for i in range(0, num_input_tokens, BLOCK_SIZE):
555 + block = i + tl.arange(0, BLOCK_SIZE)
556 + mask = block < num_input_tokens
557 + target_pos = tl.load(target_positions_ptr + query_start + block, mask=mask)
558 + tl.store(
559 + draft_positions_ptr + query_start + num_reprefill_tokens + block,
560 + target_pos,
561 + mask=mask,
562 + )
563 +
564 + # Fill the re-prefill gap with the cached token ids from the previous
565 + # decode step. These tokens sit immediately before the query's first
566 + # token, so their positions are contiguous and derived here.
567 + first_position = tl.load(target_positions_ptr + query_start)
568 + for i in range(num_reprefill_tokens):
569 + cache_read_slot = num_speculative_steps - 1 - num_reprefill_tokens + i
570 + cached_token_id = tl.load(
571 + cached_draft_input_ids_ptr
572 + + req_state_idx * cached_draft_input_ids_stride0
573 + + cache_read_slot
574 + )
575 + tl.store(draft_input_ids_ptr + query_start + i, cached_token_id)
576 + tl.store(
577 + draft_positions_ptr + query_start + i,
578 + first_position - num_reprefill_tokens + i,
579 + )
580 +
581 + if req_idx == (num_reqs - 1):
582 + # Pad query_start_loc for CUDA graphs.
583 + for i in range(num_reqs, max_num_reqs + 1, BLOCK_SIZE):
584 + block = i + tl.arange(0, BLOCK_SIZE)
585 + mask = block < max_num_reqs + 1
586 + tl.store(query_start_loc_ptr + block, query_end, mask=mask)
587 + # Pad seq_lens for CUDA graphs.
588 + for i in range(num_reqs, max_num_reqs, BLOCK_SIZE):
589 + block = i + tl.arange(0, BLOCK_SIZE)
590 + mask = block < max_num_reqs
591 + tl.store(draft_seq_lens_ptr + block, 0, mask=mask)
592 + # Pad last_token_indices for CUDA graphs.
593 + for i in range(num_reqs, max_num_reqs, BLOCK_SIZE):
594 + block = i + tl.arange(0, BLOCK_SIZE)
595 + mask = block < max_num_reqs
596 + tl.store(last_token_indices_ptr + block, 0, mask=mask)
597 +
598 +
599 +def prepare_input_buffers(
600 + num_reqs: int,
601 + input_batch: InputBatch,
602 + # [max_num_reqs, num_speculative_steps - 1]
603 + cached_draft_input_ids: torch.Tensor,
604 + # [num_reqs]
605 + num_sampled: torch.Tensor,
606 + # [num_reqs]
607 + num_rejected: torch.Tensor,
608 + # [max_num_reqs]
609 + last_sampled: torch.Tensor,
610 + # [num_prefill_lookahead, max_num_reqs]
611 + next_prefill_tokens: torch.Tensor,
612 + # [max_num_reqs, num_speculative_steps - 1]
613 + draft_input_id_overrides: torch.Tensor,
614 + input_buffers: InputBuffers,
615 + # [max_num_reqs]
616 + last_token_indices: torch.Tensor,
617 + max_num_reqs: int,
618 + num_speculative_steps: int,
619 +) -> None:
620 + _prepare_input_buffers_kernel[(num_reqs,)](
621 + last_token_indices,
622 + input_buffers.input_ids,
623 + input_buffers.positions,
624 + input_buffers.seq_lens,
625 + input_batch.input_ids,
626 + input_batch.positions,
627 + cached_draft_input_ids,
628 + cached_draft_input_ids.stride(0) if cached_draft_input_ids is not None else 0,
629 + draft_input_id_overrides,
630 + draft_input_id_overrides.stride(0),
631 + input_batch.idx_mapping,
632 + last_sampled,
633 + next_prefill_tokens,
634 + next_prefill_tokens.stride(0),
635 + num_sampled,
636 + num_rejected,
637 + input_batch.seq_lens,
638 + input_buffers.query_start_loc,
639 + max_num_reqs,
640 + num_speculative_steps,
641 + BLOCK_SIZE=1024,
642 + )
643 +
644 +
645 +@triton.jit
646 +def _prepare_input_hidden_states_and_embeddings_kernel(
647 + draft_input_hidden_states_ptr,
648 + draft_input_hidden_states_stride0,
649 + target_hidden_states_ptr,
650 + target_hidden_states_stride0,
651 + cached_target_hidden_states_ptr,
652 + cached_target_hidden_states_stride0,
653 + cached_target_hidden_states_stride1,
654 + input_embeds_ptr,
655 + input_embeds_stride0,
656 + cached_draft_input_embeds_ptr,
657 + cached_draft_input_embeds_stride0,
658 + cached_draft_input_embeds_stride1,
659 + idx_mapping_ptr,
660 + num_rejected_ptr,
661 + query_start_loc_ptr,
662 + num_speculative_steps,
663 + hidden_size,
664 + BLOCK_SIZE_Q: tl.constexpr,
665 + BLOCK_SIZE_H: tl.constexpr,
666 + USE_INPUT_EMBEDS: tl.constexpr,
667 +):
668 + req_idx = tl.program_id(0)
669 + query_block_idx = tl.program_id(1)
670 + dim_block_idx = tl.program_id(2)
671 + dim_block = dim_block_idx * BLOCK_SIZE_H + tl.arange(0, BLOCK_SIZE_H)
672 + dim_mask = dim_block < hidden_size
673 +
674 + req_state_idx = tl.load(idx_mapping_ptr + req_idx)
675 + query_start = tl.load(query_start_loc_ptr + req_idx)
676 + query_end = tl.load(query_start_loc_ptr + req_idx + 1)
677 + query_len = query_end - query_start
678 +
679 + # Get the number of rejected tokens, and the number of trailing tokens from
680 + # the last decode step that need to be re-prefilled to update the stale
681 + # KV cache slots in later MTP modules.
682 + num_rejected = tl.load(num_rejected_ptr + req_idx)
683 + num_reprefill_hidden_states = max(0, num_rejected - 1)
684 +
685 + # The rejected tokens are excluded as inputs to the draft model.
686 + num_input_hidden_states = query_len - num_rejected
687 +
688 + # Copy the output target hidden states as inputs to the first MTP module,
689 + # shifted right by the re-prefill gap. Each program copies one
690 + # (BLOCK_SIZE_Q, BLOCK_SIZE_H) tile of this request's query.
691 + query_block = query_block_idx * BLOCK_SIZE_Q + tl.arange(0, BLOCK_SIZE_Q)
692 + query_mask = (query_block < num_input_hidden_states)[:, None] & dim_mask[None, :]
693 + hidden_state = tl.load(
694 + target_hidden_states_ptr
695 + + (query_start + query_block)[:, None] * target_hidden_states_stride0
696 + + dim_block[None, :],
697 + mask=query_mask,
698 + )
699 + tl.store(
700 + draft_input_hidden_states_ptr
701 + + (query_start + num_reprefill_hidden_states + query_block)[:, None]
702 + * draft_input_hidden_states_stride0
703 + + dim_block[None, :],
704 + hidden_state,
705 + mask=query_mask,
706 + )
707 +
708 + if query_block_idx == 0:
709 + # Fill the re-prefill gap with the cached hidden states and embeddings
710 + # (for MM models) from the previous decode step, mirroring the token
711 + # ids and positions inserted by _prepare_input_buffers_kernel. The gap
712 + # is at most num_speculative_steps - 1 tokens, so a serial loop in the
713 + # first query block suffices.
714 + for i in range(num_reprefill_hidden_states):
715 + cache_read_slot = (
716 + num_speculative_steps - 1 - num_reprefill_hidden_states + i
717 + )
718 + cached_hidden_state = tl.load(
719 + cached_target_hidden_states_ptr
720 + + req_state_idx * cached_target_hidden_states_stride0
721 + + cache_read_slot * cached_target_hidden_states_stride1
722 + + dim_block,
723 + mask=dim_mask,
724 + )
725 + tl.store(
726 + draft_input_hidden_states_ptr
727 + + (query_start + i) * draft_input_hidden_states_stride0
728 + + dim_block,
729 + cached_hidden_state,
730 + mask=dim_mask,
731 + )
732 + if USE_INPUT_EMBEDS:
733 + cached_embed = tl.load(
734 + cached_draft_input_embeds_ptr
735 + + req_state_idx * cached_draft_input_embeds_stride0
736 + + cache_read_slot * cached_draft_input_embeds_stride1
737 + + dim_block,
738 + mask=dim_mask,
739 + )
740 + tl.store(
741 + input_embeds_ptr
742 + + (query_start + i) * input_embeds_stride0
743 + + dim_block,
744 + cached_embed,
745 + mask=dim_mask,
746 + )
747 +
748 +
749 +def prepare_input_hidden_states_and_embeddings(
750 + num_reqs: int,
751 + # Upper bound on the draft query length of any request in the batch.
752 + max_query_len: int,
753 + # [num_tokens, hidden_size]
754 + hidden_states: torch.Tensor,
755 + # [num_tokens, hidden_size]
756 + target_hidden_states: torch.Tensor,
757 + # [max_num_reqs, num_speculative_steps - 1, hidden_size]
758 + cached_target_hidden_states: torch.Tensor | None,
759 + # [num_tokens, hidden_size]
760 + input_embeds: torch.Tensor | None,
761 + # [max_num_reqs, num_speculative_steps - 1, hidden_size]
762 + cached_draft_input_embeds: torch.Tensor | None,
763 + input_batch: InputBatch,
764 + input_buffers: InputBuffers,
765 + # [num_reqs]
766 + num_rejected: torch.Tensor,
767 + num_speculative_steps: int,
768 +) -> None:
769 + use_input_embeds = input_embeds is not None
770 + hidden_size = target_hidden_states.shape[-1]
771 + query_block_size = 16
772 + hidden_block_size = 256
773 + grid = (
774 + num_reqs,
775 + triton.cdiv(max_query_len, query_block_size),
776 + triton.cdiv(hidden_size, hidden_block_size),
777 + )
778 + _prepare_input_hidden_states_and_embeddings_kernel[grid](
779 + hidden_states,
780 + hidden_states.stride(0),
781 + target_hidden_states,
782 + target_hidden_states.stride(0),
783 + cached_target_hidden_states,
784 + cached_target_hidden_states.stride(0)
785 + if cached_target_hidden_states is not None
786 + else 0,
787 + cached_target_hidden_states.stride(1)
788 + if cached_target_hidden_states is not None
789 + else 0,
790 + input_embeds,
791 + input_embeds.stride(0) if input_embeds is not None else 0,
792 + cached_draft_input_embeds,
793 + cached_draft_input_embeds.stride(0)
794 + if cached_draft_input_embeds is not None
795 + else 0,
796 + cached_draft_input_embeds.stride(1)
797 + if cached_draft_input_embeds is not None
798 + else 0,
799 + input_batch.idx_mapping,
800 + num_rejected,
801 + input_buffers.query_start_loc,
802 + num_speculative_steps,
803 + hidden_size,
804 + BLOCK_SIZE_Q=query_block_size,
805 + BLOCK_SIZE_H=hidden_block_size,
806 + USE_INPUT_EMBEDS=use_input_embeds,
807 + )
808 +
809 +
810 +@triton.jit
811 +def _pad_trailing_draft_slots_kernel(
812 + slot_mappings_ptr,
813 + slot_mappings_stride0,
814 + query_start_loc_ptr,
815 + last_token_indices_ptr,
816 + PAD_ID,
817 + BLOCK_SIZE: tl.constexpr,
818 +):
819 + group_idx = tl.program_id(0)
820 + req_idx = tl.program_id(1)
821 + # Slots computed from stale token positions in the range
822 + # [last_token_index + 1, query_end) can result in writes to blocks.
823 + # Pad these slot values so that attention kernels ignore them.
824 + start = tl.load(last_token_indices_ptr + req_idx) + 1
825 + end = tl.load(query_start_loc_ptr + req_idx + 1)
826 + base = slot_mappings_ptr + group_idx * slot_mappings_stride0
827 + for i in range(start, end, BLOCK_SIZE):
828 + offs = i + tl.arange(0, BLOCK_SIZE)
829 + mask = offs < end
830 + tl.store(base + offs, PAD_ID, mask=mask)
831 +
832 +
833 +def pad_trailing_draft_slots(
834 + # [num_groups, num_tokens_padded]
835 + slot_mappings: torch.Tensor,
836 + # [num_reqs + 1]
837 + query_start_loc: torch.Tensor,
838 + # [num_reqs]
839 + last_token_indices: torch.Tensor,
840 + num_reqs: int,
841 +) -> None:
842 + num_groups = slot_mappings.shape[0]
843 + _pad_trailing_draft_slots_kernel[(num_groups, num_reqs)](
844 + slot_mappings,
845 + slot_mappings.stride(0),
846 + query_start_loc,
847 + last_token_indices,
848 + PAD_SLOT_ID,
849 + BLOCK_SIZE=256,
850 + )
851 +
852 +
853 +@triton.jit
854 +def _cache_inputs_kernel(
855 + draft_input_ids_ptr,
856 + draft_input_embeds_ptr,
857 + draft_input_embeds_stride0,
858 + draft_input_hidden_states_ptr,
859 + draft_input_hidden_states_stride0,
860 + cached_draft_input_ids_ptr,
861 + cached_draft_input_ids_stride0,
862 + cached_draft_input_embeds_ptr,
863 + cached_draft_input_embeds_stride0,
864 + cached_draft_input_embeds_stride1,
865 + cached_target_hidden_states_ptr,
866 + cached_target_hidden_states_stride0,
867 + cached_target_hidden_states_stride1,
868 + idx_mapping_ptr,
869 + last_token_indices_ptr,
870 + query_start_loc_ptr,
871 + num_speculative_steps,
872 + hidden_size,
873 + BLOCK_SIZE: tl.constexpr,
874 + USE_INPUT_EMBEDS: tl.constexpr,
875 +):
876 + req_idx = tl.program_id(0)
877 + block_idx = tl.program_id(1)
878 + block = block_idx * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
879 + mask = block < hidden_size
880 +
881 + req_state_idx = tl.load(idx_mapping_ptr + req_idx)
882 + if req_state_idx < 0:
883 + # Skip cudagraph padded requests.
884 + return
885 +
886 + query_start = tl.load(query_start_loc_ptr + req_idx)
887 + last_token_index = tl.load(last_token_indices_ptr + req_idx)
888 +
889 + # Snapshot the last num_speculative_steps - 1 input draft token ids/hidden
890 + # states and embeddings (for MM models), indexed by request state. These
891 + # may be needed to re-prefill the tokens during the next decode step.
892 + cache_window_size = num_speculative_steps - 1
893 + window_start = last_token_index - cache_window_size + 1
894 + for i in range(max(window_start, query_start), last_token_index + 1):
895 + cache_write_slot = i - window_start
896 + if block_idx == 0:
897 + input_id = tl.load(draft_input_ids_ptr + i)
898 + tl.store(
899 + cached_draft_input_ids_ptr
900 + + req_state_idx * cached_draft_input_ids_stride0
901 + + cache_write_slot,
902 + input_id,
903 + )
904 + if USE_INPUT_EMBEDS:
905 + input_embeds = tl.load(
906 + draft_input_embeds_ptr + i * draft_input_embeds_stride0 + block,
907 + mask=mask,
908 + )
909 + tl.store(
910 + cached_draft_input_embeds_ptr
911 + + req_state_idx * cached_draft_input_embeds_stride0
912 + + cache_write_slot * cached_draft_input_embeds_stride1
913 + + block,
914 + input_embeds,
915 + mask=mask,
916 + )
917 + hidden_state = tl.load(
918 + draft_input_hidden_states_ptr
919 + + i * draft_input_hidden_states_stride0
920 + + block,
921 + mask=mask,
922 + )
923 + tl.store(
924 + cached_target_hidden_states_ptr
925 + + req_state_idx * cached_target_hidden_states_stride0
926 + + cache_write_slot * cached_target_hidden_states_stride1
927 + + block,
928 + hidden_state,
929 + mask=mask,
930 + )
931 +
932 +
933 +def cache_inputs(
934 + input_buffers: InputBuffers,
935 + # [num_tokens, hidden_size]
936 + draft_input_embeds: torch.Tensor | None,
937 + # [num_tokens, hidden_size]
938 + draft_input_hidden_states: torch.Tensor,
939 + # [max_num_reqs, num_speculative_steps - 1]
940 + cached_draft_input_ids: torch.Tensor,
941 + # [max_num_reqs, num_speculative_steps - 1, hidden_size]
942 + cached_draft_input_embeds: torch.Tensor | None,
943 + # [max_num_reqs, num_speculative_steps - 1, hidden_size]
944 + cached_target_hidden_states: torch.Tensor,
945 + # [num_reqs]
946 + last_token_indices: torch.Tensor,
947 + # [num_reqs]
948 + idx_mapping: torch.Tensor,
949 + num_reqs: int,
950 + num_speculative_steps: int,
951 + use_input_embeds: bool,
952 +) -> None:
953 + hidden_size = draft_input_hidden_states.shape[-1]
954 + hidden_block_size = 1024
955 + _cache_inputs_kernel[(num_reqs, triton.cdiv(hidden_size, hidden_block_size))](
956 + input_buffers.input_ids,
957 + draft_input_embeds,
958 + draft_input_embeds.stride(0) if draft_input_embeds is not None else 0,
959 + draft_input_hidden_states,
960 + draft_input_hidden_states.stride(0),
961 + cached_draft_input_ids,
962 + cached_draft_input_ids.stride(0),
963 + cached_draft_input_embeds,
964 + cached_draft_input_embeds.stride(0)
965 + if cached_draft_input_embeds is not None
966 + else 0,
967 + cached_draft_input_embeds.stride(1)
968 + if cached_draft_input_embeds is not None
969 + else 0,
970 + cached_target_hidden_states,
971 + cached_target_hidden_states.stride(0),
972 + cached_target_hidden_states.stride(1),
973 + idx_mapping,
974 + last_token_indices,
975 + input_buffers.query_start_loc,
976 + num_speculative_steps,
977 + hidden_size,
978 + BLOCK_SIZE=hidden_block_size,
979 + USE_INPUT_EMBEDS=use_input_embeds,
980 + )
981 +
982 +
983 +@triton.jit
984 +def _shift_input_ids_kernel(
985 + input_ids_ptr,
986 + idx_mapping_ptr,
987 + query_start_loc_ptr,
988 + last_token_indices_ptr,
989 + draft_tokens_ptr,
990 + BLOCK_SIZE: tl.constexpr,
991 +):
992 + req_idx = tl.program_id(0)
993 + req_state_idx = tl.load(idx_mapping_ptr + req_idx)
994 + if req_state_idx < 0:
995 + # Skip cudagraph padded requests.
996 + return
997 +
998 + query_start = tl.load(query_start_loc_ptr + req_idx)
999 + # Use the post-rejection last token index so the shift and insertion align
1000 + # with the position the draft token was sampled from.
1001 + last_token_index = tl.load(last_token_indices_ptr + req_idx)
1002 + query_len = last_token_index - query_start + 1
1003 +
1004 + # Shift input token ids to the left by one position and
1005 + # insert the last sampled draft token.
1006 + for i in range(1, query_len, BLOCK_SIZE):
1007 + block = i + tl.arange(0, BLOCK_SIZE)
1008 + mask = block < query_len
1009 + input_ids = tl.load(input_ids_ptr + query_start + block, mask=mask)
1010 + tl.store(input_ids_ptr + query_start + block - 1, input_ids, mask=mask)
1011 + draft_token = tl.load(draft_tokens_ptr + req_idx)
1012 + tl.store(input_ids_ptr + last_token_index, draft_token)
1013 +
1014 +
1015 +@triton.jit
1016 +def _shift_input_embeds_kernel(
1017 + input_embeds_ptr,
1018 + input_embeds_stride0,
1019 + draft_embeds_ptr,
1020 + draft_embeds_stride0,
1021 + idx_mapping_ptr,
1022 + query_start_loc_ptr,
1023 + last_token_indices_ptr,
1024 + hidden_size,
1025 + BLOCK_SIZE_Q: tl.constexpr,
1026 + BLOCK_SIZE_H: tl.constexpr,
1027 +):
1028 + req_idx = tl.program_id(0)
1029 + req_state_idx = tl.load(idx_mapping_ptr + req_idx)
1030 + if req_state_idx < 0:
1031 + # Skip cudagraph padded requests.
1032 + return
1033 +
1034 + block_idx = tl.program_id(1)
1035 + dim_block = block_idx * BLOCK_SIZE_H + tl.arange(0, BLOCK_SIZE_H)
1036 + dim_mask = dim_block < hidden_size
1037 +
1038 + query_start = tl.load(query_start_loc_ptr + req_idx)
1039 + last_token_index = tl.load(last_token_indices_ptr + req_idx)
1040 + query_len = last_token_index - query_start + 1
1041 +
1042 + # Shift input token embeddings to the left by one position and
1043 + # insert the last sampled draft token's embeddings.
1044 + for i in range(1, query_len, BLOCK_SIZE_Q):
1045 + query_block = i + tl.arange(0, BLOCK_SIZE_Q)
1046 + query_mask = query_block < query_len
1047 + mask = query_mask[:, None] & dim_mask[None, :]
1048 + input_embed = tl.load(
1049 + input_embeds_ptr
1050 + + (query_start + query_block)[:, None] * input_embeds_stride0
1051 + + dim_block[None, :],
1052 + mask=mask,
1053 + )
1054 + tl.store(
1055 + input_embeds_ptr
1056 + + (query_start + query_block - 1)[:, None] * input_embeds_stride0
1057 + + dim_block[None, :],
1058 + input_embed,
1059 + mask=mask,
1060 + )
1061 + draft_embed = tl.load(
1062 + draft_embeds_ptr + req_idx * draft_embeds_stride0 + dim_block,
1063 + mask=dim_mask,
1064 + )
1065 + tl.store(
1066 + input_embeds_ptr + last_token_index * input_embeds_stride0 + dim_block,
1067 + draft_embed,
1068 + mask=dim_mask,
1069 + )
1070 +
1071 +
1072 +def update_draft_inputs(
1073 + draft_tokens: torch.Tensor,
1074 + draft_embeds: torch.Tensor | None,
1075 + input_buffers: InputBuffers,
1076 + input_embeds: torch.Tensor | None,
1077 + last_token_indices: torch.Tensor,
1078 + idx_mapping: torch.Tensor,
1079 + num_reqs: int,
1080 +) -> None:
1081 + _shift_input_ids_kernel[(num_reqs,)](
1082 + input_buffers.input_ids,
1083 + idx_mapping,
1084 + input_buffers.query_start_loc,
1085 + last_token_indices,
1086 + draft_tokens,
1087 + BLOCK_SIZE=1024,
1088 + )
1089 + if input_embeds is not None:
1090 + assert draft_embeds is not None
1091 + hidden_size = input_embeds.shape[-1]
1092 + hidden_block_size = 256
1093 + _shift_input_embeds_kernel[
1094 + (num_reqs, triton.cdiv(hidden_size, hidden_block_size))
1095 + ](
1096 + input_embeds,
1097 + input_embeds.stride(0),
1098 + draft_embeds,
1099 + draft_embeds.stride(0),
1100 + idx_mapping,
1101 + input_buffers.query_start_loc,
1102 + last_token_indices,
1103 + hidden_size,
1104 + BLOCK_SIZE_Q=16,
1105 + BLOCK_SIZE_H=hidden_block_size,
1106 + )
vllm/v1/worker/gpu/spec_decode/speculator.py
+27 -13
@@ -4,6 +4,7 @@ from abc import ABC, abstractmethod
4 from collections.abc import Mapping
5 from typing import Any
6
7 +import numpy as np
8 import torch
9 import torch.nn as nn
10
@@ -51,7 +52,7 @@ class BaseSpeculator(ABC):
52 num_rejected: torch.Tensor,
53 # [max_num_reqs]
54 last_sampled: torch.Tensor,
54 - # [max_num_reqs]
55 + # [num_prefill_lookahead, max_num_reqs]
56 next_prefill_tokens: torch.Tensor,
57 # [max_num_reqs]
58 temperature: torch.Tensor,
@@ -214,14 +215,28 @@ class DraftModelSpeculator(BaseSpeculator):
215 step: int,
216 num_query_per_req: int = 1,
217 causal: bool | Mapping[int, bool] = True,
218 + query_start_loc_np: np.ndarray | None = None,
219 ) -> dict[str, Any] | None:
218 - # Uniform query: query_start_loc[i] = min(i, num_reqs) * num_query_per_req.
219 - # Clamp keeps the series non-decreasing past num_reqs, which some
220 - # attention backends require.
221 - query_start_loc_cpu = (
222 - torch.clamp(self.arange[: num_reqs_padded + 1], max=num_reqs)
223 - * num_query_per_req
224 - )
220 + if query_start_loc_np is not None:
221 + # Non-uniform query layout (e.g. multi-module MTP's mixed
222 + # prefill/decode queries); num_query_per_req is ignored.
223 + query_start_loc_cpu = torch.empty(num_reqs_padded + 1, dtype=torch.int32)
224 + query_start_loc_cpu[: num_reqs + 1] = torch.from_numpy(
225 + query_start_loc_np[: num_reqs + 1]
226 + )
227 + query_start_loc_cpu[num_reqs:] = query_start_loc_cpu[num_reqs]
228 + max_query_len = int(
229 + (query_start_loc_cpu[1:] - query_start_loc_cpu[:-1]).max()
230 + )
231 + else:
232 + # Uniform query: query_start_loc[i] = min(i, num_reqs) * num_query_per_req.
233 + # Clamp keeps the series non-decreasing past num_reqs, which some
234 + # attention backends require.
235 + query_start_loc_cpu = (
236 + torch.clamp(self.arange[: num_reqs_padded + 1], max=num_reqs)
237 + * num_query_per_req
238 + )
239 + max_query_len = num_query_per_req
240 block_tables = [
241 x[:num_reqs_padded] for x in self.block_tables.input_block_tables
242 ]
@@ -243,7 +258,7 @@ class DraftModelSpeculator(BaseSpeculator):
258 : num_reqs_padded + 1
259 ],
260 query_start_loc_cpu=query_start_loc_cpu,
246 - max_query_len=num_query_per_req,
261 + max_query_len=max_query_len,
262 seq_lens=self.input_buffers.seq_lens[:num_reqs_padded],
263 max_seq_len=self.draft_max_seq_len,
264 block_tables=block_tables,
@@ -325,7 +340,6 @@ class DraftModelSpeculator(BaseSpeculator):
340 self.temperature.copy_(temperature)
341 self.seeds.copy_(seeds)
342 self.idx_mapping[:num_reqs].copy_(idx_mapping)
328 - if self.draft_logits is not None:
329 - # idx_mapping for CG padded requests points to -1, which is ignored
330 - # during sampling to prevent writing stale values to draft logits.
331 - self.idx_mapping[num_reqs:].fill_(-1)
343 + # idx_mapping for CG padded requests points to -1, which is ignored
344 + # during sampling to prevent writing stale values to draft logits.
345 + self.idx_mapping[num_reqs:].fill_(-1)
vllm/v1/worker/gpu/states.py
+5 -1
@@ -15,6 +15,7 @@ class RequestState:
15 num_speculative_steps: int,
16 vocab_size: int,
17 device: torch.device,
18 + num_prefill_lookahead: int = 1,
19 ):
20 self.max_num_reqs = max_num_reqs
21 self.max_model_len = max_model_len
@@ -77,7 +78,10 @@ class RequestState:
78 )
79
80 self.next_prefill_tokens = torch.zeros(
80 - self.max_num_reqs, dtype=torch.int32, device=device
81 + num_prefill_lookahead,
82 + self.max_num_reqs,
83 + dtype=torch.int32,
84 + device=device,
85 )
86
87 @property