Skip to content

Commit e30a6f5

Browse files
committed
Support DSpark MTP under pipeline parallelism on RDNA2
Three guards refused the drafter under PP -- the draft config inherited the target's pipeline size, so a draft model without SupportsPP was rejected; the V2 runner raised for eagle3/dflash/dspark because their aux taps may sit on an earlier stage; and the DSpark loader aliases the target's embed_tokens, which is a PPMissingLayer anywhere but the first stage. Past those, warmup deadlocked: PPHandler.receive always allocates [num_reqs, max_sample_len] while the sampler returns a single column when there are no drafts to verify, so broadcast sent a narrower tensor, the ranks disagreed on the element count, and the stage that had moved on wedged in its next device sync. Give the draft pipeline_parallel_size=1 since it is built on the last stage only, let a model declare that it carries its EAGLE3 aux taps across the handoff, build the target's embedding on the last stage rather than draft with uninitialized weights, pin one broadcast payload width on both sides and send the next step's drafts in it through the filtered index mapping, and declare the AMD DSpark head's draft_id_to_target_id. Decode TPOT 43.9 -> 35.2 ms and output throughput 20.0 -> 24.3 tok/s against the same runner without speculation, at 19.8% draft acceptance. Upstream adds pipeline-parallel support for these drafters in vllm-project#50514; drop this commit when rebasing onto a tag that already contains it.
1 parent 5993735 commit e30a6f5

11 files changed

Lines changed: 628 additions & 24 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3+
"""Aux hidden-state transport across pipeline stages.
4+
5+
EAGLE3-style drafters (eagle3, dflash, dspark) consume auxiliary hidden states
6+
tapped from target layers. Under pipeline parallelism those layers can sit on an
7+
earlier stage than the drafter, which runs on the last rank, so the taps have to
8+
ride the intermediate-tensor handoff. Slot numbering is derived independently on
9+
every stage from the layer split, so these tests pin that the derivations agree
10+
-- a stage disagreeing by one silently feeds the drafter the wrong tap.
11+
"""
12+
13+
import pytest
14+
import torch
15+
16+
from vllm.model_executor.models.interfaces import EagleModelMixin
17+
from vllm.sequence import IntermediateTensors
18+
19+
20+
class _Config:
21+
def __init__(self, num_hidden_layers: int):
22+
self.num_hidden_layers = num_hidden_layers
23+
24+
25+
class _Model(EagleModelMixin):
26+
supports_aux_hidden_states_over_pp = True
27+
28+
def __init__(self, num_hidden_layers: int, aux_layers: tuple[int, ...]):
29+
self.config = _Config(num_hidden_layers)
30+
self.aux_hidden_state_layers = aux_layers
31+
32+
33+
@pytest.mark.parametrize(
34+
"start,end,aux_ids,is_first,expected",
35+
[
36+
# First stage also taps its own start layer, as the runner does.
37+
(0, 4, (0, 2, 4), True, (0, 2, 4)),
38+
(0, 4, (0, 2, 4), False, (2, 4)),
39+
# A later stage only claims taps whose layer it owns.
40+
(4, 8, (0, 2, 4), False, ()),
41+
(4, 8, (5, 8), False, (5, 8)),
42+
(4, 8, (), False, ()),
43+
],
44+
)
45+
def test_local_aux_tap_ids(start, end, aux_ids, is_first, expected):
46+
assert EagleModelMixin.local_aux_tap_ids(start, end, aux_ids, is_first) == expected
47+
48+
49+
def test_every_tap_is_claimed_by_exactly_one_stage():
50+
"""The per-stage split must partition the taps, with none lost or doubled."""
51+
num_layers, pp = 43, 2
52+
aux = (0, 21, 42)
53+
model = _Model(num_layers, aux)
54+
55+
from vllm.distributed.utils import get_pp_indices
56+
57+
claimed: list[int] = []
58+
for rank in range(pp):
59+
start, end = get_pp_indices(num_layers, rank, pp)
60+
claimed.extend(
61+
EagleModelMixin.local_aux_tap_ids(start, end, aux, rank == 0)
62+
)
63+
assert sorted(claimed) == sorted(aux)
64+
assert len(claimed) == len(set(claimed))
65+
# And the totals line up with the slot arithmetic.
66+
assert sum(model._num_local_taps_on_rank(r, pp) for r in range(pp)) == len(aux)
67+
68+
69+
def test_slot_numbering_is_contiguous_and_agreed():
70+
"""Each rank's base is the count of taps produced by all earlier ranks."""
71+
num_layers, pp = 43, 2
72+
model = _Model(num_layers, (0, 21, 42))
73+
74+
bases = [model._aux_slot_base(r, pp) for r in range(pp)]
75+
assert bases[0] == 0
76+
for r in range(1, pp):
77+
assert bases[r] == bases[r - 1] + model._num_local_taps_on_rank(r - 1, pp)
78+
79+
80+
def test_pack_local_aux_keys_by_global_slot():
81+
model = _Model(43, (0, 21, 42))
82+
model._aux_slot_base_cached = 2
83+
taps = [torch.zeros(1), torch.ones(1)]
84+
85+
packed = model.pack_local_aux_for_last(taps)
86+
87+
assert sorted(packed) == ["aux_hidden_states_2", "aux_hidden_states_3"]
88+
assert torch.equal(packed["aux_hidden_states_2"], taps[0])
89+
assert torch.equal(packed["aux_hidden_states_3"], taps[1])
90+
# Nothing to say when this stage owns no taps.
91+
assert model.pack_local_aux_for_last([]) == {}
92+
93+
94+
def test_recv_remote_aux_returns_producer_order():
95+
model = _Model(43, (0, 21, 42))
96+
model._aux_upstream_total_cached = 2
97+
a, b = torch.zeros(1), torch.ones(1)
98+
it = IntermediateTensors(
99+
{"aux_hidden_states_0": a, "aux_hidden_states_1": b, "hidden_states": a}
100+
)
101+
102+
got = model.recv_remote_aux_from_producers(it)
103+
104+
assert len(got) == 2
105+
assert torch.equal(got[0], a)
106+
assert torch.equal(got[1], b)
107+
108+
109+
def test_recv_remote_aux_raises_rather_than_zero_filling():
110+
"""A missing slot must fail loudly; zeros would only cost acceptance."""
111+
model = _Model(43, (0, 21, 42))
112+
model._aux_upstream_total_cached = 2
113+
it = IntermediateTensors({"aux_hidden_states_0": torch.zeros(1)})
114+
115+
with pytest.raises(RuntimeError, match="aux_hidden_states_1 missing"):
116+
model.recv_remote_aux_from_producers(it)
117+
118+
119+
def test_no_upstream_taps_needs_no_intermediate_tensors():
120+
"""At PP=1, or when every tap is local, the receive side is a no-op."""
121+
model = _Model(43, (0, 21, 42))
122+
model._aux_upstream_total_cached = 0
123+
assert model.recv_remote_aux_from_producers(None) == []
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3+
"""The PP sampled-token broadcast must agree on width between the two sides.
4+
5+
`PPHandler.receive` allocates `[num_reqs, max_sample_len + draft_token_width]`
6+
unconditionally, while the sampler hands `broadcast` a single column whenever
7+
there were no draft tokens to verify -- which is the case throughout warmup.
8+
Broadcasting the narrower tensor makes the ranks disagree on the element count,
9+
and the collective then never completes: the last rank sails on while the other
10+
blocks in the next device sync with its GPU spinning.
11+
12+
Without speculation `max_sample_len` is 1 and the two widths coincide, so this
13+
only bites with MTP enabled, which is why it is worth pinning here.
14+
"""
15+
16+
import pytest
17+
import torch
18+
19+
20+
def _payload_width(max_sample_len: int, draft_token_width: int) -> int:
21+
return max_sample_len + draft_token_width
22+
23+
24+
def _sender_payload(
25+
sampled_token_ids: torch.Tensor,
26+
max_sample_len: int,
27+
draft_token_width: int,
28+
draft_token_ids: torch.Tensor | None,
29+
) -> torch.Tensor:
30+
"""Mirrors PPHandler.broadcast's payload construction."""
31+
payload = sampled_token_ids.new_zeros(
32+
sampled_token_ids.shape[0], max_sample_len + draft_token_width
33+
)
34+
num_cols = min(sampled_token_ids.shape[1], max_sample_len)
35+
payload[:, :num_cols].copy_(sampled_token_ids[:, :num_cols])
36+
if draft_token_width:
37+
assert draft_token_ids is not None
38+
payload[:, max_sample_len:].copy_(draft_token_ids)
39+
return payload
40+
41+
42+
@pytest.mark.parametrize("num_speculative_steps", [0, 1, 3, 7])
43+
@pytest.mark.parametrize("sampler_cols", [1, None])
44+
def test_sender_width_matches_receiver_allocation(num_speculative_steps, sampler_cols):
45+
"""A one-column sampler output must still broadcast at the agreed width."""
46+
num_reqs = 4
47+
max_sample_len = num_speculative_steps + 1
48+
cols = sampler_cols if sampler_cols is not None else max_sample_len
49+
sampled = torch.arange(num_reqs * cols, dtype=torch.int64).view(num_reqs, cols)
50+
51+
payload = _sender_payload(sampled, max_sample_len, 0, None)
52+
53+
# This is exactly what receive() allocates.
54+
assert payload.shape == (num_reqs, _payload_width(max_sample_len, 0))
55+
# The columns the sampler did provide survive unchanged.
56+
assert torch.equal(payload[:, :cols], sampled[:, :cols])
57+
58+
59+
@pytest.mark.parametrize("num_speculative_steps", [1, 7])
60+
def test_draft_tokens_ride_the_same_payload(num_speculative_steps):
61+
num_reqs = 3
62+
max_sample_len = num_speculative_steps + 1
63+
draft_width = num_speculative_steps
64+
sampled = torch.full((num_reqs, 1), 5, dtype=torch.int64)
65+
drafts = torch.arange(num_reqs * draft_width, dtype=torch.int64).view(
66+
num_reqs, draft_width
67+
)
68+
69+
payload = _sender_payload(sampled, max_sample_len, draft_width, drafts)
70+
71+
assert payload.shape == (num_reqs, _payload_width(max_sample_len, draft_width))
72+
# Receiver splits at max_sample_len; both halves must come back intact.
73+
assert torch.equal(payload[:, :1], sampled)
74+
assert torch.equal(payload[:, max_sample_len:], drafts)
75+
76+
77+
def test_narrow_sampler_output_is_not_replicated_across_columns():
78+
"""Padding must be zeros, not a broadcast of the single sampled column.
79+
80+
`Tensor.copy_` broadcasts a [n, 1] source across a [n, k] destination, which
81+
would silently fill every speculative slot with the bonus token. num_sampled
82+
bounds what is read, but replicating real token ids into unused slots makes
83+
any later off-by-one read plausible-looking garbage instead of an obvious 0.
84+
"""
85+
num_reqs, max_sample_len = 2, 8
86+
sampled = torch.full((num_reqs, 1), 7, dtype=torch.int64)
87+
88+
payload = _sender_payload(sampled, max_sample_len, 0, None)
89+
90+
assert torch.equal(payload[:, :1], sampled)
91+
assert torch.equal(payload[:, 1:], torch.zeros(num_reqs, max_sample_len - 1,
92+
dtype=torch.int64))
93+
94+
95+
def test_stale_slot_rows_are_filtered_before_restore():
96+
"""Only still-valid rows may be scattered back into request state.
97+
98+
A pending PP entry is consumed pp_size steps after it is received; a request
99+
can finish in that window and its state index be handed to a new request.
100+
Restoring through the unfiltered mapping would write the finished request's
101+
drafts into whoever owns the index now.
102+
"""
103+
import numpy as np
104+
105+
idx_mapping_np = np.array([3, 1, 2], dtype=np.int32)
106+
exclude_mask = np.array([False, True, False]) # row 1 finished
107+
drafts = torch.tensor([[10, 11], [20, 21], [30, 31]], dtype=torch.int64)
108+
109+
valid_rows = np.flatnonzero(~exclude_mask)
110+
update_indices = np.stack((valid_rows, idx_mapping_np[valid_rows]))
111+
draft_rows = torch.from_numpy(update_indices[0]).to(torch.int64)
112+
draft_idx_mapping = torch.from_numpy(update_indices[1]).to(torch.int64)
113+
selected = drafts.index_select(0, draft_rows)
114+
115+
# Row 1 (state index 1) must not be written at all.
116+
assert draft_idx_mapping.tolist() == [3, 2]
117+
assert selected.tolist() == [[10, 11], [30, 31]]

vllm/config/speculative.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1258,7 +1258,11 @@ def create_draft_parallel_config(
12581258
This is mostly a copy of the target parallel config, except the tp_size.
12591259
"""
12601260
draft_parallel_config = ParallelConfig(
1261-
pipeline_parallel_size=target_parallel_config.pipeline_parallel_size,
1261+
# The drafter is built on the last pipeline stage only, so it never
1262+
# spans stages and must not inherit the target's pipeline size --
1263+
# doing so makes verify_with_parallel_config reject every draft
1264+
# model that does not implement SupportsPP, which is all of them.
1265+
pipeline_parallel_size=1,
12621266
tensor_parallel_size=speculative_draft_tensor_parallel_size,
12631267
distributed_executor_backend=target_parallel_config.distributed_executor_backend,
12641268
max_parallel_loading_workers=target_parallel_config.max_parallel_loading_workers,

vllm/model_executor/models/interfaces.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1399,8 +1399,41 @@ def get_top_tokens(self, hidden_states: torch.Tensor) -> torch.Tensor:
13991399
class EagleModelMixin:
14001400
aux_hidden_state_layers: tuple[int, ...] = ()
14011401

1402+
# Set by models that pack their own aux taps into the forward output so the
1403+
# runner can carry them across the pipeline handoff.
1404+
supports_aux_hidden_states_over_pp: ClassVar[bool] = False
1405+
1406+
AUX_HIDDEN_STATE_KEY: ClassVar[str] = "aux_hidden_states_"
1407+
1408+
# Resolved once at setup: get_pp_indices logs on an uneven split, and dynamo
1409+
# cannot trace logging from inside a forward.
1410+
_aux_slot_base_cached: int = 0
1411+
_aux_upstream_total_cached: int = 0
1412+
14021413
def _set_aux_hidden_state_layers(self, layers: tuple[int, ...]) -> None:
14031414
self.aux_hidden_state_layers = layers
1415+
self._cache_aux_pp_layout()
1416+
1417+
def _cache_aux_pp_layout(self) -> None:
1418+
"""Resolve this stage's aux slot numbering, off the forward path."""
1419+
from vllm.distributed.parallel_state import (
1420+
get_pp_group,
1421+
model_parallel_is_initialized,
1422+
)
1423+
1424+
# Models are also built outside a worker, where there is no PP group and
1425+
# nothing to forward.
1426+
if not model_parallel_is_initialized():
1427+
return
1428+
pp = get_pp_group()
1429+
if pp.world_size < 2:
1430+
return
1431+
self._aux_slot_base_cached = self._aux_slot_base(
1432+
pp.rank_in_group, pp.world_size
1433+
)
1434+
self._aux_upstream_total_cached = self._aux_slot_base(
1435+
pp.world_size - 1, pp.world_size
1436+
)
14041437

14051438
def _maybe_add_hidden_state(
14061439
self,
@@ -1414,6 +1447,90 @@ def _maybe_add_hidden_state(
14141447
aux_hidden_states.append(value)
14151448
return aux_hidden_states
14161449

1450+
@staticmethod
1451+
def local_aux_tap_ids(
1452+
start_layer: int,
1453+
end_layer: int,
1454+
aux_ids: tuple[int, ...],
1455+
is_first_rank: bool,
1456+
) -> tuple[int, ...]:
1457+
"""Tap ids this stage produces itself, excluding inherited ones."""
1458+
out: list[int] = []
1459+
if is_first_rank and start_layer in aux_ids:
1460+
out.append(start_layer)
1461+
for layer_idx in range(start_layer, end_layer):
1462+
if (layer_idx + 1) in aux_ids:
1463+
out.append(layer_idx + 1)
1464+
return tuple(out)
1465+
1466+
def _total_num_layers(self) -> int:
1467+
num_layers = getattr(getattr(self, "config", None), "num_hidden_layers", None)
1468+
if num_layers is None:
1469+
raise RuntimeError(
1470+
"aux-over-PP transport needs config.num_hidden_layers on the model"
1471+
)
1472+
return num_layers
1473+
1474+
def _num_local_taps_on_rank(self, rank: int, pp_world_size: int) -> int:
1475+
"""Taps stage ``rank`` produces, derived without building that stage."""
1476+
from vllm.distributed.utils import get_pp_indices
1477+
1478+
start, end = get_pp_indices(self._total_num_layers(), rank, pp_world_size)
1479+
return len(
1480+
self.local_aux_tap_ids(
1481+
start, end, tuple(self.aux_hidden_state_layers), rank == 0
1482+
)
1483+
)
1484+
1485+
def _aux_slot_base(self, rank: int, pp_world_size: int) -> int:
1486+
"""Global slot index of ``rank``'s first tap.
1487+
1488+
One slot per tap, ordered by producing rank. Every stage derives the same
1489+
numbering from the layer split, so the slots need no negotiation.
1490+
"""
1491+
return sum(self._num_local_taps_on_rank(r, pp_world_size) for r in range(rank))
1492+
1493+
def pack_local_aux_for_last(
1494+
self, aux_hidden_states: list[torch.Tensor]
1495+
) -> dict[str, torch.Tensor]:
1496+
"""Expose this stage's taps to the runner, keyed by global slot.
1497+
1498+
Pure packing, so the forward stays capturable by a full CUDA graph.
1499+
"""
1500+
if not aux_hidden_states:
1501+
return {}
1502+
base = self._aux_slot_base_cached
1503+
return {
1504+
f"{self.AUX_HIDDEN_STATE_KEY}{base + i}": t
1505+
for i, t in enumerate(aux_hidden_states)
1506+
}
1507+
1508+
def recv_remote_aux_from_producers(
1509+
self, intermediate_tensors: "IntermediateTensors | None"
1510+
) -> list[torch.Tensor]:
1511+
"""Collect earlier stages' taps on the last rank, in tap order.
1512+
1513+
The handoff has already landed them in the persistent buffer, so reading
1514+
fixed slots keeps the forward capturable by a full CUDA graph.
1515+
"""
1516+
total = self._aux_upstream_total_cached
1517+
if total == 0:
1518+
return []
1519+
1520+
assert intermediate_tensors is not None
1521+
out: list[torch.Tensor] = []
1522+
for i in range(total):
1523+
key = f"{self.AUX_HIDDEN_STATE_KEY}{i}"
1524+
if key not in intermediate_tensors.tensors:
1525+
# Zero-filling here would silently cost acceptance rate.
1526+
raise RuntimeError(
1527+
f"{key} missing from the last stage's intermediate tensors; "
1528+
"the aux slots were not reserved (got "
1529+
f"{sorted(intermediate_tensors.tensors)})"
1530+
)
1531+
out.append(intermediate_tensors[key])
1532+
return out
1533+
14171534

14181535
@runtime_checkable
14191536
class SupportsEagle(SupportsEagleBase, Protocol):

0 commit comments

Comments
 (0)