---
title: "Module 23: Distributed Training"
format:
html:
code-fold: false
toc: true
ipynb: default
jupyter: python3
---
{{< include ../_diagram-lib.qmd >}}
{{< include ../_components/step-control.qmd >}}
## Introduction
Every training module so far ([m02](../m02_autograd/lesson.qmd),
[m07](../m07_training/lesson.qmd)) quietly assumed **one device holds the whole
model**. Frontier LLMs do not fit. A 7-billion-parameter model needs *120 GB*
just to hold its training state — more than any single GPU — and that is before a
single activation. **Distributed training** is how the same math from m07 runs
across a cluster.
There are two orthogonal problems, and this module builds a from-scratch answer
to each:
- **Speed** → **data parallelism.** Replicate the model on $N$ devices, feed each
a different slice of the batch, then **average the gradients** so every replica
takes the exact step one big-batch device would. The averaging is a **ring
all-reduce**, which we build as a real, bandwidth-optimal algorithm.
- **Memory** → **ZeRO.** Plain data parallelism keeps a *full* copy of the
parameters, gradients, and optimizer states on **every** device — $N$-way
redundant. **ZeRO** (Zero Redundancy Optimizer) partitions them across devices,
cutting per-device memory from $16\Psi$ bytes toward $16\Psi/N$.
Why it matters for LLMs:
- **Nothing else fits.** Every model past a few billion parameters is trained
distributed; the memory math below is the reason the field talks in "GPU-hours".
- **The step is unchanged.** Done right, $N$ devices produce *bit-for-bit* the
same gradient as one — distribution is a systems trick, not a new algorithm.
- **Memory is a budget you can rebalance.** ZeRO turns "the model doesn't fit"
into "add devices," on a precise, linear curve you will drive yourself.
### What You'll Learn
After this module, you can:
- Compute the **training memory budget** of any model: the $2+2+12 = 16$
bytes-per-parameter model of mixed-precision Adam.
- Explain **data parallelism** and why averaging gradients equals one big batch.
- Build a **ring all-reduce** from scratch (reduce-scatter → all-gather) and prove
it equals the naive sum, at $2(N{-}1)/N \cdot M$ bytes per device.
- Derive the three **ZeRO** stages ($P_{os}$, $P_{os+g}$, $P_{os+g+p}$) and their
per-device memory, and see the $4\times$/$8\times$/$N\times$ savings.
- Build **tensor parallelism** (Megatron-LM) from scratch — column-parallel and
row-parallel GEMMs, the MLP and attention split, and the $f$/$g$ conjugate
operators — and prove the split layer is bit-exact to one device.
- Build **pipeline parallelism** (GPipe / 1F1B) from scratch — the device × time
schedule, the $(P{-}1)/(M{+}P{-}1)$ **bubble** and why micro-batching shrinks it,
and why 1F1B keeps the same bubble at $O(P)$ instead of $O(M)$ activation memory.
- Build **ring attention** (sequence / context parallelism) from scratch — shard
the sequence across devices, rotate K/V blocks around a ring, accumulate with an
online softmax, and prove it equals full attention while context scales linearly
with devices; plus the causal load-imbalance and the striped fix.
- Place **FSDP** and **gradient checkpointing** in the same map, and reason about
tensor parallelism's memory-for-communication trade.
### Prerequisites
This module requires familiarity with:
- [Module 02: Autograd](../m02_autograd/lesson.qmd) — gradients are the thing we
average across devices.
- [Module 07: Training](../m07_training/lesson.qmd) — the optimizer states (Adam's
momentum and variance) that dominate the memory budget.
- [Module 09: Efficient Attention](../m09_efficient_attention/lesson.qmd) — the
*inference* memory wall (the KV cache); this module is its *training* twin.
## Intuition: One GPU Is Not Enough
m09 hit a memory wall at *inference* — the KV cache grows with the context.
Training hits a wall long before that, and for a different reason: to take **one
Adam step** you must keep several full-model-sized tensors resident at once.
Think of the model's parameters $\Psi$ as the unit. Modern training runs in
**mixed precision**: the forward and backward passes use fast 16-bit floats, but
the optimizer keeps a high-precision 32-bit copy so tiny updates don't vanish.
Counting bytes per parameter:
- an **fp16 copy of the parameters** — used in the forward/backward pass → 2 bytes
- an **fp16 copy of the gradients** → 2 bytes
- the **fp32 optimizer states**: a master copy of the parameters (4), Adam's
momentum (4), and Adam's variance (4) → 12 bytes
That is $2 + 2 + 12 = \mathbf{16}$ bytes for *every* parameter, every step. A 7.5B
model therefore needs $7.5 \times 10^9 \times 16 = 120$ GB — and an 80 GB A100
cannot hold it. The parameters you actually compute with are only $\tfrac{1}{8}$
of the bill; the optimizer states are $\tfrac{3}{4}$ of it. **That imbalance is
what ZeRO exploits.**
::: {.callout-note}
## Key Insight
The thing that doesn't fit is rarely the parameters — it's the *optimizer state*.
Adam's momentum and variance, plus the fp32 master copy, are $12$ of the $16$
bytes. Halving parameter precision barely helps; partitioning the optimizer state
is where the memory is.
:::
## The Math: The Training Memory Budget
The byte-count above is exactly the memory model from the **ZeRO** paper. In code
it is `training_memory_bytes` in `distributed.py`:
```{python}
import sys
sys.path.insert(0, ".")
from distributed import training_memory_bytes, zero_memory_per_device
mem = training_memory_bytes(7_500_000_000) # 7.5B parameters
for k, v in mem.items():
print(f"{k:>10}: {v/1e9:6.1f} GB")
```
Params and grads are 2 bytes each; the optimizer states are 12 (Adam's $K=12$).
Now the key move — **ZeRO data parallelism partitions that state across $N$
devices** in three stages, each partitioning one more kind of state:
| Stage | Name | What is partitioned | Per-device bytes |
|-------|------|---------------------|------------------|
| 0 | baseline | nothing (full replica) | $16\Psi$ |
| 1 | $P_{os}$ | optimizer states | $4\Psi + 12\Psi/N$ |
| 2 | $P_{os+g}$ | + gradients | $2\Psi + 14\Psi/N$ |
| 3 | $P_{os+g+p}$ | + parameters | $16\Psi/N$ |
Watch a 7.5B model land on 64 GPUs:
```{python}
psi, n = 7_500_000_000, 64
for stage in range(4):
gb = zero_memory_per_device(psi, n, stage) / 1e9
print(f"stage {stage}: {gb:7.2f} GB/device")
```
120 GB collapses to under 2 — the same numbers the ZeRO paper reports (a $4\times$,
$8\times$, and $N\times$ reduction). **The model didn't shrink; the redundancy
did.**
### Drive the memory budget
Slide the model size, the device count, and the ZeRO stage. The bar is per-device
memory; the ghost behind it is the $16\Psi$ baseline you started from.
```{python}
#| echo: false
#| output: false
from distributed import demonstrate_memory
_ref = demonstrate_memory(7.5, 64)
ojs_define(memRef = _ref)
```
```{ojs}
//| echo: false
viewof dtParams = Inputs.range([1, 200], {value: 7.5, step: 0.5, label: "params (billions)"})
```
```{ojs}
//| echo: false
viewof dtDevices = Inputs.range([1, 512], {value: 64, step: 1, label: "devices N"})
```
```{ojs}
//| echo: false
viewof dtStage = Inputs.radio(
new Map([
["0 · baseline (16Ψ)", 0],
["1 · Pos — partition optimizer", 1],
["2 · Pos+g — + gradients", 2],
["3 · Pos+g+p — + params", 3]
]),
{value: 3, label: "ZeRO stage"}
)
```
```{ojs}
//| echo: false
dtZeroMem = (psi, n, stage) => {
if (stage === 0) return 16 * psi;
if (stage === 1) return 4 * psi + 12 * psi / n;
if (stage === 2) return 2 * psi + 14 * psi / n;
return 16 * psi / n;
}
```
```{ojs}
//| echo: false
dtMemoryBar = {
const theme = diagramTheme;
const width = 720, height = 300;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const psi = dtParams * 1e9;
const baseline = 16 * psi / 1e9; // GB
const current = dtZeroMem(psi, dtDevices, dtStage) / 1e9;
const maxGB = baseline;
const x0 = 90, barW = 480, y0 = 70, rowH = 70;
const scale = d3.scaleLinear().domain([0, maxGB]).range([0, barW]);
// baseline ghost bar
svg.append("rect").attr("x", x0).attr("y", y0).attr("width", barW).attr("height", 44)
.attr("rx", 6).attr("fill", theme.nodeFill).attr("opacity", 0.25)
.attr("stroke", theme.nodeStroke).attr("stroke-dasharray", "4 3");
svg.append("text").attr("x", x0 - 12).attr("y", y0 + 27).attr("text-anchor", "end")
.attr("font-size", 12).attr("fill", theme.edgeStroke).text("baseline");
svg.append("text").attr("x", x0 + barW + 10).attr("y", y0 + 27)
.attr("font-size", 12).attr("fill", theme.edgeStroke).text(`${baseline.toFixed(1)} GB`);
// current per-device bar
const cy = y0 + rowH;
const w = Math.max(2, scale(current));
const fits80 = current <= 80;
svg.append("rect").attr("x", x0).attr("y", cy).attr("width", w).attr("height", 44)
.attr("rx", 6).attr("fill", fits80 ? theme.success : theme.highlight)
.attr("stroke", theme.highlight).attr("stroke-width", 2);
svg.append("text").attr("x", x0 - 12).attr("y", cy + 27).attr("text-anchor", "end")
.attr("font-size", 12).attr("fill", theme.nodeText).text(`stage ${dtStage}`);
svg.append("text").attr("x", x0 + w + 10).attr("y", cy + 27)
.attr("font-size", 13).attr("font-weight", 700)
.attr("fill", fits80 ? theme.success : theme.highlight)
.text(`${current.toFixed(2)} GB/device`);
// 80 GB (A100) reference line
const ax = x0 + scale(80);
if (80 <= maxGB) {
svg.append("line").attr("x1", ax).attr("y1", y0 - 8).attr("x2", ax).attr("y2", cy + 56)
.attr("stroke", theme.error).attr("stroke-width", 1.5).attr("stroke-dasharray", "5 4");
svg.append("text").attr("x", ax).attr("y", y0 - 14).attr("text-anchor", "middle")
.attr("font-size", 10).attr("fill", theme.error).text("80 GB (A100)");
}
svg.append("text").attr("x", x0).attr("y", 34)
.attr("font-size", 13).attr("font-weight", 700).attr("fill", theme.highlight)
.text(`${dtParams}B params · N=${dtDevices} · stage ${dtStage}`);
const reduction = baseline / current;
svg.append("text").attr("x", x0).attr("y", height - 34)
.attr("font-size", 12).attr("fill", theme.nodeText)
.text(`${reduction.toFixed(1)}× smaller than baseline · ${fits80 ? "fits an 80 GB GPU ✓" : "still too big for one 80 GB GPU"}`);
// validated reference: the ZeRO paper's 7.5B / 64-GPU example
svg.append("text").attr("x", x0).attr("y", height - 16)
.attr("font-size", 10).attr("fill", theme.edgeStroke)
.text(`formula validated vs ZeRO paper: ${memRef.param_billions}B on ${memRef.num_devices} GPUs → stage 3 = ${memRef.stages_gb.stage3.toFixed(2)} GB`);
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Set params to 70B.** Even stage 3 needs many devices to fit an 80 GB GPU —
this is why 70B models train on hundreds of GPUs.
2. **Hold the model fixed and slide $N$.** Stage 1 flattens out (it can never beat
$4\Psi$); stage 3 keeps falling as $16\Psi/N$. That gap *is* the difference
between partitioning some state and partitioning all of it.
3. **Compare stage 1 vs stage 3 at $N=8$.** Small clusters still get most of the
win from partitioning just the optimizer state — the cheapest stage to run.
:::
## Code: Data Parallelism & Ring All-Reduce
Memory is one half; **speed** is the other. Data parallelism runs $N$ replicas of
the model, each on a different **shard** of the batch. Replica $i$ computes the
gradient of its own mini-batch; then all replicas **average** their gradients so
every one applies the *same* update. Because each shard's loss is a mean over an
equal slice, that average is *exactly* the gradient of the loss over the whole
batch:
$$
\frac{1}{N}\sum_{i=1}^{N} \nabla_i \;=\; \nabla \Big(\tfrac{1}{B}\textstyle\sum_{b} \ell_b\Big)
$$
Let's prove it on a linear model. `data_parallel_gradients` shards the batch,
computes each shard's gradient with autograd, sums them with a ring all-reduce,
and divides by $N$:
```{python}
import torch
from distributed import data_parallel_gradients, single_device_gradient
torch.manual_seed(0)
W = torch.randn(3, 5) # a shared (out=3, in=5) weight
x_shards = [torch.randn(4, 5) for _ in range(4)] # 4 devices, 4 samples each
y_shards = [torch.randn(4, 3) for _ in range(4)]
g_dp = data_parallel_gradients(W, x_shards, y_shards)
g_sd = single_device_gradient(W, torch.cat(x_shards), torch.cat(y_shards))
print("max |g_dp - g_single|:", (g_dp - g_sd).abs().max().item())
print("identical step:", torch.allclose(g_dp, g_sd, atol=1e-5))
```
Four devices, four separate gradients, one all-reduce — and the result is the
single-device gradient over all 16 samples. **Distribution changed nothing about
the math.** The only new machinery is the collective that did the averaging.
### The all-reduce, and why it must be a ring
An **all-reduce** leaves *every* device holding the sum of all devices' tensors.
The obvious way — send everything to device 0, add, broadcast back — pushes
$O(N \cdot M)$ bytes through one poor GPU's link while the rest sit idle. The
**ring all-reduce** fixes both: it uses every link at once and moves only
$2(N{-}1)/N \cdot M$ bytes per device, *independent of $N$* as it grows.
The trick is to arrange the devices in a logical ring (each sends only to its
right neighbour) and split every tensor into $N$ chunks, then run two phases:
1. **Reduce-scatter** ($N{-}1$ steps): chunks circulate and accumulate until each
device owns the *fully summed* value of exactly one chunk.
2. **All-gather** ($N{-}1$ steps): those finished chunks circulate again until
every device has all of them.
This is `ring_all_reduce` in `distributed.py` — a real implementation, snapshotting
each step's sends before applying receives so it matches true simultaneous
communication. Its correctness anchor is exact:
```{python}
from distributed import ring_all_reduce, ring_all_reduce_bytes
tensors = [
torch.tensor([1., 2., 3., 4.]),
torch.tensor([10., 20., 30., 40.]),
torch.tensor([100., 200., 300., 400.]),
torch.tensor([1000., 2000., 3000., 4000.]),
]
out = ring_all_reduce(tensors)
print("device 0 result:", out[0].tolist())
print("== naive sum: ", torch.equal(out[0], torch.stack(tensors).sum(0)))
print("all devices agree:", all(torch.equal(o, out[0]) for o in out))
print("bytes/device (M=4KB, N=4):", ring_all_reduce_bytes(4000, 4))
```
Bit-for-bit equal to the sum, on every device. And the communication cost
$2(N{-}1)/N \cdot M$ approaches $2M$ — a fixed budget no matter how many GPUs you
add, which is exactly why the ring, not a central reducer, is what real systems
(NCCL, Horovod) run.
### Step through the ring
Four devices, each starting with one row of values; watch reduce-scatter
accumulate one finished chunk per device (the ringed cell), then all-gather spread
the finished chunks around. By the last step every cell equals the column sum.
```{python}
#| echo: false
#| output: false
from distributed import demonstrate_ring
ojs_define(ringTrace = demonstrate_ring())
```
```{ojs}
//| echo: false
viewof dtRingStep = stepControl({min: 0, max: 6, value: 0, label: "Communication step"})
```
```{ojs}
//| echo: false
dtRingDiagram = {
const theme = diagramTheme;
const width = 720, height = 420;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const steps = ringTrace.steps;
const step = Math.min(dtRingStep, steps.length - 1);
const cur = steps[step];
const N = ringTrace.num_devices;
const grid = cur.grid; // grid[device][chunk]
const finalVec = ringTrace.final;
// reduced chunk index owned by device d after reduce-scatter = (d+1) % N
const owned = d => (d + 1) % N;
const cell = 62, gap = 8, x0 = 150, y0 = 90;
// column (chunk) headers
for (let c = 0; c < N; c++) {
svg.append("text").attr("x", x0 + c * (cell + gap) + cell / 2).attr("y", y0 - 12)
.attr("text-anchor", "middle").attr("font-size", 11).attr("fill", theme.edgeStroke)
.text(`chunk ${c}`);
}
for (let d = 0; d < N; d++) {
svg.append("text").attr("x", x0 - 14).attr("y", y0 + d * (cell + gap) + cell / 2 + 4)
.attr("text-anchor", "end").attr("font-size", 11).attr("fill", theme.nodeText)
.text(`dev ${d}`);
for (let c = 0; c < N; c++) {
const x = x0 + c * (cell + gap), y = y0 + d * (cell + gap);
// is this the finished chunk for this device (during/after reduce-scatter)?
const done = cur.phase !== "start" && c === owned(d)
&& Math.abs(grid[d][c] - finalVec[c]) < 1e-6;
svg.append("rect").attr("x", x).attr("y", y).attr("width", cell).attr("height", cell)
.attr("rx", 6)
.attr("fill", done ? theme.highlight : theme.nodeFill)
.attr("stroke", done ? theme.highlight : theme.nodeStroke)
.attr("stroke-width", done ? 3 : 1.2)
.attr("opacity", done ? 1 : 0.9);
svg.append("text").attr("x", x + cell / 2).attr("y", y + cell / 2 + 4)
.attr("text-anchor", "middle").attr("font-size", 12)
.attr("fill", done ? theme.bgOpaque : theme.nodeText)
.attr("font-weight", done ? 700 : 400)
.text(grid[d][c]);
}
}
svg.append("text").attr("x", x0 - 14).attr("y", 40)
.attr("font-size", 13).attr("font-weight", 700).attr("fill", theme.highlight)
.text(`${cur.phase === "start" ? "initial values" : cur.phase} · step ${cur.step}`);
svg.append("text").attr("x", x0 - 14).attr("y", 62)
.attr("font-size", 11).attr("fill", theme.nodeText)
.text(cur.phase === "all-gather"
? "finished chunks spread to every device"
: cur.phase === "reduce-scatter"
? "each device accumulates toward its one finished chunk (ringed)"
: "each device holds its own contribution");
svg.append("text").attr("x", x0 - 14).attr("y", height - 18)
.attr("font-size", 12).attr("fill", theme.nodeText)
.text(`column sums = [${finalVec.join(", ")}] → every device ends here`);
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Step to the end of reduce-scatter (step 3).** Exactly one cell per device is
ringed — the finished chunk. No device has the whole answer yet.
2. **Finish the all-gather.** The ringed chunks copy around the ring until every
row equals the column sums. That second half is why the cost is $2\times$ the
scatter, not $1\times$.
:::
## ZeRO: Stop Replicating What You Can Partition
Data parallelism is fast but memory-wasteful: all $N$ devices hold the *same*
16 bytes/param. **ZeRO** keeps data parallelism's compute pattern but removes the
redundancy — each device stores only its **slice** of the state and fetches the
rest with collectives (an all-gather for parameters just before they're used, a
reduce-scatter for gradients) exactly when needed.
- **Stage 1 — $P_{os}$:** partition the **optimizer states** (the 12 fp32 bytes).
Each device updates only its slice of the master weights, then all-gathers the
updated fp16 params. Per-device: $4\Psi + 12\Psi/N$.
- **Stage 2 — $P_{os+g}$:** also partition the **gradients** — a device only needs
the gradient slice for the optimizer slice it owns. Per-device:
$2\Psi + 14\Psi/N$.
- **Stage 3 — $P_{os+g+p}$:** also partition the **parameters** themselves,
all-gathering each layer's weights just-in-time for its forward/backward, then
discarding them. Per-device: $16\Psi/N$ — a true $N\times$ split. (This is what
PyTorch ships as **FSDP**.)
The step through the stages, with the $N=64$ / 7.5B numbers as the caption:
```{python}
#| echo: false
#| output: false
from distributed import demonstrate_zero
ojs_define(zeroDemo = demonstrate_zero(7.5, 64))
```
```{ojs}
//| echo: false
viewof dtZeroStep = stepControl({min: 0, max: 3, value: 0, label: "ZeRO stage"})
```
```{ojs}
//| echo: false
dtZeroDiagram = {
const theme = diagramTheme;
const width = 720, height = 380;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const stage = dtZeroStep;
const row = zeroDemo[stage];
const Ndev = 4; // draw a 4-device schematic
// which state types are partitioned at this stage?
const partitioned = {params: stage >= 3, grads: stage >= 2, optim: stage >= 1};
const bands = [
{key: "params", label: "params", frac: partitioned.params ? 1/Ndev : 1, color: theme.accent},
{key: "grads", label: "grads", frac: partitioned.grads ? 1/Ndev : 1, color: theme.info},
{key: "optim", label: "optimizer states", frac: partitioned.optim ? 1/Ndev : 1, color: theme.highlight}
];
const x0 = 60, colW = 140, gap = 24, y0 = 90, bandH = 22, bandGap = 6;
for (let d = 0; d < Ndev; d++) {
const x = x0 + d * (colW + gap);
svg.append("text").attr("x", x + colW / 2).attr("y", y0 - 14)
.attr("text-anchor", "middle").attr("font-size", 11).attr("fill", theme.nodeText)
.text(`device ${d}`);
let y = y0;
bands.forEach(b => {
// draw the full slot faintly, then the resident slice solid
svg.append("rect").attr("x", x).attr("y", y).attr("width", colW).attr("height", bandH)
.attr("rx", 4).attr("fill", theme.nodeFill).attr("opacity", 0.2)
.attr("stroke", theme.nodeStroke).attr("stroke-dasharray", "3 3");
const sliceW = colW * b.frac;
const sliceX = x + d * (colW / Ndev) * (b.frac < 1 ? 1 : 0);
svg.append("rect").attr("x", b.frac < 1 ? sliceX : x).attr("y", y)
.attr("width", b.frac < 1 ? sliceW : colW).attr("height", bandH)
.attr("rx", 4).attr("fill", b.color).attr("opacity", 0.9);
y += bandH + bandGap;
});
}
// legend
let ly = y0 + Ndev * 0 + 3 * (bandH + bandGap) + 40;
bands.forEach((b, i) => {
const lx = x0 + i * 210;
svg.append("rect").attr("x", lx).attr("y", ly - 11).attr("width", 14).attr("height", 14)
.attr("rx", 3).attr("fill", b.color);
svg.append("text").attr("x", lx + 20).attr("y", ly).attr("font-size", 11)
.attr("fill", theme.nodeText)
.text(`${b.label}${b.frac < 1 ? " — partitioned" : " — replicated"}`);
});
svg.append("text").attr("x", x0).attr("y", 40)
.attr("font-size", 13).attr("font-weight", 700).attr("fill", theme.highlight)
.text(`Stage ${stage}: ${row.label}`);
svg.append("text").attr("x", x0).attr("y", 62)
.attr("font-size", 12).attr("fill", theme.nodeText)
.text(`7.5B on 64 GPUs → ${row.per_device_gb.toFixed(2)} GB/device · ${row.reduction.toFixed(1)}× smaller`);
return svg.node();
}
```
::: {.callout-note}
## Key Insight
ZeRO is not a different parallelism from data parallelism — it *is* data
parallelism, with the redundant storage removed and paid back with communication.
Stage 3 partitions everything and is what PyTorch's **FSDP** implements; you trade
a modest amount of extra all-gather traffic for an $N\times$ memory cut.
:::
## Beyond Data Parallelism
Data parallelism (and ZeRO) shards the *batch* and, optionally, the *state* — but
every device still runs the whole model graph. When a single **layer** is too big,
or the pipeline of layers is too deep, two other axes come in. They compose:
real systems run **3D parallelism** (data × tensor × pipeline).
| Kind | What it splits | The cost it pays |
|------|----------------|------------------|
| **Data parallel** (this module) | the batch | an all-reduce of gradients per step |
| **Tensor parallel** | each layer's matrices (split $QKV$/FFN across devices) | an all-reduce *inside* every layer — needs fast intra-node links |
| **Pipeline parallel** | the stack of layers into stages | a "bubble" of idle time; hidden by micro-batching |
| **ZeRO / FSDP** | the optimizer/grad/param state | extra all-gathers to reassemble params just-in-time |
One more lever is orthogonal to all of these — **gradient checkpointing** (a.k.a.
activation recomputation). The backward pass needs the activations from the
forward pass; storing them all costs memory that grows with depth × batch ×
sequence. Checkpointing keeps only a few and **recomputes** the rest during the
backward pass — trading roughly one extra forward pass ($\sim$33% more compute)
for a large activation-memory cut. It stacks on top of everything above.
## Tensor Parallelism: Split Each Layer
Data parallelism keeps the whole model on each device and splits the *work*. But
if a single layer's weight matrices don't fit in one device's memory, no amount of
batch-splitting helps — you have to split the **matrix itself**. **Tensor
parallelism** (also called *model parallelism* or *intra-layer* parallelism) does
exactly that: it cuts each layer's GEMMs across devices and stitches the pieces
back together with one collective. This is the scheme **Megatron-LM** introduced
and every large training run uses.
There are only two ways to cut a matrix multiply $Y = X A$:
- **Column-parallel** — split $A$ by its **output columns**,
$A = [A_1 \mid A_2 \mid \dots \mid A_N]$. Device $i$ computes
$Y_i = X A_i$, a slice of the output columns. No communication: the input $X$ is
shared, and each device owns a disjoint block of outputs.
- **Row-parallel** — split $A$ by its **input rows**,
$A = [A_1; A_2; \dots; A_N]$, and split the input to match,
$X = [X_1 \mid \dots \mid X_N]$. Device $i$ computes a **partial**
$X_i A_i$; the full result is $\sum_i X_i A_i$ — an **all-reduce**.
::: {.callout-note}
## Key Insight
Column-parallel needs no communication but leaves the output *split*. Row-parallel
consumes a split input and pays **one all-reduce** to reassemble the output. Chain
them — column-parallel then row-parallel — and the split from the first GEMM is
exactly the layout the second one wants, so the whole two-layer block costs a
single collective. That is the entire trick.
:::
### The Math: The Megatron MLP
The transformer's feed-forward block is two GEMMs with a nonlinearity between them:
$Z = \text{GeLU}(X A)\,B$, where $A$ is $d_\text{model}\times d_\text{ff}$ and $B$
is $d_\text{ff}\times d_\text{model}$. Megatron splits $A$ **column-parallel** and
$B$ **row-parallel**:
$$
[\,Y_1 \mid \dots \mid Y_N\,] = \text{GeLU}\big(X\,[\,A_1 \mid \dots \mid A_N\,]\big),
\qquad
Z = \sum_{i=1}^{N} Y_i B_i .
$$
Why this exact pairing? Because GeLU is **elementwise** and each output column of
$XA$ depends only on the matching column of $A$, the nonlinearity can be applied
**independently on each shard** — there is no need to gather the columns first:
$$
\text{GeLU}(XA)\ \text{split by columns} \;=\; \big[\,\text{GeLU}(XA_1) \mid \dots \mid \text{GeLU}(XA_N)\,\big].
$$
Splitting $A$ any other way would break this (you'd need to sum partial columns
*before* the nonlinearity — a collective *inside* the block). Column-then-row is
the one split that defers all communication to a single all-reduce at the very end.
Megatron names the two communication points with a **conjugate operator pair**:
- $f$ — **identity** in the forward pass, **all-reduce** in the backward pass (it
sits at the block's input, where the replicated $X$ enters).
- $g$ — **all-reduce** in the forward pass, **identity** in the backward pass (it
sits at the block's output, summing the row-parallel partials).
Step through one forward pass of the split MLP on two devices:
```{python}
#| echo: false
#| output: false
import sys
from pathlib import Path
sys.path.insert(0, str(Path.cwd()))
from tensor_parallel import mlp_forward_trace, demonstrate_tensor_parallel
ojs_define(tpTrace = mlp_forward_trace())
ojs_define(tpDemo = demonstrate_tensor_parallel())
```
```{ojs}
//| echo: false
// Step control for the Megatron-MLP forward-pass walkthrough
viewof tpMlpStep = stepControl({min: 0, max: 5, value: 0, label: "MLP forward step"})
```
```{ojs}
//| echo: false
tpSteps = tpTrace.steps
```
```{ojs}
//| echo: false
tpMlpDiagram = {
const width = 760, height = 340;
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%")
.attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect")
.attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const cols = tpSteps.length; // 6 stages
const x0 = 40, x1 = width - 40;
const cx = i => x0 + (i + 0.5) * (x1 - x0) / cols;
const rowY = [130, 230]; // two device lanes
const bw = 96, bh = 54;
// lane labels
svg.append("text").attr("x", 12).attr("y", rowY[0] + 5)
.attr("fill", theme.nodeText).attr("font-size", 12).text("dev 0");
svg.append("text").attr("x", 12).attr("y", rowY[1] + 5)
.attr("fill", theme.nodeText).attr("font-size", 12).text("dev 1");
tpSteps.forEach((s, i) => {
const on = i === tpMlpStep;
const shared = i === 0 || i === cols - 1; // X in / Z out are replicated (one box)
const ys = shared ? [(rowY[0] + rowY[1]) / 2] : rowY;
ys.forEach(yc => {
const g = svg.append("g");
g.append("rect")
.attr("x", cx(i) - bw / 2).attr("y", yc - bh / 2)
.attr("width", bw).attr("height", bh).attr("rx", 8)
.attr("fill", on ? theme.highlight : theme.nodeFill)
.attr("stroke", s.comm ? theme.accent : theme.nodeStroke)
.attr("stroke-width", s.comm ? 3 : 1.5)
.attr("filter", on ? `drop-shadow(0 0 6px ${theme.highlightGlow})` : null);
g.append("text")
.attr("x", cx(i)).attr("y", yc - 4)
.attr("text-anchor", "middle").attr("font-size", 11)
.attr("fill", on ? theme.bgOpaque : theme.nodeText)
.text(s.title.split(" ").slice(0, 2).join(" "));
g.append("text")
.attr("x", cx(i)).attr("y", yc + 13)
.attr("text-anchor", "middle").attr("font-size", 10)
.attr("fill", on ? theme.bgOpaque : theme.edgeStroke)
.text(s.op);
});
// arrows between adjacent stages
if (i < cols - 1) {
const fromShared = i === 0, toShared = i + 1 === cols - 1;
const src = fromShared ? [(rowY[0]+rowY[1])/2] : rowY;
const dst = toShared ? [(rowY[0]+rowY[1])/2] : rowY;
const pairs = fromShared ? dst.map(d => [(rowY[0]+rowY[1])/2, d])
: toShared ? src.map(s2 => [s2, (rowY[0]+rowY[1])/2])
: rowY.map((y, k) => [y, rowY[k]]);
pairs.forEach(([ys1, ys2]) => {
svg.append("line")
.attr("x1", cx(i) + bw / 2).attr("y1", ys1)
.attr("x2", cx(i + 1) - bw / 2).attr("y2", ys2)
.attr("stroke", theme.edgeStroke).attr("stroke-width", 1.5)
.attr("marker-end", "url(#tp-arrow)");
});
}
});
// arrowhead
svg.append("defs").append("marker")
.attr("id", "tp-arrow").attr("viewBox", "0 0 10 10")
.attr("refX", 8).attr("refY", 5).attr("markerWidth", 6).attr("markerHeight", 6)
.attr("orient", "auto-start-reverse")
.append("path").attr("d", "M0,0 L10,5 L0,10 Z").attr("fill", theme.edgeStroke);
// caption
svg.append("text")
.attr("x", width / 2).attr("y", 40)
.attr("text-anchor", "middle").attr("font-size", 14).attr("font-weight", "bold")
.attr("fill", theme.highlight)
.text(`${tpMlpStep}. ${tpSteps[tpMlpStep].title}` + (tpSteps[tpMlpStep].comm ? " ⇄ communication" : " (no communication)"));
svg.append("text")
.attr("x", width / 2).attr("y", height - 20)
.attr("text-anchor", "middle").attr("font-size", 12)
.attr("fill", theme.nodeText)
.text(tpSteps[tpMlpStep].caption);
return svg.node();
}
```
Only **one** stage — the all-reduce ($g$) — moves data between devices. Everything
else is a local GEMM or an elementwise op. The input and output boxes collapse to a
single lane because $X$ and $Z$ are **replicated** on every device; the two GEMMs
and the GeLU run **split**.
### Code: the Split MLP from Scratch
`tensor_parallel.py` builds this directly. `column_parallel_shards` /
`row_parallel_shards` cut the two weights, `column_parallel_forward` runs the first
GEMM with no communication, and `row_parallel_forward` runs the second and calls the
module's own `ring_all_reduce` to sum the partials — the same collective data
parallelism used, now *inside* a layer.
```{python}
import torch
from tensor_parallel import megatron_mlp, single_device_mlp
torch.manual_seed(0)
x = torch.randn(2, 6, 64) # (batch, seq, d_model)
A = torch.randn(64, 256) # up-projection d_model -> d_ff
B = torch.randn(256, 64) # down-projection d_ff -> d_model
z_ref = single_device_mlp(x, A, B) # one device
for n in (1, 2, 4, 8):
z_tp = megatron_mlp(x, A, B, num_devices=n) # split across n devices
rel = (z_tp - z_ref).abs().max() / z_ref.abs().max()
print(f"N={n}: max relative diff = {rel:.2e}")
```
The split output equals the single-device MLP for every $N$ — the only gap is the
float-point reassociation of summing the partials in a different order, which is
machine-epsilon tiny. **Distribution is exact, not an approximation** — the same
anchor data parallelism earned earlier in this module.
```{ojs}
//| echo: false
tpMatchChart = {
const width = 640, height = 240;
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const devs = tpDemo.devices;
const x = d3.scaleBand().domain(devs.map(String)).range([70, width - 30]).padding(0.3);
// relative diffs are ~1e-7; plot on a symlog-ish fixed scale up to 1e-5
const y = d3.scaleLinear().domain([0, 1e-5]).range([height - 40, 30]);
svg.append("text").attr("x", width / 2).attr("y", 20).attr("text-anchor", "middle")
.attr("fill", theme.highlight).attr("font-size", 13).attr("font-weight", "bold")
.text("max relative diff (tensor-parallel vs single device)");
[["mlp_rel_diff", theme.highlight, -1], ["attn_rel_diff", theme.accent, 1]].forEach(([key, col, off]) => {
svg.selectAll(`rect.${key}`).data(devs).join("rect")
.attr("x", (d, i) => x(String(d)) + (off < 0 ? 0 : x.bandwidth() / 2))
.attr("width", x.bandwidth() / 2 - 2)
.attr("y", (d, i) => y(Math.max(tpDemo[key][i], 0)))
.attr("height", (d, i) => y(0) - y(Math.max(tpDemo[key][i], 0)))
.attr("fill", col).attr("rx", 2);
});
svg.append("line").attr("x1", 70).attr("x2", width - 30)
.attr("y1", y(0)).attr("y2", y(0)).attr("stroke", theme.edgeStroke);
devs.forEach(d => svg.append("text").attr("x", x(String(d)) + x.bandwidth() / 2)
.attr("y", height - 22).attr("text-anchor", "middle")
.attr("fill", theme.nodeText).attr("font-size", 11).text("N=" + d));
svg.append("text").attr("x", 74).attr("y", 44).attr("fill", theme.highlight).attr("font-size", 10).text("■ MLP");
svg.append("text").attr("x", 74).attr("y", 58).attr("fill", theme.accent).attr("font-size", 10).text("■ attention");
svg.append("text").attr("x", 74).attr("y", y(0) + 16).attr("fill", theme.edgeStroke).attr("font-size", 10).text("≈ 0");
return svg.node();
}
```
### Splitting Attention by Heads
Multi-head attention splits even more naturally: the heads are **already
independent**. Give each device a disjoint set of heads and it computes those heads'
attention end-to-end with **no communication** — the $Q/K/V$ projections are just a
column-parallel split whose boundaries fall on head edges, so a head never straddles
two devices. Only the **output projection** $W_O$ is row-parallel, paying the same
single all-reduce as the MLP.
```{python}
import torch
from tensor_parallel import megatron_attention, single_device_attention
torch.manual_seed(0)
x = torch.randn(2, 8, 64) # (batch, seq, d_model)
Wq, Wk, Wv, Wo = (torch.randn(64, 64) / 8 for _ in range(4)) # 1/√d init, 8 heads of dim 8
o_ref = single_device_attention(x, Wq, Wk, Wv, Wo, num_heads=8)
o_tp = megatron_attention(x, Wq, Wk, Wv, Wo, num_heads=8, num_devices=4) # 2 heads/device
print("attention split matches:", torch.allclose(o_ref, o_tp, atol=1e-5, rtol=1e-5))
```
A transformer layer is one attention block plus one MLP block, so it does **two**
all-reduces in the forward pass ($g$ for each) and **two** more in the backward pass
($f$ for each) — **four all-reduces per layer per step**. That is the price of tensor
parallelism, and it is why it lives *inside* a node.
### The Cost: Memory Down, Communication Up
Unlike data parallelism, tensor parallelism **shards the weights**, so each device
stores only $1/N$ of the split layers' parameters — it is a genuine memory cut, not
just a batch split. The bill it runs up is communication: those four all-reduces per
layer fire on **every** step and sit on the critical path (a nonlinearity is waiting
on the other side), so unlike the data-parallel gradient all-reduce they cannot be
hidden behind other compute. Drive the trade-off:
```{python}
#| echo: false
#| output: false
from tensor_parallel import parameter_bytes_per_device, tensor_parallel_comm_bytes
# The from-scratch anchor for the widget's default config (GPT-3-scale FFN:
# 8·d_model² params, d_model=12288, N=8, 4096 tokens/microbatch).
_ffn_params = 8 * 12288 * 12288
_ref = {
"params_mb": parameter_bytes_per_device(_ffn_params, 8) / 1e6,
"comm_mb": tensor_parallel_comm_bytes(4096 * 12288 * 2, 8, 1) / 1e6,
}
ojs_define(tpCostRef = _ref)
```
```{ojs}
//| echo: false
viewof tpDevices = Inputs.range([1, 16], {value: 8, step: 1, label: "tensor-parallel devices N"})
```
```{ojs}
//| echo: false
viewof tpHidden = Inputs.range([1024, 16384], {value: 12288, step: 256, label: "d_model"})
```
```{ojs}
//| echo: false
viewof tpTokens = Inputs.range([512, 8192], {value: 4096, step: 256, label: "tokens/microbatch (batch·seq)"})
```
```{ojs}
//| echo: false
tpCost = {
const N = tpDevices;
// MLP: A is d×4d, B is 4d×d ⇒ 8·d² params; per device 8d²/N.
const paramsFull = 8 * tpHidden * tpHidden;
const paramsPerDevBytes = 2 * paramsFull / N; // fp16
// one all-reduced activation = tokens · d_model · 2 bytes (fp16); 4 per layer.
const act = tpTokens * tpHidden * 2;
const ring = N === 1 ? 0 : 2 * (N - 1) / N * act; // ring all-reduce / device
const commPerLayer = ring * 4;
return {N, paramsPerDevBytes, commPerLayer, paramsFull};
}
```
```{ojs}
//| echo: false
md`For one FFN block at **d_model = ${tpHidden}**, **N = ${tpCost.N}** devices:
- **Parameters / device:** ${(tpCost.paramsPerDevBytes / 1e6).toFixed(1)} MB
— the full block is ${(2 * tpCost.paramsFull / 1e6).toFixed(0)} MB, sharded to
**1/${tpCost.N}** (**${tpCost.N}× smaller**).
- **Communication / layer / step:** ${(tpCost.commPerLayer / 1e6).toFixed(1)} MB
moved per device (4 all-reduces of a ${(tpTokens * tpHidden * 2 / 1e6).toFixed(1)} MB
activation) — this grows toward a fixed ${(2 * tpTokens * tpHidden * 2 * 4 / 1e6).toFixed(0)} MB
as N rises, and it is **on the critical path**.
_At the defaults this mirrors the from-scratch \`parameter_bytes_per_device\` and
\`tensor_parallel_comm_bytes\`: ${tpCostRef.params_mb.toFixed(0)} MB params/device,
${tpCostRef.comm_mb.toFixed(0)} MB communication/layer._`
```
::: {.callout-tip}
## Try This
1. **Push $N$ up.** Parameters-per-device keep falling as $1/N$, but the
communication per layer *rises* toward a fixed ceiling and never goes away.
That crossover is why tensor parallelism is kept **within a node** (fast NVLink)
and data/pipeline parallelism spans nodes.
2. **Shrink `d_model`.** Communication scales with the activation
($\text{tokens}\times d_\text{model}$) while parameters scale with
$d_\text{model}^2$ — so the bigger the layer, the *better* the memory-for-comm
trade. Tensor parallelism pays off exactly where you need it: enormous layers.
:::
## Pipeline Parallelism: Split the Stack
Data parallelism splits the *batch*; tensor parallelism splits each *layer's
matrices*. The third axis splits the **depth**: put layers 0–7 on device 0, layers
8–15 on device 1, and so on. A forward pass then **flows** device 0 → 1 → … →
$P{-}1$ (each stage sends its output activations to the next), and the backward
pass flows back. This is **pipeline parallelism**, and it is the only axis that
lets a model *deeper* than one device's memory train at all — no single device ever
holds more than its slice of the stack.
The catch is idle time. Feed one batch straight through a 4-stage pipeline and the
picture is embarrassing: device 1 waits for device 0, device 2 waits for device 1,
and while stage 3 finally runs the forward pass, stages 0–2 have nothing to do.
Only **one device works at a time** — a $4\times$ cluster running at $1\times$
speed. That wasted wavefront is the **pipeline bubble**.
The fix (GPipe, Huang et al. 2018) is the assembly line. Split the mini-batch into
$M$ **micro-batches** and stream them through the stages: as soon as stage 0
finishes micro-batch 0 and hands it to stage 1, it starts micro-batch 1. Once the
pipeline is *full*, every stage is busy on a different micro-batch, exactly like
cars moving down a factory line where every station works a different car. The
bubble never fully closes — someone is idle during the fill and the drain — but its
share shrinks as you push more micro-batches through.
::: {.callout-note}
## Key Insight
Pipeline parallelism trades a fixed **bubble** of idle time for the ability to
place layers on separate devices. Micro-batching hides the bubble by keeping every
stage busy on a different micro-batch — the more micro-batches, the smaller the
bubble's share of the total. Unlike tensor parallelism's per-layer all-reduce, the
only communication is a **point-to-point send** of activations across each stage
boundary, so pipeline parallelism happily spans the *slow* links between nodes.
:::
### The Math: The Bubble
Assume, as GPipe does, that a micro-batch's forward and backward each cost one time
slot. With $P$ stages the pipeline needs $P{-}1$ slots to **fill** (stage $s$ can't
start until the wavefront reaches it) and $P{-}1$ slots to **drain** at the end.
During those $2(P{-}1)$ slots some device is idle. The useful work per device is
$M$ forwards $+\,M$ backwards $= 2M$ slots, so the total makespan is
$2(M + P - 1)$ and the idle **bubble fraction** is
$$
\text{bubble} \;=\; \frac{2(P-1)}{2(M + P - 1)} \;=\; \frac{P-1}{M + P - 1}.
$$
This is GPipe's $O\!\big((P{-}1)/(M{+}P{-}1)\big)$. Megatron-LM reports the same
quantity *relative to the ideal compute time* $2M$, which drops the $P{-}1$ from
the denominator: $(P-1)/M$. Both say the same thing — **more micro-batches, less
bubble** — and GPipe's rule of thumb is that the bubble is negligible once
$M \ge 4P$. Drive it:
```{python}
#| echo: false
#| output: false
import sys
from pathlib import Path
sys.path.insert(0, str(Path.cwd()))
from pipeline import bubble_curve, demonstrate_pipeline, schedule_grid
ojs_define(pipeCurveRef = bubble_curve(4, 32))
ojs_define(pipeDemo = demonstrate_pipeline(4, 8))
```
```{ojs}
//| echo: false
viewof pipeStages = Inputs.range([2, 12], {value: 4, step: 1, label: "pipeline stages P"})
```
```{ojs}
//| echo: false
viewof pipeMicro = Inputs.range([1, 32], {value: 8, step: 1, label: "micro-batches M"})
```
```{ojs}
//| echo: false
pipeBubble = (p, m) => (p - 1) / (m + p - 1)
```
```{ojs}
//| echo: false
pipeBubbleCurve = {
const theme = diagramTheme;
const width = 720, height = 320;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const P = pipeStages;
const mMax = 32;
const x0 = 60, x1 = width - 30, y0 = 40, y1 = height - 50;
const xs = d3.scaleLinear().domain([1, mMax]).range([x0, x1]);
const ys = d3.scaleLinear().domain([0, 1]).range([y1, y0]);
// axes
svg.append("line").attr("x1", x0).attr("y1", y1).attr("x2", x1).attr("y2", y1)
.attr("stroke", theme.edgeStroke);
svg.append("line").attr("x1", x0).attr("y1", y0).attr("x2", x0).attr("y2", y1)
.attr("stroke", theme.edgeStroke);
[0, 0.25, 0.5, 0.75, 1].forEach(v => {
svg.append("text").attr("x", x0 - 8).attr("y", ys(v) + 4).attr("text-anchor", "end")
.attr("font-size", 10).attr("fill", theme.edgeStroke).text(`${(v*100)|0}%`);
svg.append("line").attr("x1", x0).attr("y1", ys(v)).attr("x2", x1).attr("y2", ys(v))
.attr("stroke", theme.edgeStroke).attr("opacity", 0.12);
});
svg.append("text").attr("x", (x0+x1)/2).attr("y", height - 12).attr("text-anchor", "middle")
.attr("font-size", 12).attr("fill", theme.nodeText).text("micro-batches M");
// bubble curve for the chosen P
const pts = d3.range(1, mMax + 1).map(m => [xs(m), ys(pipeBubble(P, m))]);
svg.append("path").attr("fill", "none").attr("stroke", theme.highlight)
.attr("stroke-width", 2.5).attr("d", d3.line()(pts));
// the M >= 4P "negligible" marker
const ruleM = Math.min(4 * P, mMax);
const rx = xs(ruleM), rb = pipeBubble(P, ruleM);
svg.append("line").attr("x1", rx).attr("y1", y0).attr("x2", rx).attr("y2", y1)
.attr("stroke", theme.accent).attr("stroke-dasharray", "5 4").attr("opacity", 0.7);
svg.append("text").attr("x", rx + 5).attr("y", y0 + 12)
.attr("font-size", 10).attr("fill", theme.accent).text(`M = 4P (${(rb*100).toFixed(0)}%)`);
// current M dot
const cm = Math.min(pipeMicro, mMax);
const cb = pipeBubble(P, cm);
svg.append("circle").attr("cx", xs(cm)).attr("cy", ys(cb)).attr("r", 6)
.attr("fill", theme.highlight).attr("stroke", theme.bgOpaque).attr("stroke-width", 2);
svg.append("text").attr("x", xs(cm)).attr("y", ys(cb) - 12).attr("text-anchor", "middle")
.attr("font-size", 12).attr("font-weight", 700).attr("fill", theme.highlight)
.text(`${(cb*100).toFixed(1)}%`);
svg.append("text").attr("x", x0).attr("y", 22)
.attr("font-size", 13).attr("font-weight", 700).attr("fill", theme.highlight)
.text(`P = ${P} stages · M = ${pipeMicro} micro-batches → bubble ${(cb*100).toFixed(1)}%`);
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Start at $M = 1$.** The bubble is $(P{-}1)/P$ — a $P$-stage pipeline running at
nearly $1/P$ efficiency. That is the naive picture, and it is terrible.
2. **Slide $M$ up to $4P$** (the blue line). The bubble drops below $\sim$20% and
keeps falling. This is why real runs use dozens of micro-batches per step.
3. **Raise $P$.** More stages means a bigger bubble *at the same $M$* — deeper
pipelines need proportionally more micro-batches to stay efficient.
:::
### Code: The Schedule from Scratch
The bubble formula falls out of an actual **schedule** — an assignment of each
micro-batch's forward and backward to a (device, time-slot). `pipeline.py` builds
one from scratch with a small greedy list-scheduler that respects three data
dependencies: a forward can't run until the previous stage's forward for that
micro-batch is done; a backward starts at the last stage once its forward finishes;
and a backward flows from stage $P{-}1$ back to stage $0$.
```{python}
from pipeline import partition_layers, pipeline_schedule, schedule_makespan, bubble_fraction
# 12 transformer blocks over 4 stages: each device owns a contiguous slice.
print("layer partition:", partition_layers(12, 4))
cells = pipeline_schedule(num_stages=4, num_microbatches=8, policy="gpipe")
print("ops scheduled:", len(cells), " makespan:", schedule_makespan(cells))
print("bubble fraction:", round(bubble_fraction(4, 8), 4))
```
The same scheduler produces **both** classic schedules — the only difference is the
tie-break when a device could run either a ready forward or a ready backward.
**GPipe** prefers forwards (all forwards, then all backwards); **1F1B**
(one-forward-one-backward) prefers backwards, retiring each micro-batch's
activations as early as it can. Toggle between them and watch the grid — blue is a
forward, orange a backward, and an empty cell is a **bubble**:
```{ojs}
//| echo: false
viewof pipePolicy = Inputs.radio(
new Map([["GPipe (all-F then all-B)", "gpipe"], ["1F1B (interleaved)", "1f1b"]]),
{value: "1f1b", label: "schedule"}
)
```
```{ojs}
//| echo: false
// Faithful port of pipeline.py's greedy list-scheduler (validated below against
// the bridged Python schedule_grid).
pipeMakeSchedule = function(p, m, policy) {
const preferB = policy === "1f1b";
const doneF = Array.from({length: m}, () => Array(p).fill(null));
const doneB = Array.from({length: m}, () => Array(p).fill(null));
const inflight = Array(p).fill(0);
const cap = s => preferB ? (p - s) : m;
const fReady = (i, s, t) => doneF[i][s] === null && inflight[s] < cap(s) &&
(s === 0 || (doneF[i][s-1] !== null && doneF[i][s-1] < t));
const bReady = (i, s, t) => {
if (doneB[i][s] !== null) return false;
const dep = s === p-1 ? doneF[i][s] : doneB[i][s+1];
return dep !== null && dep < t;
};
const cells = [];
let remaining = 2*p*m, t = 0;
while (remaining > 0) {
for (let s = 0; s < p; s++) {
const rf = [], rb = [];
for (let i = 0; i < m; i++) { if (fReady(i,s,t)) rf.push(i); if (bReady(i,s,t)) rb.push(i); }
let phase = null, i = null;
if (preferB) { if (rb.length) { phase="B"; i=rb[0]; } else if (rf.length) { phase="F"; i=rf[0]; } }
else { if (rf.length) { phase="F"; i=rf[0]; } else if (rb.length) { phase="B"; i=rb[0]; } }
if (phase === null) continue;
if (phase === "F") { doneF[i][s] = t; inflight[s]++; } else { doneB[i][s] = t; inflight[s]--; }
cells.push({device: s, time: t, microbatch: i, phase});
remaining--;
}
t++;
if (t > 4*(m+p)) break;
}
return {cells, makespan: Math.max(...cells.map(c => c.time)) + 1};
}
```
```{ojs}
//| echo: false
pipeGantt = {
const theme = diagramTheme;
const P = pipeStages, M = pipeMicro;
const sched = pipeMakeSchedule(P, M, pipePolicy);
const T = sched.makespan;
const cellW = Math.max(14, Math.min(30, 640 / T));
const cellH = Math.max(18, Math.min(34, 220 / P));
const x0 = 70, y0 = 40;
const width = x0 + T * cellW + 20, height = y0 + P * cellH + 66;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${Math.max(width, 480)}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height).attr("fill", theme.bg).attr("rx", 12);
// empty-grid (bubble) backdrop
for (let s = 0; s < P; s++)
for (let t = 0; t < T; t++)
svg.append("rect").attr("x", x0 + t*cellW).attr("y", y0 + s*cellH)
.attr("width", cellW - 1.5).attr("height", cellH - 1.5).attr("rx", 2)
.attr("fill", theme.nodeFill).attr("opacity", 0.18);
// device lane labels
for (let s = 0; s < P; s++)
svg.append("text").attr("x", x0 - 8).attr("y", y0 + s*cellH + cellH/2 + 4)
.attr("text-anchor", "end").attr("font-size", 11).attr("fill", theme.nodeText)
.text(`dev ${s}`);
// ops
sched.cells.forEach(c => {
const fwd = c.phase === "F";
svg.append("rect").attr("x", x0 + c.time*cellW).attr("y", y0 + c.device*cellH)
.attr("width", cellW - 1.5).attr("height", cellH - 1.5).attr("rx", 2)
.attr("fill", fwd ? theme.accent : theme.highlight)
.attr("opacity", 0.35 + 0.6 * (c.microbatch + 1) / M);
if (cellW >= 18)
svg.append("text").attr("x", x0 + c.time*cellW + cellW/2)
.attr("y", y0 + c.device*cellH + cellH/2 + 3.5).attr("text-anchor", "middle")
.attr("font-size", 9).attr("fill", theme.nodeText)
.attr("font-weight", 700).text(c.microbatch);
});
// legend + bubble read-out
const bub = (P-1)/(M+P-1);
const empties = P*T - sched.cells.length;
const total = P*T;
svg.append("rect").attr("x", x0).attr("y", height - 42).attr("width", 14).attr("height", 14)
.attr("rx", 2).attr("fill", theme.accent);
svg.append("text").attr("x", x0 + 20).attr("y", height - 31).attr("font-size", 11)
.attr("fill", theme.nodeText).text("forward");
svg.append("rect").attr("x", x0 + 90).attr("y", height - 42).attr("width", 14).attr("height", 14)
.attr("rx", 2).attr("fill", theme.highlight);
svg.append("text").attr("x", x0 + 110).attr("y", height - 31).attr("font-size", 11)
.attr("fill", theme.nodeText).text("backward");
svg.append("rect").attr("x", x0 + 190).attr("y", height - 42).attr("width", 14).attr("height", 14)
.attr("rx", 2).attr("fill", theme.nodeFill).attr("opacity", 0.4);
svg.append("text").attr("x", x0 + 210).attr("y", height - 31).attr("font-size", 11)
.attr("fill", theme.nodeText).text("bubble (idle)");
svg.append("text").attr("x", x0).attr("y", 24)
.attr("font-size", 13).attr("font-weight", 700).attr("fill", theme.highlight)
.text(`${pipePolicy === "gpipe" ? "GPipe" : "1F1B"} · P=${P} · M=${M} · makespan ${T} · bubble ${(empties/total*100).toFixed(1)}% (formula ${(bub*100).toFixed(1)}%)`);
return svg.node();
}
```
```{ojs}
//| echo: false
// The JS schedule matches the from-scratch Python one at the widget's default.
pipeCheck = {
const js = pipeMakeSchedule(4, 8, "gpipe");
const ok = js.makespan === pipeDemo.gpipe.makespan;
return md`_Validation: the JS schedule reproduces \`pipeline.py\`'s at P=4, M=8 — makespan ${js.makespan} ${ok ? "==" : "≠"} Python ${pipeDemo.gpipe.makespan}._`;
}
```
Notice the two schedules span the **same number of columns** — GPipe and 1F1B have
the *identical* bubble. So why does everyone use 1F1B? Look at how long each stage
must hold onto activations.
### Same Bubble, Less Memory
The backward pass needs the activations saved during the forward pass. In **GPipe**,
stage 0 runs *all* $M$ forwards before the first backward reaches it — so it must
stash the activations of **all $M$ micro-batches** at once. In **1F1B**, each stage
starts backpropagating as soon as its warm-up forwards are done and immediately
frees that micro-batch's activations, so it never holds more than **$P$** in flight
(one per stage ahead of it). Same bubble, but peak activation memory of $O(M)$ vs
$O(P)$ — and since good utilization *wants* $M \gg P$, that difference is enormous.
The scheduler measures it directly:
```{python}
from pipeline import peak_inflight_microbatches, pipeline_schedule
for policy in ("gpipe", "1f1b"):
peak = peak_inflight_microbatches(pipeline_schedule(num_stages=4, num_microbatches=16, policy=policy))
print(f"{policy:5s}: peak micro-batches held at stage 0 = {peak}")
```
```{ojs}
//| echo: false
pipeMemBars = {
const theme = diagramTheme;
const width = 700, height = 210;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height).attr("fill", theme.bg).attr("rx", 12);
const P = pipeStages, M = pipeMicro;
const gpipePeak = M, f1Peak = Math.min(P, M);
const maxPeak = Math.max(gpipePeak, f1Peak, 1);
const x0 = 150, barW = 460, scale = v => Math.max(3, v / maxPeak * barW);
const rows = [
{label: "GPipe", peak: gpipePeak, color: theme.error, y: 60},
{label: "1F1B", peak: f1Peak, color: theme.success, y: 120},
];
rows.forEach(r => {
svg.append("rect").attr("x", x0).attr("y", r.y).attr("width", scale(r.peak)).attr("height", 40)
.attr("rx", 6).attr("fill", r.color).attr("opacity", 0.85);
svg.append("text").attr("x", x0 - 12).attr("y", r.y + 25).attr("text-anchor", "end")
.attr("font-size", 13).attr("font-weight", 700).attr("fill", theme.nodeText).text(r.label);
svg.append("text").attr("x", x0 + scale(r.peak) + 10).attr("y", r.y + 25)
.attr("font-size", 13).attr("font-weight", 700).attr("fill", r.color)
.text(`${r.peak} micro-batches`);
});
svg.append("text").attr("x", x0).attr("y", 30)
.attr("font-size", 13).attr("font-weight", 700).attr("fill", theme.highlight)
.text(`Peak activation stashes at stage 0 · P=${P}, M=${M}`);
svg.append("text").attr("x", x0).attr("y", height - 14)
.attr("font-size", 11).attr("fill", theme.nodeText)
.text(`1F1B holds ${(gpipePeak / f1Peak).toFixed(1)}× less — and stays flat as you add micro-batches`);
return svg.node();
}
```
### Exact by Construction
One worry should be nagging: does chopping the batch into micro-batches and running
them through separate devices change the answer? **No.** Splitting the batch and
**summing** the per-micro-batch gradients is exactly the gradient of the summed
loss over the whole batch — pipeline parallelism is gradient accumulation along the
depth axis. `pipeline.py` proves it against a single-device reference:
```{python}
from pipeline import gradient_match
res = gradient_match(num_layers=6, width=8, batch=12, num_stages=3, num_microbatches=4)
print("max relative gradient difference:", f"{res['max_rel_diff']:.2e}")
print("bit-for-bit (to float reassociation):", res["allclose"])
```
The gradients agree to machine epsilon — the only gap is the order floating-point
adds the micro-batch contributions. Distribution across depth, like distribution
across the batch (data parallelism) and across a layer (tensor parallelism), is
**exact, not an approximation**.
## Sequence Parallelism: Ring Attention
Data, tensor, and pipeline parallelism all leave one thing whole on every device:
**the sequence**. Each replica still holds the entire length-$L$ activation for
its examples. That is fine at $L = 2\text{k}$; it is fatal at $L = 1\text{M}$.
Attention's score matrix is $L \times L$, and even m09's **FlashAttention** — which
never *materializes* that matrix — still keeps the whole length-$L$ $Q$, $K$, and
$V$ resident on one device. Past a few hundred thousand tokens, that alone
overflows. None of the three axes above help: they shard the batch, the weights,
or the layers, never the tokens.
**Ring attention** (Liu, Zaharia & Abbeel, 2023) adds the missing fourth axis:
shard the *sequence itself* across $P$ devices. Device $p$ permanently owns its
query block $Q_p$ — one $L/P$-length slice. The key/value blocks are then passed
**around a ring**: at each of $P$ steps, every device holds exactly one $K/V$
block, folds that block's contribution into its own queries with an **online
softmax** (the running-max / running-denominator trick from m09), then hands the
block to its neighbor and receives the next. After $P$ steps every device has seen
every $K/V$ block, so every query has attended over the *whole* sequence — and
because the send of the next block overlaps the compute on the current one, the
communication is almost free.
::: {.callout-note}
## Key Insight
Ring attention is the **sequence** twin of the three axes you just built. It is
**exact** — the online-softmax accumulation is the same identity m09 proves, so
the ring output equals full attention bit-for-bit. And it is *unboundedly*
scalable: each device only ever holds $O(L/P)$ of the sequence (its own block plus
one in-flight block), so the maximum context length grows **linearly** with the
number of devices, with no change to the math. That is the "near-infinite context"
headline.
:::
### The Ring, Step by Step
Watch the $K/V$ blocks hop around the ring. Each GPU keeps its own query block
fixed (the label $Q_p$) and, at every step, absorbs whichever $K/V$ block has
rotated to it — the colored tile riding on each node tells you which one. The arc
inside each node fills as it absorbs more blocks; after $P$ steps it is full and
that GPU's slice of the output is done.
```{python}
#| echo: false
#| output: false
from ring_attention import ring_attention_trace, demonstrate_ring_attention
_ring_tr = ring_attention_trace(seq_len=16, d_k=8, num_devices=4)
_ring_demo = demonstrate_ring_attention(seq_len=16, d_k=8, num_devices=4)
ojs_define(ringAttTrace = _ring_tr)
ojs_define(ringAttWork = {
"contiguous": _ring_demo["contiguous"]["work"],
"striped": _ring_demo["striped"]["work"],
})
```
```{ojs}
//| echo: false
viewof ringAttStep = stepControl({min: 0, max: ringAttTrace.steps.length - 1, value: 0, label: "Ring step"})
```
```{ojs}
//| echo: false
ringAttSteps = ringAttTrace.steps.map((row, i) => ({
title: `Step ${i + 1} of ${ringAttTrace.num_devices}`,
caption: i === 0
? "Each GPU starts with its own K/V block (block color = origin GPU)."
: `Every K/V block has hopped ${i} time${i > 1 ? "s" : ""} clockwise; each GPU folds in the block now resting on it.`,
}))
```
```{ojs}
//| echo: false
ringAttDiagram = {
const width = 760, height = 470;
const theme = diagramTheme;
const P = ringAttTrace.num_devices;
const step = ringAttTrace.steps[ringAttStep];
const palette = [theme.highlight, theme.accent, theme.info, theme.success,
theme.error, theme.nodeStroke];
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%")
.attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect")
.attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const cx = width / 2, cy = 215, R = 135;
const angle = p => (-Math.PI / 2) + (2 * Math.PI * p / P); // GPU 0 at top
// The ring backbone + a clockwise rotation hint.
svg.append("circle")
.attr("cx", cx).attr("cy", cy).attr("r", R)
.attr("fill", "none").attr("stroke", theme.edgeStroke)
.attr("stroke-width", 2).attr("stroke-dasharray", "4 6");
for (let p = 0; p < P; p++) {
const a0 = angle(p) + 0.28, a1 = angle((p + 1) % P) - 0.28;
const arc = d3.arc()({innerRadius: R, outerRadius: R, startAngle: a0 + Math.PI/2, endAngle: a1 + Math.PI/2});
const mid = (angle(p) + 0.5 * (2 * Math.PI / P));
svg.append("path")
.attr("transform", `translate(${cx},${cy})`)
.attr("d", arc).attr("fill", "none")
.attr("stroke", theme.edgeStroke).attr("stroke-width", 2)
.attr("marker-end", "url(#ringAttArrow)");
}
svg.append("defs").append("marker")
.attr("id", "ringAttArrow").attr("viewBox", "0 0 10 10")
.attr("refX", 8).attr("refY", 5).attr("markerWidth", 7).attr("markerHeight", 7)
.attr("orient", "auto-start-reverse")
.append("path").attr("d", "M0,0 L10,5 L0,10 z").attr("fill", theme.edgeStroke);
// Each GPU node: query block + a fill arc for absorbed blocks + its K/V tile.
for (let p = 0; p < P; p++) {
const x = cx + R * Math.cos(angle(p));
const y = cy + R * Math.sin(angle(p));
const rec = step[p];
const absorbed = ringAttStep + (rec.computed ? 1 : 0); // blocks folded in so far
// absorbed-progress ring behind the node
svg.append("path")
.attr("transform", `translate(${x},${y})`)
.attr("d", d3.arc()({innerRadius: 30, outerRadius: 36,
startAngle: 0, endAngle: 2 * Math.PI * absorbed / P}))
.attr("fill", theme.highlight).attr("opacity", 0.85);
svg.append("circle")
.attr("cx", x).attr("cy", y).attr("r", 30)
.attr("fill", theme.nodeFill)
.attr("stroke", rec.computed ? theme.highlight : theme.edgeStroke)
.attr("stroke-width", rec.computed ? 3 : 1.5);
svg.append("text")
.attr("x", x).attr("y", y - 3).attr("text-anchor", "middle")
.attr("font-size", 13).attr("font-weight", 700).attr("fill", theme.nodeText)
.text(`GPU ${p}`);
svg.append("text")
.attr("x", x).attr("y", y + 12).attr("text-anchor", "middle")
.attr("font-size", 11).attr("fill", theme.nodeText).text(`Q${p}`);
// the K/V tile currently resting on this GPU (color = origin GPU)
const tx = cx + (R + 46) * Math.cos(angle(p));
const ty = cy + (R + 46) * Math.sin(angle(p));
svg.append("rect")
.attr("x", tx - 20).attr("y", ty - 13).attr("width", 40).attr("height", 26).attr("rx", 5)
.attr("fill", rec.computed ? palette[rec.holding % palette.length] : theme.nodeFill)
.attr("stroke", theme.nodeStroke).attr("stroke-width", 1.5)
.attr("opacity", rec.computed ? 0.92 : 0.4);
svg.append("text")
.attr("x", tx).attr("y", ty + 4).attr("text-anchor", "middle")
.attr("font-size", 10).attr("font-weight", 700)
.attr("fill", rec.computed ? theme.bgOpaque : theme.nodeText)
.text(`KV${rec.holding}`);
}
// caption band
const s = ringAttSteps[ringAttStep];
svg.append("text").attr("x", cx).attr("y", height - 52).attr("text-anchor", "middle")
.attr("font-size", 15).attr("font-weight", 700).attr("fill", theme.highlight)
.text(s.title);
svg.append("text").attr("x", cx).attr("y", height - 28).attr("text-anchor", "middle")
.attr("font-size", 12).attr("fill", theme.nodeText)
.text(s.caption);
svg.append("text").attr("x", cx).attr("y", height - 10).attr("text-anchor", "middle")
.attr("font-size", 11).attr("fill", theme.nodeText)
.text(`Blocks absorbed per GPU: ${ringAttStep + 1} / ${P} · ring send overlaps the next block's compute`);
return svg.node();
}
```
### The Math: One Block at a Time
The whole trick is that softmax can be computed **incrementally**. Keep three
running numbers per query row — the max logit $m$, the denominator $\ell$, and the
unnormalized output $o$ — and fold in one $K/V$ block at a time. When a block with
scores $s_{ij} = q_i \cdot k_j / \sqrt{d_k}$ arrives:
$$
m_i \leftarrow \max\!\big(m_i,\ \max_j s_{ij}\big), \qquad
\ell_i \leftarrow \ell_i\,e^{\,m_i^{\text{old}} - m_i} + \textstyle\sum_j e^{\,s_{ij} - m_i}
$$
$$
o_i \leftarrow o_i\,e^{\,m_i^{\text{old}} - m_i} + \textstyle\sum_j e^{\,s_{ij} - m_i}\,v_j,
\qquad\text{and at the end}\quad \text{out}_i = o_i / \ell_i .
$$
The $e^{\,m^{\text{old}} - m}$ factor rescales the mass already accumulated to the
new maximum — the *exact* correction that makes streaming equal to computing the
whole softmax at once (this is m09's online-softmax identity). Ring attention runs
this loop once per device, over the $P$ blocks that rotate past it. Because the
loop is order-independent, the blocks can arrive in any ring order and the answer
is unchanged. `absorb_block` in `ring_attention.py` is exactly this update.
### Exact by Construction
`ring_attention` simulates the $P$ devices: it shards $Q$, $K$, $V$ along the
sequence, and for each device streams every rotated $K/V$ block through
`absorb_block`. The output is the same tensor a single device would compute — for
both full and causal attention.
```{python}
import torch
from ring_attention import ring_attention
torch.manual_seed(0)
q, k, v = (torch.randn(2, 4, 96, 32) for _ in range(3)) # (batch, heads, seq=96, d=32)
scale = 1.0 / q.size(-1) ** 0.5
# The single-device reference.
ref = torch.softmax(q @ k.transpose(-2, -1) * scale, dim=-1) @ v
# Split the length-96 sequence across 8 ring devices.
ring = ring_attention(q, k, v, num_devices=8)
print("max |ring − full|:", f"{(ring - ref).abs().max():.2e}")
print("exact (bit-for-bit to float reassociation):", torch.allclose(ring, ref, atol=1e-5))
```
The gap is machine epsilon — the ring never approximates. The causal variant skips
any $K/V$ block strictly in the future of a device's queries and masks the diagonal
block, matching a causally-masked full attention just as exactly:
```{python}
seq = q.size(-2)
tri = torch.triu(torch.ones(seq, seq, dtype=torch.bool), diagonal=1)
ref_causal = torch.softmax(
(q @ k.transpose(-2, -1) * scale).masked_fill(tri, torch.finfo(q.dtype).min), dim=-1
) @ v
ring_causal = ring_attention(q, k, v, num_devices=8, causal=True)
print("causal exact:", torch.allclose(ring_causal, ref_causal, atol=1e-5))
```
### Context Scales with Devices
Because each device holds only its own block plus one in-flight block, the
sequence-activation memory per device is $O(L/P)$, not $O(L)$ — and the $L\times L$
score matrix is never formed. Invert that and the story flips into the headline:
with a **fixed** per-device memory budget, the context length you can attend to
grows **linearly** with the number of ring devices. Drive $P$ and watch the wall
move:
```{ojs}
//| echo: false
viewof ringAttP = Inputs.range([1, 32], {value: 8, step: 1, label: "ring devices P"})
```
```{ojs}
//| echo: false
ringAttScaleChart = {
const width = 720, height = 200, m = {t: 28, r: 20, b: 42, l: 56};
const theme = diagramTheme;
const base = 128; // tokens attainable on one device, in "k", illustrative
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`).attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`).style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height).attr("fill", theme.bg).attr("rx", 12);
const x = d3.scaleLinear().domain([1, 32]).range([m.l, width - m.r]);
const y = d3.scaleLinear().domain([0, 32 * base]).range([height - m.b, m.t]);
// the linear context line
svg.append("line")
.attr("x1", x(1)).attr("y1", y(base)).attr("x2", x(32)).attr("y2", y(32 * base))
.attr("stroke", theme.accent).attr("stroke-width", 2);
// the operating point
const P = ringAttP;
svg.append("circle").attr("cx", x(P)).attr("cy", y(P * base)).attr("r", 6).attr("fill", theme.highlight);
svg.append("line").attr("x1", x(P)).attr("y1", height - m.b).attr("x2", x(P)).attr("y2", y(P * base))
.attr("stroke", theme.highlight).attr("stroke-dasharray", "3 4").attr("stroke-width", 1.5);
svg.append("text").attr("x", x(P)).attr("y", y(P * base) - 12).attr("text-anchor", "middle")
.attr("font-size", 13).attr("font-weight", 700).attr("fill", theme.highlight)
.text(`P=${P} → ~${(P * base / 1000).toFixed(P * base >= 1000 ? 1 : 2)}M tokens`);
// axes
svg.append("text").attr("x", (m.l + width - m.r) / 2).attr("y", height - 10).attr("text-anchor", "middle")
.attr("font-size", 11).attr("fill", theme.nodeText).text("ring devices P");
svg.append("text").attr("transform", `translate(16,${(m.t + height - m.b) / 2}) rotate(-90)`)
.attr("text-anchor", "middle").attr("font-size", 11).attr("fill", theme.nodeText).text("max context");
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Push $P$ to 32.** The attainable context grows in lockstep — 32 devices, 32×
the context, and the per-device memory is unchanged. This linearity is the whole
point: long context becomes a *systems* dial, not a modeling limit.
2. **Step the ring to the last step.** Every GPU's absorb-arc is full: each has now
seen all $P$ key blocks, so every query attended the entire sequence — yet no GPU
ever held more than $2/P$ of the sequence at once.
:::
### The Causal Imbalance — and the Striped Fix
There is one honest wrinkle. Under a **causal** mask, a query at position $i$ only
attends to the $i{+}1$ keys before it. With **contiguous** shards, the device
holding the *last* block of positions must attend to *every* prior block, while the
device holding the *first* block attends to almost none — so the late devices do far
more work, and the ring's step time is set by the busiest one. `causal_block_work`
counts the exact causal $(q,k)$ pairs each device computes:
```{python}
from ring_attention import causal_block_work
contig = causal_block_work(num_devices=8, block_size=16, layout="contiguous")
striped = causal_block_work(num_devices=8, block_size=16, layout="striped")
print("contiguous per-device work:", contig["work"])
print(" imbalance (max/min):", f"{contig['imbalance']:.2f}×")
print("striped per-device work:", striped["work"])
print(" imbalance (max/min):", f"{striped['imbalance']:.2f}×")
print("same total work:", contig["total"] == striped["total"])
```
The fix, from **Striped Attention** (Brandon et al., 2023), is to stop giving each
device a *contiguous* run of tokens. Assign device $p$ the *interleaved* positions
$\{p,\ p+P,\ p+2P,\dots\}$ instead. Now every device holds some early *and* some late
positions, so under the same causal ring each does nearly identical work — the total
number of causal pairs is unchanged, but it is spread evenly. (The same idea, paired
up block-by-block, is the widely-used "zigzag" ring schedule.)
```{ojs}
//| echo: false
ringAttWorkChart = {
const width = 720, height = 210, m = {t: 26, r: 16, b: 40, l: 46};
const theme = diagramTheme;
const rows = [
{name: "contiguous", work: ringAttWork.contiguous, color: theme.error},
{name: "striped", work: ringAttWork.striped, color: theme.success},
];
const P = ringAttWork.contiguous.length;
const maxW = d3.max(rows.flatMap(r => r.work));
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`).attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`).style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height).attr("fill", theme.bg).attr("rx", 12);
const groupW = (width - m.l - m.r) / P;
const y = d3.scaleLinear().domain([0, maxW]).range([height - m.b, m.t]);
rows.forEach((row, ri) => {
const bw = groupW * 0.34;
row.work.forEach((w, p) => {
const x0 = m.l + p * groupW + groupW * 0.5 - bw + ri * (bw + 2);
svg.append("rect")
.attr("x", x0).attr("y", y(w)).attr("width", bw).attr("height", (height - m.b) - y(w))
.attr("fill", row.color).attr("rx", 3).attr("opacity", 0.9);
});
});
d3.range(P).forEach(p => {
svg.append("text").attr("x", m.l + p * groupW + groupW * 0.5).attr("y", height - 22)
.attr("text-anchor", "middle").attr("font-size", 11).attr("fill", theme.nodeText).text(`GPU ${p}`);
});
// legend
rows.forEach((row, ri) => {
svg.append("rect").attr("x", width - m.r - 150).attr("y", m.t + ri * 20 - 8).attr("width", 12).attr("height", 12)
.attr("fill", row.color).attr("rx", 2);
svg.append("text").attr("x", width - m.r - 132).attr("y", m.t + ri * 20 + 2)
.attr("font-size", 12).attr("fill", theme.nodeText).text(row.name);
});
svg.append("text").attr("x", m.l).attr("y", 16).attr("font-size", 12).attr("font-weight", 700)
.attr("fill", theme.highlight).text("Causal work per device — contiguous piles up on the last GPU; striped is flat");
return svg.node();
}
```
## Common Pitfalls
::: {.callout-warning}
## Uneven shards break the "one big batch" equality
The gradient average equals the full-batch gradient *only when the shards are the
same size*. With ragged shards you'd need a weighted average by sample count;
`data_parallel_gradients` rejects uneven shards rather than silently bias the step.
:::
::: {.callout-warning}
## Scaling $N$ scales the effective batch — retune the LR
$N$ devices with per-device batch $b$ is one global batch of $Nb$. Larger batches
usually need a larger learning rate (the linear-scaling rule) and a warmup, or the
first steps diverge. Distribution changes the *batch*, and the batch changes the
*optimizer* ([m07](../m07_training/lesson.qmd)).
:::
::: {.callout-warning}
## Communication can dominate if you don't overlap it
The all-reduce moves $\sim 2M$ bytes every step. Naively you'd compute, then
communicate, then wait. Real frameworks **overlap** the gradient all-reduce of
early layers with the backward pass of later ones, so most of the transfer is
free. Without overlap, distributed training can be *slower* than one device.
:::
::: {.callout-warning}
## ZeRO trades memory for communication, not for free
Higher ZeRO stages add all-gather traffic to reassemble what they partitioned.
Stage 3 on a slow interconnect can be communication-bound. Pick the lowest stage
that makes the model fit — stage 1 is often enough and the cheapest.
:::
::: {.callout-warning}
## Tensor parallelism all-reduces every layer — keep it intra-node
The data-parallel all-reduce fires once per step and hides behind the backward
pass. Tensor parallelism's four all-reduces fire *per layer* and block on the
critical path (a GeLU or softmax waits on the sum). Over a slow inter-node link
that stall dominates, so tensor parallelism is confined to a single node's fast
interconnect (NVLink); data and pipeline parallelism span the slower links between
nodes. And never split below one head or one output column — an empty shard is a
device doing nothing but communicating.
:::
::: {.callout-warning}
## Too few micro-batches wastes the pipeline
The bubble is $(P{-}1)/(M{+}P{-}1)$: with $M = 1$ a $P$-stage pipeline runs at
nearly $1/P$ efficiency. Micro-batching is not optional — it *is* pipeline
parallelism. Aim for $M \ge 4P$ (GPipe's rule of thumb) so the bubble drops below
$\sim$20%. But micro-batches can't shrink forever: each one is a smaller GEMM, and
past a point the per-kernel overhead and lower arithmetic intensity eat the gain.
:::
::: {.callout-warning}
## GPipe stores every micro-batch's activations — use 1F1B
The naive all-forward-then-all-backward schedule (GPipe) keeps stage 0 holding the
activations of **all $M$** micro-batches until the backward sweep arrives —
activation memory that grows with the very $M$ you raised to shrink the bubble.
**1F1B** has the *same* bubble but bounds in-flight micro-batches to $P$, so it is
the schedule real frameworks ship. (Interleaved 1F1B and activation
recomputation/checkpointing cut the bill further still.)
:::
::: {.callout-warning}
## Causal ring attention is load-imbalanced — stripe the tokens
A contiguous causal ring makes the device holding the last query block attend to
*every* prior block while the first device attends to almost none, so its step time
is set by the busiest device (~$2\times$ the average at large $P$). **Striped /
zigzag** token assignment — interleaved positions instead of contiguous runs —
spreads the same total causal work evenly and recovers the throughput. The
*non-causal* ring has no such imbalance; this bites only under the causal mask.
:::
## Exercises
### Exercise 1: All-reduce **mean** from all-reduce **sum**
`ring_all_reduce` returns the *sum*. Wrap it to return the mean (what gradient
averaging actually needs), and confirm it matches `torch.stack(...).mean(0)`.
```{python}
import torch
from distributed import ring_all_reduce
def all_reduce_mean(tensors):
# Your implementation here: sum, then divide by the device count.
summed = ring_all_reduce(tensors)
n = len(tensors)
return [s / n for s in summed]
xs = [torch.randn(6) for _ in range(4)]
got = all_reduce_mean(xs)[0]
print("matches mean:", torch.allclose(got, torch.stack(xs).mean(0), atol=1e-6))
```
### Exercise 2: Derive the stage-2 memory formula
Stage 2 partitions the optimizer states (12 bytes) **and** the gradients (2 bytes)
across $N$, but replicates the fp16 params (2 bytes). Write the per-device byte
formula and check it against `zero_memory_per_device(psi, N, 2)`.
```{python}
from distributed import zero_memory_per_device
def stage2_bytes(psi, n):
# Your implementation here: 2 bytes replicated + (2 + 12) bytes partitioned.
return 2 * psi + (2 + 12) * psi / n
psi, n = 1_000_000, 8
print("mine:", stage2_bytes(psi, n))
print("ref: ", zero_memory_per_device(psi, n, 2))
```
### Exercise 3: Gradient accumulation — data parallelism on one device
Gradient accumulation gets a big effective batch on *one* device by summing
gradients over several micro-batches before stepping. Show it equals the
data-parallel gradient over the same shards.
```{python}
import torch
from distributed import data_parallel_gradients
torch.manual_seed(1)
W = torch.randn(2, 3)
shards_x = [torch.randn(4, 3) for _ in range(3)]
shards_y = [torch.randn(4, 2) for _ in range(3)]
def accumulate(W, xs, ys):
# Your implementation here: mean-grad per micro-batch, averaged.
grads = []
for x, y in zip(xs, ys):
w = W.detach().clone().requires_grad_(True)
loss = ((x @ w.T - y) ** 2).mean()
grads.append(torch.autograd.grad(loss, w)[0])
return sum(grads) / len(grads)
acc = accumulate(W, shards_x, shards_y)
dp = data_parallel_gradients(W, shards_x, shards_y)
print("accumulation == data parallel:", torch.allclose(acc, dp, atol=1e-6))
```
### Exercise 4: Build the row-parallel GEMM from the shards
`row_parallel_forward` splits $B$ by rows, multiplies each partial, and all-reduces
the sum. Reconstruct it from the primitives and confirm it matches the full
$Y B$ — the second half of the Megatron MLP.
```{python}
import torch
from tensor_parallel import row_parallel_shards, ring_all_reduce
torch.manual_seed(0)
Y = torch.randn(3, 16) # column-split activation (full, for the check)
B = torch.randn(16, 8)
def my_row_parallel(y, b, n):
# Your implementation here: split b by rows AND y by columns to match,
# form each partial y_i @ b_i, then all-reduce the partials.
y_shards = list(y.chunk(n, dim=-1))
b_shards = row_parallel_shards(b, n)
partials = [ys @ bs for ys, bs in zip(y_shards, b_shards)]
return ring_all_reduce(partials)[0]
got = my_row_parallel(Y, B, 4)
print("row-parallel matches full:", torch.allclose(got, Y @ B, atol=1e-5))
```
### Exercise 5: How many micro-batches to hit a target bubble?
The bubble is $(P{-}1)/(M{+}P{-}1)$. Invert it: for a pipeline of $P$ stages, find
the smallest $M$ whose bubble is at most a target fraction, then check it against
`microbatches_for_bubble`.
```{python}
import math
from pipeline import bubble_fraction, microbatches_for_bubble
def min_microbatches(p, target):
# Your implementation: solve (p-1)/(M+p-1) <= target for the smallest integer M.
return max(1, math.ceil((p - 1) * (1 - target) / target))
for p, target in [(4, 0.1), (8, 0.1), (16, 0.05)]:
m = min_microbatches(p, target)
print(f"P={p:2d}, target {target:>4}: M={m:3d} → bubble {bubble_fraction(p, m):.3f}",
"✓" if m == microbatches_for_bubble(p, target) else "✗")
```
## Summary
Key takeaways:
1. **Training memory is $16$ bytes/param** — under mixed-precision Adam: 2 (fp16
params) + 2 (fp16 grads) + 12 (fp32 master + momentum + variance). The
optimizer state, not the parameters, is what doesn't fit.
2. **Data parallelism replicates the model and averages gradients**, and that
average is *bit-for-bit* the gradient one big-batch device would compute — a
distributed step is not an approximation.
3. **The ring all-reduce** does the averaging in two phases (reduce-scatter →
all-gather), bit-exact to the naive sum, at $2(N{-}1)/N \cdot M$ bytes per
device — a fixed budget as $N$ grows, which is why it, not a central reducer,
is the standard.
4. **ZeRO removes data parallelism's redundancy** in three stages — partitioning
the optimizer states ($4\Psi + 12\Psi/N$), then gradients ($2\Psi + 14\Psi/N$),
then parameters ($16\Psi/N$) — up to an $N\times$ memory cut. Stage 3 is FSDP.
5. **Tensor parallelism splits a single layer** across devices: the MLP's first
GEMM column-parallel (so GeLU stays independent) and its second row-parallel,
reassembled by one all-reduce; attention splits the same way, by heads. The
result is *bit-exact* to one device (bar reassociation) — it shards the weights
$1/N$ at the cost of **four all-reduces per layer**, which is why it stays
inside a node.
6. **Pipeline parallelism splits the stack of layers** into stages and streams $M$
micro-batches through them to hide the fill/drain **bubble**, which shrinks as
$(P{-}1)/(M{+}P{-}1)$ (negligible at $M \ge 4P$). **GPipe** and **1F1B** have the
*same* bubble, but 1F1B bounds peak activation memory to $O(P)$ instead of
$O(M)$ — and, like the other axes, pipelining is *exact*, since micro-batch
gradient accumulation equals the full-batch gradient.
7. **The axes compose:** data parallelism splits the batch, tensor parallelism
splits each layer, pipeline parallelism splits the stack, and gradient
checkpointing trades compute for activation memory. Frontier runs combine all of
them (**3D parallelism**).
## What's Next
You can now build the model (m01–m06), train it (m07), align it (m12), serve it
(m08–m09), and **scale its training across a cluster** on all four axes — across
the batch (data parallelism, ZeRO), *within a layer* (tensor parallelism), *along
the depth* (pipeline parallelism), and *along the sequence* (**ring attention** /
context parallelism). The frontier from here: **interleaved 1F1B** and
**zero-bubble** pipeline schedules, **FSDP** wired into a real `GPTModel`,
**gradient checkpointing** built from an autograd hook, and the **all-gather**
context-parallel variant Llama 3 used at 128k — all filed on the roadmap.
### Going Deeper
**Core Papers:**
- [ZeRO: Memory Optimizations Toward Training Trillion Parameter Models](https://arxiv.org/abs/1910.02054) — Rajbhandari et al. (2020): the $2+2+12$ memory model and the three-stage partitioning built here.
- [Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism](https://arxiv.org/abs/1909.08053) — Shoeybi et al. (2019): the column/row-parallel MLP and head-split attention built here, and the $f$/$g$ conjugate operators (4 all-reduces per layer).
- [GPipe: Efficient Training of Giant Neural Networks using Pipeline Parallelism](https://arxiv.org/abs/1811.06965) — Huang et al. (2018): the micro-batch pipeline and the $O((P{-}1)/(M{+}P{-}1))$ bubble built here; the $M \ge 4P$ negligible-bubble rule.
- [Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM](https://arxiv.org/abs/2104.04473) — Narayanan et al. (2021): 1F1B / interleaved pipeline schedules, the $(P{-}1)/M$ bubble form, and 3D parallelism at scale.
- [PipeDream: Generalized Pipeline Parallelism for DNN Training](https://arxiv.org/abs/1806.03377) — Narayanan et al. (2019): the origin of the one-forward-one-backward (1F1B) schedule.
- [Bandwidth Optimal All-reduce Algorithms for Clusters of Workstations](https://web.cels.anl.gov/~thakur/papers/ring.pdf) — Patarasuk & Yuan (2009): the ring all-reduce and its $2(N{-}1)/N \cdot M$ cost.
- [Horovod: fast and easy distributed deep learning in TensorFlow](https://arxiv.org/abs/1802.05799) — Sergeev & Del Balso (2018): ring all-reduce brought to deep learning.
- [PyTorch FSDP: Experiences on Scaling Fully Sharded Data Parallel](https://arxiv.org/abs/2304.11277) — Zhao et al. (2023): ZeRO-3 as a production PyTorch API.
- [Training Deep Nets with Sublinear Memory Cost](https://arxiv.org/abs/1604.06174) — Chen et al. (2016): gradient checkpointing.
- [Ring Attention with Blockwise Transformers for Near-Infinite Context](https://arxiv.org/abs/2310.01889) — Liu, Zaharia & Abbeel (2023): the sequence-axis parallelism built here — K/V blocks rotated around a ring, online-softmax accumulation, context linear in device count.
- [Blockwise Parallel Transformer for Large Context Models](https://arxiv.org/abs/2305.19370) — Liu & Abbeel (2023): the blockwise attention + feedforward accumulation ring attention distributes.
- [Striped Attention: Faster Ring Attention for Causal Transformers](https://arxiv.org/abs/2311.09431) — Brandon et al. (2023): the interleaved token assignment that rebalances the causal ring built here.
- [The Llama 3 Herd of Models](https://arxiv.org/abs/2407.21783) — Grattafiori et al. (2024): context parallelism (CP=16) in production to reach 128k tokens (an all-gather-based cousin of the pass-KV ring).
**Practical Resources:**
- [DeepSpeed](https://www.deepspeed.ai/) — the library that popularized ZeRO.
- [Megatron-LM (code)](https://github.com/NVIDIA/Megatron-LM) — NVIDIA's reference implementation of tensor + pipeline parallelism.