@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / d6517be3cd
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 13, 23:15 UTC

[Bugfix] Missing NIXL metadata for handshake initialization if instance spans multi-node (#26338)

Signed-off-by: Guan Luo <gluo@nvidia.com> Signed-off-by: GuanLuo <41310872+GuanLuo@users.noreply.github.com> Signed-off-by: Guan Luo <41310872+GuanLuo@users.noreply.github.com> Co-authored-by: Nicolò Lucchesi <nlucches@redhat.com>

GuanLuo committed Nov 1, 2025 at 01:16 UTC d6517be3cd06111ada0a603acaeab28dd4580641
7 files changed +321 -95
docs/features/nixl_connector_usage.md
+1 -1
@@ -81,7 +81,7 @@ python tests/v1/kv_connector/nixl_integration/toy_proxy_server.py \
81 - Default: 5600
82 - **Required for both prefiller and decoder instances**
83 - Each vLLM worker needs a unique port on its host; using the same port number across different hosts is fine
84 - - For TP/DP deployments, each worker's port on a node is computed as: base_port + dp_rank * tp_size + tp_rank (e.g., with `--tensor-parallel-size=4` and base_port=5600, tp_rank 0..3 use ports 5600, 5601, 5602, 5603 on that node).
84 + - For TP/DP deployments, each worker's port on a node is computed as: base_port + dp_rank (e.g., with `--data-parallel-size=2` and base_port=5600, dp_rank 0..1 use port 5600, 5601 on that node).
85 - Used for the initial NIXL handshake between the prefiller and the decoder
86
87 - `VLLM_NIXL_SIDE_CHANNEL_HOST`: Host for side channel communication
tests/v1/kv_connector/unit/test_nixl_connector.py
+102 -4
@@ -27,6 +27,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.nixl_connector import (
27 NixlAgentMetadata,
28 NixlConnector,
29 NixlConnectorMetadata,
30 + NixlConnectorScheduler,
31 NixlConnectorWorker,
32 NixlKVConnectorStats,
33 )
@@ -283,6 +284,92 @@ def test_prompt_less_than_block_size():
284 assert len(scheduler_output.scheduled_new_reqs) == 0
285
286
287 +@patch(
288 + "vllm.distributed.kv_transfer.kv_connector.v1.nixl_connector.NixlWrapper",
289 + FakeNixlWrapper,
290 +)
291 +def test_kv_transfer_handshake(dist_init):
292 + """Unit test for basic NixlConnector interface functionality."""
293 +
294 + # Test setup, we creates a scheduler that contains a NixlConnector
295 + # of role SCHEDULER, and expect it to be serving NixlAgentMetadata from
296 + # all workers of the instance.
297 + vllm_config = create_vllm_config()
298 + # in case the test runs on non-GPU machine
299 + vllm_config.kv_transfer_config.kv_buffer_device = "cpu"
300 + scheduler = create_scheduler(vllm_config)
301 +
302 + # Create two NixlConnector of role WORKER, one is the worker of
303 + # the scheduler (prefill), the other is a worker of decode instance.
304 +
305 + # Prefill connector will register KV cache to populate proper handshake
306 + # metadata.
307 + prefill_connector = NixlConnector(vllm_config, KVConnectorRole.WORKER)
308 + kv_cache_shape = FlashAttentionBackend.get_kv_cache_shape(
309 + num_blocks=2, block_size=16, num_kv_heads=4, head_size=64
310 + )
311 + shared_tensor = torch.zeros(*kv_cache_shape, dtype=torch.float16)
312 + unique_tensor = torch.zeros(*kv_cache_shape, dtype=torch.float16)
313 + kv_caches = {
314 + "layer0": shared_tensor,
315 + "layer1": unique_tensor,
316 + "layer2": shared_tensor,
317 + }
318 + prefill_connector.register_kv_caches(kv_caches)
319 +
320 + # Simulate EngineCore initialization that would
321 + # gather connector metadata from all workers, the scheduler connector
322 + # expects metadata to be in dict[int, KVConnectorHandshakeMetadata],
323 + # where the first key is the dp_rank, the second key is the tp_rank.
324 + metadata = {0: prefill_connector.get_handshake_metadata()}
325 + scheduler_connector = scheduler.get_kv_connector()
326 + scheduler_connector.set_xfer_handshake_metadata(metadata)
327 +
328 + # Simulate a request that finishes prefill, which returns
329 + # corresponding NixlConnectorMetadata for decode instance.
330 + BLOCK_SIZE = vllm_config.cache_config.block_size
331 + NUM_EXTERNAL_FULL_BLOCKS = 2
332 + NUM_TOKENS = int(BLOCK_SIZE * (NUM_EXTERNAL_FULL_BLOCKS + 0.5))
333 +
334 + request = create_request(
335 + request_id=1,
336 + block_size=BLOCK_SIZE,
337 + num_tokens=NUM_TOKENS,
338 + do_remote_decode=True,
339 + )
340 + request.status = RequestStatus.FINISHED_LENGTH_CAPPED
341 + delay, kv_connector_metadata = scheduler.get_kv_connector().request_finished(
342 + request, [0, 1, 2]
343 + )
344 + assert delay
345 +
346 + # Decode connector will be able to create handshake with the prefill connector.
347 + decode_connector = NixlConnector(vllm_config, KVConnectorRole.WORKER)
348 +
349 + # Here we are testing the retrieval of NIXLAgentMetadata.
350 + # Knowing the implementation detail, we override the add_remote_agent
351 + # to validate the metadata received is the same as the one in prefill_connector.
352 + with patch.object(
353 + decode_connector.connector_worker, "add_remote_agent"
354 + ) as mock_add_remote_agent:
355 + mock_add_remote_agent.return_type = "remote_agent"
356 +
357 + decode_connector.connector_worker._nixl_handshake(
358 + kv_connector_metadata["remote_host"],
359 + kv_connector_metadata["remote_port"],
360 + kv_connector_metadata["tp_size"],
361 + kv_connector_metadata["remote_engine_id"],
362 + )
363 +
364 + received_metadata = mock_add_remote_agent.call_args.args
365 + assert received_metadata[1] == 0 # remote_tp_rank
366 + assert received_metadata[2] == 1 # remote_tp_size
367 + assert metadata[0] == received_metadata[0]
368 +
369 + # Need to shutdown the background thread to release NIXL side channel port
370 + scheduler_connector.shutdown()
371 +
372 +
373 class FakeNixlConnectorWorker(NixlConnectorWorker):
374 REMOTE_ENGINE_ID = "remote_engine"
375
@@ -313,6 +400,7 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
400 engine_id=self.REMOTE_ENGINE_ID,
401 agent_metadata=FakeNixlWrapper.AGENT_METADATA,
402 kv_caches_base_addr=[0],
403 + device_id=0,
404 num_blocks=1,
405 block_lens=self.block_len_per_layer,
406 attn_backend_name=self.backend_name,
@@ -559,6 +647,7 @@ class TestNixlHandshake:
647 engine_id=FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
648 agent_metadata=FakeNixlWrapper.AGENT_METADATA,
649 kv_caches_base_addr=[0],
650 + device_id=0,
651 num_blocks=1,
652 block_lens=worker.block_len_per_layer,
653 attn_backend_name=worker.backend_name,
@@ -611,6 +700,7 @@ class TestNixlHandshake:
700 engine_id=FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
701 agent_metadata=FakeNixlWrapper.AGENT_METADATA,
702 kv_caches_base_addr=[0],
703 + device_id=0,
704 num_blocks=1,
705 # prefill TP=1, decode TP=2, remote block_lens is double to local
706 block_lens=[i * 2 for i in worker.block_len_per_layer],
@@ -1005,6 +1095,8 @@ def _run_abort_timeout_test(llm: LLM, timeout: int):
1095 _ = llm.generate([f"What is the capital of France? {padding}"], sampling_params)
1096 # Request-0 times out and is cleared!
1097 assert "0" not in req_to_blocks
1098 + # Need to shutdown the background thread to release NIXL side channel port
1099 + llm.llm_engine.engine_core.shutdown()
1100
1101
1102 def test_register_kv_caches(dist_init):
@@ -1177,13 +1269,15 @@ def test_shutdown_cleans_up_resources(dist_init):
1269 """Test that shutdown() properly cleans up all resources."""
1270 vllm_config = create_vllm_config()
1271
1272 + scheduler = NixlConnectorScheduler(
1273 + vllm_config, vllm_config.kv_transfer_config.engine_id
1274 + )
1275 worker = NixlConnectorWorker(vllm_config, vllm_config.kv_transfer_config.engine_id)
1276 nixl_wrapper = worker.nixl_wrapper
1277
1278 with (
1279 patch.object(worker, "_handshake_initiation_executor") as mock_exec,
1185 - patch.object(worker, "_nixl_handshake_listener_t") as mock_listener,
1186 - patch.object(worker, "_nixl_handshake_listener_stop_event") as mock_event,
1280 + patch.object(scheduler, "_nixl_handshake_listener_t") as mock_listener,
1281 patch.object(nixl_wrapper, "release_xfer_handle") as mock_rel_xfer,
1282 patch.object(nixl_wrapper, "release_dlist_handle") as mock_rel_dlist,
1283 patch.object(nixl_wrapper, "remove_remote_agent") as mock_rem_agent,
@@ -1204,8 +1298,12 @@ def test_shutdown_cleans_up_resources(dist_init):
1298 worker.shutdown()
1299
1300 mock_exec.shutdown.assert_called_with(wait=False)
1207 - mock_event.set.assert_called_once()
1208 - mock_listener.join.assert_called_once_with(timeout=1.0)
1301 +
1302 + # Same sequence on scheduler.shutdown()
1303 + scheduler.shutdown()
1304 + scheduler.shutdown()
1305 + scheduler.shutdown()
1306 + mock_listener.join.assert_called_once()
1307
1308 mock_rel_xfer.assert_called_once_with(123)
1309 assert mock_rel_dlist.call_count == 2
vllm/distributed/kv_transfer/kv_connector/v1/base.py
+32
@@ -122,6 +122,15 @@ class KVConnectorRole(enum.Enum):
122 WORKER = 1
123
124
125 +class KVConnectorHandshakeMetadata(ABC): # noqa: B024
126 + """
127 + Metadata used for out of band connector handshake between
128 + P/D workers. This needs to serializeable.
129 + """
130 +
131 + pass
132 +
133 +
134 class KVConnectorMetadata(ABC): # noqa: B024
135 """
136 Abstract Metadata used to communicate between the
@@ -320,6 +329,18 @@ class KVConnectorBase_V1(ABC):
329 """
330 return None
331
332 + def get_handshake_metadata(self) -> KVConnectorHandshakeMetadata | None:
333 + """
334 + Get the KVConnector handshake metadata for this connector.
335 + This metadata is used for out-of-band connector handshake
336 + between P/D workers.
337 +
338 + Returns:
339 + KVConnectorHandshakeMetadata: the handshake metadata.
340 + None if no handshake metadata is available.
341 + """
342 + return None
343 +
344 # ==============================
345 # Scheduler-side methods
346 # ==============================
@@ -477,6 +498,17 @@ class KVConnectorBase_V1(ABC):
498 """
499 return None
500
501 + def set_xfer_handshake_metadata(
502 + self, metadata: dict[int, KVConnectorHandshakeMetadata]
503 + ) -> None:
504 + """
505 + Set the KV connector handshake metadata for this connector.
506 +
507 + Args:
508 + metadata (KVConnectorHandshakeMetadata): the handshake metadata to set.
509 + """
510 + return None
511 +
512 @classmethod
513 def build_prom_metrics(
514 cls,
vllm/distributed/kv_transfer/kv_connector/v1/nixl_connector.py
+136 -88
@@ -27,6 +27,7 @@ from vllm.config import VllmConfig
27 from vllm.distributed.kv_transfer.kv_connector.v1.base import (
28 CopyBlocksOp,
29 KVConnectorBase_V1,
30 + KVConnectorHandshakeMetadata,
31 KVConnectorMetadata,
32 KVConnectorRole,
33 )
@@ -93,15 +94,12 @@ _NIXL_SUPPORTED_DEVICE = {
94 _NIXL_SUPPORTED_DEVICE.update(current_platform.get_nixl_supported_devices())
95
96
96 -class NixlAgentMetadata(
97 - msgspec.Struct,
98 - omit_defaults=True, # type: ignore[call-arg]
99 - # required for @cached_property.
100 - dict=True,
101 -):
97 +@dataclass
98 +class NixlAgentMetadata(KVConnectorHandshakeMetadata):
99 engine_id: str
100 agent_metadata: bytes
101 kv_caches_base_addr: list[int]
102 + device_id: int
103 num_blocks: int
104 block_lens: list[int]
105 attn_backend_name: str
@@ -223,6 +221,18 @@ class NixlConnector(KVConnectorBase_V1):
221 assert self.connector_scheduler is not None
222 return self.connector_scheduler.request_finished(request, block_ids)
223
224 + def set_xfer_handshake_metadata(
225 + self, metadata: dict[int, KVConnectorHandshakeMetadata]
226 + ) -> None:
227 + """
228 + Set the KV connector handshake metadata for this connector.
229 +
230 + Args:
231 + metadata (dict): the handshake metadata to set.
232 + """
233 + assert self.connector_scheduler is not None
234 + self.connector_scheduler.set_xfer_handshake_metadata(metadata)
235 +
236 ############################################################
237 # Worker Side Methods
238 ############################################################
@@ -299,6 +309,21 @@ class NixlConnector(KVConnectorBase_V1):
309 def shutdown(self):
310 if self.connector_worker is not None:
311 self.connector_worker.shutdown()
312 + if self.connector_scheduler is not None:
313 + self.connector_scheduler.shutdown()
314 +
315 + def get_handshake_metadata(self) -> KVConnectorHandshakeMetadata | None:
316 + """
317 + Get the KVConnector handshake metadata for this connector.
318 + This metadata is used for out-of-band connector handshake
319 + between P/D workers.
320 +
321 + Returns:
322 + KVConnectorHandshakeMetadata: the handshake metadata.
323 + None if no handshake metadata is available.
324 + """
325 + assert self.connector_worker is not None
326 + return self.connector_worker.xfer_handshake_metadata
327
328
329 class NixlConnectorScheduler:
@@ -312,12 +337,16 @@ class NixlConnectorScheduler:
337 self.side_channel_port = (
338 envs.VLLM_NIXL_SIDE_CHANNEL_PORT
339 + vllm_config.parallel_config.data_parallel_rank
315 - * vllm_config.parallel_config.tensor_parallel_size
340 )
341 assert vllm_config.kv_transfer_config is not None
342 self.use_host_buffer = vllm_config.kv_transfer_config.kv_buffer_device == "cpu"
343 logger.info("Initializing NIXL Scheduler %s", engine_id)
344
345 + # Background thread for handling new handshake requests.
346 + self._nixl_handshake_listener_t: threading.Thread | None = None
347 + self._encoded_xfer_handshake_metadata: dict[int, Any] = {}
348 + self._stop_event = threading.Event()
349 +
350 # Requests that need to start recv/send.
351 # New requests are added by update_state_after_alloc in
352 # the scheduler. Used to make metadata passed to Worker.
@@ -330,6 +359,89 @@ class NixlConnectorScheduler:
359 # remote prefill or aborted.
360 self._reqs_not_processed: set[ReqId] = set()
361
362 + def shutdown(self):
363 + self._stop_event.set()
364 + if self._nixl_handshake_listener_t is not None:
365 + self._nixl_handshake_listener_t.join()
366 + self._nixl_handshake_listener_t = None
367 +
368 + def set_xfer_handshake_metadata(
369 + self, metadata: dict[int, KVConnectorHandshakeMetadata]
370 + ) -> None:
371 + """
372 + Set the KV connector handshake metadata for this connector.
373 +
374 + Args:
375 + metadata (dict): the handshake metadata to set.
376 + """
377 + encoded_data: dict[int, bytes] = {}
378 + encoder = msgspec.msgpack.Encoder()
379 + for tp_rank, rank_metadata in metadata.items():
380 + if not isinstance(rank_metadata, NixlAgentMetadata):
381 + raise ValueError(
382 + "NixlConnectorScheduler expects NixlAgentMetadata for "
383 + "handshake metadata."
384 + )
385 + encoded_data[tp_rank] = encoder.encode(rank_metadata)
386 + logger.debug(
387 + "Tp rank %d: encoded NixlAgentMetadata size: %s bytes",
388 + tp_rank,
389 + str(len(encoded_data[tp_rank])),
390 + )
391 + self._encoded_xfer_handshake_metadata = encoded_data
392 +
393 + # Only start the listener when we have metadata to serve.
394 + if self._nixl_handshake_listener_t is None:
395 + ready_event = threading.Event()
396 + self._nixl_handshake_listener_t = threading.Thread(
397 + target=self._nixl_handshake_listener,
398 + args=(
399 + encoded_data,
400 + ready_event,
401 + self._stop_event,
402 + self.side_channel_port,
403 + ),
404 + daemon=True,
405 + name="nixl_handshake_listener",
406 + )
407 + self._nixl_handshake_listener_t.start()
408 + ready_event.wait() # Wait for listener ZMQ socket to be ready.
409 +
410 + @staticmethod
411 + def _nixl_handshake_listener(
412 + encoded_data: dict[int, Any],
413 + ready_event: threading.Event,
414 + stop_event: threading.Event,
415 + port: int,
416 + ):
417 + """Background thread for getting new NIXL handshakes."""
418 + # NOTE(rob): this is a simple implementation. We will move
419 + # to a better approach via HTTP endpoint soon.
420 +
421 + # Listen for new requests for metadata.
422 + host = envs.VLLM_NIXL_SIDE_CHANNEL_HOST
423 + path = make_zmq_path("tcp", host, port)
424 + logger.debug("Starting listening on path: %s", path)
425 + with zmq_ctx(zmq.ROUTER, path) as sock:
426 + sock.setsockopt(zmq.RCVTIMEO, 1000)
427 + ready_event.set()
428 + while True:
429 + try:
430 + identity, _, msg = sock.recv_multipart()
431 + except zmq.Again:
432 + if stop_event.is_set():
433 + break
434 + continue
435 + # Decode the message which contains (GET_META_MSG, rank)
436 + msg, target_tp_rank = msgspec.msgpack.decode(msg)
437 + logger.debug(
438 + "Received message for tp rank %s",
439 + target_tp_rank,
440 + )
441 + if msg != GET_META_MSG:
442 + logger.warning("Connection listener got unexpected message %s", msg)
443 + sock.send_multipart((identity, b"", encoded_data[target_tp_rank]))
444 +
445 def get_num_new_matched_tokens(
446 self, request: "Request", num_computed_tokens: int
447 ) -> tuple[int, bool]:
@@ -537,8 +649,6 @@ class NixlConnectorScheduler:
649 class NixlConnectorWorker:
650 """Implementation of Worker side methods"""
651
540 - _POLL_TIMEOUT = 0.1 # Handshake thread polls for stop event every 100ms
541 -
652 @dataclass
653 class TpKVTopology:
654 """
@@ -651,16 +761,6 @@ class NixlConnectorWorker:
761 # Map of engine_id -> {rank0: agent_name0, rank1: agent_name1..}.
762 self._remote_agents: dict[EngineId, dict[int, str]] = defaultdict(dict)
763
654 - # NIXL handshake port.
655 - # NOTE(rob): Within a DP group, each DP rank gets its own
656 - # base port (which is sent in the KVTransferParams).
657 - # Each TP rank listens/queries on the base_port + tp_rank.
658 - self.side_channel_port: int = (
659 - envs.VLLM_NIXL_SIDE_CHANNEL_PORT
660 - + vllm_config.parallel_config.data_parallel_rank
661 - * vllm_config.parallel_config.tensor_parallel_size
662 - )
663 -
764 # Metadata.
765 self.engine_id: EngineId = engine_id
766 self.tp_rank = get_tensor_model_parallel_rank()
@@ -706,6 +806,7 @@ class NixlConnectorWorker:
806 # Map of engine_id -> kv_caches_base_addr. For TP case, each local
807 # rank will still only pull from a single remote TP worker.
808 self.kv_caches_base_addr: dict[EngineId, list[int]] = {}
809 + self.device_id: int = 0
810
811 # Number of NIXL regions. Currently one region per cache
812 # (so 1 per layer for MLA, otherwise 2 per layer)
@@ -736,9 +837,8 @@ class NixlConnectorWorker:
837 # requests that skipped transfer (handshake or transfer failures)
838 self._failed_recv_reqs: set[ReqId] = set()
839
739 - # Background thread for handling new handshake requests.
740 - self._nixl_handshake_listener_t: threading.Thread | None = None
741 - self._nixl_handshake_listener_stop_event: threading.Event | None = None
840 + # Handshake metadata of this worker for NIXL transfers.
841 + self.xfer_handshake_metadata: NixlAgentMetadata | None = None
842 # Background thread for initializing new NIXL handshakes.
843 self._handshake_initiation_executor = ThreadPoolExecutor(
844 # NIXL is not guaranteed to be thread-safe, limit 1 worker.
@@ -790,42 +890,6 @@ class NixlConnectorWorker:
890 total_num_kv_heads=self.model_config.get_total_num_kv_heads(),
891 )
892
793 - @staticmethod
794 - def _nixl_handshake_listener(
795 - metadata: NixlAgentMetadata,
796 - ready_event: threading.Event,
797 - stop_event: threading.Event,
798 - base_port: int,
799 - tp_rank: int,
800 - ):
801 - """Background thread for getting new NIXL handshakes."""
802 - # NOTE(rob): this is a simple implementation. We will move
803 - # to a better approach via HTTP endpoint soon.
804 -
805 - encoder = msgspec.msgpack.Encoder()
806 - encoded_data = encoder.encode(metadata)
807 - size_in_bytes = len(encoded_data)
808 - logger.debug("Size of encoded NixlAgentMetadata: %s bytes", str(size_in_bytes))
809 -
810 - # Listen for new requests for metadata.
811 - host = envs.VLLM_NIXL_SIDE_CHANNEL_HOST
812 - path = make_zmq_path("tcp", host, base_port + tp_rank)
813 - logger.debug("Starting listening on path: %s", path)
814 - with zmq_ctx(zmq.ROUTER, path) as sock:
815 - ready_event.set()
816 - poller = zmq.Poller()
817 - poller.register(sock, zmq.POLLIN)
818 - while not stop_event.is_set():
819 - events = dict(
820 - poller.poll(timeout=NixlConnectorWorker._POLL_TIMEOUT * 1000)
821 - )
822 - if sock not in events:
823 - continue
824 - identity, _, msg = sock.recv_multipart()
825 - if msg != GET_META_MSG:
826 - logger.warning("Connection listener got unexpected message %s", msg)
827 - sock.send_multipart((identity, b"", encoded_data))
828 -
893 def _nixl_handshake(
894 self,
895 host: str,
@@ -844,16 +908,17 @@ class NixlConnectorWorker:
908 # Handshake only with the remote TP rank that current local rank will
909 # pull from. With homogeneous TP it happens to be the same rank_i.
910 p_remote_rank = self.kv_topo.get_target_remote_rank(remote_tp_size)
847 - path = make_zmq_path("tcp", host, port + p_remote_rank)
911 + path = make_zmq_path("tcp", host, port)
912 logger.debug(
849 - "Querying metadata on path: %s at remote rank %s", path, p_remote_rank
913 + "Querying metadata on path: %s at remote tp rank %s", path, p_remote_rank
914 )
915
916 # Send query for the request.
917 with zmq_ctx(zmq.REQ, path) as sock:
918 + msg = msgspec.msgpack.encode((GET_META_MSG, p_remote_rank))
919 # Set receive timeout to 5 seconds to avoid hanging on dead server
920 sock.setsockopt(zmq.RCVTIMEO, 5000) # milliseconds
856 - sock.send(GET_META_MSG)
921 + sock.send(msg)
922 metadata_bytes = sock.recv()
923 decoder = msgspec.msgpack.Decoder(NixlAgentMetadata)
924 metadata = decoder.decode(metadata_bytes)
@@ -1042,6 +1107,10 @@ class NixlConnectorWorker:
1107 assert tensor_size_bytes == curr_tensor_size_bytes, (
1108 "All kv cache tensors must have the same size"
1109 )
1110 + # Need to make sure the device ID is non-negative for NIXL,
1111 + # Torch uses -1 to indicate CPU tensors while NIXL uses explicit
1112 + # memory type.
1113 + self.device_id = max(cache.get_device(), 0)
1114 caches_data.append(
1115 (base_addr, curr_tensor_size_bytes, self.device_id, "")
1116 )
@@ -1139,10 +1208,11 @@ class NixlConnectorWorker:
1208 assert len(self.block_window_per_layer) == self.num_layers
1209
1210 # After KV Caches registered, listen for new connections.
1142 - metadata = NixlAgentMetadata(
1211 + self.xfer_handshake_metadata = NixlAgentMetadata(
1212 engine_id=self.engine_id,
1213 agent_metadata=self.nixl_wrapper.get_agent_metadata(),
1214 kv_caches_base_addr=self.kv_caches_base_addr[self.engine_id],
1215 + device_id=self.device_id,
1216 num_blocks=self.num_blocks,
1217 block_lens=self.block_len_per_layer,
1218 attn_backend_name=self.backend_name,
@@ -1150,22 +1220,6 @@ class NixlConnectorWorker:
1220 if not self.use_host_buffer
1221 else self.host_buffer_kv_cache_layout,
1222 )
1153 - ready_event, stop_event = threading.Event(), threading.Event()
1154 - self._nixl_handshake_listener_t = threading.Thread(
1155 - target=self._nixl_handshake_listener,
1156 - args=(
1157 - metadata,
1158 - ready_event,
1159 - stop_event,
1160 - self.side_channel_port,
1161 - self.tp_rank,
1162 - ),
1163 - daemon=True,
1164 - name="nixl_handshake_listener",
1165 - )
1166 - self._nixl_handshake_listener_t.start()
1167 - self._nixl_handshake_listener_stop_event = stop_event
1168 - ready_event.wait() # Wait for listener ZMQ socket to be ready.
1223
1224 def add_remote_agent(
1225 self,
@@ -1267,7 +1321,7 @@ class NixlConnectorWorker:
1321 # self.block_len == remote_block_len//tp_ratio bytes.
1322 addr = base_addr + block_offset + rank_offset
1323 # (addr, len, device id)
1270 - blocks_data.append((addr, kv_block_len, remote_tp_rank))
1324 + blocks_data.append((addr, kv_block_len, nixl_agent_meta.device_id))
1325
1326 if self._use_flashinfer:
1327 # With FlashInfer index V separately to allow head splitting.
@@ -1275,7 +1329,9 @@ class NixlConnectorWorker:
1329 block_offset = block_id * nixl_agent_meta.block_lens[i]
1330 addr = base_addr + block_offset + rank_offset
1331 v_addr = addr + nixl_agent_meta.block_lens[i] // 2
1278 - blocks_data.append((v_addr, kv_block_len, remote_tp_rank))
1332 + blocks_data.append(
1333 + (v_addr, kv_block_len, nixl_agent_meta.device_id)
1334 + )
1335
1336 logger.debug(
1337 "Created %s blocks for dst engine %s with remote rank %s and local rank %s",
@@ -1843,14 +1899,6 @@ class NixlConnectorWorker:
1899 def shutdown(self):
1900 """Shutdown the connector worker."""
1901 self._handshake_initiation_executor.shutdown(wait=False)
1846 - if self._nixl_handshake_listener_stop_event is not None:
1847 - self._nixl_handshake_listener_stop_event.set()
1848 - self._nixl_handshake_listener_stop_event = None
1849 - if self._nixl_handshake_listener_t is not None:
1850 - # Generous timeout to allow the thread to exit
1851 - self._nixl_handshake_listener_t.join(timeout=self._POLL_TIMEOUT * 10)
1852 - assert not self._nixl_handshake_listener_t.is_alive()
1853 - self._nixl_handshake_listener_t = None
1902 for handles in self._recving_transfers.values():
1903 for handle, _ in handles:
1904 self.nixl_wrapper.release_xfer_handle(handle)
vllm/v1/engine/core.py
+22 -1
@@ -163,6 +163,27 @@ class EngineCore:
163 vllm_config, mm_registry
164 )
165
166 + # If a KV connector is initialized for scheduler, we want to collect
167 + # handshake metadata from all workers so the connector in the scheduler
168 + # will have the full context
169 + kv_connector = self.scheduler.get_kv_connector()
170 + if kv_connector is not None:
171 + # Collect and store KV connector xfer metadata from workers
172 + # (after KV cache registration)
173 + xfer_handshake_metadata = (
174 + self.model_executor.get_kv_connector_handshake_metadata()
175 + )
176 +
177 + if xfer_handshake_metadata:
178 + # xfer_handshake_metadata is list of dicts from workers
179 + # Each dict already has structure {tp_rank: metadata}
180 + # Merge all worker dicts into a single dict
181 + content: dict[int, Any] = {}
182 + for worker_dict in xfer_handshake_metadata:
183 + if worker_dict is not None:
184 + content.update(worker_dict)
185 + kv_connector.set_xfer_handshake_metadata(content)
186 +
187 # Setup batch queue for pipeline parallelism.
188 # Batch queue for scheduled batches. This enables us to asynchronously
189 # schedule and execute batches, and is required by pipeline parallelism
@@ -178,7 +199,7 @@ class EngineCore:
199 self.request_block_hasher: Callable[[Request], list[BlockHash]] | None = None
200 if (
201 self.vllm_config.cache_config.enable_prefix_caching
181 - or self.scheduler.get_kv_connector() is not None
202 + or kv_connector is not None
203 ):
204 caching_hash_fn = get_hash_fn_by_name(
205 vllm_config.cache_config.prefix_caching_hash_algo
vllm/v1/executor/abstract.py
+8
@@ -9,6 +9,9 @@ from typing import TYPE_CHECKING, Literal, TypeVar, overload
9
10 from vllm.config import VllmConfig
11 from vllm.distributed.kv_transfer.kv_connector.utils import KVOutputAggregator
12 +from vllm.distributed.kv_transfer.kv_connector.v1.base import (
13 + KVConnectorHandshakeMetadata,
14 +)
15 from vllm.logger import init_logger
16 from vllm.lora.request import LoRARequest
17 from vllm.tasks import SupportedTask
@@ -177,6 +180,11 @@ class Executor(ABC):
180 ):
181 raise NotImplementedError
182
183 + def get_kv_connector_handshake_metadata(
184 + self,
185 + ) -> list[dict[int, KVConnectorHandshakeMetadata]]:
186 + return self.collective_rpc("get_kv_connector_handshake_metadata")
187 +
188 @overload
189 def execute_model(
190 self,
vllm/v1/worker/gpu_worker.py
+20 -1
@@ -19,7 +19,11 @@ from vllm.distributed import (
19 init_distributed_environment,
20 set_custom_all_reduce,
21 )
22 -from vllm.distributed.kv_transfer import ensure_kv_transfer_initialized
22 +from vllm.distributed.kv_transfer import (
23 + ensure_kv_transfer_initialized,
24 + get_kv_transfer_group,
25 + has_kv_transfer_group,
26 +)
27 from vllm.distributed.parallel_state import (
28 get_pp_group,
29 get_tp_group,
@@ -348,6 +352,21 @@ class Worker(WorkerBase):
352
353 return int(self.available_kv_cache_memory_bytes)
354
355 + def get_kv_connector_handshake_metadata(self) -> dict | None:
356 + """Get KV connector metadata from this worker if available."""
357 +
358 + if not has_kv_transfer_group():
359 + return None
360 +
361 + connector = get_kv_transfer_group()
362 + # Return None for connectors that don't need to exchange handshake
363 + # metadata across workers.
364 + if (metadata := connector.get_handshake_metadata()) is None:
365 + return None
366 +
367 + tp_rank = get_tp_group().rank_in_group
368 + return {tp_rank: metadata}
369 +
370 def get_kv_cache_spec(self) -> dict[str, KVCacheSpec]:
371 return self.model_runner.get_kv_cache_spec()
372