[Bugfix][KV Connector][NIXL] Support PCP producers (#52779)

Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com> Co-authored-by: OpenAI Codex <codex@openai.com>

Lucas Wilkinson committed Aug 21, 2026 at 16:22 UTC 0a3a4f7f35b7e6e70a1804742dec65b6bec1d2fa
9 files changed +173 -11
tests/v1/kv_connector/unit/test_multi_connector.py
+6 -6
@@ -283,12 +283,11 @@ def test_multi_example_connector_consistency():
283 events = get_connector_events()
284 storage1_scheduler_events = _ignore_event_collection(events["storage1-SCHEDULER"])
285 storage2_scheduler_events = _ignore_event_collection(events["storage2-SCHEDULER"])
286 - # First event is bind_gpu_block_pool from initialization, then
287 - # set_xfer_handshake_metadata_pp_aware, then on_new_request when the request is
288 - # enqueued, then get_num_new_matched_tokens and update_state_after_alloc from
289 - # generate().
290 - assert storage1_scheduler_events[:6] == [
286 + # Initial events bind the block pool, query completion counts, and exchange
287 + # handshake metadata before the request is enqueued.
288 + assert storage1_scheduler_events[:7] == [
289 "bind_gpu_block_pool",
290 + "get_finished_count",
291 "set_xfer_handshake_metadata_pp_aware",
292 "on_new_request",
293 "get_num_new_matched_tokens 0",
@@ -307,8 +306,9 @@ def test_multi_example_connector_consistency():
306 "wait_for_layer_load",
307 "save_kv_layer",
308 ]
310 - assert storage2_scheduler_events[:6] == [
309 + assert storage2_scheduler_events[:7] == [
310 "bind_gpu_block_pool",
311 + "get_finished_count",
312 "set_xfer_handshake_metadata_pp_aware",
313 "on_new_request",
314 "get_num_new_matched_tokens 0",
tests/v1/kv_connector/unit/test_nixl_connector.py
+52
@@ -570,6 +570,58 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
570
571
572 class TestNixlHandshake:
573 + @pytest.mark.parametrize("pcp_rank", [0, 1])
574 + @patch(
575 + "vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper",
576 + FakeNixlWrapper,
577 + )
578 + def test_pcp_producer_uses_canonical_replica(
579 + self, default_vllm_config, dist_init, pcp_rank
580 + ):
581 + """Only PCP rank zero publishes and reports sending completion."""
582 + vllm_config = create_vllm_config(kv_role="kv_producer")
583 + vllm_config.parallel_config.prefill_context_parallel_size = 2
584 + with (
585 + patch(
586 + "vllm.distributed.kv_transfer.kv_connector.v1.nixl."
587 + "base_worker.get_current_attn_backends",
588 + return_value=[FlashAttentionBackend],
589 + ),
590 + patch(
591 + "vllm.distributed.kv_transfer.kv_connector.v1.nixl."
592 + "base_worker.get_pcp_group"
593 + ) as mock_get_pcp_group,
594 + ):
595 + mock_get_pcp_group.return_value.rank_in_group = pcp_rank
596 + connector = NixlConnector(
597 + vllm_config,
598 + KVConnectorRole.WORKER,
599 + make_kv_cache_config(block_size=16),
600 + )
601 +
602 + worker = connector.connector_worker
603 + assert worker is not None
604 + assert worker.pcp_rank == pcp_rank
605 +
606 + req_id = "req"
607 + metadata = NixlConnectorMetadata()
608 + metadata.reqs_in_batch.add(req_id)
609 + metadata.reqs_to_send[req_id] = time.perf_counter() + 10
610 + worker.start_load_kv(metadata)
611 + expected_tracked = pcp_rank == 0
612 + assert (req_id in worker._reqs_to_process) == expected_tracked
613 + assert (req_id in worker._reqs_to_send) == expected_tracked
614 +
615 + payload = MagicMock(spec=NixlHandshakePayload)
616 + worker.xfer_handshake_metadata = payload
617 + worker.get_finished = MagicMock(return_value=({"sent"}, set()))
618 +
619 + expected_payload = payload if pcp_rank == 0 else None
620 + assert connector.get_handshake_metadata() is expected_payload
621 + done_sending, done_recving = connector.get_finished(set())
622 + assert done_sending == ({"sent"} if pcp_rank == 0 else set())
623 + assert done_recving == set()
624 +
625 @patch(
626 "vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper",
627 FakeNixlWrapper,
tests/v1/kv_connector/unit/test_nixl_push_connector.py
+19
@@ -334,6 +334,7 @@ class _StubWriterWorker(NixlPushConnectorWorker):
334 w._reqs_to_send = {}
335 w.consumer_notification_counts_by_req = defaultdict(int)
336 w.tp_rank = 0
337 + w.pcp_rank = 0
338 w.world_size = 1
339 w.engine_id = "test-decode-engine"
340 w._remote_agents = {}
@@ -514,6 +515,24 @@ class TestPushWriterStartLoadKv:
515 assert w._push_writer_wake.is_set()
516 assert w.start_push_calls == []
517
518 + def test_noncanonical_pcp_rank_skips_producer_work(self):
519 + w = _StubWriterWorker.fresh()
520 + w.pcp_rank = 1
521 + w._send_heartbeats = MagicMock()
522 +
523 + meta = NixlConnectorMetadata()
524 + meta.push_finished_blocks["req"] = ([1, 2],)
525 + meta.reqs_in_batch.add("req")
526 + meta.reqs_to_send["req"] = time.perf_counter() + 10
527 +
528 + w.start_load_kv(meta)
529 +
530 + assert w._finished_blocks_inbox.empty()
531 + assert "req" not in w._reqs_to_process
532 + assert "req" not in w._reqs_to_send
533 + assert not w._push_writer_wake.is_set()
534 + w._send_heartbeats.assert_not_called()
535 +
536
537 # The P→D handshake must run on the base worker's background executor, never
538 # blocking the writer thread: ``_do_start_push_kv`` defers the WRITE until the
vllm/distributed/kv_transfer/kv_connector/v1/multi_connector.py
+12 -3
@@ -363,9 +363,18 @@ class MultiConnector(KVConnectorBase_V1, SupportsHMA):
363 c.handle_preemptions(cm)
364
365 def get_finished_count(self) -> int | None:
366 - # TODO(https://github.com/vllm-project/vllm/issues/33400)
367 - # Currently no connectors return non-None
368 - return None
366 + child_counts = [
367 + connector.get_finished_count() for connector in self._connectors
368 + ]
369 + if any(count is None for count in child_counts):
370 + return None
371 + counts = {count for count in child_counts if count is not None}
372 + if len(counts) > 1:
373 + raise ValueError(
374 + "MultiConnector children returned incompatible finished counts: "
375 + f"{sorted(counts)}"
376 + )
377 + return next(iter(counts), None)
378
379 def build_connector_worker_meta(self) -> KVConnectorWorkerMetadata | None:
380 metadata_list: list[KVConnectorWorkerMetadata | None] | None = None
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+27
@@ -62,6 +62,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.ssm_conv_transfer_utils import
62 )
63 from vllm.distributed.nixl_utils import NixlWrapper, nixl_agent_config
64 from vllm.distributed.parallel_state import (
65 + get_pcp_group,
66 get_tensor_model_parallel_rank,
67 get_tensor_model_parallel_world_size,
68 )
@@ -369,6 +370,9 @@ class NixlBaseConnectorWorker:
370 self.engine_id: EngineId = engine_id
371 self.tp_rank = get_tensor_model_parallel_rank()
372 self.world_size = get_tensor_model_parallel_world_size()
373 + self.pcp_size = vllm_config.parallel_config.prefill_context_parallel_size
374 + self.dcp_size = vllm_config.parallel_config.decode_context_parallel_size
375 + self.pcp_rank = get_pcp_group().rank_in_group if self.pcp_size > 1 else 0
376
377 self.num_blocks = kv_cache_config.num_blocks
378 self.enable_permute_local_kv = False
@@ -561,6 +565,23 @@ class NixlBaseConnectorWorker:
565 "enforce_handshake_compat", True
566 )
567
568 + def _validate_remote_parallel_config(
569 + self, agent_metadata: NixlAgentMetadata
570 + ) -> None:
571 + local_pcp_size = self.pcp_size
572 + local_dcp_size = self.dcp_size
573 + remote_pcp_size = agent_metadata.pcp_size
574 + remote_dcp_size = agent_metadata.dcp_size
575 + if (local_pcp_size > 1 and remote_dcp_size > 1) or (
576 + remote_pcp_size > 1 and local_dcp_size > 1
577 + ):
578 + raise NotImplementedError(
579 + "NixlConnector PCP requires decode_context_parallel_size=1 "
580 + "on both instances. "
581 + f"Local PCP/DCP={local_pcp_size}/{local_dcp_size}; "
582 + f"remote PCP/DCP={remote_pcp_size}/{remote_dcp_size}."
583 + )
584 +
585 def _sync_block_size_with_kernel(self) -> None:
586 backends = get_current_attn_backends(self.vllm_config)
587 kernel_block_size = select_common_block_size(self.block_size, backends)
@@ -700,6 +721,8 @@ class NixlBaseConnectorWorker:
721 f"Failed to decode NixlAgentMetadata. Error: {e}"
722 ) from e
723
724 + self._validate_remote_parallel_config(metadata)
725 +
726 # Ensure engine id matches.
727 if metadata.engine_id != expected_engine_id:
728 raise RuntimeError(
@@ -1033,6 +1056,8 @@ class NixlBaseConnectorWorker:
1056 physical_blocks_per_logical_kv_block=(
1057 self._physical_blocks_per_logical_kv_block
1058 ),
1059 + dcp_size=self.dcp_size,
1060 + pcp_size=self.pcp_size,
1061 )
1062 assert self.compat_hash is not None
1063 encoder = msgspec.msgpack.Encoder()
@@ -1277,6 +1302,8 @@ class NixlBaseConnectorWorker:
1302 physical_blocks_per_logical_kv_block=(
1303 self._physical_blocks_per_logical_kv_block
1304 ),
1305 + dcp_size=self.dcp_size,
1306 + pcp_size=self.pcp_size,
1307 )
1308 # Wrap metadata in payload with hash for defensive decoding
1309 assert self.compat_hash is not None
vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py
+47 -1
@@ -131,6 +131,29 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA):
131 "kv_role='kv_consumer' for decode instances. "
132 )
133
134 + parallel_config = vllm_config.parallel_config
135 + pcp_size = parallel_config.prefill_context_parallel_size
136 + kv_role = vllm_config.kv_transfer_config.kv_role
137 + if pcp_size > 1 and kv_role in ("kv_consumer", "kv_both"):
138 + raise NotImplementedError(
139 + "NixlConnector PCP currently supports kv_producer only. "
140 + "Consumers and kv_both require "
141 + "prefill_context_parallel_size=1."
142 + )
143 + if pcp_size > 1 and parallel_config.decode_context_parallel_size > 1:
144 + raise NotImplementedError(
145 + "NixlConnector PCP producers currently require "
146 + "decode_context_parallel_size=1."
147 + )
148 + # TODO: Support PCP with bidirectional KV transfer by tracking separate
149 + # send and receive completion counts.
150 + if pcp_size > 1 and vllm_config.kv_transfer_config.get_from_extra_config(
151 + "bidirectional_kv_xfer", False
152 + ):
153 + raise NotImplementedError(
154 + "NixlConnector PCP producers do not support bidirectional KV transfer."
155 + )
156 +
157 self.kv_cache_config = kv_cache_config
158 self.engine_id: EngineId = vllm_config.kv_transfer_config.engine_id
159 self.kv_transfer_config = vllm_config.kv_transfer_config
@@ -164,6 +187,18 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA):
187 # Scheduler Side Methods
188 ############################################################
189
190 + def get_finished_count(self) -> int | None:
191 + parallel_config = self._vllm_config.parallel_config
192 + if (
193 + self.kv_transfer_config.kv_role == "kv_producer"
194 + and parallel_config.prefill_context_parallel_size > 1
195 + ):
196 + return (
197 + parallel_config.tensor_parallel_size
198 + * parallel_config.pipeline_parallel_size
199 + )
200 + return None
201 +
202 def get_num_new_matched_tokens(
203 self, request: "Request", num_computed_tokens: int
204 ) -> tuple[int | None, bool]:
@@ -244,7 +279,13 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA):
279 def get_finished(self, finished_req_ids: set[str]) -> tuple[set[str], set[str]]:
280 """Get the finished recving and sending requests."""
281 assert self.connector_worker is not None
247 - return self.connector_worker.get_finished()
282 + done_sending, done_recving = self.connector_worker.get_finished()
283 + if (
284 + self.kv_transfer_config.kv_role == "kv_producer"
285 + and self.connector_worker.pcp_rank > 0
286 + ):
287 + done_sending.clear()
288 + return done_sending, done_recving
289
290 def get_block_ids_with_load_errors(self) -> set[int]:
291 """Get block IDs that failed to load via NIXL."""
@@ -320,6 +361,11 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA):
361 None if no handshake metadata is available.
362 """
363 assert self.connector_worker is not None
364 + if (
365 + self.kv_transfer_config.kv_role == "kv_producer"
366 + and self.connector_worker.pcp_rank > 0
367 + ):
368 + return None
369 return self.connector_worker.xfer_handshake_metadata
370
371
vllm/distributed/kv_transfer/kv_connector/v1/nixl/metadata.py
+4 -1
@@ -43,8 +43,9 @@ PUSH_REG_NOTIF_PREFIX = b"PUSH_REG:"
43 # clock-sync timestamp
44 # 6: Validate EAGLE/MTP speculative configuration compatibility
45 # 7: Include NIXL transfer mode (push vs pull) in the compatibility hash
46 +# 8: Add dcp_size and pcp_size to NixlAgentMetadata
47 #
47 -NIXL_CONNECTOR_VERSION: int = 7
48 +NIXL_CONNECTOR_VERSION: int = 8
49
50
51 @dataclass
@@ -60,6 +61,8 @@ class NixlAgentMetadata:
61 ssm_sizes: tuple[int, int]
62 attn_backend_name: str
63 physical_blocks_per_logical_kv_block: int
64 + dcp_size: int = 1
65 + pcp_size: int = 1
66
67
68 @dataclass
vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py
+3
@@ -76,6 +76,9 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
76 while not self._ready_requests.empty():
77 self._read_blocks_for_req(*self._ready_requests.get_nowait())
78
79 + if self.pcp_rank > 0:
80 + return
81 +
82 # Keep around the requests that have been part of a batch. This is
83 # needed because async scheduling pushes the misalignment between the
84 # moment in which requests expiration is set (P side) and the moment in
vllm/distributed/kv_transfer/kv_connector/v1/nixl/push_worker.py
+3
@@ -159,6 +159,9 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
159
160 def start_load_kv(self, metadata: NixlConnectorMetadata):
161 """Pre-process metadata; defer NIXL ops to the writer thread."""
162 + if self.pcp_rank > 0:
163 + return
164 +
165 # D-side: track reqs waiting for P to push.
166 for req_id, meta in metadata.reqs_to_recv.items():
167 meta.local_physical_block_ids = self._logical_to_kernel_block_ids(