Rishiraj's blog

The 5 Dimensions of Parallelism

A little while ago, I gave a talk at an Google event on a topic that eats up an unreasonable amount of my waking hours: how you take a language model that has no business fitting on a single graphics card and stretch it across tens, hundreds, or thousands of GPUs without setting your cluster—or your cloud budget—on fire.

After the talk, a bunch of folks came up asking for the slides. I always hesitate to just drop a raw slide deck on LinkedIn. Slides are meant to be a visual backdrop while somebody is talking; when you read them cold, half the nuance is missing and they just look like boxes and arrows. So instead of dumping a PDF link and calling it a day, I sat down and wrote out the entire talk as a long-form post—walking through every slide, the actual matrix math behind the cuts, and the practical systems trade-offs that didn't fit on a 30-minute stage timer.

Grab a coffee. This is a long read.

About Me - Hi, I'm Rishiraj

Quick intro if we haven't crossed paths before: I’m Rishiraj. I work as an Applied Scientist on the research team at Zomato, and outside of work I’m a Google Developer Expert (GDE) for AI & Cloud and a Hugging Face Fellow. I got my start in open source years ago contributing to TensorFlow during Google Summer of Code, and I’ve been hooked on the systems side of ML ever since.

My day job revolves around NLP and distributed LLM training pipelines. Everything in this post comes straight from the trenches of staring at nvidia-smi, hunting down silent NCCL hangs at 2 AM, and figuring out why a training script that hummed along happily at a 4K context length suddenly explodes when you bump it to 32K.

One Model, Five Ways to Cut It

Training a small model on a single GPU is peaceful. Your data sits on the card, your weights sit on the card, the forward pass goes up, the backward pass comes down, the optimizer steps, and you move on with your life.

The moment you try to train or fine-tune a modern LLM at scale, you slam into two walls:

  1. The Memory Wall: The model weights, gradients, optimizer states, and intermediate activations simply do not fit in a single GPU’s VRAM.
  2. The Time Wall: Even if your model does fit on one card, training it on billions or trillions of tokens on a single GPU would take years.

To get past both walls, you have to slice your training run across multiple GPUs. Distributed training papers love inventing intimidating names for their setups, which makes the field look way more fragmented than it actually is. Strip away the branding, and there are really only five ways to cut a model.

Roadmap - One model, five ways to cut it

Here is the whole map:

  1. Data Parallelism (DP): Shards the batch. Every GPU gets a different slice of data. When paired with ZeRO or FSDP, we also shard the optimizer states, gradients, and weights across those GPUs.
  2. Tensor Parallelism (TP): Shards the math. We split individual matrix multiplications down the middle so multiple GPUs compute a single layer together in lockstep. This lives strictly inside one 8-GPU node.
  3. Sequence & Context Parallelism (SP/CP): Shards the text. When sequences get long, we split the token dimension across GPUs so activation memory doesn't blow up.
  4. Pipeline Parallelism (PP): Shards the layers. Server 1 takes layers 1 to 4, Server 2 takes layers 5 to 8, and they pass activations down the line like a factory assembly belt.
  5. Expert Parallelism (EP): Shards the experts. Exclusive to Mixture-of-Experts (MoE) models, where different GPUs host different expert sub-networks and route tokens back and forth.

Those five dimensions aren't arbitrary software abstractions. They come directly from the tensor shapes of a Transformer.


Part 0: Foundations (Where Is All the VRAM Going?)

To see why there are only five cuts, trace a single batch of text as it flows through a standard decoder-only Transformer.

Foundations - Follow one batch through the model

Your input enters the model as integer token IDs of shape [batch_size, seq_len]. The embedding lookup turns each token ID into a dense vector, giving you a 3D activation tensor of shape [batch_size, seq_len, hidden_dim]. That tensor passes through N identical decoder layers, hits a final RMSNorm, and gets projected by the language modeling head into [batch_size, seq_len, vocab_size] logits.

Look at the activation tensor sitting in the middle of that diagram—[batch_size, seq_len, hidden_dim]—and the N layers it travels through. Every single dimension of parallelism is just a decision about which axis to slice:

For a long time, the standard playbook for big models was called "3D Parallelism," which meant combining the three classic cuts: Pipeline, Tensor, and Data.

Foundations - The three classic cuts

If you picture two GPUs sharing the work:

Before we start splitting models across a cluster, though, we need to do some back-of-the-envelope memory math. Most out-of-memory crashes happen because people underestimate how much state a training loop actually holds.

The 20-Byte Rule

Almost everyone who starts training LLMs makes the same mental calculation at least once: "I want to train an 8B parameter model in BF16. Two bytes per parameter times 8 billion is 16 GB. I have an 80 GB A100, so I've got 64 GB to spare."

Then you launch the script and it OOMs before finishing the first step.

Foundations - The memory eaters

The 16 GB figure only accounts for inference weights. When you train with mixed precision and AdamW, here is what actually sits in memory for every single parameter:

Add those up and you get 20 bytes per parameter. Multiply 20 bytes by 8 billion parameters, and your "16 GB" model actually needs 160 GB of VRAM just to hold its static training state—before a single token of training data ever touches the GPU.

And once data does touch the GPU, you have to pay for activations. During the forward pass, PyTorch has to save the intermediate outputs of every layer in memory because the backward pass needs them to compute gradients via the chain rule (for a linear layer Y=XW, computing the weight gradient dW=XTdY requires the original input activation X). Unlike weights and optimizer states, which are fixed by your model size, activation memory scales linearly with batch_size and explodes with seq_len.

Before you throw more GPUs at activation memory, there are two single-GPU techniques you should always turn on first.

Trick 1: Activation Recomputation (Gradient Checkpointing)

If saving every intermediate tensor from the forward pass is what fills up your VRAM, the fix is simple: stop saving all of them.

Foundations · Activation recomputation - Trade compute for memory

With activation recomputation (called gradient_checkpointing in Hugging Face and PyTorch), you only save the activation tensor at the input boundary of each Transformer layer and discard everything computed inside the layer. During the backward pass, right before backpropagating through Layer k, the GPU takes that saved input boundary tensor and re-runs Layer k’s forward pass on the fly to regenerate the internal activations it needs.

Doing full recomputation across every layer slashes activation memory from O(num\_layers×internal\_ops) down to just the layer boundaries, at the cost of roughly 30–40% extra training time (since you are effectively running a second forward pass during backward).

If you want most of the memory savings without eating the full 35% compute hit, you use selective recomputation:

Trick 2: Gradient Accumulation

For stable optimization, LLMs want large effective batch sizes—often millions of tokens per step. Your GPU, meanwhile, might only be able to hold 1 or 2 sequences at a time without running out of activation memory.

Foundations · Gradient accumulation - Big batches, small memory

Gradient accumulation decouples your mathematical batch size from your physical memory footprint. Instead of feeding a batch of 4 all at once, you run 4 sequential micro-batches of size 1:

  1. Run the forward and backward pass on Micro-batch 1. This populates your gradient buffers with g1. As soon as backward finishes, PyTorch frees the forward activations for Micro-batch 1. You do not run the optimizer yet.
  2. Run forward and backward on Micro-batch 2. PyTorch adds the new gradients directly into the existing buffer (g1+g2), and frees Micro-batch 2’s activations.
  3. Do the same for Micro-batches 3 and 4.
  4. Average the accumulated gradients (g1+g2+g3+g4)/4 and call optimizer.step() once.

You get the exact same weight update as a batch of 4, using only a quarter of the activation memory.

One classic footgun here: when you combine gradient accumulation with Data Parallelism, make sure micro-batches 1 through 3 are wrapped in PyTorch’s model.no_sync() context manager. By default, DDP hooks into the backward pass and triggers a network all-reduce every time loss.backward() finishes. If you forget no_sync(), your GPUs will pointlessly synchronize un-stepped partial gradients across the network on micro-batches 1, 2, and 3—wasting 75% of your network bandwidth.


Dimension 1: Data Parallelism (Sharding the Batch)

Dimension 1 of 5 - Data parallelism

Data Parallelism is the workhorse of distributed training. In standard Distributed Data Parallel (DDP), every GPU holds a complete copy of the model, receives a different slice of the data batch, runs its own forward and backward pass, and then averages gradients across all GPUs via an all-reduce collective so every replica stays in sync.

If you coded this naively—wait for the whole backward pass to finish, call dist.all_reduce() on all gradients, wait for the network, then run the optimizer—your GPU utilization would tank. Your compute cores would sit idle during communication, and your network card would sit idle during computation.

Data parallelism - Overlap the all-reduce with backward

The saving grace of Data Parallelism is that backpropagation runs in reverse: Layer 2 (L2), then Layer 1 (L1), then Layer 0 (L0).

As soon as the backward pass finishes L2, the gradients for L2 are final. Nothing happening in L1 or L0 will alter them. So DDP groups gradients into buckets and fires off an asynchronous all-reduce on L2’s gradients over NCCL at the exact same time the CUDA compute stream moves on to calculating L1’s backward pass. By the time L0 finishes its backward pass, nearly all the network communication has already finished in the background.

ZeRO & FSDP: Stop Storing the Same Thing N Times

Overlapping communication makes plain DDP fast, but DDP doesn't help at all with the Memory Wall. If your 8B model takes 160 GB of training state, and you have 8 GPUs with 80 GB each, plain DDP tries to put all 160 GB onto every single card. You have 640 GB of aggregate cluster memory, yet you still crash with an OOM because all 8 GPUs are hoarding identical copies of the same state.

Zero Redundancy Optimizer (ZeRO) - Stop storing the same thing N times

The Zero Redundancy Optimizer (ZeRO) (implemented in DeepSpeed) and FSDP (Fully Sharded Data Parallel, native in PyTorch) eliminate that duplication by partitioning the 20 bytes of state across your N data-parallel GPUs in three stages:

Of course, a GPU can't compute the forward pass of Layer 1 on its local data batch if it only has 1/8th of Layer 1’s weight matrix.

ZeRO-3 · FSDP - More traffic, hidden by prefetching

So ZeRO-3 / FSDP materializes each layer on demand:

  1. Right before Layer 1 runs in the forward pass, all GPUs execute an all-gather to assemble the full BF16 weights of Layer 1.
  2. Every GPU runs the forward pass of Layer 1 on its own local data batch.
  3. Immediately after Layer 1 finishes, every GPU frees the N−1 weight shards it doesn't own, dropping memory right back down.
  4. During the backward pass, it does the same thing in reverse: all-gather the layer weights to compute gradients, free the unowned weights, and reduce-scatter the newly computed gradients.

Because ZeRO-3 has to all-gather parameters during both the forward and backward passes (on top of reduce-scattering the gradients), it generates 1.5× the total communication volume of ZeRO-2 or standard DDP.

To keep that extra 50% network traffic from stalling the GPUs, FSDP relies heavily on prefetching: while the compute stream is executing Layer 1, the communication stream is already running gather 2 to pull in the weights for Layer 2 (and in the backward pass, backward_prefetch grabs Layer k-1 while Layer k is still computing).

Practical Realities: What the Slides Don't Tell You About FSDP & ZeRO

Since almost everyone reading this will use ZeRO or FSDP on a weekly basis, there are three practical lessons worth burning into memory before you configure your next run:

  1. Never use ZeRO-3 (FULL_SHARD) if ZeRO-2 (SHARD_GRAD_OP) fits in memory. Many practitioners default to ZeRO-3 / full FSDP because "more sharding = less memory = better." In reality, if your model fits under ZeRO-2, switching to ZeRO-3 is pure downside: you pay 50% more network traffic, and if your interconnect can't completely hide the all-gather latency behind compute, your GPU utilization (MFU) drops. Always try ZeRO-2 + activation checkpointing first; only step up to ZeRO-3 when the model genuinely won't fit otherwise.
  2. Use Hybrid Sharding (HSDP / ZeRO++) on multi-node clusters. Suppose you have 4 nodes of 8 GPUs (32 GPUs total), and your model needs ZeRO-3 to fit across 8 GPUs. If you run standard ZeRO-3 across all 32 GPUs, every single layer's all-gather has to traverse the slower inter-node network across all 4 servers. Instead, use Hybrid Sharding (ShardingStrategy.HYBRID_SHARD in PyTorch FSDP): it runs ZeRO-3 inside each 8-GPU node (where the frequent weight all-gathers fly over fast NVLink) and plain DDP / gradient all-reduce across the 4 nodes (which only happens once at the end of the gradient accumulation step). On 2-to-8 node setups without top-tier InfiniBand, flipping from FULL_SHARD to HYBRID_SHARD regularly doubles training throughput.
  3. Remember that ZeRO-3 does NOT shard activations. ZeRO-3 shards weights, gradients, and optimizer states, plus it still requires enough temporary VRAM buffer to hold one full unsharded layer (or FSDP unit) plus its prefetch buffer during the forward/backward pass. If you wrap your entire model as a single giant FSDP unit instead of wrapping at the TransformerDecoderLayer level, FSDP will all-gather the entire model at once and immediately OOM.

Dimension 2: Tensor Parallelism (Sharding the Math)

Dimension 2 of 5 - Tensor parallelism

What happens when a model is so big that even holding a single unsharded layer and its activations during an FSDP all-gather blows out your memory? Or what if you have hundreds of GPUs and want to cut down both weight memory and activation memory without doing global all-gathers across the whole cluster?

You cut the matrix multiplications themselves. This is Tensor Parallelism (TP), introduced by NVIDIA’s Megatron-LM.

There is one ironclad rule with Tensor Parallelism: keep it inside a single 8-GPU node (TP <= 8). Unlike Data Parallelism (which syncs once per layer or once per accumulation step in the background), TP requires blocking collective communications in the middle of the forward and backward pass of every single layer. Inside a node connected by NVLink (400–900 GB/s bidirectional bandwidth), those syncs take microseconds. Across nodes over Ethernet or standard InfiniBand, latency kills you and your GPUs spend most of their time waiting on the network.

Let's walk through how you split a linear layer Y=XW across two GPUs using concrete numbers.

Tensor parallelism · Linear layers - One matmul, two ways to split it

Take an input activation matrix X of shape (4, 2) and a weight matrix W of shape (2, 2):

X=[01234567],W=[10302040]

On a single GPU, Y=X@W produces a (4, 2) matrix:

Y=[204080180140320200460]

Now let's split W across GPU 0 and GPU 1. Because a 2D matrix has two axes, there are two ways to slice W: along its columns or along its rows.

1. Column Linear: Split W by Columns, Then All-Gather

Tensor parallelism · Column linear - Split W by columns, then all-gather

In a Column Linear split, both GPUs receive the full input X, and each GPU holds half the columns of W:

Because matrix multiplication operates column-by-column on the right-hand matrix, Y0 is already the exact first column of Y, and Y1 is the exact second column of Y. If we need the full output Y right now, we just run an all-gather to concatenate Y0 and Y1 side-by-side: [Y_0, Y_1] = Y.

2. Row Linear: Split W by Rows, Then All-Reduce

Tensor parallelism · Row linear - Split W by rows, then all-reduce

Now suppose we slice W horizontally by rows instead. For the inner dimensions of the matrix multiplication to match, slicing W by rows forces us to slice the input X by columns:

Notice the difference: neither Y0 nor Y1 contains final numbers yet. By the block matrix multiplication rule:

XW=[X0X1][W0W1]=X0W0+X1W1=Y0+Y1

Each GPU has computed a partial sum of the full (4, 2) output. To get the final Y, we run an all-reduce (sum) to add Y0 and Y1 element-wise:

[0020604012060180]+[204060120100200140280]=[204080180140320200460]

Why We Always Pair "Column First, Then Row"

If both Column Linear and Row Linear work, why not just use Column Linear for every layer?

Look at what happens inside a Transformer MLP block, where you have two linear layers separated by a non-linear activation function like GeLU: MLP(X)=GeLU(XW(1))W(2).

Tensor parallelism · Linear -> GeLU -> Linear - Why column first, then row

If you use Column → Column, you have to all-gather the columns after the 1st linear layer, apply GeLU, and then all-gather again after the 2nd linear layer (plus the corresponding backward communications)—totalling 4 communication ops for the block.

Now see what happens when you pair Column → Row:

  1. The first layer is Column Linear. Without doing an all-gather, GPU 0 holds the left columns XW0(1) and GPU 1 holds the right columns XW1(1).
  2. Next comes GeLU. Because GeLU acts element-by-element on each scalar independently, applying GeLU to two half-matrices and concatenating them gives the exact same result as concatenating them first and applying GeLU: cat(gelu(XW0),gelu(XW1))=gelu(cat(XW0,XW1)) That means each GPU can run GeLU locally on its own column slice without any network communication at all.
  3. Right after GeLU, GPU 0 holds the left columns of the activated tensor and GPU 1 holds the right columns. And what does a Row Linear layer need as its input? A tensor already split by columns! So the output of GeLU drops straight into the second (Row Linear) layer with zero communication in between.

You only communicate once at the very end of the second linear layer (via the Row Linear all-reduce). That cuts the communication ops in half!

(And you can see on the bottom-right of the slide why Row → Column doesn't work: a Row Linear layer outputs un-summed partial matrices, and because GeLU is non-linear, gelu(A)+gelu(B)≠gelu(A+B). You would be forced to run an all-reduce before you could apply GeLU).

Tensor parallelism · Inside a decoder layer - Column in, row out, every time

This "Column in, Row out" pattern is the heartbeat of Tensor Parallelism across the entire Transformer decoder layer:

So across an entire Transformer layer—both Attention and MLP—Tensor Parallelism only needs two all-reduce operations in the forward pass (and two in the backward pass).

Splitting the Embedding Table (Vocab-Parallel Embedding)

Modern tokenizers have huge vocabularies—128,256 tokens in Llama 3, over 150K in Qwen, and 256K+ in multilingual models. At a hidden dimension of 8192, a 128K embedding table plus the output lm_head has over 2 billion parameters by itself. So Megatron also tensor-parallelizes the embedding layer across the vocabulary axis.

Tensor parallelism · Embedding - Split the vocabulary, mask, then sum

Here is how vocab-parallel embedding works with a toy 1D embedding weight EW = [10, 20, 30, 40, 50, 60] (vocab size 6) and input token IDs x = [1, 4, 2, 5]:

  1. Shard the vocab: GPU 0 holds the first half of the vocabulary, IDs 0–2 ([10, 20, 30]). GPU 1 holds the second half, IDs 3–5 ([40, 50, 60]).
  2. Build a range mask: Both GPUs receive the full token sequence x = [1, 4, 2, 5] and check which tokens fall inside their local vocab range. GPU 0 gets [T, F, T, F]; GPU 1 gets [F, T, F, T].
  3. Shift to local indices: Each GPU subtracts its starting vocab offset (0 for GPU 0, 3 for GPU 1) and clamps out-of-range indices to 0 so F.embedding won't throw an out-of-bounds error. GPU 0 gets local IDs [1, 0, 2, 0]; GPU 1 gets [0, 1, 0, 2].
  4. Local lookup: Each GPU runs F.embedding on its local shard. GPU 0 gets [20, 10, 30, 10]; GPU 1 gets [40, 50, 40, 60].
  5. Zero out the masked positions: Where the mask from Step 2 was False, overwrite the dummy lookup with zeros. Now GPU 0 has [20, 0, 30, 0] and GPU 1 has [0, 50, 0, 60].
  6. All-reduce (sum): Sum the two vectors across GPUs to get [20, 50, 30, 60]—identical to a single-GPU lookup.

Dimension 3: Sequence & Context Parallelism (Sharding the Text)

Dimension 3 of 5 - Sequence & context parallelism

Tensor Parallelism splits the heavy matrix multiplications inside Attention and MLP, but what happens outside those two blocks?

Between every Attention block and MLP block, you have LayerNorm / RMSNorm, residual connections, and Dropout. Because TP finishes every Attention and MLP block with an all-reduce, the activation tensor exiting that all-reduce is replicated identically on all 8 GPUs.

When sequence lengths were 2,048 tokens, nobody cared that LayerNorm and residual activations were duplicated 8 times inside a node. When you push context length to 32K, 64K, or 128K, those unsharded [batch, seq_len, hidden_dim] boundary tensors become one of the largest consumers of activation memory on the GPU.

Sequence Parallelism (SP): Free Memory with Zero Extra Traffic

Sequence parallelism - Split what tensor parallelism can't

Sequence Parallelism (SP) fixes that duplication by exploiting two facts:

  1. LayerNorm, RMSNorm, Dropout, and residual adds are completely independent across the token dimension (seq_len). Computing RMSNorm for token 5 only requires the hidden_dim vector of token 5; it never looks at token 6. So if we have an 8,000-token sequence across 8 GPUs, there is no reason for all 8 GPUs to normalize all 8,000 tokens. Each GPU can just hold 1,000 tokens and normalize its own slice.
  2. An all-reduce is mathematically identical to a reduce-scatter followed by an all-gather (and uses the exact same total network bandwidth).

Look at the diagram above to see how SP rewires the layer:

Because reduce-scatter + all-gather transfers the exact same number of bytes as a ring all-reduce, turning on Sequence Parallelism alongside Tensor Parallelism is essentially a free lunch: zero extra communication volume, and a massive drop in activation memory. In modern Megatron-style training, TP and SP are almost always turned on together as a pair.

Context Parallelism (CP) & Ring Attention

Here is a question that trips up a lot of engineers: "Wait—if we already use FlashAttention (which avoids materializing the O(S2) attention matrix) and TP+SP, why do we still run out of memory at 64K or 128K context lengths?"

Because even though FlashAttention makes attention memory linear in sequence length O(S) instead of quadratic O(S2), linear in S=128,000 across 64 or 80 layers is still gigantic! Remember, inside the TP region (Step 3 of the previous slide), we had to all-gather the full sequence of tokens so Attention and MLP could run. Even split 8 ways across heads by TP, holding the Q,K,V projections, MLP intermediate activations (which expand hidden_dim by 3.5× to 4× in SwiGLU), and layer boundary checkpoints for 128,000 tokens inside a single node will blow right past 80 GB per GPU.

To train on ultra-long documents (64K to 1M+ tokens), we cannot all-gather the full sequence anywhere. Every GPU must hold only a 1/N chunk of the sequence at all times—even during Attention and MLP. That is Context Parallelism (CP).

In the MLP, LayerNorm, and linear projections, keeping the sequence sharded is trivial because those ops work token-by-token. The only place tokens actually interact with each other is inside Self-Attention, where a Query token at position 100,000 needs to attend to Key and Value tokens all the way back at position 1.

Ring Attention solves this without ever gathering the full sequence on one GPU.

Context parallelism - Ring attention

Suppose we split a long sequence into 4 chunks across 4 GPUs placed in a logical ring:

  1. Queries stay home: GPU 1 permanently holds Q1, GPU 2 holds Q2, GPU 3 holds Q3, and GPU 4 holds Q4.
  2. Keys and Values travel the ring: In Round 0, each GPU computes FlashAttention between its local Qi and its local Ki,Vi. Simultaneously, in the background over P2P network sends/recvs, each GPU passes its Ki,Vi tensor to the next GPU in the ring.
  3. In Round 1, GPU 2 now has K1,V1. It computes attention between Q2 and K1,V1, merges the result with Round 0's output using online softmax rescaling (the exact same log-sum-exp trick FlashAttention uses across SRAM tiles), and forwards K1,V1 along to GPU 3.
  4. After N−1 hops around the ring, every Query chunk has attended to every Key/Value chunk, and each GPU has the exact final attention output for its local sequence chunk.

(A nice practical bonus here: in modern LLMs that use Grouped-Query Attention (GQA), the K and V tensors have 4× to 8× fewer heads than Q. Because Ring Attention only sends K and V over the network while keeping the bulky Q tensor stationary, GQA makes it much easier to hide the ring communication completely behind the attention FLOPs!)

The Causal Mask Trap (and How Zig-Zag Ring Attention Fixes It)

If you implement naive Ring Attention for an autoregressive (causal) LLM, you immediately hit a severe load-imbalance bug.

Because of the lower-triangular causal mask, early tokens can only attend to earlier tokens, while late tokens must attend to the entire document:

The fix is Zig-Zag Ring Attention (striped or symmetric chunking). Instead of cutting the sequence into 4 contiguous blocks, we cut it into 2N=8 sub-chunks and pair one chunk from the very front with one chunk from the very back on each GPU:

Because every GPU now holds one early slice (almost no causal work) and one symmetrically late slice (lots of causal work), the total unmasked attention FLOPs on every GPU at every step of the ring are 100% identical. No GPU ever idles waiting for another.


Dimension 4: Pipeline Parallelism (Sharding the Layers)

Dimension 4 of 5 - Pipeline parallelism

What if your model has 70B, 405B, or 671B parameters? A single 8-GPU node doesn't have enough memory to hold it, Tensor Parallelism can't leave the 8-GPU node without hitting a latency wall, and running pure ZeRO-3 across 64 or 128 nodes would choke your inter-node network with massive weight all-gathers.

This is where Pipeline Parallelism (PP) comes in. Instead of splitting individual matrices, we assign whole groups of contiguous layers (called stages) to different nodes: Server 1 runs Layers 1–4, sends only the single boundary activation tensor [micro_batch, seq_len, hidden_dim] over the inter-node link to Server 2, and Server 2 runs Layers 5–8.

Because Pipeline Parallelism only communicates point-to-point at the boundary between stages, its network traffic is tiny compared to TP or ZeRO-3. The challenge with PP isn't bandwidth—it's keeping all the GPUs busy at the same time.

Pipeline parallelism - An assembly line, and its bubble

If you feed one big batch into a 4-stage pipeline, Stage 2, 3, and 4 sit completely idle while Stage 1 computes; then Stage 1, 3, and 4 sit idle while Stage 2 computes. Three-quarters of your cluster is wasted.

To fill the assembly line, we split each batch into M smaller micro-batches (F1, F2, F3, F4) and stream them through the stages back-to-back.

The timeline on the slide above shows the simplest schedule, All Forward, All Backward (GPipe). Even with micro-batching, two headaches jump out immediately:

  1. The Pipeline Bubble: Look at the gray hatched boxes where stages wait for upstream activations to arrive or downstream gradients to return. The fraction of time spent in that idle bubble is roughly P−1M, where P is the number of pipeline stages and M is the number of micro-batches.
  2. The Activation Memory Spike: Look at GPU 1 (Stage 1). It computes forward micro-batches F1, F2, F3, F4 right at the start of the step, and then has to wait for the entire pipeline to finish before it can run its first backward pass B1. That means Stage 1 must hold the forward activations for all M micro-batches in VRAM simultaneously! If you increase M to shrink the bubble, you blow up activation memory on Stage 1.

Four Schedules to Shrink the Bubble

To get around the memory spike and crush that idle bubble, pipeline schedules have evolved through four main generations:

Pipeline parallelism · Schedules - Four ways to shrink the bubble

  1. All Forward, All Backward (GPipe): Runs all forwards, then all backwards. Easy to reason about, but rarely used in large-scale LLM training today because holding M micro-batches of activations in memory doesn't scale.
  2. One Forward, One Backward (1F1B): Instead of waiting for all forward micro-batches to finish, as soon as the last stage finishes F1, it immediately starts B1 and sends gradients upstream. Once the pipeline warms up, every stage alternates strictly between one forward pass and one backward pass (F, B, F, B...). Because each B immediately frees the saved activations of a prior F, peak in-flight activation memory on Stage 1 is capped at P micro-batches instead of M—letting you crank up M to shrink the bubble without running out of memory. (Meta used 1F1B to train Llama 3 405B).
  3. Interleaved 1F1B: Instead of giving each server one big block of contiguous layers (Server 1 = L1–L4, Server 2 = L5–L8), you assign each server multiple smaller "virtual stages" looped around the pipeline (Server 1 = L1–L2 and L9–L10). Because each micro-batch passes through smaller chunks of layers faster, the pipeline warms up and drains much quicker, reducing the bubble size by a factor of v (the number of virtual stages per GPU), at the cost of v× more point-to-point network hops.
  4. Zero Bubble & DualPipe: Popularized by Sea AI Lab's Zero Bubble paper and pushed to production scale in DeepSeek-V3 and DeepSeek-R1 (via their bidirectional DualPipe schedule, which also overlaps forward/backward compute with MoE all-to-all communication).

How do you get a pipeline bubble down to zero without changing the math of backprop?

Pipeline parallelism · Zero bubble - Split the backward pass in two

When we draw B1 on a pipeline diagram, we treat the backward pass of a layer Y=XW as a single monolithic block. Under the hood, though, the backward pass of every linear layer is actually two completely separate matrix multiplications:

  1. Gradient with respect to input activations (dX=dY·WT, often called B): Stage k−1 upstream cannot start its own backward pass until Stage k computes dX and sends it back over the network. So dX is on the critical path and must be computed immediately.
  2. Gradient with respect to weights (dW=XT·dY, often called W): Who is waiting on dW? Nobody. No upstream stage needs dW. The only thing that ever touches dW is the local optimizer step (optimizer.step()) at the very end of the entire batch!

Zero Bubble schedules un-fuse the backward pass into those two distinct operations: B (activation grad) and W (weight grad). During the main pipeline flow, each stage only computes B and immediately fires dX upstream so the previous stage never has to wait. Meanwhile, all those W matmuls are placed into a local work queue. Whenever a GPU hits an idle "bubble" slot where it is waiting for the network, it pops a deferred W matmul off the queue and computes it to fill the gap. Same exact FLOPs, same exact gradients, and the idle bubble practically disappears.


Dimension 5: Expert Parallelism (Sharding the Experts)

Dimension 5 of 5 - Expert parallelism

In the live talk, Expert Parallelism only got one slide because of time constraints. In practice right now, though, you can't talk about frontier LLM training without talking about Mixture-of-Experts (MoE). From Mixtral to Qwen-MoE to DeepSeek-V3/R1, MoE has become the dominant way to scale model capacity without blowing up per-token compute FLOPs.

In a dense Transformer, every single token passes through the exact same giant Feed-Forward (MLP) network. In an MoE layer, you replace that single giant MLP with E smaller parallel MLP sub-networks called experts (for instance, 8 experts in Mixtral 8x7B, or 256 routed experts in DeepSeek-V3) plus a tiny linear router (gating network). For each incoming token, the router outputs a probability distribution over all experts and selects only the top-k experts to actually process that token.

This creates an interesting systems problem: a model like DeepSeek-V3 has 671B total parameters (so all the experts combined require massive amounts of VRAM), yet each individual token only activates 37B parameters (k=8 out of 256 routed experts).

Expert Parallelism (EP) distributes those experts across GPUs:

Three Practical Headaches with Expert Parallelism

If you ever train or fine-tune an MoE model with Expert Parallelism, you will immediately run into three systems quirks that don't exist in dense models:

  1. The "Hot Expert" Straggler Problem (Load Imbalance): In Data, Tensor, Sequence, and Pipeline Parallelism, every GPU knows ahead of time exactly how many tokens and FLOPs it will compute. In Expert Parallelism, the workload is data-dependent and dynamic! What if, on a given batch of Python code, the router decides to send 80% of all tokens across the entire cluster to Expert 12 on GPU 1 (the "coding expert"), while sending almost nothing to the experts on GPU 4? GPU 1 gets buried in compute (or even OOMs from holding too many incoming token activations), while the other 7 GPUs sit completely idle waiting at the second all-to-all barrier.
    • How we fix it: During training, MoE models use auxiliary load-balancing losses (or auxiliary-loss-free dynamic bias terms, as introduced in DeepSeek-V3) that nudge the router to distribute tokens evenly across experts. Many frameworks also set a hard capacity factor (dropping overflow tokens through the residual connection if an expert's buffer fills up) or use dropless MoE (dMoE / MegaBlocks) with block-sparse matrix kernels to handle variable-sized token buckets without padding waste.
  2. All-to-All Is Brutal on Network Topology: In an all-reduce or reduce-scatter, communication can be neatly pipelined around a ring. In an all-to-all, every GPU in the EP group simultaneously talks to every other GPU in the EP group twice per layer (once in dispatch, once in combine, and again in backward!). If your EP degree spans across multiple nodes with oversubscribed network switches, all-to-all congestion will tank your throughput unless you use hierarchical routing (restricting a token to pick experts on at most M nodes) or overlap the all-to-all with compute (like DualPipe does).
  3. Shared Experts vs. Routed Experts: Modern MoEs (like DeepSeek and Qwen) often split their experts into 1 or 2 "shared experts" (which process every token deterministically, just like a dense MLP) plus dozens of routed experts. In your parallelism config, those shared experts don't need all-to-all routing—they stay replicated on each GPU (or sharded via Tensor Parallelism), and their computation can actually be overlapped on a separate CUDA stream while the routed tokens are flying across the network in the all-to-all!

Putting It All Together: The 5D Cheat Sheet

Let's zoom out and put all five dimensions on one table so you can see how they fit together.

Recap - 5D parallelism on one slide

Notice how orthogonal they are:

When you combine them on a large cluster, the total number of GPUs in your job is simply the product of your parallel degrees: Total GPUs=DP×TP×CP×PP (With Expert Parallelism typically folding into or replacing a subset of the DP/TP axes specifically for the MoE MLP layers: DPexpert×EP=DPattention).

Pick Your Recipe: What Should You Actually Run?

In practice, you never turn on all five dimensions just to look clever. Every additional parallelism dimension introduces communication overhead, extra process groups, checkpointing headaches, and potential bugs. Your goal isn't just "make the OOM error go away"—it's maximizing MFU (Model FLOPs Utilization), the percentage of theoretical peak GPU compute you're actually achieving.

Putting it together - Pick your recipe

On the slide above, I split the world into GPU-Rich and GPU-Poor. In the real world, I find it helps to break that down into three concrete hardware tiers:

Tier 1: The "GPU-Poor" Single Node (1 to 8 GPUs, e.g., Fine-Tuning 7B–70B)

If you are working on a single node of 2, 4, or 8 GPUs (A100s, H100s, or L40Ss) doing SFT, DPO, or continued pre-training:

Tier 2: The "Awkward Middle" (2 to 8 Nodes / 16 to 64 GPUs)

This is where most startup and enterprise ML teams live, and it's where the most performance gets left on the table. You have a handful of nodes, maybe connected by standard cloud networking (RoCE / TCP / EFA) rather than a dedicated non-blocking InfiniBand fabric:

Tier 3: The "GPU-Rich" Pre-Training Cluster (16 to 1,000+ Nodes, Fast InfiniBand)

When you are pre-training a 70B+ dense model or a several-hundred-billion-parameter MoE across hundreds or thousands of GPUs:

Thank you - Questions? Let's talk parallelism.

When you strip away the acronyms, every single distributed training system out there—whether it's Megatron-LM, DeepSpeed, PyTorch FSDP2, or TorchTitan—is just choosing which axis of [batch_size, seq_len, hidden_dim, layers, experts] to slice, and scheduling the resulting all-gather, reduce-scatter, all-reduce, or all-to-all collectives so the network card is working while the Tensor Cores are busy.

If you're currently wrestling with a multi-node training setup, hitting weird OOM spikes on long sequences, or trying to squeeze higher MFU out of your GPUs, feel free to reach out on LinkedIn or drop a comment below. Always happy to talk shop!

#distributed-training #gpus #llm #machine-learning #parallelism