[PD][NixlPush][Bugfix] Fix blocking handshake call on writer thread (#49221)
Signed-off-by: NickLucche <nicolo.lucchesi@mistral.ai> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Nicolò Lucchesi committed
Jul 24, 2026 at 10:41 UTC
589a5b884bcc9a7662aac4a184529aa68da77436
4 files changed
+196
-80
docs/design/nixl_kv_push_connector.md
+28
-14
@@ -47,7 +47,8 @@ sequenceDiagram
47
else only one side present
48
PWriter->>PWriter: stash and wait, self-poll only when blocks unmatched
49
end
50
- PWriter->>PWriter: ensure D handshake (one-time)
50
+ PWriter->>PWriter: _ensure_handshake to D (async; defer WRITE)
51
+ PWriter->>PWriter: handshake callback re-queues on _deferred_push_inbox, wake
52
PWriter->>DWriter: NIXL WRITE direct to D GPU + completion notif
53
54
note over DWorker,DWriter: D side - completion accounting
@@ -100,15 +101,20 @@ event:
101
D, completion notifs after a WRITE, late-arriving ``PUSH_REG``)
102
even when there is no new metadata to act on.
103
3. **Handshake-completion callback** (background handshake executor
103
- thread) — when a deferred D→P handshake finishes successfully, the
104
- future's done-callback re-enqueues the registration onto
105
- ``_reg_send_inbox`` and sets the wake so the corresponding
106
- ``send_notif`` runs on the writer (we never call ``send_notif`` from
107
- the executor thread). On this second pass ``_ensure_handshake``
108
- returns ``None`` (the agent is now connected), so the writer sends
109
- the ``PUSH_REG`` directly. If the handshake *failed*, the callback
110
- fails the request instead of re-enqueuing, so there is no retry
111
- loop.
104
+ thread) — both handshakes run on the executor and never block the
105
+ writer; their done-callbacks re-enqueue the deferred op and set the
106
+ wake, since neither ``send_notif`` nor the NIXL WRITE may run off the
107
+ writer thread:
108
+ * the **D→P** handshake (before sending ``PUSH_REG``) re-enqueues the
109
+ registration onto ``_reg_send_inbox``;
110
+ * the **P→D** handshake (before a WRITE) re-enqueues the matched
111
+ ``(req_id, blocks, reg_data)`` onto ``_deferred_push_inbox``.
112
+
113
+ On this second pass ``_ensure_handshake`` returns ``None`` (the agent
114
+ is now connected), so the writer sends the ``PUSH_REG`` / issues the
115
+ WRITE directly. If a handshake *failed*, the callback fails or drops
116
+ the request instead of re-enqueuing, so there is no retry loop (see
117
+ Failure handling).
118
119
In addition to event-driven wakes, the writer self-polls at
120
``_PUSH_WRITER_POLL_INTERVAL_MS = 1.0`` ms while there are P-side
@@ -227,6 +233,13 @@ Two per-request timers are armed on the scheduler:
233
* **D-side ``send_notif`` failure when shipping the PUSH_REG to P** —
234
identical handling: ``_handle_failed_transfer`` marks the recv as
235
failed.
236
+* **P-side handshake failure (P→D handshake before a WRITE)** — the
237
+ future's done-callback logs ``push_handshake_failed`` and drops the
238
+ request without re-queuing. It deliberately does *not* call
239
+ ``_handle_failed_transfer`` (there is no ``_recving_metadata`` entry to
240
+ invalidate on the producer side, same reasoning as the WRITE-submission
241
+ failure below). P's blocks are reclaimed by the ``_kv_lease_duration``
242
+ lease and D's stale registration by its watchdog.
243
* **P-side WRITE submission failure** — the WRITE handle (if any) is
244
released and ``xfer_stats.record_failed_transfer()`` bumps the
245
failure counter. We deliberately do not call
@@ -246,10 +259,11 @@ existing NIXL connector:
259
class — all subclasses of the existing base classes;
260
* one dedicated background thread per worker;
261
* a few cross-thread queues, each with a single consumer (the writer);
249
- most have one producer, except ``_reg_send_inbox``, which is fed both
250
- by the engine main thread (new registrations) and by the
251
- handshake-completion callback (registrations replayed after their
252
- D→P handshake finishes);
262
+ most have one producer, except the two replay queues fed by both the
263
+ engine main thread and a handshake-completion callback:
264
+ ``_reg_send_inbox`` (registrations replayed after their D→P handshake)
265
+ and ``_deferred_push_inbox`` (matched pushes replayed after their P→D
266
+ handshake);
267
* one new notification type (`PUSH_REG:<msgpack>`).
268
269
Behavior on the engine main thread is otherwise unchanged. The writer
tests/v1/kv_connector/unit/test_nixl_push_connector.py
+109
@@ -25,6 +25,7 @@ import queue
25
import threading
26
import time
27
from collections import defaultdict
28
+from concurrent.futures import Future
29
from typing import Any
30
from unittest.mock import MagicMock, patch
31
@@ -320,6 +321,7 @@ class _StubWriterWorker(NixlPushConnectorWorker):
321
w._finished_blocks_inbox = queue.Queue()
322
w._pending_completion_notifs = queue.Queue()
323
w._evict_finished_inbox = queue.Queue()
324
+ w._deferred_push_inbox = queue.Queue()
325
w._push_writer_wake = threading.Event()
326
w._push_writer_stop = threading.Event()
327
w._push_writer_thread = None
@@ -334,6 +336,7 @@ class _StubWriterWorker(NixlPushConnectorWorker):
336
w.world_size = 1
337
w.engine_id = "test-decode-engine"
338
w._remote_agents = {}
339
+ w._physical_blocks_per_logical_kv_block = 1
340
341
# Track _do_start_push_kv invocations.
342
calls: list[tuple[str, Any, dict[str, Any]]] = []
@@ -506,6 +509,112 @@ class TestPushWriterStartLoadKv:
509
assert w.start_push_calls == []
510
511
512
+# The P→D handshake must run on the base worker's background executor, never
513
+# blocking the writer thread: ``_do_start_push_kv`` defers the WRITE until the
514
+# handshake resolves, then re-drives via ``_deferred_push_inbox``. These call
515
+# the *real* ``_do_start_push_kv`` (the stub overrides it for matching tests).
516
+def _real_do_start_push_kv(w, *args):
517
+ return NixlPushConnectorWorker._do_start_push_kv(w, *args)
518
+
519
+
520
+def test_do_start_push_kv_defers_then_writes_when_handshake_ready():
521
+ """Full happy-path lifecycle: an in-flight handshake defers the WRITE (no
522
+ NIXL op from the writer or the executor callback); once it resolves the
523
+ request is re-queued on ``_deferred_push_inbox`` with the wake set; and on
524
+ re-drive with the handshake ready the WRITE is issued with a correct
525
+ ReqMeta."""
526
+ w = _StubWriterWorker.fresh()
527
+ w._logical_to_kernel_block_ids = lambda x, ratio: x
528
+ xfer_calls: list[dict[str, Any]] = []
529
+ w._xfer_blocks_for_req = lambda **kw: xfer_calls.append(kw)
530
+
531
+ fut: Future = Future()
532
+ w._ensure_handshake = lambda *a, **k: fut
533
+
534
+ rd = _registration_data("req-hs", decode_engine_id="decode-engine")
535
+ _real_do_start_push_kv(w, "req-hs", ([1, 2, 3],), rd)
536
+
537
+ # Handshake pending -> nothing issued, nothing queued, no wake.
538
+ assert xfer_calls == []
539
+ assert w._deferred_push_inbox.qsize() == 0
540
+ assert not w._push_writer_wake.is_set()
541
+
542
+ # Handshake completes: request re-queued for the writer, wake set, but no
543
+ # *direct* WRITE from the callback (wrong thread for NIXL ops).
544
+ fut.set_result(({(0, 0): "agent"}, 0.0))
545
+ assert xfer_calls == []
546
+ assert w._push_writer_wake.is_set()
547
+ rid, blocks, reg = w._deferred_push_inbox.get_nowait()
548
+ assert (rid, blocks, reg) == ("req-hs", ([1, 2, 3],), rd)
549
+
550
+ # Re-drive on the writer with the handshake now ready -> WRITE issued.
551
+ w._ensure_handshake = lambda *a, **k: None
552
+ _real_do_start_push_kv(w, rid, blocks, reg)
553
+ assert len(xfer_calls) == 1
554
+ assert xfer_calls[0]["req_id"] == "req-hs"
555
+ meta = xfer_calls[0]["meta"]
556
+ assert meta.remote is not None
557
+ assert meta.remote.engine_id == "decode-engine"
558
+ # RemoteMeta.request_id is D's request id from the registration.
559
+ assert meta.remote.request_id == "req-hs"
560
+
561
+
562
+def test_do_start_push_kv_drops_request_on_handshake_failure():
563
+ """Handshake raises: the request is dropped (not re-queued, no WRITE) and
564
+ the failure is logged. Blocks are reclaimed by the lease/watchdog, matching
565
+ the old blocking behaviour."""
566
+ w = _StubWriterWorker.fresh()
567
+ w._logical_to_kernel_block_ids = lambda x: x
568
+ xfer_calls: list[dict[str, Any]] = []
569
+ w._xfer_blocks_for_req = lambda **kw: xfer_calls.append(kw)
570
+ failures: list[dict[str, Any]] = []
571
+ w._log_failure = lambda **kw: failures.append(kw)
572
+
573
+ fut: Future = Future()
574
+ w._ensure_handshake = lambda *a, **k: fut
575
+
576
+ _real_do_start_push_kv(w, "req-fail", ([9],), _registration_data("req-fail"))
577
+ fut.set_exception(RuntimeError("handshake boom"))
578
+
579
+ assert w._deferred_push_inbox.qsize() == 0
580
+ assert xfer_calls == []
581
+ assert len(failures) == 1
582
+ assert failures[0]["failure_type"] == "push_handshake_failed"
583
+
584
+
585
+def test_writer_loop_drains_deferred_push_inbox():
586
+ """The writer loop drains ``_deferred_push_inbox`` and re-drives
587
+ ``_do_start_push_kv`` for each entry (event-driven, no polling)."""
588
+ w = _StubWriterWorker.fresh()
589
+ w.nixl_wrapper = MagicMock()
590
+ w.nixl_wrapper.get_new_notifs.return_value = {}
591
+
592
+ processed = threading.Event()
593
+
594
+ def _tracked(rid, blocks, rd):
595
+ w.start_push_calls.append((rid, blocks, rd))
596
+ processed.set()
597
+
598
+ w._do_start_push_kv = _tracked # type: ignore[method-assign]
599
+
600
+ w._deferred_push_inbox.put(
601
+ ("req-retry", ([1, 2],), _registration_data("req-retry"))
602
+ )
603
+ w._push_writer_wake.set()
604
+
605
+ t = threading.Thread(target=w._push_writer_loop, daemon=True)
606
+ t.start()
607
+ try:
608
+ assert processed.wait(timeout=2.0), "writer did not drain deferred inbox"
609
+ finally:
610
+ w._push_writer_stop.set()
611
+ w._push_writer_wake.set()
612
+ t.join(timeout=2)
613
+
614
+ assert len(w.start_push_calls) == 1
615
+ assert w.start_push_calls[0][0] == "req-retry"
616
+
617
+
618
class TestPushWriterNotifs:
619
def test_get_new_notifs_processes_forwarded_completion_notif(self):
620
"""Non-PUSH_REG notifs forwarded by the writer thread are drained
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+1
-1
@@ -792,7 +792,7 @@ class NixlBaseConnectorWorker:
792
failure_type: str,
793
req_id: str | None,
794
msg: str = "",
795
- error: Exception | None = None,
795
+ error: BaseException | None = None,
796
meta: ReqMeta | None = None,
797
**extra_context,
798
):
vllm/distributed/kv_transfer/kv_connector/v1/nixl/push_worker.py
+58
-65
@@ -8,10 +8,13 @@ notifs are forwarded to the engine main thread), sends PUSH_REG via
8
``send_notif``, matches D registrations with P finished blocks, and
9
issues WRITE transfers via ``make_prepped_xfer`` / ``transfer``.
10
11
-The engine main thread feeds the writer through three queues:
11
+The engine main thread feeds the writer through queues:
12
``_reg_send_inbox`` (D-side regs to send), ``_finished_blocks_inbox``
13
(P-side blocks from metadata) and ``_pending_completion_notifs``
14
(non-PUSH_REG notifs forwarded back for HB / completion accounting).
15
+The handshake-completion callback feeds ``_deferred_push_inbox`` with
16
+matched pushes whose P→D handshake has finished so the writer can
17
+(re-)issue the WRITE without ever blocking on the network.
18
19
Wake model: the writer self-polls every
20
``_PUSH_WRITER_POLL_INTERVAL_MS`` only while it has unmatched
@@ -21,8 +24,8 @@ event-driven: the engine main thread sets ``_push_writer_wake`` from
24
``start_load_kv`` (when handing it new work) and from ``get_finished``
25
(so each engine step gives the writer a chance to drain NIXL notifs);
26
the handshake-completion callback sets the same event after a deferred
24
-PUSH_REG send has been queued. When a request's lease expires (the base
25
-worker reports it via ``done_sending``) or the WRITE completes,
27
+PUSH_REG send or a deferred push WRITE has been queued. When a request's
28
+lease expires (the base worker reports it via ``done_sending``) or the WRITE completes,
29
``get_finished`` enqueues an eviction onto ``_evict_finished_inbox`` so
30
the writer drops any leftover ``_push_finished_blocks`` /
31
``_pending_d_registrations`` and stops self-polling.
@@ -109,6 +112,8 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
112
# ``_push_finished_blocks`` so an unmatched entry doesn't keep the
113
# writer busy-polling forever.
114
self._evict_finished_inbox: queue.Queue[str] = queue.Queue()
115
+ # Handshakes that have just completed and are ready for the WRITE on wthread
116
+ self._deferred_push_inbox = queue.Queue[tuple[str, BlockIds, dict[str, Any]]]()
117
118
# Wake signal from engine main thread (start_load_kv / get_finished).
119
# Writer self-polls at _PUSH_WRITER_POLL_INTERVAL_MS while it has
@@ -206,7 +211,15 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
211
break
212
self._send_registration_to_p(rid, rd)
213
209
- # 2. P-side finished blocks; match against pending regs.
214
+ # 2. Deferred P→D pushes whose handshake just completed; do xfer now
215
+ while True:
216
+ try:
217
+ rid, blocks, rd = self._deferred_push_inbox.get_nowait()
218
+ except queue.Empty:
219
+ break
220
+ self._do_start_push_kv(rid, blocks, rd)
221
+
222
+ # 3. P-side finished blocks; match against pending regs.
223
while True:
224
try:
225
rid, blocks = self._finished_blocks_inbox.get_nowait()
@@ -218,7 +231,7 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
231
else:
232
self._push_finished_blocks[rid] = blocks
233
221
- # 2b. Evict finished blocks for requests that have either
234
+ # 3b. Evict finished blocks for requests that have either
235
# completed (WRITE acknowledged) or whose lease expired
236
# without a D registration. Drop pending registrations
237
# for the same reason so we don't leak state.
@@ -230,7 +243,7 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
243
self._push_finished_blocks.pop(rid, None)
244
self._pending_d_registrations.pop(rid, None)
245
233
- # 3. NIXL notifs: route PUSH_REG; forward the rest.
246
+ # 4. NIXL notifs: route PUSH_REG; forward the rest.
247
for notifs in self.nixl_wrapper.get_new_notifs().values():
248
for notif in notifs:
249
if notif.startswith(PUSH_REG_NOTIF_PREFIX):
@@ -384,34 +397,53 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
397
) -> None:
398
"""Start push-based KV transfer from P worker to D node.
399
387
- ``local_block_ids`` are P's *logical* block IDs (from the P
388
- scheduler's metadata). ``registration_data["local_block_ids"]``
389
- are D's *logical* block IDs (from D's scheduler, sent over the
390
- PUSH_REG notif). All conversion to physical block IDs is
391
- deferred to ``_xfer_blocks_for_req`` so each side uses its own
392
- physical-blocks-per-logical ratio (P uses
393
- ``self._physical_blocks_per_logical_kv_block``; D's ratio is
394
- learned during the NIXL handshake)."""
395
- decode_engine_id = registration_data["decode_engine_id"]
396
- remote_block_ids = registration_data["local_block_ids"]
397
- decode_host = registration_data["decode_host"]
398
- decode_port = registration_data["decode_port"]
399
- decode_request_id = registration_data["request_id"]
400
+ The P→D handshake runs on the base worker's background executor.
401
+ If it isn't ready yet we register a completion callback, defer the
402
+ WRITE, and re-drive this request via ``_deferred_push_inbox`` once
403
+ the handshake resolves -- so the writer thread never blocks on the
404
+ network (mirrors ``_send_registration_to_p``).
405
+ """
406
if not local_block_ids:
407
logger.warning("No local blocks to push for request %s", request_id)
408
return
409
404
- if not self._ensure_d_handshake(
410
+ # ``local_block_ids`` are P's logical block IDs; ``remote_block_ids``
411
+ # (D's, from the PUSH_REG notif) are also logical.
412
+ decode_engine_id = registration_data["decode_engine_id"]
413
+ remote_block_ids = registration_data["local_block_ids"]
414
+ decode_request_id = registration_data["request_id"]
415
+
416
+ # Runs on the background executor; defer the WRITE until it's ready.
417
+ fut = self._ensure_handshake(
418
decode_engine_id,
406
- decode_host,
407
- decode_port,
419
+ registration_data["decode_host"],
420
+ registration_data["decode_port"],
421
registration_data["decode_tp_size"],
409
- request_id,
410
- ):
422
+ )
423
+ if fut is not None:
424
+
425
+ def _on_handshake(
426
+ f: Future[tuple[dict[tuple[int, int], str], float]],
427
+ rid: str = request_id,
428
+ blocks: BlockIds = local_block_ids,
429
+ rd: dict[str, Any] = registration_data,
430
+ ) -> None:
431
+ if (e := f.exception()) is not None:
432
+ # The engine reclaims the blocks via the TTL so we dont free here
433
+ self._log_failure(
434
+ failure_type="push_handshake_failed", req_id=rid, error=e
435
+ )
436
+ return
437
+ self._deferred_push_inbox.put((rid, blocks, rd))
438
+ self._push_writer_wake.set()
439
+
440
+ fut.add_done_callback(_on_handshake)
441
return
442
413
- # Both sides are kept in logical form here; ``_xfer_blocks_for_req``
414
- # expands each side using the appropriate ratio.
443
+ # Both sides stay logical here; ``_xfer_blocks_for_req`` converts each
444
+ # to physical with its own physical-blocks-per-logical ratio -- P uses
445
+ # ``self._physical_blocks_per_logical_kv_block``, D's is learned during
446
+ # the NIXL handshake.
447
logical_local = self._as_grouped_block_ids(local_block_ids)
448
logical_remote = self._as_grouped_block_ids(remote_block_ids)
449
physical_local = self._logical_to_kernel_block_ids(
@@ -441,45 +473,6 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
473
elapsed_ms,
474
)
475
444
- def _ensure_d_handshake(
445
- self,
446
- decode_engine_id: str,
447
- decode_host: str,
448
- decode_port: int,
449
- decode_tp_size: int,
450
- request_id: str,
451
- ) -> bool:
452
- """First-time P→D handshake. Blocking call on the writer thread.
453
-
454
- Returns True iff the handshake succeeded (or had already been
455
- completed). Returns False if the handshake raised; the request is
456
- skipped in that case (the engine layer will reschedule or fail it
457
- via the standard lease/timeout path)."""
458
- if decode_engine_id in self._remote_agents:
459
- return True
460
- try:
461
- remote_agents, _ = self._nixl_handshake(
462
- decode_host,
463
- decode_port,
464
- decode_tp_size,
465
- decode_engine_id,
466
- )
467
- except Exception:
468
- logger.exception(
469
- "Failed handshake to D %s for push %s",
470
- decode_engine_id,
471
- request_id,
472
- )
473
- return False
474
- with self._handshake_lock:
475
- self._remote_agents[decode_engine_id] = remote_agents
476
- logger.info(
477
- "Push handshake to D %s done (%d agents)",
478
- decode_engine_id,
479
- len(remote_agents),
480
- )
481
- return True
482
-
476
@staticmethod
477
def _as_grouped_block_ids(block_ids: BlockIds) -> BlockIds:
478
"""Normalise a sequence of block IDs to a tuple-of-groups shape.