@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 3d2a026fd0
Fork 0

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 8, 08:45 UTC

[Feature] Pipeline Parallel Async send/recv, 2.9% E2E throughput improvement (#33368)

Signed-off-by: yewentao256 <zhyanwentao@126.com> Signed-off-by: Wentao Ye <44945378+yewentao256@users.noreply.github.com> Co-authored-by: Nick Hill <nhill@redhat.com>

Wentao Ye committed Feb 13, 2026 at 03:38 UTC 3d2a026fd0317204752d7933408aff19aaa80cfd
3 files changed +298 -81
tests/distributed/test_comm_ops.py
+107
@@ -19,6 +19,8 @@ from vllm.distributed import (
19 tensor_model_parallel_all_reduce,
20 tensor_model_parallel_reduce_scatter,
21 )
22 +from vllm.distributed.parallel_state import GroupCoordinator, TensorMetadata
23 +from vllm.v1.worker.gpu_worker import AsyncIntermediateTensors
24
25 from ..utils import (
26 init_test_distributed_environment,
@@ -200,6 +202,111 @@ def send_recv_tensor_dict_test_worker(
202 torch.testing.assert_close(recv_dict["f"], test_dict["f"])
203
204
205 +class _DummyWork:
206 + def __init__(self) -> None:
207 + self.wait_calls = 0
208 +
209 + def wait(self) -> None:
210 + self.wait_calls += 1
211 +
212 +
213 +class _DummyAllGatherGroup:
214 + def __init__(self, world_size: int, rank_in_group: int) -> None:
215 + self.world_size = world_size
216 + self.rank_in_group = rank_in_group
217 +
218 + def all_gather(self, t: torch.Tensor, dim: int = 0) -> torch.Tensor:
219 + # duplicate local slice across ranks.
220 + assert dim == 0
221 + return torch.cat([t for _ in range(self.world_size)], dim=0)
222 +
223 +
224 +def _make_group_for_unit_test(
225 + rank_in_group: int = 0, world_size: int = 2
226 +) -> GroupCoordinator:
227 + # avoid running GroupCoordinator.__init__ (it wires up real process groups).
228 + g = GroupCoordinator.__new__(GroupCoordinator)
229 + g.world_size = world_size
230 + g.rank_in_group = rank_in_group
231 + g.ranks = list(range(world_size))
232 + g.use_cpu_custom_send_recv = False
233 + g.device_group = None
234 + g.cpu_group = None
235 + return g
236 +
237 +
238 +def test_irecv_tensor_dict_send_allgather_postprocess_binds_keys(
239 + monkeypatch: pytest.MonkeyPatch,
240 +) -> None:
241 + def fake_irecv(t: torch.Tensor, *args: Any, **kwargs: Any) -> _DummyWork:
242 + t.fill_(1)
243 + return _DummyWork()
244 +
245 + monkeypatch.setattr(torch.distributed, "is_initialized", lambda: True)
246 + monkeypatch.setattr(torch.distributed, "irecv", fake_irecv)
247 +
248 + g = _make_group_for_unit_test(rank_in_group=0, world_size=2)
249 + # 2 tensors so we can catch late-binding bugs in postprocess closures.
250 + metadata_list = [
251 + ("a", TensorMetadata("cpu", torch.int32, torch.Size([4]))),
252 + ("b", TensorMetadata("cpu", torch.int32, torch.Size([4]))),
253 + ]
254 + g.recv_object = lambda src=None: metadata_list # type: ignore[method-assign]
255 +
256 + ag = _DummyAllGatherGroup(world_size=2, rank_in_group=0)
257 + td, handles, postprocess = g.irecv_tensor_dict(all_gather_group=ag)
258 +
259 + assert td is not None
260 + assert len(handles) == 2
261 + assert len(postprocess) == 2
262 +
263 + # before postprocess, dict holds the TP slice (shape 2).
264 + assert td["a"].shape == torch.Size([2])
265 + assert td["b"].shape == torch.Size([2])
266 +
267 + # simulate worker-side "defer wait": wait + postprocess later.
268 + for handle in handles:
269 + handle.wait()
270 + for fn in postprocess:
271 + fn()
272 +
273 + # after postprocess, dict values are reconstructed to full shape (shape 4),
274 + # and each key should be updated independently
275 + assert td["a"].shape == torch.Size([4])
276 + assert td["b"].shape == torch.Size([4])
277 + torch.testing.assert_close(td["a"], torch.ones(4, dtype=torch.int32))
278 + torch.testing.assert_close(td["b"], torch.ones(4, dtype=torch.int32))
279 +
280 +
281 +def test_async_intermediate_tensors_lazy_wait() -> None:
282 + work = _DummyWork()
283 + post_calls = {"n": 0}
284 +
285 + def post() -> None:
286 + post_calls["n"] += 1
287 +
288 + it = AsyncIntermediateTensors(
289 + {"x": torch.tensor([1])},
290 + comm_handles=[work],
291 + comm_postprocess=[post],
292 + )
293 +
294 + # accessing non-tensor attributes should not trigger wait.
295 + assert it.kv_connector_output is None
296 + assert work.wait_calls == 0
297 + assert post_calls["n"] == 0
298 +
299 + # first access of `.tensors` triggers wait + postprocess.
300 + _ = it.tensors
301 + assert work.wait_calls == 1
302 + assert post_calls["n"] == 1
303 +
304 + # subsequent access should not re-wait.
305 + _ = it.tensors
306 + assert work.wait_calls == 1
307 + assert post_calls["n"] == 1
308 +
309 +
310 @ray.remote(num_gpus=1, max_calls=1)
311 def send_recv_test_worker(
312 monkeypatch: pytest.MonkeyPatch,
vllm/distributed/parallel_state.py
+137 -76
@@ -33,7 +33,7 @@ from contextlib import contextmanager, nullcontext
33 from dataclasses import dataclass
34 from datetime import timedelta
35 from multiprocessing import shared_memory
36 -from typing import Any
36 +from typing import Any, Protocol
37 from unittest.mock import patch
38
39 import torch
@@ -64,6 +64,14 @@ class GraphCaptureContext:
64 TensorMetadata = namedtuple("TensorMetadata", ["device", "dtype", "size"])
65
66
67 +class Handle(Protocol):
68 + """Minimal async work handle used by P2P send/recv methods."""
69 +
70 + def is_completed(self) -> bool: ...
71 +
72 + def wait(self) -> None: ...
73 +
74 +
75 def _split_tensor_dict(
76 tensor_dict: dict[str, torch.Tensor | Any],
77 ) -> tuple[list[tuple[str, Any]], list[torch.Tensor]]:
@@ -780,6 +788,20 @@ class GroupCoordinator:
788 async_handle.wait()
789 return tensor_dict
790
791 + def _should_use_all_gather(
792 + self,
793 + key: str,
794 + numel: int,
795 + all_gather_group: "GroupCoordinator | None",
796 + all_gather_tensors: dict[str, bool] | None,
797 + ) -> bool:
798 + if all_gather_group is None:
799 + return False
800 + use_all_gather = numel % all_gather_group.world_size == 0
801 + if all_gather_tensors is not None:
802 + use_all_gather = all_gather_tensors.get(key, use_all_gather)
803 + return use_all_gather
804 +
805 def send_tensor_dict(
806 self,
807 tensor_dict: dict[str, torch.Tensor | Any],
@@ -808,6 +830,35 @@ class GroupCoordinator:
830 # Bypass the function if we are using only 1 GPU.
831 if not torch.distributed.is_initialized() or self.world_size == 1:
832 return tensor_dict
833 + handles = self.isend_tensor_dict(
834 + tensor_dict,
835 + dst=dst,
836 + all_gather_group=all_gather_group,
837 + all_gather_tensors=all_gather_tensors,
838 + )
839 + for handle in handles:
840 + handle.wait()
841 + return None
842 +
843 + def isend_tensor_dict(
844 + self,
845 + tensor_dict: dict[str, torch.Tensor | Any],
846 + dst: int | None = None,
847 + all_gather_group: "GroupCoordinator | None" = None,
848 + all_gather_tensors: dict[str, bool] | None = None,
849 + ) -> list[Handle]:
850 + if self.world_size <= 1:
851 + return []
852 +
853 + if self.use_cpu_custom_send_recv:
854 + if self.device_communicator is None:
855 + raise ValueError("No device communicator found")
856 + # custom device communicator path is synchronous
857 + self.device_communicator.send_tensor_dict( # type: ignore
858 + tensor_dict, dst
859 + )
860 + return []
861 +
862 all_gather_size = 1 if all_gather_group is None else all_gather_group.world_size
863 all_gather_rank = (
864 0 if all_gather_group is None else all_gather_group.rank_in_group
@@ -820,53 +871,31 @@ class GroupCoordinator:
871 dst = (self.rank_in_group + 1) % self.world_size
872 assert dst < self.world_size, f"Invalid dst rank ({dst})"
873
823 - if self.use_cpu_custom_send_recv:
824 - if self.device_communicator is None:
825 - raise ValueError("No device communicator found")
826 - self.device_communicator.send_tensor_dict( # type: ignore
827 - tensor_dict, dst
828 - )
829 - return None
830 -
831 - metadata_list: list[tuple[Any, Any]] = []
832 - assert isinstance(tensor_dict, dict), (
833 - f"Expecting a dictionary, got {type(tensor_dict)}"
834 - )
874 metadata_list, tensor_list = _split_tensor_dict(tensor_dict)
836 - # `metadata_list` lives in CPU memory.
837 - # `send_object_list` has serialization & deserialization,
838 - # all happening on CPU. Therefore, we can use the CPU group.
875 self.send_object(metadata_list, dst=dst)
876
877 tensor_keys = [k for k, v in tensor_dict.items() if isinstance(v, torch.Tensor)]
878 assert len(tensor_keys) == len(tensor_list)
879
880 + handles: list[Handle] = []
881 for key, tensor in zip(tensor_keys, tensor_list):
882 if tensor.numel() == 0:
846 - # Skip sending empty tensors.
883 continue
884
849 - # send-allgather: send only a slice, then do allgather.
850 - use_all_gather = (
851 - all_gather_group is not None and tensor.numel() % all_gather_size == 0
852 - )
853 - use_all_gather = (
854 - all_gather_tensors.get(key, use_all_gather)
855 - if all_gather_tensors
856 - else use_all_gather
857 - )
858 - if use_all_gather:
885 + if self._should_use_all_gather(
886 + key, tensor.numel(), all_gather_group, all_gather_tensors
887 + ):
888 tensor = tensor.reshape(all_gather_size, -1)[all_gather_rank]
889
861 - if tensor.is_cpu:
862 - # use metadata_group for CPU tensors
863 - torch.distributed.send(
864 - tensor, dst=self.ranks[dst], group=metadata_group
865 - )
866 - else:
867 - # use group for GPU tensors
868 - torch.distributed.send(tensor, dst=self.ranks[dst], group=group)
869 - return None
890 + comm_group = metadata_group if tensor.is_cpu else group
891 + handle = torch.distributed.isend(
892 + tensor, dst=self.ranks[dst], group=comm_group
893 + )
894 + if tensor.is_cuda:
895 + tensor.record_stream(torch.cuda.current_stream(tensor.device))
896 + handles.append(handle)
897 +
898 + return handles
899
900 def recv_tensor_dict(
901 self,
@@ -895,6 +924,38 @@ class GroupCoordinator:
924 # Bypass the function if we are using only 1 GPU.
925 if not torch.distributed.is_initialized() or self.world_size == 1:
926 return None
927 + tensor_dict, handles, postprocess = self.irecv_tensor_dict(
928 + src=src,
929 + all_gather_group=all_gather_group,
930 + all_gather_tensors=all_gather_tensors,
931 + )
932 + for handle in handles:
933 + handle.wait()
934 + for fn in postprocess:
935 + fn()
936 + return tensor_dict
937 +
938 + def irecv_tensor_dict(
939 + self,
940 + src: int | None = None,
941 + all_gather_group: "GroupCoordinator | None" = None,
942 + all_gather_tensors: dict[str, bool] | None = None,
943 + ) -> tuple[
944 + dict[str, torch.Tensor | Any] | None,
945 + list[Handle],
946 + list[Callable[[], None]],
947 + ]:
948 + if not torch.distributed.is_initialized() or self.world_size == 1:
949 + return None, [], []
950 + if self.use_cpu_custom_send_recv:
951 + if self.device_communicator is None:
952 + raise ValueError("No device communicator found")
953 + # custom device communicator path is synchronous
954 + sync_tensor_dict = self.device_communicator.recv_tensor_dict( # type: ignore
955 + src
956 + )
957 + return sync_tensor_dict, [], []
958 +
959 all_gather_size = 1 if all_gather_group is None else all_gather_group.world_size
960 all_gather_rank = (
961 0 if all_gather_group is None else all_gather_group.rank_in_group
@@ -907,57 +968,57 @@ class GroupCoordinator:
968 src = (self.rank_in_group - 1) % self.world_size
969 assert src < self.world_size, f"Invalid src rank ({src})"
970
910 - if self.use_cpu_custom_send_recv:
911 - if self.device_communicator is None:
912 - raise ValueError("No device communicator found")
913 - return self.device_communicator.recv_tensor_dict( # type: ignore
914 - src
915 - )
916 -
971 recv_metadata_list = self.recv_object(src=src)
972 tensor_dict: dict[str, Any] = {}
973 + handles: list[Handle] = []
974 + postprocess: list[Callable[[], None]] = []
975 +
976 for key, value in recv_metadata_list:
977 if isinstance(value, TensorMetadata):
921 - tensor = torch.empty(value.size, dtype=value.dtype, device=value.device)
922 - if tensor.numel() == 0:
923 - # Skip broadcasting empty tensors.
924 - tensor_dict[key] = tensor
925 - continue
926 -
927 - # send-allgather: send only a slice, then do allgather.
928 - use_all_gather = (
929 - all_gather_group is not None
930 - and tensor.numel() % all_gather_size == 0
931 - )
932 - use_all_gather = (
933 - all_gather_tensors.get(key, use_all_gather)
934 - if all_gather_tensors
935 - else use_all_gather
978 + full_tensor = torch.empty(
979 + value.size, dtype=value.dtype, device=value.device
980 )
981 + if full_tensor.numel() == 0:
982 + tensor_dict[key] = full_tensor
983 + continue
984
938 - if use_all_gather:
939 - orig_shape = tensor.shape
940 - tensor = tensor.reshape(all_gather_size, -1)[all_gather_rank]
941 -
942 - if tensor.is_cpu:
943 - # use metadata_group for CPU tensors
944 - torch.distributed.recv(
945 - tensor, src=self.ranks[src], group=metadata_group
985 + if self._should_use_all_gather(
986 + key, full_tensor.numel(), all_gather_group, all_gather_tensors
987 + ):
988 + orig_shape = full_tensor.shape
989 + slice_tensor = full_tensor.reshape(all_gather_size, -1)[
990 + all_gather_rank
991 + ]
992 + comm_group = metadata_group if slice_tensor.is_cpu else group
993 + handle = torch.distributed.irecv(
994 + slice_tensor, src=self.ranks[src], group=comm_group
995 )
996 + handles.append(handle)
997 +
998 + def _postprocess(
999 + key: str = key,
1000 + slice_tensor: torch.Tensor = slice_tensor,
1001 + orig_shape: tuple[int, ...] = tuple(orig_shape),
1002 + all_gather_group=all_gather_group,
1003 + ) -> None:
1004 + assert all_gather_group is not None
1005 + tensor_dict[key] = all_gather_group.all_gather(
1006 + slice_tensor, dim=0
1007 + ).reshape(orig_shape)
1008 +
1009 + postprocess.append(_postprocess)
1010 + tensor_dict[key] = slice_tensor
1011 else:
948 - # use group for GPU tensors
949 - torch.distributed.recv(tensor, src=self.ranks[src], group=group)
950 - if use_all_gather:
951 - # do the allgather
952 - tensor = all_gather_group.all_gather( # type: ignore
953 - tensor, dim=0
1012 + comm_group = metadata_group if full_tensor.is_cpu else group
1013 + handle = torch.distributed.irecv(
1014 + full_tensor, src=self.ranks[src], group=comm_group
1015 )
955 - tensor = tensor.reshape(orig_shape)
956 -
957 - tensor_dict[key] = tensor
1016 + handles.append(handle)
1017 + tensor_dict[key] = full_tensor
1018 else:
1019 tensor_dict[key] = value
960 - return tensor_dict
1020 +
1021 + return tensor_dict, handles, postprocess
1022
1023 def barrier(self):
1024 """Barrier synchronization among the group.
vllm/v1/worker/gpu_worker.py
+54 -5
@@ -4,6 +4,7 @@
4
5 import gc
6 import os
7 +from collections.abc import Callable
8 from contextlib import AbstractContextManager, nullcontext
9 from types import NoneType
10 from typing import TYPE_CHECKING, Any, cast
@@ -30,6 +31,7 @@ from vllm.distributed.kv_transfer import (
31 has_kv_transfer_group,
32 )
33 from vllm.distributed.parallel_state import (
34 + Handle,
35 get_pcp_group,
36 get_pp_group,
37 get_tp_group,
@@ -68,6 +70,38 @@ if TYPE_CHECKING:
70 from vllm.v1.worker.gpu_model_runner import GPUModelRunner
71
72
73 +class AsyncIntermediateTensors(IntermediateTensors):
74 + """IntermediateTensors with lazy comm synchronization"""
75 +
76 + def __init__(
77 + self,
78 + tensors: dict[str, torch.Tensor],
79 + comm_handles: list[Handle] | None = None,
80 + comm_postprocess: list[Callable[[], None]] | None = None,
81 + ) -> None:
82 + super().__init__(tensors)
83 + self._comm_handles = comm_handles
84 + self._comm_postprocess = comm_postprocess
85 + self._comm_waited = False
86 +
87 + def wait_for_comm(self) -> None:
88 + if self._comm_waited:
89 + return
90 + if self._comm_handles:
91 + for handle in self._comm_handles:
92 + handle.wait()
93 + if self._comm_postprocess:
94 + for fn in self._comm_postprocess:
95 + fn()
96 + self._comm_waited = True
97 +
98 + def __getattribute__(self, name: str):
99 + # ensure `.tensors` is ready before use
100 + if name == "tensors" and not object.__getattribute__(self, "_comm_waited"):
101 + object.__getattribute__(self, "wait_for_comm")()
102 + return object.__getattribute__(self, name)
103 +
104 +
105 class Worker(WorkerBase):
106 def __init__(
107 self,
@@ -113,6 +147,8 @@ class Worker(WorkerBase):
147 raise ValueError(f"Unknown profiler type: {self.profiler_config.profiler}")
148
149 self.use_v2_model_runner = envs.VLLM_USE_V2_MODEL_RUNNER
150 + # pending non-blocking PP send work from the previous iteration
151 + self._pp_send_work: list[Handle] = []
152
153 def sleep(self, level: int = 1) -> None:
154 from vllm.device_allocator.cumem import CuMemAllocator
@@ -600,6 +636,12 @@ class Worker(WorkerBase):
636 def execute_model(
637 self, scheduler_output: "SchedulerOutput"
638 ) -> ModelRunnerOutput | AsyncModelRunnerOutput | None:
639 + # ensure any previous non-blocking PP sends are complete
640 + if self._pp_send_work:
641 + for handle in self._pp_send_work:
642 + handle.wait()
643 + self._pp_send_work = []
644 +
645 intermediate_tensors = None
646 forward_pass = scheduler_output.total_num_scheduled_tokens > 0
647 num_scheduled_tokens = scheduler_output.total_num_scheduled_tokens
@@ -637,12 +679,18 @@ class Worker(WorkerBase):
679 }
680
681 if forward_pass and not get_pp_group().is_first_rank:
640 - tensor_dict = get_pp_group().recv_tensor_dict(
641 - all_gather_group=get_tp_group(),
642 - all_gather_tensors=all_gather_tensors,
682 + tensor_dict, comm_handles, comm_postprocess = (
683 + get_pp_group().irecv_tensor_dict(
684 + all_gather_group=get_tp_group(),
685 + all_gather_tensors=all_gather_tensors,
686 + )
687 )
688 assert tensor_dict is not None
645 - intermediate_tensors = IntermediateTensors(tensor_dict)
689 + intermediate_tensors = AsyncIntermediateTensors(
690 + tensor_dict,
691 + comm_handles=comm_handles,
692 + comm_postprocess=comm_postprocess,
693 + )
694
695 with self.annotate_profile(scheduler_output):
696 output = self.model_runner.execute_model(
@@ -660,7 +708,8 @@ class Worker(WorkerBase):
708 and not get_pp_group().is_last_rank
709 )
710
663 - get_pp_group().send_tensor_dict(
711 + # launch non-blocking send of intermediate tensors
712 + self._pp_send_work = get_pp_group().isend_tensor_dict(
713 output.tensors,
714 all_gather_group=get_tp_group(),
715 all_gather_tensors=all_gather_tensors,