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.

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:
- The Memory Wall: The model weights, gradients, optimizer states, and intermediate activations simply do not fit in a single GPU’s VRAM.
- 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.

Here is the whole map:
- 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.
- 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.
- 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.
- 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.
- 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.

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 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:
- Slice
batch_sizeData Parallelism - Slice
seq_lenSequence / Context Parallelism - Slice
hidden_dimTensor Parallelism - Slice
N layersPipeline Parallelism - Slice parallel MLP branches inside a layer Expert Parallelism
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.

If you picture two GPUs sharing the work:
- Pipeline Parallelism cuts horizontally between layers. GPU 0 runs the early layers and hands the resulting activations to GPU 1, which runs the remaining layers.
- Tensor Parallelism cuts vertically through every layer. GPU 0 and GPU 1 work on Layer 1 at the exact same time—each doing half the matrix math—then sync up and move to Layer 2 together.
- Data Parallelism doesn't cut the model architecture at all. Both GPUs run the full model, but each looks at different training examples.
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.

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:
- Parameters (BF16): 2 bytes. These are the weights used in the fast forward and backward matrix multiplications.
- Master weights (FP32): 4 bytes. In BF16, you only have 7 bits of mantissa. If your weight is and your learning rate times gradient update is , adding them in BF16 rounds right back to —the model literally stops learning. So we keep a master copy of the weights in FP32 where small updates can accumulate accurately, and cast down to BF16 before each forward pass.
- Gradients (BF16): 2 bytes, produced during backprop.
- Master gradients (FP32): 4 bytes. Many distributed frameworks accumulate or reduce gradients in FP32 to avoid underflow/overflow across large reduction trees. (Even if your framework skips persistent FP32 master gradients, temporary buffers during reduction and optimizer steps often eat a similar chunk of peak memory, bringing the floor to 16–20 bytes).
- Optimizer momentum (FP32): 4 bytes for Adam's first moment ().
- Optimizer variance (FP32): 4 bytes for Adam's second moment ().
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 , computing the weight gradient requires the original input activation ). 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.

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 , the GPU takes that saved input boundary tensor and re-runs Layer ’s forward pass on the fly to regenerate the internal activations it needs.
Doing full recomputation across every layer slashes activation memory from 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:
- Attention produces massive intermediate activations (especially pre-softmax scores or multi-head projections across long sequences), but thanks to modern fused kernels, recomputing it is relatively fast. So we discard its internals and recompute them in backward.
- Feed-Forward (MLP) blocks do heavy matrix multiplications (
up,gate,down) that dominate the FLOPs of the model. Recomputing them is expensive, so we keep their activations in memory.
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.

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:
- Run the forward and backward pass on Micro-batch 1. This populates your gradient buffers with . As soon as backward finishes, PyTorch frees the forward activations for Micro-batch 1. You do not run the optimizer yet.
- Run forward and backward on Micro-batch 2. PyTorch adds the new gradients directly into the existing buffer (), and frees Micro-batch 2’s activations.
- Do the same for Micro-batches 3 and 4.
- Average the accumulated gradients 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)

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.

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 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.

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 data-parallel GPUs in three stages:
- ZeRO-1 (Optimizer State Sharding): We keep full copies of the BF16 parameters and gradients on every GPU, but shard the bulky FP32 optimizer states (, and FP32 master weights) so each GPU only holds of them. At the end of the backward pass, each GPU updates only its slice of parameters, followed by an
all-gatherso everyone gets the updated BF16 weights. Your forward and backward passes have zero extra overhead compared to DDP, while your memory per parameter drops significantly. - ZeRO-2 (Gradient + Optimizer Sharding): Since GPU only updates shard of the parameters during the optimizer step, it never actually needed the averaged gradients for the other shards. Instead of running an
all-reduceon gradients (which equals areduce-scatter+all-gather), ZeRO-2 stops halfway and just runs areduce-scatter. Each GPU receives only the averaged gradient slice corresponding to the optimizer shard it owns. This saves even more memory and actually reduces communication slightly compared to naive all-reduce + scatter. - ZeRO-3 / FSDP (Parameter + Gradient + Optimizer Sharding): Now we shard everything, including the BF16 model weights themselves. At rest, each GPU holds just of the parameters, of the gradients, and of the optimizer state. For an 8B model across 8 GPUs, that 160 GB training state shrinks to just 20 GB per GPU.
Of course, a GPU can't compute the forward pass of Layer 1 on its local data batch if it only has th of Layer 1’s weight matrix.

So ZeRO-3 / FSDP materializes each layer on demand:
- Right before Layer 1 runs in the forward pass, all GPUs execute an
all-gatherto assemble the full BF16 weights of Layer 1. - Every GPU runs the forward pass of Layer 1 on its own local data batch.
- Immediately after Layer 1 finishes, every GPU frees the weight shards it doesn't own, dropping memory right back down.
- During the backward pass, it does the same thing in reverse:
all-gatherthe layer weights to compute gradients, free the unowned weights, andreduce-scatterthe 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:
- 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 theall-gatherlatency 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. - 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-gatherhas to traverse the slower inter-node network across all 4 servers. Instead, use Hybrid Sharding (ShardingStrategy.HYBRID_SHARDin PyTorch FSDP): it runs ZeRO-3 inside each 8-GPU node (where the frequent weightall-gathersfly over fast NVLink) and plain DDP / gradientall-reduceacross 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 fromFULL_SHARDtoHYBRID_SHARDregularly doubles training throughput. - 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
TransformerDecoderLayerlevel, FSDP willall-gatherthe entire model at once and immediately OOM.
Dimension 2: Tensor Parallelism (Sharding the Math)

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 across two GPUs using concrete numbers.

Take an input activation matrix of shape (4, 2) and a weight matrix of shape (2, 2):
On a single GPU, produces a (4, 2) matrix:
Now let's split across GPU 0 and GPU 1. Because a 2D matrix has two axes, there are two ways to slice : along its columns or along its rows.
1. Column Linear: Split by Columns, Then All-Gather

In a Column Linear split, both GPUs receive the full input , and each GPU holds half the columns of :
- GPU 0 holds the left column and computes .
- GPU 1 holds the right column and computes .
Because matrix multiplication operates column-by-column on the right-hand matrix, is already the exact first column of , and is the exact second column of . If we need the full output right now, we just run an all-gather to concatenate and side-by-side: [Y_0, Y_1] = Y.
2. Row Linear: Split by Rows, Then All-Reduce

Now suppose we slice horizontally by rows instead. For the inner dimensions of the matrix multiplication to match, slicing by rows forces us to slice the input by columns:
- GPU 0 gets the first column of () and the first row of (). Multiplying
(4, 1) @ (1, 2)produces a(4, 2)matrix . - GPU 1 gets the second column of () and the second row of (), producing another
(4, 2)matrix .
Notice the difference: neither nor contains final numbers yet. By the block matrix multiplication rule:
Each GPU has computed a partial sum of the full (4, 2) output. To get the final , we run an all-reduce (sum) to add and element-wise:
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: .

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:
- The first layer is Column Linear. Without doing an
all-gather, GPU 0 holds the left columns and GPU 1 holds the right columns . - Next comes
GeLU. BecauseGeLUacts element-by-element on each scalar independently, applyingGeLUto two half-matrices and concatenating them gives the exact same result as concatenating them first and applyingGeLU: That means each GPU can runGeLUlocally on its own column slice without any network communication at all. - 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 ofGeLUdrops 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, . You would be forced to run an all-reduce before you could apply GeLU).

This "Column in, Row out" pattern is the heartbeat of Tensor Parallelism across the entire Transformer decoder layer:
- In Multi-Head / Grouped-Query Attention: The , , and projections are Column Linear layers. Slicing by columns cleanly partitions the attention heads across GPUs (for example, with 32 heads across 8 GPUs, each GPU owns 4 heads). Because attention heads don't talk to each other during scaled dot-product attention, each GPU computes FlashAttention on its local heads with zero communication, and then feeds the result into
out_proj—which is a Row Linear layer ending in a singleall-reduce. (One practical detail for GQA models: if your model has 8 KV heads, you can't easily useTP > 8without duplicating KV heads across ranks, which is another reasonTP = 8is the natural ceiling). - In the SwiGLU MLP: Both
up_projandgate_projare Column Linear. The SiLU activation and element-wise multiplication (silu(gate) * up) happen completely locally on each GPU's column shard, anddown_projis a Row Linear layer ending in anall-reduce.
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.

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]:
- Shard the vocab: GPU 0 holds the first half of the vocabulary, IDs
0–2([10, 20, 30]). GPU 1 holds the second half, IDs3–5([40, 50, 60]). - 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]. - Shift to local indices: Each GPU subtracts its starting vocab offset (
0for GPU 0,3for GPU 1) and clamps out-of-range indices to0soF.embeddingwon't throw an out-of-bounds error. GPU 0 gets local IDs[1, 0, 2, 0]; GPU 1 gets[0, 1, 0, 2]. - Local lookup: Each GPU runs
F.embeddingon its local shard. GPU 0 gets[20, 10, 30, 10]; GPU 1 gets[40, 50, 40, 60]. - 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]. - 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)

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 (SP) fixes that duplication by exploiting two facts:
- LayerNorm, RMSNorm, Dropout, and residual adds are completely independent across the token dimension (
seq_len). Computing RMSNorm for token 5 only requires thehidden_dimvector 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. - An
all-reduceis mathematically identical to areduce-scatterfollowed by anall-gather(and uses the exact same total network bandwidth).
Look at the diagram above to see how SP rewires the layer:
- In standard TP, Step 3 (the MLP or Attention block) ended with an
all-reducethat left all 8,000 tokens replicated on every GPU. - With SP enabled, we replace that
all-reducewith areduce-scatteralong the sequence dimension (Step 4). This sums the partial results from the Row Linear layer and splits the 8,000-token sequence into eight 1,000-token chunks in one collective. - Now we enter the SP region (Step 5 and Step 1 of the next block): each GPU runs Dropout, the residual connection, and the next LayerNorm/RMSNorm on just 1,000 tokens. Activation memory for all these ops drops by .
- Right before the next TP Column Linear layer needs the full sequence, we run the second half of our old all-reduce—an
all-gather(Step 2)—to reassemble the 8,000 tokens.
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 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 instead of quadratic , linear in 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 projections, MLP intermediate activations (which expand hidden_dim by to 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 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.

Suppose we split a long sequence into 4 chunks across 4 GPUs placed in a logical ring:
- Queries stay home: GPU 1 permanently holds , GPU 2 holds , GPU 3 holds , and GPU 4 holds .
- Keys and Values travel the ring: In Round 0, each GPU computes FlashAttention between its local and its local . Simultaneously, in the background over P2P network sends/recvs, each GPU passes its tensor to the next GPU in the ring.
- In Round 1, GPU 2 now has . It computes attention between and , 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 along to GPU 3.
- After 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 and tensors have to fewer heads than . Because Ring Attention only sends and over the network while keeping the bulky 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:
- If GPU 1 holds tokens
0–32K(the start of the document), its queries only attend to . When , and (tokens32K–128K) arrive around the ring, they are completely masked out by the causal mask! So GPU 1 finishes its work in Round 0 and sits idle for Rounds 1, 2, and 3. - Meanwhile, GPU 4 holds tokens
96K–128K(the end of the document). Its queries have to attend to , and . GPU 4 does more compute than GPU 1, bottlenecking the entire ring.
The fix is Zig-Zag Ring Attention (striped or symmetric chunking). Instead of cutting the sequence into 4 contiguous blocks, we cut it into sub-chunks and pair one chunk from the very front with one chunk from the very back on each GPU:
- GPU 1 gets chunks
(1, 8) - GPU 2 gets chunks
(2, 7) - GPU 3 gets chunks
(3, 6) - GPU 4 gets chunks
(4, 5)
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)

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.

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 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:
- 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 , where is the number of pipeline stages and is the number of micro-batches.
- The Activation Memory Spike: Look at
GPU 1(Stage 1). It computes forward micro-batchesF1, F2, F3, F4right at the start of the step, and then has to wait for the entire pipeline to finish before it can run its first backward passB1. That means Stage 1 must hold the forward activations for all micro-batches in VRAM simultaneously! If you increase 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:

- 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 micro-batches of activations in memory doesn't scale.
- 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 startsB1and 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 eachBimmediately frees the saved activations of a priorF, peak in-flight activation memory on Stage 1 is capped at micro-batches instead of —letting you crank up to shrink the bubble without running out of memory. (Meta used 1F1B to train Llama 3 405B). - 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 (the number of virtual stages per GPU), at the cost of more point-to-point network hops. - 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-allcommunication).
How do you get a pipeline bubble down to zero without changing the math of backprop?

When we draw B1 on a pipeline diagram, we treat the backward pass of a layer as a single monolithic block. Under the hood, though, the backward pass of every linear layer is actually two completely separate matrix multiplications:
- Gradient with respect to input activations (, often called ): Stage upstream cannot start its own backward pass until Stage computes and sends it back over the network. So is on the critical path and must be computed immediately.
- Gradient with respect to weights (, often called ): Who is waiting on ? Nobody. No upstream stage needs . The only thing that ever touches 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: (activation grad) and (weight grad). During the main pipeline flow, each stage only computes and immediately fires upstream so the previous stage never has to wait. Meanwhile, all those 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 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)

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 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- 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 ( out of 256 routed experts).
Expert Parallelism (EP) distributes those experts across GPUs:
- If you have 64 experts and 8 GPUs (
EP = 8), GPU 0 holds Experts 0–7, GPU 1 holds Experts 8–15, and so on. - Attention layers stay replicated (Data Parallel) or Tensor Parallel as usual.
- When tokens reach an MoE layer, each GPU runs the router locally on its tokens. Suppose GPU 0 has a token
"apple"that the router assigns to Expert 12 (which lives on GPU 1), and a token"code"assigned to Expert 60 (which lives on GPU 7). - All GPUs in the EP group execute an
all-to-allcollective (token dispatch) to physically ship each token's hidden state over the network to whichever GPU hosts its chosen expert. - Each GPU runs its local experts on the batch of tokens it just received from across the cluster.
- Finally, the GPUs execute a second
all-to-allcollective (token combine) to send the processed token vectors back to their original home GPUs, where they are weighted by the router probabilities and added back to the residual stream.
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:
- 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-allbarrier.- 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.
All-to-AllIs Brutal on Network Topology: In anall-reduceorreduce-scatter, communication can be neatly pipelined around a ring. In anall-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 yourEPdegree spans across multiple nodes with oversubscribed network switches,all-to-allcongestion will tank your throughput unless you use hierarchical routing (restricting a token to pick experts on at most nodes) or overlap theall-to-allwith compute (like DualPipe does).- 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-allrouting—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 theall-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.

Notice how orthogonal they are:
- Data Parallelism (DP / ZeRO / FSDP) slices the
batch(and shards model state), communicates viaall-reduceorreduce-scatter+all-gather, and scales out throughput to any number of GPUs. - Tensor Parallelism (TP) slices
hidden_dim, communicates viaall-reduceinside every layer, and stays strictly inside a single 8-GPU NVLink node. - Sequence & Context Parallelism (SP / CP) slices
seq_len, communicates viareduce-scatter/all-gather(SP) or ring P2P transfers of (CP), and keeps long-context activations from exploding. - Pipeline Parallelism (PP) slices
layers, communicates small boundary activations point-to-point between stages, and spans across multiple nodes when a model is too big for one server. - Expert Parallelism (EP) slices
experts, communicates viaall-to-alltoken dispatch/combine, and scales parameter counts in MoE models.
When you combine them on a large cluster, the total number of GPUs in your job is simply the product of your parallel degrees: (With Expert Parallelism typically folding into or replacing a subset of the DP/TP axes specifically for the MoE MLP layers: ).
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.

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:
- Start with ZeRO-2 or ZeRO-3 (PyTorch FSDP / DeepSpeed) across your GPUs. Because all your GPUs are on the same machine, even ZeRO-3’s extra
all-gathertraffic stays local and fast. - Turn on Activation Recomputation (gradient checkpointing) and FlashAttention-2/3. This is non-negotiable for keeping activation memory sane.
- Use Gradient Accumulation (wrapped in
no_sync()) to hit your target effective batch size while keeping micro-batch size small (1–4 sequences per GPU). - Pack your sequences (concatenating short examples together with a block-diagonal
cu_seqlensmask instead of right-padding every short sequence with<pad>tokens out tomax_seq_len). In fine-tuning, sequence packing often gives a bigger speedup than any parallelism tweak because you stop doing matmuls on pad tokens! - Do you need TP or PP here? Almost never for training 7B–32B models on a single node—FSDP + activation checkpointing is simpler and usually faster.
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:
- First choice: Hybrid Sharding (HSDP / ZeRO++). Shard weights with ZeRO-3 inside each 8-GPU node (
shard_group = 8), and replicate /all-reducegradients across the nodes (replicate_group = num_nodes) at the end of several gradient accumulation steps. This keeps all the chatty weightall-gathersinside the fast NVLink domain and barely touches the inter-node network. - If the model is 70B+ or sequence length is 32K+: Add TP = 4 or 8 (with SP) inside each node so a single node doesn't choke on activation memory, and wrap ZeRO-1 or ZeRO-2 across the nodes.
- If your context length hits 64K–128K+: Turn on Context Parallelism (CP = 2 or 4) using Zig-Zag Ring Attention so no single GPU has to hold the full sequence activations.
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:
- Inside each node: Lock in TP = 8 + SP over NVLink.
- Across nodes (Model Depth): Use Pipeline Parallelism (PP) with a 1F1B, Interleaved 1F1B, or Zero-Bubble/DualPipe schedule so the model spans across nodes with minimal point-to-point activation traffic.
- Long-context stage: Add CP across fast links when extending context windows to 128K+.
- For MoE models: Carve out Expert Parallelism (EP) across high-bandwidth groups for the MoE layers, overlapping the
all-to-alldispatch/combine with shared-expert or backward-pass compute. - Outer loop (Batch Scaling): Wrap the entire multi-node pod in Data Parallelism with ZeRO-1 (or ZeRO-2) to scale out the global batch size to millions of tokens per step without paying the 1.5× inter-node communication tax of ZeRO-3.

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!