vLLM Explained

distributed/parallel_state.py: process groups and GroupCoordinator

AdvancedCode mapabout 9 min read + 3 min deep dives

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.

Filevllm/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 classGroupCoordinator (vllm/distributed/parallel_state.py:427).
Main functionsinit_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 inEach worker process (or the engine core process with the uni executor), main thread. The groups are per process.
Called byinit_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).
Callstorch.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 fileThe 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#

Figure: what initialize_model_parallel builds for TP = 2 × PP = 2 × DP = 2 (MoE): the grid, the groups and their communicators.
Open full size ↗

Who calls it#

What it calls#

Key classes and functions#

NameLineWhat it does
_split_tensor_dictvllm/distributed/parallel_state.py:116-139Splits a dict into metadata (with TensorMetadata for tensors) and a tensor list.
_get_unique_name, _register_groupvllm/distributed/parallel_state.py:145-164Names like tp:0; weak references by name for the custom ops.
all_reduce, reduce_scatter, all_gather (+ fakes)vllm/distributed/parallel_state.py:199-244The custom-op bodies: look up the group by name, call its out-of-place method.
GroupCoordinatorvllm/distributed/parallel_state.py:427-1489One process group: CPU and device groups, communicator, broadcaster, collectives, send/recv, MoE dispatch/combine.
init_world_group, init_model_parallel_groupvllm/distributed/parallel_state.py:1507-1536Thin constructors for GroupCoordinator.
get_tp_group … get_pcp_groupvllm/distributed/parallel_state.py:1590-1685Getters that assert the group exists.
graph_capturevllm/distributed/parallel_state.py:1693-1726Capture stream plus the capture contexts of TP, PP and DP.
init_distributed_environmentvllm/distributed/parallel_state.py:1836-2039Default process group and the world GroupCoordinator.
initialize_model_parallelvllm/distributed/parallel_state.py:2078-2385The rank grid and every model-parallel group.
ensure_model_parallel_initializedvllm/distributed/parallel_state.py:2388-2437Build once; on a second call, assert the same sizes.
destroy_model_parallel, destroy_distributed_environment, cleanup_dist_env_and_memoryvllm/distributed/parallel_state.py:2480-2583Tear-down in a fixed order.
in_the_same_node_as, _node_countvllm/distributed/parallel_state.py:2586-2767Node 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
  1. Lines 498-499. Optional timeouts for the device and CPU groups (from --distributed-timeout-seconds and its CPU twin).
  2. Lines 501-512. For every row of group_ranks, the process makes a device group and a gloo group. new_group is collective, so all ranks must make all rows in the same order, even rows they are not in.
  3. 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()
        )
  1. Lines 549-559. A device communicator only for more than one rank. use_all2all is passed through; the communicator honours it only for groups named ep (vllm/distributed/device_communicators/base_device_communicator.py:244-245).
  2. Lines 563-567. The broadcaster: a MessageQueue of 6 chunks × 4 MiB, writer = the group's first rank.
  3. Lines 571-573. use_custom_op_call decides whether the collectives go through torch.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_)
  1. Lines 740-741. A group of one returns the input; no kernel runs.
  2. 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).
  3. 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
  1. Line 1211. Clean up finished earlier sends before posting new ones.
  2. Line 1212. The metadata (keys, dtypes, shapes, plain values) goes first, pickled on the gloo group by isend_object.
  3. 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.
  4. Lines 1224-1229. Each tensor goes by isend on the device group (CPU tensors on gloo). record_stream keeps the CUDA memory alive until the send finishes.
  5. 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
  1. 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.
  2. Lines 1869-1871. Shift the rank by dp_rank × world_size and 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 dive

Data flow: types in and out

ValueTypeWhere
group_rankslist[list[int]], global ranks, one list per groupvllm/distributed/parallel_state.py:2168-2169
all_rankstorch.Tensor int64, shape (ExternalDP, DP, PP, PCP, TP)vllm/distributed/parallel_state.py:2157-2163
tensor-dict metadatalist[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 dividesvllm/distributed/parallel_state.py:1068-1080
all_reduce in/outsame shape and dtype; out-of-placevllm/distributed/parallel_state.py:207-208
all_gather(x, dim)size of dim × world sizevllm/distributed/parallel_state.py:239-244
reduce_scatter(x, dim)size of dim ÷ world sizevllm/distributed/parallel_state.py:221-226
Deep dive

Processes, threads and when each part runs

WhenProcess / threadWhat runs
module importevery process that imports vllm.distributedcustom op registration (vllm/distributed/parallel_state.py:399-425)
init_deviceeach worker, main threadworld + model-parallel groups, communicators, broadcasters (all collective)
forward passeach worker, main thread (and the compiled graph)collectives on the TP / PP / EP / DP groups
CUDA graph captureeach worker, capture streamgraph_capture contexts
shutdowneach workerdestroy 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 dive

Worked 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):

  1. 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.
  2. all_ranks = arange(2).reshape(1, 1, 1, 1, 2).
  3. _TP = [0, 1] (tp:0), with a CudaCommunicator and a 6 × 4 MiB MessageQueue. _ETP is the same object.
  4. _DCP, _PCP, _PP, _DP: two groups of one rank each ([0] and [1]), so this rank's object has no communicator and no broadcaster.
  5. _EP, _EPLB: None, because Llama is not an MoE model.
  6. 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 dive

Error paths: what raises, what only logs

SituationResultWhere
a getter before init (or get_ep_group for a dense model)AssertionError with a hintvllm/distributed/parallel_state.py:1659-1665
groups built twiceAssertionError "… group is already initialized"vllm/distributed/parallel_state.py:2167
different sizes on a second ensure_model_parallel_initializedAssertionErrorvllm/distributed/parallel_state.py:2412-2437
the backend is not availableWARNING, falls back to gloovllm/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 groupValueError("Group … is destroyed.")vllm/distributed/parallel_state.py:199-204
node detection cannot open the segmentERROR "Error ignored in is_in_the_same_node", rank counted as another nodevllm/distributed/parallel_state.py:2651-2652
elastic EP with TP/PP across nodesRuntimeErrorvllm/distributed/parallel_state.py:1983-1994
Deep dive

Environment variables

VariableDefaultRead atEffect
VLLM_DISTRIBUTED_USE_SPLIT_GROUP0 (vllm/envs.py:933-935)vllm/distributed/parallel_state.py:482, vllm/distributed/parallel_state.py:1954Use split_group with an eagerly bound device; the default PG then needs both gloo and NCCL (vllm/distributed/parallel_state.py:1738-1798).
LOCAL_RANKread only with env://vllm/distributed/parallel_state.py:2000-2004Local rank when the caller passes −1 (torchrun-style launch).
VLLM_ELASTIC_EP_SCALE_UP_LAUNCH0vllm/distributed/parallel_state.py:2041-2052Defers 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.

Check yourself#

  1. 1
    What does GroupCoordinator.all_reduce do in a group of one rank?
    easy
    Show answer

    It returns the input tensor; no kernel runs (vllm/distributed/parallel_state.py:739-741).

  2. 2
    Why must every rank call torch.distributed.new_group for every row, even rows it does not belong to?
    medium
    Show answer

    new_group is a collective call in PyTorch. The constructor loops over all rows of group_ranks and keeps only its own pair (vllm/distributed/parallel_state.py:501-518).

  3. 3
    A PP stage sends a hidden_states tensor 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?
    medium
    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 an irecv into its slice and runs an all-gather on the TP group in a post-processing callback (vllm/distributed/parallel_state.py:1332-1355).

  4. 4
    In 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 with use_device_communicator=False (vllm/distributed/parallel_state.py:1507-1516).