[NIXL][PCP][DCP] Expose PCP producer KV shards as transfer ranks (#56645)
Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com> Co-authored-by: OpenAI Codex <noreply@openai.com>
Lucas Wilkinson committed
Sep 13, 2026 at 16:23 UTC
7fe8fc803d904795fd10b785776e20181aee64c5
13 files changed
+124
-44
tests/v1/kv_connector/unit/test_nixl_connector.py
+57
-16
@@ -580,19 +580,38 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
580
581
582
class TestNixlHandshake:
583
- @pytest.mark.parametrize("pcp_rank", [0, 1])
583
+ @pytest.mark.parametrize(
584
+ ("pcp_rank", "pcp_size", "dcp_size", "expected_tracked"),
585
+ [
586
+ (0, 2, 1, True),
587
+ (1, 2, 1, False),
588
+ (0, 2, 2, True),
589
+ (1, 2, 2, True),
590
+ (0, 4, 4, True),
591
+ (1, 4, 4, True),
592
+ (2, 4, 4, True),
593
+ (3, 4, 4, True),
594
+ ],
595
+ )
596
@patch(
597
"vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper",
598
FakeNixlWrapper,
599
)
588
- def test_pcp_producer_uses_canonical_replica(
589
- self, default_vllm_config, dist_init, pcp_rank
600
+ def test_pcp_producer_exposes_dcp_shards_or_canonical_replica(
601
+ self,
602
+ default_vllm_config,
603
+ dist_init,
604
+ pcp_rank,
605
+ pcp_size,
606
+ dcp_size,
607
+ expected_tracked,
608
):
591
- """Only PCP rank zero publishes, but every rank reports completion."""
609
+ """Replicated PCP is canonicalized; PCP-DCP publishes every shard."""
610
from vllm.v1.attention.backends.flash_attn import FlashAttentionBackend
611
612
vllm_config = create_vllm_config(kv_role="kv_producer")
595
- vllm_config.parallel_config.prefill_context_parallel_size = 2
613
+ vllm_config.parallel_config.prefill_context_parallel_size = pcp_size
614
+ vllm_config.parallel_config.decode_context_parallel_size = dcp_size
615
with (
616
patch(
617
"vllm.distributed.kv_transfer.kv_connector.v1.nixl."
@@ -614,13 +633,15 @@ class TestNixlHandshake:
633
worker = connector.connector_worker
634
assert worker is not None
635
assert worker.pcp_rank == pcp_rank
636
+ assert worker.pcp_dcp_sharded is (dcp_size > 1)
637
+ assert worker.transfer_tp_rank == (pcp_rank if dcp_size > 1 else 0)
638
+ assert worker.transfer_tp_size == (pcp_size if dcp_size > 1 else 1)
639
640
req_id = "req"
641
metadata = NixlConnectorMetadata()
642
metadata.reqs_in_batch.add(req_id)
643
metadata.reqs_to_send[req_id] = time.perf_counter() + 10
644
worker.start_load_kv(metadata)
623
- expected_tracked = pcp_rank == 0
645
assert (req_id in worker._reqs_to_process) == expected_tracked
646
assert (req_id in worker._reqs_to_send) == expected_tracked
647
@@ -628,22 +649,22 @@ class TestNixlHandshake:
649
worker.xfer_handshake_metadata = payload
650
worker.transfer_topo = MagicMock()
651
worker._get_new_notifs = MagicMock(
631
- side_effect=lambda: {"sent"} if pcp_rank == 0 else set()
652
+ side_effect=lambda: {"sent"} if expected_tracked else set()
653
)
654
634
- expected_payload = payload if pcp_rank == 0 else None
655
+ expected_payload = payload if expected_tracked else None
656
assert connector.get_handshake_metadata() is expected_payload
657
done_sending, done_recving = connector.get_finished(set())
637
- assert done_sending == ({"sent"} if pcp_rank == 0 else {req_id})
658
+ assert done_sending == ({"sent"} if expected_tracked else {req_id})
659
assert done_recving == set()
639
- if pcp_rank > 0:
660
+ if not expected_tracked:
661
assert connector.get_finished(set()) == (set(), set())
662
663
worker.get_transfer_results = MagicMock(
664
return_value=KVConnectorTransferResults(finished_sending={"sent"})
665
)
666
results = connector.get_transfer_results(set())
646
- assert results.finished_sending == ({"sent"} if pcp_rank == 0 else set())
667
+ assert results.finished_sending == ({"sent"} if expected_tracked else set())
668
669
@patch(
670
"vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper",
@@ -3354,9 +3375,15 @@ def test_transfer_mode_changes_compatibility_hash():
3375
3376
3377
@pytest.mark.skip_global_cleanup
3357
-def test_scheduler_advertises_transfer_mode():
3358
- # Each scheduler advertises its transfer mode in kv_transfer_params so an
3359
- # external router can route pull (READ) vs push (WRITE) producers.
3378
+@pytest.mark.parametrize("mode", ["pull", "push"])
3379
+@pytest.mark.parametrize(
3380
+ "tp_size,pcp_size,dcp_size,transfer_tp_size",
3381
+ [(1, 1, 1, 1), (4, 1, 1, 4), (4, 1, 4, 4), (1, 4, 1, 1), (1, 4, 4, 4)],
3382
+)
3383
+def test_scheduler_advertises_transfer_topology(
3384
+ mode, tp_size, pcp_size, dcp_size, transfer_tp_size
3385
+):
3386
+ """The consumer must address every distinct producer KV shard."""
3387
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.pull_scheduler import (
3388
NixlPullConnectorScheduler,
3389
)
@@ -3364,8 +3391,22 @@ def test_scheduler_advertises_transfer_mode():
3391
NixlPushConnectorScheduler,
3392
)
3393
3367
- assert NixlPullConnectorScheduler._TRANSFER_MODE == "pull"
3368
- assert NixlPushConnectorScheduler._TRANSFER_MODE == "push"
3394
+ config = create_vllm_config(kv_role="kv_producer")
3395
+ config.parallel_config.tensor_parallel_size = tp_size
3396
+ config.parallel_config.prefill_context_parallel_size = pcp_size
3397
+ config.parallel_config.decode_context_parallel_size = dcp_size
3398
+ cls = NixlPullConnectorScheduler if mode == "pull" else NixlPushConnectorScheduler
3399
+ scheduler = cls(config, "prefiller", make_kv_cache_config(block_size=16))
3400
+ request = create_request(request_id=1, num_tokens=32, do_remote_decode=True)
3401
+ request.status = RequestStatus.FINISHED_LENGTH_CAPPED
3402
+ try:
3403
+ delay, params = scheduler.request_finished(request, ([0, 1],))
3404
+ assert delay
3405
+ assert params["transfer_mode"] == mode
3406
+ assert params["tp_size"] == transfer_tp_size
3407
+ assert params["dcp_size"] == dcp_size
3408
+ finally:
3409
+ scheduler.shutdown()
3410
3411
3412
@pytest.mark.parametrize(
tests/v1/kv_connector/unit/test_nixl_push_connector.py
+9
-6
@@ -358,6 +358,7 @@ class _StubWriterWorker(NixlPushConnectorWorker):
358
w.consumer_notification_counts_by_req = defaultdict(int)
359
w.tp_rank = 0
360
w.pcp_rank = 0
361
+ w.pcp_dcp_sharded = False
362
w.world_size = 1
363
w.engine_id = "test-decode-engine"
364
w._remote_agents = {}
@@ -545,9 +546,11 @@ class TestPushWriterStartLoadKv:
546
assert w._push_writer_wake.is_set()
547
assert w.start_push_calls == []
548
548
- def test_noncanonical_pcp_rank_skips_producer_work(self):
549
+ @pytest.mark.parametrize("sharded", [False, True])
550
+ def test_noncanonical_pcp_rank_only_pushes_distinct_shards(self, sharded):
551
w = _StubWriterWorker.fresh()
552
w.pcp_rank = 1
553
+ w.pcp_dcp_sharded = sharded
554
w._send_heartbeats = MagicMock()
555
556
meta = NixlConnectorMetadata()
@@ -557,11 +560,11 @@ class TestPushWriterStartLoadKv:
560
561
w.start_load_kv(meta)
562
560
- assert w._finished_blocks_inbox.empty()
561
- assert "req" not in w._reqs_to_process
562
- assert "req" not in w._reqs_to_send
563
- assert not w._push_writer_wake.is_set()
564
- w._send_heartbeats.assert_not_called()
563
+ assert w._finished_blocks_inbox.empty() is (not sharded)
564
+ assert ("req" in w._reqs_to_process) is sharded
565
+ assert ("req" in w._reqs_to_send) is sharded
566
+ assert w._push_writer_wake.is_set() is sharded
567
+ assert w._send_heartbeats.called is sharded
568
569
570
# The P→D handshake must run on the base worker's background executor, never
tests/v1/kv_connector/unit/test_tp_mapping.py
+1
@@ -75,6 +75,7 @@ class TestTPMappingStructure:
75
@pytest.mark.parametrize(
76
"tp_rank,tp_size,remote_tp_size,dcp_size,remote_dcp_size,expected_ranks",
77
[
78
+ (0, 1, 8, 1, 8, tuple(range(8))),
79
(0, 4, 4, 1, 4, (0, 1, 2, 3)),
80
(2, 4, 4, 4, 4, (2,)),
81
(0, 2, 4, 2, 4, (0, 2)),
tests/v1/kv_connector/unit/utils.py
+2
@@ -548,6 +548,7 @@ def make_nixl_scheduler(
548
sched._reqs_need_save = {}
549
sched.use_host_buffer = False
550
sched.engine_id = "test-engine"
551
+ sched.transfer_tp_size = 1
552
sched.side_channel_host = "localhost"
553
sched.side_channel_port = 5555
554
sched.blocks_per_sw = []
@@ -587,6 +588,7 @@ def make_nixl_push_scheduler(
588
sched.decoder_kv_blocks_ttl = decoder_kv_blocks_ttl
589
sched.use_host_buffer = False
590
sched.engine_id = "decode-engine"
591
+ sched.transfer_tp_size = 1
592
sched.side_channel_host = "127.0.0.1"
593
sched.side_channel_port = 5600
594
sched.is_bidirectional_kv_xfer_enabled = is_bidirectional_kv_xfer_enabled
vllm/config/vllm.py
+6
-6
@@ -1329,14 +1329,14 @@ class VllmConfig:
1329
self.kv_transfer_config is not None
1330
and self.kv_transfer_config.has_connector("NixlConnector")
1331
):
1332
- assert self.parallel_config.prefill_context_parallel_size == 1, (
1333
- "NIXL does not support prefill context parallelism."
1334
- )
1332
dcp_size = self.parallel_config.decode_context_parallel_size
1336
- tp_size = self.parallel_config.tensor_parallel_size
1337
- assert dcp_size in (1, tp_size), (
1333
+ transfer_tp_size = max(
1334
+ self.parallel_config.tensor_parallel_size,
1335
+ self.parallel_config.prefill_context_parallel_size,
1336
+ )
1337
+ assert dcp_size in (1, transfer_tp_size), (
1338
f"decode_context_parallel_size={dcp_size} must be 1 or equal "
1339
- f"to tensor_parallel_size={tp_size} when using NixlConnector."
1339
+ f"to the NIXL transfer parallel size={transfer_tp_size}."
1340
)
1341
if self.model_config is not None:
1342
assert self.model_config.use_mla or dcp_size == 1, (
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py
+6
@@ -63,6 +63,12 @@ class NixlBaseConnectorScheduler:
63
kv_cache_config: "KVCacheConfig",
64
):
65
self.vllm_config = vllm_config
66
+ parallel_config = vllm_config.parallel_config
67
+ # TP1 PCP+DCP exposes its DCP shards as transfer ranks.
68
+ self.transfer_tp_size = max(
69
+ parallel_config.tensor_parallel_size,
70
+ parallel_config.decode_context_parallel_size,
71
+ )
72
self.block_size = vllm_config.cache_config.block_size
73
self.engine_id: EngineId = engine_id
74
self.kv_cache_config = kv_cache_config
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+19
-9
@@ -548,9 +548,15 @@ class NixlBaseConnectorWorker:
548
self.dcp_size = vllm_config.parallel_config.decode_context_parallel_size
549
self.pcp_rank = get_pcp_group().rank_in_group if self.pcp_size > 1 else 0
550
551
- # DCP support is scoped to MLA, with dcp_size in (1, tp_size): either fully
552
- # replicated or fully sharded. A DCP rank is always derivable this way.
553
- self.dcp_rank = self.tp_rank % self.dcp_size
551
+ # TP1 PCP+DCP owns distinct KV shards; replicated PCP uses rank zero.
552
+ self.pcp_dcp_sharded = self.pcp_size > 1 and self.dcp_size > 1
553
+ self.transfer_tp_size = (
554
+ self.pcp_size if self.pcp_dcp_sharded else self.world_size
555
+ )
556
+ self.transfer_tp_rank = self.pcp_rank if self.pcp_dcp_sharded else self.tp_rank
557
+
558
+ # MLA is either fully replicated or fully sharded across transfer ranks.
559
+ self.dcp_rank = self.transfer_tp_rank % self.dcp_size
560
561
self.num_blocks = kv_cache_config.num_blocks
562
self.enable_permute_local_kv = False
@@ -781,12 +787,16 @@ class NixlBaseConnectorWorker:
787
local_dcp_size = self.dcp_size
788
remote_pcp_size = agent_metadata.pcp_size
789
remote_dcp_size = agent_metadata.dcp_size
784
- if (local_pcp_size > 1 and remote_dcp_size > 1) or (
785
- remote_pcp_size > 1 and local_dcp_size > 1
790
+ if remote_pcp_size > 1 and remote_dcp_size not in (1, remote_pcp_size):
791
+ raise NotImplementedError(
792
+ "Remote NixlConnector PCP+DCP does not span the full PCP group. "
793
+ f"Remote PCP/DCP={remote_pcp_size}/{remote_dcp_size}."
794
+ )
795
+ if (local_pcp_size > 1 and local_dcp_size == 1 and remote_dcp_size > 1) or (
796
+ remote_pcp_size > 1 and remote_dcp_size == 1 and local_dcp_size > 1
797
):
798
raise NotImplementedError(
788
- "NixlConnector PCP requires decode_context_parallel_size=1 "
789
- "on both instances. "
799
+ "Replicated PCP cannot be paired with a DCP-sharded NIXL peer. "
800
f"Local PCP/DCP={local_pcp_size}/{local_dcp_size}; "
801
f"remote PCP/DCP={remote_pcp_size}/{remote_dcp_size}."
802
)
@@ -1210,8 +1220,8 @@ class NixlBaseConnectorWorker:
1220
"""Register the KV Cache data in nixl."""
1221
1222
self.transfer_topo = TransferTopology(
1213
- tp_rank=self.tp_rank,
1214
- tp_size=self.world_size,
1223
+ tp_rank=self.transfer_tp_rank,
1224
+ tp_size=self.transfer_tp_size,
1225
block_size=self.block_size,
1226
engine_id=self.engine_id,
1227
is_mla=self.use_mla,
vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py
+10
-3
@@ -108,10 +108,15 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA):
108
"Consumers and kv_both require "
109
"prefill_context_parallel_size=1."
110
)
111
- if pcp_size > 1 and parallel_config.decode_context_parallel_size > 1:
111
+ dcp_size = parallel_config.decode_context_parallel_size
112
+ if (
113
+ pcp_size > 1
114
+ and dcp_size > 1
115
+ and (parallel_config.tensor_parallel_size != 1 or dcp_size != pcp_size)
116
+ ):
117
raise NotImplementedError(
113
- "NixlConnector PCP producers currently require "
114
- "decode_context_parallel_size=1."
118
+ "NixlConnector PCP+DCP requires TP1 with DCP spanning "
119
+ "the full PCP group."
120
)
121
# TODO: Support PCP with bidirectional KV transfer by tracking separate
122
# send and receive completion counts.
@@ -243,6 +248,7 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA):
248
if (
249
self.kv_transfer_config.kv_role == "kv_producer"
250
and self.connector_worker.pcp_rank > 0
251
+ and not self.connector_worker.pcp_dcp_sharded
252
):
253
results.finished_sending.clear()
254
return results
@@ -324,6 +330,7 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA):
330
if (
331
self.kv_transfer_config.kv_role == "kv_producer"
332
and self.connector_worker.pcp_rank > 0
333
+ and not self.connector_worker.pcp_dcp_sharded
334
):
335
return None
336
return self.connector_worker.xfer_handshake_metadata
vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_scheduler.py
+1
-1
@@ -282,7 +282,7 @@ class NixlPullConnectorScheduler(NixlBaseConnectorScheduler):
282
remote_request_id=request.request_id,
283
remote_host=self.side_channel_host,
284
remote_port=self.side_channel_port,
285
- tp_size=self.vllm_config.parallel_config.tensor_parallel_size,
285
+ tp_size=self.transfer_tp_size,
286
dcp_size=self.vllm_config.parallel_config.decode_context_parallel_size,
287
pp_size=self.vllm_config.parallel_config.pipeline_parallel_size,
288
remote_num_tokens=remote_num_tokens,
vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py
+1
-1
@@ -80,7 +80,7 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
80
while not self._ready_requests.empty():
81
self._read_blocks_for_req(*self._ready_requests.get_nowait())
82
83
- if self.pcp_rank > 0:
83
+ if self.pcp_rank > 0 and not self.pcp_dcp_sharded:
84
# Replicated-KV PCP: only PCP rank 0 serves the KV, so this rank
85
# has nothing to send. Report the requests as sent right away so
86
# the scheduler-side aggregation (world_size workers, and any
vllm/distributed/kv_transfer/kv_connector/v1/nixl/push_scheduler.py
+2
-1
@@ -286,7 +286,8 @@ class NixlPushConnectorScheduler(NixlBaseConnectorScheduler):
286
remote_request_id=request.request_id,
287
remote_host=self.side_channel_host,
288
remote_port=self.side_channel_port,
289
- tp_size=self.vllm_config.parallel_config.tensor_parallel_size,
289
+ tp_size=self.transfer_tp_size,
290
+ dcp_size=self.vllm_config.parallel_config.decode_context_parallel_size,
291
pp_size=self.vllm_config.parallel_config.pipeline_parallel_size,
292
remote_num_tokens=remote_num_tokens,
293
transfer_mode=self._TRANSFER_MODE,
vllm/distributed/kv_transfer/kv_connector/v1/nixl/push_worker.py
+1
-1
@@ -166,7 +166,7 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
166
167
def start_load_kv(self, metadata: NixlConnectorMetadata):
168
"""Pre-process metadata; defer NIXL ops to the writer thread."""
169
- if self.pcp_rank > 0:
169
+ if self.pcp_rank > 0 and not self.pcp_dcp_sharded:
170
return
171
172
# D-side: track reqs waiting for P to push.
vllm/v1/worker/gpu_worker.py
+9
@@ -45,6 +45,7 @@ from vllm.distributed.parallel_state import (
45
Handle,
46
checkpoint_prepare_distributed_state,
47
checkpoint_restore_distributed_state,
48
+ get_pcp_group,
49
get_pp_group,
50
get_tp_group,
51
resume_device_comms,
@@ -703,6 +704,14 @@ class Worker(WorkerBase):
704
705
pp_rank = get_pp_group().rank_in_group
706
tp_rank = get_tp_group().rank_in_group
707
+ parallel_config = self.vllm_config.parallel_config
708
+ if (
709
+ parallel_config.prefill_context_parallel_size > 1
710
+ and parallel_config.decode_context_parallel_size > 1
711
+ ):
712
+ tp_rank += (
713
+ get_pcp_group().rank_in_group * parallel_config.tensor_parallel_size
714
+ )
715
return {(pp_rank, tp_rank): metadata}
716
717
def get_kv_cache_spec(self) -> dict[str, KVCacheSpec]: