distributed/parallel_state.py: process groups and GroupCoordinator
Code version: vLLM @ 3e44c0d · 2026-10-22
Purpose#
vllm/distributed/parallel_state.py owns the process groups of a worker process: it creates them from the rank layout, keeps them in module globals such as _TP, and gives model code one object per group, the GroupCoordinator, with all collectives.
| File | vllm/distributed/parallel_state.py, 2,767 lines of Python. The header says it is adapted from Megatron-LM (vllm/distributed/parallel_state.py:4-23). |
|---|---|
| Main class | GroupCoordinator (vllm/distributed/parallel_state.py:427). |
| Main functions | init_distributed_environment (vllm/distributed/parallel_state.py:1836), initialize_model_parallel (vllm/distributed/parallel_state.py:2078), ensure_model_parallel_initialized (vllm/distributed/parallel_state.py:2388), the getters get_tp_group … get_eplb_group, graph_capture, destroy_model_parallel, cleanup_dist_env_and_memory. |
| Runs in | Each worker process (or the engine core process with the uni executor), main thread. The groups are per process. |
| Called by | init_worker_distributed_environment in the GPU worker (vllm/v1/worker/gpu_worker.py:1678-1720); model layers through vllm.distributed, which re-exports this module (vllm/distributed/__init__.py:4-6). |
| Calls | torch.distributed (init_process_group, new_group, send/recv), the platform's device communicator class, MessageQueue from shm_broadcast.py, direct_register_custom_op. |
| Not in this file | The all-reduce kernels and their selection (device_communicators/cuda_communicator.py and friends), the ring buffer (shm_broadcast.py), the MoE all2all managers (device_communicators/all2all.py), the stateless DP group of the engine core (config/parallel.py), the helpers tensor_model_parallel_all_reduce etc. (communication_op.py). |
The page Process groups: how vLLM slices its ranks explains the ideas and the rank layout. This page reads the file function by function.
Where it sits#
Who calls it#
- The GPU worker, in
init_device, before the memory snapshot (vllm/v1/worker/gpu_worker.py:497-507). Its helper callsset_custom_all_reduce,init_distributed_environmentandensure_model_parallel_initializedin that order (vllm/v1/worker/gpu_worker.py:1694-1716). - Model layers call the getters at construction and the collectives at run time, for example
RowParallelLinearthroughtensor_model_parallel_all_reduce(vllm/model_executor/layers/linear.py:1803-1806,vllm/distributed/communication_op.py:12-14). - Model Runner V2 wraps CUDA graph capture in
graph_capture, and the worker uses the PP group forisend_tensor_dict/irecv_tensor_dict(vllm/v1/worker/gpu_worker.py:1336-1347). - Shutdown: the worker process calls
destroy_model_parallelanddestroy_distributed_environment(vllm/v1/executor/multiproc_executor.py:824-833); the engine core callscleanup_dist_env_and_memory(vllm/v1/engine/core.py:837-839).
What it calls#
torch.distributed.init_process_groupfor the world, thennew_grouptwice per group: device backend and gloo (vllm/distributed/parallel_state.py:1973-1982,vllm/distributed/parallel_state.py:501-518).current_platform.get_device_communicator_cls(), only for groups with more than one rank (vllm/distributed/parallel_state.py:549-560).MessageQueue.create_from_process_groupfor the groups that ask for a broadcaster (vllm/distributed/parallel_state.py:562-567).direct_register_custom_opforall_reduce,reduce_scatter,all_gatherand a patched scaled-matmul reduce-scatter, at import time (vllm/distributed/parallel_state.py:399-425).
Key classes and functions#
| Name | Line | What it does |
|---|---|---|
_split_tensor_dict | vllm/distributed/parallel_state.py:116-139 | Splits a dict into metadata (with TensorMetadata for tensors) and a tensor list. |
_get_unique_name, _register_group | vllm/distributed/parallel_state.py:145-164 | Names like tp:0; weak references by name for the custom ops. |
all_reduce, reduce_scatter, all_gather (+ fakes) | vllm/distributed/parallel_state.py:199-244 | The custom-op bodies: look up the group by name, call its out-of-place method. |
GroupCoordinator | vllm/distributed/parallel_state.py:427-1489 | One process group: CPU and device groups, communicator, broadcaster, collectives, send/recv, MoE dispatch/combine. |
init_world_group, init_model_parallel_group | vllm/distributed/parallel_state.py:1507-1536 | Thin constructors for GroupCoordinator. |
get_tp_group … get_pcp_group | vllm/distributed/parallel_state.py:1590-1685 | Getters that assert the group exists. |
graph_capture | vllm/distributed/parallel_state.py:1693-1726 | Capture stream plus the capture contexts of TP, PP and DP. |
init_distributed_environment | vllm/distributed/parallel_state.py:1836-2039 | Default process group and the world GroupCoordinator. |
initialize_model_parallel | vllm/distributed/parallel_state.py:2078-2385 | The rank grid and every model-parallel group. |
ensure_model_parallel_initialized | vllm/distributed/parallel_state.py:2388-2437 | Build once; on a second call, assert the same sizes. |
destroy_model_parallel, destroy_distributed_environment, cleanup_dist_env_and_memory | vllm/distributed/parallel_state.py:2480-2583 | Tear-down in a fixed order. |
in_the_same_node_as, _node_count | vllm/distributed/parallel_state.py:2586-2767 | Node detection through a shared-memory segment. |
Walk through the main methods#
GroupCoordinator.__init__: two PyTorch groups per row#
from vllm.distributed.utils import (
get_cpu_distributed_timeout_or_none,
get_distributed_timeout_or_none,
)
timeout = get_cpu_distributed_timeout_or_none()
device_timeout = get_distributed_timeout_or_none()
for ranks in group_ranks:
device_group = torch.distributed.new_group(
ranks,
backend=torch_distributed_backend,
timeout=device_timeout,
)
# a group with `gloo` backend, to allow direct coordination between
# processes through the CPU.
with suppress_stdout():
cpu_group = torch.distributed.new_group(
ranks, backend="gloo", timeout=timeout
)
if self.rank in ranks:
self.ranks = ranks
self.world_size = len(ranks)
self.rank_in_group = ranks.index(self.rank)
self_device_group = device_group
self_cpu_group = cpu_group- Lines 498-499. Optional timeouts for the device and CPU groups (from
--distributed-timeout-secondsand its CPU twin). - Lines 501-512. For every row of
group_ranks, the process makes a device group and a gloo group.new_groupis collective, so all ranks must make all rows in the same order, even rows they are not in. - Lines 513-518. The process keeps the pair for its own row, and records its rank inside the group.
The class comment shows the difference between rank, local_rank and rank_in_group with a 4-rank group over two nodes (vllm/distributed/parallel_state.py:436-449). With VLLM_DISTRIBUTED_USE_SPLIT_GROUP=1, the groups come from PyTorch split_group instead (vllm/distributed/parallel_state.py:480-491). The rest of the constructor picks the device, then adds the optional parts:
self.use_device_communicator = use_device_communicator
self.device_communicator = None
if use_device_communicator and self.world_size > 1:
device_comm_cls = resolve_obj_by_qualname(
current_platform.get_device_communicator_cls()
)
self.device_communicator = device_comm_cls(
cpu_group=self.cpu_group,
device=self.device,
device_group=self.device_group,
unique_name=self.unique_name,
use_all2all=use_all2all,
)
from vllm.distributed.device_communicators.shm_broadcast import MessageQueue
self.mq_broadcaster: MessageQueue | None = None
if use_message_queue_broadcaster and self.world_size > 1:
self.mq_broadcaster = MessageQueue.create_from_process_group(
self.cpu_group, 1 << 22, 6
)
# TODO(#35915): Remove is_tpu() check once tpu_inference
# overrides use_custom_op_collectives() to return True.
self.use_custom_op_call = (
current_platform.is_tpu() or current_platform.use_custom_op_collectives()
)
- Lines 549-559. A device communicator only for more than one rank.
use_all2allis passed through; the communicator honours it only for groups namedep(vllm/distributed/device_communicators/base_device_communicator.py:244-245). - Lines 563-567. The broadcaster: a
MessageQueueof 6 chunks × 4 MiB, writer = the group's first rank. - Lines 571-573.
use_custom_op_calldecides whether the collectives go throughtorch.ops.vllm.*.
all_reduce and friends#
def all_reduce(self, input_: torch.Tensor) -> torch.Tensor:
"""User-facing all-reduce function before we actually call the
all-reduce operation.
We need this because Dynamo does not support passing an arbitrary
object (`self` in this case) to a custom op. We need to pass the
group name as a string, and then look up the group coordinator from
the group name, dispatch the all-reduce operation to the group
coordinator.
In addition, PyTorch custom ops do not support mutation or returning
a new tensor in the same op. So we always make the all-reduce operation
out-of-place.
"""
# Bypass the function if we are using only 1 GPU.
if self.world_size == 1:
return input_
if self.use_custom_op_call:
return torch.ops.vllm.all_reduce(input_, group_name=self.unique_name)
else:
return self._all_reduce_out_place(input_)
def _all_reduce_out_place(self, input_: torch.Tensor) -> torch.Tensor:
if self.device_communicator is None:
raise ValueError("No device communicator found")
return self.device_communicator.all_reduce(input_)- Lines 740-741. A group of one returns the input; no kernel runs.
- Lines 743-746. The registered op takes the group's unique name (a string), because Dynamo cannot pass the Python object. The op body looks the group up in
_groups(vllm/distributed/parallel_state.py:199-204). - Lines 748-751. The real work is in the device communicator; without one this raises "No device communicator found".
all_gather and reduce_scatter follow the same pattern and check the dim argument (vllm/distributed/parallel_state.py:753-798). gather, all_gatherv and reduce_scatterv go straight to the communicator (vllm/distributed/parallel_state.py:774-838). broadcast uses torch.distributed.broadcast on the device group (vllm/distributed/parallel_state.py:840-853); broadcast_object prefers the shared-memory broadcaster (vllm/distributed/parallel_state.py:855-877).
isend_tensor_dict and irecv_tensor_dict: the PP hand-off#
group = self.device_group
metadata_group = self.cpu_group
metadata_list, tensor_list = _split_tensor_dict(tensor_dict)
tensor_keys = [k for k, v in tensor_dict.items() if isinstance(v, torch.Tensor)]
assert len(tensor_keys) == len(tensor_list)
# Reap completed sends before posting new ones, bounding the
# self-retention FIFO.
self._reap_completed_isends()
handles: list[Handle] = [self.isend_object(metadata_list, dst=dst)]
sent_tensors: list[torch.Tensor] = []
for key, tensor in zip(tensor_keys, tensor_list):
if tensor.numel() == 0:
continue
if self._should_use_all_gather(
key, tensor.numel(), all_gather_group, all_gather_tensors
):
tensor = tensor.reshape(all_gather_size, -1)[all_gather_rank]
comm_group = metadata_group if tensor.is_cpu else group
handle = torch.distributed.isend(
tensor, dst=self.ranks[dst], group=comm_group
)
if tensor.is_cuda:
tensor.record_stream(torch.cuda.current_stream(tensor.device))
handles.append(handle)
sent_tensors.append(tensor)
# Self-retain for fire-and-forget callers: keep the handles and
# the source tensors alive until the sends complete (reaped
# lazily by ``_reap_completed_isends``). Without this, dropping
# the returned handles would GC the ``_RetainedHandle``'s
# serialized metadata buffers mid-send, and non-CUDA devices
# have no ``record_stream`` equivalent protecting the tensor
# sources.
pending = getattr(self, "_pending_isends", None)
if pending is None:
self._pending_isends = pending = deque()
pending.append((handles, sent_tensors))
return handles- Line 1211. Clean up finished earlier sends before posting new ones.
- Line 1212. The metadata (keys, dtypes, shapes, plain values) goes first, pickled on the gloo group by
isend_object. - Lines 1219-1222. The all-gather trick: with
all_gather_group(the TP group), each TP rank sends only its 1/TP slice; the receiver rebuilds the full tensor with an all-gather. - Lines 1224-1229. Each tensor goes by
isendon the device group (CPU tensors on gloo).record_streamkeeps the CUDA memory alive until the send finishes. - Lines 1240-1243. The handles are also kept in
_pending_isends, so a caller may drop them safely.
The receiver posts irecv for each tensor and returns post-processing callbacks for the all-gather case (vllm/distributed/parallel_state.py:1285-1369). The worker uses exactly this pair with all_gather_group=get_tp_group() (vllm/v1/worker/gpu_worker.py:1336-1347, vllm/v1/worker/gpu_worker.py:1372-1378).
init_distributed_environment#
enable_elastic_ep = config is not None and config.parallel_config.enable_elastic_ep
world_pg_store: Store | None = None
if (
config is not None
and config.parallel_config.distributed_executor_backend != "external_launcher"
and (
config.parallel_config.nnodes > 1
or config.parallel_config.data_parallel_size > 1
)
and not enable_elastic_ep
):
parallel_config = config.parallel_config
# adjust to take into account data parallelism
# offset the rank by the data parallel rank
rank = parallel_config.data_parallel_rank * world_size + rank
# adjust the world size to take into account data parallelism
world_size = parallel_config.world_size_across_dp
# Use appropriate IP and port based on configuration- Lines 1857-1865. The DP/multi-node branch runs only with a config, no external launcher, no elastic EP, and DP > 1 or several nodes. A dense model's engine reset its DP size to 1, so it never enters for DP.
- Lines 1869-1871. Shift the rank by
dp_rank × world_sizeand widen the world to all DP ranks.
Then the default process group starts, with a gloo fallback when the backend is not available (vllm/distributed/parallel_state.py:1927-1982), and the world GroupCoordinator is made once. When one DP replica spans several nodes and DP > 1, an extra inner_dp_world group with a broadcaster is made for the executor's cross-node queue; with DP = 1 the name points to the world group (vllm/distributed/parallel_state.py:2022-2038).
initialize_model_parallel#
# the layout order is: ExternalDP x DP x PP x PCP x TP
# ExternalDP is the data parallel group that is not part of the model,
# every dp rank can generate independently (in verl integration).
# DP is the data parallel group that is part of the model,
# all the ranks in the same DP group should generate simultaneously,
# i.e. the `generate` call in the same DP group should be called together,
# otherwise it will cause deadlock.
# to get group_ranks for each dimension, transpose that dimension to the
# last dimension, then reshape to 2D, then unbind the last dimension
all_ranks = torch.arange(world_size).reshape(
-1,
data_parallel_size,
pipeline_model_parallel_size,
prefill_context_model_parallel_size,
tensor_model_parallel_size,
) # noqa
# Build the tensor model-parallel groups.
global _TP
assert _TP is None, "tensor model parallel group is already initialized"
group_ranks = all_ranks.view(-1, tensor_model_parallel_size).unbind(0)Every group comes from this one tensor: TP with view(-1, TP), PP with transpose(2, 4), DP with transpose(1, 4), PCP with transpose(3, 4), and EP (MoE only) with transpose(1, 2) into rows of DP × PCP × TP (vllm/distributed/parallel_state.py:2256-2345). With elastic EP, TP/PP/PCP use a local grid and DP/EP/EPLB become stateless groups (vllm/distributed/parallel_state.py:2122-2140, vllm/distributed/parallel_state.py:2297-2305). The function ends with one INFO log line per rank, logged once (vllm/distributed/parallel_state.py:2372-2385).
Tear-down#
def cleanup_dist_env_and_memory(shutdown_ray: bool = False):
logger.debug(
"[shutdown] Distributed: cleanup start shutdown_ray=%s",
shutdown_ray,
)
# Reset environment variable cache
envs.disable_envs_cache()
# Reset rocm_aiter_ops class variables to match current os.environ.
# These are class-level attributes that persist across tests and are
# NOT restored by monkeypatch (which only restores os.environ).
from vllm.platforms import current_platform
if current_platform.is_rocm():
from vllm._aiter_ops import rocm_aiter_ops
rocm_aiter_ops.refresh_env_variables()
# Ensure all objects are not frozen before cleanup
gc.unfreeze()
destroy_model_parallel()
destroy_distributed_environment()
if shutdown_ray:
import ray # Lazy import Ray
ray.shutdown()
gc.collect()destroy_model_parallel destroys the groups in the order ETP, TP, Engram DP, DCP, PCP, PP, DP, EP, EPLB (vllm/distributed/parallel_state.py:2480-2525). Each GroupCoordinator.destroy first destroys its device communicator, because communicator workspaces may need the groups for their own tear-down (vllm/distributed/parallel_state.py:1400-1413).
Deep dive#
The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.
Deep diveData flow: types in and out
| Value | Type | Where |
|---|---|---|
group_ranks | list[list[int]], global ranks, one list per group | vllm/distributed/parallel_state.py:2168-2169 |
all_ranks | torch.Tensor int64, shape (ExternalDP, DP, PP, PCP, TP) | vllm/distributed/parallel_state.py:2157-2163 |
| tensor-dict metadata | list[tuple[str, TensorMetadata | Any]]; TensorMetadata(device, dtype, size) | vllm/distributed/parallel_state.py:116-139 |
| PP payload for Llama | {"hidden_states": [T, H], "residual": [T, H]}; each TP rank sends T × H / TP elements per tensor when the size divides | vllm/distributed/parallel_state.py:1068-1080 |
all_reduce in/out | same shape and dtype; out-of-place | vllm/distributed/parallel_state.py:207-208 |
all_gather(x, dim) | size of dim × world size | vllm/distributed/parallel_state.py:239-244 |
reduce_scatter(x, dim) | size of dim ÷ world size | vllm/distributed/parallel_state.py:221-226 |
Deep diveProcesses, threads and when each part runs
| When | Process / thread | What runs |
|---|---|---|
| module import | every process that imports vllm.distributed | custom op registration (vllm/distributed/parallel_state.py:399-425) |
init_device | each worker, main thread | world + model-parallel groups, communicators, broadcasters (all collective) |
| forward pass | each worker, main thread (and the compiled graph) | collectives on the TP / PP / EP / DP groups |
| CUDA graph capture | each worker, capture stream | graph_capture contexts |
| shutdown | each worker | destroy groups, then the default PG |
The engine core of an MoE DP rank has its own DP process group, but it does not come from this file: stateless_init_dp_group builds a gloo group "since the engine process might not have cuda device" (vllm/config/parallel.py:735-743).
Deep diveWorked example: what one TP = 2 worker builds
vllm serve meta-llama/Llama-3.1-8B-Instruct -tp 2, worker rank 1 (derived from the code above):
init_distributed_environment(world_size=2, rank=1, "file:///tmp/vllm_dist_…", local_rank=1, "nccl"): no DP branch; default PG of 2 ranks;_WORLD= ranks [0, 1], no device communicator;_NODE_COUNT= 1.all_ranks = arange(2).reshape(1, 1, 1, 1, 2)._TP= [0, 1] (tp:0), with aCudaCommunicatorand a 6 × 4 MiBMessageQueue._ETPis the same object._DCP,_PCP,_PP,_DP: two groups of one rank each ([0] and [1]), so this rank's object has no communicator and no broadcaster._EP,_EPLB:None, because Llama is not an MoE model.- The log: "rank 1 in world size 2 is assigned as DP rank 0, PP rank 0, PCP rank 0, TP rank 1, ETP rank 1, EP rank N/A, EPLB rank N/A".
Every new_group call above makes two PyTorch groups (NCCL and gloo), and each rank takes part in both rows of each size-1 group kind. That is 2 + 2 + 4 × 4 = 20 new_group calls for this worker: world and TP have 1 row each, DCP, PCP, PP and DP have 2 rows each, and each row needs two calls. The communicators then do their own setup (derived from lines 501-518 and the group list).
Deep diveError paths: what raises, what only logs
| Situation | Result | Where |
|---|---|---|
a getter before init (or get_ep_group for a dense model) | AssertionError with a hint | vllm/distributed/parallel_state.py:1659-1665 |
| groups built twice | AssertionError "… group is already initialized" | vllm/distributed/parallel_state.py:2167 |
different sizes on a second ensure_model_parallel_initialized | AssertionError | vllm/distributed/parallel_state.py:2412-2437 |
| the backend is not available | WARNING, falls back to gloo | vllm/distributed/parallel_state.py:1940-1948 |
| collective on a group without communicator (size > 1, CPU platform paths) | ValueError("No device communicator found") | vllm/distributed/parallel_state.py:748-751 |
| the custom op finds a destroyed group | ValueError("Group … is destroyed.") | vllm/distributed/parallel_state.py:199-204 |
| node detection cannot open the segment | ERROR "Error ignored in is_in_the_same_node", rank counted as another node | vllm/distributed/parallel_state.py:2651-2652 |
| elastic EP with TP/PP across nodes | RuntimeError | vllm/distributed/parallel_state.py:1983-1994 |
Deep diveEnvironment variables
| Variable | Default | Read at | Effect |
|---|---|---|---|
VLLM_DISTRIBUTED_USE_SPLIT_GROUP | 0 (vllm/envs.py:933-935) | vllm/distributed/parallel_state.py:482, vllm/distributed/parallel_state.py:1954 | Use split_group with an eagerly bound device; the default PG then needs both gloo and NCCL (vllm/distributed/parallel_state.py:1738-1798). |
LOCAL_RANK | read only with env:// | vllm/distributed/parallel_state.py:2000-2004 | Local rank when the caller passes −1 (torchrun-style launch). |
VLLM_ELASTIC_EP_SCALE_UP_LAUNCH | 0 | vllm/distributed/parallel_state.py:2041-2052 | Defers communicator warm-up when joining a live elastic-EP cluster on ROCm. |
The variables that pick the all-reduce implementation are read in the device communicators; see Process groups: how vLLM slices its ranks.
Related concepts#
- Process groups: how vLLM slices its ranks: the rank layout, the communicator chain and the worked 8-GPU example.
- The tensor-parallel layers (linear.py and vocab_parallel_embedding.py): the layers that call the TP collectives.
- Expert parallelism and MoE serving: the EP group and its all2all managers.
- distributed/device_communicators/shm_broadcast.py: MessageQueue: the
MessageQueuebehind the broadcasters.
Check yourself#
- 1What doeseasy
GroupCoordinator.all_reducedo in a group of one rank?Show answer
It returns the input tensor; no kernel runs (
vllm/distributed/parallel_state.py:739-741). - 2Why must every rank callmedium
torch.distributed.new_groupfor every row, even rows it does not belong to?Show answer
new_groupis a collective call in PyTorch. The constructor loops over all rows ofgroup_ranksand keeps only its own pair (vllm/distributed/parallel_state.py:501-518). - 3A PP stage sends amedium
hidden_statestensor of[2048, 4096]BF16 with TP = 4. How many bytes does each TP rank send for it, and how does the receiver get the full tensor?Show answer
The size divides by 4, so each rank sends its 1/4 slice: 2048 × 4096 × 2 / 4 = 4 MiB (
vllm/distributed/parallel_state.py:1068-1080,vllm/distributed/parallel_state.py:1219-1222). The receiver posts anirecvinto its slice and runs an all-gather on the TP group in a post-processing callback (vllm/distributed/parallel_state.py:1332-1355). - 4In the TP = 2 example of the deep dive, which groups of worker 1 have a device communicator, and why does the PP group have none?hard
Show answer
Only the TP group (and ETP, the same object). The PP, DP, PCP and DCP groups of this rank have one rank each, and the constructor makes a communicator only when
world_size > 1(vllm/distributed/parallel_state.py:549). The world group is built withuse_device_communicator=False(vllm/distributed/parallel_state.py:1507-1516).