[Core][MRV2] Support eagle3 spec decode with pipeline parallel (#50514)
Signed-off-by: Yongqin Wang <yongqinwang@roblox.com> Co-authored-by: Cursor <noreply@cursor.com> Co-authored-by: Wentao Ye <44945378+yewentao256@users.noreply.github.com>
yongqinwang-cmd committed
Sep 4, 2026 at 20:45 UTC
d87a440f88e28e5b37f9b1e22ce214d0426d5352
27 files changed
+582
-152
tests/model_executor/test_qwen3_omni.py
+3
-3
@@ -347,6 +347,7 @@ def test_dspark_shares_target_embedding_with_smaller_draft_vocabulary():
347
vllm_config = SimpleNamespace(
348
speculative_config=SimpleNamespace(
349
draft_model_config=draft_model_config,
350
+ draft_parallel_config=SimpleNamespace(),
351
attention_backend=None,
352
kv_cache_dtype=None,
353
),
@@ -362,9 +363,8 @@ def test_dspark_shares_target_embedding_with_smaller_draft_vocabulary():
363
364
with (
365
patch.object(dspark_utils, "replace", side_effect=fake_replace),
365
- patch.object(
366
- dspark_utils,
367
- "get_pp_group",
366
+ patch(
367
+ "vllm.v1.worker.gpu.spec_decode.eagle.utils.get_pp_group",
368
return_value=SimpleNamespace(world_size=1),
369
),
370
patch(
tests/models/kimi_k3/test_aux_attn_res_stream.py
+14
-16
@@ -157,25 +157,23 @@ def test_last_layer_on_the_final_rank_uses_the_output_aggregation(
157
assert recorder[0].kwargs["num_blocks"] == 99
158
159
160
-def test_last_layer_of_a_non_final_stage_falls_back(recorder, monkeypatch):
161
- """The consumer lives on the next rank and the output aggregation only
162
- exists on the last one, so there is nothing here to mix against.
163
-
164
- This is the case that would otherwise reach for weights this rank never
165
- constructs. The forward guard is `layer_idx + 1 < end_layer`, where
166
- `end_layer` is the rank's own exclusive bound from `get_pp_indices`, so a
167
- `PPMissingLayer` is unreachable by construction -- the fallback below is
168
- what makes that true rather than merely likely.
169
- """
160
+def test_aux_layer_at_non_final_pp_boundary_is_rejected(monkeypatch):
161
+ model = k3_model.KimiLinearModel.__new__(k3_model.KimiLinearModel)
162
+ torch.nn.Module.__init__(model)
163
+ model.use_attn_res = True
164
+ model.end_layer = 72
165
+ monkeypatch.setattr(
166
+ "vllm.distributed.parallel_state.model_parallel_is_initialized", lambda: False
167
+ )
168
_set_last_rank(monkeypatch, False)
171
- prefix_sum = torch.tensor([3.0, 4.0])
172
-
173
- got = _call(
174
- _stub_model(enabled=True), END_LAYER - 1, prefix_sum, None, torch.zeros(2)
169
+ monkeypatch.setattr(
170
+ k3_model.KimiLinearModel,
171
+ "_aux_attn_res_stream",
172
+ property(lambda self: True),
173
)
174
177
- torch.testing.assert_close(got, prefix_sum)
178
- assert not recorder, "no weights exist on this rank to mix against"
175
+ with pytest.raises(ValueError, match="Auxiliary layer 72"):
176
+ model._set_aux_hidden_state_layers((3, 24, 48, 72, 90))
177
178
179
def test_pending_mlp_output_is_folded_in_rather_than_passed_as_delta(
tests/v1/e2e/spec_decode/eagle/test_eagle3_pp.py
new
+59
@@ -0,0 +1,59 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+import torch
4
+
5
+from tests.utils import multi_gpu_test
6
+from tests.v1.e2e.spec_decode.utils import compute_acceptance_len
7
+from vllm import LLM, SamplingParams
8
+from vllm.distributed import cleanup_dist_env_and_memory
9
+
10
+MODEL = "meta-llama/Llama-3.2-1B-Instruct"
11
+DRAFT = "nm-testing/Llama3_2_1B_speculator.eagle3"
12
+PROMPTS = [
13
+ "The capital of France is",
14
+ "2 + 2 equals",
15
+ "In one word, the color of the sky is",
16
+ "Q: If a train travels 60 miles in 1.5 hours, what is its average speed?\nA:",
17
+]
18
+
19
+ACCEPTANCE_TOLERANCE = 0.95
20
+
21
+
22
+def _run(pp_size: int) -> float:
23
+ llm = LLM(
24
+ model=MODEL,
25
+ tensor_parallel_size=1,
26
+ pipeline_parallel_size=pp_size,
27
+ max_model_len=512,
28
+ gpu_memory_utilization=0.45,
29
+ disable_log_stats=False,
30
+ compilation_config={"cudagraph_mode": "FULL_AND_PIECEWISE"},
31
+ speculative_config={
32
+ "method": "eagle3",
33
+ "model": DRAFT,
34
+ "num_speculative_tokens": 3,
35
+ },
36
+ )
37
+ try:
38
+ llm.generate(
39
+ PROMPTS,
40
+ SamplingParams(temperature=0.0, max_tokens=32, ignore_eos=True),
41
+ )
42
+ acceptance = compute_acceptance_len(llm.get_metrics())
43
+ assert acceptance > 1
44
+ return acceptance
45
+ finally:
46
+ del llm
47
+ torch.accelerator.empty_cache()
48
+ cleanup_dist_env_and_memory()
49
+
50
+
51
+@multi_gpu_test(num_gpus=4)
52
+def test_eagle3_pipeline_parallel_acceptance():
53
+ baseline = _run(1)
54
+ for pp_size in (2, 4):
55
+ parallel = _run(pp_size)
56
+ assert parallel >= baseline * ACCEPTANCE_TOLERANCE, (
57
+ f"PP={pp_size} acceptance regressed: {parallel:.3f} < "
58
+ f"{baseline:.3f} * {ACCEPTANCE_TOLERANCE}"
59
+ )
tests/v1/worker/test_eagle3_aux_hidden_states_pp.py
new
+26
@@ -0,0 +1,26 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from types import SimpleNamespace
4
+
5
+import pytest
6
+
7
+from vllm.model_executor.models.interfaces import EagleModelMixin
8
+from vllm.model_executor.models.mimo import MiMoModel
9
+from vllm.v1.worker.gpu.spec_decode.eagle.eagle3_utils import (
10
+ verify_supports_aux_hidden_states_over_pp,
11
+)
12
+
13
+
14
+def test_aux_layers_are_sorted_and_deduplicated():
15
+ model = EagleModelMixin()
16
+ model._set_aux_hidden_state_layers((48, 3, 90, 24, 48))
17
+ assert model.aux_hidden_state_layers == (3, 24, 48, 90)
18
+
19
+
20
+def test_mimo_does_not_inherit_aux_hidden_state_pp_support():
21
+ inner = MiMoModel.__new__(MiMoModel)
22
+ target = SimpleNamespace(model=inner)
23
+
24
+ assert not inner.supports_aux_hidden_states_over_pp
25
+ with pytest.raises(ValueError, match="does not support eagle3"):
26
+ verify_supports_aux_hidden_states_over_pp(target, "eagle3")
tests/v1/worker/test_spec_decode_embed_sharing_pp.py
new
+110
@@ -0,0 +1,110 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from types import SimpleNamespace
4
+
5
+import pytest
6
+import torch
7
+import torch.nn as nn
8
+
9
+from vllm.model_executor.models.utils import (
10
+ PPMissingLayer,
11
+ spec_decode_needs_target_embed,
12
+)
13
+from vllm.v1.worker.gpu.spec_decode.eagle import utils as eagle_utils
14
+
15
+VOCAB, HIDDEN = 32, 8
16
+
17
+
18
+def _fake_pp(world_size: int, is_last_rank: bool = True):
19
+ return lambda: SimpleNamespace(
20
+ world_size=world_size,
21
+ is_last_rank=is_last_rank,
22
+ is_first_rank=world_size == 1,
23
+ )
24
+
25
+
26
+def _inner(embed: nn.Module | None) -> nn.Module:
27
+ inner = nn.Module()
28
+ if embed is not None:
29
+ inner.embed_tokens = embed
30
+ return inner
31
+
32
+
33
+def _embed(fill: float | None = None) -> nn.Embedding:
34
+ embed = nn.Embedding(VOCAB, HIDDEN)
35
+ if fill is not None:
36
+ with torch.no_grad():
37
+ embed.weight.fill_(fill)
38
+ return embed
39
+
40
+
41
+@pytest.mark.parametrize("draft_embed", ["loaded", "unset"])
42
+def test_drafter_without_own_embedding_gets_the_targets(monkeypatch, draft_embed):
43
+ monkeypatch.setattr(eagle_utils, "get_pp_group", _fake_pp(2))
44
+ target_embed = _embed()
45
+ draft_inner = _inner(_embed() if draft_embed == "loaded" else None)
46
+ if draft_embed == "unset":
47
+ draft_inner.embed_tokens = None
48
+ draft = SimpleNamespace(has_own_embed_tokens=False)
49
+
50
+ eagle_utils.maybe_share_target_embed(draft, draft_inner, _inner(target_embed))
51
+
52
+ assert draft_inner.embed_tokens is target_embed
53
+
54
+
55
+def test_missing_target_embedding_raises_instead_of_running_on_garbage(monkeypatch):
56
+ monkeypatch.setattr(eagle_utils, "get_pp_group", _fake_pp(2))
57
+ draft_inner = _inner(_embed())
58
+ draft = SimpleNamespace(has_own_embed_tokens=False)
59
+
60
+ with pytest.raises(RuntimeError, match="needs the target input embedding"):
61
+ eagle_utils.maybe_share_target_embed(
62
+ draft, draft_inner, _inner(PPMissingLayer())
63
+ )
64
+
65
+
66
+def test_drafter_with_distinct_weights_keeps_them(monkeypatch):
67
+ monkeypatch.setattr(eagle_utils, "get_pp_group", _fake_pp(2))
68
+ draft_embed = _embed(fill=1.0)
69
+ draft_inner = _inner(draft_embed)
70
+ draft = SimpleNamespace(has_own_embed_tokens=True)
71
+
72
+ eagle_utils.maybe_share_target_embed(draft, draft_inner, _inner(_embed(fill=2.0)))
73
+
74
+ assert draft_inner.embed_tokens is draft_embed
75
+
76
+
77
+def test_mtp_style_drafter_is_left_alone_under_pp(monkeypatch):
78
+ monkeypatch.setattr(eagle_utils, "get_pp_group", _fake_pp(2))
79
+ draft_embed = _embed()
80
+ draft_inner = _inner(draft_embed)
81
+
82
+ eagle_utils.maybe_share_target_embed(nn.Module(), draft_inner, _inner(_embed()))
83
+
84
+ assert draft_inner.embed_tokens is draft_embed
85
+
86
+
87
+@pytest.mark.parametrize(
88
+ "method,pp_size,is_last_rank,expected",
89
+ [
90
+ ("eagle", 2, True, True),
91
+ ("eagle3", 2, True, True),
92
+ ("dflash", 2, True, True),
93
+ ("dspark", 2, True, True),
94
+ ("eagle3", 1, True, False),
95
+ ("eagle3", 2, False, False),
96
+ ("mtp", 2, True, False),
97
+ (None, 2, True, False),
98
+ ],
99
+)
100
+def test_target_embedding_provisioning(
101
+ monkeypatch, method, pp_size, is_last_rank, expected
102
+):
103
+ monkeypatch.setattr(
104
+ "vllm.distributed.parallel_state.get_pp_group",
105
+ _fake_pp(pp_size, is_last_rank),
106
+ raising=True,
107
+ )
108
+ speculative_config = None if method is None else SimpleNamespace(method=method)
109
+ config = SimpleNamespace(speculative_config=speculative_config)
110
+ assert spec_decode_needs_target_embed(config) is expected
vllm/config/speculative.py
+1
-1
@@ -1699,7 +1699,7 @@ class SpeculativeConfig:
1699
This is mostly a copy of the target parallel config, except the tp_size.
1700
"""
1701
draft_parallel_config = ParallelConfig(
1702
- pipeline_parallel_size=target_parallel_config.pipeline_parallel_size,
1702
+ pipeline_parallel_size=1,
1703
tensor_parallel_size=speculative_draft_tensor_parallel_size,
1704
distributed_executor_backend=target_parallel_config.distributed_executor_backend,
1705
max_parallel_loading_workers=target_parallel_config.max_parallel_loading_workers,
vllm/config/vllm.py
-6
@@ -2590,12 +2590,6 @@ class VllmConfig:
2590
):
2591
unsupported.append("parallel drafting for EAGLE speculative decoding")
2592
2593
- if (
2594
- speculative_config.method == "eagle3"
2595
- and self.parallel_config.pipeline_parallel_size > 1
2596
- ):
2597
- unsupported.append("EAGLE3 with pipeline parallelism")
2598
-
2593
if self.parallel_config.use_ubatching:
2594
unsupported.extend(self._get_dbo_unsupported_features())
2595
vllm/model_executor/models/deepseek_eagle3.py
+1
-3
@@ -285,9 +285,7 @@ class Eagle3DeepseekV2ForCausalLM(LocalArgmaxMixin, DeepseekV2ForCausalLM):
285
base_vocab_size = getattr(self.config, "vocab_size", None)
286
self.config.draft_vocab_size = base_vocab_size
287
288
- target_layer_num = vllm_config.model_config.get_num_layers(
289
- vllm_config.parallel_config
290
- )
288
+ target_layer_num = vllm_config.model_config.get_total_num_hidden_layers()
289
290
# Store target layer count in draft config
291
self.config.target_layer_count = target_layer_num
vllm/model_executor/models/interfaces.py
+58
-1
@@ -3,6 +3,7 @@
3
4
import asyncio
5
import weakref
6
+from bisect import bisect_right
7
from collections.abc import (
8
AsyncGenerator,
9
Callable,
@@ -1572,10 +1573,36 @@ class LocalArgmaxMixin:
1573
1574
1575
class EagleModelMixin:
1576
+ start_layer: int
1577
aux_hidden_state_layers: tuple[int, ...] = ()
1578
+ supports_aux_hidden_states_over_pp: ClassVar[bool] = False
1579
+ AUX_HIDDEN_STATE_KEY: ClassVar[str] = "aux_hidden_states_"
1580
+ _aux_slot_base_cached: int = 0
1581
+ _aux_upstream_total_cached: int = 0
1582
1583
def _set_aux_hidden_state_layers(self, layers: tuple[int, ...]) -> None:
1578
- self.aux_hidden_state_layers = layers
1584
+ self.aux_hidden_state_layers = tuple(sorted(set(layers)))
1585
+ self._aux_slot_base_cached = 0
1586
+ self._aux_upstream_total_cached = 0
1587
+ self._cache_aux_pp_layout()
1588
+
1589
+ def _cache_aux_pp_layout(self) -> None:
1590
+ from vllm.distributed.parallel_state import (
1591
+ get_pp_group,
1592
+ model_parallel_is_initialized,
1593
+ )
1594
+
1595
+ if not model_parallel_is_initialized():
1596
+ return
1597
+ pp = get_pp_group()
1598
+ if pp.world_size < 2:
1599
+ return
1600
+ if not pp.is_first_rank:
1601
+ self._aux_slot_base_cached = bisect_right(
1602
+ self.aux_hidden_state_layers, self.start_layer
1603
+ )
1604
+ if pp.is_last_rank:
1605
+ self._aux_upstream_total_cached = self._aux_slot_base_cached
1606
1607
def _maybe_add_hidden_state(
1608
self,
@@ -1589,6 +1616,36 @@ class EagleModelMixin:
1616
aux_hidden_states.append(value)
1617
return aux_hidden_states
1618
1619
+ def pack_local_aux_hidden_states(
1620
+ self, aux_hidden_states: list[torch.Tensor]
1621
+ ) -> dict[str, torch.Tensor]:
1622
+ if not aux_hidden_states:
1623
+ return {}
1624
+ base = self._aux_slot_base_cached
1625
+ return {
1626
+ f"{self.AUX_HIDDEN_STATE_KEY}{base + i}": t
1627
+ for i, t in enumerate(aux_hidden_states)
1628
+ }
1629
+
1630
+ def collect_remote_aux_hidden_states(
1631
+ self, intermediate_tensors: "IntermediateTensors | None"
1632
+ ) -> list[torch.Tensor]:
1633
+ total = self._aux_upstream_total_cached
1634
+ if total == 0:
1635
+ return []
1636
+
1637
+ assert intermediate_tensors is not None
1638
+ out: list[torch.Tensor] = []
1639
+ for i in range(total):
1640
+ key = f"{self.AUX_HIDDEN_STATE_KEY}{i}"
1641
+ if key not in intermediate_tensors.tensors:
1642
+ raise RuntimeError(
1643
+ f"Missing {key} from PP intermediate tensors: "
1644
+ f"{sorted(intermediate_tensors.tensors)}"
1645
+ )
1646
+ out.append(intermediate_tensors[key])
1647
+ return out
1648
+
1649
1650
@runtime_checkable
1651
class SupportsEagle(SupportsEagleBase, Protocol):
vllm/model_executor/models/laguna_dflash.py
+1
-3
@@ -245,9 +245,7 @@ class DFlashLagunaForCausalLM(nn.Module, SupportsEagle3):
245
raise ValueError("Laguna DFlash config requires `draft_vocab_size`.")
246
self.has_own_embed_tokens = False
247
self.has_own_lm_head = False
248
- target_layer_num = vllm_config.model_config.get_num_layers(
249
- vllm_config.parallel_config
250
- )
248
+ target_layer_num = vllm_config.model_config.get_total_num_hidden_layers()
249
self.config.target_layer_count = target_layer_num
250
target_vocab_size = vllm_config.model_config.get_vocab_size()
251
if self.config.draft_vocab_size != target_vocab_size:
vllm/model_executor/models/llama.py
+21
-5
@@ -75,6 +75,7 @@ from .utils import (
75
make_empty_intermediate_tensors_factory,
76
make_layers,
77
maybe_prefix,
78
+ spec_decode_needs_target_embed,
79
)
80
81
@@ -354,6 +355,7 @@ class LlamaModel(nn.Module, EagleModelMixin):
355
".up_proj": (".gate_up_proj", 1),
356
}
357
)
358
+ supports_aux_hidden_states_over_pp = True
359
360
def __init__(
361
self,
@@ -372,8 +374,10 @@ class LlamaModel(nn.Module, EagleModelMixin):
374
375
self.vocab_size = config.vocab_size
376
375
- if get_pp_group().is_first_rank or (
376
- config.tie_word_embeddings and get_pp_group().is_last_rank
377
+ if (
378
+ get_pp_group().is_first_rank
379
+ or (config.tie_word_embeddings and get_pp_group().is_last_rank)
380
+ or spec_decode_needs_target_embed(vllm_config)
381
):
382
self.embed_tokens = VocabParallelEmbedding(
383
self.vocab_size,
@@ -418,9 +422,16 @@ class LlamaModel(nn.Module, EagleModelMixin):
422
hidden_states = intermediate_tensors["hidden_states"]
423
residual = intermediate_tensors["residual"]
424
421
- aux_hidden_states = self._maybe_add_hidden_state([], 0, hidden_states, residual)
425
+ remote_aux = self.collect_remote_aux_hidden_states(intermediate_tensors)
426
+
427
+ aux_hidden_states: list[torch.Tensor] = []
428
+ if get_pp_group().is_first_rank:
429
+ self._maybe_add_hidden_state(
430
+ aux_hidden_states, self.start_layer, hidden_states, residual
431
+ )
432
for idx, layer in enumerate(
423
- islice(self.layers, self.start_layer, self.end_layer)
433
+ islice(self.layers, self.start_layer, self.end_layer),
434
+ start=self.start_layer,
435
):
436
hidden_states, residual = layer(
437
positions, hidden_states, residual, **extra_layer_kwargs
@@ -431,11 +442,16 @@ class LlamaModel(nn.Module, EagleModelMixin):
442
443
if not get_pp_group().is_last_rank:
444
return IntermediateTensors(
434
- {"hidden_states": hidden_states, "residual": residual}
445
+ {
446
+ "hidden_states": hidden_states,
447
+ "residual": residual,
448
+ **self.pack_local_aux_hidden_states(aux_hidden_states),
449
+ }
450
)
451
452
hidden_states, _ = self.norm(hidden_states, residual)
453
454
+ aux_hidden_states = remote_aux + aux_hidden_states
455
if len(aux_hidden_states) > 0:
456
return hidden_states, aux_hidden_states
457
return hidden_states
vllm/model_executor/models/llama_eagle3.py
+1
-3
@@ -314,9 +314,7 @@ class Eagle3LlamaForCausalLM(_Eagle3LlamaForCausalLMBase):
314
if getattr(self.config, "draft_vocab_size", None) is None:
315
base_vocab_size = getattr(self.config, "vocab_size", None)
316
self.config.draft_vocab_size = base_vocab_size
317
- target_layer_num = vllm_config.model_config.get_num_layers(
318
- vllm_config.parallel_config
319
- )
317
+ target_layer_num = vllm_config.model_config.get_total_num_hidden_layers()
318
319
# Store target layer count in draft config for
320
# proper layer_types indexing in draft models
vllm/model_executor/models/mimo.py
+2
@@ -55,6 +55,8 @@ logger = init_logger(__name__)
55
}
56
)
57
class MiMoModel(Qwen2Model):
58
+ supports_aux_hidden_states_over_pp = False
59
+
60
def forward(
61
self,
62
input_ids: torch.Tensor | None,
vllm/model_executor/models/qwen2.py
+21
-5
@@ -74,6 +74,7 @@ from .utils import (
74
make_empty_intermediate_tensors_factory,
75
make_layers,
76
maybe_prefix,
77
+ spec_decode_needs_target_embed,
78
)
79
80
@@ -330,6 +331,7 @@ class Qwen2Model(nn.Module, EagleModelMixin):
331
".up_proj": (".gate_up_proj", 1),
332
}
333
)
334
+ supports_aux_hidden_states_over_pp = True
335
336
def __init__(
337
self,
@@ -360,8 +362,10 @@ class Qwen2Model(nn.Module, EagleModelMixin):
362
self.quant_config = quant_config
363
self.vocab_size = config.vocab_size
364
363
- if get_pp_group().is_first_rank or (
364
- config.tie_word_embeddings and get_pp_group().is_last_rank
365
+ if (
366
+ get_pp_group().is_first_rank
367
+ or (config.tie_word_embeddings and get_pp_group().is_last_rank)
368
+ or spec_decode_needs_target_embed(vllm_config)
369
):
370
self.embed_tokens = VocabParallelEmbedding(
371
config.vocab_size,
@@ -412,9 +416,16 @@ class Qwen2Model(nn.Module, EagleModelMixin):
416
hidden_states = intermediate_tensors["hidden_states"]
417
residual = intermediate_tensors["residual"]
418
415
- aux_hidden_states = self._maybe_add_hidden_state([], 0, hidden_states, residual)
419
+ remote_aux = self.collect_remote_aux_hidden_states(intermediate_tensors)
420
+
421
+ aux_hidden_states: list[torch.Tensor] = []
422
+ if get_pp_group().is_first_rank:
423
+ self._maybe_add_hidden_state(
424
+ aux_hidden_states, self.start_layer, hidden_states, residual
425
+ )
426
for idx, layer in enumerate(
417
- islice(self.layers, self.start_layer, self.end_layer)
427
+ islice(self.layers, self.start_layer, self.end_layer),
428
+ start=self.start_layer,
429
):
430
hidden_states, residual = layer(positions, hidden_states, residual)
431
self._maybe_add_hidden_state(
@@ -423,11 +434,16 @@ class Qwen2Model(nn.Module, EagleModelMixin):
434
435
if not get_pp_group().is_last_rank:
436
return IntermediateTensors(
426
- {"hidden_states": hidden_states, "residual": residual}
437
+ {
438
+ "hidden_states": hidden_states,
439
+ "residual": residual,
440
+ **self.pack_local_aux_hidden_states(aux_hidden_states),
441
+ }
442
)
443
444
hidden_states, _ = self.norm(hidden_states, residual)
445
446
+ aux_hidden_states = remote_aux + aux_hidden_states
447
if len(aux_hidden_states) > 0:
448
return hidden_states, aux_hidden_states
449
vllm/model_executor/models/qwen3_dflash.py
+1
-3
@@ -694,9 +694,7 @@ class DFlashQwen3ForCausalLM(Qwen3ForCausalLM):
694
self.config = self.draft_model_config.hf_config
695
if getattr(self.config, "draft_vocab_size", None) is None:
696
self.config.draft_vocab_size = getattr(self.config, "vocab_size", None)
697
- target_layer_num = vllm_config.model_config.get_num_layers(
698
- vllm_config.parallel_config
699
- )
697
+ target_layer_num = vllm_config.model_config.get_total_num_hidden_layers()
698
self.model = self.model_cls(
699
vllm_config=vllm_config,
700
prefix=maybe_prefix(prefix, "model"),
vllm/model_executor/models/qwen3_dspark.py
+1
-3
@@ -182,9 +182,7 @@ class Qwen3DSparkForCausalLM(DFlashQwen3ForCausalLM):
182
self.config = self.draft_model_config.hf_config
183
if getattr(self.config, "draft_vocab_size", None) is None:
184
self.config.draft_vocab_size = getattr(self.config, "vocab_size", None)
185
- target_layer_num = vllm_config.model_config.get_num_layers(
186
- vllm_config.parallel_config
187
- )
185
+ target_layer_num = vllm_config.model_config.get_total_num_hidden_layers()
186
self.model = Qwen3DSparkModel(
187
vllm_config=vllm_config,
188
prefix=maybe_prefix(prefix, "model"),
vllm/model_executor/models/qwen3_eagle3.py
+1
-3
@@ -288,9 +288,7 @@ class Eagle3Qwen3ForCausalLM(Qwen3ForCausalLM):
288
if getattr(self.config, "draft_vocab_size", None) is None:
289
base_vocab_size = getattr(self.config, "vocab_size", None)
290
self.config.draft_vocab_size = base_vocab_size
291
- target_layer_num = vllm_config.model_config.get_num_layers(
292
- vllm_config.parallel_config
293
- )
291
+ target_layer_num = vllm_config.model_config.get_total_num_hidden_layers()
292
293
# Store target layer count in draft config for
294
# proper layer_types indexing in draft models
vllm/model_executor/models/utils.py
+16
@@ -835,6 +835,22 @@ class PPMissingLayer(torch.nn.Identity):
835
return args[0] if args else next(iter(kwargs.values()))
836
837
838
+def spec_decode_needs_target_embed(vllm_config: VllmConfig) -> bool:
839
+ """Whether the last PP rank needs the target input embedding."""
840
+ from vllm.distributed.parallel_state import get_pp_group
841
+
842
+ speculative_config = vllm_config.speculative_config
843
+ if speculative_config is None or speculative_config.method not in (
844
+ "eagle",
845
+ "eagle3",
846
+ "dflash",
847
+ "dspark",
848
+ ):
849
+ return False
850
+ pp = get_pp_group()
851
+ return pp.world_size > 1 and pp.is_last_rank
852
+
853
+
854
def make_layers(
855
num_hidden_layers: int,
856
layer_fn: LayerFn,
vllm/models/deepseek_v4/nvidia/model.py
+12
-2
@@ -67,6 +67,7 @@ from vllm.model_executor.models.utils import (
67
is_pp_missing_parameter,
68
make_layers,
69
maybe_prefix,
70
+ spec_decode_needs_target_embed,
71
)
72
from vllm.model_executor.utils import set_weight_attrs
73
from vllm.models.common.ops.sequence_parallel import (
@@ -1270,6 +1271,8 @@ class DeepseekV4DecoderLayer(nn.Module):
1271
1272
1273
class DeepseekV4Model(nn.Module, EagleModelMixin):
1274
+ supports_aux_hidden_states_over_pp = True
1275
+
1276
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
1277
super().__init__()
1278
@@ -1305,7 +1308,7 @@ class DeepseekV4Model(nn.Module, EagleModelMixin):
1308
dtype=torch.int32,
1309
)
1310
1308
- if get_pp_group().is_first_rank:
1311
+ if get_pp_group().is_first_rank or spec_decode_needs_target_embed(vllm_config):
1312
self.embed_tokens = VocabParallelEmbedding(
1313
config.vocab_size,
1314
config.hidden_size,
@@ -1416,6 +1419,7 @@ class DeepseekV4Model(nn.Module, EagleModelMixin):
1419
input_ids = sp_shard(input_ids)
1420
1421
residual, post_mix, res_mix = None, None, None
1422
+ remote_aux = self.collect_remote_aux_hidden_states(intermediate_tensors)
1423
aux_hidden_states: list[torch.Tensor] = []
1424
final_aux_recon: torch.Tensor | None = None # avoid duplicate mhc_post call
1425
for idx, layer in enumerate(
@@ -1450,7 +1454,12 @@ class DeepseekV4Model(nn.Module, EagleModelMixin):
1454
)
1455
1456
if not get_pp_group().is_last_rank:
1453
- return IntermediateTensors({"hidden_states": hidden_states})
1457
+ return IntermediateTensors(
1458
+ {
1459
+ "hidden_states": hidden_states,
1460
+ **self.pack_local_aux_hidden_states(aux_hidden_states),
1461
+ }
1462
+ )
1463
1464
if self.use_sequence_parallel:
1465
hidden_states = sp_all_gather(hidden_states)[:full_num_tokens]
@@ -1468,6 +1477,7 @@ class DeepseekV4Model(nn.Module, EagleModelMixin):
1477
self.hc_eps,
1478
)
1479
hidden_states = self.norm(hidden_states)
1480
+ aux_hidden_states = remote_aux + aux_hidden_states
1481
if len(aux_hidden_states) > 0:
1482
return hidden_states, aux_hidden_states
1483
return hidden_states
vllm/models/kimi_k3/nvidia/dspark_mla.py
+1
-3
@@ -421,9 +421,7 @@ class K3DSparkForCausalLM(nn.Module):
421
assert vllm_config.speculative_config is not None
422
self.draft_model_config = vllm_config.speculative_config.draft_model_config
423
self.config = self.draft_model_config.hf_config
424
- target_layer_num = vllm_config.model_config.get_num_layers(
425
- vllm_config.parallel_config
426
- )
424
+ target_layer_num = vllm_config.model_config.get_total_num_hidden_layers()
425
self.model = K3DSparkModel(
426
vllm_config=vllm_config,
427
start_layer_id=target_layer_num,
vllm/models/kimi_k3/nvidia/model.py
+26
-33
@@ -85,6 +85,7 @@ from vllm.model_executor.models.utils import (
85
is_pp_missing_parameter,
86
make_layers,
87
maybe_prefix,
88
+ spec_decode_needs_target_embed,
89
)
90
from vllm.model_executor.models.vision import is_vit_use_data_parallel
91
from vllm.models.common.ops.sequence_parallel import (
@@ -1121,6 +1122,8 @@ class KimiLinearModel(nn.Module, EagleModelMixin, SupportsQuant):
1122
"fused_qkv_a_g_proj": ["q_a_proj", "kv_a_proj_with_mqa", "g_proj"],
1123
}
1124
1125
+ supports_aux_hidden_states_over_pp = True
1126
+
1127
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
1128
super().__init__()
1129
@@ -1145,7 +1148,7 @@ class KimiLinearModel(nn.Module, EagleModelMixin, SupportsQuant):
1148
vllm_config, self.use_sequence_parallel
1149
)
1150
1148
- if get_pp_group().is_first_rank:
1151
+ if get_pp_group().is_first_rank or spec_decode_needs_target_embed(vllm_config):
1152
self.embed_tokens = VocabParallelEmbedding(
1153
config.vocab_size,
1154
config.hidden_size,
@@ -1228,15 +1231,18 @@ class KimiLinearModel(nn.Module, EagleModelMixin, SupportsQuant):
1231
1232
def _set_aux_hidden_state_layers(self, layers: tuple[int, ...]) -> None:
1233
super()._set_aux_hidden_state_layers(layers)
1234
+ if self.use_attn_res and self._aux_attn_res_stream:
1235
+ pp = get_pp_group()
1236
+ if not pp.is_last_rank and self.end_layer in self.aux_hidden_state_layers:
1237
+ raise ValueError(
1238
+ f"Auxiliary layer {self.end_layer} cannot end a non-final PP "
1239
+ "stage when VLLM_KIMI_K3_AUX_ATTN_RES_STREAM=1"
1240
+ )
1241
if self.use_attn_res:
1232
- # Emitted once, at configuration time. Which layers are tapped and
1233
- # which convention is in force are the two things you need to
1234
- # confirm from a running process, and neither is recoverable from
1235
- # the served output.
1242
logger.info_once(
1243
"Kimi-K3 aux hidden capture: layers=%s mode=%s "
1244
"(VLLM_KIMI_K3_AUX_ATTN_RES_STREAM=%d)",
1239
- layers,
1245
+ self.aux_hidden_state_layers,
1246
"attn_res_stream" if self._aux_attn_res_stream else "prefix_only",
1247
int(self._aux_attn_res_stream),
1248
)
@@ -1252,25 +1258,8 @@ class KimiLinearModel(nn.Module, EagleModelMixin, SupportsQuant):
1258
pending_mlp_out: torch.Tensor | None,
1259
block_residual: torch.Tensor,
1260
) -> torch.Tensor:
1255
- """Auxiliary feature tapped after ``layer_idx`` under AttnRes.
1256
-
1257
- The wire between layers only carries the current block's running prefix;
1258
- the committed blocks live in the bank. The value the next consumer
1259
- actually reads is the pre-norm AttnRes mixture over
1260
- ``bank[:num_blocks] + prefix``, which is what the DFlash drafters were
1261
- trained against. ``attn_res`` with no delta, no block write and no
1262
- output norm computes exactly that and leaves both the prefix and the
1263
- bank untouched.
1264
-
1265
- Folding the pending MLP output into the prefix rather than passing it as
1266
- ``delta`` is deliberate: the kernel writes an applied delta back into
1267
- the prefix in place, which would double-add it into the live residual
1268
- stream.
1269
- """
1261
+ """Return the AttnRes stream after ``layer_idx``."""
1262
prefix = prefix_sum if pending_mlp_out is None else prefix_sum + pending_mlp_out
1271
- # `use_attn_res` is what constructs the norm and projection weights this
1272
- # reads; without it there is no mixture to compute and the attribute
1273
- # lookups below would raise.
1263
if not (self._aux_attn_res_stream and self.use_attn_res):
1264
return prefix
1265
@@ -1280,17 +1269,11 @@ class KimiLinearModel(nn.Module, EagleModelMixin, SupportsQuant):
1269
score_proj = consumer.self_attention_res_proj
1270
num_blocks = consumer.prev_valid_blocks
1271
elif get_pp_group().is_last_rank:
1283
- # Nothing downstream but the model's own output-side aggregation.
1272
score_norm = self.output_attn_res_norm
1273
score_proj = self.output_attn_res_proj
1274
num_blocks = self.num_attn_res_blocks
1275
else:
1288
- # Last layer of a non-final pipeline stage: the consumer lives on
1289
- # the next rank and the output-side aggregation only exists on the
1290
- # last one, so there is nothing here to mix against. Falling back
1291
- # to the running prefix keeps the tap defined rather than reaching
1292
- # for weights this rank does not construct.
1293
- return prefix
1276
+ raise RuntimeError("Auxiliary AttnRes capture crossed a PP boundary")
1277
1278
return attn_res(
1279
prefix,
@@ -1338,9 +1321,14 @@ class KimiLinearModel(nn.Module, EagleModelMixin, SupportsQuant):
1321
hidden_states = sp_shard(hidden_states)
1322
assert residual is None, "Currently, SP is not supported with PP"
1323
1324
+ remote_aux = self.collect_remote_aux_hidden_states(intermediate_tensors)
1325
+
1326
# sharded aux hidden states when sp is enabled
1327
aux_hidden_states: list[torch.Tensor] = []
1343
- if self.start_layer in self.aux_hidden_state_layers:
1328
+ if (
1329
+ get_pp_group().is_first_rank
1330
+ and self.start_layer in self.aux_hidden_state_layers
1331
+ ):
1332
if self.use_attn_res or residual is None:
1333
aux_hidden_states.append(hidden_states)
1334
else:
@@ -1391,7 +1379,11 @@ class KimiLinearModel(nn.Module, EagleModelMixin, SupportsQuant):
1379
if prefix_sum is not None:
1380
hidden_states = hidden_states + prefix_sum
1381
return IntermediateTensors(
1394
- {"hidden_states": hidden_states, "residual": residual}
1382
+ {
1383
+ "hidden_states": hidden_states,
1384
+ "residual": residual,
1385
+ **self.pack_local_aux_hidden_states(aux_hidden_states),
1386
+ }
1387
)
1388
1389
if self.use_attn_res:
@@ -1428,6 +1420,7 @@ class KimiLinearModel(nn.Module, EagleModelMixin, SupportsQuant):
1420
1421
# NOTE: the final norm is applied in compute_logits instead of here, so
1422
# the MTP draft model receives the pre-norm hidden states.
1423
+ aux_hidden_states = remote_aux + aux_hidden_states
1424
if aux_hidden_states:
1425
return hidden_states, aux_hidden_states
1426
return hidden_states
vllm/v1/worker/gpu/model_runner.py
+20
-7
@@ -152,6 +152,7 @@ from vllm.v1.worker.gpu.spec_decode.adaptive_verification import (
152
)
153
from vllm.v1.worker.gpu.spec_decode.eagle.eagle3_utils import (
154
set_eagle3_aux_hidden_state_layers,
155
+ verify_supports_aux_hidden_states_over_pp,
156
)
157
from vllm.v1.worker.gpu.spec_decode.rejection_sampler import (
158
RejectionSampler,
@@ -280,11 +281,6 @@ class GPUModelRunner(LoRAModelRunnerMixin):
281
):
282
# Drafting may require auxiliary hidden states from target model outputs
283
self.use_aux_hidden_state_outputs = True
283
- if self.use_pp:
284
- raise ValueError(
285
- f"{self.speculative_config.method} with pipeline parallel "
286
- "is not supported."
287
- )
284
285
# Draft tokens propagation - for spec-dec + struct outputs.
286
self.draft_tokens_handler = DraftTokensHandler(self.device)
@@ -407,6 +403,13 @@ class GPUModelRunner(LoRAModelRunnerMixin):
403
if self.use_aux_hidden_state_outputs:
404
assert self.speculative_config is not None
405
set_eagle3_aux_hidden_state_layers(self.model, self.speculative_config)
406
+ if self.use_pp:
407
+ assert self.speculative_config.method is not None
408
+ verify_supports_aux_hidden_states_over_pp(
409
+ self.model, self.speculative_config.method
410
+ )
411
+ assert self.pp_handler is not None
412
+ self.pp_handler.configure_aux_hidden_state_relay(self.model)
413
if isinstance(self.speculator, DraftModelSpeculator):
414
with use_workspace_lane(self._draft_workspace_lane):
415
self.speculator.load_model(self.model)
@@ -1043,7 +1046,9 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1046
# For non-last PP ranks, update decode requests with sampler output from
1047
# the prior step in which they were scheduled (pp_size steps ago).
1048
if self.pp_handler is not None:
1046
- outputs = self.pp_handler.get_prev_sampled_outputs()
1049
+ outputs = self.pp_handler.get_prev_sampled_outputs(
1050
+ self.req_states.draft_tokens
1051
+ )
1052
if outputs is not None:
1053
self.postprocess_sampled(**outputs)
1054
@@ -1879,7 +1884,11 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1884
1885
if not self.is_last_pp_rank:
1886
# Non-last PP rank: return IntermediateTensors for sending.
1882
- return output_intermediate_tensors
1887
+ assert output_intermediate_tensors is not None
1888
+ assert self.pp_handler is not None
1889
+ return self.pp_handler.relay_aux_hidden_states(
1890
+ model_inputs["intermediate_tensors"], output_intermediate_tensors
1891
+ )
1892
return None
1893
1894
@torch.inference_mode()
@@ -2036,6 +2045,10 @@ class GPUModelRunner(LoRAModelRunnerMixin):
2045
input_batch,
2046
self.req_states.draft_tokens[input_batch.idx_mapping],
2047
)
2048
+ if self.pp_handler is not None:
2049
+ self.pp_handler.broadcast_drafts(
2050
+ self.req_states.draft_tokens, input_batch
2051
+ )
2052
2053
# Post-step KV connector related operations.
2054
kv_connector_output = self.kv_connector.post_forward(finished_req_ids)
vllm/v1/worker/gpu/pp_utils.py
+76
-2
@@ -10,6 +10,7 @@ import torch
10
11
from vllm.distributed.parallel_state import get_pp_group
12
from vllm.platforms import current_platform
13
+from vllm.sequence import IntermediateTensors
14
from vllm.v1.worker.gpu.buffer_utils import async_copy_to_gpu
15
from vllm.v1.worker.gpu.input_batch import InputBatch
16
@@ -30,6 +31,7 @@ class PendingRecv:
31
# Snapshot of slot generation counters at receive time, used to
32
# detect requests aborted since then.
33
gen_at_receive_np: np.ndarray # [num_reqs]
34
+ draft_tokens: torch.Tensor | None = None # [num_reqs, num_speculative_steps]
35
36
37
def compute_need_sampled_mask(input_batch: InputBatch) -> np.ndarray | None:
@@ -61,6 +63,7 @@ class PPHandler:
63
self.is_last_rank = get_pp_group().is_last_rank
64
self.last_rank = get_pp_group().last_rank
65
self.max_sample_len = num_speculative_steps + 1
66
+ self.num_speculative_steps = num_speculative_steps
67
self.device = device
68
self.main_stream = torch.cuda.current_stream(device)
69
self.broadcast_stream = torch.cuda.Stream(device)
@@ -82,11 +85,37 @@ class PPHandler:
85
self.broadcast_group = get_pp_group().make_sibling_device_group(
86
group_desc="pp_broadcast"
87
)
88
+ self.aux_hidden_state_relay_keys: tuple[str, ...] = ()
89
90
def on_req_idx_freed(self, req_idx: int) -> None:
91
self.req_idx_gen_np[req_idx] += 1
92
89
- def get_prev_sampled_outputs(self) -> dict[str, torch.Tensor] | None:
93
+ def configure_aux_hidden_state_relay(self, model: torch.nn.Module) -> None:
94
+ from vllm.v1.worker.gpu.spec_decode.eagle.eagle3_utils import (
95
+ aux_hidden_state_relay_keys,
96
+ )
97
+
98
+ self.aux_hidden_state_relay_keys = aux_hidden_state_relay_keys(model)
99
+
100
+ def relay_aux_hidden_states(
101
+ self,
102
+ intermediate_tensors: IntermediateTensors | None,
103
+ output_intermediate_tensors: IntermediateTensors,
104
+ ) -> IntermediateTensors:
105
+ if not self.aux_hidden_state_relay_keys:
106
+ return output_intermediate_tensors
107
+ assert intermediate_tensors is not None
108
+ return IntermediateTensors(
109
+ output_intermediate_tensors.tensors
110
+ | {
111
+ key: intermediate_tensors[key]
112
+ for key in self.aux_hidden_state_relay_keys
113
+ }
114
+ )
115
+
116
+ def get_prev_sampled_outputs(
117
+ self, draft_tokens_to_update: torch.Tensor | None = None
118
+ ) -> dict[str, torch.Tensor] | None:
119
"""Consume the entry from pp_size steps ago and wait for its recv event,
120
then filter out entries whose request was freed since `receive`.
121
"""
@@ -112,6 +141,18 @@ class PPHandler:
141
idx_mapping = async_copy_to_gpu(idx_mapping_np, device=self.device)
142
143
self.main_stream.wait_event(slot.event)
144
+ if slot.draft_tokens is not None and draft_tokens_to_update is not None:
145
+ draft_tokens = slot.draft_tokens
146
+ draft_idx_mapping = slot.idx_mapping
147
+ if exclude_mask.any():
148
+ keep = ~exclude_mask
149
+ keep_t = torch.as_tensor(keep, device=self.device)
150
+ draft_tokens = draft_tokens[keep_t]
151
+ draft_idx_mapping = async_copy_to_gpu(
152
+ slot.idx_mapping_np[keep], device=self.device
153
+ )
154
+ draft_tokens_to_update[draft_idx_mapping] = draft_tokens
155
+
156
return dict(
157
sampled_tokens=slot.sampled_tokens,
158
num_sampled=slot.num_sampled,
@@ -119,6 +160,21 @@ class PPHandler:
160
idx_mapping=idx_mapping,
161
)
162
163
+ def broadcast_drafts(
164
+ self, draft_tokens: torch.Tensor, input_batch: InputBatch
165
+ ) -> None:
166
+ """Broadcast draft proposals so non-last ranks can embed real token ids."""
167
+ assert self.is_last_rank
168
+ if compute_need_sampled_mask(input_batch) is None:
169
+ return
170
+ with torch.cuda.stream(self.broadcast_stream):
171
+ self.broadcast_stream.wait_stream(self.main_stream)
172
+ send = draft_tokens[input_batch.idx_mapping].contiguous()
173
+ torch.distributed.broadcast(
174
+ send, src=self.last_rank, group=self.broadcast_group
175
+ )
176
+ send.record_stream(self.broadcast_stream)
177
+
178
def receive(self, input_batch: InputBatch) -> bool:
179
"""Returns True iff sampled tokens need to be gathered from *all*
180
requests in the batch."""
@@ -145,12 +201,25 @@ class PPHandler:
201
torch.distributed.broadcast(
202
combined, src=self.last_rank, group=self.broadcast_group
203
)
204
+ draft_tokens = None
205
+ if self.num_speculative_steps > 0:
206
+ draft_tokens = torch.empty(
207
+ num_reqs,
208
+ self.num_speculative_steps,
209
+ dtype=torch.int64,
210
+ device=self.device,
211
+ )
212
+ torch.distributed.broadcast(
213
+ draft_tokens, src=self.last_rank, group=self.broadcast_group
214
+ )
215
event = self.broadcast_stream.record_event()
216
num_sampled, num_rejected = combined.unbind(dim=0)
217
# Must record_stream since these were allocated on broadcast stream but
218
# later used on the main stream.
219
sampled_tokens.record_stream(self.main_stream)
220
combined.record_stream(self.main_stream)
221
+ if draft_tokens is not None:
222
+ draft_tokens.record_stream(self.main_stream)
223
self.queue[-1] = PendingRecv(
224
event,
225
sampled_tokens,
@@ -160,6 +229,7 @@ class PPHandler:
229
input_batch.idx_mapping_np,
230
need_sampled_mask,
231
gen_at_receive_np,
232
+ draft_tokens,
233
)
234
return bool(need_sampled_mask.all())
235
@@ -182,8 +252,12 @@ class PPHandler:
252
253
with torch.cuda.stream(self.broadcast_stream):
254
self.broadcast_stream.wait_stream(self.main_stream)
255
+ send_tokens = torch.nn.functional.pad(
256
+ sampled_token_ids,
257
+ (0, self.max_sample_len - sampled_token_ids.shape[-1]),
258
+ )
259
torch.distributed.broadcast(
186
- sampled_token_ids.contiguous(),
260
+ send_tokens.contiguous(),
261
src=self.last_rank,
262
group=self.broadcast_group,
263
)
vllm/v1/worker/gpu/spec_decode/dflash/utils.py
+2
-13
@@ -3,11 +3,11 @@
3
import torch.nn as nn
4
5
from vllm.config import VllmConfig, replace
6
-from vllm.distributed.parallel_state import get_pp_group
6
from vllm.model_executor.model_loader import get_model
7
from vllm.v1.worker.gpu.spec_decode.eagle.utils import (
8
_should_share,
9
get_target_lm_head,
10
+ maybe_share_target_embed,
11
)
12
13
@@ -54,18 +54,7 @@ def load_dflash_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Mo
54
target_inner = getattr(target_language_model, "model", target_language_model)
55
draft_inner = dflash_model.model
56
57
- # Skip embedding sharing under PP — each rank owns its own embedding.
58
- if get_pp_group().world_size == 1:
59
- target_embed = getattr(target_inner, "embed_tokens", None) or getattr(
60
- target_inner, "embedding", None
61
- )
62
- draft_embed = getattr(draft_inner, "embed_tokens", None)
63
- if target_embed is not None and _should_share(
64
- dflash_model, "has_own_embed_tokens", draft_embed, target_embed
65
- ):
66
- if draft_embed is not None:
67
- del draft_inner.embed_tokens
68
- draft_inner.embed_tokens = target_embed
57
+ maybe_share_target_embed(dflash_model, draft_inner, target_inner)
58
59
target_lm_head = get_target_lm_head(target_model, target_language_model)
60
draft_lm_head = getattr(dflash_model, "lm_head", None)
vllm/v1/worker/gpu/spec_decode/dspark/utils.py
+4
-16
@@ -4,7 +4,6 @@
4
import torch.nn as nn
5
6
from vllm.config import ModelConfig, VllmConfig, replace
7
-from vllm.distributed.parallel_state import get_pp_group
7
from vllm.logger import init_logger
8
from vllm.v1.attention.backends.registry import AttentionBackendEnum
9
@@ -43,6 +42,7 @@ def load_dspark_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Mo
42
from vllm.v1.worker.gpu.spec_decode.eagle.utils import (
43
_should_share,
44
get_target_lm_head,
45
+ maybe_share_target_embed,
46
)
47
48
draft_attention_backend = _resolve_dspark_attention_backend(
@@ -53,6 +53,7 @@ def load_dspark_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Mo
53
54
draft_vllm_config = replace(
55
vllm_config,
56
+ parallel_config=speculative_config.draft_parallel_config,
57
attention_config=replace(
58
vllm_config.attention_config,
59
use_non_causal=dflash_has_any_non_causal(draft_model_config.hf_config),
@@ -76,9 +77,6 @@ def load_dspark_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Mo
77
vllm_config=draft_vllm_config, model_config=draft_model_config
78
)
79
79
- if get_pp_group().world_size != 1:
80
- raise NotImplementedError("DSpark does not support pipeline parallelism.")
81
-
80
target_language_model = (
81
target_model.get_language_model()
82
if hasattr(target_model, "get_language_model")
@@ -88,18 +86,8 @@ def load_dspark_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Mo
86
draft_inner = draft_model.model
87
target_vocab_size = vllm_config.model_config.get_vocab_size()
88
91
- target_embed = getattr(target_inner, "embed_tokens", None)
92
- draft_embed = getattr(draft_inner, "embed_tokens", None)
93
- if (
94
- target_embed is not None
95
- and draft_model_config.get_vocab_size() <= target_vocab_size
96
- and _should_share(
97
- draft_model, "has_own_embed_tokens", draft_embed, target_embed
98
- )
99
- ):
100
- if draft_embed is not None:
101
- del draft_inner.embed_tokens
102
- draft_inner.embed_tokens = target_embed
89
+ if draft_model_config.get_vocab_size() <= target_vocab_size:
90
+ maybe_share_target_embed(draft_model, draft_inner, target_inner)
91
92
target_lm_head = get_target_lm_head(target_model, target_language_model)
93
draft_lm_head = getattr(draft_model, "lm_head", None)
vllm/v1/worker/gpu/spec_decode/eagle/eagle3_utils.py
+67
-2
@@ -2,6 +2,7 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
from typing import cast
4
5
+import torch
6
import torch.nn as nn
7
8
from vllm.config import SpeculativeConfig
@@ -17,8 +18,6 @@ def set_eagle3_aux_hidden_state_layers(
18
) -> None:
19
if not supports_eagle3(model):
20
raise RuntimeError("Model does not support EAGLE3 interface")
20
- # mypy may infer the class-level overload for supports_eagle3.
21
- # Narrow explicitly to the runtime protocol instance.
21
if isinstance(model, type):
22
raise RuntimeError("Expected model instance for EAGLE3 configuration")
23
eagle3_model = cast(SupportsEagle3, model)
@@ -30,6 +29,72 @@ def set_eagle3_aux_hidden_state_layers(
29
aux_layers = eagle3_model.get_eagle3_default_aux_hidden_state_layers()
30
logger.info("Using Eagle3 auxiliary layers from model: %s", aux_layers)
31
eagle3_model.set_aux_hidden_state_layers(aux_layers)
32
+ reserve_aux_intermediate_tensor_slots(model)
33
+
34
+
35
+def _inner_decoder(model: nn.Module) -> nn.Module | None:
36
+ parent_ref = model
37
+ if hasattr(model, "get_language_model"):
38
+ parent_ref = model.get_language_model()
39
+ elif hasattr(model, "language_model"):
40
+ parent_ref = model.language_model
41
+ return getattr(parent_ref, "model", None)
42
+
43
+
44
+def verify_supports_aux_hidden_states_over_pp(model: nn.Module, method: str) -> None:
45
+ inner = _inner_decoder(model)
46
+ if not getattr(inner, "supports_aux_hidden_states_over_pp", False):
47
+ raise ValueError(
48
+ f"{type(model).__name__} does not support {method} with "
49
+ "pipeline parallelism"
50
+ )
51
+
52
+
53
+def aux_hidden_state_relay_keys(model: nn.Module) -> tuple[str, ...]:
54
+ from vllm.distributed.parallel_state import get_pp_group
55
+
56
+ pp = get_pp_group()
57
+ if pp.world_size < 2 or pp.is_first_rank or pp.is_last_rank:
58
+ return ()
59
+ inner = _inner_decoder(model)
60
+ assert inner is not None
61
+ return tuple(
62
+ f"{inner.AUX_HIDDEN_STATE_KEY}{i}" for i in range(inner._aux_slot_base_cached)
63
+ )
64
+
65
+
66
+def reserve_aux_intermediate_tensor_slots(model: nn.Module) -> None:
67
+ from vllm.distributed.parallel_state import (
68
+ get_pp_group,
69
+ model_parallel_is_initialized,
70
+ )
71
+
72
+ if not model_parallel_is_initialized():
73
+ return
74
+ pp = get_pp_group()
75
+ if pp.world_size < 2 or pp.is_first_rank:
76
+ return
77
+ inner = _inner_decoder(model)
78
+ if inner is None or not getattr(inner, "supports_aux_hidden_states_over_pp", False):
79
+ return
80
+
81
+ num_aux_states = inner._aux_slot_base_cached
82
+ if num_aux_states == 0:
83
+ return
84
+
85
+ key = inner.AUX_HIDDEN_STATE_KEY
86
+ hidden_size = inner.config.hidden_size
87
+ make_empty = model.make_empty_intermediate_tensors
88
+
89
+ def make_empty_with_aux(batch_size, dtype, device):
90
+ tensors = make_empty(batch_size, dtype, device)
91
+ for i in range(num_aux_states):
92
+ tensors[f"{key}{i}"] = torch.zeros(
93
+ (batch_size, hidden_size), dtype=dtype, device=device
94
+ )
95
+ return tensors
96
+
97
+ model.make_empty_intermediate_tensors = make_empty_with_aux
98
99
100
def get_eagle3_aux_layers_from_config(
vllm/v1/worker/gpu/spec_decode/eagle/utils.py
+37
-19
@@ -7,6 +7,7 @@ from vllm.config import VllmConfig, replace
7
from vllm.distributed.parallel_state import get_pp_group
8
from vllm.lora.layers.base import BaseLayerWithLoRA
9
from vllm.model_executor.model_loader import get_model
10
+from vllm.model_executor.models.utils import PPMissingLayer
11
12
13
def _should_share(eagle: nn.Module, flag: str, draft, target) -> bool:
@@ -33,6 +34,41 @@ def get_target_lm_head(target_model: nn.Module, target_language_model: nn.Module
34
)
35
36
37
+def maybe_share_target_embed(
38
+ draft_model: nn.Module, draft_inner: nn.Module, target_inner: nn.Module
39
+) -> None:
40
+ """Share the target input embedding with the drafter when needed."""
41
+ target_embed = getattr(target_inner, "embed_tokens", None) or getattr(
42
+ target_inner, "embedding", None
43
+ )
44
+ if isinstance(target_embed, PPMissingLayer):
45
+ target_embed = None
46
+ # The drafter does not use the target's LoRA adapter.
47
+ if isinstance(target_embed, BaseLayerWithLoRA):
48
+ target_embed = target_embed.base_layer
49
+ draft_embed = getattr(draft_inner, "embed_tokens", None)
50
+
51
+ if get_pp_group().world_size > 1 and not hasattr(
52
+ draft_model, "has_own_embed_tokens"
53
+ ):
54
+ return
55
+
56
+ if target_embed is None:
57
+ if hasattr(draft_inner, "embed_tokens") and not getattr(
58
+ draft_model, "has_own_embed_tokens", False
59
+ ):
60
+ raise RuntimeError(
61
+ f"{type(draft_model).__name__} needs the target input embedding, "
62
+ "but it is unavailable on this PP stage"
63
+ )
64
+ return
65
+
66
+ if _should_share(draft_model, "has_own_embed_tokens", draft_embed, target_embed):
67
+ if draft_embed is not None:
68
+ del draft_inner.embed_tokens
69
+ draft_inner.embed_tokens = target_embed
70
+
71
+
72
def load_eagle_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Module:
73
from vllm.compilation.backends import set_model_tag
74
@@ -70,25 +106,7 @@ def load_eagle_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Mod
106
target_inner = target_language_model.model
107
draft_inner = eagle_model.model
108
73
- # Skip embedding sharing under PP — each rank owns its own embedding.
74
- if get_pp_group().world_size == 1:
75
- target_embed = getattr(target_inner, "embed_tokens", None) or getattr(
76
- target_inner, "embedding", None
77
- )
78
- # If the target's embedding is LoRA-wrapped, share the underlying base
79
- # layer. The draft is not part of the LoRA adapter; sharing the wrapper
80
- # would make the draft run the LoRA embedding kernel with the target's
81
- # punica metadata (sized for the target's token count), causing an
82
- # out-of-bounds GPU access during multi-step draft decode.
83
- if isinstance(target_embed, BaseLayerWithLoRA):
84
- target_embed = target_embed.base_layer
85
- draft_embed = getattr(draft_inner, "embed_tokens", None)
86
- if target_embed is not None and _should_share(
87
- eagle_model, "has_own_embed_tokens", draft_embed, target_embed
88
- ):
89
- if draft_embed is not None:
90
- del draft_inner.embed_tokens
91
- draft_inner.embed_tokens = target_embed
109
+ maybe_share_target_embed(eagle_model, draft_inner, target_inner)
110
111
target_lm_head = get_target_lm_head(target_model, target_language_model)
112
draft_lm_head = getattr(eagle_model, "lm_head", None)