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 14, 05:30 UTC
[P/D] [NixlConnector] kv load recovery integration (#26171)
Signed-off-by: Will Eaton <weaton@redhat.com>
Will Eaton committed
Oct 13, 2025 at 11:48 UTC
53c9a7cee208bc408f81fd2ddd8e30fc437d158f
3 files changed
+252
-26
tests/v1/kv_connector/unit/test_nixl_connector.py
+142
-1
@@ -190,7 +190,6 @@ def _make_fake_nixl_pkg():
190
# Copy of FakeNixlWrapper implementation for Ray workers
191
import uuid
192
from collections import defaultdict
193
-from typing import Optional
193
194
{fake_nixl_source}
195
@@ -1143,3 +1142,145 @@ def test_aborted_request_removed_from_worker_in_batch(dist_init):
1142
# After abort, the worker should not keep tracking it as "in-batch"
1143
assert req.request_id not in connector.connector_worker._reqs_to_process
1144
#### Model Runner end ####
1145
+
1146
+
1147
+class FailingNixlWrapper(FakeNixlWrapper):
1148
+ """Mock NixlWrapper that fails on specific operations."""
1149
+
1150
+ def __init__(self, *args, **kwargs):
1151
+ super().__init__(*args, **kwargs)
1152
+ self.fail_handshake = False
1153
+ self.fail_transfer_setup = False
1154
+ self.fail_send_notif = False
1155
+
1156
+ def add_remote_agent(self, agent_metadata: bytes) -> str:
1157
+ if self.fail_handshake:
1158
+ from zmq.error import Again
1159
+
1160
+ raise Again("Simulated timeout failure")
1161
+ return super().add_remote_agent(agent_metadata)
1162
+
1163
+ def make_prepped_xfer(
1164
+ self,
1165
+ xfer_type: str,
1166
+ local_xfer_side_handle: int,
1167
+ local_block_descs_ids: list[int],
1168
+ remote_xfer_side_handle: int,
1169
+ remote_block_descs_ids: list[int],
1170
+ notif_msg: bytes | None = None,
1171
+ ) -> int:
1172
+ if self.fail_transfer_setup:
1173
+ # classic RuntimeError to simulate failure
1174
+ raise RuntimeError("BAD STATUS")
1175
+ return super().make_prepped_xfer(
1176
+ xfer_type,
1177
+ local_xfer_side_handle,
1178
+ local_block_descs_ids,
1179
+ remote_xfer_side_handle,
1180
+ remote_block_descs_ids,
1181
+ notif_msg,
1182
+ )
1183
+
1184
+ def send_notif(self, agent_name: str, notif_msg: bytes) -> None:
1185
+ if self.fail_send_notif:
1186
+ raise RuntimeError("Simulated send_notif failure")
1187
+ return super().send_notif(agent_name, notif_msg)
1188
+
1189
+
1190
+@patch(
1191
+ "vllm.distributed.kv_transfer.kv_connector.v1.nixl_connector.NixlWrapper",
1192
+ FailingNixlWrapper,
1193
+)
1194
+def test_handshake_failure_returns_finished(dist_init):
1195
+ """Test that handshake failures mark blocks invalid and return via get_finished."""
1196
+ vllm_config = create_vllm_config()
1197
+
1198
+ connector = NixlConnector(vllm_config, KVConnectorRole.WORKER)
1199
+ connector.connector_worker = FakeNixlConnectorWorker(
1200
+ vllm_config, connector.engine_id, hand_shake_latency=0.1
1201
+ )
1202
+ connector.connector_worker.nixl_wrapper.fail_handshake = True
1203
+
1204
+ request_id = "test_handshake_fail"
1205
+ metadata = NixlConnectorMetadata()
1206
+ metadata.add_new_req(
1207
+ request_id=request_id,
1208
+ local_block_ids=[1, 2, 3],
1209
+ kv_transfer_params={
1210
+ "remote_block_ids": [4, 5, 6],
1211
+ "remote_engine_id": FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
1212
+ "remote_host": "localhost",
1213
+ "remote_port": 1234,
1214
+ "remote_tp_size": 1,
1215
+ },
1216
+ )
1217
+ connector.bind_connector_metadata(metadata)
1218
+
1219
+ dummy_ctx = ForwardContext(
1220
+ no_compile_layers={},
1221
+ attn_metadata={},
1222
+ virtual_engine=0,
1223
+ )
1224
+ connector.start_load_kv(dummy_ctx)
1225
+
1226
+ # Wait for handshake to fail
1227
+ time.sleep(0.3)
1228
+
1229
+ # Check that blocks were marked invalid
1230
+ invalid_blocks = connector.get_block_ids_with_load_errors()
1231
+ assert invalid_blocks == {1, 2, 3}
1232
+
1233
+ # Check that request appears in get_finished
1234
+ _, done_recving = connector.get_finished(finished_req_ids=set())
1235
+ assert request_id in done_recving
1236
+
1237
+
1238
+@patch(
1239
+ "vllm.distributed.kv_transfer.kv_connector.v1.nixl_connector.NixlWrapper",
1240
+ FailingNixlWrapper,
1241
+)
1242
+def test_transfer_setup_failure_returns_finished(dist_init):
1243
+ """Test that transfer setup failures mark blocks invalid
1244
+ and return via get_finished."""
1245
+ vllm_config = create_vllm_config()
1246
+
1247
+ connector = NixlConnector(vllm_config, KVConnectorRole.WORKER)
1248
+ connector.connector_worker = FakeNixlConnectorWorker(
1249
+ vllm_config, connector.engine_id, hand_shake_latency=0
1250
+ )
1251
+ connector.connector_worker.nixl_wrapper.fail_transfer_setup = True
1252
+
1253
+ request_id = "test_transfer_fail"
1254
+ metadata = NixlConnectorMetadata()
1255
+ metadata.add_new_req(
1256
+ request_id=request_id,
1257
+ local_block_ids=[7, 8, 9],
1258
+ kv_transfer_params={
1259
+ "remote_block_ids": [10, 11, 12],
1260
+ "remote_engine_id": FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
1261
+ "remote_host": "localhost",
1262
+ "remote_port": 1234,
1263
+ "remote_tp_size": 1,
1264
+ },
1265
+ )
1266
+ connector.bind_connector_metadata(metadata)
1267
+
1268
+ dummy_ctx = ForwardContext(
1269
+ no_compile_layers={},
1270
+ attn_metadata={},
1271
+ virtual_engine=0,
1272
+ )
1273
+ connector.start_load_kv(dummy_ctx)
1274
+
1275
+ # Wait for handshake to complete and process ready_requests
1276
+ connector.bind_connector_metadata(NixlConnectorMetadata())
1277
+ time.sleep(0.1)
1278
+ connector.start_load_kv(dummy_ctx)
1279
+
1280
+ # check that blocks were marked invalid
1281
+ invalid_blocks = connector.get_block_ids_with_load_errors()
1282
+ assert invalid_blocks == {7, 8, 9}
1283
+
1284
+ # ensure request appears in get_finished
1285
+ _, done_recving = connector.get_finished(finished_req_ids=set())
1286
+ assert request_id in done_recving
vllm/distributed/kv_transfer/kv_connector/v1/nixl_connector.py
+109
-24
@@ -68,6 +68,7 @@ except ImportError:
68
NixlWrapper = None
69
nixlXferTelemetry = None
70
71
+
72
try:
73
from nixl._api import nixl_agent_config
74
except ImportError:
@@ -234,6 +235,11 @@ class NixlConnector(KVConnectorBase_V1):
235
assert self.connector_worker is not None
236
return self.connector_worker.get_finished()
237
238
+ def get_block_ids_with_load_errors(self) -> set[int]:
239
+ """Get block IDs that failed to load via NIXL."""
240
+ assert self.connector_worker is not None
241
+ return self.connector_worker.get_block_ids_with_load_errors()
242
+
243
def get_kv_connector_stats(self) -> KVConnectorStats | None:
244
assert self.connector_worker is not None
245
return self.connector_worker.get_kv_connector_stats()
@@ -614,6 +620,11 @@ class NixlConnectorWorker:
620
# Set of requests that have been part of a batch, regardless of status.
621
self._reqs_to_process: set[ReqId] = set()
622
623
+ # invalid blocks from failed NIXL operations
624
+ self._invalid_block_ids: set[int] = set()
625
+ # requests that skipped transfer (handshake or transfer failures)
626
+ self._failed_recv_reqs: set[ReqId] = set()
627
+
628
# Background thread for handling new handshake requests.
629
self._nixl_handshake_listener_t: threading.Thread | None = None
630
# Background thread for initializing new NIXL handshakes.
@@ -713,6 +724,8 @@ class NixlConnectorWorker:
724
725
# Send query for the request.
726
with zmq_ctx(zmq.REQ, path) as sock:
727
+ # Set receive timeout to 5 seconds to avoid hanging on dead server
728
+ sock.setsockopt(zmq.RCVTIMEO, 5000) # milliseconds
729
sock.send(GET_META_MSG)
730
metadata_bytes = sock.recv()
731
decoder = msgspec.msgpack.Decoder(NixlAgentMetadata)
@@ -795,10 +808,20 @@ class NixlConnectorWorker:
808
809
fut.add_done_callback(done_callback)
810
798
- # TODO: handle failure state of future in the
799
- # callback, we want to fail the request in this case.
800
- def request_ready(_f: Future[Any], entry=(req_id, meta)):
801
- self._ready_requests.put(entry)
811
+ # check handshake success before proceeding with request
812
+ def request_ready(f: Future[Any], entry=(req_id, meta)):
813
+ try:
814
+ # check if handshake succeeded
815
+ f.result()
816
+ self._ready_requests.put(entry)
817
+ except Exception:
818
+ # handshake failed - mark blocks as invalid
819
+ logger.exception(
820
+ "Handshake failed for request %s, marking blocks as invalid", req_id
821
+ )
822
+ if req_meta := self._recving_metadata.get(req_id):
823
+ self._invalid_block_ids.update(req_meta.local_block_ids)
824
+ self._failed_recv_reqs.add(req_id)
825
826
fut.add_done_callback(request_ready)
827
@@ -1205,6 +1228,11 @@ class NixlConnectorWorker:
1228
"""
1229
done_sending = self._get_new_notifs()
1230
done_recving = self._pop_done_transfers(self._recving_transfers)
1231
+
1232
+ # add requests that skipped transfer to done_recving
1233
+ done_recving.update(self._failed_recv_reqs)
1234
+ self._failed_recv_reqs.clear()
1235
+
1236
if len(done_sending) > 0 or len(done_recving) > 0:
1237
logger.debug(
1238
"Rank %s, get_finished: %s requests done sending "
@@ -1214,10 +1242,10 @@ class NixlConnectorWorker:
1242
len(done_recving),
1243
)
1244
1217
- if self.use_host_buffer:
1218
- for req_id in done_recving:
1219
- meta = self._recving_metadata.pop(req_id)
1220
- assert meta, f"{req_id} not found in recving_metadata list"
1245
+ # clean up metadata for completed requests
1246
+ for req_id in done_recving:
1247
+ meta = self._recving_metadata.pop(req_id, None)
1248
+ if self.use_host_buffer and meta:
1249
self.sync_recved_kv_to_device(req_id, meta)
1250
1251
# Handle timeout to avoid stranding blocks on remote.
@@ -1296,7 +1324,19 @@ class NixlConnectorWorker:
1324
in_progress = True
1325
continue
1326
else:
1299
- raise RuntimeError("Transfer failed with state %s", xfer_state)
1327
+ # transfer failed - mark blocks as invalid
1328
+ logger.error(
1329
+ "NIXL transfer failed for request %s with state %s. "
1330
+ "Marking blocks as invalid.",
1331
+ req_id,
1332
+ xfer_state,
1333
+ )
1334
+ # mark all blocks for this request as invalid
1335
+ if meta := self._recving_metadata.pop(req_id, None):
1336
+ self._invalid_block_ids.update(meta.local_block_ids)
1337
+ self._recving_metadata.pop(req_id, None)
1338
+ self.nixl_wrapper.release_xfer_handle(handle)
1339
+ self.xfer_stats.record_failed_transfer()
1340
if not in_progress:
1341
done_req_ids.add(req_id)
1342
del transfers[req_id]
@@ -1317,8 +1357,8 @@ class NixlConnectorWorker:
1357
len(meta.local_block_ids),
1358
len(meta.remote_block_ids),
1359
)
1320
- if self.use_host_buffer:
1321
- self._recving_metadata[req_id] = meta
1360
+ # always store metadata for failure recovery
1361
+ self._recving_metadata[req_id] = meta
1362
if remote_engine_id not in self._remote_agents:
1363
# Initiate handshake with remote engine to exchange metadata.
1364
with self._handshake_lock:
@@ -1394,7 +1434,16 @@ class NixlConnectorWorker:
1434
if num_local_blocks == 0:
1435
remote_rank = self.tp_rank // tp_ratio
1436
agent_name = self._remote_agents[dst_engine_id][remote_rank]
1397
- self.nixl_wrapper.send_notif(agent_name, notif_msg=notif_id)
1437
+ try:
1438
+ self.nixl_wrapper.send_notif(agent_name, notif_msg=notif_id)
1439
+ except Exception:
1440
+ logger.exception(
1441
+ "NIXL send_notif failed for request %s: "
1442
+ "P worker blocks will be freed after timeout. "
1443
+ "This may indicate network issues.",
1444
+ request_id,
1445
+ )
1446
+ self.xfer_stats.record_failed_notification()
1447
return
1448
1449
# Partial prefix cache hit: just read uncomputed blocks.
@@ -1456,20 +1505,35 @@ class NixlConnectorWorker:
1505
assert len(local_block_descs_ids) == len(remote_block_descs_ids)
1506
1507
# Prepare transfer with Nixl.
1459
- handle = self.nixl_wrapper.make_prepped_xfer(
1460
- "READ",
1461
- local_xfer_side_handle,
1462
- local_block_descs_ids,
1463
- remote_xfer_side_handle,
1464
- remote_block_descs_ids,
1465
- notif_msg=notif_id,
1466
- )
1508
+ handle = None
1509
+ try:
1510
+ handle = self.nixl_wrapper.make_prepped_xfer(
1511
+ "READ",
1512
+ local_xfer_side_handle,
1513
+ local_block_descs_ids,
1514
+ remote_xfer_side_handle,
1515
+ remote_block_descs_ids,
1516
+ notif_msg=notif_id,
1517
+ )
1518
1468
- # Begin async xfer.
1469
- self.nixl_wrapper.transfer(handle)
1519
+ # Begin async xfer.
1520
+ self.nixl_wrapper.transfer(handle)
1521
1471
- # Use handle to check completion in future step().
1472
- self._recving_transfers[request_id].append((handle, time.perf_counter()))
1522
+ # Use handle to check completion in future step().
1523
+ self._recving_transfers[request_id].append((handle, time.perf_counter()))
1524
+ except Exception:
1525
+ logger.exception(
1526
+ "NIXL transfer setup/initiation failed for request %s. "
1527
+ "Marking blocks as invalid.",
1528
+ request_id,
1529
+ )
1530
+ # mark all blocks for this request as invalid
1531
+ if meta := self._recving_metadata.get(request_id):
1532
+ self._invalid_block_ids.update(meta.local_block_ids)
1533
+ self.xfer_stats.record_failed_transfer()
1534
+ if handle is not None:
1535
+ self.nixl_wrapper.release_xfer_handle(handle)
1536
+ self._failed_recv_reqs.add(request_id)
1537
1538
def _get_block_descs_ids(
1539
self, engine_id: str, block_ids: list[int], layer_idx: int | None = None
@@ -1527,6 +1591,17 @@ class NixlConnectorWorker:
1591
return self.xfer_stats.clone_and_reset()
1592
return None
1593
1594
+ def get_block_ids_with_load_errors(self) -> set[int]:
1595
+ """
1596
+ Return and clear the set of block IDs that failed to load.
1597
+
1598
+ This is called by the scheduler to identify blocks that need
1599
+ to be retried after a NIXL transfer failure.
1600
+ """
1601
+ result = self._invalid_block_ids
1602
+ self._invalid_block_ids = set()
1603
+ return result
1604
+
1605
def shutdown(self):
1606
"""Shutdown the connector worker."""
1607
self._handshake_initiation_executor.shutdown(wait=False)
@@ -1586,6 +1661,8 @@ class NixlKVConnectorStats(KVConnectorStats):
1661
"post_duration": [],
1662
"bytes_transferred": [],
1663
"num_descriptors": [],
1664
+ "num_failed_transfers": [],
1665
+ "num_failed_notifications": [],
1666
}
1667
1668
def record_transfer(self, res: nixlXferTelemetry):
@@ -1595,6 +1672,14 @@ class NixlKVConnectorStats(KVConnectorStats):
1672
self.data["bytes_transferred"].append(res.totalBytes)
1673
self.data["num_descriptors"].append(res.descCount)
1674
1675
+ def record_failed_transfer(self):
1676
+ """Record a failed NIXL transfer operation."""
1677
+ self.data["num_failed_transfers"].append(1.0)
1678
+
1679
+ def record_failed_notification(self):
1680
+ """Record a failed NIXL notification (send_notif)."""
1681
+ self.data["num_failed_notifications"].append(1.0)
1682
+
1683
def clone_and_reset(self) -> "NixlKVConnectorStats":
1684
old = copy.copy(self)
1685
self.reset()
vllm/v1/core/sched/scheduler.py
+1
-1
@@ -1487,7 +1487,7 @@ class Scheduler(SchedulerInterface):
1487
total_tokens_to_reschedule += num_tokens_to_reschedule
1488
1489
# Mark requests with async KV load failures; they will be rescheduled
1490
- # once loading completes
1490
+ # once loading completes.
1491
self.failed_recving_kv_req_ids |= async_affected_req_ids
1492
1493
# --- Handle sync KV loads (running requests) ---