Lecture 02 · Part 1

PyTorch (einops), Resource Accounting


Percy builds the resource-accounting mindset the whole course runs on. Everything in training is a tensor operation, every tensor operation has a memory cost and a FLOPs cost, and two napkin formulas — 6ND FLOPs per training run and bytes-per-parameter for memory — let you size a training job before you write any code.

Napkin math you can do before writing any code

The course’s framing question is fixed: given finite compute and memory, train the best model you can. Before you can maximize efficiency, you have to be able to account for it, and Percy opens with two questions you should be able to answer by the end of the lecture.

How long does it take to train a 70B-parameter model on 15T tokens with 1,024 H100s? Total work is 670×10915×10126 \cdot 70\times10^9 \cdot 15\times10^{12} FLOPs (the 6ND6ND formula this lecture derives). An H100 promises 1,979 teraFLOP/s — but that’s the sparsity number, so divide by 2 — and you’ll realize about half of that in practice (MFU of 0.5). Chain it together:

total_flops = 6 * 70e9 * 15e12
h100_flop_per_sec = 1979e12 / 2
flops_per_day = h100_flop_per_sec * 0.5 * 1024 * 60 * 60 * 24
days = total_flops / flops_per_day   # ≈ 143 days

What’s the largest model you can train on 8 H100s with AdamW? Each parameter costs 2 bytes (bf16 weight) + 2 (gradient) + 4 + 4 (fp32 Adam moments) = 12 bytes, so 880GB/12538 \cdot 80\text{GB} / 12 \approx 53B parameters — an upper bound, since activations (which scale with batch size and sequence length) aren’t counted.

Tensors and what precision buys you

Everything is a tensor: parameters, gradients, activations, optimizer state, data. Memory is simply the number of elements times the bytes per element, and the bytes per element is a design choice with real consequences.

x = torch.zeros(4, 8)              # fp32 by default
x.numel() * x.element_size()       # 32 * 4 = 128 bytes

One feedforward matrix in GPT-3 is already 2.3 GB at fp32 — these get big.

The practical recipe is mixed precision: bf16 for parameters, activations, and gradients; fp32 for optimizer state, where you’re accumulating averages over many steps and sloppiness compounds. PyTorch’s AMP library automates the safe casts (matmuls yes, exponentials no). And remember the deep-learning inversion of scientific computing: “we’re going the other way, because even 32 is a lot.”

einops: stop transposing, start naming

Percy’s motivation is honest: x @ y.transpose(-2, -1) makes you reason about what -2 and -1 are, and “I always get confused by transposes.” einops names the dimensions instead.

There’s no speed cost — it compiles to the same primitives, “you can think about it as just sugar” — but the transposes disappear into the naming, and code stops caring how many batch dimensions ride along.

Counting FLOPs (and the two acronyms that sound identical)

A FLOP is one floating-point addition or multiplication. FLOPs counts work done; FLOP/s measures hardware speed. GPT-3 took about 3.14×10233.14\times10^{23} FLOPs; GPT-4 is speculated around 2×10252\times10^{25}.

For the matmul at the heart of everything — xx of shape B×DB \times D times ww of shape D×KD \times K — there is one multiply and one add per (i,j,k)(i, j, k) triple:

FLOPs=2BDK\text{FLOPs} = 2 \cdot B \cdot D \cdot K

Read it as 2 × (number of data points) × (number of parameters) — the form that generalizes to Transformers. Elementwise operations are linear in the tensor size; nothing you’ll meet is as expensive as the matmuls, so matmuls are what we count.

Arithmetic intensity: why MFU isn’t 1

A computation moves inputs from memory to the accelerator, computes, and moves results back. Two hardware numbers govern it: compute speed (H100: 989×1012989\times10^{12} FLOP/s dense bf16) and memory bandwidth (H100: 3.35×10123.35\times10^{12} bytes/s). Assuming perfect overlap, runtime is the max of communication time and computation time — whichever dominates names the regime, memory-bound or compute-bound.

Percy walks the ladder for a million-element bf16 vector:

OperationBytes movedFLOPsIntensityRegime
ReLU4n4nnn~0.25memory-bound
GELU4n4n20n20n~5memory-bound
dot product4n4n2n2n~0.5memory-bound
matrix–vector2n2+4n2n^2 + 4n2n22n^2~1memory-bound
matrix–matrix6n26n^22n32n^3~n/3n/3compute-bound

The full training step: 6ND, and where the bytes go

For gradients, zoom in on one layer h2=h1w2h_2 = h_1 w_2 of a deep linear network. The forward pass costs 2BD22BD^2. The backward pass must produce two gradients — one flowing to the input (loss/h1\partial\text{loss}/\partial h_1) and one for the parameters (loss/w2\partial\text{loss}/\partial w_2), each another matmul against the incoming gradient:

h1_grad = einsum(h2.grad, w2, "batch out, in out -> batch in")
w2_grad = einsum(h2.grad, h1, "batch out, batch in -> in out")

So backward is twice forward, and per training step:

Memory per parameter under mixed precision with an Adam-family optimizer: 2 bytes (bf16 parameter) + 2 (gradient) + 8 (fp32 first and second moments) — the 12 bytes from the opening question. AdaGrad-style optimizers with one accumulator need 4 instead of 8. Activations add 2BDL2BDL bytes on top, which motivates the two closing tricks:

  • Gradient accumulation — compute gradients on micro-batches and defer the optimizer step, shrinking activation memory by the micro-batch ratio while keeping the effective batch size.
  • Activation checkpointing — keep activations at only L\sqrt{L} of the layers and recompute the rest during backward: memory drops from O(L)O(L) to O(L)O(\sqrt{L}) for one extra O(L)O(L) recompute. “Trade memory for compute.”

Check yourself

Exercises in the lecture’s napkin-math spirit — do them on paper with a calculator, nothing else.

Problem 2.1 training-time estimate

You have 64 H100s at MFU 0.4. How many days to train an 8B-parameter model on 2T tokens?

Show solution

Total FLOPs: 68×1092×1012=9.6×10226 \cdot 8\times10^9 \cdot 2\times10^{12} = 9.6\times10^{22}. Per-GPU dense bf16: 1979×1012/2=989.5×10121979\times10^{12}/2 = 989.5\times10^{12} FLOP/s; at MFU 0.4 the cluster delivers 989.5×10120.4642.53×1016989.5\times10^{12} \cdot 0.4 \cdot 64 \approx 2.53\times10^{16} FLOP/s, i.e. 2.19×10212.19\times10^{21} FLOPs/day. So 9.6×1022/2.19×1021449.6\times10^{22} / 2.19\times10^{21} \approx 44 days. The knobs to sanity-check in any such estimate: the 6, the sparsity halving, and the MFU.

Problem 2.2 memory accounting

Largest model trainable on a single 80 GB H100 with AdamW under the lecture’s mixed-precision recipe? What single change to the optimizer roughly halves the per-parameter cost, and to what size does that take you?

Show solution

Bytes per parameter: 2 (bf16 weights) + 2 (bf16 gradients) + 4 + 4 (fp32 Adam moments) = 12. So 80×109/126.780\times10^9 / 12 \approx 6.7B parameters, ignoring activations. Swapping Adam for an optimizer with a single fp32 accumulator (AdaGrad-style) gives 2 + 2 + 4 = 8 bytes/parameter, or 80×109/8=1080\times10^9/8 = 10B. Activations make both upper bounds.

Problem 2.3 arithmetic intensity

An elementwise product z = x * y on two bf16 vectors of n=220n = 2^{20} elements. Compute its arithmetic intensity and classify it on an H100. Would fusing it with a subsequent relu(z) change the classification?

Show solution

Bytes: read xx and yy (each 2n2n), write zz (2n2n) — 6n6n total. FLOPs: nn multiplies. Intensity =n/6n=1/60.17= n/6n = 1/6 \approx 0.17, far below 295 — memory-bound. Fusing the ReLU adds nn comparisons for zero extra traffic (z never round-trips), doubling intensity to 1/31/3 — still hopelessly memory-bound, but that “free” second op is exactly why kernels fuse elementwise chains.

Problem 2.4 einops fluency

Attention scores are computed from QQ of shape (batch, heads, seq, head_dim) and KK of the same shape. Write the einsum for the (batch, heads, seq, seq) score tensor, then the rearrange that merges heads back into a single hidden dimension after the values are applied.

Show solution
scores = einsum(Q, K, "b h sq d, b h sk d -> b h sq sk")   # d summed out
out = rearrange(out, "b h s d -> b s (h d)")               # merge heads

Naming the two sequence axes differently (sq, sk) is the einsum equivalent of the transpose you’d otherwise have to reason about; the unnamed d disappearing is the contraction.