[DSpark] Support pipeline-parallel targets in aggregated serving (#56956)

Signed-off-by: Zihua Wu <13583761+lucifer1004@users.noreply.github.com> Co-authored-by: Kimi Code <noreply@moonshot.cn> Co-authored-by: Yongye Zhu <zyy1102000@gmail.com>

Gabriel Wu committed Sep 23, 2026 at 19:52 UTC 157bcb7c489689dd34cf28d9c9970a465d326a03
11 files changed +629 -28
tests/kernels/moe/test_topk_softplus_sqrt.py
+130
@@ -339,6 +339,136 @@ def test_dsv4_fast_topk(
339 )
340
341
342 +@pytest.mark.skipif(
343 + not current_platform.is_cuda(),
344 + reason="The DeepSeek V4 fast path is CUDA-only.",
345 +)
346 +def test_dsv4_fast_topk_padding_uint32_falls_back(monkeypatch: pytest.MonkeyPatch):
347 + """Padded rows need the -1 sentinel, which uint32 cannot represent: the
348 + router must skip the dsv4 fast path and still route the real rows."""
349 + torch.manual_seed(0)
350 + num_tokens = 17
351 + num_experts = 256
352 + hidden_states = torch.randn((num_tokens, 64), dtype=torch.float32, device="cuda")
353 + gating_output = torch.randn(
354 + (num_tokens, num_experts), dtype=torch.float32, device="cuda"
355 + )
356 + correction_bias = torch.randn(num_experts, dtype=torch.float32, device="cuda")
357 + is_padding = torch.zeros(num_tokens, dtype=torch.bool, device="cuda")
358 + is_padding[1::2] = True
359 + gating_output[is_padding] = float("nan")
360 +
361 + monkeypatch.setattr(
362 + "vllm.model_executor.layers.fused_moe.router."
363 + "fused_topk_bias_router._get_padding_mask",
364 + lambda _: is_padding,
365 + )
366 + # uint32 + padding would trip dsv4_topk's signed-indices assertion; the
367 + # generic path must take over instead.
368 + topk_weights, topk_ids = fused_topk_bias(
369 + hidden_states=hidden_states,
370 + gating_output=gating_output,
371 + scoring_func="sqrtsoftplus",
372 + e_score_correction_bias=correction_bias,
373 + topk=6,
374 + renormalize=True,
375 + indices_type=torch.uint32,
376 + routed_scaling_factor=1.5,
377 + )
378 +
379 + assert topk_ids.dtype == torch.uint32
380 + topk_weights_ref, topk_ids_ref = _torch_topk_softplus_sqrt(
381 + gating_output=gating_output[~is_padding],
382 + topk=6,
383 + renormalize=True,
384 + routed_scaling_factor=1.5,
385 + e_score_correction_bias=correction_bias,
386 + )
387 + # uint32 CUDA tensors do not support boolean-mask indexing; widen first.
388 + torch.testing.assert_close(
389 + topk_ids.to(torch.int64)[~is_padding],
390 + topk_ids_ref.to(torch.int64),
391 + atol=0,
392 + rtol=0,
393 + )
394 + torch.testing.assert_close(
395 + topk_weights[~is_padding], topk_weights_ref, atol=2e-5, rtol=2e-5
396 + )
397 +
398 +
399 +@pytest.mark.skipif(
400 + not current_platform.is_cuda(),
401 + reason="The DeepSeek V4 fast path is CUDA-only.",
402 +)
403 +def test_dsv4_fast_topk_padding(monkeypatch: pytest.MonkeyPatch):
404 + """Verify the DSV4 fast path removes graph-padding rows from routing."""
405 + torch.manual_seed(0)
406 + num_tokens = 17
407 + num_experts = 256
408 + hidden_states = torch.randn((num_tokens, 64), dtype=torch.float32, device="cuda")
409 + gating_output = torch.randn(
410 + (num_tokens, num_experts), dtype=torch.float32, device="cuda"
411 + )
412 + correction_bias = torch.randn(num_experts, dtype=torch.float32, device="cuda")
413 + is_padding = torch.zeros(num_tokens, dtype=torch.bool, device="cuda")
414 + is_padding[1::2] = True
415 + gating_output[is_padding] = float("nan")
416 +
417 + monkeypatch.setattr(
418 + "vllm.model_executor.layers.fused_moe.router."
419 + "fused_topk_bias_router._get_padding_mask",
420 + lambda _: is_padding,
421 + )
422 + topk_weights, topk_ids = fused_topk_bias(
423 + hidden_states=hidden_states,
424 + gating_output=gating_output,
425 + scoring_func="sqrtsoftplus",
426 + e_score_correction_bias=correction_bias,
427 + topk=6,
428 + renormalize=True,
429 + routed_scaling_factor=1.5,
430 + )
431 +
432 + assert torch.equal(topk_ids[is_padding], torch.full_like(topk_ids[is_padding], -1))
433 + assert torch.equal(
434 + topk_weights[is_padding], torch.zeros_like(topk_weights[is_padding])
435 + )
436 +
437 + topk_weights_ref, topk_ids_ref = _torch_topk_softplus_sqrt(
438 + gating_output=gating_output[~is_padding],
439 + topk=6,
440 + renormalize=True,
441 + routed_scaling_factor=1.5,
442 + e_score_correction_bias=correction_bias,
443 + )
444 + torch.testing.assert_close(topk_ids[~is_padding], topk_ids_ref, atol=0, rtol=0)
445 + torch.testing.assert_close(
446 + topk_weights[~is_padding], topk_weights_ref, atol=2e-5, rtol=2e-5
447 + )
448 +
449 + # The mask buffer is persistent under CUDA graph replay, but its contents
450 + # change with every batch. Verify that the kernel reads those contents at
451 + # runtime rather than specializing on the mask captured above.
452 + graph = torch.cuda.CUDAGraph()
453 + with torch.cuda.graph(graph):
454 + graph_weights, graph_ids = dsv4_topk(
455 + gating_output,
456 + correction_bias,
457 + torch.int32,
458 + 1.5,
459 + is_padding=is_padding,
460 + )
461 +
462 + is_padding.logical_not_()
463 + graph.replay()
464 + assert torch.equal(
465 + graph_ids[is_padding], torch.full_like(graph_ids[is_padding], -1)
466 + )
467 + assert torch.equal(
468 + graph_weights[is_padding], torch.zeros_like(graph_weights[is_padding])
469 + )
470 +
471 +
472 @pytest.mark.skipif(
473 not current_platform.is_cuda_alike(),
474 reason="This test is skipped on non-CUDA platform.",
tests/v1/spec_decode/test_dflash_prepare_inputs.py
+149
@@ -1,6 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +from contextlib import contextmanager
5 from types import SimpleNamespace
6
7 import numpy as np
@@ -8,7 +9,9 @@ import pytest
9 import torch
10
11 from vllm.v1.attention.backends.utils import PAD_SLOT_ID
12 +from vllm.v1.worker.gpu.spec_decode.dflash import speculator as dflash_speculator
13 from vllm.v1.worker.gpu.spec_decode.dflash.speculator import (
14 + DFlashSpeculator,
15 prepare_dflash_inputs,
16 )
17
@@ -33,6 +36,7 @@ def _run_prepare(
36 input_buffers = SimpleNamespace(
37 input_ids=torch.full((max_num_tokens,), -1, dtype=torch.int32, device=device),
38 positions=torch.full((max_num_tokens,), -1, dtype=torch.int64, device=device),
39 + is_padding=torch.zeros(max_num_tokens, dtype=torch.bool, device=device),
40 query_start_loc=torch.full(
41 (max_num_reqs + 1,), -1, dtype=torch.int32, device=device
42 ),
@@ -140,6 +144,114 @@ def test_prepare_dflash_inputs_excludes_rejected_context_suffix():
144 assert out.temperature[2].item() == 1.0
145 assert out.seeds[2].item() == 17
146
147 + assert not out.input_buffers.is_padding[:3].any()
148 + assert out.input_buffers.is_padding[3:].all()
149 + assert out.input_buffers.input_ids[3:].cpu().tolist() == [0] * 13
150 + assert out.input_buffers.positions[3:].cpu().tolist() == [0] * 13
151 +
152 +
153 +def test_prepare_dflash_inputs_compacts_noncontiguous_request_slots():
154 + device = torch.device("cuda")
155 + max_num_reqs = 4
156 + max_num_tokens = 16
157 + num_speculative_steps = 3
158 + input_buffers = SimpleNamespace(
159 + input_ids=torch.full((max_num_tokens,), -1, dtype=torch.int32, device=device),
160 + positions=torch.full((max_num_tokens,), -1, dtype=torch.int64, device=device),
161 + is_padding=torch.zeros(max_num_tokens, dtype=torch.bool, device=device),
162 + query_start_loc=torch.full(
163 + (max_num_reqs + 1,), -1, dtype=torch.int32, device=device
164 + ),
165 + seq_lens=torch.full((max_num_reqs,), -1, dtype=torch.int32, device=device),
166 + )
167 + input_batch = SimpleNamespace(
168 + num_reqs=2,
169 + num_scheduled_tokens=np.array([4, 4], dtype=np.int32),
170 + positions=torch.tensor(
171 + [10, 11, 12, 13, 20, 21, 22, 23],
172 + dtype=torch.int64,
173 + device=device,
174 + ),
175 + query_start_loc=torch.tensor([0, 4, 8], dtype=torch.int32, device=device),
176 + # Active batch rows are compact, while request state remains in slots 3 and 1.
177 + idx_mapping=torch.tensor([3, 1], dtype=torch.int32, device=device),
178 + )
179 + query_slot_mapping = torch.full(
180 + (max_num_tokens,), -2, dtype=torch.int64, device=device
181 + )
182 + context_positions = torch.full(
183 + (max_num_tokens,), -1, dtype=torch.int64, device=device
184 + )
185 + context_slot_mapping = torch.full(
186 + (max_num_tokens,), -2, dtype=torch.int64, device=device
187 + )
188 + sample_indices = torch.full(
189 + (max_num_reqs * num_speculative_steps,),
190 + -1,
191 + dtype=torch.int64,
192 + device=device,
193 + )
194 + sample_pos = torch.full_like(sample_indices, -1)
195 + sample_idx_mapping = torch.full(
196 + sample_indices.shape, -1, dtype=torch.int32, device=device
197 + )
198 + temperature = torch.zeros(max_num_reqs, dtype=torch.float32, device=device)
199 + seeds = torch.zeros(max_num_reqs, dtype=torch.int64, device=device)
200 + input_temperature = torch.tensor(
201 + [0.0, 0.5, 0.0, 1.0], dtype=torch.float32, device=device
202 + )
203 + input_seeds = torch.tensor([0, 11, 0, 33], dtype=torch.int64, device=device)
204 + last_sampled = torch.tensor([0, 77, 0, 99], dtype=torch.int64, device=device)
205 + next_prefill_tokens = torch.zeros_like(last_sampled)
206 + block_table = torch.tensor(
207 + [[0, 0, 7, 8, 9, 10, 11, 12], [0, 0, 13, 14, 15, 16, 17, 18]],
208 + dtype=torch.int32,
209 + device=device,
210 + )
211 +
212 + prepare_dflash_inputs(
213 + input_buffers,
214 + query_slot_mapping,
215 + context_positions,
216 + context_slot_mapping,
217 + sample_indices,
218 + sample_pos,
219 + sample_idx_mapping,
220 + temperature,
221 + seeds,
222 + input_batch,
223 + torch.tensor([1, 1], dtype=torch.int32, device=device),
224 + torch.tensor([2, 1], dtype=torch.int32, device=device),
225 + last_sampled,
226 + next_prefill_tokens,
227 + input_temperature,
228 + input_seeds,
229 + block_table,
230 + 4,
231 + 0,
232 + 1,
233 + 1,
234 + 123,
235 + num_speculative_steps,
236 + num_speculative_steps,
237 + max_num_reqs,
238 + max_num_tokens,
239 + 128,
240 + sample_from_anchor=True,
241 + )
242 + torch.accelerator.synchronize()
243 +
244 + # Query rows follow compact batch order, but every persistent state lookup
245 + # follows idx_mapping instead of accidentally using the compact row index.
246 + assert input_buffers.input_ids[:6].cpu().tolist() == [99, 123, 123, 77, 123, 123]
247 + assert input_buffers.positions[:6].cpu().tolist() == [12, 13, 14, 23, 24, 25]
248 + assert sample_indices[:6].cpu().tolist() == [0, 1, 2, 3, 4, 5]
249 + assert sample_idx_mapping[:6].cpu().tolist() == [3, 3, 3, 1, 1, 1]
250 + assert temperature.cpu().tolist() == [0.0, 0.5, 0.0, 1.0]
251 + assert seeds.cpu().tolist() == [0, 11, 0, 33]
252 + assert not input_buffers.is_padding[:6].any()
253 + assert input_buffers.is_padding[6:].all()
254 +
255
256 def test_prepare_dflash_inputs_excludes_rejected_context_suffix_with_dcp():
257 out = _run_prepare(
@@ -174,3 +286,40 @@ def test_prepare_dflash_inputs_never_writes_the_null_block():
286 PAD_SLOT_ID,
287 PAD_SLOT_ID,
288 ]
289 +
290 +
291 +def test_dflash_forward_context_receives_draft_padding_mask(monkeypatch):
292 + device = torch.device("cuda")
293 + input_buffers = SimpleNamespace(
294 + input_ids=torch.tensor([11, 12, 0, 0], dtype=torch.int32, device=device),
295 + positions=torch.tensor([7, 8, 0, 0], dtype=torch.int64, device=device),
296 + is_padding=torch.tensor([False, False, True, True], device=device),
297 + )
298 + observed = None
299 +
300 + @contextmanager
301 + def fake_set_forward_context(*args, **kwargs):
302 + nonlocal observed
303 + observed = kwargs["is_padding"].clone()
304 + yield
305 +
306 + monkeypatch.setattr(
307 + dflash_speculator, "set_forward_context", fake_set_forward_context
308 + )
309 + speculator = SimpleNamespace(
310 + input_buffers=input_buffers,
311 + vllm_config=SimpleNamespace(),
312 + model=lambda **kwargs: kwargs["input_ids"],
313 + )
314 +
315 + result = DFlashSpeculator._run_model(
316 + speculator,
317 + num_tokens=4,
318 + attn_metadata=None,
319 + slot_mappings=None,
320 + num_tokens_across_dp=None,
321 + )
322 +
323 + assert result.tolist() == [11, 12, 0, 0]
324 + assert observed is not None
325 + assert observed.tolist() == [False, False, True, True]
tests/v1/worker/test_pp_utils.py
+149 -3
@@ -5,16 +5,35 @@
5 from unittest.mock import Mock
6
7 import numpy as np
8 +import pytest
9 +import torch
10
9 -from vllm.v1.worker.gpu import pp_utils
11 +from vllm.v1.worker.gpu import model_runner, pp_utils
12
13
12 -def _batch(num_computed, prefill_len, num_scheduled):
14 +def _cuda_handler(max_sample_len=6):
15 + handler = object.__new__(pp_utils.PPHandler)
16 + handler.is_last_rank = True
17 + handler.disabled = False
18 + handler.max_sample_len = max_sample_len
19 + handler.last_rank = 1
20 + handler.broadcast_group = Mock()
21 + handler.device = torch.device("cuda")
22 + handler.main_stream = torch.cuda.current_stream()
23 + handler.broadcast_stream = torch.cuda.Stream()
24 + return handler
25 +
26 +
27 +def _batch(num_computed, prefill_len, num_scheduled, idx_mapping=None):
28 + num_reqs = len(num_computed)
29 + if idx_mapping is None:
30 + idx_mapping = list(range(num_reqs))
31 return Mock(
14 - num_reqs=len(num_computed),
32 + num_reqs=num_reqs,
33 num_computed_tokens_np=np.array(num_computed, dtype=np.int32),
34 prefill_len_np=np.array(prefill_len, dtype=np.int32),
35 num_scheduled_tokens=np.array(num_scheduled, dtype=np.int32),
36 + idx_mapping=torch.tensor(idx_mapping, dtype=torch.int64),
37 )
38
39
@@ -81,3 +100,130 @@ def test_decode_row_ahead_of_a_prefill_chunk():
100
101 assert mask is not None
102 assert mask.tolist() == [True, False]
103 +
104 +
105 +def test_disabled_handler_skips_broadcast_and_receive(monkeypatch):
106 + """While disabled (warmup), neither side enqueues a broadcast op."""
107 + sent = []
108 + monkeypatch.setattr(
109 + pp_utils.torch.distributed,
110 + "broadcast",
111 + lambda *args, **kwargs: sent.append((args, kwargs)),
112 + )
113 +
114 + handler = object.__new__(pp_utils.PPHandler)
115 + handler.set_disabled(True)
116 +
117 + handler.is_last_rank = False
118 + assert handler.receive(Mock()) is False
119 +
120 + handler.is_last_rank = True
121 + assert handler.broadcast(Mock(), Mock(), Mock(), Mock()) is None
122 +
123 + assert sent == []
124 +
125 + handler.set_disabled(False)
126 + assert handler.disabled is False
127 +
128 +
129 +def test_alloc_combined_keeps_unbind_views_16_byte_aligned():
130 + """Triton specializes on pointer alignment: an unaligned `num_rejected`
131 + would compile a second `_post_update_kernel` variant at serving time,
132 + where the in-flight broadcast NCCL kernel can block the module load."""
133 + for num_reqs in range(1, 9):
134 + combined = pp_utils._alloc_combined(num_reqs, torch.device("cpu"))
135 + num_sampled, num_rejected = combined.unbind(dim=0)
136 + assert num_sampled.data_ptr() % 16 == 0
137 + assert num_rejected.data_ptr() % 16 == 0
138 + assert combined.shape[1] >= num_reqs
139 +
140 +
141 +def test_warmup_pp_decode_update_matches_serving_specialization(monkeypatch):
142 + """The warmup launch must hit the same triton specialization as serving.
143 +
144 + A mismatch means the first real ``update_pp_decode_requests`` recompiles
145 + mid-serving, where the in-flight broadcast NCCL kernel blocks the CUDA
146 + module load and deadlocks the pipeline.
147 + """
148 + calls = []
149 + monkeypatch.setattr(model_runner, "post_update", lambda *args: calls.append(args))
150 +
151 + runner = object.__new__(model_runner.GPUModelRunner)
152 + runner.device = torch.device("cpu")
153 + runner.pp_handler = Mock(max_sample_len=3)
154 + runner.req_states = Mock()
155 +
156 + runner.warmup_pp_decode_update()
157 +
158 + assert len(calls) == 1
159 + args = calls[0]
160 + idx_mapping, _, _, output_bin_counts = args[:4]
161 + sampled_tokens, num_sampled, num_rejected, query_start_loc = args[4:8]
162 + broadcast_drafts, draft_tokens_out = args[10:12]
163 + assert idx_mapping.tolist() == [-1] and idx_mapping.dtype == torch.int64
164 + assert output_bin_counts is None
165 + assert query_start_loc is None
166 + assert sampled_tokens.shape == (1, 3) and sampled_tokens.dtype == torch.int64
167 + assert num_sampled.dtype == torch.int32
168 + assert num_rejected.dtype == torch.int32
169 + # Spec-enabled PP handlers receive drafts over the broadcast; the warmup
170 + # must compile that specialization (non-None draft pointers) too.
171 + assert broadcast_drafts.shape == (1, 2) and broadcast_drafts.dtype == torch.int64
172 + assert draft_tokens_out is runner.req_states.draft_tokens
173 +
174 +
175 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="needs a CUDA stream")
176 +def test_broadcast_pads_plain_sampler_rows_to_max_sample_len(monkeypatch):
177 + """The wire shape must not depend on whether the batch carried drafts:
178 + the receiver always allocates [num_reqs, max_sample_len], and a NCCL
179 + broadcast with mismatched counts hangs the receiver."""
180 + sent = []
181 + monkeypatch.setattr(
182 + pp_utils.torch.distributed,
183 + "broadcast",
184 + lambda tensor, **kw: sent.append(tensor),
185 + )
186 + handler = _cuda_handler()
187 + batch = _batch(num_computed=[10], prefill_len=[8], num_scheduled=[1])
188 +
189 + handler.broadcast(
190 + torch.zeros(1, 1, dtype=torch.int64, device="cuda"), # plain sampler
191 + torch.ones(1, dtype=torch.int32, device="cuda"),
192 + torch.zeros(1, dtype=torch.int32, device="cuda"),
193 + batch,
194 + )
195 +
196 + assert sent[0].shape == (1, 6)
197 + assert sent[1].shape == (2, 4)
198 + torch.accelerator.synchronize()
199 +
200 +
201 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="needs a CUDA stream")
202 +def test_broadcast_drafts_gathers_fresh_rows_from_the_table(monkeypatch):
203 + sent = []
204 + monkeypatch.setattr(
205 + pp_utils.torch.distributed,
206 + "broadcast",
207 + lambda tensor, **kw: sent.append(tensor),
208 + )
209 + handler = _cuda_handler()
210 + # The batch's single row maps to request-state row 2.
211 + batch = _batch(num_computed=[10], prefill_len=[8], num_scheduled=[1])
212 + batch.idx_mapping = torch.tensor([2], dtype=torch.int64, device="cuda")
213 + table = torch.arange(20, dtype=torch.int64, device="cuda").view(4, 5)
214 +
215 + handler.broadcast_drafts(table, batch)
216 +
217 + assert sent[0].shape == (1, 5)
218 + # The payload is a gather into a fresh tensor: propose() overwrites its
219 + # persistent buffer on the next step, possibly before this send completes.
220 + assert sent[0].data_ptr() != table.data_ptr()
221 + assert sent[0].cpu().tolist() == [table[2].cpu().tolist()]
222 +
223 + # An all-prefill batch sends nothing (receive() enqueues nothing either).
224 + sent.clear()
225 + prefill_batch = _batch(num_computed=[0], prefill_len=[4096], num_scheduled=[448])
226 + prefill_batch.idx_mapping = prefill_batch.idx_mapping.cuda()
227 + handler.broadcast_drafts(table, prefill_batch)
228 + assert sent == []
229 + torch.accelerator.synchronize()
vllm/model_executor/layers/fused_moe/router/dsv4_topk.py
+18
@@ -41,6 +41,7 @@ if current_platform.is_cuda():
41 def _dsv4_topk_kernel(
42 gating_output_ptr,
43 correction_bias_ptr,
44 + is_padding_ptr,
45 topk_weights_ptr,
46 topk_ids_ptr,
47 routed_scaling_factor,
@@ -49,6 +50,7 @@ if current_platform.is_cuda():
50 image_sentinel_lo,
51 NUM_EXPERTS: tl.constexpr,
52 BLOCK_N: tl.constexpr,
53 + HAS_PADDING: tl.constexpr,
54 HAS_VL: tl.constexpr,
55 launch_pdl: tl.constexpr,
56 ):
@@ -106,6 +108,11 @@ if current_platform.is_cuda():
108 output_mask = topk_offsets < 6
109 output_offsets = row * 6 + topk_offsets
110
111 + if HAS_PADDING:
112 + is_padding = tl.load(is_padding_ptr + row)
113 + selected_weights = tl.where(is_padding, 0.0, selected_weights)
114 + selected_ids = tl.where(is_padding, -1, selected_ids)
115 +
116 if launch_pdl:
117 tl.extra.cuda.gdc_launch_dependents()
118
@@ -118,11 +125,20 @@ def dsv4_topk(
125 correction_bias: torch.Tensor,
126 indices_dtype: torch.dtype,
127 routed_scaling_factor: float,
128 + is_padding: torch.Tensor | None = None,
129 input_ids: torch.Tensor | None = None,
130 bias_vl: torch.Tensor | None = None,
131 image_sentinel_lo: int = 0,
132 ) -> tuple[torch.Tensor, torch.Tensor]:
133 num_tokens, num_experts = gating_output.shape
134 + if is_padding is not None:
135 + assert is_padding.dtype == torch.bool
136 + assert is_padding.shape == (num_tokens,)
137 + assert is_padding.device == gating_output.device
138 + assert is_padding.is_contiguous()
139 + assert indices_dtype in (torch.int32, torch.int64), (
140 + "Padding requires a signed indices dtype for the -1 sentinel."
141 + )
142 has_vl = bias_vl is not None and image_sentinel_lo > 0
143 if bias_vl is not None:
144 assert input_ids is not None, "bias_vl routing requires input_ids"
@@ -136,6 +152,7 @@ def dsv4_topk(
152 _dsv4_topk_kernel[(num_tokens,)](
153 gating_output,
154 correction_bias,
155 + is_padding,
156 topk_weights,
157 topk_ids,
158 routed_scaling_factor,
@@ -144,6 +161,7 @@ def dsv4_topk(
161 image_sentinel_lo,
162 NUM_EXPERTS=num_experts,
163 BLOCK_N=triton.next_power_of_2(num_experts),
164 + HAS_PADDING=is_padding is not None,
165 HAS_VL=has_vl,
166 num_warps=1,
167 launch_pdl=current_platform.is_arch_support_pdl(),
vllm/model_executor/layers/fused_moe/router/fused_topk_bias_router.py
+5
@@ -148,9 +148,13 @@ def fused_topk_bias(
148 )
149
150 output_indices_dtype = torch.int32 if indices_type is None else indices_type
151 + padding_mask = _get_padding_mask(gating_output.shape[0])
152 if (
153 scoring_func == "sqrtsoftplus"
154 and hash_indices_table is None
155 + # dsv4_topk marks padded rows with a -1 sentinel, which unsigned
156 + # indices cannot represent; keep the generic path for that pair.
157 + and (padding_mask is None or output_indices_dtype != torch.uint32)
158 and can_use_dsv4_topk(
159 gating_output,
160 e_score_correction_bias,
@@ -165,6 +169,7 @@ def fused_topk_bias(
169 e_score_correction_bias,
170 output_indices_dtype,
171 routed_scaling_factor,
172 + is_padding=padding_mask,
173 input_ids=input_tokens,
174 bias_vl=bias_vl,
175 image_sentinel_lo=image_sentinel_lo,
vllm/v1/worker/gpu/input_batch.py
+26
@@ -555,6 +555,11 @@ def _post_update_kernel(
555 all_token_ids_ptr,
556 all_token_ids_stride,
557 total_len_ptr,
558 + broadcast_drafts_ptr,
559 + broadcast_drafts_stride,
560 + draft_tokens_ptr,
561 + draft_tokens_stride,
562 + num_spec,
563 ):
564 req_id = tl.program_id(0)
565 req_state_idx = tl.load(idx_mapping_ptr + req_id)
@@ -562,6 +567,17 @@ def _post_update_kernel(
567 # Filter rows with negative index entries.
568 return
569
570 + if broadcast_drafts_ptr is not None:
571 + # PP path: adopt the draft tokens proposed by the last rank's
572 + # speculator so the next verification step embeds the real drafts.
573 + for i in range(num_spec):
574 + token_id = tl.load(
575 + broadcast_drafts_ptr + req_id * broadcast_drafts_stride + i
576 + )
577 + tl.store(
578 + draft_tokens_ptr + req_state_idx * draft_tokens_stride + i, token_id
579 + )
580 +
581 total_len = tl.load(total_len_ptr + req_state_idx)
582 num_sampled = tl.load(num_sampled_ptr + req_id)
583 if num_sampled > 0:
@@ -622,6 +638,11 @@ def post_update(
638 all_token_ids: torch.Tensor,
639 # [max_num_reqs]
640 total_len: torch.Tensor,
641 + # [num_reqs, num_spec]; drafts broadcast from the last PP rank. Only
642 + # passed on non-last PP ranks, which never run the speculator.
643 + broadcast_drafts: torch.Tensor | None = None,
644 + # [max_num_reqs, num_spec]
645 + draft_tokens_out: torch.Tensor | None = None,
646 ) -> None:
647 num_reqs = idx_mapping.shape[0]
648 _post_update_kernel[(num_reqs,)](
@@ -638,6 +659,11 @@ def post_update(
659 all_token_ids,
660 all_token_ids.stride(0),
661 total_len,
662 + broadcast_drafts,
663 + broadcast_drafts.stride(0) if broadcast_drafts is not None else 0,
664 + draft_tokens_out,
665 + draft_tokens_out.stride(0) if draft_tokens_out is not None else 0,
666 + broadcast_drafts.shape[1] if broadcast_drafts is not None else 0,
667 num_warps=1,
668 )
669
vllm/v1/worker/gpu/model_runner.py
+63 -5
@@ -860,6 +860,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
860 # dummy run the eagle speculator's propose to ensure DP/EP sync.
861 if self.speculator is not None:
862 assert self.sampler is not None
863 + assert hidden_states is not None
864 self.step_timing.drafter_start()
865 mm_inputs: tuple[list[torch.Tensor], torch.Tensor] | None = None
866 if self.speculator.supports_mm_inputs:
@@ -875,7 +876,8 @@ class GPUModelRunner(LoRAModelRunnerMixin):
876 spec_hidden_states = hidden_states
877 if hasattr(self.model, "get_mtp_target_hidden_states"):
878 pre_hc_hidden_states = self.model.get_mtp_target_hidden_states()
878 - spec_hidden_states = pre_hc_hidden_states[: hidden_states.shape[0]] # type: ignore[union-attr]
879 + if pre_hc_hidden_states is not None:
880 + spec_hidden_states = pre_hc_hidden_states[: hidden_states.shape[0]]
881 if isinstance(self.sampler, GPUWatermarkSampler):
882 self.speculator.prepare_watermarking(
883 self.sampler._get_contexts(input_batch.idx_mapping),
@@ -1107,6 +1109,44 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1109 if outputs is not None:
1110 self.postprocess_sampled(**outputs)
1111
1112 + def warmup_pp_decode_update(self) -> None:
1113 + """JIT-compile the kernel behind ``update_pp_decode_requests``.
1114 +
1115 + That path only runs on real steps, so the warmup steps never reach it
1116 + on non-last PP ranks. Its first triton compile must not happen
1117 + mid-serving: the in-flight sampled-token broadcast keeps a NCCL kernel
1118 + spinning on this device, which blocks the CUDA module load and
1119 + deadlocks the pipeline. An all -1 idx_mapping makes this a no-op.
1120 + The freshly allocated int32 tensors are 16-byte aligned, matching the
1121 + padded views `PPHandler` produces at serving time (triton specializes
1122 + on pointer alignment).
1123 + """
1124 + assert self.pp_handler is not None
1125 + num_spec = self.pp_handler.max_sample_len - 1
1126 + broadcast_drafts = (
1127 + torch.zeros((1, num_spec), dtype=torch.int64, device=self.device)
1128 + if num_spec > 0
1129 + else None
1130 + )
1131 + post_update(
1132 + torch.full((1,), -1, dtype=torch.int64, device=self.device),
1133 + self.req_states.num_computed_tokens.gpu,
1134 + self.req_states.last_sampled_tokens,
1135 + None,
1136 + torch.zeros(
1137 + (1, self.pp_handler.max_sample_len),
1138 + dtype=torch.int64,
1139 + device=self.device,
1140 + ),
1141 + torch.zeros(1, dtype=torch.int32, device=self.device),
1142 + torch.zeros(1, dtype=torch.int32, device=self.device),
1143 + None,
1144 + self.req_states.all_token_ids.gpu,
1145 + self.req_states.total_len.gpu,
1146 + broadcast_drafts,
1147 + self.req_states.draft_tokens if broadcast_drafts is not None else None,
1148 + )
1149 +
1150 def add_requests(self, scheduler_output: SchedulerOutput) -> None:
1151 for new_req_data in scheduler_output.scheduled_new_reqs:
1152 assert new_req_data.prefill_token_ids is not None
@@ -1588,6 +1628,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1628 num_sampled: torch.Tensor,
1629 num_rejected: torch.Tensor,
1630 query_start_loc: torch.Tensor | None = None,
1631 + broadcast_drafts: torch.Tensor | None = None,
1632 ) -> None:
1633 # Update the number of computed tokens.
1634 output_bin_counts = None
@@ -1605,6 +1646,8 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1646 query_start_loc,
1647 self.req_states.all_token_ids.gpu,
1648 self.req_states.total_len.gpu,
1649 + broadcast_drafts,
1650 + self.req_states.draft_tokens if broadcast_drafts is not None else None,
1651 )
1652
1653 self.model_state.postprocess_state(
@@ -2125,7 +2168,10 @@ class GPUModelRunner(LoRAModelRunnerMixin):
2168 spec_hidden_states = draft_hidden_states
2169 if hasattr(self.model, "get_mtp_target_hidden_states"):
2170 pre_hc_hidden_states = self.model.get_mtp_target_hidden_states()
2128 - spec_hidden_states = pre_hc_hidden_states[: draft_hidden_states.size(0)]
2171 + if pre_hc_hidden_states is not None:
2172 + spec_hidden_states = pre_hc_hidden_states[
2173 + : draft_hidden_states.size(0)
2174 + ]
2175 if isinstance(self.sampler, GPUWatermarkSampler):
2176 self.speculator.prepare_watermarking(
2177 self.sampler._get_contexts(input_batch.idx_mapping),
@@ -2147,8 +2193,16 @@ class GPUModelRunner(LoRAModelRunnerMixin):
2193 dp_sync=dp_sync,
2194 mm_inputs=mm_inputs,
2195 )
2150 - self.req_states.draft_tokens[input_batch.idx_mapping] = draft_tokens
2151 - if self.adaptive_verification is not None:
2196 + if draft_tokens is not None:
2197 + self.req_states.draft_tokens[input_batch.idx_mapping] = draft_tokens
2198 + if self.pp_handler is not None:
2199 + # Earlier stages never run the speculator; ship the
2200 + # drafts so their next verification step embeds the real
2201 + # draft tokens instead of stale buffer contents.
2202 + self.pp_handler.broadcast_drafts(
2203 + self.req_states.draft_tokens, input_batch
2204 + )
2205 + if draft_tokens is not None and self.adaptive_verification is not None:
2206 self.adaptive_verification.record_confidences(
2207 self.speculator.draft_token_confidence_probs, input_batch
2208 )
@@ -2160,7 +2214,11 @@ class GPUModelRunner(LoRAModelRunnerMixin):
2214 input_batch,
2215 self.req_states.draft_tokens[input_batch.idx_mapping],
2216 )
2163 - if self.pp_handler is not None:
2217 + if self.pp_handler is not None and self.speculator is None:
2218 + # When a speculator ran, the propose() path above already
2219 + # broadcast the fresh drafts. Broadcasting here as well would
2220 + # double-post on the pp_broadcast group and misalign the
2221 + # recv FIFO on earlier stages, hanging the pipeline.
2222 self.pp_handler.broadcast_drafts(
2223 self.req_states.draft_tokens, input_batch
2224 )
vllm/v1/worker/gpu/pp_utils.py
+65 -20
@@ -46,6 +46,18 @@ def compute_need_sampled_mask(input_batch: InputBatch) -> np.ndarray | None:
46 return produces_sample if produces_sample.any() else None
47
48
49 +def _alloc_combined(num_reqs: int, device: torch.device) -> torch.Tensor:
50 + """Allocate the (2, N) int32 buffer broadcast alongside sampled tokens.
51 +
52 + The inner dim is padded to a multiple of 4 so that both `unbind` views
53 + stay 16-byte aligned for any `num_reqs`: triton specializes on pointer
54 + alignment, and a misaligned `num_rejected` would JIT-compile a second
55 + `_post_update_kernel` variant at serving time. The padding is broadcast
56 + but never read. Sender and receiver must both use this allocation.
57 + """
58 + return torch.empty(2, -(-num_reqs // 4) * 4, dtype=torch.int32, device=device)
59 +
60 +
61 class PPHandler:
62 """Runs the PP sampled-token broadcast/recv on a side stream so the
63 default stream isn't gated by the matching peer call. Step T's recv is
@@ -86,6 +98,14 @@ class PPHandler:
98 )
99 self.aux_hidden_state_relay_keys: tuple[str, ...] = ()
100
101 + # Warmup steps run the pipeline with synthetic batches whose outputs are
102 + # discarded; the sampled-token broadcast is disabled there so its
103 + # side-stream NCCL ops cannot overlap the next step's activation p2p.
104 + self.disabled = False
105 +
106 + def set_disabled(self, disabled: bool) -> None:
107 + self.disabled = disabled
108 +
109 def on_req_idx_freed(self, req_idx: int) -> None:
110 self.req_idx_gen_np[req_idx] += 1
111
@@ -157,29 +177,15 @@ class PPHandler:
177 num_sampled=slot.num_sampled,
178 num_rejected=slot.num_rejected,
179 idx_mapping=idx_mapping,
180 + broadcast_drafts=slot.draft_tokens,
181 )
182
162 - def broadcast_drafts(
163 - self, draft_tokens: torch.Tensor, input_batch: InputBatch
164 - ) -> None:
165 - """Broadcast draft proposals so non-last ranks can embed real token ids."""
166 - assert self.is_last_rank
167 - if compute_need_sampled_mask(input_batch) is None:
168 - return
169 - with torch.cuda.stream(self.broadcast_stream):
170 - self.broadcast_stream.wait_stream(self.main_stream)
171 - send = draft_tokens[input_batch.idx_mapping].contiguous()
172 - # Must record the idx_mapping tensor since it was allocated
173 - # on the main stream.
174 - input_batch.idx_mapping.record_stream(self.broadcast_stream)
175 - torch.distributed.broadcast(
176 - send, src=self.last_rank, group=self.broadcast_group
177 - )
178 -
183 def receive(self, input_batch: InputBatch) -> bool:
184 """Returns True iff sampled tokens need to be gathered from *all*
185 requests in the batch."""
186 assert not self.is_last_rank
187 + if self.disabled:
188 + return False
189 need_sampled_mask = compute_need_sampled_mask(input_batch)
190 if need_sampled_mask is None:
191 # Leave this step's reserved slot as None.
@@ -195,7 +201,7 @@ class PPHandler:
201 sampled_tokens = torch.empty(
202 num_reqs, self.max_sample_len, dtype=torch.int64, device=self.device
203 )
198 - combined = torch.empty(2, num_reqs, dtype=torch.int32, device=self.device)
204 + combined = _alloc_combined(num_reqs, self.device)
205 torch.distributed.broadcast(
206 sampled_tokens, src=self.last_rank, group=self.broadcast_group
207 )
@@ -213,6 +219,7 @@ class PPHandler:
219 torch.distributed.broadcast(
220 draft_tokens, src=self.last_rank, group=self.broadcast_group
221 )
222 +
223 event = self.broadcast_stream.record_event()
224 num_sampled, num_rejected = combined.unbind(dim=0)
225 # Must record_stream since these were allocated on broadcast stream but
@@ -242,11 +249,16 @@ class PPHandler:
249 input_batch: InputBatch,
250 ) -> None:
251 assert self.is_last_rank
245 - if compute_need_sampled_mask(input_batch) is None:
252 + if self.disabled:
253 + return
254 + mask = compute_need_sampled_mask(input_batch)
255 + if mask is None:
256 # No request needs sampled outputs for a subsequent decode step.
257 return
258
259 assert sampled_token_ids.dtype == torch.int64
260 + assert num_sampled.dtype == torch.int32
261 + assert num_rejected.dtype == torch.int32
262
263 if current_platform.is_xpu():
264 self.main_stream.synchronize()
@@ -262,9 +274,42 @@ class PPHandler:
274 src=self.last_rank,
275 group=self.broadcast_group,
276 )
265 - combined = torch.stack((num_sampled, num_rejected), dim=0)
277 + combined = _alloc_combined(num_sampled.shape[0], self.device)
278 + combined[0, : num_sampled.shape[0]] = num_sampled
279 + combined[1, : num_sampled.shape[0]] = num_rejected
280 torch.distributed.broadcast(
281 combined, src=self.last_rank, group=self.broadcast_group
282 )
283 for tensor in (sampled_token_ids, num_sampled, num_rejected):
284 tensor.record_stream(self.broadcast_stream)
285 +
286 + def broadcast_drafts(
287 + self, draft_token_table: torch.Tensor, input_batch: InputBatch
288 + ) -> None:
289 + """Broadcast the speculator's freshly proposed draft tokens.
290 +
291 + Runs after propose() on the last rank; the send is stream-ordered
292 + after broadcast()'s sends, matching receive()'s enqueue order. The
293 + payload is gathered from the runner's draft table (just updated from
294 + propose()'s output) into a fresh compact tensor: the speculator
295 + overwrites its own persistent buffer on the next step, possibly
296 + before this async send completes.
297 + """
298 + assert self.is_last_rank
299 + if self.disabled or self.max_sample_len == 1:
300 + return
301 + if compute_need_sampled_mask(input_batch) is None:
302 + return
303 + assert draft_token_table.dtype == torch.int64
304 + assert draft_token_table.shape[1] == self.max_sample_len - 1
305 +
306 + # Gather on the main stream so the payload is ordered after this
307 + # step's table update and before any later one.
308 + drafts = draft_token_table[input_batch.idx_mapping]
309 + assert drafts.shape[0] == input_batch.num_reqs
310 + with torch.cuda.stream(self.broadcast_stream):
311 + self.broadcast_stream.wait_stream(self.main_stream)
312 + torch.distributed.broadcast(
313 + drafts, src=self.last_rank, group=self.broadcast_group
314 + )
315 + drafts.record_stream(self.broadcast_stream)
vllm/v1/worker/gpu/spec_decode/dflash/speculator.py
+7
@@ -256,6 +256,7 @@ class DFlashSpeculator(DraftModelSpeculator):
256 num_tokens_across_dp=num_tokens_across_dp,
257 slot_mapping=slot_mappings,
258 batch_descriptor=batch_descriptor,
259 + is_padding=self.input_buffers.is_padding[:num_tokens],
260 ):
261 last_hidden_states = self.model(
262 input_ids=self.input_buffers.input_ids[:num_tokens],
@@ -487,6 +488,7 @@ def _prepare_dflash_inputs_kernel(
488 # Outputs
489 out_input_ids_ptr,
490 out_query_positions_ptr,
491 + out_is_padding_ptr,
492 out_query_start_loc_ptr,
493 out_seq_lens_ptr,
494 out_query_slot_mapping_ptr,
@@ -617,6 +619,7 @@ def _prepare_dflash_inputs_kernel(
619 )
620
621 tl.store(out_input_ids_ptr + query_idx, input_id, mask=is_query)
622 + tl.store(out_is_padding_ptr + query_idx, False, mask=is_query)
623 clamped_query_pos = tl.minimum(query_pos, max_model_len - 1)
624 tl.store(out_query_positions_ptr + query_idx, clamped_query_pos, mask=is_query)
625 tl.store(out_query_slot_mapping_ptr + query_idx, q_slot, mask=is_query)
@@ -679,6 +682,9 @@ def _prepare_dflash_inputs_kernel(
682 for i in range(q_pad_start, max_num_tokens, BLOCK_SIZE):
683 block = i + tl.arange(0, BLOCK_SIZE)
684 mask = block < max_num_tokens
685 + tl.store(out_input_ids_ptr + block, 0, mask=mask)
686 + tl.store(out_query_positions_ptr + block, 0, mask=mask)
687 + tl.store(out_is_padding_ptr + block, True, mask=mask)
688 tl.store(out_query_slot_mapping_ptr + block, PAD_SLOT_ID, mask=mask)
689
690
@@ -730,6 +736,7 @@ def prepare_dflash_inputs(
736 _prepare_dflash_inputs_kernel[(num_reqs, num_blocks)](
737 input_buffers.input_ids,
738 input_buffers.positions,
739 + input_buffers.is_padding,
740 input_buffers.query_start_loc,
741 input_buffers.seq_lens,
742 query_slot_mapping,
vllm/v1/worker/gpu/warmup.py
+5
@@ -445,6 +445,11 @@ def _warmup_kernels(
445 for step_indices, step_spec_flags in decode_steps:
446 _run_decode_step(step_indices, step_spec_flags)
447
448 + # The deferred PP post-update path only runs on real steps, so the steps
449 + # above never JIT-compile its kernel on non-last ranks.
450 + if not model_runner.is_last_pp_rank and model_runner.pp_handler is not None:
451 + model_runner.warmup_pp_decode_update()
452 +
453 # Clean up - process finish_req_ids.
454 cleanup_output = SchedulerOutput.make_empty()
455 cleanup_output.finished_req_ids = set(req_ids)
vllm/v1/worker/gpu_worker.py
+12
@@ -805,6 +805,15 @@ class Worker(WorkerBase):
805
806 @instrument(span_name="Warmup (GPU)")
807 def compile_or_warm_up_model(self) -> CompilationTimes:
808 + # All warmup phases below run synthetic steps whose sampled outputs are
809 + # discarded. The PP sampled-token broadcast would carry no payload, and
810 + # its side-stream NCCL ops can overlap the next step's activation p2p
811 + # and deadlock the pipeline, so keep it disabled for the whole warmup
812 + # window and restore it before serving.
813 + pp_handler = getattr(self.model_runner, "pp_handler", None)
814 + if pp_handler is not None:
815 + pp_handler.set_disabled(True)
816 +
817 warmup_sizes: list[int] = []
818
819 if (
@@ -983,6 +992,9 @@ class Worker(WorkerBase):
992 # gate so subsequent `execute_model` / `sample_tokens` calls enforce it.
993 enable_gpu_sync_check()
994
995 + if pp_handler is not None:
996 + pp_handler.set_disabled(False)
997 +
998 return CompilationTimes(
999 language_model=self.compilation_config.compilation_time,
1000 encoder=self.compilation_config.encoder_compilation_time,