[NIXL] Avoid reading expired blocks in bidirectional turn-2 read (#47021)

Signed-off-by: Tomer Gilad <tgilad@nvidia.com> Signed-off-by: NickLucche <nicolo.lucchesi@mistral.ai> Co-authored-by: NickLucche <nicolo.lucchesi@mistral.ai>

tomerg-nvidia committed Jul 14, 2026 at 15:03 UTC 7a74a9662bff4e5ef91f210f41b7f930de3df0bc
10 files changed +264 -22
docs/design/nixl_kv_cache_lease.md
+1 -1
@@ -101,7 +101,7 @@ The heartbeat is deferred to the next step once the handshake completes --- the
101
102 ## Bidirectional KV Transfer
103
104 -For multi-turn conversations, [bidirectional KV transfer](../features/disagg_prefill.md) allows D to cache KV blocks that P can pull from on subsequent turns. Since the timing of the next conversational turn is **client-dependent** (not controlled by the system), the heartbeat-based lease mechanism does not apply here. Instead, a separate `decoder_kv_blocks_ttl` (default 480s) provides a simple fixed timeout for blocks cached on D. If the client takes too long to continue the conversation, the blocks expire and P recomputes. Future work may extend a symmetric heartbeat mechanism to this case.
104 +For multi-turn conversations, [bidirectional KV transfer](../features/disagg_prefill.md) allows D to cache KV blocks that P can pull from on subsequent turns. Since the timing of the next conversational turn is **client-dependent** (not controlled by the system), the heartbeat-based lease mechanism does not apply here. Instead, a separate `decoder_kv_blocks_ttl` (default 480s) provides a simple fixed timeout for blocks cached on D. If the client takes too long to continue the conversation, the blocks expire. D communicates back the expiry time so P can know when blocks are expired and recompute. Because the deadline is a `perf_counter` value produced on D and the two engines run in separate processes (with unrelated clocks), P estimates the clock offset to D from the handshake round-trip and applies it before comparing the deadline against its own `perf_counter`. Future work may extend a symmetric heartbeat mechanism to this case.
105
106 ## Key Design Decisions
107
tests/v1/kv_connector/unit/test_bidirectional_kv_transfer.py
+163 -2
@@ -87,7 +87,7 @@ def _make_connector_with_fake_worker(
87 hand_shake_latency=0, cycles_before_done=0, do_handshake=True
88 ):
89 """Create a NixlConnector with FakeNixlConnectorWorker."""
90 - vllm_config = create_vllm_config()
90 + vllm_config = create_vllm_config(kv_connector_extra_config=BIDIR_KV_EXTRA_CONFIG)
91 kv_cache_config = make_kv_cache_config(block_size=16, num_blocks=2)
92 connector = NixlConnector(vllm_config, KVConnectorRole.WORKER, kv_cache_config)
93 connector.connector_worker = FakeNixlConnectorWorker(
@@ -100,7 +100,7 @@ def _make_connector_with_fake_worker(
100 assert isinstance(worker.nixl_wrapper, FakeNixlWrapper)
101 worker.kv_cache_layout = "HND"
102 if do_handshake:
103 - remote_agents = worker._nixl_handshake(
103 + remote_agents, _ = worker._nixl_handshake(
104 host="localhost",
105 port=1234,
106 remote_tp_size=1,
@@ -912,3 +912,164 @@ def test_remote_blocks_processed_flag_persists():
912 mro.kv_connector_output = KVConnectorOutput(finished_sending={req_id})
913 scheduler.update_from_output(so, mro)
914 assert_scheduler_empty(scheduler)
915 +
916 +
917 +# 6. Turn-2 deadline expiry check (P declines reading expired D blocks)
918 +
919 +_REMOTE = FakeNixlConnectorWorker.REMOTE_ENGINE_ID
920 +
921 +
922 +@pytest.mark.parametrize(
923 + ("offset", "expiry_delta", "expect_declined"),
924 + [
925 + # offset known + deadline expired/near-expiry -> decline & recompute.
926 + pytest.param(0.0, -10.0, True, id="expired"),
927 + # offset applied before comparison: +50s in D's clock is expired locally
928 + # when D is 100s ahead.
929 + pytest.param(100.0, 50.0, True, id="offset_makes_expired"),
930 + # within the default 5s safety margin -> treated as expired.
931 + pytest.param(0.0, 2.0, True, id="near_expiry_within_margin"),
932 + # far-future deadline -> read proceeds.
933 + pytest.param(0.0, 1000.0, False, id="valid_far_deadline"),
934 + # no deadline field (older peer / router did not forward) -> skipped.
935 + pytest.param(0.0, None, False, id="missing_expiry_field"),
936 + ],
937 +)
938 +@patch(
939 + "vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper",
940 + FakeNixlWrapper,
941 +)
942 +def test_turn2_deadline_gate(dist_init, offset, expiry_delta, expect_declined):
943 + """P declines on the turn-2 readback if the deadline is known,
944 + near-expiry and a handshake clock offset is known"""
945 + connector, worker = _make_connector_with_fake_worker()
946 + worker._engine_clock_offset[_REMOTE] = offset
947 + expiry_time = None if expiry_delta is None else time.perf_counter() + expiry_delta
948 + meta = NixlConnectorMetadata()
949 + params = {
950 + "do_remote_prefill": False,
951 + "do_remote_decode": True,
952 + "remote_block_ids": ([20, 21],),
953 + "remote_engine_id": FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
954 + "remote_request_id": "decode-req",
955 + "remote_host": "localhost",
956 + "remote_port": 1234,
957 + "remote_tp_size": 1,
958 + }
959 + if expiry_time is not None:
960 + params["remote_blocks_expiry_time"] = expiry_time
961 + meta.add_new_req_to_recv(
962 + request_id="req",
963 + local_block_ids=([10, 11],),
964 + kv_transfer_params=params,
965 + )
966 + _do_load_kv(connector, meta)
967 + assert worker.xfer_stats.data["num_kv_expired_reqs"] == (
968 + [1] if expect_declined else []
969 + )
970 + _, done_recving = connector.get_finished(finished_req_ids=set())
971 + assert "req" in done_recving
972 +
973 +
974 +@patch(
975 + "vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper",
976 + FakeNixlWrapper,
977 +)
978 +def test_turn2_full_prefix_hit_with_expired_deadline_skips_gate(dist_init):
979 + """A full local prefix hit issues no READ. The expiry gate must be skipped
980 + even when D's deadline is expired."""
981 + connector, worker = _make_connector_with_fake_worker()
982 + worker._engine_clock_offset[_REMOTE] = 0.0
983 +
984 + meta = NixlConnectorMetadata()
985 + meta.add_new_req_to_recv(
986 + request_id="req",
987 + local_block_ids=(), # full prefix hit -> zero local block groups
988 + kv_transfer_params={
989 + "do_remote_prefill": False,
990 + "do_remote_decode": True,
991 + "remote_block_ids": ([20, 21],),
992 + "remote_engine_id": _REMOTE,
993 + "remote_request_id": "decode-req",
994 + "remote_host": "localhost",
995 + "remote_port": 1234,
996 + "remote_tp_size": 1,
997 + # Expired on D's clock; with offset 0 it is expired locally too.
998 + "remote_blocks_expiry_time": time.perf_counter() - 10.0,
999 + },
1000 + )
1001 + # Must not raise, and the gate must not fire for a no-read prefix hit.
1002 + _do_load_kv(connector, meta)
1003 + assert worker.xfer_stats.data["num_kv_expired_reqs"] == []
1004 + assert worker.xfer_stats.data["num_failed_transfers"] == []
1005 +
1006 +
1007 +def test_d_node_request_finished_exports_blocks_expiry_time():
1008 + """D-node (do_remote_prefill request) exports a future float expiry time."""
1009 + vllm_config = create_vllm_config(kv_connector_extra_config=BIDIR_KV_EXTRA_CONFIG)
1010 + scheduler = create_scheduler(vllm_config)
1011 + BS = vllm_config.cache_config.block_size
1012 + req = create_request(
1013 + request_id=600, block_size=BS, num_tokens=int(BS * 2.5), do_remote_prefill=True
1014 + )
1015 + scheduler.add_request(req)
1016 + req_id = req.request_id
1017 + so = scheduler.schedule()
1018 + scheduler.update_from_output(
1019 + so, create_model_runner_output(reqs=[], finished_recving={req_id})
1020 + )
1021 + so = scheduler.schedule()
1022 + eco = scheduler.update_from_output(
1023 + so, create_model_runner_output(reqs=[req], use_eos=True)
1024 + )
1025 + kv = eco[0].outputs[0].kv_transfer_params
1026 + assert kv["do_remote_decode"] is True
1027 + assert isinstance(kv["remote_blocks_expiry_time"], float)
1028 + assert kv["remote_blocks_expiry_time"] > time.perf_counter()
1029 +
1030 +
1031 +def test_handshake_listener_appends_perf_counter_frame():
1032 + """The handshake reply carries a 2nd frame with the listener's live
1033 + perf_counter."""
1034 + import threading
1035 +
1036 + import msgspec
1037 + import zmq
1038 +
1039 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_scheduler import (
1040 + NixlBaseConnectorScheduler,
1041 + )
1042 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import GET_META_MSG
1043 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl.utils import zmq_ctx
1044 + from vllm.utils.network_utils import get_open_port, make_zmq_path
1045 +
1046 + encoded_data = {(0, 0): b"payload-rank-0"}
1047 + ready_event = threading.Event()
1048 + stop_event = threading.Event()
1049 + host = "127.0.0.1"
1050 + port = get_open_port()
1051 + listener = threading.Thread(
1052 + target=NixlBaseConnectorScheduler._nixl_handshake_listener,
1053 + args=(encoded_data, ready_event, stop_event, host, port),
1054 + daemon=True,
1055 + )
1056 + listener.start()
1057 + try:
1058 + assert ready_event.wait(timeout=5)
1059 + path = make_zmq_path("tcp", host, port)
1060 + with zmq_ctx(zmq.REQ, path) as sock: # type: ignore[attr-defined]
1061 + sock.setsockopt(zmq.RCVTIMEO, 5000) # type: ignore[attr-defined]
1062 + t0 = time.perf_counter()
1063 + sock.send(msgspec.msgpack.encode((GET_META_MSG, 0, 0)))
1064 + parts = sock.recv_multipart()
1065 + t1 = time.perf_counter()
1066 + assert len(parts) == 2
1067 + assert parts[0] == b"payload-rank-0"
1068 + remote_perf = msgspec.msgpack.decode(parts[1])
1069 + assert isinstance(remote_perf, float)
1070 + # same-clock => offset is near zero.
1071 + offset = remote_perf - (t0 + t1) / 2
1072 + assert abs(offset) < 1.0
1073 + finally:
1074 + stop_event.set()
1075 + listener.join(timeout=5)
tests/v1/kv_connector/unit/test_nixl_connector.py
+14 -7
@@ -508,7 +508,7 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
508 expected_engine_id: str,
509 remote_pp_size: int = 1,
510 notif_agents_only: bool = False,
511 - ) -> dict[tuple[int, int], str]:
511 + ) -> tuple[dict[tuple[int, int], str], float]:
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
@@ -560,7 +560,8 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
560 remote_tp_size=remote_tp_size,
561 )
562 remote_agents[(0, remote_tp_rank)] = remote_agent_name
563 - return remote_agents
563 + # Handshake bypasses zmq, so report a zero clock offset to the peer.
564 + return remote_agents, 0.0
565
566
567 class TestNixlHandshake:
@@ -768,7 +769,7 @@ class TestNixlHandshake:
769 range(tp_ratio)
770 )
771
771 - remote_agents = worker._nixl_handshake(
772 + remote_agents, _ = worker._nixl_handshake(
773 host="localhost",
774 port=1234,
775 remote_tp_size=4,
@@ -780,7 +781,7 @@ class TestNixlHandshake:
781 # discovered. This is not a scenario we actively support right now, but
782 # the connector allows it.
783 worker.REMOTE_ENGINE_ID = "remote_engine_2"
783 - remote_agents = worker._nixl_handshake(
784 + remote_agents, _ = worker._nixl_handshake(
785 host="localhost",
786 port=1234,
787 remote_tp_size=6,
@@ -2878,7 +2879,10 @@ def test_compatibility_hash_validation(
2879
2880 # Mock ZMQ socket to return our handshake payload
2881 mock_socket = MagicMock()
2881 - mock_socket.recv.return_value = msgspec.msgpack.encode(handshake_payload)
2882 + mock_socket.recv_multipart.return_value = [
2883 + msgspec.msgpack.encode(handshake_payload),
2884 + msgspec.msgpack.encode(time.perf_counter()),
2885 + ]
2886
2887 # Mock add_remote_agent to avoid actual NIXL operations
2888 # Patch zmq_ctx to return our mock socket
@@ -2897,7 +2901,7 @@ def test_compatibility_hash_validation(
2901 expected_engine_id=FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
2902 )
2903 else:
2900 - result = decode_worker._nixl_handshake(
2904 + result, _ = decode_worker._nixl_handshake(
2905 host="localhost",
2906 port=1234,
2907 remote_tp_size=1,
@@ -2981,7 +2985,10 @@ def test_handshake_decode_errors(default_vllm_config, dist_init, error_scenario)
2985 raise AssertionError(f"{error_scenario} not a valid scenario")
2986
2987 mock_socket = MagicMock()
2984 - mock_socket.recv.return_value = msg_bytes
2988 + mock_socket.recv_multipart.return_value = [
2989 + msg_bytes,
2990 + msgspec.msgpack.encode(time.perf_counter()),
2991 + ]
2992 with (
2993 patch.object(decode_worker, "add_remote_agent", return_value="fake_agent"),
2994 patch.object(nixl.base_worker, "zmq_ctx") as mock_zmq_ctx,
tests/v1/kv_connector/unit/test_nixl_connector_hma.py
+1
@@ -209,6 +209,7 @@ def test_read_blocks_for_req_expands_remote_ids(
209 worker = object.__new__(NixlConnectorWorker)
210 worker._physical_blocks_per_logical_kv_block = local_physical_per_logical
211 worker._engine_last_active = {}
212 + worker._bidirectional_kv_xfer_enabled = False
213
214 has_mamba = any(t is MambaSpec for t in resolved_types)
215 has_swa = any(t is SlidingWindowSpec for t in resolved_types)
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py
+6 -1
@@ -322,8 +322,13 @@ class NixlBaseConnectorScheduler:
322 )
323 if msg != GET_META_MSG:
324 logger.warning("Connection listener got unexpected message %s", msg)
325 + # Echo our perf_counter so P can estimate the clock offset.
326 + # perf_counter is only comparable within a process, so this
327 + # listener must run in the same process that stamps the block
328 + # expiry deadline (`_reqs_need_send`).
329 + ts = msgspec.msgpack.encode(time.perf_counter())
330 sock.send_multipart(
326 - (identity, b"", encoded_data[(target_pp_rank, target_tp_rank)])
331 + (identity, b"", encoded_data[(target_pp_rank, target_tp_rank)], ts)
332 )
333
334 def _get_remote_prefill_token_count(self, num_prompt_tokens: int) -> int:
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+41 -8
@@ -266,6 +266,12 @@ class NixlBaseConnectorWorker:
266 # NOTE (NickLucche): For now we use a hardcoded value for a simpler interface.
267 self._lease_extension = kv_lease_duration * 2 // 3
268
269 + self._bidirectional_kv_xfer_enabled: bool = (
270 + vllm_config.kv_transfer_config.get_from_extra_config(
271 + "bidirectional_kv_xfer", False
272 + )
273 + )
274 +
275 self._is_hma_required = (
276 not vllm_config.scheduler_config.disable_hybrid_kv_cache_manager
277 and any(
@@ -340,6 +346,8 @@ class NixlBaseConnectorWorker:
346 self._remote_agents: dict[EngineId, dict[tuple[int, int], str]] = defaultdict(
347 dict
348 )
349 + # Map of engine_id -> clock offset.
350 + self._engine_clock_offset: dict[EngineId, float] = {}
351
352 # Metadata.
353 self.engine_id: EngineId = engine_id
@@ -470,7 +478,9 @@ class NixlBaseConnectorWorker:
478 thread_name_prefix="vllm-nixl-handshake-initiator",
479 )
480 self._ready_requests = queue.Queue[tuple[ReqId, ReqMeta]]()
473 - self._handshake_futures: dict[EngineId, Future[dict[tuple[int, int], str]]] = {}
481 + self._handshake_futures: dict[
482 + EngineId, Future[tuple[dict[tuple[int, int], str], float]]
483 + ] = {}
484 # Protects _handshake_futures and _remote_agents.
485 self._handshake_lock = threading.RLock()
486
@@ -559,7 +569,7 @@ class NixlBaseConnectorWorker:
569 expected_engine_id: str,
570 remote_pp_size: int = 1,
571 notif_agents_only: bool = False,
562 - ) -> dict[tuple[int, int], str]:
572 + ) -> tuple[dict[tuple[int, int], str], float]:
573 """Do a NIXL handshake with a remote instance."""
574
575 # the first time we connect to a remote agent.
@@ -583,6 +593,11 @@ class NixlBaseConnectorWorker:
593 p_remote_ranks = self.transfer_topo.handshake_target_ranks(remote_tp_size)
594 remote_rank_to_agent_name: dict[tuple[int, int], str] = {}
595 path = make_zmq_path("tcp", host, port)
596 + # Clock offset to the peer, estimated from the handshake round-trip.
597 + # Keep the lowest-RTT sample: hop cost is ~uniform across ranks, so a
598 + # higher RTT is just noise that skews the midpoint estimate.
599 + best_rtt = float("inf")
600 + best_offset: float | None = None
601
602 with zmq_ctx(zmq.REQ, path) as sock:
603 for remote_pp_rank, remote_rank in itertools.product(
@@ -595,15 +610,24 @@ class NixlBaseConnectorWorker:
610 remote_rank,
611 )
612
598 - start_time = time.perf_counter()
613 # Send query for the request.
614 msg = msgspec.msgpack.encode(
615 (GET_META_MSG, remote_pp_rank, remote_rank)
616 )
617 # Set receive timeout to 5 seconds to avoid hanging on dead server
618 sock.setsockopt(zmq.RCVTIMEO, 5000) # milliseconds
619 + start_time = time.perf_counter()
620 sock.send(msg)
606 - handshake_bytes = sock.recv()
621 + reply_parts = sock.recv_multipart()
622 + recv_time = time.perf_counter()
623 + assert len(reply_parts) == 2
624 + handshake_bytes = reply_parts[0]
625 +
626 + remote_perf = msgspec.msgpack.decode(reply_parts[1])
627 + rtt = recv_time - start_time
628 + if rtt < best_rtt:
629 + best_rtt = rtt
630 + best_offset = remote_perf - (start_time + recv_time) / 2
631
632 # Decode handshake payload to get compatibility hash
633 handshake_decoder = msgspec.msgpack.Decoder(NixlHandshakePayload)
@@ -681,7 +705,9 @@ class NixlBaseConnectorWorker:
705 )
706 remote_ranks = (remote_pp_rank, remote_rank)
707 remote_rank_to_agent_name[remote_ranks] = remote_agent_name
684 - return remote_rank_to_agent_name
708 +
709 + assert best_offset is not None
710 + return remote_rank_to_agent_name, best_offset
711
712 def _add_notif_only_remote_agent(
713 self, metadata: NixlAgentMetadata, remote_tp_size: int
@@ -818,7 +844,7 @@ class NixlBaseConnectorWorker:
844 tp_size: int,
845 pp_size: int = 1,
846 notif_agents_only: bool = False,
821 - ) -> Future[dict[tuple[int, int], str]] | None:
847 + ) -> Future[tuple[dict[tuple[int, int], str], float]] | None:
848 """
849 Ensure a handshake is in-flight (or already done) for *engine_id*.
850
@@ -846,11 +872,16 @@ class NixlBaseConnectorWorker:
872 )
873 self._handshake_futures[engine_id] = fut
874
849 - def done_callback(f: Future[dict[tuple[int, int], str]], eid=engine_id):
875 + def done_callback(
876 + f: Future[tuple[dict[tuple[int, int], str], float]],
877 + eid=engine_id,
878 + ):
879 with self._handshake_lock:
880 del self._handshake_futures[eid]
881 try:
853 - self._remote_agents[eid] = f.result()
882 + remote_agents, clock_offset = f.result()
883 + self._remote_agents[eid] = remote_agents
884 + self._engine_clock_offset[eid] = clock_offset
885 self._engine_last_active[eid] = time.perf_counter()
886 except Exception as e:
887 self._log_failure(
@@ -2421,6 +2452,8 @@ class NixlBaseConnectorWorker:
2452 if self.transfer_topo is not None:
2453 self.transfer_topo.unregister_remote_engine(engine_id)
2454
2455 + # Drop the cached clock offset; it is re-measured on the next handshake.
2456 + self._engine_clock_offset.pop(engine_id, None)
2457 # Push P-side engines are tracked in _remote_agents but not in
2458 # _engine_last_active (they don't participate in stale eviction), so
2459 # tolerate a missing entry.
vllm/distributed/kv_transfer/kv_connector/v1/nixl/metadata.py
+5 -1
@@ -39,8 +39,10 @@ PUSH_REG_NOTIF_PREFIX = b"PUSH_REG:"
39 # 2: Add remote_request_id to kv_transfer_params
40 # 3: Add physical_blocks_per_logical_kv_block to NixlAgentMetadata
41 # 4: Add KV block lease renewal through heartbeats
42 +# 5: Add remote_blocks_expiry_time to kv_transfer_params + handshake
43 +# clock-sync timestamp
44 #
43 -NIXL_CONNECTOR_VERSION: int = 4
45 +NIXL_CONNECTOR_VERSION: int = 5
46
47
48 @dataclass
@@ -157,6 +159,7 @@ class RemoteMeta:
159 port: int
160 engine_id: str
161 request_id: str
162 + blocks_expiry_time: float | None = None
163
164
165 @dataclass
@@ -225,5 +228,6 @@ class NixlConnectorMetadata(KVConnectorMetadata):
228 request_id=kv_transfer_params["remote_request_id"],
229 host=kv_transfer_params["remote_host"],
230 port=kv_transfer_params["remote_port"],
231 + blocks_expiry_time=kv_transfer_params.get("remote_blocks_expiry_time"),
232 )
233 self.reqs_to_recv[request_id] = req
vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_scheduler.py
+5
@@ -238,6 +238,7 @@ class NixlPullConnectorScheduler(NixlBaseConnectorScheduler):
238 # remove the conditional below
239 delay_free_blocks = any(len(group) > 0 for group in block_ids)
240 remote_num_tokens = 0
241 + blocks_expiry_time = None
242 if delay_free_blocks:
243 # Prefill request on remote. It will be read from D upon completion
244 request_kv_blocks_ttl = self._kv_lease_duration
@@ -254,6 +255,9 @@ class NixlPullConnectorScheduler(NixlBaseConnectorScheduler):
255 self._reqs_need_send[request.request_id] = (
256 time.perf_counter() + request_kv_blocks_ttl
257 )
258 + if is_d_node:
259 + # D blocks expiry time exported for the turn-2 readback.
260 + blocks_expiry_time = self._reqs_need_send[request.request_id]
261 # NOTE HMA will "mark" empty/null blocks in groups with 0s (eg SWA ones),
262 # trimming down after allocating for the whole sequence length. Empty
263 # blocks are always at the start of the list.
@@ -272,4 +276,5 @@ class NixlPullConnectorScheduler(NixlBaseConnectorScheduler):
276 remote_port=self.side_channel_port,
277 tp_size=self.vllm_config.parallel_config.tensor_parallel_size,
278 remote_num_tokens=remote_num_tokens,
279 + remote_blocks_expiry_time=blocks_expiry_time,
280 )
vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py
+26
@@ -25,6 +25,10 @@ if TYPE_CHECKING:
25
26 logger = init_logger(__name__)
27
28 +# Slack (seconds) subtracted from D's exported block-expiry deadline on the turn-2
29 +# readback, absorbing clock-offset error and read latency.
30 +_KV_BLOCKS_EXPIRY_SAFETY_MARGIN = 5.0
31 +
32
33 class NixlPullConnectorWorker(NixlBaseConnectorWorker):
34 """Pull-specific (READ) worker logic."""
@@ -98,12 +102,34 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
102 # requests sit in the D scheduler WAITING queue.
103 self._send_heartbeats(metadata)
104
105 + def _is_turn2_read_expired(self, meta: ReqMeta) -> bool:
106 + """Whether D's cached blocks for this turn-2 readback have (nearly) expired."""
107 + assert meta.remote is not None
108 + blocks_expiry_time = meta.remote.blocks_expiry_time
109 + # Deadline may be absent (router may not forward it) -> read as usual.
110 + if blocks_expiry_time is None or not meta.local_physical_block_ids:
111 + return False
112 + clock_offset = self._engine_clock_offset[meta.remote.engine_id]
113 + deadline = blocks_expiry_time - clock_offset
114 + return time.perf_counter() + _KV_BLOCKS_EXPIRY_SAFETY_MARGIN >= deadline
115 +
116 def _read_blocks_for_req(self, req_id: str, meta: ReqMeta):
117 assert meta.remote is not None and self.transfer_topo is not None
118 engine_id = meta.remote.engine_id
119 # Update last activity from this remote. Mind that cleanup is done on main
120 # thread (this one), so we don't race on this structure.
121 self._engine_last_active[engine_id] = time.perf_counter()
122 +
123 + if self._bidirectional_kv_xfer_enabled and self._is_turn2_read_expired(meta):
124 + logger.warning(
125 + "Declining expired remote read for %s from engine %s.",
126 + req_id,
127 + engine_id,
128 + )
129 + self.xfer_stats.record_kv_expired_req()
130 + self._handle_failed_transfer(req_id, None)
131 + return
132 +
133 plan = self.tp_mappings[engine_id]
134 remote_info = self.transfer_topo.get_engine_info(engine_id)
135 tp_ratio = self.transfer_topo.tp_ratio(remote_info.remote_tp_size)
vllm/distributed/kv_transfer/kv_connector/v1/nixl/push_worker.py
+2 -2
@@ -294,7 +294,7 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
294 return
295
296 def _on_handshake(
297 - f: Future[dict[tuple[int, int], str]],
297 + f: Future[tuple[dict[tuple[int, int], str], float]],
298 rid: str = req_id,
299 rd: dict[str, Any] = reg_data,
300 ) -> None:
@@ -456,7 +456,7 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
456 if decode_engine_id in self._remote_agents:
457 return True
458 try:
459 - remote_agents = self._nixl_handshake(
459 + remote_agents, _ = self._nixl_handshake(
460 decode_host,
461 decode_port,
462 decode_tp_size,