[PCP][DCP] Enable PCP+DCP on sparse-MLA models (#56157)
Signed-off-by: patryk.saffer <patryk.saffer@blc-login-1.blc1.mistralcompute.ai> Signed-off-by: patryk.saffer <patryk.saffer@blc-login-0.blc1.mistralcompute.ai> Signed-off-by: PatrykSaffer <patryk.saffer@mistral.ai> Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com> Co-authored-by: patryk.saffer <patryk.saffer@blc-login-1.blc1.mistralcompute.ai> Co-authored-by: patryk.saffer <patryk.saffer@blc-login-0.blc1.mistralcompute.ai> Co-authored-by: Lucas Wilkinson <lwilkins@redhat.com> Co-authored-by: Patryk Saffer <PatrykSaffer@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
PatrykSaffer committed
Sep 13, 2026 at 01:24 UTC
ebe1dec2da16ac559e40446a4c6e06a73c9efe00
21 files changed
+917
-138
.buildkite/test_areas/lm_eval.yaml
+5
@@ -110,12 +110,17 @@ steps:
110
- csrc/
111
- tests/evals/gsm8k/configs/GLM-5.2-NVFP4-TP2-PCP2-EP.yaml
112
- tests/evals/gsm8k/configs/GLM-5.2-NVFP4-TP1-PCP4-EP.yaml
113
+ - tests/evals/gsm8k/configs/GLM-5.2-NVFP4-TP1-PCP4-DCP4-EP.yaml
114
- tests/evals/gsm8k/configs/models-pcp.txt
115
- vllm/model_executor/layers/quantization
116
- vllm/config/parallel.py
117
- vllm/distributed/parallel_state.py
118
- vllm/model_executor/layers/attention/mla_attention.py
119
- vllm/model_executor/layers/attention/pcp.py
120
+ - vllm/model_executor/layers/attention/sparse_mla_attention.py
121
+ - vllm/model_executor/layers/sparse_attn_indexer.py
122
+ - vllm/v1/attention/backends/mla/flashmla_sparse.py
123
+ - vllm/v1/attention/backends/mla/indexer.py
124
- vllm/v1/worker/gpu/model_runner.py
125
- vllm/v1/worker/gpu/pcp_manager.py
126
autorun_on_main: true
tests/evals/gsm8k/configs/GLM-5.2-NVFP4-TP1-PCP4-DCP4-EP.yaml
new
+19
@@ -0,0 +1,19 @@
1
+model_name: "nvidia/GLM-5.2-NVFP4"
2
+accuracy_threshold: 0.90
3
+num_questions: 1319
4
+num_fewshot: 5
5
+max_concurrency: 100
6
+server_args: >-
7
+ --compilation-config '{"cudagraph_mode":"PIECEWISE"}'
8
+ --max-model-len 4096
9
+ --max-num-batched-tokens 32768
10
+ --safetensors-load-strategy prefetch
11
+ --moe-backend flashinfer_cutlass
12
+ --prefill-context-parallel-size 4
13
+ --decode-context-parallel-size 4
14
+ --enable-expert-parallel
15
+ --kv-cache-dtype fp8
16
+env:
17
+ PYTORCH_CUDA_ALLOC_CONF: "expandable_segments:True"
18
+ VLLM_LOGGING_LEVEL: "DEBUG"
19
+ VLLM_USE_V2_MODEL_RUNNER: "1"
tests/evals/gsm8k/configs/models-pcp.txt
+1
@@ -1,2 +1,3 @@
1
GLM-5.2-NVFP4-TP2-PCP2-EP.yaml
2
GLM-5.2-NVFP4-TP1-PCP4-EP.yaml
3
+GLM-5.2-NVFP4-TP1-PCP4-DCP4-EP.yaml
tests/v1/attention/test_indexer_dcp_localize.py
+35
@@ -1,6 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+import numpy as np
5
import pytest
6
import torch
7
@@ -962,3 +963,37 @@ def test_sparse_decode_dcp_short_context_matches_non_dcp():
963
dcp_out, dcp_lse = _dcp_lse_merge(local_outs, local_lses)
964
torch.testing.assert_close(dcp_out, ref_out, atol=1e-5, rtol=1e-5)
965
torch.testing.assert_close(dcp_lse, ref_lse, atol=1e-5, rtol=1e-5)
966
+
967
+
968
+@pytest.mark.parametrize("dcp_world_size", [2, 4, 8])
969
+@pytest.mark.parametrize("req_lens", [[7], [8, 8], [1, 9], [256, 1, 2730]])
970
+def test_pcp_plan_deinterleave_restores_global_order(dcp_world_size, req_lens):
971
+ """The index gather must undo DCP sharding per request"""
972
+ from vllm.v1.attention.backends.mla.indexer import build_pcp_global_chunk_plan
973
+
974
+ scheduled = np.array(req_lens, dtype=np.int64)
975
+ shard_rows = -(-scheduled // dcp_world_size)
976
+ rows = np.arange(len(scheduled))
977
+ plan = build_pcp_global_chunk_plan(
978
+ rows, shard_rows, dcp_world_size, torch.device("cpu")
979
+ )
980
+
981
+ starts = np.concatenate([[0], np.cumsum(scheduled)])
982
+
983
+ # Build each rank's padded shard exactly as the cache gather would.
984
+ padded_cu = plan.padded_local_cu.tolist()
985
+ shards = torch.zeros(dcp_world_size, plan.padded_local_total, 1)
986
+ for r in range(dcp_world_size):
987
+ for i, g in enumerate(req_lens):
988
+ for t in range(r, g, dcp_world_size):
989
+ shards[r, padded_cu[i] + t // dcp_world_size, 0] = starts[i] + t
990
+
991
+ gathered = shards.reshape(dcp_world_size * plan.padded_local_total, 1)
992
+ restored = gathered[plan.deinterleave_idx]
993
+ # The layout is padded per request; only the real positions are read.
994
+ row_start = plan.row_start_cu.tolist()
995
+ for i, g in enumerate(req_lens):
996
+ torch.testing.assert_close(
997
+ restored[row_start[i] : row_start[i] + g, 0],
998
+ torch.arange(starts[i], starts[i] + g, dtype=torch.float32),
999
+ )
tests/v1/attention/test_indexer_deepseek_v4_slot_mapping.py
+1
@@ -212,6 +212,7 @@ def test_index_conversion_warmup_uses_physical_block_stride():
212
),
213
parallel_config=SimpleNamespace(
214
decode_context_parallel_size=1,
215
+ prefill_context_parallel_size=1,
216
cp_kv_cache_interleave_size=1,
217
),
218
)
tests/v1/attention/test_sparse_mla_backends.py
+3
@@ -1080,6 +1080,7 @@ def test_flashmla_forward_bf16_kv_slices_req_id_to_mqa_tokens():
1080
_convert_logical_to_physical_topk=_convert_topk,
1081
index_group=None,
1082
index_group_index=0,
1083
+ pcp_dcp_kv_gather=False,
1084
)
1085
1086
out, _ = FlashMLASparseImpl._forward_bf16_kv(
@@ -3257,6 +3258,7 @@ def test_flashmla_fp8_metadata_excludes_zero_token_decode_padding(monkeypatch):
3258
device=torch.device(DEVICE_TYPE),
3259
dummy_block_table=torch.zeros(7, 1, device=DEVICE_TYPE),
3260
max_model_len_tensor=torch.zeros(7, device=DEVICE_TYPE),
3261
+ pcp_dcp_kv_gather=False,
3262
)
3263
query_start_loc_cpu = torch.tensor([0, 110, 220, 330, 440, 550, 660, 660])
3264
common_metadata = SimpleNamespace(
@@ -3363,6 +3365,7 @@ def test_flashmla_fp8_paths_accept_decode_subset(monkeypatch, use_mixed_batch: b
3365
index_group=None,
3366
index_group_index=0,
3367
dcp_world_size=1,
3368
+ pcp_dcp_kv_gather=False,
3369
need_to_return_lse_for_decode=False,
3370
_fp8_flash_mla_kernel=run_kernel,
3371
_convert_logical_to_physical_topk=(
tests/v1/worker/test_gpu_pcp_manager.py
+149
@@ -114,6 +114,155 @@ def test_graph_padding_cannot_be_smaller_than_largest_pcp_rank(monkeypatch):
114
)
115
116
117
+def _rank_rows(
118
+ pcp_rank: int,
119
+ pcp_world_size: int,
120
+ num_scheduled_tokens: np.ndarray,
121
+ num_computed_tokens: np.ndarray,
122
+ is_prefilling: np.ndarray,
123
+) -> tuple[np.ndarray, np.ndarray]:
124
+ """One PCP+DCP rank's rows as (global request, extent)."""
125
+ manager = PCPManager(
126
+ pcp_world_size=pcp_world_size,
127
+ pcp_rank=pcp_rank,
128
+ device=torch.device("cpu"),
129
+ dcp_world_size=2,
130
+ )
131
+ query_start_loc_np = np.concatenate([[0], np.cumsum(num_scheduled_tokens)]).astype(
132
+ np.int32
133
+ )
134
+ segments = manager._get_rank_segments(
135
+ pcp_rank, num_scheduled_tokens, is_prefilling, query_start_loc_np
136
+ )
137
+ rows = np.array([segment.global_batch_req_idx for segment in segments])
138
+ extents = (num_computed_tokens + num_scheduled_tokens)[rows]
139
+ return rows, extents
140
+
141
+
142
+@pytest.mark.parametrize(
143
+ ("num_scheduled_tokens", "num_computed_tokens"),
144
+ [
145
+ ([32, 32], [0, 0]),
146
+ ([32, 32, 32, 32], [0, 0, 0, 0]),
147
+ ([32, 32], [100, 0]), # one continued, one fresh
148
+ ([64, 32, 48], [0, 512, 0]), # ragged lengths and mixed contexts
149
+ # A replicated prefill BEHIND a split one. Sorting replicated rows first
150
+ # put request 1 ahead of request 0.
151
+ ([64, 3], [0, 0]),
152
+ ([64, 3, 64], [0, 0, 0]),
153
+ ],
154
+)
155
+@pytest.mark.parametrize("pcp_world_size", [2, 4])
156
+def test_published_row_order_is_identical_on_every_pcp_rank(
157
+ num_scheduled_tokens, num_computed_tokens, pcp_world_size
158
+):
159
+ """Every rank must map its rows to the same global requests, in the same order."""
160
+ num_scheduled_tokens = np.array(num_scheduled_tokens, dtype=np.int32)
161
+ num_computed_tokens = np.array(num_computed_tokens, dtype=np.int32)
162
+ is_prefilling = np.ones(len(num_scheduled_tokens), dtype=np.bool_)
163
+
164
+ orders = [
165
+ _rank_rows(
166
+ rank,
167
+ pcp_world_size,
168
+ num_scheduled_tokens,
169
+ num_computed_tokens,
170
+ is_prefilling,
171
+ )[0]
172
+ for rank in range(pcp_world_size)
173
+ ]
174
+ for rank, order in enumerate(orders[1:], start=1):
175
+ assert np.array_equal(orders[0], order), (
176
+ f"rank {rank} rows map to {order.tolist()}, rank 0 to {orders[0].tolist()}"
177
+ )
178
+ # Grouped by request and ascending: what the indexer plan indexes by.
179
+ assert np.all(np.diff(orders[0]) >= 0)
180
+
181
+
182
+def test_published_row_order_puts_every_decode_before_every_prefill():
183
+ """split_decodes_and_prefills takes the FIRST prefilling row as the boundary."""
184
+ # req 0 is a continued prefill, req 1 is a decode.
185
+ req_idx, _ = _rank_rows(
186
+ pcp_rank=0,
187
+ pcp_world_size=2,
188
+ num_scheduled_tokens=np.array([32, 1], dtype=np.int32),
189
+ num_computed_tokens=np.array([100, 20], dtype=np.int32),
190
+ is_prefilling=np.array([True, False], dtype=np.bool_),
191
+ )
192
+ # The decode (request 1) must be row 0.
193
+ assert req_idx[0] == 1
194
+
195
+
196
+def test_split_prefill_rows_repeat_the_request_and_its_full_extent():
197
+ """Equal adjacent request indices are what let a backend share a KV region."""
198
+ req_idx, extents = _rank_rows(
199
+ pcp_rank=1,
200
+ pcp_world_size=2,
201
+ num_scheduled_tokens=np.array([64, 3], dtype=np.int32),
202
+ num_computed_tokens=np.array([100, 0], dtype=np.int32),
203
+ is_prefilling=np.ones(2, dtype=np.bool_),
204
+ )
205
+ assert req_idx.tolist() == [0, 0, 1]
206
+ assert extents.tolist() == [164, 164, 3]
207
+
208
+
209
+@pytest.mark.parametrize("query_len", [1, 2, 3, 5, 6, 9])
210
+def test_dcp_replicates_prefills_too_short_to_split(query_len):
211
+ """A prefill that cannot fill 2*pcp chunks is replicated, not split."""
212
+ pcp_world_size = 2
213
+ num_scheduled_tokens = np.array([query_len], dtype=np.int32)
214
+ is_prefilling = np.ones(1, dtype=np.bool_)
215
+
216
+ rows_per_rank = []
217
+ for rank in range(pcp_world_size):
218
+ manager = PCPManager(
219
+ pcp_world_size=pcp_world_size,
220
+ pcp_rank=rank,
221
+ device=torch.device("cpu"),
222
+ dcp_world_size=2,
223
+ )
224
+ assert manager.replicated_requests(num_scheduled_tokens, is_prefilling)[0]
225
+ rows = list(
226
+ manager._iter_rank_chunks(rank, num_scheduled_tokens, is_prefilling)
227
+ )
228
+ assert rows == [(0, 0, query_len)]
229
+ rows_per_rank.append(rows)
230
+ assert rows_per_rank[0] == rows_per_rank[1]
231
+
232
+
233
+@pytest.mark.parametrize("pcp_world_size", [2, 4, 8])
234
+@pytest.mark.parametrize(
235
+ "query_len", [16, 17, 31, 32, 33, 63, 64, 65, 127, 128, 129, 1000, 4097]
236
+)
237
+def test_pcp_first_chunk_row_is_never_short(pcp_world_size, query_len):
238
+ num_scheduled_tokens = np.array([query_len], dtype=np.int32)
239
+ is_prefilling = np.ones(1, dtype=np.bool_)
240
+
241
+ charged_per_rank = []
242
+ for rank in range(pcp_world_size):
243
+ manager = PCPManager(
244
+ pcp_world_size=pcp_world_size,
245
+ pcp_rank=rank,
246
+ device=torch.device("cpu"),
247
+ dcp_world_size=pcp_world_size,
248
+ )
249
+ chunk_lens = [
250
+ chunk_len
251
+ for _, _, chunk_len in manager._iter_rank_chunks(
252
+ rank, num_scheduled_tokens, is_prefilling
253
+ )
254
+ ]
255
+ assert chunk_lens, f"rank {rank} got no rows for {query_len=}"
256
+ assert chunk_lens[0] == max(chunk_lens), (
257
+ f"rank {rank} emitted a short first chunk for {query_len=}: {chunk_lens}"
258
+ )
259
+ charged_per_rank.append(len(chunk_lens) * chunk_lens[0])
260
+
261
+ assert len(set(charged_per_rank)) == 1, (
262
+ f"ranks would chunk differently for {query_len=}: {charged_per_rank}"
263
+ )
264
+
265
+
266
def _make_global_decode_batch(
267
num_computed_tokens: list[int], buffers: InputBuffers, device: torch.device
268
) -> InputBatch:
vllm/model_executor/layers/attention/mla_attention.py
+7
@@ -420,6 +420,9 @@ class MLAAttention(nn.Module, AttentionLayerBase):
420
"""
421
422
supports_dense_mha_prefill: ClassVar[bool] = True
423
+ # Under PCP+DCP only the decode rows carry an LSE; the base forward
424
+ # merges a full-batch LSE, so subclasses opt in with their own forward.
425
+ supports_pcp_dcp: ClassVar[bool] = False
426
427
def __init__(
428
self,
@@ -660,6 +663,10 @@ class MLAAttention(nn.Module, AttentionLayerBase):
663
664
_COMPUTE_PREFILL_METADATA_KERNEL.register_warmup()
665
666
+ if self.use_pcp and self.impl.dcp_world_size > 1 and not self.supports_pcp_dcp:
667
+ raise NotImplementedError(
668
+ f"{type(self).__name__} does not support PCP+DCP."
669
+ )
670
self.dcp_manager: MLADCPManager | None = None
671
if self.impl.dcp_world_size > 1:
672
query_dtype = (
vllm/model_executor/layers/attention/sparse_mla_attention.py
+8
-3
@@ -190,7 +190,8 @@ class SparseMLACommonMetadataBuilder(AttentionMetadataBuilder[T]):
190
pin_memory=PIN_MEMORY,
191
)
192
parallel_config = vllm_config.parallel_config
193
- self.use_pcp = parallel_config.prefill_context_parallel_size > 1
193
+ self.pcp_world_size = parallel_config.prefill_context_parallel_size
194
+ self.use_pcp = self.pcp_world_size > 1
195
try:
196
self.dcp_world_size = get_dcp_group().world_size
197
except AssertionError:
@@ -409,8 +410,12 @@ class SparseMLACommonMetadataBuilder(AttentionMetadataBuilder[T]):
410
q_data_type=self.model_config.dtype,
411
output_dtype=self.model_config.dtype,
412
prefill_backend=self._prefill_backend,
412
- use_dense_mha=_use_dense_mha_prefill(
413
- self.vllm_config, prefill_max_seq_len, self.topk_tokens
413
+ use_dense_mha=(
414
+ _use_dense_mha_prefill(
415
+ self.vllm_config, prefill_max_seq_len, self.topk_tokens
416
+ )
417
+ # Dense MHA cannot be used under PCP+DCP.
418
+ and not (self.use_pcp and self.dcp_world_size > 1)
419
),
420
topk_mask_workspace=self.topk_mask_workspace,
421
host_staging_plan=staging_plan,
vllm/model_executor/layers/sparse_attn_indexer.py
+81
-13
@@ -3,6 +3,7 @@
3
"""Custom Sparse Attention Indexer layers."""
4
5
import torch
6
+import torch.distributed as dist
7
8
import vllm.envs as envs
9
from vllm import _custom_ops as ops
@@ -130,6 +131,23 @@ def _merge_dcp_topk_global(
131
)
132
133
134
+def dcp_gather_kv_rows(
135
+ local_padded: torch.Tensor,
136
+ deinterleave_idx: torch.Tensor,
137
+ gathered_buf: torch.Tensor,
138
+ out_buf: torch.Tensor,
139
+) -> torch.Tensor:
140
+ """All-gather this rank's KV shard and return it in global token order."""
141
+
142
+ gathered = gathered_buf[: get_dcp_group().world_size * local_padded.shape[0]]
143
+ dist.all_gather_into_tensor(
144
+ gathered, local_padded.contiguous(), group=get_dcp_group().device_group
145
+ )
146
+ return torch.index_select(
147
+ gathered, 0, deinterleave_idx, out=out_buf[: deinterleave_idx.shape[0]]
148
+ )
149
+
150
+
151
@triton.jit
152
def _fused_indexer_q_rope_quant_kernel(
153
positions,
@@ -346,11 +364,19 @@ def sparse_attn_indexer(
364
values_spec, scales_spec = _gather_workspace_shapes(
365
total_seq_lens, head_dim, fp8_dtype, use_fp4_cache
366
)
349
- current_workspace_manager().get_simultaneous(
367
+ profile_specs: list[tuple[tuple[int, ...], torch.dtype]] = [
368
values_spec,
369
scales_spec,
370
((RADIX_TOPK_WORKSPACE_SIZE,), torch.uint8),
353
- )
371
+ ]
372
+ if use_pcp and dcp_world_size > 1:
373
+ # The PCP+DCP path takes an all-gather destination and a
374
+ # de-interleaved result.
375
+ gather_spec = _gather_workspace_shapes(
376
+ total_seq_lens, head_dim, fp8_dtype, use_fp4_cache
377
+ )
378
+ profile_specs.extend(gather_spec * 2)
379
+ current_workspace_manager().get_simultaneous(*profile_specs)
380
381
# Dummy allocation to simulate for peak logits tensor memory during inference.
382
# FP8 elements so elements == bytes
@@ -462,9 +488,27 @@ def sparse_attn_indexer(
488
values_spec, scales_spec = _gather_workspace_shapes(
489
total_seq_lens, head_dim, fp8_dtype, use_fp4_cache
490
)
465
- k_quant_full, k_scale_full = workspace_manager.get_simultaneous(
491
+ # PCP + DCP needs two more pairs: the rank-major all-gather destination
492
+ # and the de-interleaved result.
493
+ pcp_chunks = [
494
+ (c, c.pcp_deinterleave_idx)
495
+ for c in prefill_metadata.chunks
496
+ if c.pcp_deinterleave_idx is not None
497
+ ]
498
+ gather_specs: list[tuple[tuple[int, int], torch.dtype]] = []
499
+ if pcp_chunks:
500
+ gathered_rows = max(
501
+ dcp_world_size * c.local_total_seq_lens for c, _ in pcp_chunks
502
+ )
503
+ deinterleaved_rows = max(idx.shape[0] for _, idx in pcp_chunks)
504
+ for rows in (gathered_rows, deinterleaved_rows):
505
+ gather_specs.extend(
506
+ _gather_workspace_shapes(rows, head_dim, fp8_dtype, use_fp4_cache)
507
+ )
508
+ k_quant_full, k_scale_full, *gather_bufs = workspace_manager.get_simultaneous(
509
values_spec,
510
scales_spec,
511
+ *gather_specs,
512
)
513
for chunk in prefill_metadata.chunks:
514
cu_seqlen_ks = chunk.cu_seqlen_ks
@@ -481,6 +525,23 @@ def sparse_attn_indexer(
525
chunk.local_cu_seq_lens,
526
)
527
528
+ # PCP + DCP KV all-gather.
529
+ deinterleave_idx = chunk.pcp_deinterleave_idx
530
+ if deinterleave_idx is not None:
531
+ # local_total_seq_lens is the PCP-padded extent here, so every
532
+ # rank contributes an identically shaped shard.
533
+ gathered_values, gathered_scales, out_values, out_scales = gather_bufs
534
+ shard = k_quant[: chunk.local_total_seq_lens]
535
+ k_quant = dcp_gather_kv_rows(
536
+ shard, deinterleave_idx, gathered_values, out_values
537
+ )
538
+ k_scale = dcp_gather_kv_rows(
539
+ k_scale[: chunk.local_total_seq_lens],
540
+ deinterleave_idx,
541
+ gathered_scales,
542
+ out_scales,
543
+ )
544
+
545
q_slice = q_quant[chunk.token_start : chunk.token_end]
546
q_scale_slice = (
547
q_scale[chunk.token_start : chunk.token_end]
@@ -491,7 +552,7 @@ def sparse_attn_indexer(
552
chunk.token_start : chunk.token_end, :topk_tokens
553
]
554
494
- if chunk.local_total_seq_lens == 0:
555
+ if chunk.local_total_seq_lens == 0 or q_slice.shape[0] == 0:
556
logits = q_slice.new_empty((q_slice.shape[0], 0), dtype=torch.float32)
557
topk_indices.fill_(-1)
558
else:
@@ -561,15 +622,18 @@ def sparse_attn_indexer(
622
topk_tokens,
623
)
624
564
- _merge_dcp_topk_global(
565
- logits,
566
- topk_indices,
567
- topk_tokens,
568
- dcp_rank,
569
- dcp_world_size,
570
- cp_kv_cache_interleave_size,
571
- row_starts=chunk.cu_seqlen_ks,
572
- )
625
+ if deinterleave_idx is None:
626
+ # Under the PCP path the top-k already ran over the whole
627
+ # context.
628
+ _merge_dcp_topk_global(
629
+ logits,
630
+ topk_indices,
631
+ topk_tokens,
632
+ dcp_rank,
633
+ dcp_world_size,
634
+ cp_kv_cache_interleave_size,
635
+ row_starts=chunk.cu_seqlen_ks,
636
+ )
637
638
if has_decode:
639
decode_metadata = attn_metadata_narrowed.decode
@@ -910,6 +974,10 @@ class SparseAttnIndexer(CustomOp):
974
if current_platform.is_cuda() or current_platform.is_xpu():
975
return self.forward_cuda(hidden_states, q_quant, k, weights)
976
elif current_platform.is_rocm():
977
+ if self.use_pcp and self.dcp_world_size > 1:
978
+ raise NotImplementedError(
979
+ "The ROCm sparse-indexer path does not support PCP+DCP."
980
+ )
981
return self.forward_hip(hidden_states, q_quant, k, weights)
982
elif current_platform.is_cpu():
983
return self.forward_cpu(hidden_states, q_quant, k, weights)
vllm/model_executor/layers/sparse_attn_indexer_kpool.py
+10
@@ -919,6 +919,16 @@ class SparseAttnIndexerKpool(CustomOp):
919
raise RuntimeError(
920
"Sparse Attention Indexer CUDA op requires DeepGEMM to be installed."
921
)
922
+ _cfg = get_current_vllm_config_or_none()
923
+ _parallel = _cfg.parallel_config if _cfg is not None else None
924
+ if (
925
+ _parallel is not None
926
+ and _parallel.prefill_context_parallel_size > 1
927
+ and _parallel.decode_context_parallel_size > 1
928
+ ):
929
+ raise NotImplementedError(
930
+ "SparseAttnIndexerKpool does not support PCP+DCP."
931
+ )
932
933
def forward_native(
934
self,
vllm/models/deepseek_v32/attention.py
+22
-9
@@ -1,6 +1,6 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-from typing import TYPE_CHECKING, cast
3
+from typing import TYPE_CHECKING, ClassVar, cast
4
5
import torch
6
import torch.nn as nn
@@ -121,6 +121,7 @@ class DeepseekV32Indexer(nn.Module):
121
class DeepseekV32Attention(MLAAttention):
122
indexer: "DeepseekV32Indexer | None"
123
indexer_cls: "type[DeepseekV32Indexer]" = DeepseekV32Indexer
124
+ supports_pcp_dcp: ClassVar[bool] = True
125
126
def __init__(
127
self,
@@ -569,8 +570,9 @@ class DeepseekV32Attention(MLAAttention):
570
seq_lens: torch.Tensor | None
571
query_start_loc: torch.Tensor | None
572
if self.use_pcp:
572
- if attn_metadata.decode is not None:
573
- seq_lens = attn_metadata.decode.seq_lens
573
+ decode_metadata = getattr(attn_metadata, "decode", None)
574
+ if decode_metadata is not None:
575
+ seq_lens = decode_metadata.seq_lens
576
else:
577
all_seq_lens = cast(
578
torch.Tensor,
@@ -585,12 +587,23 @@ class DeepseekV32Attention(MLAAttention):
587
# PCP-only empty-shard metadata is needed.
588
seq_lens = None
589
query_start_loc = None
588
- attn_out = self.dcp_manager.combine(
589
- attn_out,
590
- lse,
591
- seq_lens=seq_lens, # type: ignore[arg-type]
592
- query_start_loc=query_start_loc, # type: ignore[arg-type]
593
- )
590
+ # Under PCP+DCP the prefill rows attended over the gathered KV, so
591
+ # only the decode rows carry an LSE and take part in the merge.
592
+ num_merge_rows = lse.shape[0]
593
+ if num_merge_rows == attn_out.shape[0]:
594
+ attn_out = self.dcp_manager.combine(
595
+ attn_out,
596
+ lse,
597
+ seq_lens=seq_lens, # type: ignore[arg-type]
598
+ query_start_loc=query_start_loc, # type: ignore[arg-type]
599
+ )
600
+ elif num_merge_rows > 0:
601
+ attn_out[:num_merge_rows] = self.dcp_manager.combine(
602
+ attn_out[:num_merge_rows],
603
+ lse,
604
+ seq_lens=seq_lens, # type: ignore[arg-type]
605
+ query_start_loc=query_start_loc, # type: ignore[arg-type]
606
+ )
607
if self.use_pcp:
608
attn_out = finalize_mla_pcp_decode(attn_out, self.num_heads)
609
vllm/v1/attention/backend.py
+15
-2
@@ -415,9 +415,14 @@ class CommonAttentionMetadata:
415
encoder_seq_lens_cpu: np.ndarray | None = None
416
417
dcp_local_seq_lens: torch.Tensor | None = None
418
- dcp_local_seq_lens_cpu: torch.Tensor | None = None
418
"""Sequence lengths of the local rank in decode context parallelism world"""
419
420
+ dcp_local_seq_lens_cpu_upper_bound: torch.Tensor | None = None
421
+ """(batch_size,) CPU upper bound on dcp_local_seq_lens. Under PCP+DCP it
422
+ holds, on every row, the largest shard any DCP rank has of the row's
423
+ whole request, identical on every PCP rank, so the sparse backends pad
424
+ their KV gather to it."""
425
+
426
positions: torch.Tensor | None = None
427
"""(num_actual_tokens,) token positions. Optional; set when the caller
428
has positions available so that builders can pre-compute position-dependent
@@ -434,6 +439,11 @@ class CommonAttentionMetadata:
439
decode rows (assumes every draft was accepted). Not safe for kernels
440
that need exact per-row context lengths on decode rows."""
441
442
+ req_idx: np.ndarray | None = None
443
+ """(batch_size,) index of each row's request in the runner's request
444
+ table. Rows of one request are adjacent, so equal neighbours are PCP
445
+ chunks sharing one KV context."""
446
+
447
mm_req_doc_ranges: dict[int, list[tuple[int, int]]] | None = None
448
"""PrefixLM bidirectional ranges for multimodal tokens. Maps
449
request index to list of (start, end) token position ranges
@@ -525,9 +535,12 @@ class CommonAttentionMetadata:
535
encoder_seq_lens=maybe_slice_reqs(self.encoder_seq_lens),
536
encoder_seq_lens_cpu=maybe_slice_reqs(self.encoder_seq_lens_cpu),
537
dcp_local_seq_lens=maybe_slice_reqs(self.dcp_local_seq_lens),
528
- dcp_local_seq_lens_cpu=maybe_slice_reqs(self.dcp_local_seq_lens_cpu),
538
+ dcp_local_seq_lens_cpu_upper_bound=maybe_slice_reqs(
539
+ self.dcp_local_seq_lens_cpu_upper_bound
540
+ ),
541
seq_lens_cpu_upper_bound=maybe_slice_reqs(self.seq_lens_cpu_upper_bound),
542
is_prefilling=maybe_slice_reqs(self.is_prefilling),
543
+ req_idx=maybe_slice_reqs(self.req_idx),
544
rswa_prefix_lens=maybe_slice_reqs(self.rswa_prefix_lens),
545
replayssm_decode_base_cpu=maybe_slice_reqs(self.replayssm_decode_base_cpu),
546
)
vllm/v1/attention/backends/mla/flashmla_sparse.py
+215
-57
@@ -3,11 +3,14 @@
3
from dataclasses import dataclass
4
from typing import TYPE_CHECKING, ClassVar
5
6
+import numpy as np
7
import torch
8
+import torch.distributed as dist
9
10
from vllm import _custom_ops as ops
11
from vllm.config import VllmConfig, get_current_vllm_config
12
from vllm.config.cache import CacheDType
13
+from vllm.distributed.parallel_state import get_dcp_group
14
from vllm.logger import init_logger
15
from vllm.model_executor.layers.attention.sparse_mla_attention import (
16
SparseMLACommonImpl,
@@ -28,10 +31,12 @@ from vllm.v1.attention.backend import (
31
from vllm.v1.attention.backends.mla.index_group import HiSparseMLAIndexGroup
32
from vllm.v1.attention.backends.mla.sparse_utils import (
33
flat_kv_row_view,
34
+ request_row_bounds,
35
triton_convert_req_index_to_global_index,
36
triton_filter_and_convert_dcp_index,
37
)
38
from vllm.v1.attention.backends.utils import (
39
+ get_dcp_local_seq_lens,
40
reshape_attn_output_for_spec_decode,
41
reshape_query_for_spec_decode,
42
split_prefill_chunks,
@@ -43,6 +48,7 @@ from vllm.v1.attention.ops.flashmla import (
48
get_mla_metadata,
49
)
50
from vllm.v1.kv_cache_interface import AttentionSpec
51
+from vllm.v1.worker.gpu.buffer_utils import async_copy_to_gpu
52
from vllm.v1.worker.workspace import current_workspace_manager
53
54
if TYPE_CHECKING:
@@ -171,6 +177,28 @@ class FlashMLASparseBackend(AttentionBackend):
177
return None
178
179
180
+def gathered_prefill_shards(
181
+ row_req_idx: np.ndarray, row_shard_rows: np.ndarray
182
+) -> tuple[np.ndarray, np.ndarray]:
183
+ """Under PCP+DCP, group this rank's prefill rows by request and take each
184
+ request's slice of this rank's KV shard.
185
+
186
+ PCP gives a rank two adjacent chunk rows of a split prefill; they share
187
+ one context, so the workspace holds it once. ``row_shard_rows`` is
188
+ ``CommonAttentionMetadata.dcp_local_seq_lens_cpu_upper_bound``: the
189
+ largest DCP shard of the whole request on every row, identical on every
190
+ PCP rank, so the all-gather is shaped the same everywhere.
191
+
192
+ Returns the request row bounds and, per request, its shard rows.
193
+ """
194
+ row_bounds = request_row_bounds(row_req_idx)
195
+ shard_rows = row_shard_rows[row_bounds[:-1]].astype(np.int64)
196
+ assert np.all(shard_rows > 0), (
197
+ f"PCP+DCP prefill got an empty context: {shard_rows.tolist()}"
198
+ )
199
+ return row_bounds, shard_rows
200
+
201
+
202
@dataclass
203
class FlashMLASparseMetadata(SparseMLACommonMetadata):
204
@dataclass
@@ -331,8 +359,13 @@ class FlashMLASparseMetadataBuilder(
359
device=device,
360
)
361
362
+ # PCP+DCP attends prefill rows over the DCP-gathered KV, which only the
363
+ # separate prefill/decode path can do.
364
+ self.pcp_dcp_kv_gather = self.use_pcp and self.dcp_world_size > 1
365
self.fp8_use_mixed_batch = (
335
- self.num_heads < MIN_HEADS_FOR_BF16_PREFILL and not self.use_hisparse
366
+ self.num_heads < MIN_HEADS_FOR_BF16_PREFILL
367
+ and not self.use_hisparse
368
+ and not self.pcp_dcp_kv_gather
369
)
370
371
if parallel_config.decode_context_parallel_size > 1:
@@ -342,7 +375,12 @@ class FlashMLASparseMetadataBuilder(
375
"default 'ag_rs' DCP comm backend; got "
376
f"'{parallel_config.dcp_comm_backend}'"
377
)
345
- if not self.fp8_use_mixed_batch:
378
+ if self.pcp_dcp_kv_gather and cache_config.cache_dtype != "fp8_ds_mla":
379
+ raise NotImplementedError(
380
+ "PCP+DCP sparse prefill gathers the KV through the fp8_ds_mla "
381
+ f"upconvert; got a {cache_config.cache_dtype} cache"
382
+ )
383
+ if not self.fp8_use_mixed_batch and not self.pcp_dcp_kv_gather:
384
raise NotImplementedError(
385
"DCP for FlashMLA sparse is only supported on the "
386
"mixed-batch fp8 path (num_heads < "
@@ -353,9 +391,16 @@ class FlashMLASparseMetadataBuilder(
391
# Head padding (and the tile-scheduler metadata sized from it) is
392
# computed from the local head count, but the kernel runs on the
393
# DCP-gathered heads.
356
- gathered_num_heads = (
357
- self.num_heads * parallel_config.decode_context_parallel_size
358
- )
394
+ if self.use_pcp:
395
+ gathered_num_heads = (
396
+ self.num_heads * parallel_config.tensor_parallel_size
397
+ if self.dcp_world_size > self.pcp_world_size
398
+ else self.num_heads
399
+ )
400
+ else:
401
+ gathered_num_heads = (
402
+ self.num_heads * parallel_config.decode_context_parallel_size
403
+ )
404
gathered_padded_heads = FlashMLASparseImpl._compute_fp8_decode_padded_heads(
405
gathered_num_heads
406
)
@@ -433,6 +478,40 @@ class FlashMLASparseMetadataBuilder(
478
query_start_loc_cpu = common_attn_metadata.query_start_loc_cpu
479
480
prefill_seq_lens_cpu = seq_lens_cpu[num_decodes:]
481
+ # Chunk prefill requests to fit within workspace size
482
+ max_prefill_buffer_size = get_prefill_workspace_size(
483
+ self.vllm_config.model_config.max_model_len
484
+ )
485
+ # Workspace rows per entry, and the prefill rows each entry covers.
486
+ workspace_rows = prefill_seq_lens_cpu
487
+ row_bounds = np.arange(num_prefills + 1)
488
+ prefill_block_table = common_attn_metadata.block_table_tensor[num_decodes:]
489
+ prefill_seq_lens = common_attn_metadata.seq_lens[num_decodes:]
490
+ if self.pcp_dcp_kv_gather:
491
+ # One entry per request, holding this rank's KV shard: the
492
+ # all-gather of every rank's shard is the whole context.
493
+ # A dummy batch bypasses the PCP manager: one row per request,
494
+ # so the row's own extent is the request's.
495
+ row_req_idx = common_attn_metadata.req_idx
496
+ if row_req_idx is None:
497
+ row_req_idx = np.arange(common_attn_metadata.num_reqs)
498
+ shard_rows_cpu = common_attn_metadata.dcp_local_seq_lens_cpu_upper_bound
499
+ if shard_rows_cpu is None:
500
+ shard_rows_cpu = get_dcp_local_seq_lens(
501
+ seq_lens_cpu,
502
+ self.dcp_world_size,
503
+ 0,
504
+ self.cp_kv_cache_interleave_size,
505
+ )
506
+ row_bounds, rows_per_rank = gathered_prefill_shards(
507
+ row_req_idx[num_decodes:], shard_rows_cpu[num_decodes:].numpy()
508
+ )
509
+ workspace_rows = torch.from_numpy(rows_per_rank.astype(np.int32))
510
+ max_prefill_buffer_size //= self.dcp_world_size
511
+ entry_rows = async_copy_to_gpu(row_bounds[:-1], device=self.device)
512
+ prefill_block_table = prefill_block_table.index_select(0, entry_rows)
513
+ prefill_seq_lens = prefill_seq_lens.index_select(0, entry_rows)
514
+ num_entries = len(workspace_rows)
515
516
# Build prefill_request_id: -1 for decode, request index for
517
# prefill. This enables a single
@@ -441,33 +520,28 @@ class FlashMLASparseMetadataBuilder(
520
(num_tokens,), -1, dtype=torch.int32, device=self.device
521
)
522
# Map prefill tokens to their request IDs (0, 1, 2, ...)
444
- for req_idx in range(num_prefills):
523
+ for req_idx in range(num_entries):
524
# Get query token range for this prefill request
446
- global_req_idx = num_decodes + req_idx
447
- req_query_start = query_start_loc_cpu[global_req_idx]
448
- req_query_end = query_start_loc_cpu[global_req_idx + 1]
525
+ req_query_start = query_start_loc_cpu[
526
+ num_decodes + int(row_bounds[req_idx])
527
+ ]
528
+ req_query_end = query_start_loc_cpu[
529
+ num_decodes + int(row_bounds[req_idx + 1])
530
+ ]
531
prefill_request_id[req_query_start:req_query_end] = req_idx
532
533
# will be adjusted by chunk loop
534
prefill_workspace_starts_cpu = torch.zeros(
453
- num_prefills, dtype=torch.int32, pin_memory=True
454
- )
455
- prefill_workspace_starts_cpu[1:] = torch.cumsum(
456
- prefill_seq_lens_cpu[:-1], dim=0
535
+ num_entries, dtype=torch.int32, pin_memory=True
536
)
537
+ prefill_workspace_starts_cpu[1:] = torch.cumsum(workspace_rows[:-1], dim=0)
538
# populated by non-blocking copy after prefill_workspace_starts_cpu is
539
# updated by each chunk
540
prefill_workspace_starts = torch.empty(
461
- num_prefills, dtype=torch.int32, device=self.device
541
+ num_entries, dtype=torch.int32, device=self.device
542
)
543
464
- # Chunk prefill requests to fit within workspace size
465
- max_prefill_buffer_size = get_prefill_workspace_size(
466
- self.vllm_config.model_config.max_model_len
467
- )
468
- chunk_bounds = split_prefill_chunks(
469
- prefill_seq_lens_cpu, max_prefill_buffer_size
470
- )
544
+ chunk_bounds = split_prefill_chunks(workspace_rows, max_prefill_buffer_size)
545
546
prefill_chunks = []
547
for chunk_start, chunk_end in chunk_bounds:
@@ -480,19 +554,19 @@ class FlashMLASparseMetadataBuilder(
554
offset = prefill_workspace_starts_cpu[chunk_start].item()
555
prefill_workspace_starts_cpu[chunk_start:chunk_end] -= offset
556
483
- chunk_tot_seqlen = prefill_seq_lens_cpu[chunk_start:chunk_end].sum()
484
- token_start = query_start_loc_cpu[num_decodes + chunk_start].item()
485
- token_end = query_start_loc_cpu[num_decodes + chunk_end].item()
557
+ chunk_tot_seqlen = workspace_rows[chunk_start:chunk_end].sum()
558
+ token_start = query_start_loc_cpu[
559
+ num_decodes + int(row_bounds[chunk_start])
560
+ ].item()
561
+ token_end = query_start_loc_cpu[
562
+ num_decodes + int(row_bounds[chunk_end])
563
+ ].item()
564
tokens_slice = slice(token_start, token_end)
565
566
# Create chunk view of gpu tensor
567
chunk_workspace_starts = prefill_workspace_starts[chunk_start:chunk_end]
490
- chunk_block_table = common_attn_metadata.block_table_tensor[
491
- num_decodes + chunk_start : num_decodes + chunk_end
492
- ]
493
- chunk_seq_lens = common_attn_metadata.seq_lens[
494
- num_decodes + chunk_start : num_decodes + chunk_end
495
- ]
568
+ chunk_block_table = prefill_block_table[chunk_start:chunk_end]
569
+ chunk_seq_lens = prefill_seq_lens[chunk_start:chunk_end]
570
571
prefill_chunks.append(
572
FP8Meta.Prefill.Chunk(
@@ -516,14 +590,19 @@ class FlashMLASparseMetadataBuilder(
590
)
591
592
if num_decodes > 0:
519
- # Use padded head count since that's what the kernel will see
520
- scheduler_metadata, _ = get_mla_metadata()
593
+ if self.pcp_dcp_kv_gather:
594
+ # Decode rows take the mixed-batch kernel call, which returns
595
+ # the LSE the DCP merge needs.
596
+ kernel_meta = self._build_fp8_mixed_decode_prefill()
597
+ else:
598
+ # Use padded head count since that's what the kernel will see
599
+ scheduler_metadata, _ = get_mla_metadata()
600
522
- kernel_meta = FlashMLASparseMetadata.FP8KernelMetadata(
523
- scheduler_metadata=scheduler_metadata,
524
- dummy_block_table=self.dummy_block_table[:active_num_decodes],
525
- cache_lens=self.max_model_len_tensor[:active_num_decodes],
526
- )
601
+ kernel_meta = FlashMLASparseMetadata.FP8KernelMetadata(
602
+ scheduler_metadata=scheduler_metadata,
603
+ dummy_block_table=self.dummy_block_table[:active_num_decodes],
604
+ cache_lens=self.max_model_len_tensor[:active_num_decodes],
605
+ )
606
fp8_metadata.decode = FP8Meta.Decode(
607
seq_lens=common_attn_metadata.seq_lens[:active_num_decodes],
608
kernel_metadata=kernel_meta,
@@ -626,19 +705,35 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
705
"the bf16 sparse path is not supported under DCP."
706
)
707
708
+ self.pcp_dcp_kv_gather = False
709
+ self.gathered_kv_workspace: torch.Tensor | None = None
710
if kv_cache_dtype in QUANTIZED_DS_MLA_CACHE_FORMATS:
711
# Reserve workspace during initialization
712
assert vllm_config is not None and vllm_config.model_config is not None
713
prefill_workspace_size = get_prefill_workspace_size(
714
vllm_config.model_config.max_model_len
715
)
635
- self.prefill_workspace_shape = (prefill_workspace_size, head_size)
636
- self.q_concat_buffer, self.prefill_bf16_workspace = (
637
- current_workspace_manager().get_simultaneous(
638
- (q_concat_shape, torch.bfloat16),
639
- (self.prefill_workspace_shape, torch.bfloat16),
640
- )
716
+ parallel_config = vllm_config.parallel_config
717
+ self.pcp_dcp_kv_gather = (
718
+ parallel_config.prefill_context_parallel_size > 1
719
+ and parallel_config.decode_context_parallel_size > 1
720
)
721
+ shard_rows = prefill_workspace_size
722
+ if self.pcp_dcp_kv_gather:
723
+ # PCP+DCP upconverts this rank's KV shard, then all-gathers the
724
+ # shards into a workspace of the full prefill size.
725
+ shard_rows //= parallel_config.decode_context_parallel_size
726
+ self.prefill_workspace_shape = (shard_rows, head_size)
727
+ specs = [
728
+ (q_concat_shape, torch.bfloat16),
729
+ (self.prefill_workspace_shape, torch.bfloat16),
730
+ ]
731
+ if self.pcp_dcp_kv_gather:
732
+ specs.append(((prefill_workspace_size, head_size), torch.bfloat16))
733
+ buffers = current_workspace_manager().get_simultaneous(*specs)
734
+ self.q_concat_buffer, self.prefill_bf16_workspace = buffers[:2]
735
+ if self.pcp_dcp_kv_gather:
736
+ self.gathered_kv_workspace = buffers[2]
737
else:
738
(self.q_concat_buffer,) = current_workspace_manager().get_simultaneous(
739
(q_concat_shape, torch.bfloat16),
@@ -729,13 +824,49 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
824
return attn_out, lse
825
return torch.cat([decode_out, attn_out], dim=0), None
826
827
+ def _gather_prefill_chunk(
828
+ self,
829
+ chunk: "FlashMLASparseMetadata.FP8SeparatePrefillDecode.Prefill.Chunk",
830
+ shard: torch.Tensor,
831
+ topk_indices: torch.Tensor,
832
+ attn_metadata: FlashMLASparseMetadata,
833
+ prefill_meta: "FlashMLASparseMetadata.FP8SeparatePrefillDecode.Prefill",
834
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
835
+ """All-gather this rank's upconverted KV shard so the chunk's rows attend
836
+ the whole context, and map their top-k onto the rank-major result."""
837
+ shard_rows = int(chunk.chunk_tot_seqlen)
838
+ assert self.gathered_kv_workspace is not None
839
+ gathered_kv = self.gathered_kv_workspace[: self.dcp_world_size * shard_rows]
840
+ dist.all_gather_into_tensor(
841
+ gathered_kv, shard, group=get_dcp_group().device_group
842
+ )
843
+
844
+ tokens = chunk.tokens_slice
845
+ topk_indices, topk_length = triton_convert_req_index_to_global_index(
846
+ attn_metadata.req_id_per_token[tokens],
847
+ attn_metadata.block_table,
848
+ topk_indices,
849
+ BLOCK_SIZE=attn_metadata.block_size,
850
+ NUM_TOPK_TOKENS=topk_indices.shape[1],
851
+ HAS_PREFILL_WORKSPACE=True,
852
+ prefill_workspace_request_ids=prefill_meta.request_ids[tokens],
853
+ prefill_workspace_starts=prefill_meta.workspace_starts,
854
+ prefill_workspace_rank_stride=shard_rows,
855
+ dcp_size=self.dcp_world_size,
856
+ dcp_rank=self.dcp_rank,
857
+ cp_kv_cache_interleave_size=attn_metadata.cp_kv_cache_interleave_size,
858
+ return_valid_counts=True,
859
+ )
860
+ return gathered_kv, topk_indices, topk_length
861
+
862
def _forward_fp8_kv_separate_prefill_decode(
863
self,
864
q: torch.Tensor,
865
kv_c_and_k_pe_cache: torch.Tensor,
866
topk_indices: torch.Tensor,
867
attn_metadata: FlashMLASparseMetadata,
738
- ) -> torch.Tensor:
868
+ ) -> tuple[torch.Tensor, torch.Tensor | None]:
869
+ """The lse covers the decode rows and is only returned under PCP+DCP."""
870
fp8_metadata = attn_metadata.fp8_extra_metadata
871
assert isinstance(fp8_metadata, FlashMLASparseMetadata.FP8SeparatePrefillDecode)
872
num_decodes = fp8_metadata.num_decodes
@@ -783,8 +914,13 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
914
# For BF16 cache: always use global cache slots (no workspace)
915
# prefill_workspace_starts has been adjusted in-place per chunk so
916
# prefill indices automatically come out chunk-local
786
- topk_length = None
787
- if num_prefill_tokens == 0 and not uses_host_cache:
917
+ # Under PCP+DCP the decode rows are converted by the mixed-batch call
918
+ # and the prefill rows per chunk, against the gathered workspace.
919
+ lse: torch.Tensor | None = None
920
+ topk_length: torch.Tensor | None = None
921
+ if self.pcp_dcp_kv_gather:
922
+ pass
923
+ elif num_prefill_tokens == 0 and not uses_host_cache:
924
topk_indices, topk_length = self._convert_logical_to_physical_topk(
925
topk_indices,
926
attn_metadata,
@@ -812,6 +948,17 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
948
topk_indices: torch.Tensor,
949
) -> torch.Tensor:
950
assert fp8_metadata.decode is not None
951
+ if self.pcp_dcp_kv_gather:
952
+ # Attend this rank's KV shard; the LSE feeds the DCP merge.
953
+ nonlocal lse
954
+ out, lse = self._forward_fp8_kv_mixed_batch(
955
+ q,
956
+ kv_c_and_k_pe_cache,
957
+ topk_indices,
958
+ attn_metadata,
959
+ kernel_metadata=fp8_metadata.decode.kernel_metadata,
960
+ )
961
+ return out
962
if uses_host_cache:
963
return self._host_backed_fp8_decode(
964
q,
@@ -896,9 +1043,20 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
1043
)
1044
1045
chunk_q = q[chunk.tokens_slice]
899
- chunk_topk_indices_workspace = topk_indices[chunk.tokens_slice]
900
- assert topk_length is not None
901
- chunk_topk_length = topk_length[chunk.tokens_slice]
1046
+ if self.pcp_dcp_kv_gather:
1047
+ chunk_workspace, chunk_topk_indices_workspace, chunk_topk_length = (
1048
+ self._gather_prefill_chunk(
1049
+ chunk,
1050
+ chunk_workspace,
1051
+ topk_indices[chunk.tokens_slice],
1052
+ attn_metadata,
1053
+ fp8_metadata.prefill,
1054
+ )
1055
+ )
1056
+ else:
1057
+ chunk_topk_indices_workspace = topk_indices[chunk.tokens_slice]
1058
+ assert topk_length is not None
1059
+ chunk_topk_length = topk_length[chunk.tokens_slice]
1060
1061
attn_out[chunk.tokens_slice], _ = self._bf16_flash_mla_kernel(
1062
chunk_q,
@@ -907,7 +1065,10 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
1065
chunk_topk_length,
1066
)
1067
910
- return attn_out
1068
+ if self.pcp_dcp_kv_gather and lse is None:
1069
+ # No decode rows: the DCP merge still expects an LSE, of no rows.
1070
+ lse = q.new_empty((0, q.shape[1]), dtype=torch.float32)
1071
+ return attn_out, lse
1072
1073
def _forward_fp8_kv_mixed_batch(
1074
self,
@@ -915,6 +1076,7 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
1076
kv_c_and_k_pe_cache: torch.Tensor,
1077
topk_indices: torch.Tensor,
1078
attn_metadata: FlashMLASparseMetadata,
1079
+ kernel_metadata: "FlashMLASparseMetadata.FP8KernelMetadata | None" = None,
1080
) -> tuple[torch.Tensor, torch.Tensor | None]:
1081
"""Mixed batch FP8 forward path that treats all tokens as one batch.
1082
@@ -924,12 +1086,8 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
1086
1087
The lse is only returned when DCP needs it, otherwise None.
1088
"""
927
- assert attn_metadata.fp8_extra_metadata is not None
928
- assert isinstance(
929
- attn_metadata.fp8_extra_metadata,
930
- FlashMLASparseMetadata.FP8KernelMetadata,
931
- )
932
- fp8_metadata = attn_metadata.fp8_extra_metadata
1089
+ fp8_metadata = kernel_metadata or attn_metadata.fp8_extra_metadata
1090
+ assert isinstance(fp8_metadata, FlashMLASparseMetadata.FP8KernelMetadata)
1091
1092
block_table = attn_metadata.block_table
1093
# req_id_per_token covers the whole batch; slice it to the MQA tokens
@@ -1160,7 +1318,7 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
1318
q, kv_c_and_k_pe_cache, topk_indices, attn_metadata
1319
)
1320
else:
1163
- attn_out = self._forward_fp8_kv_separate_prefill_decode(
1321
+ attn_out, lse = self._forward_fp8_kv_separate_prefill_decode(
1322
q, kv_c_and_k_pe_cache, topk_indices, attn_metadata
1323
)
1324
vllm/v1/attention/backends/mla/indexer.py
+210
-20
@@ -3,6 +3,7 @@
3
from dataclasses import dataclass
4
from typing import Any
5
6
+import numpy as np
7
import torch
8
9
import vllm.envs as envs
@@ -25,6 +26,7 @@ from vllm.utils.deep_gemm import (
26
native_next_n_supported,
27
)
28
from vllm.utils.platform_utils import num_compute_units
29
+from vllm.utils.torch_utils import PIN_MEMORY
30
from vllm.v1.attention.backend import (
31
AttentionBackend,
32
AttentionCGSupport,
@@ -33,6 +35,7 @@ from vllm.v1.attention.backend import (
35
MultipleOf,
36
)
37
from vllm.v1.attention.backends.mla.compressor_utils import get_compressed_slot_mapping
38
+from vllm.v1.attention.backends.mla.sparse_utils import request_row_bounds
39
from vllm.v1.attention.backends.utils import (
40
get_dcp_local_seq_lens,
41
split_decodes_and_prefills,
@@ -43,6 +46,7 @@ from vllm.v1.kv_cache_interface import (
46
KVCacheSpec,
47
MLAAttentionSpec,
48
)
49
+from vllm.v1.worker.gpu.buffer_utils import async_copy_to_gpu
50
51
logger = init_logger(__name__)
52
@@ -259,6 +263,87 @@ class DeepseekV41IndexerBackend(DeepseekV4IndexerBackend):
263
return [64 if current_platform.is_device_capability_family(90) else 128]
264
265
266
+@dataclass(frozen=True)
267
+class PCPGlobalChunkPlan:
268
+ """PCP packing for one indexer prefill chunk under PCP + DCP."""
269
+
270
+ # [num_reqs+1] cumsum of the padded per-request context (shard rows x W):
271
+ # the global layout.
272
+ row_start_cu: torch.Tensor
273
+ # [num_reqs+1] like row_start_cu, but only a region's FIRST row carries its
274
+ # extent.
275
+ global_cu: torch.Tensor
276
+ # [num_reqs+1] cumsum of shard rows: this rank's padded layout.
277
+ padded_local_cu: torch.Tensor
278
+ padded_local_total: int
279
+ total: int
280
+ # [total] for each global row, its position in the rank-major gathered buffer.
281
+ deinterleave_idx: torch.Tensor
282
+
283
+
284
+def build_pcp_global_chunk_plan(
285
+ row_req_idx: np.ndarray,
286
+ row_shard_rows: np.ndarray,
287
+ dcp_world_size: int,
288
+ device: torch.device,
289
+) -> PCPGlobalChunkPlan:
290
+ """Plan the PCP packing for one chunk from its requests' KV shard rows.
291
+
292
+ The global layout is padded to ``shard rows x W`` per request. Positions
293
+ past a request's true extent lie beyond every token's causal bound, so
294
+ the kernel never reads them.
295
+ """
296
+ shard_rows = np.ascontiguousarray(row_shard_rows, dtype=np.int64)
297
+ assert row_req_idx.shape == shard_rows.shape
298
+ num_rows = len(shard_rows)
299
+
300
+ row_bounds = request_row_bounds(row_req_idx)
301
+ region_first_row = row_bounds[:-1]
302
+ region_of_row = np.repeat(np.arange(len(region_first_row)), np.diff(row_bounds))
303
+
304
+ region_padded = shard_rows[region_first_row]
305
+ assert np.all(region_padded > 0), (
306
+ f"PCP+DCP prefill got an empty context: {region_padded.tolist()}"
307
+ )
308
+
309
+ # Pinned host buffers, filled in place so the copies below are async.
310
+ cu = torch.zeros((3, num_rows + 1), dtype=torch.int32, pin_memory=PIN_MEMORY)
311
+ row_start_cu, global_cu, padded_cu = cu
312
+ # Only a region's first row carries its extent.
313
+ first_row = torch.from_numpy(region_first_row)
314
+ row_padded = torch.zeros(num_rows, dtype=torch.int32)
315
+ row_padded[first_row] = torch.from_numpy(region_padded).int()
316
+ torch.cumsum(row_padded * dcp_world_size, 0, out=global_cu[1:])
317
+ torch.cumsum(row_padded, 0, out=padded_cu[1:])
318
+ row_start_cu[:num_rows] = global_cu[first_row][torch.from_numpy(region_of_row)]
319
+ row_start_cu[num_rows] = global_cu[num_rows]
320
+ total = int(global_cu[num_rows])
321
+ padded_total = int(padded_cu[num_rows])
322
+
323
+ idx = torch.empty(total, dtype=torch.int64, pin_memory=PIN_MEMORY)
324
+ idx_np = idx.numpy()
325
+ region_start = global_cu[first_row].numpy()
326
+ region_padded_start = padded_cu[first_row].numpy()
327
+ for i in range(len(region_first_row)):
328
+ g = int(region_padded[i]) * dcp_world_size
329
+ t = np.arange(g, dtype=np.int64)
330
+ idx_np[region_start[i] : region_start[i] + g] = (
331
+ (t % dcp_world_size) * padded_total
332
+ + region_padded_start[i]
333
+ + t // dcp_world_size
334
+ )
335
+
336
+ cu = async_copy_to_gpu(cu, device=device)
337
+ return PCPGlobalChunkPlan(
338
+ row_start_cu=cu[0],
339
+ global_cu=cu[1],
340
+ padded_local_cu=cu[2],
341
+ padded_local_total=padded_total,
342
+ total=total,
343
+ deinterleave_idx=async_copy_to_gpu(idx, device=device),
344
+ )
345
+
346
+
347
@dataclass
348
class DeepseekV32IndexerPrefillChunkMetadata:
349
block_table: torch.Tensor
@@ -277,6 +362,8 @@ class DeepseekV32IndexerPrefillChunkMetadata:
362
local_total_seq_lens: int = 0
363
max_local_total_seq_lens: int = 0
364
365
+ pcp_deinterleave_idx: torch.Tensor | None = None
366
+
367
368
class BuildPrefillChunkMetadataKernel(
369
VllmTritonJitKernel["BuildPrefillChunkMetadataKernel.CompileKey"]
@@ -960,6 +1047,48 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
1047
self.global_decode_seq_lens_buffer[actual_expanded:num_decode_tokens] = 0
1048
return self.global_decode_seq_lens_buffer[:num_decode_tokens]
1049
1050
+ def _split_pcp_dcp_prefill_chunks(
1051
+ self,
1052
+ row_req_idx: np.ndarray,
1053
+ row_shard_rows: np.ndarray,
1054
+ row_query_lens_cpu: torch.Tensor,
1055
+ max_logits_bytes: int,
1056
+ request_offset: int,
1057
+ ) -> list[tuple[slice, slice]]:
1058
+ """Chunk by request rather than by row, so a split prefill's two rows
1059
+ charge their shared context once, then widen each chunk back to rows.
1060
+
1061
+ ``row_shard_rows`` holds the whole request's largest DCP shard on
1062
+ every row, so the plan is identical on every PCP rank.
1063
+ """
1064
+ row_bounds = request_row_bounds(row_req_idx)
1065
+ first_rows = row_bounds[:-1]
1066
+ # Each request's context, padded to whole DCP shards.
1067
+ seq_lens = row_shard_rows[first_rows] * self.dcp_world_size
1068
+ # Every rank holds a full-size chunk of a split request (shorter ones
1069
+ # are replicated), so rows x longest row is the same on every rank,
1070
+ # whichever row holds the short tail.
1071
+ row_query_lens = row_query_lens_cpu.numpy()
1072
+ query_lens = np.diff(row_bounds) * np.maximum.reduceat(
1073
+ row_query_lens, first_rows
1074
+ )
1075
+ chunk_specs = self._split_indexer_prefill_chunks(
1076
+ torch.from_numpy(seq_lens.astype(np.int32)),
1077
+ torch.from_numpy(query_lens.astype(np.int32)),
1078
+ self.max_prefill_buffer_size,
1079
+ max_logits_bytes,
1080
+ )
1081
+ return [
1082
+ (
1083
+ slice(
1084
+ request_offset + int(row_bounds[request_slice.start]),
1085
+ request_offset + int(row_bounds[request_slice.stop]),
1086
+ ),
1087
+ query_slice,
1088
+ )
1089
+ for request_slice, query_slice in chunk_specs
1090
+ ]
1091
+
1092
@staticmethod
1093
def _split_indexer_prefill_chunks(
1094
compressed_seq_lens_cpu: torch.Tensor,
@@ -1094,16 +1223,52 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
1223
# slice below).
1224
assert common_attn_metadata.seq_lens_cpu_upper_bound is not None
1225
seq_lens_cpu = common_attn_metadata.seq_lens_cpu_upper_bound
1097
- chunk_specs = self._split_indexer_prefill_chunks(
1098
- compressed_seq_lens_cpu[num_decodes:],
1099
- prefill_query_lens_cpu,
1100
- self.max_prefill_buffer_size,
1101
- max_logits_bytes,
1102
- request_offset=num_decodes,
1103
- )
1226
+ req_idx = None
1227
+ shard_rows = None
1228
+ if self.use_pcp and self.dcp_world_size > 1:
1229
+ # The gathered KV must be packed identically on every PCP rank:
1230
+ # chunk by request from its DCP shard rows, which every rank
1231
+ # holds. A dummy batch bypasses the PCP manager and has one
1232
+ # row per request, so its own extent is the request's.
1233
+ req_idx = common_attn_metadata.req_idx
1234
+ if req_idx is None:
1235
+ req_idx = np.arange(num_reqs)
1236
+ shard_rows_cpu = common_attn_metadata.dcp_local_seq_lens_cpu_upper_bound
1237
+ if shard_rows_cpu is None:
1238
+ shard_rows_cpu = get_dcp_local_seq_lens(
1239
+ seq_lens_cpu,
1240
+ self.dcp_world_size,
1241
+ 0,
1242
+ self.cp_kv_cache_interleave_size,
1243
+ )
1244
+ shard_rows = shard_rows_cpu.numpy()
1245
+ chunk_specs = self._split_pcp_dcp_prefill_chunks(
1246
+ req_idx[num_decodes:],
1247
+ shard_rows[num_decodes:],
1248
+ prefill_query_lens_cpu,
1249
+ max_logits_bytes,
1250
+ request_offset=num_decodes,
1251
+ )
1252
+ else:
1253
+ chunk_specs = self._split_indexer_prefill_chunks(
1254
+ compressed_seq_lens_cpu[num_decodes:],
1255
+ prefill_query_lens_cpu,
1256
+ self.max_prefill_buffer_size,
1257
+ max_logits_bytes,
1258
+ request_offset=num_decodes,
1259
+ )
1260
1261
chunks = []
1262
for req_slice, query_slice in chunk_specs:
1263
+ pcp_plan = None
1264
+ if req_idx is not None:
1265
+ assert shard_rows is not None
1266
+ pcp_plan = build_pcp_global_chunk_plan(
1267
+ req_idx[req_slice],
1268
+ shard_rows[req_slice],
1269
+ self.dcp_world_size,
1270
+ self.device,
1271
+ )
1272
metadata = build_prefill_chunk_metadata(
1273
req_slice.start,
1274
req_slice.stop,
@@ -1119,6 +1284,7 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
1284
dcp_rank=self.dcp_rank,
1285
dcp_world_size=self.dcp_world_size,
1286
cp_kv_cache_interleave_size=self.cp_kv_cache_interleave_size,
1287
+ pcp_plan=pcp_plan,
1288
)
1289
# Skip when total_seq_lens is 0 (i.e., no compressed token).
1290
if metadata is not None:
@@ -1353,8 +1519,12 @@ def build_prefill_chunk_metadata(
1519
dcp_rank: int = 0,
1520
dcp_world_size: int = 1,
1521
cp_kv_cache_interleave_size: int = 1,
1522
+ pcp_plan: PCPGlobalChunkPlan | None = None,
1523
) -> DeepseekV32IndexerPrefillChunkMetadata | None:
1357
- total_seq_lens = compressed_seq_lens_cpu[start_idx:end_idx].sum().item()
1524
+ if pcp_plan is not None:
1525
+ total_seq_lens = pcp_plan.total
1526
+ else:
1527
+ total_seq_lens = compressed_seq_lens_cpu[start_idx:end_idx].sum().item()
1528
if total_seq_lens == 0:
1529
return None
1530
@@ -1362,15 +1532,22 @@ def build_prefill_chunk_metadata(
1532
device = block_table.device
1533
token_to_seq = torch.empty(total_seq_lens, dtype=torch.int32, device=device)
1534
1365
- cu_seq_lens = torch.empty(num_reqs + 1, dtype=torch.int32, device=device)
1366
- # Assigning to slice avoids cpu sync.
1367
- cu_seq_lens[:1] = 0
1368
- torch.cumsum(compressed_seq_lens[start_idx:end_idx], dim=0, out=cu_seq_lens[1:])
1535
+ if pcp_plan is not None:
1536
+ cu_seq_lens = pcp_plan.global_cu
1537
+ else:
1538
+ cu_seq_lens = torch.empty(num_reqs + 1, dtype=torch.int32, device=device)
1539
+ # Assigning to slice avoids cpu sync.
1540
+ cu_seq_lens[:1] = 0
1541
+ torch.cumsum(compressed_seq_lens[start_idx:end_idx], dim=0, out=cu_seq_lens[1:])
1542
1543
local_cu_seq_lens = cu_seq_lens
1544
local_total_seq_lens = total_seq_lens
1545
max_local_total_seq_lens = total_seq_lens
1373
- if dcp_world_size > 1:
1546
+ if pcp_plan is not None:
1547
+ local_cu_seq_lens = pcp_plan.padded_local_cu
1548
+ local_total_seq_lens = pcp_plan.padded_local_total
1549
+ max_local_total_seq_lens = pcp_plan.padded_local_total
1550
+ elif dcp_world_size > 1:
1551
# Per-rank local KV length under interleave-aware DCP sharding, shape
1552
# [num_reqs, dcp_world_size]. Reuse the canonical CP helper so the
1553
# sharding matches the rest of the DCP pipeline (decode/prefill).
@@ -1394,35 +1571,47 @@ def build_prefill_chunk_metadata(
1571
(query_start_loc_cpu[end_idx] - query_start_loc_cpu[start_idx]).item()
1572
)
1573
if query_slice is not None:
1397
- qs_start = query_slice.start
1398
- qs_stop = query_slice.stop
1574
+ qs_start = min(query_slice.start, total_query_len)
1575
+ qs_stop = min(query_slice.stop, total_query_len)
1576
else:
1577
qs_start = 0
1578
qs_stop = total_query_len
1579
output_query_len = qs_stop - qs_start
1580
+ assert 0 <= output_query_len <= total_query_len - qs_start, (
1581
+ f"query slice [{qs_start}, {qs_stop}) does not fit this rank's "
1582
+ f"{total_query_len} tokens; the metadata tail would stay uninitialized"
1583
+ )
1584
1585
cu_seq_len_ks = torch.empty(output_query_len, dtype=torch.int32, device=device)
1586
cu_seq_len_ke = torch.empty(output_query_len, dtype=torch.int32, device=device)
1587
1407
- # Under DCP the kernel writes this rank's local row bounds into
1408
- # cu_seq_len_ks/ke; otherwise local_cu_seq_lens aliases cu_seq_lens.
1588
+ if pcp_plan is not None:
1589
+ row_start_cu = pcp_plan.row_start_cu
1590
+ kernel_dcp_rank, kernel_dcp_world = 0, 1
1591
+ else:
1592
+ # Under DCP the kernel writes this rank's local row bounds into
1593
+ # cu_seq_len_ks/ke; otherwise local_cu_seq_lens aliases cu_seq_lens.
1594
+ row_start_cu = local_cu_seq_lens
1595
+ kernel_dcp_rank, kernel_dcp_world = dcp_rank, dcp_world_size
1596
_BUILD_PREFILL_CHUNK_METADATA_KERNEL(
1597
query_start_loc,
1598
uncompressed_seq_lens[start_idx:end_idx],
1599
cu_seq_lens,
1413
- local_cu_seq_lens,
1600
+ row_start_cu,
1601
token_to_seq,
1602
cu_seq_len_ks,
1603
cu_seq_len_ke,
1604
qs_start,
1605
qs_stop,
1419
- dcp_rank,
1420
- dcp_world_size,
1606
+ kernel_dcp_rank,
1607
+ kernel_dcp_world,
1608
cp_kv_cache_interleave_size,
1609
num_reqs=num_reqs,
1610
COMPRESS_RATIO=compress_ratio,
1611
)
1612
1613
+ pcp_deinterleave_idx = pcp_plan.deinterleave_idx if pcp_plan is not None else None
1614
+
1615
token_start = query_start_loc_cpu[start_idx].item()
1616
if query_slice is not None:
1617
token_end = token_start + qs_stop
@@ -1432,6 +1621,7 @@ def build_prefill_chunk_metadata(
1621
token_end = query_start_loc_cpu[end_idx].item()
1622
1623
return DeepseekV32IndexerPrefillChunkMetadata(
1624
+ pcp_deinterleave_idx=pcp_deinterleave_idx,
1625
cu_seqlen_ks=cu_seq_len_ks,
1626
cu_seqlen_ke=cu_seq_len_ke,
1627
cu_seq_lens=cu_seq_lens,
vllm/v1/attention/backends/mla/sparse_utils.py
+52
-7
@@ -5,6 +5,7 @@
5
from dataclasses import dataclass
6
from typing import Any
7
8
+import numpy as np
9
import torch
10
11
from vllm.config import VllmConfig
@@ -24,6 +25,21 @@ from vllm.utils.math_utils import cdiv
25
from vllm.v1.worker.block_table import get_block_table_width
26
27
28
+def request_row_bounds(req_idx: np.ndarray) -> np.ndarray:
29
+ """Bounds of the runs of adjacent rows that belong to one request: run
30
+ ``r`` is rows ``[bounds[r], bounds[r + 1])``.
31
+
32
+ Under PCP a rank holds two adjacent chunk rows of a split prefill; the
33
+ sparse backends give such a run one KV region.
34
+ """
35
+ assert req_idx.size > 0
36
+ bounds = np.flatnonzero(np.r_[True, req_idx[1:] != req_idx[:-1], True])
37
+ assert bounds.size - 1 == np.unique(req_idx).size, (
38
+ "rows of one request must be adjacent"
39
+ )
40
+ return bounds
41
+
42
+
43
def flat_kv_row_view(
44
kv_cache: torch.Tensor, # [num_blocks, block_size, head_dim]
45
block_size: int,
@@ -69,7 +85,7 @@ class ConvertReqIndexToGlobalIndexKernel(
85
block_table_stride: int
86
87
@staticmethod
72
- @triton.jit(do_not_specialize=["max_num_blocks_per_req"])
88
+ @triton.jit(do_not_specialize=["max_num_blocks_per_req", "workspace_rank_stride"])
89
def kernel(
90
req_id_ptr, # int32 [num_tokens]
91
block_table_ptr, # int32 [num_requests, max_num_blocks_per_req]
@@ -78,6 +94,7 @@ class ConvertReqIndexToGlobalIndexKernel(
94
valid_count_ptr, # int32 [num_tokens] - output valid count per row
95
prefill_request_id_ptr, # int32 [num_tokens], -1 for decode, >=0 for prefill
96
workspace_starts_ptr, # int32 [num_prefill_reqs+1] or nullptr
97
+ workspace_rank_stride, # rows per DCP rank of a gathered prefill workspace
98
# shapes (compile-time where possible)
99
max_num_blocks_per_req,
100
BLOCK_SIZE: tl.constexpr,
@@ -146,7 +163,7 @@ class ConvertReqIndexToGlobalIndexKernel(
163
# Guard block_table access
164
valid_block = (block_id < max_num_blocks_per_req) & (block_id >= 0)
165
bt_ptr = block_table_ptr + req * bt_stride0 + block_id * bt_stride1
149
- is_invalid_tok |= ~valid_block | is_remote
166
+ is_invalid_tok |= ~valid_block | (is_remote & ~is_prefill)
167
base = tl.load(bt_ptr, mask=valid_block & ~is_prefill & ~is_remote, other=0)
168
out_val = base * BLOCK_STRIDE_ROWS + inblock_off
169
@@ -155,7 +172,11 @@ class ConvertReqIndexToGlobalIndexKernel(
172
workspace_start = tl.load(
173
workspace_starts_ptr + prefill_req_id, mask=is_prefill, other=0
174
)
158
- prefill_out = workspace_start + tok
175
+ # Under DCP the prefill workspace is the all-gather of every rank's
176
+ # shard, rank-major. With DCP_SIZE == 1 this is workspace_start + tok.
177
+ prefill_out = (
178
+ owning_rank * workspace_rank_stride + workspace_start + local_idx
179
+ )
180
out_val = tl.where(is_prefill, prefill_out, out_val)
181
out_val = tl.where(is_invalid_tok, -1, out_val)
182
@@ -238,6 +259,22 @@ class ConvertReqIndexToGlobalIndexKernel(
259
block_size * dcp_size,
260
)
261
max_num_blocks_per_req = get_block_table_width(max_num_blocks, block_size)
262
+ pcp_dcp_combos = []
263
+ if (
264
+ vllm_config.parallel_config.prefill_context_parallel_size > 1
265
+ and dcp_size > 1
266
+ ):
267
+ # PCP + DCP attends prefill rows over a DCP-gathered workspace.
268
+ pcp_dcp_combos.append(
269
+ dict(
270
+ HAS_PREFILL_WORKSPACE=True,
271
+ COUNT_VALID=True,
272
+ COMPACT_TO_FRONT=True,
273
+ DCP_SIZE=dcp_size,
274
+ DCP_RANK=dcp_rank,
275
+ DCP_INTERLEAVE=dcp_interleave,
276
+ )
277
+ )
278
return self._trace_dispatch(self.dispatch)(
279
zip_inputs(
280
dict(
@@ -280,6 +317,7 @@ class ConvertReqIndexToGlobalIndexKernel(
317
DCP_RANK=dcp_rank,
318
DCP_INTERLEAVE=dcp_interleave,
319
),
320
+ *pcp_dcp_combos,
321
),
322
BLOCK_SIZE=block_size,
323
BLOCK_STRIDE_ROWS=block_stride_rows,
@@ -316,6 +354,7 @@ class ConvertReqIndexToGlobalIndexKernel(
354
int32_ptr if compile_key.has_prefill_workspace else None
355
),
356
max_num_blocks_per_req=1,
357
+ workspace_rank_stride=1,
358
BLOCK_SIZE=compile_key.block_size,
359
BLOCK_STRIDE_ROWS=compile_key.block_stride_rows,
360
BLOCK_N=compile_key.block_n,
@@ -339,6 +378,7 @@ class ConvertReqIndexToGlobalIndexKernel(
378
prefill_workspace_request_ids: torch.Tensor | None,
379
prefill_workspace_starts: torch.Tensor | None,
380
*,
381
+ workspace_rank_stride: int,
382
max_num_blocks_per_req: int,
383
BLOCK_SIZE: int,
384
BLOCK_STRIDE_ROWS: int,
@@ -406,6 +446,10 @@ def triton_convert_req_index_to_global_index(
446
HAS_PREFILL_WORKSPACE: bool = False,
447
prefill_workspace_request_ids: torch.Tensor | None = None,
448
prefill_workspace_starts: torch.Tensor | None = None,
449
+ prefill_workspace_rank_stride: int | None = None,
450
+ dcp_size: int = 1,
451
+ dcp_rank: int = 0,
452
+ cp_kv_cache_interleave_size: int = 1,
453
return_valid_counts: bool = False,
454
out: torch.Tensor | None = None,
455
valid_counts_out: torch.Tensor | None = None,
@@ -511,6 +555,7 @@ def triton_convert_req_index_to_global_index(
555
prefill_workspace_request_ids,
556
prefill_workspace_starts,
557
# shapes / constexprs
558
+ workspace_rank_stride=prefill_workspace_rank_stride or 0,
559
max_num_blocks_per_req=max_num_blocks_per_req,
560
BLOCK_SIZE=BLOCK_SIZE,
561
BLOCK_STRIDE_ROWS=(
@@ -521,10 +566,9 @@ def triton_convert_req_index_to_global_index(
566
HAS_PREFILL_WORKSPACE=HAS_PREFILL_WORKSPACE,
567
COUNT_VALID=return_valid_counts,
568
COMPACT_TO_FRONT=return_valid_counts,
524
- # DCP disabled (no-op de-interleave)
525
- DCP_SIZE=1,
526
- DCP_RANK=0,
527
- DCP_INTERLEAVE=1,
569
+ DCP_SIZE=dcp_size,
570
+ DCP_RANK=dcp_rank,
571
+ DCP_INTERLEAVE=cp_kv_cache_interleave_size,
572
)
573
574
if return_valid_counts:
@@ -618,6 +662,7 @@ def triton_filter_and_convert_dcp_index(
662
# No prefill workspace on the DCP decode path.
663
None,
664
None,
665
+ workspace_rank_stride=0,
666
max_num_blocks_per_req=max_num_blocks_per_req,
667
BLOCK_SIZE=BLOCK_SIZE,
668
BLOCK_STRIDE_ROWS=(
vllm/v1/worker/gpu/attn_utils.py
+8
@@ -400,6 +400,7 @@ def build_attn_metadata(
400
kv_cache_config: KVCacheConfig,
401
seq_lens_cpu_upper_bound: torch.Tensor | None = None,
402
dcp_local_seq_lens: torch.Tensor | None = None,
403
+ dcp_local_seq_lens_cpu_upper_bound: torch.Tensor | None = None,
404
positions: torch.Tensor | None = None,
405
is_prefilling: torch.Tensor | None = None,
406
mm_req_doc_ranges: dict[int, list[tuple[int, int]]] | None = None,
@@ -409,10 +410,15 @@ def build_attn_metadata(
410
rswa_prefix_lens: torch.Tensor | None = None,
411
ubatch_idx: int = 0,
412
fast_prefill: FastPrefillBatchMetadata | None = None,
413
+ req_idx: np.ndarray | None = None,
414
) -> dict[str, Any]:
415
seq_lens = seq_lens[:num_reqs]
416
if dcp_local_seq_lens is not None:
417
dcp_local_seq_lens = dcp_local_seq_lens[:num_reqs]
418
+ if dcp_local_seq_lens_cpu_upper_bound is not None:
419
+ dcp_local_seq_lens_cpu_upper_bound = dcp_local_seq_lens_cpu_upper_bound[
420
+ :num_reqs
421
+ ]
422
if seq_lens_cpu_upper_bound is not None:
423
seq_lens_cpu_upper_bound = seq_lens_cpu_upper_bound[:num_reqs]
424
@@ -457,10 +463,12 @@ def build_attn_metadata(
463
slot_mapping=slot_mapping,
464
causal=group_causal,
465
dcp_local_seq_lens=dcp_local_seq_lens,
466
+ dcp_local_seq_lens_cpu_upper_bound=dcp_local_seq_lens_cpu_upper_bound,
467
positions=positions,
468
is_prefilling=group_is_prefilling,
469
mm_req_doc_ranges=mm_req_doc_ranges,
470
rswa_prefix_lens=rswa_prefix_lens,
471
+ req_idx=req_idx,
472
**common_attn_metadata_extra_kwargs,
473
)
474
vllm/v1/worker/gpu/input_batch.py
+3
@@ -114,6 +114,9 @@ class InputBatch:
114
# (cudagraph capture) batches, which run the KV-sharing layers in full.
115
fast_prefill: "FastPrefillBatchMetadata | None" = None
116
117
+ # [num_reqs] set only under PCP+DCP (see CommonAttentionMetadata).
118
+ dcp_local_seq_lens_cpu_upper_bound: torch.Tensor | None = None
119
+
120
@classmethod
121
def make_dummy(
122
cls,
vllm/v1/worker/gpu/model_states/default.py
+2
@@ -220,11 +220,13 @@ class DefaultModelState(ModelState):
220
kv_cache_config=kv_cache_config,
221
seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound,
222
dcp_local_seq_lens=input_batch.dcp_local_seq_lens,
223
+ dcp_local_seq_lens_cpu_upper_bound=input_batch.dcp_local_seq_lens_cpu_upper_bound,
224
positions=input_batch.positions,
225
is_prefilling=torch.from_numpy(input_batch.is_prefilling_np),
226
mm_req_doc_ranges=req_doc_ranges,
227
for_cudagraph_capture=for_capture,
228
rswa_prefix_lens=input_batch.prompt_lens,
229
+ req_idx=input_batch.idx_mapping_np,
230
ubatch_idx=ubatch_idx,
231
fast_prefill=input_batch.fast_prefill,
232
)
vllm/v1/worker/gpu/pcp_manager.py
+70
-26
@@ -6,10 +6,10 @@ from dataclasses import dataclass, replace
6
import numpy as np
7
import torch
8
9
-from vllm.config import VllmConfig
9
+from vllm.config import CUDAGraphMode, VllmConfig
10
from vllm.distributed.parallel_state import get_dcp_group, get_pcp_group
11
from vllm.logger import init_logger
12
-from vllm.v1.attention.backends.utils import PAD_SLOT_ID
12
+from vllm.v1.attention.backends.utils import PAD_SLOT_ID, get_dcp_local_seq_lens
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.input_batch import (
@@ -154,30 +154,48 @@ class PCPManager:
154
"MRV2 PCP only supports DSpark or single-module MTP "
155
"speculative decoding."
156
)
157
- if vllm_config.compilation_config.cudagraph_mode.has_full_cudagraphs():
157
+ cudagraph_mode = vllm_config.compilation_config.cudagraph_mode
158
+ is_sparse_mla = hasattr(model_config.hf_text_config, "index_topk")
159
+ if parallel_config.decode_context_parallel_size > 1 and not is_sparse_mla:
160
+ # Dense MLA prefill sizes its DCP KV gather from each rank's own
161
+ # chunk rows, so the ranks' collectives diverge (#53573).
162
+ raise NotImplementedError("MRV2 PCP + DCP supports sparse MLA models only.")
163
+ if (
164
+ is_sparse_mla
165
+ and parallel_config.decode_context_parallel_size == 1
166
+ and cudagraph_mode != CUDAGraphMode.NONE
167
+ ):
168
+ raise NotImplementedError(
169
+ "MRV2 sparse MLA PCP does not support CUDA graphs yet. "
170
+ "Set -cc.cudagraph_mode=NONE."
171
+ )
172
+ if cudagraph_mode.has_full_cudagraphs():
173
raise NotImplementedError("MRV2 PCP supports PIECEWISE CUDA graphs only.")
174
+ if (
175
+ parallel_config.decode_context_parallel_size > 1
176
+ and parallel_config.dcp_comm_backend != "ag_rs"
177
+ ):
178
+ raise NotImplementedError(
179
+ "MRV2 PCP + DCP requires dcp_comm_backend='ag_rs'; got "
180
+ f"'{parallel_config.dcp_comm_backend}'."
181
+ )
182
183
@staticmethod
184
def _reorder_segments(
185
segments: list[RankSegment],
163
- num_computed_tokens: np.ndarray,
186
is_prefilling: np.ndarray,
165
- query_start_loc_np: np.ndarray,
187
) -> list[RankSegment]:
167
- """Move pure prefills last to match the batch ordering expected by
168
- attention backends like MLA and sparse MLA.
169
- """
188
+ """Order this rank's rows decodes-first, then prefills, canonically."""
189
171
- def is_pure_prefill(segment: RankSegment) -> bool:
190
+ def sort_key(segment: RankSegment) -> tuple[bool, int, int]:
191
req_idx = segment.global_batch_req_idx
173
- start_pos = (
174
- num_computed_tokens[req_idx]
175
- + segment.global_batch_slice.start
176
- - query_start_loc_np[req_idx]
192
+ return (
193
+ bool(is_prefilling[req_idx]),
194
+ req_idx,
195
+ segment.global_batch_slice.start,
196
)
178
- return is_prefilling[req_idx] and start_pos == 0
197
180
- segments.sort(key=is_pure_prefill)
198
+ segments.sort(key=sort_key)
199
rank_offset = 0
200
for index, segment in enumerate(segments):
201
segments[index] = replace(
@@ -189,6 +207,21 @@ class PCPManager:
207
rank_offset += segment.num_tokens
208
return segments
209
210
+ def replicated_requests(
211
+ self,
212
+ num_scheduled_tokens: np.ndarray,
213
+ is_prefilling: np.ndarray,
214
+ ) -> np.ndarray:
215
+ """Per global request, whether every PCP rank gets the whole query."""
216
+ num_chunks = 2 * self.pcp_world_size
217
+ query_lens = np.asarray(num_scheduled_tokens, dtype=np.int64)
218
+ replicated = ~np.asarray(is_prefilling, dtype=np.bool_)
219
+ if self.dcp_world_size > 1:
220
+ chunk_sizes = (query_lens + num_chunks - 1) // num_chunks
221
+ drops_a_chunk = (num_chunks - 1) * chunk_sizes >= query_lens
222
+ replicated |= drops_a_chunk
223
+ return replicated
224
+
225
def _iter_rank_chunks(
226
self,
227
rank: int,
@@ -204,17 +237,20 @@ class PCPManager:
237
rank 1: 1 6
238
rank 2: 2 5
239
rank 3: 3 4
240
+
241
+ Decodes, and prefills too short to fill all eight, are replicated instead.
242
"""
243
num_chunks = 2 * self.pcp_world_size
244
+ replicated = self.replicated_requests(num_scheduled_tokens, is_prefilling)
245
for global_batch_req_idx, num_tokens in enumerate(num_scheduled_tokens):
246
query_len = int(num_tokens)
247
if query_len == 0:
248
continue
249
chunk_indices: tuple[int, ...]
214
- if bool(is_prefilling[global_batch_req_idx]):
250
+ if not replicated[global_batch_req_idx]:
251
chunk_size = (query_len + num_chunks - 1) // num_chunks
252
chunk_indices = (rank, num_chunks - 1 - rank)
217
- else: # decodes are replicated
253
+ else: # decodes, and short prefills under DCP, are replicated
254
chunk_size = query_len
255
chunk_indices = (0,)
256
@@ -229,7 +265,6 @@ class PCPManager:
265
self,
266
rank: int,
267
num_scheduled_tokens: np.ndarray,
232
- num_computed_tokens: np.ndarray,
268
is_prefilling: np.ndarray,
269
query_start_loc_np: np.ndarray,
270
) -> list[RankSegment]:
@@ -248,12 +283,7 @@ class PCPManager:
283
)
284
)
285
rank_offset += chunk_len
251
- return self._reorder_segments(
252
- rank_segments,
253
- num_computed_tokens,
254
- is_prefilling,
255
- query_start_loc_np,
256
- )
286
+ return self._reorder_segments(rank_segments, is_prefilling)
287
288
def _build_batch_layout(
289
self,
@@ -263,13 +293,13 @@ class PCPManager:
293
query_start_loc_np: np.ndarray,
294
padded_num_tokens: int | None = None,
295
) -> tuple[list[list[RankSegment]], list[int]]:
296
+ replicated = self.replicated_requests(num_scheduled_tokens, is_prefilling)
297
segments_by_rank = []
298
per_rank_num_tokens = []
299
for rank in range(self.pcp_world_size):
300
segments = self._get_rank_segments(
301
rank,
302
num_scheduled_tokens,
272
- num_computed_tokens,
303
is_prefilling,
304
query_start_loc_np,
305
)
@@ -309,7 +339,7 @@ class PCPManager:
339
dtype=np.int64,
340
)
341
# Cache insertion pairs one slot entry with each rank's local decode.
312
- if not bool(is_prefilling[segment.global_batch_req_idx]) and rank != 0:
342
+ if replicated[segment.global_batch_req_idx] and rank != 0:
343
continue
344
gathered_kv_write_mask[padded_gathered_slice] = True
345
hidden_restore_idx[segment.global_batch_slice] = np.arange(
@@ -527,6 +557,19 @@ class PCPManager:
557
)
558
seq_lens_cpu_upper_bound_np = np.zeros(num_local_reqs, dtype=np.int32)
559
seq_lens_cpu_upper_bound_np[:] = local_start_pos_np + local_num_scheduled_tokens
560
+ dcp_local_seq_lens_cpu_upper_bound = None
561
+ if self.dcp_world_size > 1:
562
+ # The largest DCP shard of each row's whole request, identical on
563
+ # every PCP rank: the sparse backends pad their KV gather to it.
564
+ request_seq_lens = (num_computed_tokens + num_scheduled_tokens)[
565
+ local_to_global_batch_req_idx_np
566
+ ]
567
+ dcp_local_seq_lens_cpu_upper_bound = get_dcp_local_seq_lens(
568
+ torch.from_numpy(request_seq_lens.astype(np.int32)),
569
+ self.dcp_world_size,
570
+ 0,
571
+ self.cp_interleave,
572
+ )
573
574
self._local_batch = replace(
575
input_batch,
@@ -549,6 +592,7 @@ class PCPManager:
592
seq_lens=seq_lens,
593
seq_lens_cpu_upper_bound=torch.from_numpy(seq_lens_cpu_upper_bound_np),
594
dcp_local_seq_lens=None,
595
+ dcp_local_seq_lens_cpu_upper_bound=dcp_local_seq_lens_cpu_upper_bound,
596
num_computed_tokens_np=local_start_pos_np,
597
prefill_len_np=local_prefill_len_np,
598
num_computed_prefill_tokens_np=local_num_computed_prefill_tokens_np,
vllm/v1/worker/gpu_model_runner.py
+1
-1
@@ -2486,7 +2486,7 @@ class GPUModelRunner(
2486
self.dcp_local_seq_lens.copy_to_gpu(num_reqs_padded)
2487
2488
cm_base.dcp_local_seq_lens = self.dcp_local_seq_lens.gpu[:num_reqs_padded]
2489
- cm_base.dcp_local_seq_lens_cpu = self.dcp_local_seq_lens.cpu[
2489
+ cm_base.dcp_local_seq_lens_cpu_upper_bound = self.dcp_local_seq_lens.cpu[
2490
:num_reqs_padded
2491
]
2492