[Spec][V2] Support MTP speculative decoding under pipeline parallelism (#46994)
Signed-off-by: Chris Eastwood <chris.eastwood@pwn4g3.dev> Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Chris Eastwood committed
Sep 11, 2026 at 13:55 UTC
6fe67cbbf3e43da89bebf6ab0eeaca4ba6c75663
12 files changed
+167
-49
tests/v1/attention/test_sparse_mla_backends.py
+45
@@ -24,6 +24,7 @@ from vllm.config import set_current_vllm_config
24
from vllm.model_executor.layers.attention.mla_attention import _use_masked_mha
25
from vllm.model_executor.layers.attention.sparse_mla_attention import (
26
GLOBAL_TOPK_MASK_MAX_BYTES,
27
+ SparseMLACommonImpl,
28
_masked_mha_workspace_fits,
29
_topk_mask_shape,
30
)
@@ -44,6 +45,9 @@ import vllm.v1.attention.backends.mla.flashinfer_mla_sparse as flashinfer_sparse
45
from vllm.model_executor.layers.attention.mla_attention import (
46
_canonicalize_sparse_mla_kv_cache_dtype,
47
)
48
+from vllm.model_executor.layers.attention.sparse_mla_attention import (
49
+ SharedTopkIndicesBuffer,
50
+)
51
from vllm.utils.math_utils import cdiv
52
from vllm.v1.attention.backends.mla.flashinfer_mla_sparse import (
53
FlashInferMLASparseImpl,
@@ -1864,3 +1868,44 @@ def test_fp8_mixed_batch_dcp_neutralizes_empty_rows(monkeypatch):
1868
assert out.is_contiguous()
1869
assert not out.isnan().any()
1870
assert not lse.isnan().any()
1871
+
1872
+
1873
+def test_sparse_impl_observes_repointed_indexer_buffer():
1874
+ """The MTP proposer repoints the draft's indexer at the target model's buffer
1875
+ after the backend impl is built, so the impl must resolve the buffer per read.
1876
+ Snapshotting it in __init__ leaves the layer reading indices nothing writes."""
1877
+ impl = object.__new__(FlashInferMLASparseImpl)
1878
+ own = torch.zeros(4, 8, dtype=torch.int32)
1879
+ target = torch.ones(4, 8, dtype=torch.int32)
1880
+ indexer = SimpleNamespace(topk_indices_buffer=own)
1881
+ impl.init_topk_indices_buffer(indexer, None)
1882
+
1883
+ assert impl.topk_indices_buffer is own
1884
+
1885
+ indexer.topk_indices_buffer = target
1886
+
1887
+ assert impl.topk_indices_buffer is target
1888
+
1889
+
1890
+def test_explicit_topk_buffer_supersedes_indexer():
1891
+ """Backbone skip-topk layers have no indexer, and the proposer also assigns the
1892
+ shared buffer directly onto draft submodules."""
1893
+ impl = object.__new__(FlashInferMLASparseImpl)
1894
+ indexer = SimpleNamespace(
1895
+ topk_indices_buffer=torch.zeros(2, 2, dtype=torch.int32),
1896
+ )
1897
+ impl.init_topk_indices_buffer(indexer, None)
1898
+ explicit = torch.ones(2, 2, dtype=torch.int32)
1899
+
1900
+ impl.topk_indices_buffer = explicit
1901
+
1902
+ assert impl.topk_indices_buffer is explicit
1903
+
1904
+
1905
+def test_sparse_mla_common_impl_resolves_buffer_lazily():
1906
+ """Guards the whole SparseMLACommonImpl family at once: a backend that
1907
+ snapshots the buffer instead silently loses MTP buffer sharing."""
1908
+ assert issubclass(SparseMLACommonImpl, SharedTopkIndicesBuffer)
1909
+ assert isinstance(SparseMLACommonImpl.topk_indices_buffer, property), (
1910
+ "topk_indices_buffer must stay a lazily-resolved property"
1911
+ )
tests/v1/e2e/spec_decode/test_mtp_parallel_load.py
+1
-8
@@ -56,14 +56,7 @@ INLINE_CONFIGS = [
56
enable_expert_parallel=True,
57
enable_eplb=True,
58
),
59
- InlineConfig(
60
- id="pp2",
61
- pp_size=2,
62
- skip_reason=(
63
- "DeepSeek MTP pipeline-parallel support is in flight upstream; "
64
- "see https://github.com/vllm-project/vllm/pull/44698"
65
- ),
66
- ),
59
+ InlineConfig(id="pp2", pp_size=2),
60
]
61
62
vllm/model_executor/layers/attention/sparse_mla_attention.py
+35
-9
@@ -501,7 +501,40 @@ def _build_topk_mask(
501
return out[:batch_size, :max_q_len]
502
503
504
-class SparseMLACommonImpl(MLACommonBaseImpl[T], Generic[T]):
504
+class SharedTopkIndicesBuffer:
505
+ """Resolves the shared top-k index buffer for sparse MLA implementations.
506
+
507
+ The indexer owns the buffer, but `LLMBaseProposer.load_model` repoints the
508
+ draft's indexers at the target's buffer after the impls are constructed, so
509
+ it must be resolved per read rather than snapshotted. Backbone skip-topk
510
+ layers have no indexer and pass the buffer explicitly.
511
+ """
512
+
513
+ _indexer: object | None = None
514
+ _topk_indices_buffer: torch.Tensor | None = None
515
+
516
+ def init_topk_indices_buffer(
517
+ self,
518
+ indexer: object | None,
519
+ topk_indices_buffer: torch.Tensor | None,
520
+ ) -> None:
521
+ self._indexer = indexer
522
+ self._topk_indices_buffer = topk_indices_buffer
523
+
524
+ @property
525
+ def topk_indices_buffer(self) -> torch.Tensor | None:
526
+ if self._indexer is not None:
527
+ return self._indexer.topk_indices_buffer # type: ignore[attr-defined]
528
+ return self._topk_indices_buffer
529
+
530
+ @topk_indices_buffer.setter
531
+ def topk_indices_buffer(self, buffer: torch.Tensor | None) -> None:
532
+ # An explicit assignment supersedes the indexer.
533
+ self._indexer = None
534
+ self._topk_indices_buffer = buffer
535
+
536
+
537
+class SparseMLACommonImpl(MLACommonBaseImpl[T], SharedTopkIndicesBuffer, Generic[T]):
538
"""Sparse MLA base with dense and masked-MHA prefill paths."""
539
540
is_sparse = True
@@ -543,14 +576,7 @@ class SparseMLACommonImpl(MLACommonBaseImpl[T], Generic[T]):
576
kv_b_proj,
577
)
578
546
- # The indexer carries the shared buffer for normal layers and tests;
547
- # the explicitly-passed buffer covers backbone skip layers, whose
548
- # indexer is not constructed (see deepseek_v2.py).
549
- self.topk_indices_buffer: torch.Tensor | None = (
550
- indexer.topk_indices_buffer # type: ignore[attr-defined]
551
- if indexer is not None
552
- else topk_indices_buffer
553
- )
579
+ self.init_topk_indices_buffer(indexer, topk_indices_buffer)
580
self._use_flashinfer_concat_mla_k = (
581
has_flashinfer()
582
and which("ninja") is not None
vllm/model_executor/models/deepseek_mtp.py
+16
-2
@@ -39,9 +39,11 @@ from .deepseek_v2 import (
39
DeepseekV2MoE,
40
_try_load_fp8_indexer_wk,
41
)
42
+from .interfaces import SupportsPP
43
from .utils import (
44
get_pp_missing_layer_names,
45
get_spec_layer_idx_from_weight_name,
46
+ make_empty_intermediate_tensors_factory,
47
maybe_prefix,
48
)
49
@@ -230,7 +232,7 @@ class DeepSeekMultiTokenPredictor(nn.Module):
232
233
234
@support_torch_compile
233
-class DeepSeekMTP(nn.Module, DeepseekV2MixtureOfExperts):
235
+class DeepSeekMTP(nn.Module, DeepseekV2MixtureOfExperts, SupportsPP):
236
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
237
super().__init__()
238
self.config = vllm_config.model_config.hf_config
@@ -240,6 +242,9 @@ class DeepSeekMTP(nn.Module, DeepseekV2MixtureOfExperts):
242
)
243
# Set MoE hyperparameters
244
self.set_moe_parameters()
245
+ self.make_empty_intermediate_tensors = make_empty_intermediate_tensors_factory(
246
+ ["hidden_states", "residual"], self.config.hidden_size
247
+ )
248
249
def set_moe_parameters(self):
250
self.num_moe_layers = self.config.num_nextn_predict_layers
@@ -266,7 +271,7 @@ class DeepSeekMTP(nn.Module, DeepseekV2MixtureOfExperts):
271
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
272
return self.model.embed_input_ids(input_ids)
273
269
- def forward(
274
+ def forward( # type: ignore[override]
275
self,
276
input_ids: torch.Tensor | None,
277
positions: torch.Tensor,
@@ -328,6 +333,15 @@ class DeepSeekMTP(nn.Module, DeepseekV2MixtureOfExperts):
333
continue
334
spec_layer = get_spec_layer_idx_from_weight_name(self.config, name)
335
if spec_layer is None:
336
+ # A tied top-level embed_tokens has no spec layer to rewrite
337
+ # from; the draft needs its own copy under PP.
338
+ param = params_dict.get(name) if "embed_tokens" in name else None
339
+ if param is not None:
340
+ weight_loader = getattr(
341
+ param, "weight_loader", default_weight_loader
342
+ )
343
+ weight_loader(param, loaded_weight)
344
+ loaded_params.add(name)
345
continue
346
is_fusion_moe_shared_experts_layer = (
347
self.is_fused_shared_expert_enabled and ("mlp.shared_experts" in name)
vllm/model_executor/models/qwen3_5_mtp.py
+9
-3
@@ -38,6 +38,7 @@ from vllm.transformers_utils.configs.qwen3_5_moe import Qwen3_5MoeTextConfig
38
from .interfaces import (
39
MultiModalEmbeddings,
40
SupportsMultiModal,
41
+ SupportsPP,
42
_require_is_multimodal,
43
)
44
from .utils import (
@@ -152,7 +153,9 @@ class Qwen3_5MultiTokenPredictor(nn.Module):
153
inputs_embeds: torch.Tensor | None = None,
154
spec_step_idx: int = 0,
155
) -> torch.Tensor:
155
- if get_pp_group().is_first_rank:
156
+ # Branch on the inputs, not the rank: the drafter is built entirely on
157
+ # the last PP stage, where `is_first_rank` is False.
158
+ if intermediate_tensors is None:
159
if inputs_embeds is None:
160
inputs_embeds = self.embed_input_ids(input_ids)
161
assert hidden_states.shape[-1] == inputs_embeds.shape[-1]
@@ -162,7 +165,6 @@ class Qwen3_5MultiTokenPredictor(nn.Module):
165
hidden_states = self.fc(hidden_states)
166
residual = None
167
else:
165
- assert intermediate_tensors is not None
168
hidden_states = intermediate_tensors["hidden_states"]
169
residual = intermediate_tensors["residual"]
170
@@ -212,7 +214,7 @@ class Qwen3_5MultiTokenPredictor(nn.Module):
214
"hidden_states": 0,
215
}
216
)
215
-class Qwen3_5MTP(LocalArgmaxMixin, nn.Module, SupportsMultiModal):
217
+class Qwen3_5MTP(LocalArgmaxMixin, nn.Module, SupportsMultiModal, SupportsPP):
218
packed_modules_mapping = {
219
"qkv_proj": [
220
"q_proj",
@@ -254,6 +256,10 @@ class Qwen3_5MTP(LocalArgmaxMixin, nn.Module, SupportsMultiModal):
256
257
self.logits_processor = LogitsProcessor(config.vocab_size)
258
259
+ self.make_empty_intermediate_tensors = (
260
+ self.model.make_empty_intermediate_tensors
261
+ )
262
+
263
def embed_input_ids(
264
self,
265
input_ids: torch.Tensor,
vllm/models/deepseek_v32/amd/mtp.py
+16
-2
@@ -32,8 +32,10 @@ from vllm.model_executor.models.deepseek_v2 import (
32
_try_load_fp8_indexer_wk,
33
get_spec_layer_idx_from_weight_name,
34
)
35
+from vllm.model_executor.models.interfaces import SupportsPP
36
from vllm.model_executor.models.utils import (
37
get_pp_missing_layer_names,
38
+ make_empty_intermediate_tensors_factory,
39
maybe_prefix,
40
)
41
from vllm.models.deepseek_v32.common.kernels import fused_eh_norm
@@ -160,7 +162,7 @@ class DeepseekV32MultiTokenPredictor(nn.Module):
162
return self.logits_processor(mtp_layer.shared_head.head, hidden_states)
163
164
163
-class DeepseekV32MTP(nn.Module, DeepseekV2MixtureOfExperts):
165
+class DeepseekV32MTP(nn.Module, DeepseekV2MixtureOfExperts, SupportsPP):
166
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
167
super().__init__()
168
self.config = vllm_config.model_config.hf_config
@@ -169,6 +171,9 @@ class DeepseekV32MTP(nn.Module, DeepseekV2MixtureOfExperts):
171
vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
172
)
173
self.set_moe_parameters()
174
+ self.make_empty_intermediate_tensors = make_empty_intermediate_tensors_factory(
175
+ ["hidden_states", "residual"], self.config.hidden_size
176
+ )
177
self.is_fused_shared_expert_enabled = is_model_fused_shared_expert_compatible(
178
self.model.layers.values(),
179
DeepseekV2MoE,
@@ -192,7 +197,7 @@ class DeepseekV32MTP(nn.Module, DeepseekV2MixtureOfExperts):
197
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
198
return self.model.embed_input_ids(input_ids)
199
195
- def forward(
200
+ def forward( # type: ignore[override]
201
self,
202
input_ids: torch.Tensor | None,
203
positions: torch.Tensor,
@@ -268,6 +273,15 @@ class DeepseekV32MTP(nn.Module, DeepseekV2MixtureOfExperts):
273
continue
274
spec_layer = get_spec_layer_idx_from_weight_name(self.config, name)
275
if spec_layer is None:
276
+ # A tied top-level embed_tokens has no spec layer to rewrite
277
+ # from; the draft needs its own copy under PP.
278
+ param = params_dict.get(name) if "embed_tokens" in name else None
279
+ if param is not None:
280
+ weight_loader = getattr(
281
+ param, "weight_loader", default_weight_loader
282
+ )
283
+ weight_loader(param, loaded_weight)
284
+ loaded_params.add(name)
285
continue
286
is_fusion_moe_shared_experts_layer = (
287
self.is_fused_shared_expert_enabled and ("mlp.shared_experts" in name)
vllm/models/deepseek_v32/nvidia/model.py
+3
-2
@@ -182,7 +182,8 @@ class DeepseekV32Model(torch.nn.Module):
182
# DSA is always sparse (has index_topk); allocate the shared top-k
183
# buffer the indexer writes and the sparse MLA backend reads.
184
self.is_v32 = True
185
- topk_indices_buffer = torch.empty(
185
+ # On the model, not a local: the MTP proposer shares it with the draft.
186
+ self.topk_indices_buffer = torch.empty(
187
vllm_config.scheduler_config.max_num_batched_tokens,
188
config.index_topk,
189
dtype=torch.int32,
@@ -207,7 +208,7 @@ class DeepseekV32Model(torch.nn.Module):
208
lambda prefix: DeepseekV32DecoderLayer(
209
vllm_config=vllm_config,
210
prefix=prefix,
210
- topk_indices_buffer=topk_indices_buffer,
211
+ topk_indices_buffer=self.topk_indices_buffer,
212
),
213
prefix=f"{prefix}.layers",
214
)
vllm/models/deepseek_v32/nvidia/mtp.py
+16
-2
@@ -33,8 +33,10 @@ from vllm.model_executor.models.deepseek_v2 import (
33
_try_load_fp8_indexer_wk,
34
get_spec_layer_idx_from_weight_name,
35
)
36
+from vllm.model_executor.models.interfaces import SupportsPP
37
from vllm.model_executor.models.utils import (
38
get_pp_missing_layer_names,
39
+ make_empty_intermediate_tensors_factory,
40
maybe_prefix,
41
)
42
from vllm.models.common.ops.fused_allreduce_rms_norm import fused_allreduce_rms_norm
@@ -260,7 +262,7 @@ class DeepseekV32MultiTokenPredictor(nn.Module):
262
)
263
264
263
-class DeepseekV32MTP(nn.Module, DeepseekV2MixtureOfExperts):
265
+class DeepseekV32MTP(nn.Module, DeepseekV2MixtureOfExperts, SupportsPP):
266
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
267
super().__init__()
268
self.config = vllm_config.model_config.hf_config
@@ -271,6 +273,9 @@ class DeepseekV32MTP(nn.Module, DeepseekV2MixtureOfExperts):
273
if self.config.model_type == "glm_moe_dsa":
274
enable_glm52_low_latency_gemm(self, vllm_config.model_config.dtype)
275
self.set_moe_parameters()
276
+ self.make_empty_intermediate_tensors = make_empty_intermediate_tensors_factory(
277
+ ["hidden_states", "residual"], self.config.hidden_size
278
+ )
279
280
def set_moe_parameters(self):
281
self.num_moe_layers = self.config.num_nextn_predict_layers
@@ -289,7 +294,7 @@ class DeepseekV32MTP(nn.Module, DeepseekV2MixtureOfExperts):
294
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
295
return self.model.embed_input_ids(input_ids)
296
292
- def forward(
297
+ def forward( # type: ignore[override]
298
self,
299
input_ids: torch.Tensor | None,
300
positions: torch.Tensor,
@@ -368,6 +373,15 @@ class DeepseekV32MTP(nn.Module, DeepseekV2MixtureOfExperts):
373
continue
374
spec_layer = get_spec_layer_idx_from_weight_name(self.config, name)
375
if spec_layer is None:
376
+ # A tied top-level embed_tokens has no spec layer to rewrite
377
+ # from; the draft needs its own copy under PP.
378
+ param = params_dict.get(name) if "embed_tokens" in name else None
379
+ if param is not None:
380
+ weight_loader = getattr(
381
+ param, "weight_loader", default_weight_loader
382
+ )
383
+ weight_loader(param, loaded_weight)
384
+ loaded_params.add(name)
385
continue
386
name = self._rewrite_spec_layer_name(spec_layer, name)
387
vllm/v1/attention/backends/mla/flashinfer_mla_sparse_sm120.py
+7
-6
@@ -6,6 +6,9 @@ from typing import TYPE_CHECKING, cast
6
7
import torch
8
9
+from vllm.model_executor.layers.attention.sparse_mla_attention import (
10
+ SharedTopkIndicesBuffer,
11
+)
12
from vllm.v1.attention.backend import (
13
AttentionLayer,
14
AttentionType,
@@ -29,7 +32,9 @@ def _kv_scale_format_for_model(model_type: str | None) -> str:
32
return "pow2_fp32"
33
34
32
-class FlashInferMLASparseSM120Impl(MLAAttentionImpl[FlashInferMLASparseMetadata]):
35
+class FlashInferMLASparseSM120Impl(
36
+ MLAAttentionImpl[FlashInferMLASparseMetadata], SharedTopkIndicesBuffer
37
+):
38
"""SM120 FlashInfer sparse-MLA implementation."""
39
40
is_sparse = True
@@ -86,11 +91,7 @@ class FlashInferMLASparseSM120Impl(MLAAttentionImpl[FlashInferMLASparseMetadata]
91
92
# Skip-topk layers are built with indexer=None and get the shared
93
# buffer via mla_args instead (cf. FLASHMLA_SPARSE).
89
- self.topk_indices_buffer: torch.Tensor | None = (
90
- indexer.topk_indices_buffer
91
- if indexer is not None
92
- else mla_args.get("topk_indices_buffer")
93
- )
94
+ self.init_topk_indices_buffer(indexer, mla_args.get("topk_indices_buffer"))
95
from vllm.utils.flashinfer import has_flashinfer_sparse_mla_sm120
96
97
if not has_flashinfer_sparse_mla_sm120():
vllm/v1/attention/backends/mla/rocm_aiter_mla_sparse.py
+7
-7
@@ -15,6 +15,9 @@ from vllm.logger import init_logger
15
from vllm.model_executor.layers.attention.mla_attention import (
16
get_mla_dims,
17
)
18
+from vllm.model_executor.layers.attention.sparse_mla_attention import (
19
+ SharedTopkIndicesBuffer,
20
+)
21
from vllm.platforms import current_platform
22
from vllm.triton_utils import tl, triton
23
from vllm.utils.gpu_sync_debug import gpu_sync_allowed
@@ -750,7 +753,9 @@ def reference_mla_sparse_prefill(
753
return (result, lse)
754
755
753
-class ROCMAiterMLASparseImpl(MLAAttentionImpl[ROCMAiterMLASparseMetadata]):
756
+class ROCMAiterMLASparseImpl(
757
+ MLAAttentionImpl[ROCMAiterMLASparseMetadata], SharedTopkIndicesBuffer
758
+):
759
is_sparse = True
760
supports_dense_mha_prefill = False
761
supports_dcp = False
@@ -795,12 +800,7 @@ class ROCMAiterMLASparseImpl(MLAAttentionImpl[ROCMAiterMLASparseMetadata]):
800
self.kv_cache_dtype = kv_cache_dtype
801
self.kv_lora_rank: int = mla_args["kv_lora_rank"]
802
self.softmax_scale = scale
798
- # The indexer carries the shared buffer for normal layers and tests;
799
- # the explicitly-passed buffer covers backbone skip layers, whose
800
- # indexer is not constructed (see deepseek_v2.py).
801
- self.topk_indices_buffer: torch.Tensor | None = (
802
- indexer.topk_indices_buffer if indexer is not None else topk_indices_buffer
803
- )
803
+ self.init_topk_indices_buffer(indexer, topk_indices_buffer)
804
805
vllm_config = get_current_vllm_config()
806
max_tokens = vllm_config.scheduler_config.max_num_batched_tokens
vllm/v1/attention/backends/mla/xpu_mla_sparse.py
+5
-7
@@ -13,6 +13,9 @@ from vllm.logger import init_logger
13
from vllm.model_executor.layers.attention.mla_attention import (
14
get_mla_dims,
15
)
16
+from vllm.model_executor.layers.attention.sparse_mla_attention import (
17
+ SharedTopkIndicesBuffer,
18
+)
19
from vllm.utils.torch_utils import is_quantized_kv_cache, np_to_pinned_tensor
20
from vllm.v1.attention.backend import (
21
AttentionBackend,
@@ -183,7 +186,7 @@ class XPUMLASparseMetadataBuilder(AttentionMetadataBuilder[XPUMLASparseMetadata]
186
return metadata
187
188
186
-class XPUMLASparseImpl(MLAAttentionImpl[XPUMLASparseMetadata]):
189
+class XPUMLASparseImpl(MLAAttentionImpl[XPUMLASparseMetadata], SharedTopkIndicesBuffer):
190
is_sparse = True
191
192
def __init__(
@@ -210,12 +213,7 @@ class XPUMLASparseImpl(MLAAttentionImpl[XPUMLASparseMetadata]):
213
self.kv_cache_dtype = kv_cache_dtype
214
self.kv_lora_rank: int = mla_args["kv_lora_rank"]
215
self.softmax_scale = scale
213
- # The indexer carries the shared buffer for normal layers and tests;
214
- # the explicitly-passed buffer covers backbone skip layers, whose
215
- # indexer is not constructed (see deepseek_v2.py).
216
- self.topk_indices_buffer: torch.Tensor | None = (
217
- indexer.topk_indices_buffer if indexer is not None else topk_indices_buffer
218
- )
216
+ self.init_topk_indices_buffer(indexer, topk_indices_buffer)
217
218
def _forward_bf16_kv(
219
self,
vllm/v1/worker/gpu/model_runner.py
+7
-1
@@ -2030,7 +2030,13 @@ class GPUModelRunner(LoRAModelRunnerMixin):
2030
)
2031
2032
mm_inputs: tuple[list[torch.Tensor], torch.Tensor] | None = None
2033
- if self.speculator is not None and self.speculator.supports_mm_inputs:
2033
+ # The encoder runner exists only on the first PP rank, so later ranks
2034
+ # have no cached embeddings to gather.
2035
+ if (
2036
+ self.speculator is not None
2037
+ and self.speculator.supports_mm_inputs
2038
+ and self.model_state.supports_mm_inputs
2039
+ ):
2040
# Get cached multimodal embeddings for draft forward.
2041
# NOTE: This is done here because postprocess updates
2042
# num_computed_prefill_tokens.