The LLM StackFrom Silicon to Agents
Part III — Pretraining at Scale
38 min read·Updated ·▶ Run the code (Colab)·⚡ Run on GPU (2x A100)

3.6 Distributed Training II: Tensor, Pipeline, Sequence & Expert Parallelism

In Distributed Training I we learned to replicate a model across many GPUs and split the data. That strategy — data parallelism (DP), and its memory-sharded cousins ZeRO and FSDP — has one non-negotiable requirement: a single replica’s worth of state (parameters, gradients, optimizer states, and at least one microbatch of activations) must fit on the device, or be shardable into something that fits. When that breaks, you must split the model itself.

A 70B-parameter model in bf16 is 140 GB of weights alone. Add fp32 optimizer states (Adam’s two moments plus an fp32 master copy) and you are well over half a terabyte before a single token of activation memory. No single GPU — not an 80 GB H100, not a 192 GB MI300X — holds that. You have no choice but to carve the model up and spread the pieces across devices. This chapter is about the three orthogonal axes for doing so:

  • Tensor parallelism (TP) — split within a layer (each matmul is shared across GPUs).
  • Pipeline parallelism (PP) — split across layers (each GPU owns a contiguous block of layers).
  • Sequence / context parallelism (SP/CP) — split along the sequence dimension (each GPU owns part of the token sequence).
  • Expert parallelism (EP) — split across the experts of a Mixture-of-Experts layer.

These compose with data parallelism into what practitioners call 3D, 4D, or 5D parallelism. Getting the composition right — and understanding exactly where the communication lives — is the single highest-leverage systems skill in large-scale pretraining. It is also a favorite interview topic precisely because it forces you to reason about the memory hierarchy, collective communication, and the transformer block all at once.

The Memory Wall and the Three Axes of Splitting

Let us first quantify why we split. Consider a dense decoder-only transformer with \(L\) layers, hidden size \(h\), and FFN expansion factor 4. The parameter count is dominated by

\[ N \approx L \cdot \left( \underbrace{4 h^2}_{\text{attention } QKVO} + \underbrace{8 h^2}_{\text{FFN up+down}} \right) = 12 L h^2 . \]

For training, every parameter carries a memory multiplier. With mixed-precision Adam (see Mixed Precision, bf16 & FP8 Training) and the optimizer details from Optimizers, the per-parameter footprint is roughly:

State Precision Bytes / param
Weights bf16 2
Gradients bf16 (or fp32) 2–4
Adam momentum \(m\) fp32 4
Adam variance \(v\) fp32 4
fp32 master weights fp32 4
Total ~16

So a model needs on the order of \(16N\) bytes of static state, plus activations. For a 70B model that is \(\approx 1.1\) TB — which ZeRO/FSDP can shard across the DP group. But ZeRO does not reduce the activation memory of a single microbatch, and it does not reduce the size of the largest single tensor you must materialize. When a single layer’s activations, or a single forward pass, are too big for one device, sharding optimizer state is not enough. That is where the model-parallel axes come in.

The mental model: DP/ZeRO splits state across replicas of the whole model; model parallelism splits the computation graph itself. They are orthogonal and combine multiplicatively.

DATA (DP): whole stack REPLICATED over data shards PP stage boundary: point-to-point send/recv layer L-1 layer k layer 0 GPU(p-1) owns layers k+1 .. L-1 GPU0 owns layers 0 .. k GPU0 GPU1 GPU2 TP: one matmul split across GPUs PIPELINE (PP): GPU owns a contiguous BLOCK of layers TENSOR (TP): split WITHIN a layer (matmul sharded) tokens 0 .. s/2 tokens s/2 .. s SEQUENCE (SP/CP): split ALONG the token sequence DATA (DP) — whole model replicated, one all-reduce per step PP = across layers TP = within a layer SP/CP = along sequence DP = replicate whole model
Four orthogonal axes for splitting one transformer. PP gives each GPU a contiguous block of layers (cheap point-to-point at the boundary); TP shards a single matmul across GPUs within a layer (expensive all-reduce, must stay on NVLink); SP/CP partitions the token sequence so each device only processes its chunk; DP replicates the entire stack over independent data shards. All four compose multiplicatively: total GPUs = d × t × p × c.

Tensor Parallelism: Splitting the Matmul

Tensor parallelism — introduced at scale by Megatron-LM (Shoeybi et al., 2019) — observes that the heavy lifting of a transformer is a sequence of large matrix multiplications, and a matrix multiplication can be partitioned across devices with a single collective per region. The art is choosing partitions so that consecutive matmuls compose without a collective between them.

Column and Row Parallel Linear Layers

Take a linear layer \(Y = XA\) where \(X \in \mathbb{R}^{s \times h}\) (sequence \(\times\) hidden) and \(A \in \mathbb{R}^{h \times h'}\). There are two ways to split \(A\) across \(t\) GPUs.

Column-parallel. Split \(A\) along its output columns: \(A = [A_1, A_2, \dots, A_t]\) where each \(A_i \in \mathbb{R}^{h \times h'/t}\). Each GPU holds the full input \(X\) (replicated) and computes a slice of the output:

\[ Y_i = X A_i \in \mathbb{R}^{s \times h'/t}, \qquad Y = [Y_1, \dots, Y_t]. \]

No communication is needed to produce \(Y\) — each GPU just holds a different chunk of the output columns. The output is sharded along the feature dimension.

Row-parallel. Split \(A\) along its input rows: \(A = [A_1; A_2; \dots; A_t]\), \(A_i \in \mathbb{R}^{h/t \times h'}\), and correspondingly split the input \(X = [X_1, \dots, X_t]\) along its columns. Each GPU computes a partial sum:

\[ Y = \sum_{i=1}^{t} X_i A_i . \]

Each GPU produces a full-shaped \(Y\) but containing only its partial contribution; an all-reduce sums them to the correct result.

The magic trick: chain a column-parallel layer into a row-parallel layer and the intermediate never needs to be gathered. A column-parallel layer leaves its output sharded along features; a row-parallel layer wants its input sharded along features. They fit like puzzle pieces, and you pay exactly one all-reduce at the very end (plus one in the backward pass).

f = identity fwd / all-reduce bwd at input; g = all-reduce fwd / identity bwd at output X (replicated) shape s x h broadcast no comm Column-Par A [A1 .. At] A1 (rank 0) A2 (rank 1) A3 (rank 2) no comm here Z_i sharded on cols Z1 Z2 Z3 GeLU(Z_i) (elementwise) shard 0 shard 1 shard 2 no comm here partial Y (per rank) Row-Par B [B1 ; .. ; Bt] B1 (row) B2 (row) B3 (row) all- reduce ONE all-reduce (g) Y (replicated) full result no comm no comm (nonlinearity on shards) one collective
Megatron MLP: one all-reduce for the whole forward pass. A column-parallel layer leaves the intermediate Z sharded across ranks, so GeLU runs independently on each shard with zero communication. The row-parallel layer then produces a partial Y per rank; a single all-reduce (the "g" operator) sums them to produce the full replicated output. The backward adds one more all-reduce at the input (the "f" operator), giving exactly two all-reduces per MLP block total.

Applying It to the Transformer Block

Megatron maps this pattern onto both sublayers of a transformer block.

MLP block \(Y = \text{GeLU}(XA)B\):

  • \(A\) (the up-projection, \(h \to 4h\)) is column-parallel → the \(4h\) intermediate is sharded across GPUs.
  • GeLU is elementwise, so it acts independently on each shard — no communication, and crucially we did not have to gather before the nonlinearity (which would have been wrong to split naively).
  • \(B\) (the down-projection, \(4h \to h\)) is row-parallel → one all-reduce produces the final output.

Attention block. This is even more natural because attention is already partitioned by heads. With \(a\) attention heads and \(t\) TP ranks, give each GPU \(a/t\) heads:

  • The \(Q, K, V\) projections are column-parallel — each GPU produces the Q/K/V for its own heads only.
  • Each GPU runs full self-attention (softmax, the \(QK^\top\), the \(\times V\)) for its heads with no cross-GPU communication. (This is why TP and Multi-Head Attention are such a good fit, and why GQA changes the K/V sharding story — see the warning below.)
  • The output projection \(O\) is row-parallel → one all-reduce.

So each transformer block needs exactly two all-reduces in the forward pass (one after attention’s output proj, one after the MLP’s down proj) and two in the backward pass. In Megatron’s notation these are the operators \(f\) and \(g\): \(f\) is identity in forward / all-reduce in backward; \(g\) is all-reduce in forward / identity in backward.

import torch
import torch.distributed as dist
import torch.nn as nn

# Assume a TP process group `tp_group` of size `tp` already initialized.
# These two autograd functions place the all-reduces in exactly the
# right spots: g = forward-allreduce, f = backward-allreduce.

class _CopyToTPRegion(torch.autograd.Function):
    """f operator: identity forward, all-reduce backward."""
    @staticmethod
    def forward(ctx, x): return x
    @staticmethod
    def backward(ctx, grad):
        dist.all_reduce(grad, group=tp_group)   # sum grads from all TP ranks
        return grad

class _ReduceFromTPRegion(torch.autograd.Function):
    """g operator: all-reduce forward, identity backward."""
    @staticmethod
    def forward(ctx, x):
        dist.all_reduce(x, group=tp_group)      # sum partial outputs
        return x
    @staticmethod
    def backward(ctx, grad): return grad

copy_to_region   = _CopyToTPRegion.apply
reduce_from_region = _ReduceFromTPRegion.apply

class ColumnParallelLinear(nn.Module):
    """Y = X A, with A split along output columns across `tp` ranks.
    Output is sharded along the feature dim (gather_output=False)."""
    def __init__(self, in_f, out_f, tp, rank, bias=True):
        super().__init__()
        assert out_f % tp == 0
        self.out_local = out_f // tp
        # Each rank only allocates its slice of the weight.
        self.weight = nn.Parameter(torch.empty(self.out_local, in_f))
        self.bias   = nn.Parameter(torch.zeros(self.out_local)) if bias else None
        nn.init.normal_(self.weight, std=0.02)

    def forward(self, x):
        x = copy_to_region(x)                    # f: ensures correct bwd all-reduce
        y = torch.nn.functional.linear(x, self.weight, self.bias)
        return y                                  # shape [*, out_f/tp], sharded

class RowParallelLinear(nn.Module):
    """Y = X A, with A split along input rows; input already sharded.
    Produces the full output via an all-reduce (g operator)."""
    def __init__(self, in_f, out_f, tp, rank, bias=True):
        super().__init__()
        assert in_f % tp == 0
        self.in_local = in_f // tp
        self.weight = nn.Parameter(torch.empty(out_f, self.in_local))
        # bias is added ONCE, after the reduce, so only rank 0 should hold it
        self.bias = nn.Parameter(torch.zeros(out_f)) if (bias and rank == 0) else None
        nn.init.normal_(self.weight, std=0.02)

    def forward(self, x):                         # x sharded along features
        y = torch.nn.functional.linear(x, self.weight)   # partial sum
        y = reduce_from_region(y)                 # g: all-reduce -> full output
        if self.bias is not None:
            y = y + self.bias
        return y

# A Megatron-style MLP: column then row, nonlinearity in between, zero gathers.
class ParallelMLP(nn.Module):
    def __init__(self, h, tp, rank):
        super().__init__()
        self.fc1 = ColumnParallelLinear(h, 4 * h, tp, rank)   # h -> 4h, sharded
        self.fc2 = RowParallelLinear(4 * h, h, tp, rank)      # 4h -> h, all-reduce
    def forward(self, x):
        return self.fc2(torch.nn.functional.gelu(self.fc1(x)))

The Communication Cost of TP

The all-reduce in each block moves a tensor of shape \(s \times h\). With ring all-reduce over \(t\) devices, each device sends and receives \(\approx 2 \cdot \frac{t-1}{t} \cdot (s \cdot h \cdot 2\text{ bytes})\) per all-reduce. Two all-reduces forward + two backward = four all-reduces per layer per step. This is a lot of traffic, and it sits squarely on the critical path: the GPUs cannot proceed past the all-reduce until it completes. Frameworks claw some of it back by overlapping each collective with the GEMM that feeds it: Megatron-LM’s --tp-comm-overlap (built on Transformer Engine’s “userbuffers”) chunks the all-gather/reduce-scatter and pipelines the chunks against the matmul, and PyTorch’s async tensor parallel does the same on top of torch.distributed._symmetric_memory. Overlap hides exposed latency; it does not reduce the volume, so the placement rule below still binds.

This is the defining constraint of tensor parallelism: it must run over the fastest interconnect you have. On a DGX/HGX node that is NVLink/NVSwitch (hundreds of GB/s, sometimes ~900 GB/s aggregate). Cross more than the NVLink domain — e.g. over InfiniBand between nodes — and TP collapses your throughput because the per-layer all-reduces serialize behind a 10–25× slower link. Rule of thumb: keep the TP group inside one node, \(t \le 8\) (or whatever your NVLink domain is). The real rule is “TP must not leave the NVLink domain,” and 8 is merely that domain’s most common size: on rack-scale NVLink systems (GB200/GB300 NVL72, where dozens of GPUs share one NVLink fabric) the ceiling rises accordingly, which is exactly why 2026-era frontier configs can afford larger TP (and larger EP) groups than an 8-GPU box allows.

GQA/MQA changes the K/V sharding

With Grouped-Query Attention or Multi-Query Attention (see MHA, MQA, GQA & MLA) there are fewer K/V heads than Q heads. If the number of KV heads is smaller than the TP degree \(t\), you cannot give each rank a distinct KV head. Megatron handles this by replicating KV heads across the ranks that share them, or by requiring \(t \le\) (number of KV groups). Forgetting this produces silent wrong results or shape errors — check your KV-head-to-TP divisibility before launching.

The Vocabulary: Parallel Embedding and Parallel Cross-Entropy

The token embedding table and the (usually weight-tied) LM head are each a \(V \times h\) matrix, with \(V\) ranging from ~32k to 256k+ — for large \(h\) this is routinely the single biggest matrix in the model, bigger than any individual attention or MLP weight. You cannot assemble an end-to-end TP transformer without sharding these along the vocabulary dimension across the \(t\) TP ranks: a third partitioning strategy, distinct from both the column-parallel and row-parallel matmul patterns above.

Vocab-parallel embedding. Rank \(r\) owns rows \([r \cdot V_{\text{loc}} : (r+1) \cdot V_{\text{loc}})\) of the table, where \(V_{\text{loc}} = V / t\). A lookup for a token id outside a rank’s local range must resolve to zero on that rank; summing across ranks then recovers the correct row, since exactly one rank owns each id.

import torch, torch.nn as nn, torch.nn.functional as F, torch.distributed as dist

class VocabParallelEmbedding(nn.Module):
    def __init__(self, V, h, tp, rank, tp_group):
        super().__init__()
        assert V % tp == 0
        self.V_loc = V // tp
        self.vocab_start = rank * self.V_loc
        self.vocab_end = self.vocab_start + self.V_loc
        self.tp_group = tp_group
        self.weight = nn.Parameter(torch.empty(self.V_loc, h))   # [V_loc, h]

    def forward(self, input_ids):                      # input_ids: [b, s], long
        mask = (input_ids < self.vocab_start) | (input_ids >= self.vocab_end)
        masked_ids = input_ids.clone() - self.vocab_start
        masked_ids[mask] = 0                            # keep in-range for the local table
        y = F.embedding(masked_ids, self.weight)         # [b, s, h]
        y = y.masked_fill(mask.unsqueeze(-1), 0.0)        # zero rows this rank doesn't own
        dist.all_reduce(y, group=self.tp_group)           # SUM: exactly one rank owns each row
        return y                                           # [b, s, h]

The backward is the identity on the local rows — gradients flow only into the rows a rank actually owns — mirroring the \(g\)/\(f\) forward-all-reduce, backward-identity (and vice versa) operator pairing used for the MLP/attention sharding above.

Vocab-parallel cross-entropy. The mirror image on the output side: a column-parallel LM head (ColumnParallelLinear with gather_output=False) leaves logits sharded along vocab as logits_local of shape [N, V_loc] (with \(N = b \cdot s\) flattened). Computing the loss without ever gathering the full [N, V] logits is the whole point — \(V\) is exactly the dimension we sharded to avoid materializing.

def vocab_parallel_cross_entropy(logits_local, target, tp_group, vocab_start, vocab_end):
    # logits_local: [N, V_loc] fp32;  target: [N] global token ids
    V_loc = logits_local.shape[-1]

    # 1) global row-max via all-reduce MAX
    m = logits_local.max(dim=-1).values                           # [N]
    dist.all_reduce(m, op=dist.ReduceOp.MAX, group=tp_group)

    # 2) global normalizer Z via all-reduce SUM of local exp-sums
    exp_local = torch.exp(logits_local - m.unsqueeze(-1))         # [N, V_loc]
    sum_exp = exp_local.sum(dim=-1)                                 # [N]
    dist.all_reduce(sum_exp, op=dist.ReduceOp.SUM, group=tp_group)

    # 3) the target logit: only the rank that owns `target` contributes a nonzero value
    tmask = (target >= vocab_start) & (target < vocab_end)
    local_target = (target - vocab_start).clamp(0, V_loc - 1)
    pred = logits_local.gather(1, local_target.unsqueeze(-1)).squeeze(-1)   # [N]
    pred = pred * tmask
    dist.all_reduce(pred, op=dist.ReduceOp.SUM, group=tp_group)   # now the true target logit everywhere

    # loss = logsumexp(z) - z_target = (m + log Z) - z_target
    loss = torch.log(sum_exp) + m - pred                           # [N]
    return loss.mean()

Three all-reduces of shape \([N]\) — trivial next to gathering \([N, V]\) logits. The backward is equally cheap: the gradient w.r.t. logits_local is softmax_local - onehot_local, already vocab-sharded, needing no extra collective.

Megatron-LM implements exactly these primitives as megatron.core.tensor_parallel.VocabParallelEmbedding and vocab_parallel_cross_entropy, in megatron/core/tensor_parallel/{layers.py, cross_entropy.py} — the mask-and-all-reduce embedding trick and the max-then-sum parallel softmax above are the reference formulation, not a simplification of it. The LM head is typically a ColumnParallelLinear(gather_output=False) whose sharded output feeds directly into vocab_parallel_cross_entropy, and its weight is often tied to the embedding weight.

Sequence Parallelism as TP’s Free Companion

Look again at the Megatron block: between the two TP regions sit the LayerNorm/RMSNorm and the dropout/residual-add, which Megatron replicates (every TP rank does the same redundant work on the full \(s \times h\) tensor). That replication wastes both compute and, more importantly, activation memory: each rank stores the full LayerNorm activations.

Sequence parallelism (SP) — in this Megatron sense (Korthikanti et al., 2022) — splits those replicated regions along the sequence dimension instead. The norm and residual are now done on \(s/t \times h\) shards. The catch: the boundaries between an SP region (sharded on sequence) and a TP region (sharded on hidden/features) require a conversion. Megatron shows that the same all-reduce of the TP region can be decomposed into a reduce-scatter + all-gather that achieves the layout conversion for the same total communication volume. So Megatron-style SP is essentially free communication-wise and meaningfully cuts activation memory — it is now standard and always-on in Megatron. (Note: this “sequence parallelism” is a memory optimization within a TP group, and is distinct from context parallelism / Ring Attention, covered later, which is a true sequence split for long context.)

One correctness detail this split forces, easy to miss when hand-rolling TP: dropout needs two different RNG regimes. Dropout applied inside a TP region acts on a distinct feature shard per rank, so each rank must draw a different mask — using the same seed everywhere would correlate the masks and effectively reduce the dropout rate. Dropout applied to a replicated tensor (e.g. on the residual stream before SP shards it) must use the same seed on all ranks, or the replicas silently diverge. Megatron keeps both states in a seed tracker (model_parallel_cuda_manual_seed / get_cuda_rng_tracker in megatron/core/tensor_parallel/random.py) and switches between them at region boundaries; PyTorch’s DTensor path handles the equivalent bookkeeping through its OffsetBasedRNGTracker. If your architecture has no dropout (as most 2026 pretraining recipes do not), this problem disappears — which is one small extra reason the modern default is dropout-free pretraining.

The Library: PyTorch-Native TP with DTensor

Everything above is what Megatron-LM implements by hand — and what you should implement once, to know it. In day-to-day work you would reach for PyTorch’s built-in tensor parallelism instead, which expresses exactly the same algebra declaratively. The abstraction is DTensor: a tensor carrying a placementShard(dim) or Replicate() — over a DeviceMesh. You write a plan saying how each submodule is sharded; the runtime inserts the \(f\)/\(g\) collectives, the SP reduce-scatter/all-gather boundary conversions, and the backward counterparts for you.

# Assumes a Llama-style `model` whose blocks expose the submodule names below,
# and parallelism degrees (dp, tp) with dp * tp == world_size.
import torch, torch.nn.functional as F
from torch.distributed.device_mesh import init_device_mesh
from torch.distributed.tensor import Shard, Replicate
from torch.distributed.tensor.parallel import (
    parallelize_module, ColwiseParallel, RowwiseParallel,
    SequenceParallel, PrepareModuleInput, loss_parallel,
)

# One mesh, two axes: FSDP2 across the "dp" axis, TP inside the node on "tp".
mesh = init_device_mesh("cuda", (dp, tp), mesh_dim_names=("dp", "tp"))
tp_mesh = mesh["tp"]

for block in model.layers:
    parallelize_module(block, tp_mesh, {
        # Norms + residual run sequence-parallel (Korthikanti-style SP).
        "attn_norm": SequenceParallel(),
        "mlp_norm":  SequenceParallel(),
        # Boundary conversion Shard(1) [sequence] -> Replicate(): this IS the
        # all-gather half of the reduce-scatter/all-gather decomposition.
        "attn": PrepareModuleInput(input_layouts=(Shard(1),),
                                   desired_input_layouts=(Replicate(),)),
        "attn.wq": ColwiseParallel(),          # Q/K/V: column-parallel by head
        "attn.wk": ColwiseParallel(),
        "attn.wv": ColwiseParallel(),
        "attn.wo": RowwiseParallel(output_layouts=Shard(1)),   # g, then re-shard on seq
        "mlp": PrepareModuleInput(input_layouts=(Shard(1),),
                                  desired_input_layouts=(Replicate(),)),
        "mlp.w_gate": ColwiseParallel(),       # SwiGLU gate + up are column-parallel
        "mlp.w_up":   ColwiseParallel(),
        "mlp.w_down": RowwiseParallel(output_layouts=Shard(1)),  # row-parallel down-proj
    })

# Embedding sharded on vocab rows; LM head column-parallel, output left vocab-sharded.
parallelize_module(model, tp_mesh, {
    "tok_emb":   RowwiseParallel(input_layouts=Replicate(), output_layouts=Shard(1)),
    "final_norm": SequenceParallel(),
    "lm_head":   ColwiseParallel(output_layouts=Shard(-1), use_local_output=False),
})

logits = model(input_ids)                  # DTensor, still sharded on the vocab dim
with loss_parallel():                      # == our vocab_parallel_cross_entropy
    loss = F.cross_entropy(logits.flatten(0, 1), targets.flatten(0, 1))
    loss.backward()

Read that plan against the hand-written code above and every line maps: ColwiseParallel/RowwiseParallel are ColumnParallelLinear/RowParallelLinear, PrepareModuleInput is the SP↔TP layout conversion, and loss_parallel() is precisely the max-then-sum parallel softmax — it consumes vocab-sharded logits and never materializes the full [N, V] tensor. This DTensor path is how torchtitan, PyTorch’s reference pretraining codebase, composes TP with FSDP2, PP, and CP on a single DeviceMesh; it is the practical alternative to adopting Megatron wholesale, and the trade-off between the two stacks is weighed in Megatron-LM, DeepSpeed & Parallelism in Practice.

Pipeline Parallelism: Splitting Across Layers

Tensor parallelism is bounded by the NVLink domain. To go bigger we split the model depth-wise: GPU 0 holds layers \(0..k\), GPU 1 holds layers \(k+1..2k\), and so on. A microbatch flows GPU 0 → GPU 1 → … → GPU \(p-1\) in the forward pass, and the gradients flow back. The only communication is point-to-point (send/recv of the activation tensor at the stage boundary) — cheap, and tolerant of slower inter-node links. This is pipeline parallelism (PP).

The problem is the bubble. If you naively run one whole batch through the pipeline, while stage 0 computes, stages \(1..p-1\) sit idle; while stage \(p-1\) computes, the rest sit idle. Utilization is \(1/p\) — catastrophic.

Few microbatches — m = 2, p = 4 S0 S1 S2 S3 F1F2 F1F2 F1F2 F1F2 B2B1 B2B1 B2B1 B2B1 bubble fraction (p-1)/(m+p-1) = 3/5 = 60% More microbatches — m = 6, p = 4 S0 S1 S2 S3 bubble fraction (p-1)/(m+p-1) = 3/9 = 33% time (one optimizer step) forward (F) backward (B) idle bubble (fill + drain)
Microbatching fills the pipe and shrinks the bubble. Each stage S0..S3 runs microbatches as a diagonal of forward (then backward) cells; the hatched fill + drain triangles are idle "bubble" time. Those wasted triangles are a fixed size set by the pipeline depth p, so adding more microbatches m dilutes them — the bubble fraction (p-1)/(m+p-1) falls from 60% at m=2 to 33% at m=6, and keeps shrinking as m ≫ p.

GPipe: Microbatching to Fill the Pipe

GPipe (Huang et al., 2019) fixes this by chopping the minibatch into \(m\) microbatches and streaming them. Once the pipe is full, multiple stages work in parallel on different microbatches. The schedule is “all-forward, then all-backward”:

time 1 2 3 4 5 6 7 8 9 10 11 stage0 stage1 stage2 stage3 forward backward idle F1 F2 F3 F4 · · · B4 B3 B2 B1 · F1 F2 F3 F4 · B4 B3 B2 B1 · · · F1 F2 F3 F4 B4 B3 B2 B1 · · · · F1 F2 F3 F4 B4 B3 B2 B1 fill drain bubble fraction = (p-1)/(m+p-1); here 3/7 ~ 43% — shrinks as m grows relative to p
GPipe all-forward-then-all-backward schedule (p=4 stages, m=4 microbatches). The forward wave (purple) cascades diagonally; all four microbatch activations must remain in memory simultaneously before any backward begins. The triangular idle region (hatched) is the pipeline bubble; its fraction (p-1)/(m+p-1) shrinks by increasing m, but at the cost of storing more live activations.

The bubble is the fill + drain time. With \(p\) stages and \(m\) microbatches, the bubble fraction is

\[ \text{bubble fraction} = \frac{p - 1}{m + p - 1}. \]

Increase \(m\) and the bubble shrinks. The rule: \(m \gg p\). With \(p=8\) and \(m=8\), the bubble is \(7/15 \approx 47\%\) — terrible. With \(m = 64\), it is \(7/71 \approx 10\%\). But GPipe has a memory problem: to do all forwards before any backward, it must stash the activations of all \(m\) in-flight microbatches on each stage. Larger \(m\) means lower bubble but higher peak activation memory — a direct tension. (Activation recomputation, from Memory-Efficient Training, is the usual escape valve.)

1F1B: The Steady-State Schedule

PipeDream’s 1F1B (“one-forward-one-backward”) schedule, adopted by Megatron, fixes the memory blowup. Once the pipeline is full, each stage alternates: do one forward, then one backward, then one forward, and so on. The key consequence: a stage only needs to keep activations for the microbatches currently in flight through it, which is at most \(p - s\) for stage \(s\) — bounded by \(p\), independent of \(m\).

time 1 2 3 4 5 6 7 8 9 10 11 12 stage0 stage1 stage2 stage3 forward backward idle F1 F2 F3 F4 B1 F5 B2 F6 B3 B4 B5 B6 · F1 F2 F3 B1 F4 B2 F5 B3 B4 B5 · · · F1 F2 B1 F3 B2 F4 B3 B4 · · · · · F1 B1 F2 B2 F3 B3 · · · warmup (p=4 fwds, deepest stage) F then immediately B (no warmup) In-flight activations bounded by p, independent of m | Same bubble (p-1)/(m+p-1) as GPipe | Win is MEMORY, not bubble size Steady state: each stage alternates F and B | stage0 warmup depth = p-1; stage3 warmup depth = 0
PipeDream/Megatron 1F1B steady-state pipeline schedule (p=4 stages). Unlike GPipe, backward passes interleave with forward passes as soon as stage 3 completes its first forward, so each stage only keeps at most p live activation sets at once — independent of the total microbatch count m. The bubble fraction (p-1)/(m+p-1) matches GPipe, but 1F1B's memory footprint is bounded by p rather than m, making it practical with large m.

The bubble fraction is the same \(\frac{p-1}{m+p-1}\) as GPipe — 1F1B’s win is memory, not bubble. It lets you run a large \(m\) (small bubble) without storing \(m\) microbatches’ activations. This is why 1F1B is the default in essentially every production framework.

# Minimal 1F1B driver (single-stage view). In reality each rank runs this with
# send/recv to its neighbors. `num_micro` = m, `stage` in [0, p-1], `p` = #stages.
def run_1f1b(stage, p, num_micro, fwd_step, bwd_step, recv_act, send_act,
             recv_grad, send_grad):
    warmup = p - stage - 1                 # how many forwards before first backward
    warmup = min(warmup, num_micro)
    steady = num_micro - warmup
    act_queue = []                         # activations awaiting their backward

    # ---- warmup: only forwards, prime the pipe ----
    for _ in range(warmup):
        x = recv_act() if stage > 0 else next_input()
        y, act = fwd_step(x)               # act = saved tensors for backward
        send_act(y) if stage < p - 1 else None
        act_queue.append(act)

    # ---- steady state: 1 forward then 1 backward, bounded memory ----
    for i in range(steady):
        x = recv_act() if stage > 0 else next_input()
        y, act = fwd_step(x)
        send_act(y) if stage < p - 1 else None
        act_queue.append(act)
        # immediately do a backward for the OLDEST in-flight microbatch
        g = recv_grad() if stage < p - 1 else loss_grad()
        gx = bwd_step(act_queue.pop(0), g)
        send_grad(gx) if stage > 0 else None

    # ---- cooldown: drain remaining backwards ----
    for _ in range(warmup):
        g = recv_grad() if stage < p - 1 else loss_grad()
        gx = bwd_step(act_queue.pop(0), g)
        send_grad(gx) if stage > 0 else None

Interleaved 1F1B (Virtual Pipeline Stages)

We can shrink the bubble without more microbatches. Interleaved 1F1B (Megatron-LM, Narayanan et al., 2021) gives each physical GPU several non-contiguous chunks of layers — “virtual stages.” With \(v\) virtual stages per device, the pipeline has \(p \cdot v\) logical stages, and the bubble shrinks to

\[ \text{bubble fraction} = \frac{1}{v} \cdot \frac{p - 1}{m + p - 1}. \]

A factor \(v\) improvement in the bubble. The cost is \(v\times\) more point-to-point communication (more, smaller sends) and a more intricate schedule. With fast intra-cluster links this is usually a great trade, and interleaving is standard in large Megatron runs.

Interleaved (v=2): each GPU owns TWO chunks. GPU0 = {layers 0-1, 8-9}, etc.
The pipe is "deeper" (more stages) so fill/drain is proportionally smaller,
at the price of more boundary send/recv ops.

Zero-Bubble and the Frontier

The bubble is fundamentally about the forward → backward dependency. Zero-Bubble Pipeline (Qi et al., 2024) observes that the backward pass actually splits into two pieces: the gradient w.r.t. the input (needed to keep the pipeline flowing upstream) and the gradient w.r.t. the weights (needed only before the optimizer step, and not on the critical path). By scheduling the weight-gradient computation into the bubbles, the bubble can be driven to near zero. DeepSeek-V3’s “DualPipe” pushes this further by overlapping forward and backward across a bidirectional pipeline and hiding communication. These are the current frontier — but plain interleaved 1F1B remains the workhorse you should reach for first.

The Library: torch.distributed.pipelining

You do not have to hand-roll the driver above either. PyTorch ships the whole schedule taxonomy as classes: give each rank its own nn.Module holding that stage’s layers, wrap it in a PipelineStage, hand that to a schedule object, and call step() once per global batch.

import torch, torch.nn.functional as F
from torch.distributed.pipelining import PipelineStage, Schedule1F1B

# `stage_mod` = this rank's contiguous block of layers, already .to(dev).
# `pp_group` = the process group spanning the p pipeline ranks.
stage = PipelineStage(stage_mod, stage_index=rank, num_stages=p,
                      device=dev, group=pp_group)
sched = Schedule1F1B(stage, n_microbatches=m, loss_fn=F.cross_entropy)

losses = []
if rank == 0:
    sched.step(x)                          # first stage supplies the inputs
elif rank == p - 1:
    sched.step(target=y, losses=losses)    # last stage supplies targets, collects losses
else:
    sched.step()                           # middle stages just relay activations/grads

The microbatch splitting, the send/recv pairs, and the warmup / steady-state / cooldown bookkeeping we wrote out by hand are all internal. Swapping schedules is a one-word change: ScheduleGPipe (all-forward-then-all-backward), Schedule1F1B, ScheduleInterleaved1F1B (pass a list of stages per rank for \(v > 1\)), ScheduleInterleavedZeroBubble / ScheduleZBVZeroBubble for the split-backward zero-bubble family, and ScheduleDualPipeV for the DeepSeek-style bidirectional schedule. Megatron-Core’s equivalent knobs are --pipeline-model-parallel-size and --num-layers-per-virtual-pipeline-stage; DeepSpeed’s is PipelineModule with LayerSpec.

Pipeline parallelism needs load-balanced stages

The pipeline runs at the speed of its slowest stage. The embedding layer (huge vocab matmul) and the final LM head + loss are unusually heavy. If you naively put \(L/p\) layers per stage, stage 0 (with embeddings) and stage \(p-1\) (with the LM head and cross-entropy over the full vocab) become stragglers and stall everyone. Megatron rebalances by giving the first/last stages fewer transformer layers, and sometimes splits the loss computation. Always profile per-stage time, not just per-stage layer count.

Sequence & Context Parallelism: Splitting the Sequence

For very long context (32k, 128k, 1M tokens — see Long-Context Pretraining), the bottleneck is no longer parameters but activation memory and the \(O(s^2)\) attention computation, both scaling with sequence length \(s\). Neither TP nor PP addresses this directly — TP shards heads, PP shards layers, but every device still processes all \(s\) tokens of its slice. Context parallelism (CP) shards the token sequence itself across devices: GPU 0 owns tokens \(0..s/c - 1\), GPU 1 owns the next chunk, and so on, for a CP group of size \(c\).

The MLP and the projections are trivially sequence-parallel (they act per-token). The hard part is attention, where every query must attend to all keys/values — including those living on other devices.

Ring Attention

Ring Attention (Liu et al., 2023) solves this by combining the FlashAttention online-softmax trick with a communication ring. Each device starts with its own block of \(Q\), \(K\), \(V\). It computes the local attention contribution, then the \(K\)/\(V\) blocks are passed around a ring (device \(i \to i+1\)) so that, over \(c\) steps, every device sees every other device’s \(K\)/\(V\) — and crucially, the \(K\)/\(V\) for the next step is being sent while the current step is being computed, so communication hides under computation.

The online-softmax running statistics (the running max \(m\) and running sum \(\ell\), from FlashAttention) let each device incrementally fold in each incoming K/V block without ever materializing the full \(s \times s\) attention matrix:

Ring Attention: c=4 devices, step 1 of c (steps 2..c repeat this pattern) Device 0 Q0 K0,V0 S = Q0 . K0^T m' l' out' folds K0,V0 into (m,l,out) Device 1 Q1 K1,V1 S = Q1 . K1^T m' l' out' folds K1,V1 into (m,l,out) Device 2 Q2 K2,V2 S = Q2 . K2^T m' l' out' folds K2,V2 into (m,l,out) Device 3 Q3 K3,V3 S = Q3 . K3^T m' l' out' folds K3,V3 into (m,l,out) Qi = fixed Ki,Vi = rotates 1 2 3 4 Step 1 of c=4, in full S = Qi . Ki^T m_new = max(m, blockmax(S)) p = exp(S - m_new) corr = exp(m - m_new) l_new = corr*l + sum(p) out_new = corr*out + p . V m,l,out <- m_new,l_new,out_new exact: associative fold, same result as full attention. steps 2..c repeat with next block Per-device tally (identical for every device) step t KV seen bytes moved t=1 1 of 4 ~(s/c)*h t=2 2 of 4 ~2*(s/c)*h t=3 3 of 4 ~3*(s/c)*h t=4 4 of 4 ~4*(s/c)*h = s*h -> O(s*h), indep. of c c cancels: c steps x (s/c)*h bytes/step = s*h total, while local compute is O(s^2/c) per device. Compute and communication overlap, step by step (c=4) compute comm step 1 overlap: next send hides under this compute step 2 step 3 step 4 Final frame, after all c steps: every device emits out / l (normalize once, at the very end). Note: a causal mask makes early query blocks cheaper than late ones (some K/V blocks are skippable) -- this unbalances the ring; striped/zig-zag Ring Attention rebalances it (below).
Each of c=4 devices holds a fixed query block Q_i and rotates key/value blocks around a ring, folding every incoming K/V block into a running online-softmax state (m, l, out) via a rescale-then-add update. Because the next block's K/V transfer is issued before the current block's compute finishes, communication overlaps compute and total data moved per device is O(s*h) -- independent of the number of devices c -- while local compute stays O(s^2/c) per device, so Ring Attention scales to long sequences by adding devices.
# Ring Attention: each of c devices owns a contiguous query/key/value block.
# We rotate K,V around the ring; online softmax accumulates the result.
# `send_recv_ring(t)` sends t to rank+1 and returns the tensor from rank-1.
import torch, math

def ring_attention(q, k, v, cp_group_size, head_dim):
    # q,k,v: local blocks, shape [b, heads, s_local, d]
    scale = 1.0 / math.sqrt(head_dim)
    # running online-softmax state, FlashAttention-style
    out   = torch.zeros_like(q)                      # accumulated output
    l_run = torch.zeros(*q.shape[:-1], 1, device=q.device)   # running sum of exp
    m_run = torch.full((*q.shape[:-1], 1), -1e30, device=q.device)  # running max
    k_cur, v_cur = k, v
    for step in range(cp_group_size):
        # Begin sending current K,V to the next rank; overlaps with the matmul.
        k_next, v_next = send_recv_ring(k_cur), send_recv_ring(v_cur)
        s = torch.matmul(q, k_cur.transpose(-1, -2)) * scale   # [b,h,s_loc,s_loc]
        # (apply causal mask here if needed, accounting for block offsets)
        m_new = torch.maximum(m_run, s.max(dim=-1, keepdim=True).values)
        p = torch.exp(s - m_new)                     # rescaled exp
        corr = torch.exp(m_run - m_new)              # correction for old terms
        l_run = corr * l_run + p.sum(dim=-1, keepdim=True)
        out   = corr * out + torch.matmul(p, v_cur)  # fold this block's V
        m_run = m_new
        k_cur, v_cur = k_next, v_next                # rotate to next block
    return out / l_run                               # normalize at the very end

The communication per step is one \(K\) and one \(V\) block (\(\approx 2 \cdot \frac{s}{c} \cdot h \cdot 2\) bytes), and there are \(c\) steps — so total volume per device is \(O(s \cdot h)\), independent of \(c\), and it overlaps with the \(O(s^2/c)\) local compute. As \(s\) grows, compute dominates communication and Ring Attention scales to arbitrarily long sequences as long as you add devices.

In production you rarely write that loop yourself. PyTorch exposes it as torch.distributed.tensor.experimental.context_parallel, a context manager that shards the Q/K/V buffers over a CP mesh dimension and swaps in a ring-communicating SDPA implementation for the duration of the block; Megatron-Core exposes --context-parallel-size (fused with its FlashAttention kernels and the balanced token reordering below); and the community ring-flash-attention package wraps the real FlashAttention CUDA kernels in the same ring so you keep kernel-level IO-awareness and the sequence split.

Causal masking unbalances the ring

With a causal mask, early query blocks attend to fewer keys than late ones, so a naive Ring Attention has devices doing wildly different amounts of work (some K/V blocks are entirely masked out for a given Q block). Production implementations (e.g. Striped/Zig-Zag Ring Attention, and Megatron-CP) renumber or interleave the token assignment so each device gets a balanced mix of early and late positions. Ignore this and your “8-way CP” runs at the speed of the busiest rank.

Expert Parallelism: Scaling Mixture-of-Experts

A Mixture-of-Experts (MoE) layer (see Mixture-of-Experts Architectures) replaces the single FFN with \(E\) expert FFNs and a router that sends each token to its top-\(k\) experts. The whole point is that the active compute per token stays constant (only \(k\) of \(E\) experts fire) while the parameter count grows with \(E\). But that means the parameters are enormous — far too big to replicate. Expert parallelism (EP) places different experts on different devices: with an EP group of size \(e\), each device holds \(E/e\) experts.

Because the router sends tokens to experts that live on other devices, EP’s signature communication is a pair of all-to-all collectives:

  1. Dispatch all-to-all: after routing, each device sends each token to the device holding its chosen expert(s).
  2. (Each device runs its local experts on the tokens it received.)
  3. Combine all-to-all: send the expert outputs back to the device that owns each token, where they are weighted by the router scores and summed.
token owners routed by gate GPU 0 tokens batch of sequences GPU 1 tokens batch of sequences GPU 2 tokens batch of sequences GPU 3 tokens batch of sequences 1. DISPATCH all-to-all each token routed to its chosen expert's GPU experts run local experts on received tokens expert 0 (GPU 0) FFN: h -> 4h -> h expert 1 (GPU 1) FFN: h -> 4h -> h expert 2 (GPU 2) FFN: h -> 4h -> h expert 3 (GPU 3) FFN: h -> 4h -> h (2) run local experts 2. COMBINE all-to-all outputs return to token owner, weighted by router probs, summed (1) dispatch Load balance decides EP speed: aux loss / capacity spread tokens; combine = exact reverse permutation of dispatch.
Expert parallelism dispatch and combine all-to-all (E=4 experts, e=4 devices, top-1 routing). After the gate assigns each token to one expert, a first all-to-all ships tokens to the device owning their expert (dispatch). Each GPU runs its local expert FFN on the tokens it received, then a second all-to-all returns outputs to the original token owners, weighted by router probabilities and summed (combine). Load imbalance — too many tokens sent to one expert — is the dominant performance bottleneck.
# Sketch of one expert-parallel MoE layer. `ep_group` spans `e` ranks;
# this rank owns experts [rank*E_local : (rank+1)*E_local].
import torch, torch.distributed as dist, torch.nn.functional as F

def bucket_tokens_by_rank(x, dest_rank, e):
    # x: [T, h] (for k>1, each token is expanded to k rows before this call);
    # dest_rank: [T] long, the target EP rank per row.
    order = torch.argsort(dest_rank)
    send_buf = x[order]                                  # [T, h], grouped by dest rank
    counts = torch.bincount(dest_rank, minlength=e)       # [e], tokens destined for each rank
    return send_buf, counts, order                        # keep `order` to unsort after combine

def run_local_experts(recv_buf, experts_local, recv_counts, E_local):
    # recv_buf: [T_recv, h]. Production code re-buckets received tokens by which of the
    # E_local local experts they hit (each row carries its expert id); this sketch keeps
    # it simple and applies the single local expert when E_local == 1.
    assert E_local == 1
    return experts_local[0](recv_buf)                     # [T_recv, h]

def moe_forward(x, gate, experts_local, E, e, ep_group, k=1):
    # x: [tokens, h];  gate: Linear(h, E);  experts_local: list of FFNs on this rank
    logits = gate(x)                                   # [tokens, E]
    topk = logits.topk(k, dim=-1)                      # choose k experts/token
    probs = F.softmax(topk.values, dim=-1)             # routing weights, [tokens, k]
    expert_ids = topk.indices                          # [tokens, k]

    # Build send buffers: bucket tokens by the DEVICE that owns their expert.
    # (k=1 shown; for k>1, expand each token to k rows first, one per chosen expert.)
    E_local = E // e
    dest_rank = expert_ids[:, 0] // E_local             # [tokens], device owning this token (k=1)
    send_buf, counts, order = bucket_tokens_by_rank(x, dest_rank, e)   # counts: [e]

    # 1) EXCHANGE COUNTS first: each rank must learn how many tokens it will receive
    #    from every peer, since ranks receive different numbers of tokens (load imbalance)
    #    — this is the part a fixed-size torch.empty_like recv buffer silently gets wrong.
    recv_counts = torch.empty_like(counts)
    dist.all_to_all_single(recv_counts, counts, group=ep_group)        # [e]
    input_splits = counts.tolist()
    output_splits = recv_counts.tolist()

    # 2) DISPATCH: all-to-all with EXPLICIT variable split sizes, sized from the exchange.
    recv_buf = send_buf.new_empty((int(recv_counts.sum()), x.shape[-1]))   # [T_recv, h]
    dist.all_to_all_single(recv_buf, send_buf, output_splits, input_splits, group=ep_group)

    # 3) Run the local experts on received tokens.
    local_out = run_local_experts(recv_buf, experts_local, recv_counts, E_local)  # [T_recv, h]

    # 4) COMBINE: all-to-all back to each token's original owner — note the split sizes
    #    are SWAPPED relative to dispatch (we're now sending back what we received).
    combine_buf = local_out.new_empty((int(counts.sum()), x.shape[-1]))    # [T, h]
    dist.all_to_all_single(combine_buf, local_out, input_splits, output_splits, group=ep_group)

    # 5) Unsort back to original token order, then weight by router probs.
    out = torch.empty_like(combine_buf)
    out[order] = combine_buf                            # [T, h]
    out = out * probs.squeeze(-1).unsqueeze(-1)          # k=1: scale by the single routing weight
    # for k>1: repeat dispatch/combine per expert slot and torch.scatter_add_ the k
    # weighted contributions back onto the T original token rows.
    return out

The exchanged-counts-then-variable-split-size handling above is the actual hard part of expert parallelism — it is exactly what Megatron’s token dispatcher (megatron.core.transformer.moe) and DeepSpeed/Tutel implement, since a naive fixed-size recv buffer is only correct when every rank happens to receive the same number of tokens, which routing essentially never guarantees. Two open-source libraries are worth knowing at this layer: DeepEP (DeepSeek’s expert-parallel communication library) supplies NVLink- and RDMA-tuned dispatch/combine kernels, including a low-latency path for MoE inference; and MegaBlocks reformulates the expert FFNs as block-sparse GEMMs, giving dropless MoE — no capacity factor, no dropped tokens — at the cost of variable-shaped kernels.

The deciding factor for EP performance is load balance. If the router sends most tokens to a few popular experts, those devices become stragglers while others idle, and the all-to-all is dominated by the heaviest bucket. This is why MoE training relies on an auxiliary load-balancing loss (or DeepSeek-style auxiliary-loss-free bias correction) to spread tokens evenly, and on expert capacity limits that drop or reroute overflow tokens. EP is almost always combined with TP and DP, and the all-to-all collectives are extremely bandwidth-sensitive — keep the EP group on fast links, and overlap dispatch/combine with the attention compute of the next layer where possible.

Combining Everything: 3D, 4D & 5D Parallelism

No single axis suffices at frontier scale. Real systems compose them. The total number of GPUs is the product:

\[ G = \underbrace{d}_{\text{DP}} \times \underbrace{t}_{\text{TP}} \times \underbrace{p}_{\text{PP}} \times \underbrace{c}_{\text{CP}} \times \underbrace{e}_{\text{EP}} . \]

Each GPU belongs to one group per axis. The orchestration trick is mapping these groups onto the physical network topology so that the chattiest collectives ride the fastest links. The canonical ordering, fastest-comm axis innermost:

Parallelism Axes Ordered by Communication Intensity innermost = fastest interconnect required; outermost = slowest link tolerable TP + EP innermost tier -> per-layer collectives -> NVLink TP must be intra-node (NVLink) heavy all-reduce every layer t <= 8 (NVLink domain) 4x all-reduce/layer EP intra-node / fast all-to-all x2 bandwidth-bound both saturate NVLink -> keep within one node CP fast ring send/recv, overlaps compute CP per-step latency matters PP tolerant of slower links PP point-to-point at stage boundaries DP outermost / slowest OK all-reduce (or RS+AG) once per step DP one all-reduce per step fastest interconnect (NVLink) slowest link tolerable (Ethernet/IB) G = d x t x p x c x e e.g. TP=8 within a node (NVLink), PP across nodes (InfiniBand), DP across racks Add axes in this order: TP (intra-node) -> PP (inter-node) -> CP (long context) -> EP (MoE) -> DP (throughput)
Canonical placement of the five parallelism axes ordered by communication intensity. Tensor and expert parallelism share the innermost tier because both fire per-layer collectives that demand NVLink bandwidth; each outer band tolerates progressively slower fabric, with data parallelism outermost because it pays only one all-reduce per training step. Mapping this nesting onto the physical topology — fast interconnect inward, slow outward — is the single most important placement decision in large-scale training.

This is why you see configs like “TP=8 (within a node), PP=12 (across nodes), DP=16 (across racks).” TP is locked inside the NVLink domain; PP and DP span the InfiniBand/Ethernet fabric where their lighter, less frequent communication is tolerable.

A useful way to think about it: TP and PP both reduce per-device memory and let you fit a bigger model; DP buys throughput; CP buys context length; EP buys parameter count. You first pick TP/PP/CP/EP large enough that one model replica fits and trains efficiently, then set DP to consume the remaining GPUs for throughput.

Worked example: sharding a 70B model on a 512-GPU cluster

Take a 70B dense model: \(L = 80\) layers, \(h = 8192\), \(a = 64\) heads, sequence \(s = 8192\), on 512 H100s (80 GB) arranged as 64 nodes × 8 GPUs (NVLink within a node, InfiniBand across).

Static memory. Full training state is \(\approx 16 \times 70\text{B} = 1120\) GB — about \(14\times\) an 80 GB GPU. We must shard the model at least 14-way before DP.

Step 1 — Tensor parallelism. Set \(t = 8\) to fill the NVLink domain. Weights+optimizer per GPU drop to \(\approx 1120/8 = 140\) GB. Still too big for one GPU — TP alone is not enough.

Step 2 — Pipeline parallelism. Add \(p = 8\) stages across 8 nodes. Now each GPU holds \(80/8 = 10\) layers, and static state per GPU is \(\approx 1120/(8 \cdot 8) = 17.5\) GB. Comfortably fits, leaving room for activations and the KV/communication buffers.

Step 3 — Data parallelism. We have used \(t \times p = 64\) GPUs for one replica. The cluster has 512, so \(d = 512/64 = 8\) data-parallel replicas. Layer in ZeRO-1 (shard optimizer state across the 8 DP ranks) to shave static memory further.

Step 4 — Bubble check. With \(p = 8\) and a global batch of, say, \(m = 64\) microbatches per replica, the 1F1B bubble is \(\frac{p-1}{m+p-1} = \frac{7}{71} \approx 9.9\%\). Add interleaving with \(v = 2\) and it halves to \(\approx 5\%\).

Result: a 3D config TP=8 × PP=8 × DP=8 = 512 GPUs, model fits with headroom, pipeline bubble ~5–10%, and every heavy collective (TP all-reduce) stays on NVLink. This is a realistic, near-optimal layout — and exactly the kind of back-of-envelope an interviewer wants to see.

Interview Corner

Q: You’re training a model where one layer’s activations fit on a GPU but the full model’s parameters do not. You’re told inter-node bandwidth is 10× slower than intra-node NVLink. How do you choose between tensor and pipeline parallelism, and how do you place them?

A: Tensor parallelism does a synchronous all-reduce twice per layer per direction — that traffic is on the critical path, so TP must live entirely inside the fast NVLink domain (typically \(t \le 8\)). Pipeline parallelism only does point-to-point sends at stage boundaries (a handful per step), so it tolerates the 10×-slower inter-node link well. So: use TP within a node to get the model small enough to fit the NVLink domain, then use PP across nodes to add more capacity. Concretely, set TP = node size (e.g. 8), then PP across nodes to fit the parameter budget, then DP across the remaining GPUs for throughput. Two cautions: keep the pipeline bubble small with many microbatches (\(m \gg p\)) and/or interleaving, and load-balance the pipeline stages (the embedding and LM-head stages are heavy — give them fewer transformer layers).

Practitioner tip

Don’t reach for model parallelism prematurely. The decision ladder is: (1) plain DDP if it fits; (2) ZeRO/FSDP to shard optimizer/grad/param state — this alone handles surprisingly large models with no model-parallel complexity (see Distributed Training I); (3) add TP once a single replica won’t fit even sharded, keeping it intra-node; (4) add PP to cross node boundaries; (5) add CP only when context length (not parameters) is the binding constraint; (6) EP only for MoE. Every axis you add multiplies the debugging surface — add the fewest that make the run fit and run efficiently.

A scale calibration for this book’s capstone: Stack-100M, the ~100M-parameter model trained in The Pretraining Run, needs none of the axes in this chapter. Its full bf16+Adam state is a couple of GB, so it trains on one GPU, and DDP/FSDP there is a pure speed-up rather than a requirement. In practice teams only reach for TP once a single replica stops fitting even after ZeRO/FSDP sharding — somewhere around the 10B-parameter mark on 80 GB devices, or much earlier if the context length is extreme. Read this chapter to understand what the frontier does and to answer for it in interviews; do not import its complexity into a 100M run.

Verify before you scale

Before scaling any of these axes to hundreds of GPUs, verify that the parallel implementation reproduces the single-GPU result — up to floating-point reduction-order differences. Fix every seed (torch.manual_seed on every rank, plus torch.use_deterministic_algorithms(True)), build a tiny model that fits on one GPU (e.g. \(h=256\), \(L=4\), \(a=8\), vocab \(=1024\), \(s=128\)), run one forward on a fixed input to get reference logits/loss at TP=1, then run the same input and weights at TP=2 (and separately PP=2, CP=2) on 2 GPUs and assert they match:

torch.testing.assert_close(loss_tp2, loss_ref, rtol=1e-3, atol=1e-3)
torch.testing.assert_close(logits_tp2, logits_ref, rtol=1e-3, atol=1e-3)

The tolerance matters: bf16/fp16 accumulate reductions (all-reduce, all-to-all) in a different order across ranks, so use rtol/atol ~ 1e-3 for bf16 — but do the initial bring-up in fp32 with ~1e-5, where any mismatch is a real bug, not roundoff. Pair this with structural guard assertions: for GQA/TP, assert num_kv_heads % tp == 0 or tp % num_kv_heads == 0; for PP stage balance, assert max(stage_params) / min(stage_params) < 1.1 so the embedding stage and the LM-head-plus-loss stage aren’t stragglers. Megatron-LM ships exactly this single-vs-parallel equivalence testing in tests/unit_tests/tensor_parallel, and DeepSpeed/PyTorch DTensor use the same allclose-against-single-GPU pattern.

Where the Communication Lives: A Summary Table

The single most important thing to internalize is what collective each axis pays, how often, and over which tensor. This is what lets you reason about whether a config will be fast on your hardware.

Axis Splits Collective Frequency Tensor moved Link needed
DP / ZeRO data (replicas) all-reduce (or RS+AG) once per step full gradients slowest OK
TP within a layer all-reduce (\(\times 4\)/layer) every layer \(s \times h\) activations NVLink only
PP across layers point-to-point send/recv stage boundaries \(s \times h\) activations inter-node OK
CP (Ring) sequence ring send/recv (overlapped) per attention \(K,V\) blocks fast, overlaps
EP experts all-to-all (\(\times 2\)) every MoE layer routed tokens fast (bandwidth)

Notice the frequency column. TP and EP pay per layer, so they demand the fastest links and the tightest placement. DP pays per step (after gradient accumulation over all microbatches), so it tolerates the slow outer fabric. PP’s communication is cheap point-to-point but introduces the bubble, a compute-utilization tax rather than a bandwidth one. CP’s communication overlaps with compute and so is nearly hidden when sequences are long. This table — internalized — is most of what you need to design or debug a large training run, and it connects directly to the collective communication primitives and the practical framework details in Megatron-LM, DeepSpeed & Parallelism in Practice.

Activation memory is the quiet killer

Engineers obsess over parameter memory because it’s easy to compute, but at long sequence lengths and large microbatch counts, activation memory often dominates and is what actually OOMs your run. TP (with Megatron sequence parallelism) and CP both attack activation memory directly; PP’s 1F1B bounds in-flight activations; and gradient checkpointing/recomputation trades compute to slash it further. When a large run OOMs, your first hypothesis should usually be activations, not weights.

Key Takeaways

Key Takeaways

  • Three orthogonal model-parallel axes plus DP: TP splits within a layer (matmuls), PP splits across layers, CP/SP splits along the sequence, EP splits across MoE experts. They compose multiplicatively into 3D/4D/5D parallelism, and total GPUs \(= d \cdot t \cdot p \cdot c \cdot e\).
  • Tensor parallelism = column-then-row matmul partitioning. A column-parallel layer feeding a row-parallel layer needs exactly one all-reduce per region (two per transformer block forward), with the nonlinearity acting on sharded features in between. It is bandwidth-heavy and must stay inside the NVLink domain (\(t \le 8\)).
  • Pipeline parallelism trades the bubble for cheap point-to-point comms. GPipe streams \(m\) microbatches; 1F1B keeps the same \(\frac{p-1}{m+p-1}\) bubble but bounds activation memory; interleaving divides the bubble by \(v\); zero-bubble schedules push it toward zero. Keep \(m \gg p\) and load-balance the stages.
  • Context parallelism (Ring Attention) shards the token sequence and rotates K/V around a ring, using the FlashAttention online softmax to fold in remote blocks while overlapping communication with compute — the key to million-token context. Mind causal-mask load imbalance.
  • Expert parallelism places experts on different devices and pays two all-to-all collectives (dispatch + combine) per MoE layer; its performance is gated by router load balance and expert capacity.
  • Place axes by communication intensity: TP and EP (per-layer collectives) on the fastest links; PP and DP (boundary/per-step) on slower fabric. This single placement principle drives most real configs.
  • Climb the ladder, don’t leap: DDP → ZeRO/FSDP → +TP (intra-node) → +PP (inter-node) → +CP (for context) → +EP (for MoE). Add the fewest axes that make the run fit and run efficiently — each one multiplies the debugging surface.
  • Activation memory, not parameters, is often what OOMs you at scale; TP+sequence-parallel, CP, 1F1B, and recomputation are your levers against it.
  • Know the library for each axis, not just the math: Megatron-Core (TP/PP/SP/CP/EP flags) and, PyTorch-natively, torch.distributed.tensor.parallel (parallelize_module + Colwise/Rowwise/SequenceParallel + loss_parallel), torch.distributed.pipelining (PipelineStage + the GPipe/1F1B/interleaved/zero-bubble schedules), context_parallel, and DeepEP/MegaBlocks for MoE — all composed on one DeviceMesh, as torchtitan demonstrates.

State of the Art & Resources (2026)

Tensor, pipeline, sequence, and expert parallelism are now mature, production-proven techniques: every frontier training run — dense giants like Llama 3 405B and large sparse MoE systems like DeepSeek-V3 (671B total, 256 experts) — uses some combination of all four axes, and MoE-at-scale has become the dominant frontier architecture. Active research is pushing toward zero-bubble schedules, compute-communication overlap (DualPipe), and smarter MoE load balancing — squeezing the last few percent of MFU out of clusters of tens of thousands of GPUs.

Foundational work

Recent advances (2023–2026)

Open-source & tools

  • NVIDIA/Megatron-LM — the reference implementation of TP, PP, SP, CP, and EP; most large public training runs are based on or validated against it.
  • PyTorch native: torch.distributed.tensor.parallel (parallelize_module, ColwiseParallel, RowwiseParallel, SequenceParallel, loss_parallel), torch.distributed.pipelining (PipelineStage plus the GPipe/1F1B/interleaved/zero-bubble/DualPipeV schedules), and torch.distributed.tensor.experimental.context_parallel — every axis in this chapter, as a supported API on one DeviceMesh.
  • pytorch/torchtitan — PyTorch’s reference pretraining codebase; the clearest worked example of composing FSDP2 + TP + PP + CP via DTensor, and the easiest 4D-parallel code to read end to end.
  • deepseek-ai/DualPipe — standalone PyTorch implementation of the DualPipe bidirectional pipeline algorithm from DeepSeek-V3/R1 training.
  • deepseek-ai/DeepEP — expert-parallel dispatch/combine communication kernels for MoE (NVLink + RDMA, with a low-latency inference path).
  • databricks/megablocks — dropless MoE via block-sparse GEMMs, removing the capacity factor and its dropped tokens.

Go deeper

Further reading

  • Shoeybi, Patwary, Puri, et al., Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism (2019) — the column/row tensor-parallel formulation.
  • Narayanan, Shoeybi, Casper, et al., Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM (2021) — interleaved 1F1B and the 3D-parallelism analysis.
  • Korthikanti, Casper, Lym, et al., Reducing Activation Recomputation in Large Transformer Models (2022) — Megatron sequence parallelism and selective recomputation.
  • Huang, Cheng, Bapna, et al., GPipe: Efficient Training of Giant Neural Networks using Pipeline Parallelism (2019).
  • Narayanan, Harlap, Phanishayee, et al., PipeDream: Generalized Pipeline Parallelism for DNN Training (2019) — the 1F1B schedule.
  • Qi, Wan, Huang, et al., Zero Bubble Pipeline Parallelism (2024).
  • Liu, Zaharia, Abbeel, Ring Attention with Blockwise Transformers for Near-Infinite Context (2023).
  • Lepikhin, Lee, Xu, et al., GShard: Scaling Giant Models with Conditional Computation and Automatic Sharding (2020) — expert parallelism and all-to-all dispatch/combine.
  • Rajbhandari, Rasley, Ruwase, He, ZeRO: Memory Optimizations Toward Training Trillion Parameter Models (2020) — the DP-side counterpart to this chapter.
  • The Megatron-LM and DeepSpeed open-source repositories — the reference implementations of every schedule discussed here.

Exercises

1. In the Megatron MLP block \(Y = \text{GeLU}(XA)B\), the up-projection \(A\) is column-parallel and the down-projection \(B\) is row-parallel. Explain why the order matters: what goes wrong if you instead make \(A\) row-parallel and \(B\) column-parallel? In particular, what extra collective would the GeLU force, and why?

Solution

The whole point of the column-then-row pairing is that the intermediate \(4h\) activation stays sharded across the nonlinearity with no collective in between. With column-parallel \(A\), each rank computes a distinct slice of the \(4h\) columns: \(H_i = X A_i \in \mathbb{R}^{s \times 4h/t}\). GeLU is elementwise, so \(\text{GeLU}(H_i)\) is exactly the correct slice of \(\text{GeLU}(H)\) — no communication is needed, and the sharded result is precisely the feature-sharded input that row-parallel \(B\) wants. One all-reduce at the end of \(B\) finishes the block.

Now reverse it. Make \(A\) row-parallel: to produce \(H = XA\) you must all-reduce the partial sums, giving every rank the full \(s \times 4h\) intermediate. That is one extra all-reduce you did not have before — and it forces you to materialize the full (un-sharded) \(4h\) tensor, losing the activation-memory saving. The reason you cannot skip that reduce is that GeLU is nonlinear: \(\text{GeLU}(\sum_i X_i A_i) \neq \sum_i \text{GeLU}(X_i A_i)\), so you may not apply the nonlinearity to the partial sums and reduce afterward — you must reduce first, then apply GeLU. Making \(B\) column-parallel then leaves the output feature-sharded, requiring yet another gather/all-reduce before the residual add. Net effect: two collectives instead of one, and no memory win. The column-then-row order is the unique choice that both keeps the nonlinearity correct and pays exactly one all-reduce per region.

2. A pipeline has \(p = 16\) stages. (a) Using GPipe/1F1B, how many microbatches \(m\) do you need to keep the bubble fraction at or below 10%? (b) If you switch to interleaved 1F1B with \(v = 4\) virtual stages per device and keep that same \(m\), what is the new bubble fraction? © The chapter says 1F1B and GPipe have the same bubble but 1F1B is preferred — what exactly does 1F1B buy you, and how does the peak activation memory scale with \(m\) in each?

Solution

(a) The bubble fraction is \(\frac{p-1}{m+p-1} = \frac{15}{m+15}\). Require \(\frac{15}{m+15} \le 0.10\):

\[ 15 \le 0.10\,(m+15) \implies 150 \le m + 15 \implies m \ge 135 . \]

So \(m = 135\) microbatches (round up), giving exactly \(\frac{15}{150} = 0.10 = 10\%\).

(b) Interleaving divides the bubble by \(v\):

\[ \frac{1}{v}\cdot\frac{p-1}{m+p-1} = \frac{1}{4}\cdot\frac{15}{135+15} = \frac{1}{4}\cdot 0.10 = 0.025 = 2.5\% . \]

© The bubble formula is identical for GPipe and 1F1B, so 1F1B buys no bubble reduction — it buys bounded activation memory. GPipe runs all \(m\) forwards before any backward, so every stage must stash the activations of all \(m\) in-flight microbatches: peak activation memory scales as \(O(m)\). 1F1B interleaves one forward with one backward in steady state, so a stage only holds activations for the microbatches currently in flight through it — at most \(\sim p\) (independent of \(m\)). This is what lets you crank \(m\) up to 135 (small bubble) without the activation memory blowing up.

3. Consider the chapter’s 70B model (\(L=80\), \(h=8192\), \(a=64\)) but now on a cluster of 256 H100s (80 GB), arranged as 32 nodes x 8 GPUs. Static training state is \(\approx 16 \times 70\text{B} = 1120\) GB. (a) With TP=8 and PP=4, how much static state sits on each GPU, and how many transformer layers does each pipeline stage own? (b) How many data-parallel replicas \(d\) does this leave, and what is the DP group size? © With \(m = 32\) microbatches, what is the plain-1F1B bubble fraction?

Solution

(a) TP and PP shard the static state multiplicatively across \(t \cdot p = 8 \cdot 4 = 32\) GPUs per replica:

\[ \frac{1120\text{ GB}}{8 \cdot 4} = \frac{1120}{32} = 35\text{ GB per GPU}. \]

That fits comfortably in 80 GB, leaving room for activations and buffers. Each pipeline stage owns \(L/p = 80/4 = 20\) transformer layers. (In practice the first and last stages would be given a few fewer layers to offset the embedding and LM-head+loss cost — see the load-balancing warning — but nominally 20 each.)

(b) One replica uses \(t \cdot p = 32\) GPUs. The cluster has 256, so

\[ d = \frac{256}{t \cdot p} = \frac{256}{32} = 8 \]

data-parallel replicas; the DP group size is therefore 8 (one rank per replica, communicating gradients once per step, tolerant of the slower inter-node fabric).

© With \(p = 4\) and \(m = 32\):

\[ \frac{p-1}{m+p-1} = \frac{3}{32+3} = \frac{3}{35} \approx 0.086 = 8.6\% . \]

4. Estimate the tensor-parallel communication volume for one transformer layer forward pass. Use \(s = 8192\), \(h = 8192\), activations in bf16 (2 bytes), and TP degree \(t = 8\) with ring all-reduce. (a) How many bytes does each GPU send+receive per all-reduce, using the chapter’s \(2\cdot\frac{t-1}{t}\cdot(s\,h\cdot 2)\) estimate? (b) A layer forward does two all-reduces; what is the per-GPU per-layer forward volume? © Why does this quantity, multiplied out over 80 layers, force the TP group onto NVLink rather than InfiniBand?

Solution

(a) The activation tensor per all-reduce has \(s \cdot h = 8192 \cdot 8192 = 67{,}108{,}864\) elements, i.e. \(\approx 6.71 \times 10^7\). In bf16 that is \(s\,h \cdot 2 = 1.342 \times 10^8\) bytes \(\approx 128\) MiB. The ring-all-reduce per-GPU send+receive volume is

\[ 2\cdot\frac{t-1}{t}\cdot(s\,h\cdot 2) = 2\cdot\frac{7}{8}\cdot 1.342\times 10^8 \approx 2.35 \times 10^8 \text{ bytes} \approx 224\text{ MiB}. \]

(b) Two all-reduces in the forward pass (after attention output-proj and after the MLP down-proj):

\[ 2 \times 2.35\times 10^8 \approx 4.70 \times 10^8 \text{ bytes} \approx 448\text{ MiB per GPU per layer (forward)}. \]

© Over \(L = 80\) layers the forward pass alone moves \(\approx 80 \times 448\text{ MiB} \approx 35\) GiB per GPU, and the backward pass roughly doubles it (two more all-reduces per layer). This traffic sits on the critical path — the GPUs stall on each all-reduce before proceeding. On NVLink/NVSwitch (\(\sim\)hundreds of GB/s, up to \(\sim\)900 GB/s aggregate) it is absorbed; on InfiniBand (10-25x slower) the per-layer all-reduces serialize behind the slow link and collapse throughput. That is exactly why the rule is to keep the TP group inside one NVLink domain, \(t \le 8\).

5. The chapter’s VocabParallelEmbedding masks out-of-range token ids, zeros the corresponding output rows, and then does an all_reduce(SUM). (a) Explain precisely why a SUM all-reduce yields the correct embedding, and why exactly one rank contributes a nonzero row per token. (b) Implement a gather_output option for ColumnParallelLinear (from the chapter’s code) that, when True, returns the full unsharded output by all-gathering the feature-sharded slices along the last dimension. Give runnable code consistent with the chapter’s style.

Solution

(a) The global vocabulary is partitioned into disjoint, contiguous ranges: rank \(r\) owns ids \([r V_{\text{loc}}, (r+1)V_{\text{loc}})\). For any token id, exactly one rank has it in range; every other rank masks it and writes a zero row for that token. So across the \(t\) ranks the set of contributions for a given token is: one correct embedding row (from the owner) plus \(t-1\) zero rows. Summing them element-wise (all-reduce SUM) therefore reproduces the true embedding row, and — because the non-owners contribute exactly zero — the sum is not corrupted. A different reduction (e.g. MAX or MEAN) would be wrong: MEAN would divide the true row by \(t\), and MAX would fail for any negative embedding component. SUM is the reduction that composes disjoint one-hot ownership into the right answer, and it also makes the backward clean: gradient flows as identity into only the owner’s local rows.

(b) Add an all-gather along the feature (last) dimension. The column-parallel output on each rank is [*, out_f/t]; concatenating the \(t\) shards in rank order reconstructs the full [*, out_f]:

class ColumnParallelLinear(nn.Module):
    """Y = X A, A split along output columns across `tp` ranks.
    If gather_output=True, all-gather the shards to return the full [*, out_f]."""
    def __init__(self, in_f, out_f, tp, rank, tp_group, bias=True,
                 gather_output=False):
        super().__init__()
        assert out_f % tp == 0
        self.tp = tp
        self.tp_group = tp_group
        self.gather_output = gather_output
        self.out_local = out_f // tp
        self.weight = nn.Parameter(torch.empty(self.out_local, in_f))
        self.bias = nn.Parameter(torch.zeros(self.out_local)) if bias else None
        nn.init.normal_(self.weight, std=0.02)

    def forward(self, x):
        x = copy_to_region(x)                              # f: correct bwd all-reduce
        y = torch.nn.functional.linear(x, self.weight, self.bias)  # [*, out_f/tp]
        if not self.gather_output:
            return y                                       # sharded (feeds a RowParallel)
        # all-gather the per-rank feature shards, then concat along last dim
        shards = [torch.empty_like(y) for _ in range(self.tp)]
        dist.all_gather(shards, y.contiguous(), group=self.tp_group)
        return torch.cat(shards, dim=-1)                   # [*, out_f], full output

Notes consistent with the chapter: (i) gather_output=False is the default and is what you use when the layer feeds a RowParallelLinear (the puzzle-piece pairing that avoids a gather); you only set gather_output=True when the full output is genuinely needed on every rank (e.g. a non-parallel head). (ii) The all-gather concatenates in rank order, matching how the columns of \(A\) were split as \([A_1, \dots, A_t]\). (iii) For a correct backward, the gather should be paired with a scatter/split in the backward pass (a _GatherFromTPRegion autograd Function mirroring the chapter’s _ReduceFromTPRegion); the forward-only sketch above shows the layout logic. (iv) An LM head that feeds vocab_parallel_cross_entropy should keep gather_output=False — the entire point of the parallel cross-entropy is never to materialize the full [N, V] logits.