This note is based off the wonderful tutorial at https://huggingface.co/spaces/nanotron/ultrascale-playbook.
Problem
To scale transformers with hundreds of GPUs, there are 3 key issues:
- Memory usage: fitting the model and batch into GPU memory.
- Compute efficiency: spending as much time as possible on computations (instead of data transfer or idling).
- Communication overhead: minimizing slow communication, parallelizing with compute.
Memory Usage
Training can be broken down into (1) forward pass to compute prediction, (2) backward pass to compute gradients, and (3) optimization step to update parameters. The information stored in memory include model weights, activations, gradients, and optimizer states.

The memory for weights, gradients, and optimizer states depend on the size of the model. For a simple transformer, the number of parameters is
where
Memory for activations is a bit different since it depends on the data batch size. Importantly, it scales linearly with batch size and quadratically with sequence length, so when sequences get longer, it takes up the majority of memory.
Activation Recomputation
To avoid activation memory from getting too large, activation recomputation will discard some activations during the forward pass and recompute them during the backward pass. Generally, selective recomputation elects to save just the expensive feedforward results and recompute attention on the fly.

This can often even be faster since accessing memory could be slower than performing computations.
Gradient Accumulation
Additionally, we can avoid the linear memory scaling on batch size. Gradient accumulation splits the batch into micro-batches, compute the forward and backward passes independently per micro-batch, then averages the gradients before optimization. However, this naturally increases compute overhead due to multiple forward-backward passesโbut this can be parallelized (see below).

Data Parallelism
With multiple GPUs, the first scaling technique we can use is data parallelism (DP): replicating the model on each GPU and running forward-backward passes on different micro-batches in parallel. Each GPU will then have different gradients, which we average using the all-reduce distributed communication primitive.
Rather than simply waiting for backward passes to finish before all-reduce, there are 3 key optimizations we can make:
- Overlap gradient synchronization with computation by gathering gradients that have already been computed before we even get to earlier layers.
- Group gradients into "buckets" and run all-reduce on buckets instead of individual gradients.
- When coupled with gradient accumulation, run all-reduce only at the final backward pass.
Zero Redundancy Optimizer (ZeRO)
Data parallelism as described above has a lot of memory redundancy: optimizer states, gradients, and parameters are replicated in each rank. ZeRO instead partitions this information across the data parallel dimension. Note that activations aren't part of this because ranks get different micro-batches and thus different activations.
ZeRO shards the optimizer states, gradients, and parameters across
ZeRO-1 (Optimizer States)
In vanilla DP, all ranks gather the gradients and perform the same optimizer steps. We can avoid this duplicate work by partitioning optimizer steps across
Before this operation, we collect gradients with a reduce-scatter operation that splits the gradients to their respective ranks.

ZeRO-2 (Optimizer States and Gradients)
In ZeRO-1, we use the optimizer for only

ZeRO-3 (Optimizer States, Gradients, and Parameters)
Now, we can also partition the model parameters. To do a forward or backward pass, we'll run all-gather on the fly whenever we need a set of parameters; the necessary parameters are used and then removed from memory.

This is a lot of all-gather operations. For optimal efficiency, we can get the weights for layer
With
where
Tensor Parallelism
In data parallelism, activation memory is not partitioned. However, this is a bottleneck that scales with sequence length and batch size. Tensor parallelism (TP) proposes to shard activations, along with parameters, gradients, and optimizer states, by exploiting the independence of matrix multiplications:
These equations allow us to compute the product by splitting up
- In the feedforward MLP, a column-linear followed by a row-linear split on the two weights.
- In the multi-head attention block, column-linear split on the query/key/value and row-linear split on the output projection.
Naturally, each split goes to a different GPU, reducing the memory needed for parameters, gradients, and optimizer states.
Sequence Parallelism
A key limitation in the TP formulation above is that some operations like layer normalization and dropout still require the full activations to be present on each GPU. Sequence parallelism (SP) solves this by partitioning along the sequence dimension rather than the hidden (weights) dimension. Combining TP and SP, we alternate between parallelizing across sequence and hidden dimensions.

Context Parallelism
Although tensor parallelism significantly reduces memory usage, it still operates on the entire sequence and remains limited by sequence length. Context parallelism (CP), similar to sequence parallelism, proposes to split the sequenceโthis time, in modules where we already apply TP.
Most modules process tokens independently, so this isn't a problem. The only key exception is attention, since each token attends to all other tokens (or the ones before it, for causal attention).
Ring Attention
To compute attention within CP, we need to communicate all tokens to all GPUs. Ring Attention is a fast way to do so: each GPU alternates between computing attention scores for a shard and sending/receiving another shard. GPUs send these shards in a circular orderโhence, "ring" attention.
There a small issue with this setup for causal attention: GPUs responsible for earlier tokens will have less to compute, and thus the compute time is unbalanced. Zig-Zag Ring Attention proposes to mix the order of tokens assigned to GPUs, thus evening out the distribution.
Pipeline Parallelism
Another potential problem is that if the model itself is too large, exceeding the number of GPUs on a single node (typically 4 or 8), then inter-node communication will slow down the whole pipeline. One more way to reduce the model's memory usage is pipeline parallelism (PP), which splits the layers of a model across GPUs.
First let's consider splitting the layers in order (so first GPU gets layers

There are a few ways to minimize idle time:
- All forward, all backward (AFAB): Split the batch into smaller portions, then process them in parallel.

- One forward, one backward (1F1B): Starting like AFAB, interleave backward passes with forward as soon as possible. This doesn't directly reduce the idle size, but now we only need to store activations for
micro-batches ( is degree of parallelism) instead of ( is number of micro-batches), allowing us to have more micro-batches and thus reduce the idle time.
- Interleaving stages: Instead of splitting layers across GPUs in order, we can interleave layers. Thus allows for even more parallelism.

Expert Parallelism
Finally, in Mixture of Experts (MoE) models, there are multiple feedforward MLP "experts," and tokens are routed through different experts during inference. Naturally, expert parallelism (EP) puts each expert's feedforward layer on a different worker. This approach is specific just for MoE and thus is often used with other parallelism methods, like DP.