The LLM StackFrom Silicon to Agents
Part I — Mathematical & Systems Foundations
27 min read·Updated

1.9 Parallel Computing & Collective Communication

Modern large language models require compute that no single GPU can provide. Training a 70-billion-parameter model in a reasonable amount of time requires hundreds — sometimes thousands — of GPUs working in tight coordination. This chapter explains the substrate that makes that coordination possible: the programming model for parallel computation and the collective communication operations that keep thousands of accelerators synchronized. Everything in Distributed Training I: Data Parallelism, DDP, ZeRO & FSDP and Distributed Training II: Tensor, Pipeline, Sequence & Expert Parallelism builds on the foundations developed here.

We start from first principles — processes, threads, and how GPUs expose parallelism — and build up to the exact cost models engineers use to reason about whether a training job will be network-bound or compute-bound. Even at the small end this matters: the ~100M-parameter model we build in The Pretraining Run has a bf16 gradient buffer of only ~0.24 GB, so a single-node 4-GPU DDP all-reduce over NVLink costs a couple of milliseconds per step — which is exactly why data parallelism alone is the right (and only) parallelism the capstone needs.

Processes, Threads, and the SPMD Model

Processes vs. Threads

A process is an OS-managed execution context with its own virtual address space, file descriptors, and signal state. Two processes cannot share memory by default; they must use explicit inter-process communication (IPC). A thread lives inside a process and shares its address space; threads communicate through shared memory and synchronize via locks, semaphores, or atomic operations.

GPU distributed training almost universally uses the one-process-per-GPU model rather than threading across GPUs. The reasons are practical:

  1. Python’s Global Interpreter Lock (GIL) prevents true CPU-thread-level parallelism, so CPU-side preprocessing and coordination would be serialized.
  2. GPU driver libraries (CUDA) are not always thread-safe when the same process owns multiple GPU contexts.
  3. Process isolation provides fault containment: a crash on GPU 3 does not corrupt the state of GPU 0.

Each process gets a unique integer rank (from 0 to \(N-1\), where \(N\) is the world size) and belongs to a process group. A process group is just a set of ranks with a shared communicator — think of it as a namespace for collective operations.

Single-Program Multiple-Data (SPMD)

The SPMD programming model is the backbone of all distributed deep learning. Every process runs the same program, but operates on different data and uses its rank to branch when needed.

# spmd_demo.py — run with:
#   torchrun --nproc_per_node=4 spmd_demo.py
import os
import torch
import torch.distributed as dist

def main():
    # Initialize the default process group. "nccl" for GPU; "gloo" for CPU.
    dist.init_process_group(backend="nccl")

    rank = dist.get_rank()        # global rank: unique across the whole job
    world_size = dist.get_world_size()  # total GPU count
    # LOCAL_RANK is the index *within this node* — always use it (not RANK) to
    # pick a device, or rank 8 of a 2-node job will ask for a nonexistent cuda:8.
    local_rank = int(os.environ["LOCAL_RANK"])

    device = torch.device(f"cuda:{local_rank}")
    torch.cuda.set_device(device)

    # Each process creates a tensor whose value equals its rank.
    # This simulates different data shards arriving at different workers.
    x = torch.tensor([float(rank)], device=device)
    print(f"[rank {rank}/{world_size}] before all-reduce: x = {x.item()}")

    # Sum x across all ranks; every rank receives the global sum.
    dist.all_reduce(x, op=dist.ReduceOp.SUM)
    print(f"[rank {rank}/{world_size}] after all-reduce:  x = {x.item()}")
    # Expected: sum(0, 1, 2, 3) = 6.0 on every rank.

    dist.destroy_process_group()

if __name__ == "__main__":
    main()

When you launch with torchrun --nproc_per_node=4, the launcher spawns four processes. Each process discovers its identity through environment variables (RANK, LOCAL_RANK, WORLD_SIZE, MASTER_ADDR, MASTER_PORT) that torchrun sets before calling your script. This is the SPMD contract: every process runs main(), but the outcome differs by rank.

torchrun (the torch.distributed.run elastic launcher, successor to the deprecated torch.distributed.launch) is also how you go multi-node. Run the same command on every node and let the c10d rendezvous server on node 0 assign global ranks:

# On EVERY node (identical command; the rendezvous assigns RANK automatically):
torchrun \
  --nnodes=2 --nproc_per_node=8 \
  --rdzv_backend=c10d \
  --rdzv_endpoint=node0.cluster.local:29500 \
  --rdzv_id=my_training_job \
  train.py --config configs/base.yaml

# Under Slurm, `srun` supplies one task per node and torchrun fans out to GPUs:
#   srun --nodes=2 --ntasks-per-node=1 torchrun --nnodes=2 --nproc_per_node=8 ...

With --rdzv_backend=c10d you never set --node_rank by hand; with the older static rendezvous you must pass --node_rank, --master_addr, and --master_port explicitly. Elastic rendezvous also lets you specify --nnodes=2:4 (min:max) so the job restarts from the last checkpoint with a smaller world when a node dies — see Checkpointing, Fault Tolerance & Long-Running Jobs.

NCCL: The Collective Communication Library

NVIDIA Collective Communications Library (NCCL) is the low-level library that torch.distributed calls under the hood when you use a GPU backend. NCCL implements a set of collective operations: routines where all \(N\) processes participate and the result depends on contributions from all of them. NCCL is highly optimized to exploit NVLink between GPUs on the same node and InfiniBand between nodes.

The Six Core Collectives

There are six operations you need to know cold. In all of them, let \(n\) be the number of ranks and \(M\) be the data size (bytes or elements) per rank before the operation.

Operation What happens Output size per rank
Broadcast rank 0 sends its buffer to all other ranks \(M\)
Reduce all ranks contribute; only rank 0 gets the result \(M\) (root only)
All-Reduce like Reduce, but every rank gets the result \(M\)
Reduce-Scatter reduce across ranks, then scatter slices \(M/n\)
All-Gather each rank contributes its slice; all collect the concatenation \(nM\)
All-to-All each rank sends a distinct chunk to each other rank \(M\)

Visually, for \(n=4\) ranks each holding a buffer \([a_0, a_1, a_2, a_3]\):

A - All-Reduce (SUM) Before After all-reduce (SUM) Rank 0 A Rank 1 B Rank 2 C Rank 3 D Rank 0 A+B+C+D Rank 1 A+B+C+D Rank 2 A+B+C+D Rank 3 A+B+C+D Every rank ends with global sum. B - Reduce-Scatter Before (each rank has 4 chunks) After (rank i owns chunk i summed) reduce-scatter (SUM) Rank 0 A0 A1 A2 A3 Rank 1 B0 B1 B2 B3 Rank 2 C0 C1 C2 C3 Rank 3 D0 D1 D2 D3 Rank 0 sum(col 0) Rank 1 sum(col 1) Rank 2 sum(col 2) Rank 3 sum(col 3) Output: M/n per rank. Col colors: 0 1 2 3 C - All-Gather Before (each rank holds one chunk) After (every rank holds full concat) all-gather Rank 0 X0 Rank 1 X1 Rank 2 X2 Rank 3 X3 Rank 0 X0 X1 X2 X3 Rank 1 X0 X1 X2 X3 Rank 2 X0 X1 X2 X3 Rank 3 X0 X1 X2 X3 Output: nM per rank. -- three more core collectives: broadcast, reduce, all-to-all -- D - Broadcast Before (only rank 0 has data) After (every rank has a copy) broadcast Rank 0 X Rank 1 empty Rank 2 empty Rank 3 empty Rank 0 X Rank 1 X Rank 2 X Rank 3 X Every rank ends with a copy of X (no reduction). E - Reduce Before (each rank has a value) After (only rank 0 gets the result) reduce (SUM) Rank 0 A Rank 1 B Rank 2 C Rank 3 D Rank 0 A+B+C+D Rank 1 unused Rank 2 unused Rank 3 unused Only rank 0 holds the sum. F - All-to-All Before (rank i holds 4 distinct chunks) After (rank j collects chunk j from everyone) all-to-all Rank 0 A0 A1 A2 A3 Rank 1 B0 B1 B2 B3 Rank 2 C0 C1 C2 C3 Rank 3 D0 D1 D2 D3 Rank 0 A0 B0 C0 D0 Rank 1 A1 B1 C1 D1 Rank 2 A2 B2 C2 D2 Rank 3 A3 B3 C3 D3 Rank j ends with chunk j from every rank. Colors follow column index j, matching the coding in Panel B above - this is a grid transpose, not a reduction. All-Reduce = Reduce-Scatter (Panel B) followed by All-Gather (Panel C) - the decomposition used in ZeRO/FSDP. All-to-All (Panel F) has no such decomposition - it is the direct shuffle used by MoE expert routing.
The six core collectives for n=4 ranks: All-Reduce, Reduce-Scatter, All-Gather, Broadcast, Reduce, and All-to-All. Panel A shows All-Reduce: each rank starts with a different value and ends with the global sum. Panel B shows Reduce-Scatter: each rank starts with 4 chunks and ends holding only its own summed chunk (output M/n per rank). Panel C shows All-Gather: each rank starts with one chunk and ends holding the full concatenation (output nM per rank). Panel D shows Broadcast: rank 0's buffer is copied (not reduced) to every rank. Panel E shows Reduce: like All-Reduce, but only rank 0 keeps the result. Panel F shows All-to-All as a grid transpose: rank i's j-th chunk lands as rank j's i-th chunk - this is the operation that shuffles tokens to experts in MoE models. The key identity - All-Reduce equals Reduce-Scatter followed by All-Gather - is exploited by ZeRO and FSDP to avoid materializing the full gradient sum on every rank simultaneously.

The key insight used in ZeRO-style data parallelism (see Distributed Training I) is that All-Reduce = Reduce-Scatter followed by All-Gather. This decomposition lets us interleave communication with computation.

torch.distributed Collective API

# collectives_demo.py — minimal, heavily-commented examples
import torch
import torch.distributed as dist

def demo_collectives(rank, world_size, device):
    """Demonstrate all six collectives on a simple tensor."""

    # ── 1. Broadcast ──────────────────────────────────────────────────────────
    # Only rank 0 sets meaningful data; others receive it.
    data = torch.zeros(4, device=device)
    if rank == 0:
        data = torch.tensor([1.0, 2.0, 3.0, 4.0], device=device)
    dist.broadcast(data, src=0)
    # After: every rank holds [1, 2, 3, 4].

    # ── 2. Reduce ─────────────────────────────────────────────────────────────
    x = torch.tensor([float(rank + 1)], device=device)
    dist.reduce(x, dst=0, op=dist.ReduceOp.SUM)
    # After: rank 0 holds sum(1,2,...,world_size); others unchanged.

    # ── 3. All-Reduce ─────────────────────────────────────────────────────────
    x = torch.tensor([float(rank)], device=device)
    dist.all_reduce(x, op=dist.ReduceOp.SUM)
    # After: every rank holds sum(0, 1, ..., world_size-1).

    # ── 4. Reduce-Scatter ─────────────────────────────────────────────────────
    # Each rank contributes a buffer of size world_size;
    # rank i receives the sum of element i from all ranks.
    input_tensor = torch.arange(world_size, dtype=torch.float32, device=device) + rank
    output_tensor = torch.zeros(1, device=device)
    dist.reduce_scatter(output_tensor, list(input_tensor.split(1)), op=dist.ReduceOp.SUM)
    # Element i on rank r equals (i + r), so element i summed across all ranks is
    #   sum over r of (i + r) = world_size*i + world_size*(world_size-1)/2.
    # Rank i receives element i, so the outputs differ per rank (illustrating the scatter).
    # For world_size=4: rank 0 -> 6.0, rank 1 -> 10.0, rank 2 -> 14.0, rank 3 -> 18.0.

    # ── 5. All-Gather ─────────────────────────────────────────────────────────
    my_chunk = torch.tensor([float(rank)], device=device)
    gathered = [torch.zeros(1, device=device) for _ in range(world_size)]
    dist.all_gather(gathered, my_chunk)
    # After: gathered = [tensor(0), tensor(1), ..., tensor(world_size-1)] on every rank.

    # ── 6. All-to-All ─────────────────────────────────────────────────────────
    # Each rank sends a different value to each other rank.
    # input_list[j] goes TO rank j; output_list[j] comes FROM rank j.
    input_list  = [torch.tensor([rank * 10.0 + j], device=device) for j in range(world_size)]
    output_list = [torch.zeros(1, device=device) for _ in range(world_size)]
    dist.all_to_all(output_list, input_list)
    # Rank 0 receives [0*10+0, 1*10+0, 2*10+0, 3*10+0] = [0, 10, 20, 30]
    # (what each other rank sent to slot 0)

Asynchronous Operations

Every collective has an async variant that returns a Work handle, allowing you to overlap computation with communication:

# Overlap gradient all-reduce with backward pass (simplified DDP idea)
import torch
import torch.distributed as dist

def overlapped_allreduce_example(grad_tensor, device):
    """
    Fire off the all-reduce without blocking; do other work; then wait.
    This is the heart of DDP's gradient communication overlap.
    """
    # Non-blocking: returns immediately; communication starts in background.
    handle = dist.all_reduce(grad_tensor, op=dist.ReduceOp.SUM, async_op=True)

    # ... CPU work or other GPU kernels can run here ...
    # For example, you could compute loss.backward() for the next micro-batch.

    # Block until the all-reduce is complete before using grad_tensor.
    handle.wait()

    # Scale by 1/world_size to get the average gradient.
    grad_tensor /= dist.get_world_size()
    return grad_tensor

Point-to-Point: send, recv and batch_isend_irecv

Collectives are not the whole story. Pipeline parallelism moves activations from stage \(i\) to stage \(i+1\) and gradients back, which is a pure point-to-point (P2P) exchange between two ranks — no collective involved. NCCL exposes this as dist.send/dist.recv (blocking) and dist.isend/dist.irecv (async).

The footgun is ordering: if every rank calls blocking send first and recv second, and every send is large enough to exceed the transport’s internal buffering, the whole ring deadlocks because no one is receiving. The two safe patterns are (a) alternate the order by parity, or (b) use dist.batch_isend_irecv, which hands NCCL the whole set of P2P ops at once so it can group them into a single communication round:

# p2p_ring_exchange.py — each rank sends to rank+1 and receives from rank-1.
import torch
import torch.distributed as dist

def ring_exchange(send_buf: torch.Tensor, recv_buf: torch.Tensor):
    """Deadlock-free neighbour exchange — the primitive under 1F1B pipelining."""
    rank, world_size = dist.get_rank(), dist.get_world_size()
    next_rank = (rank + 1) % world_size
    prev_rank = (rank - 1) % world_size

    # Post BOTH ops together. NCCL fuses them into one grouped call, so there is
    # no send-before-recv ordering hazard even if every rank is symmetric.
    ops = [
        dist.P2POp(dist.isend, send_buf, next_rank),
        dist.P2POp(dist.irecv, recv_buf, prev_rank),
    ]
    for req in dist.batch_isend_irecv(ops):
        req.wait()
    return recv_buf

# Blocking alternative — correct ONLY because the order alternates by parity:
#   if rank % 2 == 0:
#       dist.send(send_buf, dst=next_rank); dist.recv(recv_buf, src=prev_rank)
#   else:
#       dist.recv(recv_buf, src=prev_rank); dist.send(send_buf, dst=next_rank)

Two rules that will save you hours: the receiver must allocate a buffer of exactly the right shape and dtype (NCCL P2P carries no metadata, so a shape mismatch silently corrupts memory or hangs), and both ranks must agree on the number and order of P2P ops. Megatron-LM’s p2p_communication.py is essentially this function plus shape negotiation and CUDA-stream bookkeeping; see Distributed Training II.

Ring All-Reduce: The Algorithm That Scaled Deep Learning

Naive all-reduce has rank 0 collect everything then broadcast — a star topology that makes rank 0 a bottleneck. Ring all-reduce eliminates this bottleneck and achieves near-optimal bandwidth utilization.

Phase 1 · Reduce-Scatter n-1 steps · send a chunk, sum what you receive G0 [A₀ A₁ A₂ A₃] G1 [B₀ B₁ B₂ B₃] G2 [C₀ C₁ C₂ C₃] G3 [D₀ D₁ D₂ D₃] send A₀ send B₁ send C₂ send D₃ after n-1 steps each GPU owns one fully-summed chunk G0:S₃ G1:S₀ G2:S₁ G3:S₂ Sᵢ = Aᵢ+Bᵢ+Cᵢ+Dᵢ then Phase 2 · All-Gather n-1 steps · circulate the reduced chunks G0 [S₀ S₁ S₂ S₃] G1 [S₀ S₁ S₂ S₃] G2 [S₀ S₁ S₂ S₃] G3 [S₀ S₁ S₂ S₃] every GPU now holds the complete reduced buffer identical on all n ranks cost / rank (n-1)/n·M + (n-1)/n·M = 2(n-1)/n·M to 2M
Ring all-reduce = reduce-scatter, then all-gather. Each GPU's buffer is split into n chunks; in n−1 reduce-scatter steps a chunk hops around the ring and is summed at every hop, so each rank ends up owning one fully-reduced chunk. A second n−1-step all-gather circulates those reduced chunks until everyone holds the full result. Each phase moves only (n−1)/n·M bytes per rank, so the total is 2(n−1)/n·M → 2M independent of n — the bandwidth-optimal lower bound.

Algorithm

Arrange \(n\) ranks in a logical ring. The algorithm runs in two phases, each consisting of \(n-1\) steps:

Phase 1 — Reduce-Scatter: Each rank sends one chunk and receives one chunk per step, accumulating partial sums.

Phase 2 — All-Gather: Each rank sends fully-reduced chunks around the ring until every rank has every chunk.

Ring All-Reduce - Reduce-Scatter Phase (n=4, n-1=3 steps) Rank 0 A B C D sends A | owns D-slot result: D+C+B+A (after 3 steps) Rank 1 A B C D sends B | owns A-slot result: A+D+C+B (after 3 steps) Rank 2 A B C D sends C | owns B-slot result: B+A+D+C (after 3 steps) Rank 3 A B C D sends D | owns C-slot result: C+B+A+D (after 3 steps) accumulator per rank after step 0 R0=D R1=A R2=B R3=C after step 1 R0=D+C R1=A+D R2=B+A R3=C+B after step 2 (recv) R0=D+C+B R1=A+D+C R2=B+A+D R3=C+B+A +own = reduced R0=D+C+B+A (4 terms) A B C D A+D B+A C+B D+C A+D+C B+A+D C+B+A D+C+B Step 0 - raw chunks fly Each rank sends one raw chunk to its clockwise neighbor. 0->1: A 1->2: B 2->3: C 3->0: D (receivers start accumulating) Step 1 - partial sums grow Each rank forwards the accumulated partial sum it received last step. 0->1: A+D 1->2: B+A 2->3: C+B 3->0: D+C Step 2 - final sums delivered; reduce-scatter complete Each rank now owns one fully-reduced chunk (green glow). All-gather follows (3 more steps). R0 owns [D+C+B+A] R1 owns [A+D+C+B] R2 owns [B+A+D+C] R3 owns [C+B+A+D] in-flight partial sum fully reduced (green) each transfer = M/n bytes per step | total per rank over reduce-scatter = (n-1)/n . M (bandwidth-optimal)
Ring all-reduce reduce-scatter: n-1=3 clockwise steps accumulate one fully-reduced chunk per rank. Each step every rank sends M/n bytes to its clockwise neighbor; the partial sum grows by one addend per hop (blue packets). After step 2 (green packets), each rank owns one fully-reduced slot. The all-gather phase (3 more steps, not shown) then propagates these chunks so every rank holds the complete result, making ring all-reduce bandwidth-optimal at 2(n-1)/n . M bytes per rank total.

The total data sent per rank per phase is \(\frac{n-1}{n} \cdot M\) (each step sends \(M/n\) bytes). Both phases together send \(2 \cdot \frac{n-1}{n} \cdot M\) bytes per rank, approaching \(2M\) for large \(n\). This is also the lower bound — ring all-reduce is bandwidth-optimal.

Tree All-Reduce

For small messages where the latency of \(2(n-1)\) steps dominates, a binary tree or recursive halving/doubling algorithm is better. In \(\log_2 n\) steps, a tree reduces all values. This is the regime of optimizer-state broadcast or short control messages.

The tradeoff:

Algorithm Latency (steps) Bandwidth per rank Best for
Ring \(2(n-1)\) \(\frac{2(n-1)}{n} M\)\(2M\) Large messages (gradients)
Tree (binary) \(2 \log_2 n\) \(O(M \log n)\) Small messages, latency-sensitive
Recursive halving \(\log_2 n\) reduce + \(\log_2 n\) broadcast Near-optimal Medium messages

The \(O(M \log n)\) row describes a naive binomial-tree reduce followed by a broadcast, where the same bytes traverse every level. NCCL’s actual Tree algorithm is smarter: it is a double binary tree (Sanders et al., 2009), which overlays two complementary trees so that every node is an interior node in one and a leaf in the other. This keeps the \(O(\log n)\) latency and recovers near-optimal bandwidth, which is why NCCL often prefers Tree over Ring for large-message all-reduce at high node counts — the ring’s \(2(n-1)\alpha\) term becomes intolerable long before its bandwidth term does.

NCCL selects among Ring, Tree, NVLS, CollNet and friends automatically, using an internal cost model over message size, topology, and rank count. You can override it for experiments with NCCL_ALGO=Tree / NCCL_ALGO=Ring and force a wire protocol with NCCL_PROTO=Simple|LL|LL128 (LL = low-latency, used for tiny messages; LL128 exploits 128-byte NVLink flits). Overriding is a diagnostic tool, not a tuning strategy — NCCL’s defaults beat hand-picked settings on almost every cluster.

Bandwidth and Latency Cost Models

To reason about whether your training run is compute-bound or communication-bound, you need a simple cost model. The alpha-beta model (also called the Hockney model; the LogP family of models is a richer refinement that additionally separates per-message CPU overhead from network occupancy) approximates the time to send a message of \(B\) bytes as:

\[ T(B) = \alpha + \frac{B}{\beta} \]

where \(\alpha\) is the latency (startup cost in seconds, independent of message size) and \(\beta\) is the bandwidth (bytes per second, the asymptotic rate for large messages).

For a ring all-reduce of \(M\) total bytes across \(n\) ranks:

\[ T_{\text{ring-AR}}(M) = 2(n-1)\alpha + \frac{2(n-1)}{n} \cdot \frac{M}{\beta} \]

As \(n\) grows, the latency term \(2(n-1)\alpha\) becomes painful for many small messages — this is why gradient bucketing (combining small gradients into large tensors before communicating) is critical in practice.

For very large \(n\), the bandwidth term simplifies:

\[ T_{\text{ring-AR}}(M) \approx 2\alpha n + 2\frac{M}{\beta} \]

The bandwidth term is constant in \(n\) — adding more GPUs doesn’t change the bandwidth cost. The latency term grows linearly, which is why ring all-reduce across thousands of GPUs requires hierarchical approaches.

Alpha-Beta Communication Cost Model T(B) = alpha + B / beta message size B (small -> large) transfer time T latency floor alpha (fixed startup, per call) slope = 1/beta (bandwidth-limited) B* = alpha * beta (equal cost) latency-bound T ~ alpha, message too small to amortize startup bandwidth-bound T ~ B/beta, transfer dominated by raw bytes gradient bucketing merges many tiny messages -> pushes into bandwidth regime (axes qualitative - not to scale) Ring All-Reduce: Latency Floor Grows With n T_ring(M) = 2(n-1)*alpha + (2(n-1)/n) * (M/beta) message size M per rank (small -> large) T n=4 n=16 n=64 ring latency floor 2(n-1)*alpha grows with n -> why we go hierarchical (fewer, bigger hops) at large n
The alpha-beta cost model T(B) = alpha + B/beta separates a fixed per-call latency floor from a bandwidth-limited slope. Small messages (left, amber) never amortize the startup cost alpha and are latency-bound; large messages (right, green) are dominated by raw bytes moved and are bandwidth-bound; the crossover B* = alpha*beta is where the two terms cost the same — this is why gradient bucketing (merging many tiny messages into one large one) pushes communication into the cheaper bandwidth-bound regime. The inset shows the ring all-reduce variant: three lines with the same bandwidth slope but a latency-floor intercept 2(n-1)*alpha that grows with the number of ranks n, which is exactly why large clusters use hierarchical (intra-node then inter-node) collectives instead of one flat ring.

Worked Example: Is a DDP Training Step Bottlenecked by Communication?

Setup: 8 GPUs on a single node connected via NVLink. We are training a 1.3B parameter model in bf16 (2 bytes/parameter). After the backward pass, we need to all-reduce gradients.

Gradient buffer size: $\(M = 1.3 \times 10^9 \times 2 \text{ bytes} = 2.6 \text{ GB}\)$

NVLink bandwidth (H100 SXM): on the order of 900 GB/s aggregate bidirectional, or roughly \(\beta \approx 450\) GB/s per direction for a single link. With 8 GPUs in a ring, effective bandwidth is approximately \(\beta_{\text{eff}} \approx 300\) GB/s (after ring inefficiency and protocol overhead — use this as an engineering estimate, not a specification).

Communication time: $\(T_{\text{comm}} \approx \frac{2M}{\beta_{\text{eff}}} = \frac{2 \times 2.6 \text{ GB}}{300 \text{ GB/s}} \approx 17 \text{ ms}\)$

Compute time (forward + backward): For a 1.3B model on a batch of 32 tokens × 2048 context on H100, a rough estimate is on the order of 200–400 ms total. Communication is therefore on the order of 5–10% of step time — manageable, and further reducible by overlapping all-reduce with backward pass.

Cross-node scenario: If instead the 8 GPUs span 2 nodes connected by 200 Gb/s InfiniBand (\(\beta \approx 25\) GB/s): $\(T_{\text{comm}} \approx \frac{2 \times 2.6}{25} \approx 208 \text{ ms}\)$

Now communication exceeds compute time. The only remedies are gradient compression, ZeRO with reduce-scatter/all-gather split, or tensor/pipeline parallelism to reduce the communicated volume.

Communication cost is not uniform across a cluster. You need to understand the physical topology to reason about where collectives should be placed.

NVLink is NVIDIA’s proprietary high-bandwidth GPU-to-GPU interconnect. On an H100 SXM server, each GPU has 18 NVLink 4.0 lanes, providing roughly 900 GB/s bidirectional bandwidth per GPU — vastly higher than PCIe Gen 5 (on the order of 128 GB/s bidirectional). H100/H200 (NVLink 4) remain common training and inference hardware as of 2026, but the current frontier generation is Blackwell, whose fifth-generation NVLink roughly doubles per-GPU bandwidth to about 1.8 TB/s.

NVSwitch is a crossbar switch that connects all GPUs on a node with full NVLink bandwidth — every GPU can communicate with every other GPU simultaneously at full speed, rather than routing through a chain. An 8-GPU DGX H100 uses four NVSwitch 3.0 chips, providing an effective 3.6 TB/s of all-to-all bandwidth within the node. NVIDIA’s Blackwell-generation GB200 NVL72 rack extends this idea to rack scale: NVSwitch fabric ties 72 GPUs into a single NVLink domain with roughly 130 TB/s of aggregate GPU-to-GPU bandwidth, letting an all-to-all or all-reduce span far more GPUs before ever touching the slower inter-node network.

This topology means:

DGX H100 Intra-Node Topology 8 x H100 SXM through NVSwitch crossbar fabric GPU 0 GPU 1 GPU 2 GPU 3 GPU 4 GPU 5 GPU 6 GPU 7 4 x NVSwitch 3.0 (crossbar fabric) Non-blocking all-to-all NVLink 4.0 18 lanes/GPU NVLink 900 GB/s Full all-to-all ~900 GB/s per GPU (bidirectional) ~3.6 TB/s aggregate all-to-all vs. PCIe Gen 5: ~128 GB/s (7x slower) NCCL on NVSwitch: One-shot reduce across fabric - no sequential ring needed (every GPU is a direct neighbor) Every GPU can talk to every other GPU simultaneously at full NVLink speed. NCCL exploits this with a one-shot reduce across the switch fabric, bypassing the sequential ring.
DGX H100 intra-node topology: 8 GPUs through a 4x NVSwitch 3.0 crossbar. Each GPU has its own independent full-bandwidth NVLink connection to the switch fabric (8 distinct lines, not a shared bus), giving every GPU direct access to every other GPU simultaneously at ~900 GB/s. This non-blocking all-to-all topology lets NCCL use a single-step reduce pattern across the fabric rather than the sequential ring algorithm used over slower inter-node InfiniBand.

NCCL exploits NVSwitch by using its own all-reduce algorithm that leverages the all-to-all connectivity, avoiding a sequential ring in favor of a one-shot reduce pattern across the switch fabric. On Hopper and Blackwell systems this is the NVLS algorithm (NVLink SHARP): the NVSwitch chips contain arithmetic units that perform the reduction inside the switch, so each GPU pushes its buffer once and pulls the reduced result once instead of relaying \(2\frac{n-1}{n}M\) bytes around a ring. InfiniBand has the analogous SHARP in-network reduction in Quantum switches for the inter-node hop. You will see NVLS and NVLS Tree appear in NCCL_DEBUG=INFO logs when NCCL picks these paths.

Inter-Node: InfiniBand

Between nodes, current clusters use InfiniBand (IB). Common configurations:

  • HDR (200 Gb/s): ~25 GB/s effective unidirectional per port
  • NDR (400 Gb/s): ~50 GB/s effective unidirectional per port, the mainstream choice in most H100/H200-era clusters
  • XDR (800 Gb/s): shipping since 2024 (e.g., NVIDIA Quantum-X800 switches) and increasingly common in new Blackwell-generation deployments as of 2026

A cluster of nodes is connected through an IB fabric, often organized as a fat-tree or dragonfly topology, providing full bisection bandwidth in principle (but subject to hotspots in practice). The IB Host Channel Adapter (HCA) on each node connects the CPUs and GPUs to the fabric; GPU Direct RDMA (Remote Direct Memory Access) allows the NIC to read/write GPU HBM directly, bypassing the CPU.

Hierarchical Communication

Because intra-node bandwidth is ~10–50× higher than inter-node bandwidth, production NCCL jobs use hierarchical collectives: first reduce within a node (fast, NVLink), then reduce across nodes (slower, IB), then broadcast back. NCCL implements this automatically via its topology detection.

Hierarchical All-Reduce: 4 Nodes x 8 GPUs Stage 1 Stage 2 Stage 3 Intra-node reduce-scatter (NVLink) Inter-node all-reduce (InfiniBand) Intra-node all-gather (NVLink) Node 0 1/8 Each GPU now owns reduced 1/8 segment Node 1 1/8 Node 2 1/8 Node 3 1/8 ~900 GB/s (NVLink) S1 to S2 Node 0 seg Node 1 seg Node 2 seg Node 3 seg IB ~25-50 GB/s (InfiniBand) - bottleneck S2 to S3 Node 0 full all GPUs hold full result Node 1 full Node 2 full Node 3 full ~900 GB/s (NVLink) Intra-node BW (~900 GB/s NVLink) is ~10-50x faster than inter-node (~25-50 GB/s InfiniBand). NVLink steps (S1, S3) wrap the single expensive IB step (S2). NCCL detects topology and does this automatically.
Hierarchical all-reduce across 4 nodes x 8 GPUs: fast-slow-fast. Stage 1 uses NVLink (~900 GB/s) to reduce-scatter within each node so each node holds one fully-reduced 1/8 segment. Stage 2 performs the expensive all-reduce across nodes over InfiniBand (~25-50 GB/s) - only the small segments cross the wire. Stage 3 uses NVLink again to all-gather within each node so every GPU ends with the complete result. NCCL detects the topology and applies this hierarchy automatically.

A Complete torch.distributed Training Step

The following implements a minimal DDP-style training loop from scratch, showing exactly where each collective fires and why.

# minimal_ddp.py
# Run: torchrun --nproc_per_node=4 minimal_ddp.py
import os
import torch
import torch.nn as nn
import torch.distributed as dist
from torch.utils.data import DataLoader, TensorDataset, DistributedSampler

# ──────────────────────────────────────────────────────────────────────────────
# 1. Initialize distributed environment
# ──────────────────────────────────────────────────────────────────────────────
dist.init_process_group(backend="nccl")
rank       = dist.get_rank()
world_size = dist.get_world_size()
local_rank = int(os.environ["LOCAL_RANK"])   # node-local device index
device     = torch.device(f"cuda:{local_rank}")
torch.cuda.set_device(device)

# ──────────────────────────────────────────────────────────────────────────────
# 2. Build a toy model. Every rank starts with a random init;
#    we must broadcast weights from rank 0 so all ranks start identically.
# ──────────────────────────────────────────────────────────────────────────────
class TinyModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.linear = nn.Linear(128, 10)

    def forward(self, x):
        return self.linear(x)

model = TinyModel().to(device)

# Broadcast initial parameters so all ranks are identical.
for param in model.parameters():
    dist.broadcast(param.data, src=0)

# ──────────────────────────────────────────────────────────────────────────────
# 3. Build a toy dataset. DistributedSampler ensures each rank gets
#    a non-overlapping shard of the data, which is the "data parallel" part.
# ──────────────────────────────────────────────────────────────────────────────
N = 1024
dataset = TensorDataset(
    torch.randn(N, 128),
    torch.randint(0, 10, (N,))
)
sampler    = DistributedSampler(dataset, num_replicas=world_size, rank=rank)
dataloader = DataLoader(dataset, batch_size=32, sampler=sampler)

optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
criterion = nn.CrossEntropyLoss()

# ──────────────────────────────────────────────────────────────────────────────
# 4. Training loop: forward → backward → all-reduce gradients → optimizer step
# ──────────────────────────────────────────────────────────────────────────────
for epoch in range(2):
    sampler.set_epoch(epoch)  # ensures different shuffling per epoch

    for x_batch, y_batch in dataloader:
        x_batch = x_batch.to(device)
        y_batch = y_batch.to(device)

        optimizer.zero_grad()
        logits = model(x_batch)
        loss   = criterion(logits, y_batch)
        loss.backward()

        # ── All-Reduce: average gradients across all ranks ──────────────────
        # Without this, each rank would update with its own local gradient,
        # causing model divergence after the first step.
        for param in model.parameters():
            if param.grad is not None:
                # SUM then divide → average. The NCCL backend also supports
                # op=dist.ReduceOp.AVG directly, which is both fewer kernels and
                # numerically safer in bf16 (it divides inside the reduction
                # tree instead of letting the sum grow by a factor of world_size
                # before scaling). Gloo/CPU does not support AVG.
                dist.all_reduce(param.grad.data, op=dist.ReduceOp.SUM)
                param.grad.data /= world_size
        # ────────────────────────────────────────────────────────────────────

        optimizer.step()

    if rank == 0:
        print(f"Epoch {epoch}: loss = {loss.item():.4f}")

dist.destroy_process_group()

In production, torch.nn.parallel.DistributedDataParallel (DDP) wraps this loop and uses a more sophisticated gradient bucketing scheme to maximize overlap between backward computation and all-reduce communication. The principle is identical to the manual loop above.

Process Groups and Custom Communicators

So far we’ve used the default process group containing all ranks. For pipeline parallelism and tensor parallelism (see Distributed Training II), you need sub-groups where only a subset of ranks communicate.

# process_groups.py — illustrating tensor-parallel sub-groups
import torch.distributed as dist

def build_tp_dp_groups(tp_size: int):
    """
    Construct tensor-parallel (TP) and data-parallel (DP) process groups.

    For world_size=8 and tp_size=4:
      TP groups (communicate tensor shards):  [0,1,2,3] and [4,5,6,7]
      DP groups (communicate gradients):      [0,4], [1,5], [2,6], [3,7]

    Within a TP group, all-gather/reduce-scatter move activation shards.
    Across DP groups, all-reduce moves gradient updates.
    """
    world_size = dist.get_world_size()
    rank       = dist.get_rank()
    assert world_size % tp_size == 0
    dp_size = world_size // tp_size

    # ── Tensor-parallel groups ────────────────────────────────────────────────
    tp_group = None
    for i in range(dp_size):
        ranks_in_group = list(range(i * tp_size, (i + 1) * tp_size))
        group = dist.new_group(ranks=ranks_in_group)
        if rank in ranks_in_group:
            tp_group = group

    # ── Data-parallel groups ──────────────────────────────────────────────────
    dp_group = None
    for j in range(tp_size):
        ranks_in_group = list(range(j, world_size, tp_size))
        group = dist.new_group(ranks=ranks_in_group)
        if rank in ranks_in_group:
            dp_group = group

    return tp_group, dp_group

# Usage:
# tp_group, dp_group = build_tp_dp_groups(tp_size=4)
# dist.all_reduce(tensor_shard, group=tp_group)   # fast, NVLink
# dist.all_reduce(grad,         group=dp_group)   # slower, IB

NCCL treats each new_group call as a new communicator; it internally runs its topology detection and algorithm selection within that group.

DeviceMesh: The Library API for the Same Idea

Hand-rolling nested new_group loops is exactly the code that gets subtly wrong when you go from 2D (DP × TP) to 4D (DP × TP × PP × CP). PyTorch’s DeviceMesh (torch.distributed.device_mesh) is the supported abstraction for it: you declare an \(n\)-dimensional logical grid of devices with named dimensions, and it builds and caches every sub-group for you. It is the substrate that FSDP2, TensorParallel, and DTensor are all written against.

# device_mesh_demo.py — the same TP/DP decomposition as above, in 3 lines.
from torch.distributed.device_mesh import init_device_mesh

# world_size = 8, laid out as dp=2 x tp=4.
# Mesh dimensions vary fastest on the RIGHT, so this yields exactly:
#   tp groups: [0,1,2,3], [4,5,6,7]      (contiguous → lands on NVLink)
#   dp groups: [0,4], [1,5], [2,6], [3,7]
mesh = init_device_mesh("cuda", (2, 4), mesh_dim_names=("dp", "tp"))

tp_group = mesh["tp"].get_group()   # a real ProcessGroup, usable with dist.*
dp_group = mesh["dp"].get_group()

# dist.all_reduce(activation_shard, group=tp_group)  # fast, intra-node
# dist.all_reduce(grad,             group=dp_group)  # slower, inter-node

# Higher-level APIs consume the mesh directly:
#   from torch.distributed.fsdp import fully_shard
#   fully_shard(model, mesh=mesh["dp"])
#   from torch.distributed.tensor.parallel import parallelize_module
#   parallelize_module(block, mesh["tp"], {...})

Note the ordering rule, which is the whole reason meshes are worth using: the last mesh dimension is the fastest-varying, so putting tp last guarantees tensor-parallel ranks are contiguous and therefore co-located on one node behind NVLink, while the data-parallel dimension is the one that straddles the slow InfiniBand hop. Getting that backwards puts your highest-volume collective on your slowest link. Megatron-LM encodes the same convention in its parallel_state.py rank-ordering logic (see Megatron-LM, DeepSpeed & Parallelism in Practice).

Collective Performance: Benchmarking and Profiling

Understanding actual achieved bandwidth versus theoretical peak is essential for diagnosing training slowdowns.

# benchmark_allreduce.py — measure achieved bandwidth for a ring all-reduce
import os
import time
import torch
import torch.distributed as dist

def benchmark_all_reduce(message_bytes: int, n_iters: int = 50):
    """
    Measure the achieved bandwidth of all-reduce for a given message size.
    Returns (latency_ms, bandwidth_GBps).
    """
    rank       = dist.get_rank()
    world_size = dist.get_world_size()
    device     = torch.device(f"cuda:{int(os.environ['LOCAL_RANK'])}")

    # Allocate a float32 buffer of the requested size.
    n_elements = message_bytes // 4
    buf = torch.randn(n_elements, device=device)

    # Warm up: let NCCL initialize its internal state.
    for _ in range(5):
        dist.all_reduce(buf, op=dist.ReduceOp.SUM)
    torch.cuda.synchronize()
    dist.barrier()

    # Timed runs.
    start = time.perf_counter()
    for _ in range(n_iters):
        dist.all_reduce(buf, op=dist.ReduceOp.SUM)
    torch.cuda.synchronize()
    dist.barrier()
    elapsed = time.perf_counter() - start

    latency_ms = elapsed / n_iters * 1000
    # Busbw formula: 2*(n-1)/n * message_bytes / time  (ring all-reduce)
    busbw_GBps = (2 * (world_size - 1) / world_size * message_bytes) / (elapsed / n_iters) / 1e9

    if rank == 0:
        print(f"Message: {message_bytes/1e6:.1f} MB | "
              f"Latency: {latency_ms:.2f} ms | "
              f"Bus BW: {busbw_GBps:.1f} GB/s")
    return latency_ms, busbw_GBps


# Example results on an 8-GPU DGX H100 (NVLink):
# Message:   1.0 MB | Latency:  0.15 ms | Bus BW:  52.3 GB/s
# Message:  64.0 MB | Latency:  1.23 ms | Bus BW: 413.8 GB/s
# Message: 512.0 MB | Latency:  7.84 ms | Bus BW: 521.0 GB/s
# (Illustrative figures; actual results depend on driver version and cluster state)

The busbw (bus bandwidth) formula \(\frac{2(n-1)}{n} \cdot M / T\) is the standard metric because it accounts for the ring algorithm’s traffic pattern and is comparable across cluster sizes. Compare busbw to the theoretical NVLink bandwidth to understand efficiency. Note the correction factor is collective-specific: all-reduce uses \(\frac{2(n-1)}{n}\), while reduce-scatter and all-gather each use \(\frac{n-1}{n}\), and broadcast/reduce use \(1\). Using the wrong factor is the most common way people convince themselves a cluster is twice as fast as it is.

nccl-tests: The Standard Cluster Benchmark

Before blaming your training code, benchmark the fabric with the vendor tool. NVIDIA/nccl-tests is what every cluster acceptance test uses, and it reports both algbw (raw bytes/time) and busbw (the corrected metric above) at every message size:

# Build (MPI=1 if you want to sweep multiple nodes via mpirun).
git clone https://github.com/NVIDIA/nccl-tests && cd nccl-tests
make MPI=0 CUDA_HOME=/usr/local/cuda -j

# Sweep 8 B → 8 GB, doubling each step, across 8 GPUs on this node.
#   -b begin size   -e end size   -f size multiplier   -g GPUs per process
./build/all_reduce_perf -b 8 -e 8G -f 2 -g 8
# Columns:  size  count  type  redop  time(us)  algbw(GB/s)  busbw(GB/s)  #wrong
# Read the plateau of the busbw column: that is your real all-reduce bandwidth.

# Same sweep for the MoE-critical collective:
./build/alltoall_perf -b 1M -e 1G -f 2 -g 8

The environment variables you will actually reach for:

export NCCL_DEBUG=INFO                     # print topology + chosen algorithm
export NCCL_DEBUG_SUBSYS=INIT,GRAPH,ENV    # narrow the (very verbose) output
export NCCL_SOCKET_IFNAME=eth0             # which NIC carries bootstrap traffic
export NCCL_IB_HCA=mlx5_0,mlx5_1           # which InfiniBand HCAs to use
export NCCL_IB_DISABLE=0                   # set 1 to force TCP (diagnostic only)
export NCCL_P2P_DISABLE=0                  # set 1 to disable NVLink/P2P (diagnostic)

# PyTorch-side hang debugging (names are TORCH_*-prefixed since PyTorch 2.2):
export TORCH_NCCL_ASYNC_ERROR_HANDLING=1   # crash the rank instead of hanging
export TORCH_NCCL_TRACE_BUFFER_SIZE=2000   # NCCL "flight recorder": on a watchdog
                                           # timeout, dump the last N collectives
                                           # per rank so you can see WHICH rank
                                           # failed to enter WHICH collective.

The flight recorder is the single highest-value debugging tool for a hung multi-node job: a collective mismatch (one rank taking a different code path, as in the deadlock pitfall below) shows up immediately as one rank stuck on ALLREDUCE sequence number \(k\) while everyone else is at \(k+1\).

For production profiling, use PyTorch Profiler with NCCL tracing enabled:

from torch.profiler import profile, ProfilerActivity, schedule

with profile(
    activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
    schedule=schedule(wait=1, warmup=1, active=5),
    on_trace_ready=torch.profiler.tensorboard_trace_handler("./logs/profiler"),
    record_shapes=True,
    with_stack=True,
) as prof:
    for step, (x, y) in enumerate(dataloader):
        train_step(x, y, model, optimizer)
        prof.step()

# In TensorBoard → Distributed → you will see the NCCL ops timeline,
# showing exactly how much time is spent in AllReduce vs compute.

Interview Corner

Q: You’re training a 70B-parameter model in bf16 across 64 GPUs (8 nodes × 8 GPUs) with pure data parallelism. Each training step takes 1 second of compute. Your inter-node InfiniBand provides 400 Gb/s per link (one link per node). Is your job communication-bound? What would you do?

A: Gradient buffer size = \(70 \times 10^9 \times 2\) bytes \(= 140\) GB. For a ring all-reduce across \(n = 64\) ranks, the bandwidth term is approximately \(\frac{2(n-1)}{n} M \approx 2 \times 140 = 280\) GB of data per rank moved through the slowest link (IB). At 400 Gb/s = 50 GB/s effective, that’s \(\frac{280}{50} \approx 5.6\) seconds of communication — more than 5× the compute time, so yes, badly communication-bound.

Remedies in priority order — the goal is to cut communication volume on the slow inter-node hop, not just memory: (1) adopt tensor parallelism (TP-4 within node, over fast NVLink): each data-parallel rank then holds only ¼ of the parameters, so its gradient buffer shrinks from 140 GB to ~35 GB and the inter-node all-reduce moves ~4x less data per rank (~70 GB instead of ~280 GB), while TP’s own all-reduces stay on-node where bandwidth is 10-50x higher; (2) use hierarchical / hybrid sharding (e.g., FSDP HYBRID_SHARD / HSDP): shard within a node and replicate across nodes, keeping the heavy reduce-scatter and all-gather on NVLink and sending only reduced gradient shards over InfiniBand; (3) apply gradient compression (PowerSGD, Top-K sparsification) if convergence is acceptable; (4) increase per-GPU batch size or use gradient accumulation to raise the compute-to-communication ratio (more compute per all-reduce). Note that plain ZeRO Stage ½ does not fix this bottleneck: its reduce-scatter + all-gather sums to essentially the same total volume as DDP’s all-reduce (see Distributed Training I) — it removes the memory redundancy of replicated optimizer and gradient state, not the communication.

All-to-All and Expert Parallelism

The all-to-all collective is less common in vanilla data parallelism but is critical for Mixture-of-Experts (MoE) models (see Mixture-of-Experts (MoE) Architectures). In MoE, tokens are routed to expert sub-networks that may live on different GPUs. Sending each token to its assigned expert is exactly an all-to-all operation.

# all_to_all_moe_sketch.py — illustrating token dispatch in MoE
import torch
import torch.distributed as dist

def moe_dispatch(tokens: torch.Tensor, expert_ids: torch.Tensor, n_experts: int):
    """
    tokens: [seq_len, hidden_dim]  — tokens on this GPU
    expert_ids: [seq_len]          — which expert each token should go to
    n_experts: total number of experts, one per GPU

    Returns: tokens sorted and dispatched to the correct expert GPU.
    """
    rank = dist.get_rank()
    world_size = dist.get_world_size()
    assert n_experts == world_size  # one expert per GPU for simplicity

    seq_len, hidden = tokens.shape

    # Count how many tokens go to each expert.
    counts = torch.bincount(expert_ids, minlength=world_size)  # [world_size]

    # All-to-all the counts so each rank knows what it will receive.
    recv_counts = torch.zeros_like(counts)
    dist.all_to_all_single(recv_counts, counts)

    # Sort tokens by destination expert.
    sort_idx = expert_ids.argsort()
    sorted_tokens = tokens[sort_idx]  # [seq_len, hidden], grouped by expert

    # Build send/recv lists for all-to-all.
    send_splits = counts.tolist()
    recv_splits = recv_counts.tolist()

    # Allocate receive buffer.
    total_recv = recv_counts.sum().item()
    recv_buf = torch.zeros(total_recv, hidden, device=tokens.device, dtype=tokens.dtype)

    # Perform the actual all-to-all data transfer.
    dist.all_to_all_single(
        recv_buf,
        sorted_tokens,
        output_split_sizes=recv_splits,
        input_split_sizes=send_splits,
    )

    # recv_buf now contains tokens that belong to this GPU's expert.
    return recv_buf, recv_counts

The all-to-all cost is \(O(M)\) where \(M\) is the total token volume, and unlike all-reduce it does not benefit from the ring structure — it is inherently limited by the bisection bandwidth of the network.

Expert0 Expert1 Expert2 Expert3 BEFORE scattered by router AFTER sorted by destination ALL-TO-ALL 4 GPUs, 1 expert per GPU GPU0 GPU1 GPU2 GPU3 GPU1's chunk-for-2 GPU2's chunk-for-1 GPU0 - Expert0 GPU1 - Expert1 GPU2 - Expert2 GPU3 - Expert3 each GPU holds tokens bound for many experts each GPU now holds only its own expert's tokens all-to-all = transpose: rank i's chunk-for-j lands on rank j cost ~ O(total token volume), limited by network bisection bandwidth - unlike all-reduce it gets no ring speedup
All-to-all is a grid transpose: what looked scattered on every GPU becomes sorted by expert on every GPU. Before dispatch, each GPU's tokens are colored by destination expert and mixed together (left); the all-to-all sends GPU i's chunk of color j to GPU j, so afterward every GPU holds a single-color block ready for its resident expert's local compute (right). Because this shuffle moves the full token volume through the network with no ring-style amortization, its cost is bounded by bisection bandwidth rather than by GPU count.

Key Considerations for Choosing Collectives

Different distributed training strategies use different collectives as their communication primitive:

Strategy Key Collective Why
Data Parallelism (DDP) All-Reduce (gradients) Every rank needs the globally averaged gradient
ZeRO Stage 1 Reduce-Scatter (grads) + All-Gather (params) Shard optimizer state; materialize only on demand
ZeRO Stage 2 Reduce-Scatter (grads) + All-Gather (params) Also shard gradients
ZeRO Stage 3 / FSDP All-Gather (forward), Reduce-Scatter (backward) Full parameter sharding
Tensor Parallelism All-Reduce (within TP group) Reconstruct activation shards after matmul split
Expert Parallelism All-to-All Route tokens to their expert GPUs
Pipeline Parallelism Point-to-Point (send/recv) Pass activations between pipeline stages

Understanding this table is what separates an engineer who can debug a distributed training job from one who cannot. If your profiler shows an all-gather is slow, you know FSDP is materializing parameters and the bottleneck is memory bandwidth for the parameter gather, not compute. If all-to-all is slow, you have an MoE routing or load-imbalance problem.

Common Pitfall: Collective Deadlock

Every collective is a barrier — all participating ranks must call it before any rank can proceed. A common bug is a conditional collective:

# WRONG — only rank 0 calls all-reduce; all others hang forever.
if rank == 0:
    dist.all_reduce(tensor)  # deadlock!

# CORRECT — every rank calls every collective, always.
dist.all_reduce(tensor)
if rank == 0:
    process(tensor)  # branch after the collective, not before.

Similarly, mismatched new_group calls will deadlock because new_group internally calls a barrier across all ranks to initialize the communicator.

Practitioner Tip: Gradient Bucketing

By default, PyTorch DDP groups parameters into 25 MB buckets and fires an all-reduce for each bucket as soon as all gradients in that bucket are ready during backward. This pipeline — compute gradients for later layers while earlier-layer all-reduces are in flight — is responsible for most of the overlap benefit. You can tune the bucket size with DDP(model, bucket_cap_mb=50). Larger buckets reduce the number of all-reduce calls (lower latency overhead) but delay when communication starts (less overlap). Optimal bucket size depends on your model’s backward time per layer.

Key Takeaways

  • Distributed GPU training uses the SPMD model: one process per GPU, all running the same program, differentiated by rank.
  • The six core collectives are: Broadcast, Reduce, All-Reduce, Reduce-Scatter, All-Gather, and All-to-All. All-Reduce is equivalent to Reduce-Scatter followed by All-Gather.
  • Ring all-reduce achieves near-optimal bandwidth by distributing load uniformly across \(n\) ranks; its bandwidth cost is \(\approx 2M\) bytes per rank regardless of \(n\).
  • The alpha-beta cost model \(T = \alpha + B/\beta\) separates latency (per-call startup) from bandwidth (asymptotic rate). Large messages are bandwidth-bound; small messages are latency-bound.
  • NVLink/NVSwitch provides ~10–50× more bandwidth than InfiniBand, making intra-node collectives much cheaper than inter-node ones. Hierarchical collectives exploit this.
  • Different parallelism strategies use different collectives: data parallelism uses all-reduce, ZeRO/FSDP uses reduce-scatter+all-gather, tensor parallelism uses all-reduce within a sub-group, and MoE uses all-to-all.
  • Process groups (dist.new_group) let you create communicators over subsets of ranks, enabling the 3D parallelism (DP × TP × PP) used by Megatron-LM; in modern PyTorch you declare them once as a named DeviceMesh and let FSDP2/DTensor consume it, with the fastest-varying (last) mesh dimension deliberately placed on the fastest link.
  • Pipeline parallelism uses point-to-point isend/irecv rather than collectives; post sends and receives together with batch_isend_irecv to avoid ordering deadlocks.
  • Every collective is a synchronization barrier — missing a collective call or making it conditional causes deadlock. TORCH_NCCL_TRACE_BUFFER_SIZE (the NCCL flight recorder) tells you which rank stalled on which collective; nccl-tests tells you whether the fabric itself is healthy.

State of the Art & Resources (2026)

Collective communication is a mature but rapidly evolving field: ring all-reduce remains the workhorse for large-message gradient synchronization, while NVLink/NVSwitch fabrics, hierarchical collectives, and algorithm-selection heuristics in NCCL 2.x continue to push practical bandwidth efficiency close to hardware limits at scales of 10,000+ GPUs.

Foundational work

Recent advances (2021–2024)

Open-source & tools

  • NVIDIA/nccl — the authoritative implementation of GPU collective communication; topology-aware algorithm selection, NVLink and InfiniBand support.
  • NVIDIA/nccl-tests — benchmarking suite for measuring achieved bus-bandwidth across all NCCL collective operations; standard tool for cluster acceptance testing.
  • NVIDIA/Megatron-LM — reference implementation of 3D-parallel transformer training; shows exactly which collectives fire in each parallelism dimension.
  • DeepSeek-AI/DeepEP — open-source, high-throughput/low-latency all-to-all GPU kernels purpose-built for MoE dispatch/combine, the exact operation sketched in this chapter’s moe_dispatch example; illustrates how far the all-to-all collective has been specialized since the vanilla dist.all_to_all_single path.

Go deeper

Further Reading

  • Thakur, Rabenseifner, Gropp (2005): “Optimization of Collective Communication Operations in MPICH” — the foundational paper on ring and tree all-reduce algorithms and their cost models.
  • Li et al. (2020): “PyTorch Distributed: Experiences on Accelerating Data Parallel Training” — covers DDP bucketing, hook-based gradient compression, and the design choices in torch.distributed.
  • NVIDIA NCCL Documentation and Source (github.com/NVIDIA/nccl) — the authoritative reference for NCCL algorithm selection, topology detection, and tuning knobs.
  • Rajbhandari et al. (2020): “ZeRO: Memory Optimizations Toward Training Trillion Parameter Models” (DeepSpeed) — explains how reduce-scatter + all-gather enables full optimizer/gradient/parameter sharding.
  • Jiang et al. (2022): “Megascale: Scaling Large Language Model Training to More Than 10,000 GPUs” — describes hierarchical collectives, network topology design, and reliability engineering at cluster scale.
  • Shoeybi et al. (2019): “Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism” — the canonical reference for tensor-parallel all-reduce patterns within a transformer layer.

Exercises

1. (Conceptual) Why one process per GPU? The chapter states that GPU distributed training “almost universally uses the one-process-per-GPU model rather than threading across GPUs.” Give the three reasons the chapter offers, and explain which of them would not be fixed simply by removing Python’s GIL.

Solution

The chapter gives three reasons:

  1. The GIL serializes CPU work. Python’s Global Interpreter Lock prevents true CPU-thread-level parallelism, so CPU-side preprocessing and coordination would be serialized across threads.
  2. CUDA thread-safety. GPU driver libraries (CUDA) are not always thread-safe when the same process owns multiple GPU contexts.
  3. Fault containment. Process isolation means a crash on GPU 3 does not corrupt the state of GPU 0.

Only reason (1) is directly a GIL problem. Removing the GIL would let CPU-side preprocessing threads run in parallel, but it would not fix reasons (2) and (3): CUDA context thread-safety is a property of the driver libraries, not of the interpreter, and process-level fault isolation is inherently unavailable to threads that share one address space (a segfault or corrupted allocator in one thread takes down every GPU’s state in that process). So even in a GIL-free Python, the one-process-per-GPU model would still be preferred for isolation and driver-safety reasons.

2. (Conceptual) The forbidden conditional collective. Consider this snippet, intended to log the global gradient norm only from rank 0:

```python
if rank == 0:
    dist.all_reduce(grad_norm, op=dist.ReduceOp.SUM)
    log(grad_norm)
```

On a 4-GPU job, describe exactly what happens at runtime and why. Then rewrite the snippet so it is correct.
Solution

What happens: every collective is a synchronization barrier — all participating ranks must call it before any rank can proceed. Here only rank 0 calls all_reduce; ranks 1, 2, and 3 never reach the collective. Rank 0 blocks inside all_reduce waiting for contributions from the other three ranks, which never arrive, so rank 0 hangs forever. The other ranks race ahead to whatever code follows the if block. The job deadlocks (typically until a NCCL watchdog timeout kills it).

Correct version — every rank calls the collective unconditionally, and only the post-processing is branched on rank:

dist.all_reduce(grad_norm, op=dist.ReduceOp.SUM)  # all ranks participate
if rank == 0:
    log(grad_norm)                                 # branch AFTER the collective

3. (Quantitative) Ring all-reduce with the alpha-beta model. You all-reduce a gradient buffer of \(M = 512\) MB across \(n = 16\) GPUs on a fabric with latency \(\alpha = 5\ \mu\text{s}\) and per-direction bandwidth \(\beta = 50\) GB/s. Using the chapter’s ring all-reduce cost model, compute (a) the latency term, (b) the bandwidth term, and © the total time. (d) Which term dominates, and what practical technique from the chapter attacks the other term? Use \(1\text{ GB} = 10^9\) bytes.

Solution

The chapter’s ring all-reduce cost model is $\(T_{\text{ring-AR}}(M) = 2(n-1)\alpha + \frac{2(n-1)}{n}\cdot\frac{M}{\beta}.\)$

With \(n = 16\), so \(2(n-1) = 30\) and \(\frac{2(n-1)}{n} = \frac{30}{16} = 1.875\).

(a) Latency term: $\(2(n-1)\alpha = 30 \times 5\ \mu\text{s} = 150\ \mu\text{s} = 0.15\ \text{ms}.\)$

(b) Bandwidth term: with \(M = 512\text{ MB} = 0.512\text{ GB}\) and \(\beta = 50\text{ GB/s}\), $\(\frac{2(n-1)}{n}\cdot\frac{M}{\beta} = 1.875 \times \frac{0.512}{50}\ \text{s} = 1.875 \times 0.01024\ \text{s} = 0.0192\ \text{s} = 19.2\ \text{ms}.\)$

© Total: $\(T \approx 0.15\ \text{ms} + 19.2\ \text{ms} = 19.35\ \text{ms}.\)$

(d) The bandwidth term dominates (19.2 ms vs 0.15 ms), which is expected for a large 512 MB message — large messages are bandwidth-bound. The technique that attacks the other term (latency) is gradient bucketing: combining many small gradient tensors into large buffers before communicating amortizes the per-call \(2(n-1)\alpha\) startup cost over more bytes. (Here it would barely matter, but for many tiny gradients the latency term is exactly what bucketing removes.)

4. (Quantitative) Reduce-scatter output values. Reproduce the chapter’s reduce-scatter example by hand for \(n = 4\). Each rank \(r\) builds input = torch.arange(4) + r, i.e. rank \(r\) holds the vector \([r, r{+}1, r{+}2, r{+}3]\). The collective sums element \(i\) across all ranks and delivers element \(i\) to rank \(i\). Derive the closed form for the value rank \(i\) receives, then list the four outputs.

Solution

Rank \(r\)’s element \(i\) has value \((i + r)\). Summing element \(i\) across all \(n\) ranks: $\(\sum_{r=0}^{n-1}(i + r) = n\,i + \sum_{r=0}^{n-1} r = n\,i + \frac{n(n-1)}{2}.\)$

Rank \(i\) receives element \(i\), so rank \(i\)’s output is \(n\,i + \frac{n(n-1)}{2}\). For \(n = 4\) the constant is \(\frac{4\cdot3}{2} = 6\), giving output \(= 4i + 6\):

  • Rank 0: \(4(0) + 6 = 6.0\)
  • Rank 1: \(4(1) + 6 = 10.0\)
  • Rank 2: \(4(2) + 6 = 14.0\)
  • Rank 3: \(4(3) + 6 = 18.0\)

These match the chapter’s stated results (rank 0 -> 6.0, rank 1 -> 10.0, rank 2 -> 14.0, rank 3 -> 18.0). The differing per-rank outputs illustrate the scatter: each rank keeps only its own reduced slice, so the total output volume per rank is \(M/n\), not \(M\).

5. (Implementation) All-reduce built from reduce-scatter + all-gather. The chapter’s key identity is All-Reduce = Reduce-Scatter followed by All-Gather. Implement a function manual_all_reduce_sum(x) that reproduces dist.all_reduce(x, op=SUM) using only dist.reduce_scatter and dist.all_gather (no dist.all_reduce). Assume x is a 1-D tensor whose length is divisible by world_size. Add an assertion that verifies your result against the real all_reduce.

Solution

Split x into world_size equal chunks. Reduce-scatter sums the chunks across ranks and gives rank \(i\) the fully-summed chunk \(i\) (output size \(M/n\)). All-gather then concatenates every rank’s reduced chunk back into the full summed vector on every rank (output size \(M\)). The concatenation reconstructs exactly what all_reduce(SUM) produces.

import torch
import torch.distributed as dist

def manual_all_reduce_sum(x: torch.Tensor) -> torch.Tensor:
    """All-Reduce(SUM) via Reduce-Scatter + All-Gather.
    x: 1-D tensor with len(x) % world_size == 0. Returns the summed tensor."""
    world_size = dist.get_world_size()
    n = x.numel()
    assert n % world_size == 0, "length must be divisible by world_size"
    chunk = n // world_size

    # --- Phase 1: Reduce-Scatter ---
    # Split x into world_size chunks; rank i receives the SUM of chunk i.
    input_chunks = list(x.split(chunk))
    reduced_chunk = torch.zeros(chunk, device=x.device, dtype=x.dtype)
    dist.reduce_scatter(reduced_chunk, input_chunks, op=dist.ReduceOp.SUM)

    # --- Phase 2: All-Gather ---
    # Every rank collects all reduced chunks and concatenates them.
    gathered = [torch.zeros(chunk, device=x.device, dtype=x.dtype)
                for _ in range(world_size)]
    dist.all_gather(gathered, reduced_chunk)
    return torch.cat(gathered)

# ---- Verification (run under torchrun --nproc_per_node=N) ----
def check():
    rank = dist.get_rank()
    device = torch.device(f"cuda:{rank}")
    x = torch.arange(8, dtype=torch.float32, device=device) + rank

    mine = manual_all_reduce_sum(x.clone())

    reference = x.clone()
    dist.all_reduce(reference, op=dist.ReduceOp.SUM)

    assert torch.allclose(mine, reference), (rank, mine, reference)
    if rank == 0:
        print("OK: manual all-reduce matches dist.all_reduce")

Why it works: with \(n\) ranks each holding \(M\) bytes, reduce-scatter moves \(\frac{n-1}{n}M\) bytes/rank and all-gather another \(\frac{n-1}{n}M\), summing to \(2\frac{n-1}{n}M\) — identical to the cost of a monolithic ring all-reduce. This is precisely the decomposition ZeRO exploits to overlap communication with computation.

6. (Implementation / Conceptual) Hierarchical all-reduce over sub-groups. Using dist.new_group, sketch a two-level all-reduce for 8 GPUs on 2 nodes (ranks 0-3 on node A, 4-7 on node B): first reduce within each node over fast NVLink, then reduce across nodes over slow InfiniBand, then broadcast back. Explain why this moves less data over the slow inter-node link than a flat 8-way ring all-reduce would.

Solution

Build one intra-node group per node and one inter-node group made of the node “leaders” (one rank per node). Reduce within each node so every leader holds the node-local sum; all-reduce across the leaders; then broadcast the global sum back down inside each node.

import torch
import torch.distributed as dist

def hierarchical_all_reduce_sum(x, gpus_per_node=4):
    rank = dist.get_rank()
    world_size = dist.get_world_size()
    node_id = rank // gpus_per_node
    n_nodes = world_size // gpus_per_node

    # Intra-node groups: [0,1,2,3] and [4,5,6,7]
    intra_group = None
    for nid in range(n_nodes):
        ranks = list(range(nid * gpus_per_node, (nid + 1) * gpus_per_node))
        g = dist.new_group(ranks=ranks)
        if rank in ranks:
            intra_group = g

    # Inter-node "leaders" group: one rank per node, e.g. [0, 4]
    leader_ranks = list(range(0, world_size, gpus_per_node))
    leader_group = dist.new_group(ranks=leader_ranks)  # every rank must call
    is_leader = (rank % gpus_per_node == 0)

    # 1. Reduce within node onto the local leader (dst is a GLOBAL rank).
    local_leader = node_id * gpus_per_node
    dist.reduce(x, dst=local_leader, op=dist.ReduceOp.SUM, group=intra_group)

    # 2. Leaders all-reduce across nodes over InfiniBand.
    if is_leader:
        dist.all_reduce(x, op=dist.ReduceOp.SUM, group=leader_group)

    # 3. Broadcast the global sum back down within each node.
    dist.broadcast(x, src=local_leader, group=intra_group)
    return x

Why less inter-node traffic: in a flat 8-way ring all-reduce, the ring is threaded through both nodes, so chunks repeatedly cross the InfiniBand link; each of the 8 ranks pushes roughly \(2\frac{n-1}{n}M \approx 2M\) bytes through the fabric, and a large share of that traverses the slow hop. In the hierarchical scheme only the leaders (one per node) exchange data over InfiniBand, and they exchange a single already-reduced buffer of size \(M\) — so the slow link carries \(O(M)\) per node instead of \(O(M)\) per GPU. Because the chapter notes intra-node NVLink bandwidth is ~10-50x higher than inter-node IB, pushing the heavy per-GPU traffic onto NVLink and sending only one reduced buffer per node over IB is the whole point of hierarchical collectives. (One correctness note: every rank, leader or not, must execute the dist.new_group(ranks=leader_ranks) call, because new_group runs a barrier across all ranks — guarding it with if is_leader would deadlock.)