[KVConnector][NIXL] Support pipeline-parallel prefill in push mode (#45880)

Signed-off-by: zixi-qi <zixi@inferact.ai> Co-authored-by: Nick Hill <nickhill123@gmail.com>

qizixi committed Jul 8, 2026 at 16:49 UTC 7802c20c4e02d1c403fc4140d9b8e7b971e99d20
15 files changed +506 -104
.buildkite/test_areas/disaggregated.yaml
+13
@@ -39,6 +39,19 @@ steps:
39 - bash /vllm-workspace/.buildkite/scripts/install-kv-connectors.sh
40 - FLASHINFER=1 bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh
41
42 +- label: Push NixlConnector PP prefill PD accuracy (4 GPUs)
43 + key: push-nixlconnector-pp-prefill-pd-accuracy-4-gpus
44 + timeout_in_minutes: 30
45 + working_dir: "/vllm-workspace/tests"
46 + num_devices: 4
47 + source_file_dependencies:
48 + - vllm/distributed/kv_transfer/kv_connector/v1/nixl/
49 + - tests/v1/kv_connector/nixl_integration/
50 + - tests/v1/kv_connector/nixl_push_integration/
51 + commands:
52 + - bash /vllm-workspace/.buildkite/scripts/install-kv-connectors.sh
53 + - bash v1/kv_connector/nixl_push_integration/config_sweep_accuracy_test.sh
54 +
55 - label: DP EP Distributed NixlConnector PD accuracy tests (4 GPUs)
56 key: dp-ep-distributed-nixlconnector-pd-accuracy-tests-4-gpus
57 timeout_in_minutes: 30
examples/disaggregated/disaggregated_serving/disagg_proxy_pushconnector_demo.py
+15 -4
@@ -9,9 +9,9 @@ the same; the difference is in how P and D coordinate the KV transfer:
9 * Pull mode: proxy forwards P's ``kv_transfer_params`` (including
10 ``remote_block_ids``) to D, and D pulls KV from P via NIXL READ.
11 * Push mode: proxy hands D **only** P's coordinates
12 - (``remote_engine_id``, ``remote_host``, ``remote_port``, ``tp_size``)
13 - and the shared ``remote_request_id``. D registers its locally allocated
14 - blocks with P over a NIXL notification; P then pushes the KV to D via
12 + (``remote_engine_id``, ``remote_host``, ``remote_port``, ``tp_size``,
13 + ``pp_size``) and the shared ``remote_request_id``. D registers its locally
14 + allocated blocks with P over a NIXL notification; P then pushes the KV to D via
15 NIXL WRITE.
16
17 Launch multiple vLLM instances configured with ``NixlPushConnector`` and
@@ -26,6 +26,7 @@ disagg_proxy_pushconnector_demo.py \
26 --prefill-kv-host 10.0.0.1 \
27 --prefill-side-channel-port 5600 \
28 --prefill-tp-size 1 \
29 + --prefill-pp-size 1 \
30 --port 8000
31 """
32
@@ -89,6 +90,7 @@ class PushProxy:
90 prefill_kv_host: str,
91 prefill_side_channel_port: int,
92 prefill_tp_size: int,
93 + prefill_pp_size: int,
94 custom_create_completion: Callable[[Request], StreamingResponse] | None = None,
95 custom_create_chat_completion: Callable[[Request], StreamingResponse]
96 | None = None,
@@ -110,6 +112,7 @@ class PushProxy:
112 "remote_host": prefill_kv_host,
113 "remote_port": prefill_side_channel_port,
114 "tp_size": prefill_tp_size,
115 + "pp_size": prefill_pp_size,
116 }
117
118 self.custom_create_completion = custom_create_completion
@@ -198,6 +201,7 @@ class PushProxy:
201 "prefill_kv_host": self.push_metadata["remote_host"],
202 "prefill_side_channel_port": self.push_metadata["remote_port"],
203 "prefill_tp_size": self.push_metadata["tp_size"],
204 + "prefill_pp_size": self.push_metadata["pp_size"],
205 }
206
207 # ── push-mode request handling ──────────────────────────────────── #
@@ -265,7 +269,7 @@ class PushProxy:
269 generator = self.forward_request(
270 f"http://{decode_instance}{path}", decode_request, headers
271 )
268 - return StreamingResponse(generator)
272 + return StreamingResponse(generator, media_type="application/json")
273
274 async def create_completion(self, raw_request: Request):
275 try:
@@ -316,6 +320,7 @@ class PushProxyServer:
320 prefill_kv_host=args.prefill_kv_host,
321 prefill_side_channel_port=args.prefill_side_channel_port,
322 prefill_tp_size=args.prefill_tp_size,
323 + prefill_pp_size=args.prefill_pp_size,
324 custom_create_completion=create_completion,
325 custom_create_chat_completion=create_chat_completion,
326 )
@@ -420,6 +425,12 @@ def parse_args():
425 default=1,
426 help="Tensor parallel size of the prefill vLLM instance",
427 )
428 + parser.add_argument(
429 + "--prefill-pp-size",
430 + type=int,
431 + default=1,
432 + help="Pipeline parallel size of the prefill vLLM instance",
433 + )
434 return parser.parse_args()
435
436
tests/v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh
-1
@@ -40,7 +40,6 @@ sw_attn_configs=(
40 # Gemma4: SW + cross-layer KV sharing
41 "GPU_MEMORY_UTILIZATION=0.8 MODEL_NAMES=google/gemma-4-E2B-it VLLM_SERVE_EXTRA_ARGS=--max-model-len,8192"
42 )
43 -
43 # Select config array based on DP_EP env var
44 if [[ -n "${DP_EP:-}" ]]; then
45 configs=("${dp_ep_configs[@]}")
tests/v1/kv_connector/nixl_integration/nixl_side_channel_probe.py
+2 -1
@@ -15,6 +15,7 @@ def parse_args() -> argparse.Namespace:
15 parser = argparse.ArgumentParser()
16 parser.add_argument("--host", required=True)
17 parser.add_argument("--port", required=True, type=int)
18 + parser.add_argument("--pp-rank", default=0, type=int)
19 parser.add_argument("--rank", default=0, type=int)
20 parser.add_argument("--timeout-ms", default=1000, type=int)
21 return parser.parse_args()
@@ -37,7 +38,7 @@ def main() -> None:
38 sock.setsockopt(zmq.RCVTIMEO, args.timeout_ms)
39 try:
40 sock.connect(make_zmq_path(args.host, args.port))
40 - sock.send(msgspec.msgpack.encode((GET_META_MSG, args.rank)))
41 + sock.send(msgspec.msgpack.encode((GET_META_MSG, args.pp_rank, args.rank)))
42 sock.recv()
43 finally:
44 sock.close()
tests/v1/kv_connector/nixl_integration/run_accuracy_test.sh
+12 -6
@@ -49,13 +49,16 @@ else
49 KV_EXTRA_CONFIG=''
50 fi
51
52 +# Connector: default pull NixlConnector; NixlPushConnector enables PP prefill.
53 +KV_CONNECTOR=${KV_CONNECTOR:-NixlConnector}
54 +
55 # Build the kv-transfer-config for P and D
56 if [[ "$KV_BUFFER_DEVICE" == "cuda" ]]; then
54 - KV_CONFIG_P='{"kv_connector":"NixlConnector","kv_role":"kv_producer"'${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}'}'
55 - KV_CONFIG_D='{"kv_connector":"NixlConnector","kv_role":"kv_consumer"'${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}'}'
57 + KV_CONFIG_P='{"kv_connector":"'"$KV_CONNECTOR"'","kv_role":"kv_producer"'${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}'}'
58 + KV_CONFIG_D='{"kv_connector":"'"$KV_CONNECTOR"'","kv_role":"kv_consumer"'${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}'}'
59 else
57 - KV_CONFIG_P="{\"kv_connector\":\"NixlConnector\",\"kv_role\":\"kv_producer\",\"kv_buffer_device\":\"$KV_BUFFER_DEVICE\""${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}"}"
58 - KV_CONFIG_D="{\"kv_connector\":\"NixlConnector\",\"kv_role\":\"kv_consumer\",\"kv_buffer_device\":\"$KV_BUFFER_DEVICE\""${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}"}"
60 + KV_CONFIG_P="{\"kv_connector\":\"$KV_CONNECTOR\",\"kv_role\":\"kv_producer\",\"kv_buffer_device\":\"$KV_BUFFER_DEVICE\""${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}"}"
61 + KV_CONFIG_D="{\"kv_connector\":\"$KV_CONNECTOR\",\"kv_role\":\"kv_consumer\",\"kv_buffer_device\":\"$KV_BUFFER_DEVICE\""${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}"}"
62 fi
63
64 # Models to run
@@ -72,6 +75,7 @@ fi
75 NUM_PREFILL_INSTANCES=${NUM_PREFILL_INSTANCES:-1} # Default to 1
76 NUM_DECODE_INSTANCES=${NUM_DECODE_INSTANCES:-1} # Default to 1
77 PREFILLER_TP_SIZE=${PREFILLER_TP_SIZE:-1}
78 +PREFILLER_PP_SIZE=${PREFILLER_PP_SIZE:-1} # >1 requires NixlPushConnector
79 DECODER_TP_SIZE=${DECODER_TP_SIZE:-1}
80 GPU_MEMORY_UTILIZATION=${GPU_MEMORY_UTILIZATION:-0.2}
81 PREFILL_BLOCK_SIZE=${PREFILL_BLOCK_SIZE:-128}
@@ -138,8 +142,9 @@ run_tests_for_model() {
142 # Calculate GPU ID - we'll distribute across available GPUs
143 GPU_ID=$((i % $(get_num_gpus)))
144 NEXT_GPU=${GPU_ID}
141 - # If PREFILLER_TP_SIZE is more than 1
142 - for (( j=1; j < PREFILLER_TP_SIZE; j++ )); do
145 + # Reserve TP*PP GPUs for the prefiller (TP shards across PP stages).
146 + PREFILLER_WORLD_SIZE=$((PREFILLER_TP_SIZE * PREFILLER_PP_SIZE))
147 + for (( j=1; j < PREFILLER_WORLD_SIZE; j++ )); do
148 NEXT_GPU=$(((GPU_ID + j) % $(get_num_gpus)))
149 GPU_ID="${GPU_ID},${NEXT_GPU}"
150 done
@@ -161,6 +166,7 @@ run_tests_for_model() {
166 --block-size ${PREFILL_BLOCK_SIZE} \
167 --gpu-memory-utilization $GPU_MEMORY_UTILIZATION \
168 --tensor-parallel-size $PREFILLER_TP_SIZE \
169 + --pipeline-parallel-size $PREFILLER_PP_SIZE \
170 --kv-transfer-config '$KV_CONFIG_P'"
171 if [[ "$ENFORCE_EAGER" == "1" ]]; then
172 BASE_CMD="${BASE_CMD} --enforce-eager"
tests/v1/kv_connector/nixl_push_integration/config_sweep_accuracy_test.sh new
+41
@@ -0,0 +1,41 @@
1 +#!/usr/bin/env bash
2 +set -euo pipefail
3 +
4 +# NixlPushConnector (push-mode PD) integration tests. Kept separate from the
5 +# pull-mode sweep in ../nixl_integration so push coverage can grow on its own;
6 +# the per-config runner is shared.
7 +SCRIPT="v1/kv_connector/nixl_integration/run_accuracy_test.sh"
8 +IMPORT_CANARY="v1/kv_connector/nixl_integration/test_nixl_imports.py"
9 +
10 +echo "=== Running NIXL import canary ==="
11 +python3 -m pytest -s -x "${IMPORT_CANARY}"
12 +
13 +# PP=2 prefill -> TP=2 decode: each PP stage writes its layer slice into the
14 +# decoder's matching region range (non-MLA and MLA contiguous-slice paths).
15 +configs=(
16 + "KV_CONNECTOR=NixlPushConnector GPU_MEMORY_UTILIZATION=0.6 PREFILLER_PP_SIZE=2 PREFILLER_TP_SIZE=1 DECODER_TP_SIZE=2" # non-MLA, Qwen3-0.6B
17 + "KV_CONNECTOR=NixlPushConnector GPU_MEMORY_UTILIZATION=0.8 PREFILLER_PP_SIZE=2 PREFILLER_TP_SIZE=1 DECODER_TP_SIZE=2 MODEL_NAMES=deepseek-ai/deepseek-vl2-tiny" # MLA
18 +)
19 +
20 +run_tests() {
21 + local label=$1
22 + local extra_args=$2
23 +
24 + echo "=== Running push tests (${label}) ==="
25 + for cfg in "${configs[@]}"; do
26 + local -a cfg_parts extra_args_parts
27 + read -r -a cfg_parts <<< "$cfg"
28 + read -r -a extra_args_parts <<< "$extra_args"
29 +
30 + echo "-> Running with ${cfg} ${extra_args:+and ${extra_args}}"
31 + # Use 'env' to safely set variables without eval; keep argv splitting
32 + # safe and SC2086-clean via arrays.
33 + if ! env "${cfg_parts[@]}" bash "${SCRIPT}" "${extra_args_parts[@]}"; then
34 + echo "❌ Test failed for config: ${cfg} ${extra_args:+(${extra_args})}"
35 + exit 1
36 + fi
37 + done
38 + echo "✅ All ${label} push tests passed!"
39 +}
40 +
41 +run_tests "default backend" ""
tests/v1/kv_connector/unit/test_nixl_connector.py
+18 -13
@@ -408,11 +408,10 @@ def test_kv_transfer_handshake(dist_init):
408 decoder = msgspec.msgpack.Decoder(NixlAgentMetadata)
409 expected_agent_metadata = decoder.decode(metadata.agent_metadata_bytes)
410
411 - # The scheduler connector expects metadata to be in
412 - # dict[int, KVConnectorHandshakeMetadata], where the first key is
413 - # the dp_rank, the second key is the tp_rank.
411 + # The scheduler connector expects metadata keyed by
412 + # (pp_rank, tp_rank).
413 scheduler_connector = scheduler.get_kv_connector()
415 - scheduler_connector.set_xfer_handshake_metadata({0: metadata})
414 + scheduler_connector.set_xfer_handshake_metadata_pp_aware({(0, 0): metadata})
415
416 # Simulate a request that finishes prefill, which returns
417 # corresponding NixlConnectorMetadata for decode instance.
@@ -502,8 +501,14 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
501 )
502
503 def _nixl_handshake(
505 - self, host: str, port: int, remote_tp_size: int, expected_engine_id: str
506 - ) -> dict[int, str]:
504 + self,
505 + host: str,
506 + port: int,
507 + remote_tp_size: int,
508 + expected_engine_id: str,
509 + remote_pp_size: int = 1,
510 + notif_agents_only: bool = False,
511 + ) -> dict[tuple[int, int], str]:
512 # Mimic slow _nixl_handshake, as well as bypass zmq communication.
513 time.sleep(self._hand_shake_latency)
514 # These should've been done in register_kv_caches(), called by
@@ -533,7 +538,7 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
538 # When remote tp_size > local tp_size, handshake with multiple
539 # remote ranks.
540 num_handshakes = 1 if tp_ratio > 0 else -tp_ratio
536 - remote_agents: dict[int, str] = {}
541 + remote_agents: dict[tuple[int, int], str] = {}
542 for remote_tp_rank in range(num_handshakes):
543 remote_agent_name = self.add_remote_agent(
544 NixlAgentMetadata(
@@ -554,7 +559,7 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
559 remote_tp_rank=remote_tp_rank,
560 remote_tp_size=remote_tp_size,
561 )
557 - remote_agents[remote_tp_rank] = remote_agent_name
562 + remote_agents[(0, remote_tp_rank)] = remote_agent_name
563 return remote_agents
564
565
@@ -749,7 +754,7 @@ class TestNixlHandshake:
754
755 def check_handshake(remote_tp_size: int):
756 tp_ratio = remote_tp_size // local_tp_size
752 - assert set(remote_agents.keys()) == set(range(tp_ratio))
757 + assert set(remote_agents.keys()) == {(0, r) for r in range(tp_ratio)}
758
759 remote_engine_id = worker.REMOTE_ENGINE_ID
760 remote_info = worker.transfer_topo.get_engine_info(remote_engine_id)
@@ -2044,7 +2049,7 @@ def test_shutdown_cleans_up_resources(default_vllm_config, dist_init):
2049 # P TP = 2 * D TP case, we should register 2 local handles
2050 worker.src_xfer_handles_by_tp_ratio = {-2: [456, 457]}
2051 worker.dst_xfer_side_handles = {"engine1": {0: 789}}
2047 - worker._remote_agents = {"engine1": {0: "agent1"}}
2052 + worker._remote_agents = {"engine1": {(0, 0): "agent1"}}
2053 # _cleanup_remote_engine (called by shutdown) also clears these:
2054 worker.kv_caches_base_addr["engine1"] = {0: [0xABC]}
2055 worker.dst_num_blocks["engine1"] = 50
@@ -2097,7 +2102,7 @@ def _setup_worker_with_remote_engine(
2102 )
2103
2104 engine_id = "remote-engine-1"
2100 - worker._remote_agents[engine_id] = {0: "agent_0", 1: "agent_1"}
2105 + worker._remote_agents[engine_id] = {(0, 0): "agent_0", (0, 1): "agent_1"}
2106 worker.dst_xfer_side_handles[engine_id] = {0: 100, 1: 200}
2107 worker.kv_caches_base_addr[engine_id] = {0: [0xABC]}
2108 worker.dst_num_blocks[engine_id] = 50
@@ -2993,7 +2998,7 @@ def test_handshake_decode_errors(default_vllm_config, dist_init, error_scenario)
2998 local_block_len=worker.block_size * 4096,
2999 )
3000 worker._remote_agents[remote_engine_id] = {
2996 - rank: f"agent_p{rank}" for rank in range(prefill_tp_size)
3001 + (0, rank): f"agent_p{rank}" for rank in range(prefill_tp_size)
3002 }
3003 worker.dst_xfer_side_handles = {
3004 remote_engine_id: {rank: 100 + rank for rank in range(prefill_tp_size)}
@@ -3044,7 +3049,7 @@ def test_handshake_decode_errors(default_vllm_config, dist_init, error_scenario)
3049
3050 # Broadcast goes to ranks {1, 2, 3} only, never to the read target.
3051 expected_recipients = {
3047 - worker._remote_agents[remote_engine_id][r]
3052 + worker._remote_agents[remote_engine_id][(0, r)]
3053 for r in range(1, prefill_tp_size)
3054 }
3055 assert {agent for agent, _ in send_notif_calls} == expected_recipients
tests/v1/kv_connector/unit/test_nixl_push_connector.py
+177 -1
@@ -29,9 +29,11 @@ from typing import Any
29 from unittest.mock import MagicMock, patch
30
31 import msgspec
32 +import pytest
33
34 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
35 PUSH_REG_NOTIF_PREFIX,
36 + NixlAgentMetadata,
37 NixlConnectorMetadata,
38 )
39 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.push_worker import (
@@ -512,7 +514,7 @@ class TestPushWriterNotifs:
514 # Pretend the writer thread already forwarded a completion notif
515 # for a request whose KV is being received.
516 request_id = "req-recv-1"
515 - w._recving_metadata[request_id] = MagicMock()
517 + w._recving_metadata[request_id] = MagicMock(pp_size=1)
518 # Compose the standard completion notif: req_id:tp_size.
519 notif_msg = f"{request_id}:1".encode()
520 w._pending_completion_notifs.put(notif_msg)
@@ -813,3 +815,177 @@ class TestPushWriterNegative:
815 assert w._reqs_to_send[rid] >= now
816 # Unknown request must not be inserted by the heartbeat path.
817 assert "req-unknown" not in w._reqs_to_send
818 +
819 +
820 +# ----------------------------------------------------------------- #
821 +# Pipeline-parallel producer (push-mode PP-disagg) #
822 +# ----------------------------------------------------------------- #
823 +
824 +
825 +class TestPushPipelineParallel:
826 + """PP-sharded producer: per-stage completion counting, pp_size plumbing,
827 + and remote-region slicing."""
828 +
829 + def test_completion_waits_for_one_notif_per_pp_stage(self):
830 + """Each PP stage WRITEs its own layers and sends one notif; D must
831 + collect pp_size notifs before reporting the recv done."""
832 + w = _StubWriterWorker.fresh()
833 + w.transfer_topo = MagicMock()
834 + request_id = "req-pp-2"
835 + w._recving_metadata[request_id] = MagicMock(pp_size=2)
836 + notif = f"{request_id}:1".encode()
837 +
838 + # First stage: counted, not yet done.
839 + w._pending_completion_notifs.put(notif)
840 + assert w._get_new_notifs() == set()
841 + assert request_id not in w._recving_transfers
842 + assert w.consumer_notification_counts_by_req[request_id] == 1
843 +
844 + # Second (final) stage: now reported done.
845 + w._pending_completion_notifs.put(notif)
846 + assert w._get_new_notifs() == set()
847 + assert request_id in w._recving_transfers
848 + assert request_id not in w.consumer_notification_counts_by_req
849 +
850 + def test_req_meta_reads_pp_size_from_kv_transfer_params(self):
851 + """D learns the producer's pp_size from kv_transfer_params (forwarded
852 + by the proxy) and defaults to 1 when absent."""
853 + metadata = NixlConnectorMetadata()
854 + params = {
855 + "remote_block_ids": ([0],),
856 + "remote_engine_id": "p-engine",
857 + "remote_request_id": "p-req",
858 + "remote_host": "localhost",
859 + "remote_port": 1234,
860 + "tp_size": 1,
861 + "pp_size": 2,
862 + }
863 + metadata.add_new_req_to_recv("req", ([0],), params)
864 + assert metadata.reqs_to_recv["req"].pp_size == 2
865 +
866 + params.pop("pp_size")
867 + metadata.add_new_req_to_recv("req-default", ([0],), params)
868 + assert metadata.reqs_to_recv["req-default"].pp_size == 1
869 +
870 + def test_add_remote_agent_slices_remote_regions_to_local_pp_window(self):
871 + """With PP>1 the producer registered regions for the full model, but
872 + this worker holds only a contiguous layer slice; add_remote_agent
873 + trims the remote region list to [offset : offset + num_local_regions]
874 + before building descriptors. We stop right after the slice via a
875 + sentinel on the next collaborator call."""
876 + block_len = 4096 * 16
877 + w = _StubWriterWorker.fresh() # seeds writer-thread state for teardown
878 + w.pp_size = 2
879 + w._remote_region_offset = 2 # this worker owns layers [2, 4)
880 + w.block_len_per_layer = [block_len, block_len] # 2 local layers
881 + w.nixl_wrapper = MagicMock()
882 +
883 + class _StopAfterSlice(RuntimeError):
884 + pass
885 +
886 + w.transfer_topo = MagicMock()
887 + w.transfer_topo.register_remote_engine.side_effect = _StopAfterSlice()
888 +
889 + meta = NixlAgentMetadata(
890 + engine_id="p-engine",
891 + agent_metadata=b"agent",
892 + kv_caches_base_addr=[10, 11, 12, 13], # full 4-layer model
893 + device_id=0,
894 + num_blocks=4,
895 + block_lens=[block_len] * 4,
896 + kv_cache_layout="HND",
897 + block_size=16,
898 + ssm_sizes=(0, 0),
899 + attn_backend_name="FLASH_ATTN",
900 + physical_blocks_per_logical_kv_block=1,
901 + )
902 +
903 + with pytest.raises(_StopAfterSlice):
904 + w.add_remote_agent(meta, remote_tp_rank=0, remote_tp_size=1)
905 +
906 + # Sliced down to this worker's layer window (regions [2:4]).
907 + assert meta.kv_caches_base_addr == [12, 13]
908 + assert meta.block_lens == [block_len, block_len]
909 +
910 +
911 +class TestPushWriterMlaReplication:
912 + """MLA latent KV is replicated across D's TP ranks, so when D_TP > P_TP
913 + (``tp_ratio < 0``) the producer must *WRITE* the latent into every D rank
914 + it handshook -- not write one and merely notify the rest, which would
915 + leave the un-written ranks decoding against stale KV."""
916 +
917 + @staticmethod
918 + def _mla_worker_writing_to(d_ranks):
919 + from types import SimpleNamespace
920 +
921 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl.tp_mapping import (
922 + TPMapping,
923 + )
924 +
925 + engine_id = "decode-engine"
926 + w = _StubWriterWorker.fresh()
927 + w.use_mla = True
928 + w.nixl_wrapper = MagicMock()
929 + w.transfer_topo = MagicMock()
930 + w.transfer_topo.get_engine_info.return_value = SimpleNamespace(
931 + remote_tp_size=len(d_ranks),
932 + remote_block_size=16,
933 + remote_physical_blocks_per_logical=1,
934 + )
935 + # D_TP > P_TP => negative ratio (one P rank feeds |ratio| D ranks).
936 + w.transfer_topo.tp_ratio.return_value = -len(d_ranks)
937 + # The tp-mapping collapses MLA to a single source rank (correct for
938 + # the pull/read direction); the push path must fan it back out.
939 + w.tp_mappings = {
940 + engine_id: TPMapping(
941 + source_ranks_per_group=((0,),),
942 + all_source_ranks=(0,),
943 + rank_to_attention_slot={0: 0},
944 + rank_offset_factor=0,
945 + )
946 + }
947 + w._logical_to_remote_kernel_block_ids = lambda block_ids, ratio: block_ids
948 + w.dst_xfer_side_handles = {engine_id: {r: 1000 + r for r in d_ranks}}
949 + w.src_xfer_handles_by_block_size = {16: 2000}
950 + w._remote_agents = {engine_id: {(0, r): f"agent-{r}" for r in d_ranks}}
951 + return w, engine_id
952 +
953 + def test_mla_hetero_tp_writes_every_d_rank(self):
954 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
955 + RemoteMeta,
956 + ReqMeta,
957 + )
958 +
959 + w, engine_id = self._mla_worker_writing_to(d_ranks=(0, 1))
960 +
961 + written: list[int] = []
962 +
963 + def _fake_xfer(**kw):
964 + rank = kw["read_spec"].remote_rank
965 + written.append(rank)
966 + return 1000 + rank # in-flight handle, as the real method returns
967 +
968 + w._xfer_blocks = _fake_xfer
969 +
970 + meta = ReqMeta(
971 + local_block_ids=([100, 101],),
972 + local_physical_block_ids=([100, 101],),
973 + tp_size=1,
974 + remote=RemoteMeta(
975 + block_ids=([200, 201],),
976 + host="",
977 + port=0,
978 + engine_id=engine_id,
979 + request_id="d-req",
980 + ),
981 + )
982 +
983 + w._xfer_blocks_for_req(req_id="p-req", meta=meta)
984 +
985 + # Every handshook D rank must receive a real WRITE of the latent...
986 + assert sorted(written) == [0, 1]
987 + # ...and no rank may be fobbed off with a bare completion notif.
988 + assert w.nixl_wrapper.send_notif.call_count == 0
989 + # All of the request's WRITE handles must be tracked together, so the
990 + # engine thread never sees a partial set and double-frees the request.
991 + assert sorted(w._sending_transfers["p-req"]) == [1000, 1001]
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py
+17 -11
@@ -185,6 +185,7 @@ class NixlBaseConnectorScheduler:
185 host = params.get("remote_host")
186 port = params.get("remote_port")
187 tp_size = params.get("tp_size")
188 + pp_size = params.get("pp_size", 1)
189 if (
190 remote_engine_id is None
191 or remote_request_id is None
@@ -199,6 +200,7 @@ class NixlBaseConnectorScheduler:
200 host=host,
201 port=port,
202 tp_size=tp_size,
203 + pp_size=pp_size,
204 )
205 self._heartbeat_by_engine[remote_engine_id].req_ids.add(remote_request_id)
206 self._heartbeat_req_engine[request.request_id] = (
@@ -244,7 +246,7 @@ class NixlBaseConnectorScheduler:
246 )
247
248 def set_xfer_handshake_metadata(
247 - self, metadata: dict[int, KVConnectorHandshakeMetadata]
249 + self, metadata: dict[tuple[int, int], KVConnectorHandshakeMetadata]
250 ) -> None:
251 """
252 Set the KV connector handshake metadata for this connector.
@@ -252,19 +254,20 @@ class NixlBaseConnectorScheduler:
254 Args:
255 metadata (dict): the handshake metadata to set.
256 """
255 - encoded_data: dict[int, bytes] = {}
257 + encoded_data: dict[tuple[int, int], bytes] = {}
258 encoder = msgspec.msgpack.Encoder()
257 - for tp_rank, rank_metadata in metadata.items():
259 + for (pp_rank, tp_rank), rank_metadata in metadata.items():
260 if not isinstance(rank_metadata, NixlHandshakePayload):
261 raise ValueError(
262 "NixlConnectorScheduler expects NixlHandshakePayload for "
263 "handshake metadata."
264 )
263 - encoded_data[tp_rank] = encoder.encode(rank_metadata)
265 + encoded_data[(pp_rank, tp_rank)] = encoder.encode(rank_metadata)
266 logger.debug(
265 - "Tp rank %d: encoded NixlHandshakePayload size: %s bytes",
267 + "PP rank %d, TP rank %d: encoded NixlHandshakePayload size: %s bytes",
268 + pp_rank,
269 tp_rank,
267 - str(len(encoded_data[tp_rank])),
270 + str(len(encoded_data[(pp_rank, tp_rank)])),
271 )
272
273 # Only start the listener when we have metadata to serve.
@@ -287,7 +290,7 @@ class NixlBaseConnectorScheduler:
290
291 @staticmethod
292 def _nixl_handshake_listener(
290 - encoded_data: dict[int, Any],
293 + encoded_data: dict[tuple[int, int], Any],
294 ready_event: threading.Event,
295 stop_event: threading.Event,
296 host: str,
@@ -310,15 +313,18 @@ class NixlBaseConnectorScheduler:
313 if stop_event.is_set():
314 break
315 continue
313 - # Decode the message which contains (GET_META_MSG, rank)
314 - msg, target_tp_rank = msgspec.msgpack.decode(msg)
316 + # Decode (GET_META_MSG, pp_rank, tp_rank).
317 + msg, target_pp_rank, target_tp_rank = msgspec.msgpack.decode(msg)
318 logger.debug(
316 - "Received message for tp rank %s",
319 + "Received message for pp rank %s, tp rank %s",
320 + target_pp_rank,
321 target_tp_rank,
322 )
323 if msg != GET_META_MSG:
324 logger.warning("Connection listener got unexpected message %s", msg)
321 - sock.send_multipart((identity, b"", encoded_data[target_tp_rank]))
325 + sock.send_multipart(
326 + (identity, b"", encoded_data[(target_pp_rank, target_tp_rank)])
327 + )
328
329 def _get_remote_prefill_token_count(self, num_prompt_tokens: int) -> int:
330 """D-side only. Returns N-1 for Mamba models since the decoder
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+120 -23
@@ -2,6 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Base worker-side logic for the NIXL connector."""
4
5 +import itertools
6 import logging
7 import os
8 import queue
@@ -336,8 +337,11 @@ class NixlBaseConnectorWorker:
337 )
338
339 self.nixl_wrapper = nixl_wrapper_cls(str(uuid.uuid4()), config)
339 - # Map of engine_id -> {rank0: agent_name0, rank1: agent_name1..}.
340 - self._remote_agents: dict[EngineId, dict[int, str]] = defaultdict(dict)
340 + # Map of engine_id -> {(pp_rank, tp_rank): agent_name, ...}.
341 + # non-PP remote uses pp_rank 0, i.e. (0, tp_rank).
342 + self._remote_agents: dict[EngineId, dict[tuple[int, int], str]] = defaultdict(
343 + dict
344 + )
345
346 # Metadata.
347 self.engine_id: EngineId = engine_id
@@ -411,6 +415,25 @@ class NixlBaseConnectorWorker:
415 # (so 1 per layer for MLA, otherwise 2 per layer)
416 self.num_regions = 0
417
418 + # PP>1 (push mode): this worker holds a contiguous layer slice and
419 + # transfers into the matching sub-range of a PP=1 remote's regions.
420 + self.pp_size = vllm_config.parallel_config.pipeline_parallel_size
421 + self._remote_region_offset = 0
422 + # PP push slices regions per layer (uniform count); HMA breaks that.
423 + if self.pp_size > 1 and self._is_hma_required:
424 + raise NotImplementedError(
425 + "NixlPushConnector does not support pipeline_parallel_size > 1 "
426 + "with hybrid KV cache layouts (HMA) yet."
427 + )
428 + # Decode-side PP is unsupported (completions counted per consumer rank).
429 + if vllm_config.kv_transfer_config.kv_role == "kv_consumer" and self.pp_size > 1:
430 + raise NotImplementedError(
431 + "NixlPushConnector consumer (decode) does not support "
432 + "pipeline_parallel_size > 1."
433 + )
434 + # Keep heartbeat handshakes to a PP-sharded producer notif-only.
435 + self._hb_handshake_notif_only = False
436 +
437 # nixl_prepped_dlist_handle.
438 self.src_xfer_handles_by_block_size: dict[int, int] = {}
439 # Populated dynamically during handshake based on remote configuration.
@@ -449,7 +472,7 @@ class NixlBaseConnectorWorker:
472 thread_name_prefix="vllm-nixl-handshake-initiator",
473 )
474 self._ready_requests = queue.Queue[tuple[ReqId, ReqMeta]]()
452 - self._handshake_futures: dict[EngineId, Future[dict[int, str]]] = {}
475 + self._handshake_futures: dict[EngineId, Future[dict[tuple[int, int], str]]] = {}
476 # Protects _handshake_futures and _remote_agents.
477 self._handshake_lock = threading.RLock()
478
@@ -536,7 +559,9 @@ class NixlBaseConnectorWorker:
559 port: int,
560 remote_tp_size: int,
561 expected_engine_id: str,
539 - ) -> dict[int, str]:
562 + remote_pp_size: int = 1,
563 + notif_agents_only: bool = False,
564 + ) -> dict[tuple[int, int], str]:
565 """Do a NIXL handshake with a remote instance."""
566
567 # the first time we connect to a remote agent.
@@ -558,20 +583,25 @@ class NixlBaseConnectorWorker:
583 # this happens to be the same single rank_i.
584 assert self.transfer_topo is not None
585 p_remote_ranks = self.transfer_topo.handshake_target_ranks(remote_tp_size)
561 - remote_rank_to_agent_name = {}
586 + remote_rank_to_agent_name: dict[tuple[int, int], str] = {}
587 path = make_zmq_path("tcp", host, port)
588
589 with zmq_ctx(zmq.REQ, path) as sock:
565 - for remote_rank in p_remote_ranks:
590 + for remote_pp_rank, remote_rank in itertools.product(
591 + range(remote_pp_size), p_remote_ranks
592 + ):
593 logger.debug(
567 - "Querying metadata on path: %s at remote tp rank %s",
594 + "Querying metadata on path: %s at remote pp rank %s, tp rank %s",
595 path,
596 + remote_pp_rank,
597 remote_rank,
598 )
599
600 start_time = time.perf_counter()
601 # Send query for the request.
574 - msg = msgspec.msgpack.encode((GET_META_MSG, remote_rank))
602 + msg = msgspec.msgpack.encode(
603 + (GET_META_MSG, remote_pp_rank, remote_rank)
604 + )
605 # Set receive timeout to 5 seconds to avoid hanging on dead server
606 sock.setsockopt(zmq.RCVTIMEO, 5000) # milliseconds
607 sock.send(msg)
@@ -638,17 +668,44 @@ class NixlBaseConnectorWorker:
668 )
669
670 # Register Remote agent.
641 - remote_agent_name = self.add_remote_agent(
642 - metadata, remote_rank, remote_tp_size
643 - )
671 + if notif_agents_only:
672 + remote_agent_name = self._add_notif_only_remote_agent(
673 + metadata, remote_tp_size
674 + )
675 + else:
676 + remote_agent_name = self.add_remote_agent(
677 + metadata, remote_rank, remote_tp_size
678 + )
679 setup_agent_time = time.perf_counter()
680 logger.debug(
681 "NIXL handshake: add agent took: %s",
682 setup_agent_time - got_metadata_time,
683 )
649 - remote_rank_to_agent_name[remote_rank] = remote_agent_name
684 + remote_ranks = (remote_pp_rank, remote_rank)
685 + remote_rank_to_agent_name[remote_ranks] = remote_agent_name
686 return remote_rank_to_agent_name
687
688 + def _add_notif_only_remote_agent(
689 + self, metadata: NixlAgentMetadata, remote_tp_size: int
690 + ) -> str:
691 + """Load a remote agent for notifs only on the push-mode decode side.
692 +
693 + Skips descriptor setup but records engine info for block accounting.
694 + """
695 + assert self.transfer_topo is not None
696 + self.transfer_topo.register_remote_engine(
697 + metadata.engine_id,
698 + EngineTransferInfo(
699 + remote_tp_size=remote_tp_size,
700 + remote_block_size=metadata.block_size,
701 + remote_block_len=metadata.block_lens[0],
702 + remote_physical_blocks_per_logical=(
703 + metadata.physical_blocks_per_logical_kv_block
704 + ),
705 + ),
706 + )
707 + return self.nixl_wrapper.add_remote_agent(metadata.agent_metadata)
708 +
709 def initialize_host_xfer_buffer(self, kv_caches: dict[str, torch.Tensor]) -> None:
710 """
711 Initialize transfer buffer in CPU mem for accelerators
@@ -761,7 +818,9 @@ class NixlBaseConnectorWorker:
818 host: str,
819 port: int,
820 tp_size: int,
764 - ) -> Future[dict[int, str]] | None:
821 + pp_size: int = 1,
822 + notif_agents_only: bool = False,
823 + ) -> Future[dict[tuple[int, int], str]] | None:
824 """
825 Ensure a handshake is in-flight (or already done) for *engine_id*.
826
@@ -784,10 +843,12 @@ class NixlBaseConnectorWorker:
843 port,
844 tp_size,
845 engine_id,
846 + pp_size,
847 + notif_agents_only,
848 )
849 self._handshake_futures[engine_id] = fut
850
790 - def done_callback(f: Future[dict[int, str]], eid=engine_id):
851 + def done_callback(f: Future[dict[tuple[int, int], str]], eid=engine_id):
852 with self._handshake_lock:
853 del self._handshake_futures[eid]
854 try:
@@ -1114,6 +1175,15 @@ class NixlBaseConnectorWorker:
1175 self.kv_caches_base_addr[self.engine_id][self.tp_rank] = seen_base_addresses
1176 self.num_regions = len(caches_data)
1177
1178 + if self.pp_size > 1:
1179 + start_layer, end_layer = self.model_config.get_layers_start_end_indices(
1180 + self.vllm_config.parallel_config
1181 + )
1182 + num_local_layers = end_layer - start_layer
1183 + assert num_local_layers > 0 and self.num_regions % num_local_layers == 0
1184 + regions_per_layer = self.num_regions // num_local_layers
1185 + self._remote_region_offset = regions_per_layer * start_layer
1186 +
1187 if self.transfer_topo.virtually_split_kv_in_blocks:
1188 # NOTE (NickLucche) When FlashInfer is used, memory is registered
1189 # with joint KV for each block. This minimizes the overhead in
@@ -1464,14 +1534,32 @@ class NixlBaseConnectorWorker:
1534 """ # noqa: E501
1535 engine_id = nixl_agent_meta.engine_id
1536 # TODO re-evaluate refreshing for scaling/recovery
1467 - if remote_tp_rank in self._remote_agents.get(engine_id, {}):
1537 + if (0, remote_tp_rank) in self._remote_agents.get(engine_id, {}):
1538 logger.debug(
1539 "Remote agent with engine_id %s and rank"
1540 "%s already exchanged metadata, skip handshake.",
1541 engine_id,
1542 remote_tp_rank,
1543 )
1474 - return self._remote_agents[engine_id][remote_tp_rank]
1544 + return self._remote_agents[engine_id][(0, remote_tp_rank)]
1545 +
1546 + # Compare physical regions, not self.num_regions (doubled by
1547 + # FlashInfer's virtual K/V split).
1548 + num_local_regions = len(self.block_len_per_layer)
1549 + if (
1550 + self.pp_size > 1
1551 + and len(nixl_agent_meta.kv_caches_base_addr) > num_local_regions
1552 + ):
1553 + # This worker holds a PP layer-slice; the PP=1 remote registered
1554 + # the full model. Slice its regions to our layer window so the
1555 + # logic below sees congruent local/remote lists.
1556 + start = self._remote_region_offset
1557 + end = start + num_local_regions
1558 + assert len(nixl_agent_meta.kv_caches_base_addr) >= end
1559 + nixl_agent_meta.kv_caches_base_addr = nixl_agent_meta.kv_caches_base_addr[
1560 + start:end
1561 + ]
1562 + nixl_agent_meta.block_lens = nixl_agent_meta.block_lens[start:end]
1563
1564 ### Register remote engine in TransferTopology (idempotent).
1565 assert self.transfer_topo is not None
@@ -2098,7 +2186,12 @@ class NixlBaseConnectorWorker:
2186 # the **next** heartbeat for this remote can go through.
2187 if (
2188 self._ensure_handshake(
2101 - engine_id, hb_info.host, hb_info.port, hb_info.tp_size
2189 + engine_id,
2190 + hb_info.host,
2191 + hb_info.port,
2192 + hb_info.tp_size,
2193 + hb_info.pp_size,
2194 + self._hb_handshake_notif_only and hb_info.pp_size > 1,
2195 )
2196 is not None
2197 ):
@@ -2374,19 +2467,23 @@ class NixlBaseConnectorWorker:
2467 """
2468 assert engine_id in self._remote_agents
2469
2377 - for handle in self.dst_xfer_side_handles.pop(engine_id).values():
2470 + # Notif-only engines (push-mode D side) have no descriptor state.
2471 + for handle in self.dst_xfer_side_handles.pop(engine_id, {}).values():
2472 self.nixl_wrapper.release_dlist_handle(handle)
2473 for agent_name in self._remote_agents.pop(engine_id).values():
2474 self.nixl_wrapper.remove_remote_agent(agent_name)
2475
2382 - del self.kv_caches_base_addr[engine_id]
2383 - del self.dst_num_blocks[engine_id]
2384 - del self.tp_mappings[engine_id]
2476 + self.kv_caches_base_addr.pop(engine_id, None)
2477 + self.dst_num_blocks.pop(engine_id, None)
2478 + self.tp_mappings.pop(engine_id, None)
2479 if self.transfer_topo is not None:
2480 self.transfer_topo.unregister_remote_engine(engine_id)
2481
2388 - last_active = self._engine_last_active.pop(engine_id)
2389 - if log_eviction:
2482 + # Push P-side engines are tracked in _remote_agents but not in
2483 + # _engine_last_active (they don't participate in stale eviction), so
2484 + # tolerate a missing entry.
2485 + last_active = self._engine_last_active.pop(engine_id, None)
2486 + if log_eviction and last_active is not None:
2487 logger.info(
2488 "Evicted stale remote engine %s (inactive for %.1fs).",
2489 engine_id,
vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py
+4 -3
@@ -207,11 +207,12 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA):
207 assert self.connector_scheduler is not None
208 return self.connector_scheduler.request_finished(request, block_ids)
209
210 - def set_xfer_handshake_metadata(
211 - self, metadata: dict[int, KVConnectorHandshakeMetadata]
210 + def set_xfer_handshake_metadata_pp_aware(
211 + self, metadata: dict[tuple[int, int], KVConnectorHandshakeMetadata]
212 ) -> None:
213 """
214 - Set the KV connector handshake metadata for this connector.
214 + Set handshake metadata keyed by (pp_rank, tp_rank) so the side
215 + channel can serve every PP stage's agent metadata.
216
217 Args:
218 metadata (dict): the handshake metadata to set.
vllm/distributed/kv_transfer/kv_connector/v1/nixl/metadata.py
+4
@@ -147,6 +147,7 @@ class HeartbeatInfo:
147 host: str
148 port: int
149 tp_size: int
150 + pp_size: int = 1
151
152
153 @dataclass
@@ -167,6 +168,8 @@ class ReqMeta:
168 remote: RemoteMeta | None = None
169 # Remote block size, discovered during NIXL handshake (push mode).
170 remote_block_size: int | None = None
171 + # Remote producer pipeline-parallel size (push mode, D side).
172 + pp_size: int = 1
173
174
175 class NixlConnectorMetadata(KVConnectorMetadata):
@@ -196,6 +199,7 @@ class NixlConnectorMetadata(KVConnectorMetadata):
199 # P workers don't need to receive tp_size from proxy here.
200 tp_size=kv_transfer_params.get("tp_size", 1),
201 remote_block_size=kv_transfer_params.get("remote_block_size"),
202 + pp_size=kv_transfer_params.get("pp_size", 1),
203 )
204
205 def add_new_req_to_save(
vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py
+2 -2
@@ -183,7 +183,7 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
183 notif_id = f"{meta.remote.request_id}:{self.world_size}".encode()
184 remote_agents = self._remote_agents[meta.remote.engine_id]
185 for rank_to_notify, agent in remote_agents.items():
186 - if rank_to_notify != read_specs[0].remote_rank:
186 + if rank_to_notify != (0, read_specs[0].remote_rank):
187 self.nixl_wrapper.send_notif(agent, notif_msg=notif_id)
188
189 def _read_blocks(
@@ -250,7 +250,7 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
250 # just notify P worker that we have the blocks we need.
251 if len(local_block_ids) == 0:
252 # A full prefix cache hit is indicated with an empty list.
253 - agent_name = self._remote_agents[dst_engine_id][remote_rank]
253 + agent_name = self._remote_agents[dst_engine_id][(0, remote_rank)]
254 try:
255 self.nixl_wrapper.send_notif(agent_name, notif_msg=notif_id)
256 except Exception as e:
vllm/distributed/kv_transfer/kv_connector/v1/nixl/push_scheduler.py
+4
@@ -186,6 +186,7 @@ class NixlPushConnectorScheduler(NixlBaseConnectorScheduler):
186 "remote_host": params["remote_host"],
187 "remote_port": params["remote_port"],
188 "remote_tp_size": params["tp_size"],
189 + "remote_pp_size": params.get("pp_size", 1),
190 }
191 self._push_registration_deadlines[request.request_id] = (
192 time.perf_counter() + self._push_registration_timeout
@@ -238,6 +239,8 @@ class NixlPushConnectorScheduler(NixlBaseConnectorScheduler):
239 # serving layer via abort_immediately. To keep P from
240 # stranding the prefill blocks, we still register an empty
241 # recv so the worker emits a notif that lets P free them.
242 + # Seed remote_block_ids so add_new_req_to_recv won't KeyError.
243 + params["remote_block_ids"] = ()
244 self._reqs_need_recv[request.request_id] = (request, [])
245 params["do_remote_prefill"] = False
246 return False, None
@@ -285,6 +288,7 @@ class NixlPushConnectorScheduler(NixlBaseConnectorScheduler):
288 remote_host=self.side_channel_host,
289 remote_port=self.side_channel_port,
290 tp_size=self.vllm_config.parallel_config.tensor_parallel_size,
291 + pp_size=self.vllm_config.parallel_config.pipeline_parallel_size,
292 remote_num_tokens=remote_num_tokens,
293 )
294
vllm/distributed/kv_transfer/kv_connector/v1/nixl/push_worker.py
+77 -39
@@ -80,6 +80,10 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
80 ):
81 super().__init__(vllm_config, engine_id, kv_cache_config)
82
83 + # Heartbeat handshakes to a PP-sharded producer must be notif-only,
84 + # like the PUSH_REG path.
85 + self._hb_handshake_notif_only = True
86 +
87 # Push-specific state.
88 # P-side: outgoing WRITE handles awaiting completion, keyed by
89 # request_id. Mutated by writer (submit) and main thread
@@ -275,18 +279,22 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
279 and the request is re-queued onto ``_reg_send_inbox`` once it
280 completes (at which point ``_ensure_handshake`` returns ``None`` and we
281 send directly)."""
282 + remote_pp_size = reg_data.get("remote_pp_size", 1)
283 fut = self._ensure_handshake(
284 reg_data["remote_engine_id"],
285 reg_data["remote_host"],
286 reg_data["remote_port"],
287 reg_data["remote_tp_size"],
288 + pp_size=remote_pp_size,
289 + # D never addresses P memory in push mode; just load P's agents.
290 + notif_agents_only=remote_pp_size > 1,
291 )
292 if fut is None:
293 self._do_send_reg_notif(req_id, reg_data)
294 return
295
296 def _on_handshake(
289 - f: Future[dict[int, str]],
297 + f: Future[dict[tuple[int, int], str]],
298 rid: str = req_id,
299 rd: dict[str, Any] = reg_data,
300 ) -> None:
@@ -500,28 +508,43 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
508 remote_block_ids = meta.remote.block_ids
509 local_block_ids = meta.local_physical_block_ids
510 num_groups = len(local_block_ids)
503 - read_specs = [
504 - ReadSpec(
505 - remote_rank=rank,
506 - local_block_ids=[
507 - list(local_block_ids[g])
508 - if rank in plan.source_ranks_per_group[g]
509 - else []
510 - for g in range(num_groups)
511 - ],
512 - remote_block_ids=[
513 - list(remote_block_ids[g])
514 - if rank in plan.source_ranks_per_group[g]
515 - else []
516 - for g in range(num_groups)
517 - ],
518 - )
519 - for rank in plan.all_source_ranks
520 - ]
511
512 if self.use_mla and tp_ratio < 0:
523 - assert len(read_specs) == 1
513 + # MLA latent is replicated across D's TP ranks: the tp-mapping
514 + # collapses to one rank (fine for reads), but push must WRITE every
515 + # D rank or the rest decode stale KV; only the dst differs per rank.
516 + assert len(plan.all_source_ranks) == 1
517 + mla_local_ids = [list(ids) for ids in local_block_ids]
518 + mla_remote_ids = [list(ids) for ids in remote_block_ids]
519 + read_specs = [
520 + ReadSpec(
521 + remote_rank=rank,
522 + local_block_ids=mla_local_ids,
523 + remote_block_ids=mla_remote_ids,
524 + )
525 + for rank in self.dst_xfer_side_handles[engine_id]
526 + ]
527 + else:
528 + read_specs = [
529 + ReadSpec(
530 + remote_rank=rank,
531 + local_block_ids=[
532 + list(local_block_ids[g])
533 + if rank in plan.source_ranks_per_group[g]
534 + else []
535 + for g in range(num_groups)
536 + ],
537 + remote_block_ids=[
538 + list(remote_block_ids[g])
539 + if rank in plan.source_ranks_per_group[g]
540 + else []
541 + for g in range(num_groups)
542 + ],
543 + )
544 + for rank in plan.all_source_ranks
545 + ]
546
547 + handles: list[int] = []
548 for i, spec in enumerate(read_specs):
549 remote_block_size = remote_info.remote_block_size
550 logger.debug(
@@ -544,7 +567,7 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
567 spec.remote_rank
568 ]
569
547 - self._xfer_blocks(
570 + handle = self._xfer_blocks(
571 read_spec=spec,
572 request_id=req_id,
573 dst_engine_id=meta.remote.engine_id,
@@ -552,13 +575,15 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
575 local_xfer_side_handle=local_xfer_side_handle,
576 remote_xfer_side_handle=remote_xfer_side_handle,
577 )
578 + if handle is not None:
579 + handles.append(handle)
580
556 - if self.use_mla and tp_ratio < 0 and read_specs:
557 - notif_id = f"{meta.remote.request_id}:{self.world_size}".encode()
558 - remote_agents = self._remote_agents[meta.remote.engine_id]
559 - for rank_to_notify, agent in remote_agents.items():
560 - if rank_to_notify != read_specs[0].remote_rank:
561 - self.nixl_wrapper.send_notif(agent, notif_msg=notif_id)
581 + # Publish all the request's WRITE handles in one locked update: a
582 + # partial set would let ``_pop_done_transfers`` finish the request
583 + # early, then double-report it as the remaining writes land.
584 + if handles:
585 + with self._sending_transfers_lock:
586 + self._sending_transfers[req_id].extend(handles)
587
588 def _xfer_blocks(
589 self,
@@ -568,8 +593,12 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
593 remote_request_id: str,
594 local_xfer_side_handle: int,
595 remote_xfer_side_handle: int,
571 - ):
572 - """Post a WRITE point-to-point xfer request."""
596 + ) -> int | None:
597 + """Post a WRITE point-to-point xfer request.
598 +
599 + Returns the in-flight transfer handle (so the caller can track all of
600 + a request's handles atomically), or ``None`` if nothing was submitted.
601 + """
602 assert self.transfer_topo is not None
603 remote_rank = read_spec.remote_rank
604 local_block_ids = read_spec.local_block_ids
@@ -597,7 +626,7 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
626
627 if len(local_block_ids) == 0:
628 logger.warning("No blocks to push for request %s", request_id)
600 - return
629 + return None
630
631 # Align per-group block counts for push.
632 local_block_ids = list(local_block_ids)
@@ -637,9 +666,9 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
666 notif_msg=notif_id,
667 )
668 self.nixl_wrapper.transfer(handle)
640 - # Track push WRITE handles so P can free blocks once done.
641 - with self._sending_transfers_lock:
642 - self._sending_transfers[request_id].append(handle)
669 + # Caller tracks the handle (atomically with the request's other
670 + # writes) so P can free blocks once all of them are done.
671 + return handle
672 except Exception as e:
673 self._log_failure(
674 failure_type="transfer_setup_failed",
@@ -656,6 +685,7 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
685 if handle is not None:
686 self.nixl_wrapper.release_xfer_handle(handle)
687 self.xfer_stats.record_failed_transfer()
688 + return None
689
690 # --- Notification handling on engine main thread ------------------ #
691
@@ -683,12 +713,20 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
713
714 # Not tracked as a P-side send/process for this notif.
715 if req_id not in self._reqs_to_send and req_id not in self._reqs_to_process:
686 - if req_id in self._recving_metadata:
687 - # D-side: P signalled push completion. The transfer was
688 - # driven entirely by P (we don't own a NIXL handle here),
689 - # so materialise an empty entry in ``_recving_transfers``
690 - # and let ``_pop_done_transfers`` report it done on the
691 - # next ``get_finished``.
716 + if (meta := self._recving_metadata.get(req_id)) is not None:
717 + # Consumer waits for one notif per producer rank writing
718 + # here: pp_size stages * producers-per-consumer (>1 when
719 + # producer TP > consumer TP; tp_size is the producer TP).
720 + producers_per_consumer = max(1, int(tp_size) // self.world_size)
721 + expected_notifs = meta.pp_size * producers_per_consumer
722 + self.consumer_notification_counts_by_req[req_id] += 1
723 + notifs = self.consumer_notification_counts_by_req[req_id]
724 + if notifs < expected_notifs:
725 + continue
726 + del self.consumer_notification_counts_by_req[req_id]
727 + # P drove the transfer (we own no NIXL handle), so
728 + # materialise an empty ``_recving_transfers`` entry for
729 + # ``_pop_done_transfers`` to report done.
730 self._recving_transfers.setdefault(req_id, [])
731 else:
732 # Not tracked on either side (lease may have expired