[Perf][PCP] Shard decode requests across PCP ranks (#52162)

Signed-off-by: QiuChunshuo <qiuchunshuo@huawei.com> Co-authored-by: Lucas Wilkinson <LucasWilkinson@users.noreply.github.com>

Qiu Chunshuo committed Oct 2, 2026 at 09:20 UTC 8486f1ebfe6e39a5513a8636e7f07ed9746f8be5
13 files changed +406 -33
tests/model_executor/layers/test_mla_short_prefill_indexer.py
+2
@@ -205,6 +205,7 @@ def test_short_prefill_updates_k_cache_before_scoring_decision(
205 topk_indices,
206 False,
207 False,
208 + False,
209 MLA_LAYER,
210 )
211
@@ -259,6 +260,7 @@ def test_skipped_k_cache_insert_accepts_no_k(
260 topk_indices,
261 True,
262 False,
263 + False,
264 "",
265 )
266
tests/v1/attention/test_indexer_deepseek_v4_slot_mapping.py
+73
@@ -15,6 +15,7 @@ from vllm.models.deepseek_v41.sparse_mla import (
15 DeepseekV4SparseMLABackend as DeepseekV41SparseMLABackend,
16 )
17 from vllm.v1.attention.backend import CommonAttentionMetadata
18 +from vllm.v1.attention.backends.mla import indexer as indexer_module
19 from vllm.v1.attention.backends.mla.compressor_utils import (
20 CompressedSlotMappingKernel,
21 get_compressed_slot_mapping,
@@ -295,6 +296,78 @@ def test_index_conversion_warmup_uses_physical_block_stride():
296 assert {key.block_stride_rows for key in keys} == {4096}
297
298
299 +def test_zero_token_pcp_rank_participates_in_compressed_mapping_gather(monkeypatch):
300 + builder = object.__new__(DeepseekV32IndexerMetadataBuilder)
301 + builder.compress_ratio = 4
302 + builder.use_pcp = True
303 + builder.pcp_world_size = 4
304 + builder.pcp_rank = 0
305 + builder.kernel_block_size = None
306 + builder.kv_cache_spec = SimpleNamespace(block_size=64, num_states=64)
307 + builder.compressed_slot_mapping_buffer = torch.zeros(8, dtype=torch.int64)
308 +
309 + def fake_get_compressed_slot_mapping(
310 + num_tokens,
311 + slot_mapping,
312 + query_start_loc,
313 + seq_lens,
314 + block_table,
315 + block_size,
316 + compress_ratio,
317 + out,
318 + ):
319 + assert num_tokens == 0
320 + assert block_size == 64
321 + assert compress_ratio == 4
322 + out.fill_(-1)
323 + return out[:num_tokens]
324 +
325 + gathered_slot_mapping = torch.tensor([123, -1, -1, -1], dtype=torch.int64)
326 +
327 + class FakePCPGroup:
328 + def __init__(self):
329 + self.calls = 0
330 +
331 + def all_gather(self, tensor, dim=0):
332 + self.calls += 1
333 + assert dim == 0
334 + torch.testing.assert_close(tensor, torch.tensor([-1]))
335 + return gathered_slot_mapping
336 +
337 + fake_pcp_group = FakePCPGroup()
338 + monkeypatch.setattr(
339 + indexer_module,
340 + "get_compressed_slot_mapping",
341 + fake_get_compressed_slot_mapping,
342 + )
343 + monkeypatch.setattr(indexer_module, "get_pcp_group", lambda: fake_pcp_group)
344 +
345 + query_start_loc = torch.tensor([0, 0], dtype=torch.int32)
346 + seq_lens = torch.tensor([16], dtype=torch.int32)
347 + common = CommonAttentionMetadata(
348 + query_start_loc=query_start_loc,
349 + query_start_loc_cpu=query_start_loc,
350 + seq_lens=seq_lens,
351 + seq_lens_cpu_upper_bound=seq_lens,
352 + num_reqs=1,
353 + num_actual_tokens=0,
354 + max_query_len=0,
355 + max_seq_len=16,
356 + block_table_tensor=torch.tensor([[0]], dtype=torch.int32),
357 + slot_mapping=torch.tensor([11, 22, 33, 44], dtype=torch.int64),
358 + causal=True,
359 + )
360 +
361 + metadata = builder.build(common_prefix_len=0, common_attn_metadata=common)
362 +
363 + assert fake_pcp_group.calls == 1
364 + assert metadata.num_decodes == 0
365 + assert metadata.num_decode_tokens == 0
366 + assert metadata.num_prefills == 0
367 + assert metadata.num_prefill_tokens == 0
368 + torch.testing.assert_close(metadata.slot_mapping, gathered_slot_mapping)
369 +
370 +
371 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
372 def test_indexer_builder_deepseek_v4_compressed_slot_mapping_uses_num_states():
373 """Regression test: DeepseekV4 compression path must compute slot_mapping from
tests/v1/attention/test_sparse_mla_backends.py
+1
@@ -2499,6 +2499,7 @@ def test_hisparse_kv_update_writes_resident_and_staging_caches():
2499 layer = SimpleNamespace(
2500 hisparse_cache=cache_handle,
2501 use_pcp=False,
2502 + pcp_shard_decode_requests=False,
2503 impl=impl,
2504 )
2505 mla_attention.MLAAttention.update_kv_cache(
tests/v1/cudagraph/test_cudagraph_manager.py
+1
@@ -132,6 +132,7 @@ def test_piecewise_capture_uses_pcp_dummy_slot_mappings():
132 pcp_world_size=pcp_world_size,
133 pcp_rank=0,
134 device=torch.device("cpu"),
135 + shard_decode_requests=False,
136 max_num_reqs=num_reqs,
137 max_num_tokens=num_tokens,
138 block_tables=pcp_block_tables,
tests/v1/worker/test_gpu_pcp_manager.py
+220 -10
@@ -7,8 +7,9 @@ import numpy as np
7 import pytest
8 import torch
9
10 -from vllm.config import CUDAGraphMode
11 -from vllm.v1.attention.backends.utils import get_dcp_local_seq_lens
10 +from vllm.config import CUDAGraphMode, ParallelConfig
11 +from vllm.v1.attention.backends.utils import PAD_SLOT_ID, get_dcp_local_seq_lens
12 +from vllm.v1.attention.ops import pcp as attention_pcp
13 from vllm.v1.worker.gpu import cp_utils as gpu_cp_utils
14 from vllm.v1.worker.gpu import pcp_manager as pcp_manager_module
15 from vllm.v1.worker.gpu.cudagraph_utils import BatchExecutionDescriptor
@@ -53,6 +54,7 @@ def _make_capture_manager(block_table: torch.Tensor):
54 pcp_world_size=2,
55 pcp_rank=0,
56 device=torch.device("cpu"),
57 + shard_decode_requests=False,
58 max_num_reqs=4,
59 max_num_tokens=8,
60 block_tables=block_tables,
@@ -93,11 +95,12 @@ def test_validate_config_dcp_for_dspark(draft_uses_mla, dcp_size):
95 PCPManager.validate_config(config, supports_mm_inputs=False)
96
97
96 -def test_replicated_decode_piecewise_graph_padding(monkeypatch):
98 +def test_sharded_decode_piecewise_graph_padding(monkeypatch):
99 manager = PCPManager(
100 pcp_world_size=2,
101 pcp_rank=0,
102 device=torch.device("cpu"),
103 + shard_decode_requests=True,
104 dcp_world_size=1,
105 )
106 monkeypatch.setattr(pcp_manager_module, "async_tensor_h2d", _copy_to_cpu)
@@ -110,19 +113,19 @@ def test_replicated_decode_piecewise_graph_padding(monkeypatch):
113 padded_num_tokens=4,
114 )
115
113 - assert per_rank_num_tokens == [3, 3]
116 + assert per_rank_num_tokens == [2, 1]
117 request_indices = [
118 [segment.global_batch_req_idx for segment in rank] for rank in segments_by_rank
119 ]
117 - assert request_indices == [[0, 1, 2], [0, 1, 2]]
118 - assert torch.equal(manager._hidden_restore_idx, torch.tensor([0, 1, 2]))
120 + assert request_indices == [[0, 2], [1]]
121 + assert torch.equal(manager._hidden_restore_idx, torch.tensor([0, 4, 1]))
122 assert torch.equal(
123 manager._padded_gather_idx,
121 - torch.tensor([0, 1, 2, 0, 0, 1, 2, 0]),
124 + torch.tensor([0, 2, 0, 0, 1, 0, 0, 0]),
125 )
126 assert torch.equal(
127 manager._gathered_kv_write_mask,
125 - torch.tensor([True, True, True, False, False, False, False, False]),
128 + torch.tensor([True, True, False, False, True, False, False, False]),
129 )
130
131
@@ -131,6 +134,7 @@ def test_input_buffers_are_exposed_for_cudagraph_capture():
134 pcp_world_size=2,
135 pcp_rank=0,
136 device=torch.device("cpu"),
137 + shard_decode_requests=False,
138 max_num_reqs=4,
139 max_num_tokens=8,
140 )
@@ -148,7 +152,7 @@ def test_input_buffers_are_exposed_for_cudagraph_capture():
152 (2, [7], [True], 4),
153 (2, [3], [False], 3),
154 (2, [3, 8], [False, True], 7),
151 - (4, [2, 9], [False, True], 5),
155 + (4, [2, 9], [False, True], 4),
156 ],
157 )
158 def test_num_tokens_for_dispatch_uses_largest_pcp_rank(
@@ -158,6 +162,7 @@ def test_num_tokens_for_dispatch_uses_largest_pcp_rank(
162 pcp_world_size=pcp_world_size,
163 pcp_rank=0,
164 device=torch.device("cpu"),
165 + shard_decode_requests=True,
166 )
167
168 actual = manager.get_num_tokens_for_dispatch(
@@ -173,6 +178,7 @@ def test_graph_padding_cannot_be_smaller_than_largest_pcp_rank(monkeypatch):
178 pcp_world_size=2,
179 pcp_rank=0,
180 device=torch.device("cpu"),
181 + shard_decode_requests=True,
182 dcp_world_size=1,
183 )
184 monkeypatch.setattr(pcp_manager_module, "async_tensor_h2d", _copy_to_cpu)
@@ -183,7 +189,7 @@ def test_graph_padding_cannot_be_smaller_than_largest_pcp_rank(monkeypatch):
189 num_computed_tokens=np.full(3, 16, dtype=np.int32),
190 is_prefilling=np.zeros(3, dtype=np.bool_),
191 query_start_loc_np=np.arange(4, dtype=np.int32),
186 - padded_num_tokens=2,
192 + padded_num_tokens=1,
193 )
194
195
@@ -207,6 +213,7 @@ def test_partition_padding_is_derived_from_batch_descriptor(
213 pcp_world_size=2,
214 pcp_rank=0,
215 device=device,
216 + shard_decode_requests=False,
217 max_num_reqs=4,
218 max_num_tokens=8,
219 )
@@ -288,6 +295,189 @@ def test_dummy_context_updates_pcp_local_block_tables():
295 assert torch.all(global_block_table == -1)
296
297
298 +def _rank_request_ids(
299 + manager: PCPManager,
300 + rank: int,
301 + req_ids: list[str],
302 + *,
303 + is_prefilling: np.ndarray | None = None,
304 +) -> list[str]:
305 + num_reqs = len(req_ids)
306 + if is_prefilling is None:
307 + is_prefilling = np.zeros(num_reqs, dtype=np.bool_)
308 + segments = manager._get_rank_segments(
309 + rank=rank,
310 + num_scheduled_tokens=np.ones(num_reqs, dtype=np.int32),
311 + is_prefilling=is_prefilling,
312 + query_start_loc_np=np.arange(num_reqs + 1, dtype=np.int32),
313 + )
314 + return [req_ids[segment.global_batch_req_idx] for segment in segments]
315 +
316 +
317 +def test_pcp_only_decode_requests_are_round_robin_balanced_each_step():
318 + manager = PCPManager(
319 + pcp_world_size=4,
320 + pcp_rank=0,
321 + device=torch.device("cpu"),
322 + shard_decode_requests=True,
323 + dcp_world_size=1,
324 + )
325 + req_ids = [f"request-{idx}" for idx in range(18)]
326 +
327 + owners: dict[str, int] = {}
328 + for rank in range(manager.pcp_world_size):
329 + for req_id in _rank_request_ids(manager, rank, req_ids):
330 + assert req_id not in owners
331 + owners[req_id] = rank
332 +
333 + assert owners == {
334 + req_id: index % manager.pcp_world_size for index, req_id in enumerate(req_ids)
335 + }
336 + counts = [list(owners.values()).count(rank) for rank in range(4)]
337 + assert max(counts) - min(counts) == 1
338 +
339 + reordered_req_ids = req_ids[::2] + req_ids[1::2]
340 + reordered_owners = {
341 + req_id: rank
342 + for rank in range(manager.pcp_world_size)
343 + for req_id in _rank_request_ids(manager, rank, reordered_req_ids)
344 + }
345 + assert reordered_owners == {
346 + req_id: index % manager.pcp_world_size
347 + for index, req_id in enumerate(reordered_req_ids)
348 + }
349 + assert reordered_owners != owners
350 +
351 +
352 +def test_decode_requests_remain_replicated_when_dcp_is_enabled():
353 + manager = PCPManager(
354 + pcp_world_size=2,
355 + pcp_rank=0,
356 + device=torch.device("cpu"),
357 + shard_decode_requests=False,
358 + dcp_world_size=2,
359 + )
360 + req_ids = ["request-a", "request-b", "request-c"]
361 +
362 + assert _rank_request_ids(manager, 0, req_ids) == req_ids
363 + assert _rank_request_ids(manager, 1, req_ids) == req_ids
364 +
365 +
366 +def test_decode_sharding_allows_ranks_with_no_owned_request():
367 + manager = PCPManager(
368 + pcp_world_size=4,
369 + pcp_rank=0,
370 + device=torch.device("cpu"),
371 + shard_decode_requests=True,
372 + dcp_world_size=1,
373 + )
374 + req_ids = ["request-a"]
375 +
376 + assert _rank_request_ids(manager, 0, req_ids) == req_ids
377 + assert _rank_request_ids(manager, 1, req_ids) == []
378 + assert _rank_request_ids(manager, 2, req_ids) == []
379 + assert _rank_request_ids(manager, 3, req_ids) == []
380 +
381 +
382 +def test_prefill_partitioning_is_preserved_with_sharded_decode():
383 + manager = PCPManager(
384 + pcp_world_size=2,
385 + pcp_rank=0,
386 + device=torch.device("cpu"),
387 + shard_decode_requests=True,
388 + dcp_world_size=1,
389 + )
390 + segments_by_rank = [
391 + manager._get_rank_segments(
392 + rank=rank,
393 + num_scheduled_tokens=np.array([8, 1, 0, 1], dtype=np.int32),
394 + is_prefilling=np.array([True, False, False, False]),
395 + query_start_loc_np=np.array([0, 8, 9, 9, 10], dtype=np.int32),
396 + )
397 + for rank in range(manager.pcp_world_size)
398 + ]
399 +
400 + prefill_tokens = sorted(
401 + token_idx
402 + for segments in segments_by_rank
403 + for segment in segments
404 + if segment.global_batch_req_idx == 0
405 + for token_idx in range(
406 + segment.global_batch_slice.start, segment.global_batch_slice.stop
407 + )
408 + )
409 + decode_owners = {
410 + segment.global_batch_req_idx: rank
411 + for rank, segments in enumerate(segments_by_rank)
412 + for segment in segments
413 + if segment.global_batch_req_idx in (1, 3)
414 + }
415 +
416 + assert prefill_tokens == list(range(8))
417 + assert decode_owners == {1: 0, 3: 1}
418 +
419 +
420 +def test_sharded_decode_layout_selects_owner_kv_for_replication(monkeypatch):
421 + manager = PCPManager(
422 + pcp_world_size=2,
423 + pcp_rank=0,
424 + device=torch.device("cpu"),
425 + shard_decode_requests=True,
426 + dcp_world_size=1,
427 + )
428 + monkeypatch.setattr(pcp_manager_module, "async_tensor_h2d", _copy_to_cpu)
429 + manager._build_batch_layout(
430 + num_scheduled_tokens=np.array([1, 1, 1], dtype=np.int32),
431 + num_computed_tokens=np.array([16, 16, 16], dtype=np.int32),
432 + is_prefilling=np.array([False, False, False]),
433 + query_start_loc_np=np.array([0, 1, 2, 3], dtype=np.int32),
434 + )
435 +
436 + gathered_slot_mapping = manager._convert_to_gathered_slot_mappings(
437 + torch.tensor([[123, 456, 789]], dtype=torch.int64)
438 + )
439 + assert torch.equal(
440 + gathered_slot_mapping,
441 + torch.tensor([[123, 789, 456, PAD_SLOT_ID]], dtype=torch.int64),
442 + )
443 + assert torch.equal(manager._hidden_restore_idx, torch.tensor([0, 2, 1]))
444 +
445 + class FakePCPGroup:
446 + world_size = 2
447 +
448 + def all_gather(self, tensor: torch.Tensor, dim: int = 0) -> torch.Tensor:
449 + assert dim == 0
450 + assert tensor.shape == (2, 1)
451 + return torch.tensor([[11.0], [33.0], [22.0], [0.0]])
452 +
453 + monkeypatch.setattr(attention_pcp, "get_pcp_group", FakePCPGroup)
454 + (gathered_kv,), cache_slot_mapping = attention_pcp._gather_prefill_cache_inputs(
455 + (torch.tensor([[11.0], [33.0]]),),
456 + gathered_slot_mapping[0],
457 + num_decode_tokens=0,
458 + shard_decode_requests=True,
459 + )
460 +
461 + assert torch.equal(gathered_kv, torch.tensor([[11.0], [33.0], [22.0], [0.0]]))
462 + assert torch.equal(cache_slot_mapping, torch.tensor([123, 789, 456, PAD_SLOT_ID]))
463 +
464 +
465 +def test_replicated_draft_cache_inputs_keep_unexpanded_slot_mapping(monkeypatch):
466 + def unexpected_gather():
467 + pytest.fail("replicated draft decode must not gather PCP cache inputs")
468 +
469 + monkeypatch.setattr(attention_pcp, "get_pcp_group", unexpected_gather)
470 + kv = torch.tensor([[11.0], [22.0], [0.0]])
471 + slot_mapping = torch.tensor([123, 456])
472 +
473 + (cache_kv,), cache_slot_mapping = attention_pcp._gather_prefill_cache_inputs(
474 + (kv,), slot_mapping, num_decode_tokens=2, shard_decode_requests=True
475 + )
476 +
477 + assert torch.equal(cache_kv, kv[:2])
478 + assert torch.equal(cache_slot_mapping, slot_mapping)
479 +
480 +
481 def _rank_rows(
482 pcp_rank: int,
483 pcp_world_size: int,
@@ -300,6 +490,7 @@ def _rank_rows(
490 pcp_world_size=pcp_world_size,
491 pcp_rank=pcp_rank,
492 device=torch.device("cpu"),
493 + shard_decode_requests=False,
494 dcp_world_size=2,
495 )
496 query_start_loc_np = np.concatenate([[0], np.cumsum(num_scheduled_tokens)]).astype(
@@ -393,6 +584,7 @@ def test_dcp_replicates_prefills_too_short_to_split(query_len):
584 pcp_world_size=pcp_world_size,
585 pcp_rank=rank,
586 device=torch.device("cpu"),
587 + shard_decode_requests=False,
588 dcp_world_size=2,
589 )
590 assert manager.replicated_requests(num_scheduled_tokens, is_prefilling)[0]
@@ -418,6 +610,7 @@ def test_pcp_first_chunk_row_is_never_short(pcp_world_size, query_len):
610 pcp_world_size=pcp_world_size,
611 pcp_rank=rank,
612 device=torch.device("cpu"),
613 + shard_decode_requests=False,
614 dcp_world_size=pcp_world_size,
615 )
616 chunk_lens = [
@@ -493,6 +686,7 @@ def test_partition_defers_dcp_metadata_to_post_partition_batch():
686 pcp_world_size=2,
687 pcp_rank=0,
688 device=device,
689 + shard_decode_requests=False,
690 max_num_reqs=4,
691 max_num_tokens=8,
692 dcp_world_size=2,
@@ -541,3 +735,19 @@ def test_partition_defers_dcp_metadata_to_post_partition_batch():
735 )
736 assert local_batch.dcp_local_seq_lens is not None
737 assert torch.equal(local_batch.dcp_local_seq_lens.cpu(), expected)
738 +
739 +
740 +@pytest.mark.parametrize(
741 + ("pcp_world_size", "dcp_world_size", "expected"),
742 + [(1, 1, False), (2, 1, True), (2, 2, False)],
743 +)
744 +def test_parallel_config_manages_decode_sharding(
745 + pcp_world_size: int, dcp_world_size: int, expected: bool
746 +):
747 + parallel_config = ParallelConfig(
748 + prefill_context_parallel_size=pcp_world_size,
749 + decode_context_parallel_size=dcp_world_size,
750 + distributed_executor_backend="ray",
751 + )
752 +
753 + assert parallel_config.pcp_shard_decode_requests is expected
vllm/config/parallel.py
+13
@@ -599,6 +599,19 @@ class ParallelConfig:
599 """Process world size across TP, PCP, PP, and DP."""
600 return self.world_size * self.data_parallel_size
601
602 + @property
603 + def pcp_shard_decode_requests(self) -> bool:
604 + """Whether PCP can shard decode requests across its ranks.
605 +
606 + PCP-only execution replicates the KV cache, so each decode request can
607 + have a single PCP owner. DCP shards the KV cache and therefore requires
608 + every decode request to run on every participating DCP rank.
609 + """
610 + return (
611 + self.prefill_context_parallel_size > 1
612 + and self.decode_context_parallel_size == 1
613 + )
614 +
615 @property
616 def use_ubatching(self) -> bool:
617 return self.enable_dbo or self.ubatch_size > 1
vllm/model_executor/layers/attention/mla_attention.py
+2
@@ -609,6 +609,7 @@ class MLAAttention(nn.Module, AttentionLayerBase):
609 vllm_config = get_current_vllm_config()
610 parallel_config = vllm_config.parallel_config
611 self.use_pcp = parallel_config.prefill_context_parallel_size > 1
612 + self.pcp_shard_decode_requests = parallel_config.pcp_shard_decode_requests
613 compilation_config = vllm_config.compilation_config
614 if prefix in compilation_config.static_forward_context:
615 raise ValueError(f"Duplicate layer name: {prefix}")
@@ -784,6 +785,7 @@ class MLAAttention(nn.Module, AttentionLayerBase):
785 slot_mapping,
786 attn_metadata.num_decode_tokens if attn_metadata is not None else None,
787 self.use_pcp,
788 + pcp_shard_decode_requests=self.pcp_shard_decode_requests,
789 )
790 assert slot_mapping is not None
791 if cache is not None:
vllm/model_executor/layers/sparse_attn_indexer.py
+6
@@ -335,6 +335,7 @@ def sparse_attn_indexer(
335 topk_indices_buffer: torch.Tensor,
336 skip_k_cache_insert: bool,
337 use_pcp: bool,
338 + pcp_shard_decode_requests: bool,
339 dense_mha_metadata_layer_name: LayerNameType,
340 use_fp4_cache: bool = False,
341 dcp_rank: int = 0,
@@ -404,6 +405,7 @@ def sparse_attn_indexer(
405 topk_indices_buffer,
406 skip_k_cache_insert,
407 use_pcp,
408 + pcp_shard_decode_requests,
409 dense_mha_metadata_layer_name,
410 use_fp4_cache,
411 candidate_blocks=candidate_blocks,
@@ -442,6 +444,7 @@ def sparse_attn_indexer(
444 slot_mapping,
445 num_decode_tokens,
446 use_pcp,
447 + pcp_shard_decode_requests=pcp_shard_decode_requests,
448 )
449 # scale_fmt can be None, but the function expects str
450 assert scale_fmt is not None
@@ -817,6 +820,7 @@ def sparse_attn_indexer_fake(
820 topk_indices_buffer: torch.Tensor | None,
821 skip_k_cache_insert: bool,
822 use_pcp: bool,
823 + pcp_shard_decode_requests: bool,
824 dense_mha_metadata_layer_name: LayerNameType,
825 use_fp4_cache: bool = False,
826 dcp_rank: int = 0,
@@ -898,6 +902,7 @@ class SparseAttnIndexer(CustomOp):
902 self.dcp_world_size = parallel_config.decode_context_parallel_size
903 self.dcp_rank = get_dcp_group().rank_in_group if self.dcp_world_size > 1 else 0
904 self.use_pcp = parallel_config.prefill_context_parallel_size > 1
905 + self.pcp_shard_decode_requests = parallel_config.pcp_shard_decode_requests
906 self._cp_kv_cache_interleave_size: int | None = None
907 if current_platform.is_cuda() and not has_deep_gemm():
908 raise RuntimeError(
@@ -994,6 +999,7 @@ class SparseAttnIndexer(CustomOp):
999 self.topk_indices_buffer,
1000 self.skip_k_cache_insert,
1001 self.use_pcp,
1002 + self.pcp_shard_decode_requests,
1003 _encode_layer_name(self.dense_mha_metadata_layer_name),
1004 self.use_fp4_cache,
1005 self.dcp_rank,
vllm/models/deepseek_v32/attention.py
+2
@@ -500,6 +500,7 @@ class DeepseekV32Attention(MLAAttention):
500 self.topk_indices_buffer,
501 skip_k_cache_insert=not self.use_pcp,
502 use_pcp=self.use_pcp,
503 + pcp_shard_decode_requests=self.pcp_shard_decode_requests,
504 dense_mha_metadata_layer_name=self._dense_mha_metadata_layer_name,
505 dcp_rank=(
506 self.dcp_manager.group.rank_in_group
@@ -535,6 +536,7 @@ class DeepseekV32Attention(MLAAttention):
536 layer_slot_mapping,
537 attn_metadata.num_decode_tokens,
538 True,
539 + pcp_shard_decode_requests=self.pcp_shard_decode_requests,
540 )
541 )
542 self.impl.do_kv_cache_update( # type: ignore[attr-defined]
vllm/v1/attention/backends/mla/flashmla_sparse.py
+8 -1
@@ -489,6 +489,14 @@ class FlashMLASparseMetadataBuilder(
489 num_tokens - metadata.num_decode_tokens,
490 )
491
492 + FP8Meta = FlashMLASparseMetadata.FP8SeparatePrefillDecode
493 + # PCP decode sharding can leave a rank with only its collective-padding
494 + # row when the global decode batch is smaller than the PCP world size.
495 + # The row has no actual query tokens and must not be interpreted as a
496 + # zero-length decode request by the sparse FP8 metadata builder.
497 + if num_tokens == 0:
498 + return FP8Meta()
499 +
500 decode_query_len = 0
501 active_num_decodes = num_decodes
502 if num_decodes > 0:
@@ -498,7 +506,6 @@ class FlashMLASparseMetadataBuilder(
506 active_num_decodes = num_decode_tokens // decode_query_len
507 assert active_num_decodes * decode_query_len == num_decode_tokens
508
501 - FP8Meta = FlashMLASparseMetadata.FP8SeparatePrefillDecode
509 fp8_metadata = FP8Meta(
510 num_decodes=active_num_decodes,
511 num_prefills=num_prefills,
vllm/v1/attention/backends/mla/indexer.py
+28 -11
@@ -1298,17 +1298,6 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
1298 slot_mapping = common_attn_metadata.slot_mapping
1299 block_table = common_attn_metadata.block_table_tensor
1300 dcp_local_seq_lens = common_attn_metadata.dcp_local_seq_lens
1301 - num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = (
1302 - split_decodes_and_prefills(
1303 - common_attn_metadata,
1304 - decode_threshold=self.decode_threshold,
1305 - require_uniform=not (self.use_flattening or self.supports_varlen),
1306 - treat_short_extends_as_decodes=not self.use_pcp,
1307 - )
1308 - )
1309 -
1310 - assert num_decodes + num_prefills == num_reqs
1311 - assert num_decode_tokens + num_prefill_tokens == num_tokens
1301
1302 compressed_slot_mapping = slot_mapping
1303 indexer_block_table = block_table
@@ -1347,6 +1336,34 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
1336 dim=0,
1337 )
1338
1339 + # PCP decode sharding keeps a zero-token placeholder on ranks that own
1340 + # no request in a step so collectives retain a uniform rank shape. Do
1341 + # not turn that placeholder into a zero-length indexer decode request.
1342 + if num_tokens == 0:
1343 + return DeepseekV32IndexerMetadata(
1344 + seq_lens=seq_lens,
1345 + max_seq_len=common_attn_metadata.max_seq_len,
1346 + slot_mapping=compressed_slot_mapping,
1347 + num_decodes=0,
1348 + num_decode_tokens=0,
1349 + num_prefills=0,
1350 + num_prefill_tokens=0,
1351 + prefill=None,
1352 + decode=None,
1353 + )
1354 +
1355 + num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = (
1356 + split_decodes_and_prefills(
1357 + common_attn_metadata,
1358 + decode_threshold=self.decode_threshold,
1359 + require_uniform=not (self.use_flattening or self.supports_varlen),
1360 + treat_short_extends_as_decodes=not self.use_pcp,
1361 + )
1362 + )
1363 +
1364 + assert num_decodes + num_prefills == num_reqs
1365 + assert num_decode_tokens + num_prefill_tokens == num_tokens
1366 +
1367 prefill_metadata = None
1368 if num_prefills > 0:
1369 compressed_seq_lens = (
vllm/v1/attention/ops/pcp.py
+24 -8
@@ -12,29 +12,42 @@ def _gather_prefill_cache_inputs(
12 tensors: tuple[torch.Tensor, ...],
13 slot_mapping: torch.Tensor,
14 num_decode_tokens: int,
15 + shard_decode_requests: bool = False,
16 ) -> tuple[tuple[torch.Tensor, ...], torch.Tensor]:
16 - """Keep replicated decode writes local and gather partitioned prefills."""
17 + """Gather PCP cache inputs while preserving replicated KV-cache state.
18 +
19 + PCP-only execution shards decode requests across ranks. In that mode each
20 + rank must gather the other owners' decode KV as well as partitioned prefill
21 + KV so every PCP rank retains a complete cache replica. DCP execution keeps
22 + decode requests replicated and uses the legacy prefill-only gather.
23 + """
24 local_num_tokens = tensors[0].shape[0]
25 assert all(tensor.shape[0] == local_num_tokens for tensor in tensors)
26 assert 0 <= num_decode_tokens <= local_num_tokens
27
28 # Replicated draft decodes use unexpanded slot mappings, even with DP padding.
22 - if (
23 - num_decode_tokens == local_num_tokens
24 - or slot_mapping.shape[0] <= local_num_tokens
25 - ):
29 + if slot_mapping.shape[0] <= local_num_tokens:
30 return (
31 tuple(tensor[:num_decode_tokens] for tensor in tensors),
32 slot_mapping[:num_decode_tokens],
33 )
34
35 pcp_group = get_pcp_group()
36 + pcp_size = pcp_group.world_size
37 + gathered_slot_mapping = slot_mapping[: pcp_size * local_num_tokens]
38 + if shard_decode_requests:
39 + gathered_inputs = tuple(
40 + pcp_group.all_gather(tensor.contiguous(), dim=0) for tensor in tensors
41 + )
42 + return gathered_inputs, gathered_slot_mapping
43 +
44 + if num_decode_tokens == local_num_tokens:
45 + return tensors, slot_mapping[:num_decode_tokens]
46 +
47 gathered_prefills = tuple(
48 pcp_group.all_gather(tensor[num_decode_tokens:].contiguous(), dim=0)
49 for tensor in tensors
50 )
36 - pcp_size = pcp_group.world_size
37 - gathered_slot_mapping = slot_mapping[: pcp_size * local_num_tokens]
51 if num_decode_tokens == 0:
52 return gathered_prefills, gathered_slot_mapping
53
@@ -58,6 +71,7 @@ def maybe_gather_mla_latent_cache_inputs(
71 slot_mapping: torch.Tensor | None,
72 num_decode_tokens: int | None,
73 use_pcp: bool,
74 + pcp_shard_decode_requests: bool = False,
75 ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None]:
76 if not use_pcp or num_decode_tokens is None:
77 return kv_c_normed, k_pe, slot_mapping
@@ -68,6 +82,7 @@ def maybe_gather_mla_latent_cache_inputs(
82 (kv_c_normed, k_pe_flat),
83 slot_mapping,
84 num_decode_tokens,
85 + pcp_shard_decode_requests,
86 )
87 cache_k_pe = cache_k_pe_flat.view(-1, *k_pe.shape[1:])
88 return cache_kv_c, cache_k_pe, cache_slot_mapping
@@ -78,11 +93,12 @@ def maybe_gather_indexer_k(
93 slot_mapping: torch.Tensor,
94 num_decode_tokens: int,
95 use_pcp: bool,
96 + pcp_shard_decode_requests: bool = False,
97 ) -> tuple[torch.Tensor, torch.Tensor]:
98 if not use_pcp:
99 return k, slot_mapping
100 (cache_k,), cache_slot_mapping = _gather_prefill_cache_inputs(
85 - (k,), slot_mapping, num_decode_tokens
101 + (k,), slot_mapping, num_decode_tokens, pcp_shard_decode_requests
102 )
103 return cache_k, cache_slot_mapping
104
vllm/v1/worker/gpu/pcp_manager.py
+26 -3
@@ -49,6 +49,7 @@ class PCPManager:
49 pcp_world_size: int,
50 pcp_rank: int,
51 device: torch.device,
52 + shard_decode_requests: bool,
53 max_num_reqs: int | None = None,
54 max_num_tokens: int | None = None,
55 block_tables: BlockTables | None = None,
@@ -62,6 +63,7 @@ class PCPManager:
63 self.dcp_world_size = dcp_world_size
64 self.dcp_rank = dcp_rank
65 self.cp_interleave = cp_interleave
66 + self.shard_decode_requests = shard_decode_requests
67
68 self._global_batch: InputBatch | None = None
69 self._local_batch: InputBatch | None = None
@@ -248,6 +250,7 @@ class PCPManager:
250
251 Decodes, and prefills too short to fill all eight, are replicated instead.
252 """
253 + decode_ordinal = 0
254 num_chunks = 2 * self.pcp_world_size
255 replicated = self.replicated_requests(num_scheduled_tokens, is_prefilling)
256 for global_batch_req_idx, num_tokens in enumerate(num_scheduled_tokens):
@@ -258,7 +261,16 @@ class PCPManager:
261 if not replicated[global_batch_req_idx]:
262 chunk_size = (query_len + num_chunks - 1) // num_chunks
263 chunk_indices = (rank, num_chunks - 1 - rank)
261 - else: # decodes, and short prefills under DCP, are replicated
264 + elif self.shard_decode_requests:
265 + chunk_size = query_len
266 + # KV and hidden states are gathered back to every PCP rank, so
267 + # decode ownership does not need to persist across steps. Use a
268 + # compact decode-only ordinal to keep each step exactly
269 + # balanced even when prefills and zero-token rows are present.
270 + owner_rank = decode_ordinal % self.pcp_world_size
271 + decode_ordinal += 1
272 + chunk_indices = (0,) if rank == owner_rank else ()
273 + else: # DCP requires decode queries on every participating rank.
274 chunk_size = query_len
275 chunk_indices = (0,)
276
@@ -346,8 +358,18 @@ class PCPManager:
358 segment.global_batch_slice.stop,
359 dtype=np.int64,
360 )
349 - # Cache insertion pairs one slot entry with each rank's local decode.
350 - if replicated[segment.global_batch_req_idx] and rank != 0:
361 + # Replicated rows contain identical KV, so rank 0 is the
362 + # canonical writer. A sharded decode has exactly one owner and
363 + # therefore needs no de-duplication here.
364 + is_sharded_decode = (
365 + not bool(is_prefilling[segment.global_batch_req_idx])
366 + and self.shard_decode_requests
367 + )
368 + if (
369 + replicated[segment.global_batch_req_idx]
370 + and not is_sharded_decode
371 + and rank != 0
372 + ):
373 continue
374 gathered_kv_write_mask[padded_gathered_slice] = True
375 hidden_restore_idx[segment.global_batch_slice] = np.arange(
@@ -829,6 +851,7 @@ def maybe_build_pcp_manager(
851 pcp_world_size=pcp_size,
852 pcp_rank=pcp_rank,
853 device=device,
854 + shard_decode_requests=parallel_config.pcp_shard_decode_requests,
855 max_num_reqs=vllm_config.scheduler_config.max_num_seqs,
856 max_num_tokens=vllm_config.scheduler_config.max_num_batched_tokens,
857 block_tables=block_tables,