From b1401e0aa7eb7803c38e4d2d306cb2a16c8828a2 Mon Sep 17 00:00:00 2001 From: Maroon Ayoub Date: Mon, 5 Oct 2026 12:36:13 +0300 Subject: [PATCH] [Bugfix][NIXL] Count heartbeats as remote engine activity (#59873) Signed-off-by: Maroon Ayoub --- .../kv_connector/unit/test_nixl_connector.py | 46 +++++++++++++++++++ .../kv_connector/v1/nixl/base_worker.py | 8 +++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/tests/v1/kv_connector/unit/test_nixl_connector.py b/tests/v1/kv_connector/unit/test_nixl_connector.py index 07ae290219..d7ab2da552 100644 --- a/tests/v1/kv_connector/unit/test_nixl_connector.py +++ b/tests/v1/kv_connector/unit/test_nixl_connector.py @@ -50,6 +50,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.nixl import ( NixlKVConnectorStats, ) from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import ( + HeartbeatInfo, RemoteMeta, ReqMeta, compute_nixl_compatibility_hash, @@ -2700,6 +2701,51 @@ def test_engine_with_inflight_transfer_is_not_evicted(default_vllm_config, dist_ assert mock_rem.call_count == 2 +@patch( + "vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper", + FakeNixlWrapper, +) +def test_heartbeated_engine_is_not_evicted(default_vllm_config, dist_init, monkeypatch): + """Heartbeats keep a remote engine's NIXL state alive. + + Requests waiting in the D scheduler issue no reads, but their engine is + heartbeated through its remote agents. If the TTL expires anyway, the + next heartbeat evicts the engine and starts a new handshake, which loads + every remote agent again. + """ + from vllm.distributed.kv_transfer.kv_connector.v1.nixl import base_worker + + worker, engine_id = _setup_worker_with_remote_engine(engine_ttl=10.0) + nixl_wrapper = worker.nixl_wrapper + + now = {"t": 1000.0} + monkeypatch.setattr(base_worker.time, "perf_counter", lambda: now["t"]) + worker._engine_last_active[engine_id] = now["t"] + + metadata = NixlConnectorMetadata() + metadata.heartbeat_by_engine = { + engine_id: HeartbeatInfo( + req_ids={"remote-req"}, host="localhost", port=1234, tp_size=2 + ) + } + + with ( + patch.object(nixl_wrapper, "send_notif") as mock_send, + patch.object(nixl_wrapper, "remove_remote_agent") as mock_rem, + patch.object(worker, "_handshake_initiation_executor") as mock_executor, + ): + # One heartbeat every 5 s for twice the TTL, and no reads. + for _ in range(4): + now["t"] += 5.0 + worker._send_heartbeats(metadata) + + assert engine_id in worker._remote_agents + mock_rem.assert_not_called() + mock_executor.submit.assert_not_called() + # Every heartbeat reached both remote agents. + assert mock_send.call_count == 8 + + @patch( "vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper", FakeNixlWrapper, diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py index 0eaa1aadf3..dffd70643e 100644 --- a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py +++ b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py @@ -3236,6 +3236,9 @@ class NixlBaseConnectorWorker: ): continue # handshake is still pending + # Refresh the TTL: a heartbeat means the router still paired this P-D, + # so requests are still waiting on this engine + self._engine_last_active[engine_id] = time.perf_counter() # Build the heartbeat message: "HB:req1,req2,..." hb_msg = ("HB:" + ",".join(hb_info.req_ids)).encode() for agent_name in self._remote_agents[engine_id].values(): @@ -3586,10 +3589,13 @@ class NixlBaseConnectorWorker: prevents us from using background threads, though memory usage is not guaranteed to be "optimal" until a new handshake is performed. - Engines with active transfers or pending handshakes cannot be stale: + Engines with active transfers, heartbeats or pending handshakes cannot + be stale: - Reads stamp _engine_last_active when they are issued, and engines a transfer is still reading from are held back explicitly, since the stamp is not refreshed while the read runs. + - Heartbeats stamp it too, so an engine stays registered while this + instance holds requests waiting on it. - Pending handshakes don't have an _engine_last_active entry yet """ # NOTE (NickLucche): This does NOT currently prevent OOMing if a huge number