[Bugfix] Make metadata send non-blocking in GroupCoordinator.isend_tensor_dict (#49274)

Signed-off-by: z00897177 <zhouxinyi6@huawei.com>

AI-Infra Rookie committed Aug 31, 2026 at 07:51 UTC c92b29a1d40644da710209f862b1be0ebd5c2e74
3 files changed +282 -13
tests/distributed/test_comm_ops.py
+136
@@ -5,6 +5,7 @@
5 Run `pytest tests/distributed/test_comm_ops.py`.
6 """
7
8 +from collections import deque
9 from collections.abc import Callable
10 from typing import Any
11 from unittest.mock import Mock
@@ -208,9 +209,14 @@ def send_recv_tensor_dict_test_worker(
209 class _DummyWork:
210 def __init__(self) -> None:
211 self.wait_calls = 0
212 + self.completed = False
213
214 def wait(self) -> None:
215 self.wait_calls += 1
216 + self.completed = True
217 +
218 + def is_completed(self) -> bool:
219 + return self.completed
220
221
222 class _DummyAllGatherGroup:
@@ -235,6 +241,7 @@ def _make_group_for_unit_test(
241 g.use_cpu_custom_send_recv = False
242 g.device_group = None
243 g.cpu_group = None
244 + g._pending_isends = deque()
245 return g
246
247
@@ -402,6 +409,135 @@ def test_flashinfer_all_reduce_precedes_nccl(monkeypatch: pytest.MonkeyPatch) ->
409 nccl_selector.assert_not_called()
410
411
412 +def test_isend_object_posts_size_then_object_and_releases_on_wait(
413 + monkeypatch: pytest.MonkeyPatch,
414 +) -> None:
415 + posted: list[tuple[torch.Tensor, _DummyWork]] = []
416 +
417 + def fake_isend(t: torch.Tensor, *args: Any, **kwargs: Any) -> _DummyWork:
418 + w = _DummyWork()
419 + posted.append((t, w))
420 + return w
421 +
422 + monkeypatch.setattr(torch.distributed, "isend", fake_isend)
423 +
424 + g = _make_group_for_unit_test(rank_in_group=0, world_size=2)
425 + handle = g.isend_object({"k": [1, 2, 3]}, dst=1)
426 +
427 + # two sends, in size-then-object order (preserves gloo FIFO).
428 + assert len(posted) == 2
429 + assert posted[0][0].dtype == torch.long
430 + assert posted[0][0].shape == torch.Size([1])
431 + assert posted[0][0].item() == posted[1][0].numel()
432 + assert posted[1][0].dtype == torch.uint8
433 +
434 + # retain holds both source tensors until wait.
435 + assert handle._retained == (posted[0][0], posted[1][0])
436 +
437 + handle.wait()
438 +
439 + # both underlying works drained, retain dropped.
440 + assert all(w.wait_calls == 1 for _, w in posted)
441 + assert handle._retained == ()
442 +
443 + # wait is idempotent: gloo Work.wait() is single-shot for p2p sends (a
444 + # second wait blocks forever), and both the lazy FIFO reap in
445 + # ``_reap_completed_isends`` and any explicit isend caller may wait the
446 + # same metadata handle.
447 + handle.wait()
448 + assert all(w.wait_calls == 1 for _, w in posted)
449 + assert handle._retained == ()
450 +
451 +
452 +def test_isend_tensor_dict_includes_metadata_handle(
453 + monkeypatch: pytest.MonkeyPatch,
454 +) -> None:
455 + posted: list[torch.Tensor] = []
456 +
457 + def fake_isend(t: torch.Tensor, *args: Any, **kwargs: Any) -> _DummyWork:
458 + posted.append(t)
459 + return _DummyWork()
460 +
461 + monkeypatch.setattr(torch.distributed, "isend", fake_isend)
462 +
463 + g = _make_group_for_unit_test(rank_in_group=0, world_size=2)
464 + td = {"a": torch.arange(4, dtype=torch.float32, device="cpu")}
465 + handles = g.isend_tensor_dict(td, dst=1)
466 +
467 + # size + object (metadata) + one tensor send.
468 + assert len(posted) == 3
469 + assert posted[0].dtype == torch.long
470 + assert posted[1].dtype == torch.uint8
471 + assert posted[2].dtype == torch.float32
472 +
473 + # composite metadata handle + one tensor handle.
474 + assert len(handles) == 2
475 +
476 + for handle in handles:
477 + handle.wait()
478 + assert handles[0]._retained == ()
479 +
480 +
481 +def test_isend_tensor_dict_self_retains_for_fire_and_forget(
482 + monkeypatch: pytest.MonkeyPatch,
483 +) -> None:
484 + works: list[_DummyWork] = []
485 +
486 + def fake_isend(t: torch.Tensor, *args: Any, **kwargs: Any) -> _DummyWork:
487 + w = _DummyWork()
488 + works.append(w)
489 + return w
490 +
491 + monkeypatch.setattr(torch.distributed, "isend", fake_isend)
492 +
493 + g = _make_group_for_unit_test(rank_in_group=0, world_size=2)
494 + td = {"a": torch.arange(4, dtype=torch.float32, device="cpu")}
495 +
496 + # fire-and-forget: the returned handles are dropped by the caller, so the
497 + # group must self-retain the handles and the source tensor.
498 + g.isend_tensor_dict(td, dst=1)
499 + assert len(g._pending_isends) == 1
500 + handles0, tensors0 = g._pending_isends[0]
501 + assert len(handles0) == 2 # metadata composite + one tensor send
502 + assert tensors0 == [td["a"]]
503 +
504 + # the first send is still in flight, so the next call's reap keeps it.
505 + g.isend_tensor_dict(td, dst=1)
506 + assert len(g._pending_isends) == 2
507 +
508 + # metadata-only send (no tensors) is dropped best-effort on the next
509 + # reap, since it has no reliable completion signal.
510 + g.isend_tensor_dict({"meta": "no-tensors"}, dst=1)
511 + assert len(g._pending_isends) == 3
512 +
513 + # complete every posted work; the next call reaps all three old entries
514 + # (the metadata-only one unconditionally) and keeps only its own.
515 + for w in works:
516 + w.completed = True
517 + g.isend_tensor_dict(td, dst=1)
518 + assert len(g._pending_isends) == 1
519 + # reaped entries had their metadata handles waited as a backstop.
520 + assert handles0[0]._retained == ()
521 +
522 +
523 +def test_send_tensor_dict_sync_path_does_not_self_retain(
524 + monkeypatch: pytest.MonkeyPatch,
525 +) -> None:
526 + def fake_isend(t: torch.Tensor, *args: Any, **kwargs: Any) -> _DummyWork:
527 + return _DummyWork()
528 +
529 + monkeypatch.setattr(torch.distributed, "isend", fake_isend)
530 + monkeypatch.setattr(torch.distributed, "is_initialized", lambda: True)
531 + monkeypatch.setattr(GroupCoordinator, "send_object", lambda self, obj, dst: None)
532 +
533 + g = _make_group_for_unit_test(rank_in_group=0, world_size=2)
534 + td = {"a": torch.arange(4, dtype=torch.float32, device="cpu")}
535 + g.send_tensor_dict(td, dst=1)
536 +
537 + # the sync path waited every handle, so no retention entry may leak.
538 + assert len(g._pending_isends) == 0
539 +
540 +
541 def test_async_intermediate_tensors_lazy_wait() -> None:
542 work = _DummyWork()
543 post_calls = {"n": 0}
vllm/distributed/parallel_state.py
+140 -3
@@ -27,7 +27,7 @@ import contextlib
27 import gc
28 import pickle
29 import weakref
30 -from collections import namedtuple
30 +from collections import deque, namedtuple
31 from collections.abc import Callable
32 from contextlib import contextmanager, nullcontext
33 from dataclasses import dataclass
@@ -78,6 +78,37 @@ class Handle(Protocol):
78 def wait(self) -> None: ...
79
80
81 +class _RetainedHandle:
82 + """Handle that retains source tensors until all posted work completes.
83 +
84 + Used by ``isend_object`` to keep the serialized CPU tensors alive while
85 + gloo copies them in the background; ``wait`` drops the refs once drained.
86 +
87 + ``wait`` must be idempotent: gloo ``Work.wait()`` is single-shot for
88 + point-to-point operations — calling it a second time on an already
89 + completed send blocks forever (verified empirically). Both the worker's
90 + top-of-step drain of ``_pp_send_work`` and ``_reap_completed_isends``
91 + may wait the same entry's metadata handle, so the second caller must
92 + not re-wait the underlying works.
93 + """
94 +
95 + def __init__(self, works: list[Any], retained: tuple[torch.Tensor, ...]) -> None:
96 + self._works = works
97 + self._retained = retained
98 + self._waited = False
99 +
100 + def is_completed(self) -> bool:
101 + return all(w.is_completed() for w in self._works)
102 +
103 + def wait(self) -> None:
104 + if self._waited:
105 + return
106 + for w in self._works:
107 + w.wait()
108 + self._waited = True
109 + self._retained = ()
110 +
111 +
112 def _split_tensor_dict(
113 tensor_dict: dict[str, torch.Tensor | Any],
114 ) -> tuple[list[tuple[str, Any]], list[torch.Tensor]]:
@@ -532,6 +563,15 @@ class GroupCoordinator:
563 and getattr(self.device_communicator, "supports_tensor_dict", False)
564 )
565
566 + # FIFO of in-flight ``isend_tensor_dict`` calls: one entry per call,
567 + # (handles, sent source tensors). Self-retention keeps the send
568 + # buffers alive for fire-and-forget callers that drop the returned
569 + # handles; entries are reaped lazily on the next ``isend_tensor_dict``
570 + # (see ``_reap_completed_isends``). Lazily created instead of plain
571 + # attribute init because some subclasses replicate ``__init__``
572 + # without calling ``super().__init__``.
573 + self._pending_isends: deque[tuple[list[Handle], list[torch.Tensor]]] = deque()
574 +
575 def make_sibling_device_group(self, group_desc: str | None = None) -> ProcessGroup:
576 """Create a new device-side ProcessGroup with the same per-rank membership
577 as this coordinator's `device_group`, but backed by a distinct communicator.
@@ -882,6 +922,33 @@ class GroupCoordinator:
922
923 return obj
924
925 + def isend_object(self, obj: Any, dst: int) -> Handle:
926 + """Non-blocking ``send_object``: isend size + pickled object on the
927 + CPU group. Returns a handle that retains the serialized source
928 + tensors until ``wait`` drains both sends.
929 + """
930 + assert dst < self.world_size, f"Invalid dst rank ({dst})"
931 + assert dst != self.rank_in_group, (
932 + "Invalid destination rank. Destination rank is the same "
933 + "as the current rank."
934 + )
935 +
936 + object_tensor = torch.frombuffer(pickle.dumps(obj), dtype=torch.uint8)
937 + size_tensor = torch.tensor(
938 + [object_tensor.numel()], dtype=torch.long, device="cpu"
939 + )
940 +
941 + retained = (size_tensor, object_tensor)
942 + works: list[Any] = []
943 + for tensor in retained:
944 + work = torch.distributed.isend(
945 + tensor, dst=self.ranks[dst], group=self.cpu_group
946 + )
947 + if work is not None:
948 + works.append(work)
949 +
950 + return _RetainedHandle(works, retained)
951 +
952 def broadcast_tensor_dict(
953 self,
954 tensor_dict: dict[str, torch.Tensor | Any] | None = None,
@@ -1014,8 +1081,51 @@ class GroupCoordinator:
1081 )
1082 for handle in handles:
1083 handle.wait()
1084 + # The sync path waited every handle, so the self-retention entry
1085 + # appended by ``isend_tensor_dict`` is already drained; drop it
1086 + # instead of leaking the tensor refs until the next async send.
1087 + pending = getattr(self, "_pending_isends", None)
1088 + if pending and pending[-1][0] is handles:
1089 + pending.pop()
1090 return None
1091
1092 + def _reap_completed_isends(self) -> None:
1093 + """Lazily drop self-retained ``isend_tensor_dict`` entries that have
1094 + completed, oldest first (FIFO).
1095 +
1096 + gloo ``Work.is_completed()`` is unreliable (it can report completion
1097 + before the background copy of the source buffer finishes), so the
1098 + metadata handle (``handles[0]``, a gloo-backed ``_RetainedHandle``) is
1099 + never used as the completion gate. Instead we gate on the tensor-send
1100 + handles (``handles[1:]``), which run on the device backend where
1101 + ``is_completed()`` is reliable. Once all tensor sends are done the
1102 + peer must already have posted the matching tensor recvs — and since
1103 + the receiver ingests metadata before tensors, the gloo metadata send
1104 + is then guaranteed complete, so ``wait()`` on it is ~instant and only
1105 + serves to drop the retained refs.
1106 +
1107 + Metadata-only entries (``handles[1:]`` empty, e.g. an empty
1108 + ``IntermediateTensors``) have no reliable completion signal; drop them
1109 + best-effort without blocking on the gloo metadata wait.
1110 + """
1111 + pending = getattr(self, "_pending_isends", None)
1112 + if pending is None:
1113 + # Subclasses that replicate __init__ without calling
1114 + # super().__init__ may not have the attribute yet.
1115 + self._pending_isends = pending = deque()
1116 + while pending:
1117 + handles, _ = pending[0]
1118 + tensor_handles = handles[1:]
1119 + if not tensor_handles:
1120 + pending.popleft()
1121 + continue
1122 + if not all(h.is_completed() for h in tensor_handles):
1123 + # The oldest send is still in flight; FIFO ordering means the
1124 + # rest are no further along, so stop here.
1125 + break
1126 + handles[0].wait()
1127 + pending.popleft()
1128 +
1129 def isend_tensor_dict(
1130 self,
1131 tensor_dict: dict[str, torch.Tensor | Any],
@@ -1023,6 +1133,16 @@ class GroupCoordinator:
1133 all_gather_group: "GroupCoordinator | None" = None,
1134 all_gather_tensors: dict[str, bool] | None = None,
1135 ) -> list[Handle]:
1136 + """Send the input tensor dictionary asynchronously.
1137 +
1138 + The returned handles are additionally self-retained in
1139 + ``self._pending_isends`` (together with the sent source tensors) and
1140 + reaped lazily on subsequent calls, so fire-and-forget callers that
1141 + drop the handles cannot free or overwrite the send buffers while the
1142 + sends are still in flight. Callers that do keep the handles may
1143 + ``wait()`` them as before; the retention entry is dropped on reap
1144 + either way.
1145 + """
1146 if self.world_size <= 1:
1147 return []
1148
@@ -1048,12 +1168,16 @@ class GroupCoordinator:
1168 metadata_group = self.cpu_group
1169
1170 metadata_list, tensor_list = _split_tensor_dict(tensor_dict)
1051 - self.send_object(metadata_list, dst=dst)
1171
1172 tensor_keys = [k for k, v in tensor_dict.items() if isinstance(v, torch.Tensor)]
1173 assert len(tensor_keys) == len(tensor_list)
1174
1056 - handles: list[Handle] = []
1175 + # Reap completed sends before posting new ones, bounding the
1176 + # self-retention FIFO.
1177 + self._reap_completed_isends()
1178 + handles: list[Handle] = [self.isend_object(metadata_list, dst=dst)]
1179 +
1180 + sent_tensors: list[torch.Tensor] = []
1181 for key, tensor in zip(tensor_keys, tensor_list):
1182 if tensor.numel() == 0:
1183 continue
@@ -1070,6 +1194,19 @@ class GroupCoordinator:
1194 if tensor.is_cuda:
1195 tensor.record_stream(torch.cuda.current_stream(tensor.device))
1196 handles.append(handle)
1197 + sent_tensors.append(tensor)
1198 +
1199 + # Self-retain for fire-and-forget callers: keep the handles and
1200 + # the source tensors alive until the sends complete (reaped
1201 + # lazily by ``_reap_completed_isends``). Without this, dropping
1202 + # the returned handles would GC the ``_RetainedHandle``'s
1203 + # serialized metadata buffers mid-send, and non-CUDA devices
1204 + # have no ``record_stream`` equivalent protecting the tensor
1205 + # sources.
1206 + pending = getattr(self, "_pending_isends", None)
1207 + if pending is None:
1208 + self._pending_isends = pending = deque()
1209 + pending.append((handles, sent_tensors))
1210
1211 return handles
1212
vllm/v1/worker/gpu_worker.py
+6 -10
@@ -216,8 +216,6 @@ class Worker(WorkerBase):
216 self.profiler_config = vllm_config.profiler_config
217
218 self.use_v2_model_runner = vllm_config.use_v2_model_runner
219 - # pending non-blocking PP send work from the previous iteration
220 - self._pp_send_work: list[Handle] = []
219
220 # Resolved lazily on first sleep/wake; persists worker-process state.
221 self._sleep_mode_backend: SleepModeBackend | None = None
@@ -1108,12 +1106,6 @@ class Worker(WorkerBase):
1106 def execute_model(
1107 self, scheduler_output: "SchedulerOutput"
1108 ) -> ModelRunnerOutput | AsyncModelRunnerOutput | None:
1111 - # ensure any previous non-blocking PP sends are complete
1112 - if self._pp_send_work:
1113 - for handle in self._pp_send_work:
1114 - handle.wait()
1115 - self._pp_send_work = []
1116 -
1109 intermediate_tensors = None
1110 forward_pass = scheduler_output.total_num_scheduled_tokens > 0
1111 num_scheduled_tokens = scheduler_output.total_num_scheduled_tokens
@@ -1186,8 +1178,12 @@ class Worker(WorkerBase):
1178 and not get_pp_group().is_last_rank
1179 )
1180
1189 - # launch non-blocking send of intermediate tensors
1190 - self._pp_send_work = get_pp_group().isend_tensor_dict(
1181 + # launch non-blocking send of intermediate tensors; the
1182 + # GroupCoordinator self-retains the source tensors and the
1183 + # metadata handle in ``_pending_isends`` and reaps them lazily on
1184 + # the next ``isend_tensor_dict(is_async=True)`` call, so this
1185 + # step returns without waiting for the receiver to drain.
1186 + get_pp_group().isend_tensor_dict(
1187 output.tensors,
1188 all_gather_group=get_tp_group(),
1189 all_gather_tensors=all_gather_tensors,