[Bugfix][MRV2] Initialize DCP metadata after batch partitioning (#55212)
Signed-off-by: Raphael Rialland <raphael.rialland@mistral.ai> Signed-off-by: Ziyang Zhang <hafuhafu@qq.com> Signed-off-by: Sy03 <1370724210@qq.com> Co-authored-by: Raphael Rialland <raphael.rialland@mistral.ai> Co-authored-by: Ziyang Zhang <hafuhafu@qq.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Sy03 committed
Sep 9, 2026 at 23:50 UTC
83990f5bcc0b5eb08b2f1fd1b109fa8a31b122f3
12 files changed
+460
-77
tests/v1/spec_decode/test_eagle_draft_attn_metadata.py
+4
-1
@@ -144,8 +144,11 @@ def test_build_draft_attn_metadata_recomputes_dcp_local_seq_lens():
144
assert (num_reqs, dcp_size, dcp_rank, cp_interleave) == (3, 2, 1, 4)
145
out[:num_reqs].copy_(torch.tensor([1, 4, 8], dtype=torch.int32))
146
out[num_reqs:].zero_()
147
+ return out
148
148
- with patch.object(base_speculator, "prepare_dcp_local_seq_lens", fake_prepare):
149
+ with patch.object(
150
+ base_speculator, "maybe_prepare_dcp_local_seq_lens", fake_prepare
151
+ ):
152
captured = _run_build(
153
fake,
154
num_reqs=3,
tests/v1/worker/test_gpu_input_batch_v2.py
+110
@@ -2,10 +2,13 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""Tests for the V2 model runner's InputBatch (vllm.v1.worker.gpu.input_batch)."""
4
5
+import numpy as np
6
import pytest
7
import torch
8
9
from vllm.platforms import current_platform
10
+from vllm.v1.attention.backends.utils import get_dcp_local_seq_lens
11
+from vllm.v1.worker.gpu import cp_utils
12
from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
13
14
DEVICE = current_platform.device_type
@@ -51,3 +54,110 @@ def test_make_dummy_distributes_remainder(num_reqs: int, num_tokens: int):
54
assert torch.equal(
55
batch.query_start_loc.cpu(), torch.from_numpy(batch.query_start_loc_np)
56
)
57
+
58
+
59
+def test_maybe_prepare_dcp_local_seq_lens_uses_shared_buffer(monkeypatch):
60
+ """The batch must view the caller-owned buffer, sliced to padded length.
61
+
62
+ Runtime (and capture) paths all funnel through this helper; attention
63
+ metadata indexes padded rows, so the view must reach
64
+ num_reqs_after_padding, and it must alias the persistent buffer so CUDA
65
+ graph replay sees the recomputed values.
66
+ """
67
+ buffers = InputBuffers(max_num_reqs=4, max_num_tokens=4, device=torch.device("cpu"))
68
+ batch = InputBatch.make_dummy(2, 4, buffers)
69
+ batch.num_reqs_after_padding = 4
70
+
71
+ def fake_kernel(
72
+ output,
73
+ seq_lens,
74
+ dcp_size,
75
+ dcp_rank,
76
+ cp_interleave,
77
+ num_reqs,
78
+ max_num_reqs,
79
+ block_size,
80
+ ):
81
+ assert output is buffers.dcp_local_seq_lens
82
+ assert seq_lens is batch.seq_lens
83
+ assert (num_reqs, dcp_size, dcp_rank, cp_interleave) == (2, 4, 1, 16)
84
+ assert (max_num_reqs, block_size) == (4, 128)
85
+ output[:] = torch.tensor([1, 2, 0, 0], dtype=output.dtype)
86
+
87
+ class FakeKernel:
88
+ def __getitem__(self, grid):
89
+ assert grid == (1,)
90
+ return fake_kernel
91
+
92
+ monkeypatch.setattr(cp_utils, "_dcp_local_seq_lens_kernel", FakeKernel())
93
+ batch.dcp_local_seq_lens = cp_utils.maybe_prepare_dcp_local_seq_lens(
94
+ buffers.dcp_local_seq_lens,
95
+ batch.seq_lens,
96
+ batch.num_reqs,
97
+ 4,
98
+ 1,
99
+ 16,
100
+ num_reqs_padded=batch.num_reqs_after_padding,
101
+ )
102
+
103
+ assert batch.dcp_local_seq_lens is not None
104
+ assert batch.dcp_local_seq_lens.data_ptr() == buffers.dcp_local_seq_lens.data_ptr()
105
+ assert batch.dcp_local_seq_lens.tolist() == [1, 2, 0, 0]
106
+
107
+
108
+def test_maybe_prepare_dcp_local_seq_lens_clears_stale_metadata(monkeypatch):
109
+ """dcp_size == 1 resets the field to None instead of leaving a leftover.
110
+
111
+ DCP is toggled per deployment, and batches are recycled; a value from an
112
+ earlier DCP batch would otherwise be consumed as if it were current.
113
+ """
114
+ buffers = InputBuffers(max_num_reqs=2, max_num_tokens=2, device=torch.device("cpu"))
115
+ batch = InputBatch.make_dummy(1, 1, buffers)
116
+ batch.dcp_local_seq_lens = buffers.dcp_local_seq_lens[:1]
117
+
118
+ def fail_if_called(*args, **kwargs):
119
+ raise AssertionError("kernel must not run with dcp_size == 1")
120
+
121
+ monkeypatch.setattr(cp_utils, "_dcp_local_seq_lens_kernel", fail_if_called)
122
+ batch.dcp_local_seq_lens = cp_utils.maybe_prepare_dcp_local_seq_lens(
123
+ buffers.dcp_local_seq_lens,
124
+ batch.seq_lens,
125
+ batch.num_reqs,
126
+ 1,
127
+ 0,
128
+ 1,
129
+ num_reqs_padded=batch.num_reqs_after_padding,
130
+ )
131
+
132
+ assert batch.dcp_local_seq_lens is None
133
+
134
+
135
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="triton kernel needs CUDA")
136
+@pytest.mark.parametrize("dcp_size", [2, 4])
137
+@pytest.mark.parametrize("cp_interleave", [1, 16])
138
+def test_maybe_prepare_dcp_local_seq_lens_matches_reference(
139
+ dcp_size: int, cp_interleave: int
140
+):
141
+ """Every rank's local lengths must equal the reference torch formula."""
142
+ device = torch.device("cuda:0")
143
+ seq_lens_np = np.array([7, 16, 33, 64, 512, 1023], dtype=np.int32)
144
+ buffers = InputBuffers(max_num_reqs=8, max_num_tokens=32, device=device)
145
+ batch = InputBatch.make_dummy(6, 12, buffers)
146
+ buffers.seq_lens[: len(seq_lens_np)] = torch.from_numpy(seq_lens_np).to(device)
147
+
148
+ for dcp_rank in range(dcp_size):
149
+ buffers.dcp_local_seq_lens.fill_(-1)
150
+ batch.dcp_local_seq_lens = cp_utils.maybe_prepare_dcp_local_seq_lens(
151
+ buffers.dcp_local_seq_lens,
152
+ batch.seq_lens,
153
+ batch.num_reqs,
154
+ dcp_size,
155
+ dcp_rank,
156
+ cp_interleave,
157
+ num_reqs_padded=batch.num_reqs_after_padding,
158
+ )
159
+ expected = get_dcp_local_seq_lens(
160
+ torch.from_numpy(seq_lens_np), dcp_size, dcp_rank, cp_interleave
161
+ )
162
+ assert batch.dcp_local_seq_lens is not None
163
+ assert torch.equal(batch.dcp_local_seq_lens.cpu(), expected.to(torch.int32))
tests/v1/worker/test_gpu_pcp_manager.py
+105
@@ -1,11 +1,17 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from dataclasses import replace
4
+
5
import numpy as np
6
import pytest
7
import torch
8
9
+from vllm.v1.attention.backends.utils import get_dcp_local_seq_lens
10
+from vllm.v1.worker.gpu import cp_utils as gpu_cp_utils
11
from vllm.v1.worker.gpu import pcp_manager as pcp_manager_module
12
+from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
13
from vllm.v1.worker.gpu.pcp_manager import PCPManager
14
+from vllm.v1.worker.gpu.states import RequestState
15
16
17
def _copy_to_cpu(value, out=None, device=None):
@@ -107,3 +113,102 @@ def test_graph_padding_cannot_be_smaller_than_largest_pcp_rank(monkeypatch):
113
query_start_loc_np=np.arange(4, dtype=np.int32),
114
padded_num_tokens=2,
115
)
116
+
117
+
118
+def _make_global_decode_batch(
119
+ num_computed_tokens: list[int], buffers: InputBuffers, device: torch.device
120
+) -> InputBatch:
121
+ """A replicate-decode global batch as `prepare_inputs` would build it."""
122
+ num_reqs = len(num_computed_tokens)
123
+ num_tokens = num_reqs
124
+ seq_lens_np = np.asarray(num_computed_tokens, dtype=np.int32) + 1
125
+
126
+ base = InputBatch.make_dummy(num_reqs, num_tokens, buffers)
127
+ buffers.seq_lens[:num_reqs] = torch.from_numpy(seq_lens_np).to(device)
128
+ query_start_loc_np = np.arange(num_reqs + 1, dtype=np.int32)
129
+ buffers.query_start_loc[: num_reqs + 1] = torch.from_numpy(query_start_loc_np).to(
130
+ device
131
+ )
132
+
133
+ return replace(
134
+ base,
135
+ req_ids=[f"req_{i}" for i in range(num_reqs)],
136
+ num_reqs=num_reqs,
137
+ num_reqs_after_padding=num_reqs,
138
+ idx_mapping=torch.arange(num_reqs, dtype=torch.int32, device=device),
139
+ idx_mapping_np=np.arange(num_reqs, dtype=np.int32),
140
+ num_scheduled_tokens=np.ones(num_reqs, dtype=np.int32),
141
+ num_tokens=num_tokens,
142
+ num_tokens_after_padding=num_tokens,
143
+ num_draft_tokens=0,
144
+ num_draft_tokens_per_req=np.zeros(num_reqs, dtype=np.int32),
145
+ query_start_loc=buffers.query_start_loc[: num_reqs + 1],
146
+ query_start_loc_np=query_start_loc_np,
147
+ seq_lens=buffers.seq_lens[:num_reqs],
148
+ seq_lens_cpu_upper_bound=torch.from_numpy(seq_lens_np),
149
+ dcp_local_seq_lens=None,
150
+ num_computed_tokens_np=np.asarray(num_computed_tokens, dtype=np.int32),
151
+ prefill_len_np=np.zeros(num_reqs, dtype=np.int32),
152
+ num_computed_prefill_tokens_np=np.zeros(num_reqs, dtype=np.int32),
153
+ is_prefilling_np=np.zeros(num_reqs, dtype=np.bool_),
154
+ input_ids=buffers.input_ids[:num_tokens],
155
+ positions=buffers.positions[:num_tokens],
156
+ is_padding=buffers.is_padding[:num_tokens],
157
+ prompt_lens=None,
158
+ )
159
+
160
+
161
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="needs GPU kernels")
162
+def test_partition_defers_dcp_metadata_to_post_partition_batch():
163
+ """DCP-local lengths must derive from the partitioned batch, not the
164
+ global one: the partition replaces seq_lens, so a pre-partition value is
165
+ stale. partition_batch therefore returns None, and the runtime populates
166
+ the field afterwards from the PCP-owned buffers.
167
+ """
168
+ device = torch.device("cuda:0")
169
+ req_states = RequestState(
170
+ max_num_reqs=4,
171
+ max_model_len=64,
172
+ max_num_batched_tokens=8,
173
+ num_speculative_steps=1,
174
+ vocab_size=8,
175
+ device=device,
176
+ )
177
+ manager = PCPManager(
178
+ pcp_world_size=2,
179
+ pcp_rank=0,
180
+ device=device,
181
+ req_states=req_states,
182
+ max_num_reqs=4,
183
+ max_num_tokens=8,
184
+ dcp_world_size=2,
185
+ dcp_rank=0,
186
+ )
187
+
188
+ global_buffers = InputBuffers(4, 8, device)
189
+ global_batch = _make_global_decode_batch([16, 24], global_buffers, device)
190
+ # A leftover from an earlier DCP batch must not survive the partition.
191
+ global_batch.dcp_local_seq_lens = global_buffers.dcp_local_seq_lens[:2]
192
+ global_batch.dcp_local_seq_lens.fill_(-1)
193
+
194
+ local_batch = manager.partition_batch(global_batch, padded_num_tokens=2)
195
+
196
+ assert local_batch.dcp_local_seq_lens is None
197
+ assert local_batch.seq_lens.tolist() == [17, 25]
198
+
199
+ # What execute_model does next: derive DCP metadata from the final batch
200
+ # on the PCP-owned buffers.
201
+ local_batch.dcp_local_seq_lens = gpu_cp_utils.maybe_prepare_dcp_local_seq_lens(
202
+ manager.input_buffers.dcp_local_seq_lens,
203
+ local_batch.seq_lens,
204
+ local_batch.num_reqs,
205
+ dcp_size=2,
206
+ dcp_rank=0,
207
+ cp_interleave=1,
208
+ num_reqs_padded=local_batch.num_reqs_after_padding,
209
+ )
210
+ expected = get_dcp_local_seq_lens(
211
+ torch.tensor([17, 25], dtype=torch.int32), 2, 0, 1
212
+ )
213
+ assert local_batch.dcp_local_seq_lens is not None
214
+ assert torch.equal(local_batch.dcp_local_seq_lens.cpu(), expected)
tests/v1/worker/test_gpu_ubatch_slicing.py
+137
@@ -22,7 +22,9 @@ import torch
22
from tests.v1.attention.utils import BatchSpec, create_common_attn_metadata
23
from vllm.config import CUDAGraphMode, ModelConfig, ParallelConfig, VllmConfig
24
from vllm.forward_context import create_forward_context
25
+from vllm.v1.attention.backends.utils import get_dcp_local_seq_lens
26
from vllm.v1.kv_cache_interface import KVCacheConfig
27
+from vllm.v1.worker.gpu import cp_utils as gpu_cp_utils
28
from vllm.v1.worker.gpu import dp_utils
29
from vllm.v1.worker.gpu.cudagraph_utils import BatchExecutionDescriptor
30
from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
@@ -581,3 +583,138 @@ def test_ubatch_runner_names_the_microbatch_that_failed():
583
assert isinstance(result["error"], RuntimeError)
584
assert "Microbatch 0" in str(result["error"])
585
assert isinstance(result["error"].__cause__, ValueError)
586
+
587
+
588
+# DCP x DBO: each microbatch recomputes DCP-local seq_lens from its own
589
+# truncated seq_lens into its own persistent buffer (the parent's values for
590
+# a straddling request are wrong for the leading microbatch).
591
+
592
+DCP_SIZE = 2
593
+CP_INTERLEAVE = 1
594
+
595
+
596
+def _make_cuda_input_batch(
597
+ query_lens: list[int], seq_lens: list[int]
598
+) -> tuple[InputBatch, InputBuffers]:
599
+ buffers = InputBuffers(
600
+ max_num_reqs=MAX_NUM_REQS,
601
+ max_num_tokens=MAX_NUM_TOKENS,
602
+ device=torch.device("cuda:0"),
603
+ )
604
+ return _make_input_batch(query_lens, seq_lens, buffers), buffers
605
+
606
+
607
+def _make_dcp_ubatch_buffers(
608
+ num_ubatches: int = 2,
609
+) -> list[tuple[torch.Tensor, torch.Tensor, torch.Tensor]]:
610
+ """Per-microbatch (query_start_loc, seq_lens, dcp_local_seq_lens)."""
611
+ return [
612
+ (
613
+ torch.zeros(MAX_NUM_REQS + 1, dtype=torch.int32, device="cuda:0"),
614
+ torch.zeros(MAX_NUM_REQS, dtype=torch.int32, device="cuda:0"),
615
+ torch.zeros(MAX_NUM_REQS, dtype=torch.int32, device="cuda:0"),
616
+ )
617
+ for _ in range(num_ubatches)
618
+ ]
619
+
620
+
621
+def _slice_with_dcp(
622
+ input_batch: InputBatch, dcp_rank: int, num_ubatches: int = 2
623
+) -> tuple[list[InputBatch], list[torch.Tensor]]:
624
+ dcp_buffers = [
625
+ torch.zeros(MAX_NUM_REQS, dtype=torch.int32, device="cuda:0")
626
+ for _ in range(num_ubatches)
627
+ ]
628
+ ubatch_buffers = _make_dcp_ubatch_buffers(num_ubatches)
629
+ ubatches = [
630
+ _slice_input_batch(
631
+ input_batch,
632
+ ubatch_slice,
633
+ ubatch_buffers[i][0],
634
+ ubatch_buffers[i][1],
635
+ dcp_buffers[i],
636
+ dcp_size=DCP_SIZE,
637
+ dcp_rank=dcp_rank,
638
+ cp_interleave=CP_INTERLEAVE,
639
+ )
640
+ for i, ubatch_slice in enumerate(
641
+ create_ubatch_slices(input_batch, num_ubatches)
642
+ )
643
+ ]
644
+ return ubatches, dcp_buffers
645
+
646
+
647
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="triton kernel needs CUDA")
648
+@pytest.mark.parametrize("dcp_rank", [0, 1])
649
+def test_microbatches_recompute_dcp_lens_from_truncated_seq_lens(dcp_rank: int):
650
+ """A boundary-split request keeps a truncated seq_len on the leading
651
+ microbatch, whose DCP-local length may differ (in parity) from the full
652
+ batch's. Microbatches must therefore derive DCP lengths from their own
653
+ seq_lens, not reuse the parent's row.
654
+ """
655
+ input_batch, buffers = _make_cuda_input_batch(
656
+ [1, 1, 10, 1, 1], [64, 96, 512, 32, 48]
657
+ )
658
+ # execute_model has populated the merged batch's DCP metadata already.
659
+ input_batch.dcp_local_seq_lens = gpu_cp_utils.maybe_prepare_dcp_local_seq_lens(
660
+ buffers.dcp_local_seq_lens,
661
+ input_batch.seq_lens,
662
+ input_batch.num_reqs,
663
+ DCP_SIZE,
664
+ dcp_rank,
665
+ CP_INTERLEAVE,
666
+ num_reqs_padded=input_batch.num_reqs_after_padding,
667
+ )
668
+ parent_lens = input_batch.dcp_local_seq_lens.clone()
669
+
670
+ ubatches, _ = _slice_with_dcp(input_batch, dcp_rank)
671
+
672
+ for ubatch in ubatches:
673
+ assert ubatch.dcp_local_seq_lens is not None
674
+ expected = get_dcp_local_seq_lens(
675
+ ubatch.seq_lens.cpu(), DCP_SIZE, dcp_rank, CP_INTERLEAVE
676
+ )
677
+ assert torch.equal(
678
+ ubatch.dcp_local_seq_lens[: ubatch.num_reqs].cpu(),
679
+ expected.to(torch.int32),
680
+ )
681
+
682
+ # The straddling request (512 tokens, 5 of its 10 query tokens truncated)
683
+ # lands on this rank with a different local length than the full batch
684
+ # reports (507 vs 512 -> 254 vs 256 for dcp_size=2, interleave=1); copying
685
+ # the parent's row would ship the stale value.
686
+ assert ubatches[0].num_reqs == 3
687
+ assert ubatches[0].seq_lens[2].item() == 507
688
+ assert ubatches[0].dcp_local_seq_lens[2].item() != parent_lens[2].item()
689
+
690
+
691
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="triton kernel needs CUDA")
692
+def test_each_microbatch_owns_its_dcp_buffer():
693
+ """Microbatch DCP lengths live in per-microbatch persistent buffers so
694
+ CPU-visible shapes stay valid under CUDA graph capture and replay."""
695
+ input_batch, _ = _make_cuda_input_batch([4, 4, 4, 4], [8, 9, 10, 11])
696
+
697
+ ubatches, dcp_buffers = _slice_with_dcp(input_batch, dcp_rank=0)
698
+
699
+ for ubatch, dcp_buffer in zip(ubatches, dcp_buffers):
700
+ assert ubatch.dcp_local_seq_lens is not None
701
+ assert ubatch.dcp_local_seq_lens.data_ptr() == dcp_buffer.data_ptr()
702
+ assert dcp_buffers[0].data_ptr() != dcp_buffers[1].data_ptr()
703
+
704
+
705
+def test_slicing_drops_stale_dcp_metadata_when_dcp_is_off():
706
+ """dcp_size == 1 yields None even when the parent carries a value, so a
707
+ microbatch can never consume DCP metadata this deployment did not
708
+ enable.
709
+ """
710
+ buffers = _make_buffers()
711
+ input_batch = _make_input_batch([4, 4, 4, 4], [8, 9, 10, 11], buffers)
712
+ input_batch.dcp_local_seq_lens = buffers.dcp_local_seq_lens[:4]
713
+
714
+ ubatch_buffers = _make_ubatch_buffers()
715
+ ubatches = [
716
+ _slice_input_batch(input_batch, ubatch_slice, *ubatch_buffers[i])
717
+ for i, ubatch_slice in enumerate(create_ubatch_slices(input_batch, 2))
718
+ ]
719
+
720
+ assert all(ubatch.dcp_local_seq_lens is None for ubatch in ubatches)
vllm/v1/attention/backends/flashinfer.py
+20
-5
@@ -628,6 +628,9 @@ class FlashInferTrtllmAPIDecode:
628
mask: torch.Tensor | None = None
629
"""Packed XQA draft mask."""
630
631
+ dcp_query_start_loc: torch.Tensor | None = None
632
+ """Decode query boundaries for masking empty DCP shards during reduction."""
633
+
634
635
@dataclass
636
class FlashInferMetadata:
@@ -741,7 +744,7 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
744
self.dcp_world_size = get_dcp_group().world_size
745
self.dcp_rank = get_dcp_group().rank_in_group
746
self.dcp_kv_cache_interleave_size = (
744
- vllm_config.parallel_config.dcp_kv_cache_interleave_size
747
+ vllm_config.parallel_config.cp_kv_cache_interleave_size
748
)
749
except AssertionError:
750
# DCP might not be initialized in testing
@@ -1407,17 +1410,15 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1410
if needs_seq_lens_cpu:
1411
with gpu_sync_allowed():
1412
seq_lens_cpu = common_attn_metadata.seq_lens_cpu
1410
- seq_lens_np = seq_lens_cpu.numpy()
1411
- num_blocks_np = (seq_lens_np + (page_size - 1)) // page_size
1413
else:
1414
seq_lens_cpu = None
1414
- seq_lens_np = None
1415
- num_blocks_np = None
1415
1416
# Adjust seq_lens_cpu for DCP
1417
if self.use_dcp:
1418
assert seq_lens_cpu is not None
1419
if num_prefills > 0:
1420
+ # Other attention groups may reuse the same common metadata.
1421
+ seq_lens_cpu = seq_lens_cpu.clone()
1422
qo_indptr_prefill_cpu = (
1423
qo_indptr_cpu[num_decodes:] - qo_indptr_cpu[num_decodes]
1424
)
@@ -1435,6 +1436,15 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1436
self.dcp_kv_cache_interleave_size,
1437
)
1438
1439
+ # Native paged attention consumes rank-local lengths (context only
1440
+ # for DCP prefills), so derive its page counts after the conversion.
1441
+ if seq_lens_cpu is not None:
1442
+ seq_lens_np = seq_lens_cpu.numpy()
1443
+ num_blocks_np = (seq_lens_np + (page_size - 1)) // page_size
1444
+ else:
1445
+ seq_lens_np = None
1446
+ num_blocks_np = None
1447
+
1448
# Adjust num_block_np for cascade attention
1449
if use_cascade:
1450
assert num_blocks_np is not None
@@ -1700,6 +1710,9 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1710
q_len_per_req=q_len_per_req,
1711
q_cu_seq_lens=q_cu_seq_lens,
1712
mask=decode_mask,
1713
+ dcp_query_start_loc=(
1714
+ qo_indptr[: num_decodes + 1] if self.use_dcp else None
1715
+ ),
1716
)
1717
else:
1718
assert seq_lens_cpu is not None
@@ -2566,6 +2579,8 @@ class FlashInferImpl(AttentionImpl):
2579
out,
2580
lse,
2581
get_dcp_group(),
2582
+ seq_lens=seq_lens_decode,
2583
+ query_start_loc=attn_metadata.decode.dcp_query_start_loc,
2584
)
2585
elif needs_fp8_out:
2586
output[:num_decode_tokens].copy_(out)
vllm/v1/worker/gpu/cp_utils.py
+7
-4
@@ -5,17 +5,19 @@ import torch
5
from vllm.triton_utils import tl, triton
6
7
8
-def prepare_dcp_local_seq_lens(
8
+def maybe_prepare_dcp_local_seq_lens(
9
dcp_local_seq_lens: torch.Tensor,
10
seq_lens: torch.Tensor,
11
num_reqs: int,
12
dcp_size: int,
13
dcp_rank: int,
14
cp_interleave: int,
15
-) -> None:
16
- """Populate the persistent DCP local seq_lens buffer (CUDA graph safe)."""
15
+ *,
16
+ num_reqs_padded: int | None = None,
17
+) -> torch.Tensor | None:
18
+ """Populate caller-owned storage and return its padded view, or None without DCP."""
19
if dcp_size == 1:
18
- return
20
+ return None
21
22
max_num_reqs = dcp_local_seq_lens.shape[0]
23
BLOCK_SIZE = 128
@@ -30,6 +32,7 @@ def prepare_dcp_local_seq_lens(
32
max_num_reqs,
33
BLOCK_SIZE,
34
)
35
+ return dcp_local_seq_lens[:num_reqs_padded]
36
37
38
@triton.jit
vllm/v1/worker/gpu/cudagraph_utils.py
+10
-12
@@ -38,7 +38,7 @@ from vllm.v1.kv_cache_interface import KVCacheConfig
38
from vllm.v1.spec_decode.dynamic.utils import build_dynamic_sd_schedule_lookup
39
from vllm.v1.worker.gpu.attn_utils import build_slot_mappings_by_layer
40
from vllm.v1.worker.gpu.block_table import BlockTables
41
-from vllm.v1.worker.gpu.cp_utils import prepare_dcp_local_seq_lens
41
+from vllm.v1.worker.gpu.cp_utils import maybe_prepare_dcp_local_seq_lens
42
from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
43
from vllm.v1.worker.gpu.model_states.interface import ModelState
44
from vllm.v1.worker.utils import AttentionGroup, clear_layer_kv_caches
@@ -699,17 +699,15 @@ def prepare_inputs_to_capture(
699
slot_mappings, kv_cache_config
700
)
701
702
- # HACK(woosuk): Special handling for DCP.
703
- if block_tables.cp_size > 1:
704
- prepare_dcp_local_seq_lens(
705
- input_buffers.dcp_local_seq_lens,
706
- input_batch.seq_lens,
707
- num_reqs,
708
- block_tables.cp_size,
709
- block_tables.cp_rank,
710
- block_tables.cp_interleave,
711
- )
712
- input_batch.dcp_local_seq_lens = input_buffers.dcp_local_seq_lens[:num_reqs]
702
+ input_batch.dcp_local_seq_lens = maybe_prepare_dcp_local_seq_lens(
703
+ input_buffers.dcp_local_seq_lens,
704
+ input_batch.seq_lens,
705
+ input_batch.num_reqs,
706
+ block_tables.cp_size,
707
+ block_tables.cp_rank,
708
+ block_tables.cp_interleave,
709
+ num_reqs_padded=input_batch.num_reqs_after_padding,
710
+ )
711
712
# NOTE(woosuk): Attention metadata is required not just by standard attention
713
# kernels, but also by specialized attention-like operations (e.g., Inkling's sconv,
vllm/v1/worker/gpu/model_runner.py
+18
-16
@@ -99,7 +99,7 @@ from vllm.v1.worker.gpu.buffer_utils import (
99
async_copy_to_gpu,
100
set_default_max_concurrency,
101
)
102
-from vllm.v1.worker.gpu.cp_utils import prepare_dcp_local_seq_lens
102
+from vllm.v1.worker.gpu.cp_utils import maybe_prepare_dcp_local_seq_lens
103
from vllm.v1.worker.gpu.cudagraph_utils import (
104
BatchExecutionDescriptor,
105
ModelCudaGraphManager,
@@ -1324,19 +1324,6 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1324
)
1325
seq_lens = self.input_buffers.seq_lens[:num_reqs_padded]
1326
1327
- dcp_local_seq_lens = None
1328
- if self.use_dcp:
1329
- # Prepare dcp local seq_lens.
1330
- prepare_dcp_local_seq_lens(
1331
- self.input_buffers.dcp_local_seq_lens,
1332
- self.input_buffers.seq_lens,
1333
- num_reqs,
1334
- self.dcp_size,
1335
- self.dcp_rank,
1336
- self.cp_interleave,
1337
- )
1338
- dcp_local_seq_lens = self.input_buffers.dcp_local_seq_lens[:num_reqs_padded]
1339
-
1327
# Some input token ids are directly read from the last sampled tokens
1328
# and draft tokens. Also, get the logits indices to sample tokens from.
1329
logits_indices = combine_sampled_and_draft_tokens(
@@ -1384,7 +1371,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1371
query_start_loc_np=query_start_loc_np,
1372
seq_lens=seq_lens,
1373
seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound,
1387
- dcp_local_seq_lens=dcp_local_seq_lens,
1374
+ dcp_local_seq_lens=None,
1375
num_computed_tokens_np=num_computed_tokens_np,
1376
prefill_len_np=batch_req_state.prefill_len_np,
1377
num_computed_prefill_tokens_np=batch_req_state.num_computed_prefill_tokens_np,
@@ -1404,11 +1391,12 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1391
else None
1392
),
1393
)
1407
- return pcp.maybe_partition_pcp_batch(
1394
+ input_batch = pcp.maybe_partition_pcp_batch(
1395
self.pcp_manager,
1396
input_batch,
1397
padded_num_tokens=batch_desc.num_tokens,
1398
)
1399
+ return input_batch
1400
1401
def prepare_attn(
1402
self, input_batch: InputBatch
@@ -1705,6 +1693,20 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1693
1694
attn_metadata = None
1695
slot_mappings_by_layer = None
1696
+ if not (dummy_run and skip_attn_for_dummy_run):
1697
+ input_batch.dcp_local_seq_lens = maybe_prepare_dcp_local_seq_lens(
1698
+ (
1699
+ self.pcp_manager.input_buffers
1700
+ if self.pcp_manager is not None
1701
+ else self.input_buffers
1702
+ ).dcp_local_seq_lens,
1703
+ input_batch.seq_lens,
1704
+ input_batch.num_reqs,
1705
+ self.dcp_size,
1706
+ self.dcp_rank,
1707
+ self.cp_interleave,
1708
+ num_reqs_padded=input_batch.num_reqs_after_padding,
1709
+ )
1710
ubatch_state: UBatchState | None = None
1711
if batch_desc.num_ubatches > 1:
1712
assert self.ubatch_runner is not None
vllm/v1/worker/gpu/pcp_manager.py
+1
-14
@@ -12,7 +12,6 @@ from vllm.logger import init_logger
12
from vllm.v1.attention.backends.utils import PAD_SLOT_ID
13
from vllm.v1.worker.gpu.block_table import BlockTables
14
from vllm.v1.worker.gpu.buffer_utils import async_copy_to_gpu
15
-from vllm.v1.worker.gpu.cp_utils import prepare_dcp_local_seq_lens
15
from vllm.v1.worker.gpu.input_batch import (
16
InputBatch,
17
InputBuffers,
@@ -537,18 +536,6 @@ class PCPManager:
536
seq_lens_cpu_upper_bound_np = np.zeros(num_local_reqs, dtype=np.int32)
537
seq_lens_cpu_upper_bound_np[:] = local_start_pos_np + local_num_scheduled_tokens
538
540
- dcp_local_seq_lens = None
541
- if self.dcp_world_size > 1:
542
- prepare_dcp_local_seq_lens(
543
- input_buffers.dcp_local_seq_lens,
544
- seq_lens,
545
- num_local_reqs,
546
- self.dcp_world_size,
547
- self.dcp_rank,
548
- self.cp_interleave,
549
- )
550
- dcp_local_seq_lens = input_buffers.dcp_local_seq_lens[:num_local_reqs]
551
-
539
return replace(
540
input_batch,
541
req_ids=local_req_ids,
@@ -569,7 +556,7 @@ class PCPManager:
556
query_start_loc_np=local_query_start_loc_np[: num_local_reqs + 1],
557
seq_lens=seq_lens,
558
seq_lens_cpu_upper_bound=torch.from_numpy(seq_lens_cpu_upper_bound_np),
572
- dcp_local_seq_lens=dcp_local_seq_lens,
559
+ dcp_local_seq_lens=None,
560
num_computed_tokens_np=local_start_pos_np,
561
prefill_len_np=local_prefill_len_np,
562
num_computed_prefill_tokens_np=local_num_computed_prefill_tokens_np,
vllm/v1/worker/gpu/spec_decode/dflash/cudagraph.py
+11
-13
@@ -11,7 +11,7 @@ from vllm.v1.worker.gpu.attn_utils import (
11
build_slot_mappings_by_layer,
12
)
13
from vllm.v1.worker.gpu.block_table import BlockTables
14
-from vllm.v1.worker.gpu.cp_utils import prepare_dcp_local_seq_lens
14
+from vllm.v1.worker.gpu.cp_utils import maybe_prepare_dcp_local_seq_lens
15
from vllm.v1.worker.gpu.cudagraph_utils import (
16
AttentionState,
17
BatchExecutionDescriptor,
@@ -42,17 +42,15 @@ def _prepare_dflash_inputs_to_capture(
42
attn_metadata = None
43
if not skip_attn:
44
query_start_loc_cpu = torch.from_numpy(input_batch.query_start_loc_np)
45
- dcp_local_seq_lens = None
46
- if block_tables.cp_size > 1:
47
- prepare_dcp_local_seq_lens(
48
- input_buffers.dcp_local_seq_lens,
49
- input_buffers.seq_lens,
50
- num_reqs,
51
- block_tables.cp_size,
52
- block_tables.cp_rank,
53
- block_tables.cp_interleave,
54
- )
55
- dcp_local_seq_lens = input_buffers.dcp_local_seq_lens
45
+ input_batch.dcp_local_seq_lens = maybe_prepare_dcp_local_seq_lens(
46
+ input_buffers.dcp_local_seq_lens,
47
+ input_batch.seq_lens,
48
+ input_batch.num_reqs,
49
+ block_tables.cp_size,
50
+ block_tables.cp_rank,
51
+ block_tables.cp_interleave,
52
+ num_reqs_padded=input_batch.num_reqs_after_padding,
53
+ )
54
attn_metadata = build_attn_metadata(
55
attn_groups=attn_groups,
56
num_reqs=num_reqs,
@@ -61,7 +59,7 @@ def _prepare_dflash_inputs_to_capture(
59
query_start_loc_cpu=query_start_loc_cpu,
60
max_query_len=num_tokens // num_reqs,
61
seq_lens=input_batch.seq_lens,
64
- dcp_local_seq_lens=dcp_local_seq_lens,
62
+ dcp_local_seq_lens=input_batch.dcp_local_seq_lens,
63
max_seq_len=max_model_len,
64
block_tables=input_block_tables,
65
slot_mappings=slot_mappings,
vllm/v1/worker/gpu/spec_decode/speculator.py
+2
-3
@@ -22,7 +22,7 @@ from vllm.v1.worker.gpu.attn_utils import (
22
init_attn_backend,
23
)
24
from vllm.v1.worker.gpu.block_table import BlockTables
25
-from vllm.v1.worker.gpu.cp_utils import prepare_dcp_local_seq_lens
25
+from vllm.v1.worker.gpu.cp_utils import maybe_prepare_dcp_local_seq_lens
26
from vllm.v1.worker.gpu.dp_utils import DPSyncState
27
from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
28
from vllm.v1.worker.gpu.model_states.interface import ModelState
@@ -297,7 +297,7 @@ class DraftModelSpeculator(BaseSpeculator):
297
if dcp_local_seq_lens is None and self.block_tables.cp_size > 1:
298
# Draft steps advance and rewind their own global sequence lengths,
299
# so the target model's DCP-local lengths may already be stale.
300
- prepare_dcp_local_seq_lens(
300
+ dcp_local_seq_lens = maybe_prepare_dcp_local_seq_lens(
301
self.input_buffers.dcp_local_seq_lens,
302
self.input_buffers.seq_lens,
303
num_reqs,
@@ -305,7 +305,6 @@ class DraftModelSpeculator(BaseSpeculator):
305
self.block_tables.cp_rank,
306
self.block_tables.cp_interleave,
307
)
308
- dcp_local_seq_lens = self.input_buffers.dcp_local_seq_lens
308
attn_metadata = build_attn_metadata(
309
attn_groups=self.attn_groups,
310
num_reqs=num_reqs_padded,
vllm/v1/worker/gpu/ubatch_utils.py
+35
-9
@@ -10,6 +10,7 @@ import numpy as np
10
import torch
11
12
from vllm.config import CUDAGraphMode, VllmConfig
13
+from vllm.distributed import get_dcp_group
14
from vllm.forward_context import (
15
DPMetadata,
16
ForwardContext,
@@ -21,6 +22,7 @@ from vllm.sequence import IntermediateTensors
22
from vllm.utils.torch_utils import current_stream
23
from vllm.v1.kv_cache_interface import KVCacheConfig
24
from vllm.v1.worker.gpu.attn_utils import build_slot_mappings_by_layer
25
+from vllm.v1.worker.gpu.cp_utils import maybe_prepare_dcp_local_seq_lens
26
from vllm.v1.worker.gpu.input_batch import InputBatch
27
from vllm.v1.worker.gpu.model_states.interface import ModelState
28
from vllm.v1.worker.ubatch_utils import (
@@ -76,16 +78,20 @@ def _slice_input_batch(
78
ubatch_slice: UBatchSlice,
79
query_start_loc_buf: torch.Tensor,
80
seq_lens_buf: torch.Tensor,
81
+ dcp_local_seq_lens_buf: torch.Tensor | None = None,
82
+ *,
83
+ dcp_size: int = 1,
84
+ dcp_rank: int = 0,
85
+ cp_interleave: int = 1,
86
) -> InputBatch:
87
"""Build the sub-`InputBatch` a single microbatch runs on.
88
89
A request straddling the boundary appears in both microbatches, its query
90
truncated to the tokens each one owns.
91
85
- Everything except `query_start_loc` and `seq_lens` is a view of the full
86
- batch's buffers, so slicing costs no allocation. Those two are rebased onto
87
- the microbatch's token range and written to the caller's buffers, which
88
- keeps them valid under CUDA graph capture and replay.
92
+ Query offsets and sequence lengths (including DCP-local lengths) are
93
+ written to the caller's persistent buffers after truncation. Other GPU
94
+ inputs are views of the full batch's buffers.
95
96
The sub-batch describes the forward pass only. Sampling runs once over the
97
merged batch, so `logits_indices`, `cu_num_logits` and the draft-token
@@ -139,11 +145,18 @@ def _slice_input_batch(
145
# max_query_len from this array see the microbatch's own lengths.
146
num_scheduled_tokens = np.diff(query_start_loc_np)[:num_reqs]
147
142
- dcp_local_seq_lens = input_batch.dcp_local_seq_lens
143
- if dcp_local_seq_lens is not None:
144
- # NOTE: a request split across microbatches keeps its full local
145
- # seq_len here. DCP is not yet validated with DBO.
146
- dcp_local_seq_lens = dcp_local_seq_lens[req_start:req_stop]
148
+ dcp_local_seq_lens = None
149
+ if dcp_size > 1:
150
+ assert dcp_local_seq_lens_buf is not None
151
+ dcp_local_seq_lens = maybe_prepare_dcp_local_seq_lens(
152
+ dcp_local_seq_lens_buf,
153
+ seq_lens,
154
+ num_reqs,
155
+ dcp_size,
156
+ dcp_rank,
157
+ cp_interleave,
158
+ num_reqs_padded=num_reqs_padded,
159
+ )
160
161
return replace(
162
input_batch,
@@ -278,6 +291,9 @@ class UBatchRunner:
291
self.vllm_config = vllm_config
292
self.parallel_config = vllm_config.parallel_config
293
self.num_ubatches = self.parallel_config.num_ubatches
294
+ self.dcp_size = self.parallel_config.decode_context_parallel_size
295
+ self.dcp_rank = get_dcp_group().rank_in_group if self.dcp_size > 1 else 0
296
+ self.cp_interleave = self.parallel_config.cp_kv_cache_interleave_size
297
self.device = device
298
self.model_state = model_state
299
self.attn_groups = attn_groups
@@ -293,6 +309,12 @@ class UBatchRunner:
309
torch.zeros(max_num_reqs, dtype=torch.int32, device=device)
310
for _ in range(self.num_ubatches)
311
]
312
+ self.ubatch_dcp_local_seq_lens = [
313
+ torch.zeros(max_num_reqs, dtype=torch.int32, device=device)
314
+ if self.dcp_size > 1
315
+ else None
316
+ for _ in range(self.num_ubatches)
317
+ ]
318
self.comm_stream = torch.cuda.Stream(device=device)
319
# The microbatch threads plus the thread that starts them.
320
self.ready_barrier = threading.Barrier(self.num_ubatches + 1)
@@ -320,6 +342,10 @@ class UBatchRunner:
342
ubatch_slice,
343
self.ubatch_query_start_loc[i],
344
self.ubatch_seq_lens[i],
345
+ self.ubatch_dcp_local_seq_lens[i],
346
+ dcp_size=self.dcp_size,
347
+ dcp_rank=self.dcp_rank,
348
+ cp_interleave=self.cp_interleave,
349
)
350
ubatch_slot_mappings = slot_mappings[:, ubatch_slice.token_slice]
351
ubatch_block_tables = tuple(