WORKBENCH
06 · TrainingLesson 6.2

Why training needs so much memory.

Why does training a model take many times the memory of running it?

Listen · 15 min0:00 / 15:11

Running a model only needs its weights, the millions or billions of numbers it learned. Teaching it needs those weights plus a pile of notes about every one of them: which way each number should move, how it has been moving lately, a more precise backup copy, and everything the model worked out on the way to its answer so it can trace its mistakes backwards. Add up the notes and they come to many times the weights.

This is the arithmetic that decides what you can train on your own machine. Get it wrong and a job you were sure would fit dies before it finishes a single step, or you rent cloud GPUs for something the Spark could have done. Get it right and you can say, before you run anything, which model sizes are yours to train, which need tricks, and which need a cluster. Interviewers expect you to do this sum on a whiteboard.

It builds on How much memory does a model need?, which sized the serving side, and borrows the story of 16-bit and 32-bit numbers from the number formats lesson. It feeds straight into Train a small LM, end to end, Fine-tuning and LoRA and Training on many GPUs.

Today

Do How much memory does a model need? first: it covers the four places memory goes when serving, GB vs GiB, the difference between memory in use and memory PyTorch has set aside, and the Spark’s shared CPU-and-GPU memory. The number formats lesson helps for the precision part, but this lesson repeats what it needs. Plan on one evening of reading and arithmetic plus one session on the Spark.

The Problem

In How much memory does a model need? you learned to size inference: a 1B model in bf16 is about 2.5 GB of weights, plus a KV cache and some overhead. Serving Llama 3.2 1B fits on a laptop.

Now you want to fine-tune it. Same model, same 1.24 billion numbers. You write the obvious loop, batch of 8, sequence length 2,048, AdamW, on a 24 GB card, and it dies on the first step with something like:

torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 3.91 GiB.
GPU 0 has a total capacity of 23.55 GiB of which 1.12 GiB is free.

Nothing about the model changed. It is still 2.5 GB of weights. So where did the other twenty-plus gigabytes come from? You drop the batch to 1 and it runs, then dies on step 2, which is even stranger: what does step 2 need that step 1 did not?

Your cross-encoder at work has the same shape of problem. Whenever someone says “just train a bigger one”, the question that decides whether it happens on the Spark or on rented H100s is this lesson’s question.

Predict firstwrite it down

Llama 3.2 1B (1.24 billion parameters), standard mixed-precision training with AdamW. Ignore activations for now. How many GB does training need just for the model and its optimizer? Compare to the 2.5 GB it takes to serve.

Reveal

About 20 GB (18.4 GiB), eight times what serving needs. The rule is roughly 16 bytes per parameter: 2 for the bf16 weights you compute with, 2 for their gradients, 4 for a full-precision fp32 copy of the weights, and 8 for Adam’s two running averages (4 bytes each). 1.24 billion × 16 = 19.8 GB. Serving needs only the first 2 of those 16 bytes. And activations, which we ignored, can easily add as much again.

The Idea

Four buckets, again, but different ones

At inference the kitchen had equipment bolted to the floor (weights), a tray per table (KV cache), a burst of counter space while plating (activations) and overhead. Training is a kitchen that is also rewriting the recipe after every dish. You still cook the dish (the forward pass). Then you go back through every step you took, asking “how should I have done this step differently?” (the backward pass). Then you change the recipe a little (the optimizer step). The extra memory is everything you need to keep so that “going back” and “changing a little” are possible.

GPU memory during training has these four buckets:

  1. Weights. Same as before: parameters × bytes per parameter. Sometimes stored twice, in two precisions, for a reason we will get to.
  2. Gradients. One number per parameter: “if you nudged this weight up, how much would the loss change?” The gradientFor each parameter, how much the loss would change if that parameter grew a tiny bit. The backward pass computes it; the optimizer uses it to decide which way to move the parameter. has exactly the same shape as the weights, so it costs another full copy.
  3. Optimizer states. What the optimizer remembers between steps. Plain SGD remembers nothing. Adam remembers two numbers per parameter. This bucket is usually the biggest.
  4. Activations saved for backward. The intermediate values from the forward pass that the backward pass will need. At inference you threw them away the moment the next layer used them; in training you keep every layer’s worth until backward reaches it. This bucket grows with batch size and sequence length.

The first three are called model statesThe memory that scales with the number of parameters during training: weights, gradients and optimizer states. Independent of batch size and sequence length., and they depend only on the model. The fourth depends on what you feed it.

FIG_001[ SAME MODEL, TWO JOBS ]
0 GiB10203040SERVEbf16, 1 userTRAINb4 · s2048≈ 3.9 GiBADAMACTIVATIONS≈ 41BF16 WEIGHTS 2.3FP32 MASTER 4.6GRADS 2.3ADAM M + V 9.2SAVED ACTIVATIONS 11.5LOGITS + LOSS 9.8RUNTIME 1.5STATES 18.4 GIB = 16 B × 1.24B PARAMS · ACTIVATIONS GROW WITH B × S
Llama 3.2 1B, drawn to the same scale. Serving needs the bf16 weights and a little runtime. Training at batch 4 × 2,048 tokens adds four more copies' worth of model states and two big activation buckets. Sizes are computed with this lesson's formulas, not measured; you will measure them.

Let’s take them one at a time, starting with the one that surprises people most.

The optimizer remembers two things about every weight

Plain gradient descent updates each weight as w = w − lr × grad. That has two problems in practice. Gradients from one batch are noisy, so the direction wobbles from step to step. And some parameters get huge gradients while others get tiny ones, so a single learning rate is too big for some and too small for others.

AdamAn optimizer (Kingma and Ba, 2014) that keeps, for every parameter, a running average of its gradient (m) and a running average of its squared gradient (v), and moves each parameter by m divided by the square root of v. AdamW is Adam with weight decay applied separately; the memory is identical. fixes both by remembering two numbers per parameter:

The update divides one by the square root of the other, so every weight gets a step size scaled to its own history. That is why Adam works so well out of the box, and why it costs two extra full copies of the model. Both are kept in fp32 (4 bytes each) almost everywhere, because they are averages of many tiny numbers and lose accuracy fast in 16-bit. So Adam alone is 8 bytes per parameter, four times the bf16 weights it is updating.

I checked this on my Mac with a small GPT-2: before the first optimizer.step(), opt.state was empty. After it, every parameter had an exp_avg and an exp_avg_sq (that’s m and v in PyTorch’s naming), both fp32, both the parameter’s shape, totalling exactly 2.0 × the bytes of the fp32 weights. Note the after: PyTorch creates Adam’s states lazily, during the first step. That is the mystery from The Problem. Step 1 fit; step 2 did not, because step 2 is the first one that starts with m and v already in memory.

Why a second copy of the weights?

Modern training does the heavy math in bf16Brain floating point: a 16-bit format with the same 8-bit exponent (range) as fp32 but only 7 stored mantissa bits (precision). Big range, coarse steps. Covered in the number formats lesson., because tensor coresDedicated matrix-multiply units on NVIDIA GPUs. They run 16-bit (and smaller) formats many times faster than fp32 math on the ordinary cores. run it several times faster than fp32 and it halves activation memory. But bf16 has a precision problem that bites at exactly one place: the update.

bf16 keeps about 8 significant bits, so neighbouring representable numbers near 1.0 are about 0.008 apart (2⁻⁷). A typical update is learning rate × something gradient-sized, maybe 0.0001 for a weight of size 0.05, a change of 0.2%. bf16 can only step in increments of about 0.4% to 0.8% of the value. Add 0.2% and the result rounds straight back to the original number. The update vanishes. Do that for every weight and training silently stops learning.

The fix, from the 2017 “Mixed Precision Training” paper (Micikevicius and others at NVIDIA and Baidu), is to keep a master weightsA full-precision (fp32) copy of the parameters that the optimizer updates. The 16-bit weights used in the forward and backward pass are re-copied from it after every step, so tiny updates accumulate instead of rounding away. copy in fp32. The optimizer adds the tiny update to the fp32 copy, where it survives. Then the bf16 copy is refreshed from it for the next forward pass. Many small updates add up in fp32 until they are big enough to show up in bf16.

The 16 bytes, and its variants

Put the classic recipe together, as the ZeRO paper counted it and as Megatron-style and DeepSpeed training lays it out:

ItemFormatBytes / param
Weights used in forward and backwardbf162
Gradientsbf162
Master weightsfp324
Adam mfp324
Adam vfp324
Total16

So the rule of thumb: full training with Adam costs about 16 bytes per parameter, before activations. A 1B model is about 16 GB; Llama 3.2 1B at 1.24 billion parameters is 19.8 GB (18.4 GiB). An 8B model is 128 GB.

“About” matters, because the split depends on the setup, and you should know which one you are running:

FIG_002[ WHERE THE 16 BYTES GO ]
024681012141618BYTESBF16 + FP32 MASTERWGMASTERMV16PLAIN FP32W fp32G fp32M fp32V fp3216AMP AUTOCASTW fp32G fp32M fp32V fp32CAST16+2+ 8-BIT ADAMWGMASTERMV10PURE BF16WGMV8BF16 ADAFACTORWG≈4INFERENCEW2
Each cell is one byte per parameter. Three common recipes all land near 16; the savings come from shrinking or dropping the optimizer states, or from giving up the fp32 master copy. Multiply any row by the parameter count to get gigabytes.
Predict firstwrite it down

Using 16 bytes per parameter, can you fully fine-tune Llama 3.1 8B (8.03 billion parameters) on the Spark’s 128 GB? Write a yes or no and the number.

Reveal

No. 8.03 billion × 16 bytes = 128.5 GB, which is 119.7 GiB. That is already essentially the whole Spark before the operating system takes its share and before a single activation. Full fine-tuning an 8B model with standard AdamW needs either a smaller optimizer, more than one machine, or a different kind of fine-tuning (LoRA). You’ll work out how far each one gets you below.

Why backward needs the forward’s leftovers

Now the fourth bucket. You built GPT-2 from scratch, so you have called loss.backward() many times. Here is what it needs.

The chain ruleThe calculus rule for nested functions: how much the output changes when you nudge the input equals the product of how much each step changes when you nudge the step before it., in plain words: the network is a long pipeline of small steps. To find how the loss depends on something early in the pipeline, you walk backwards from the loss, and at each step you ask “if my input moved a bit, how much would my output move?” Multiply those answers together and you have it. The backward passRunning the chain rule from the loss back to the first layer: each layer receives the gradient with respect to its output and computes the gradients with respect to its input and its own weights. is exactly that walk.

The catch is that each step’s “how much would my output move?” depends on what its input was. Three examples you have written:

So every operation, during forward, stashes whatever its own derivative will need. In Karpathy’s micrograd, that’s the _prev children and the values captured in each _backward closure. In PyTorch it is ctx.save_for_backward(...) inside every autograd function, and the stash is what autograd calls saved tensorsThe tensors an autograd operation keeps from the forward pass so it can compute its gradient in the backward pass. They are the “activations” in training memory.. Under torch.no_grad() nothing is stashed, which is why inference gets to throw activations away.

FIG_003[ WHAT BACKWARD READS ]
FORWARD →x₀LINEAR 1x₁ = x₀ W₁GELUx₂ = gelu(x₁)LINEAR 2y = x₂ W₂LOSSSAVEDFOR BACKWARDx₀x₁x₂dW₂ = x₂ᵀ dydx₂ = dy W₂ᵀdx₁ = dx₂ ·gelu′(x₁)dW₁ = x₀ᵀ dx₁(needs x₀)← BACKWARDx₀ LIVES FORALL OF FORWARD + ALL OF BACKWARD
Forward runs left to right and leaves each layer's input in a stash. Backward runs right to left and reads the stash to compute each weight's gradient. The stash for layer 1 must survive the longest: from the very start of forward to the very end of backward.

Look at the shape of that. The stash for layer 1 is written first and read last. At the moment the loss is computed, every layer’s stash is alive at once. That moment is the activation peak, and it’s why activation memory grows with depth.

How big the stash is

Count it for one transformer layer. Write b for batch size, s for sequence length, h for hidden size, a for attention heads, L for layers. One “sbh” is one hidden-sized vector per token in the batch, the basic unit of everything that flows through the model.

For a GPT-style block (4h-wide MLP with GELU, dropout on), Korthikanti and colleagues at NVIDIA counted, at 2 bytes per value:

Total per layer, in bytes:

activations per layer  ≈  s · b · h · (34 + 5 · a · s / h)

and you multiply by L. The first term is linear in everything. The second grows with s², and it is the one that blows up at long sequences: at s = 2,048 with a = 32 heads and h = 2,048 it is 5 × 32 × 2,048 / 2,048 = 160, almost five times the first term.

Fused attention kernels (FlashAttention, and PyTorch’s scaled_dot_product_attention when it picks a fused backend) never store the s × s matrix. They keep only the output and one small number per row, and recompute the scores during backward, tile by tile. With them the s² term disappears, and per-layer activations are about 34 sbh. That’s the FlashAttention lesson seen from the training side: it saves more memory in training than in inference, because training would otherwise have to keep those matrices.

Two corrections to keep the formula honest:

How close is the formula to reality? I counted every tensor autograd saved for Hugging Face’s GPT-2 small on my Mac’s CPU (fp32, so double the bytes; batch 2, sequence 1,024), using PyTorch’s saved_tensors_hooks (in torch.autograd.graph), which you’ll do yourself in the Build:

Setup (GPT-2 124M, fp32, b=2, s=1024)Saved for backward
Textbook attention, dropout on6.3 GB
Textbook attention, dropout off3.7 GB
Fused SDPA attention, dropout off2.7 GB
Fused SDPA + checkpointing0.5 GB

The formula predicts about 5.5 GB for the first row (including three fp32 copies of the logits). The measurement came in about 15% higher, probably mostly because HF’s GPT-2 computes its GELU as a chain of small Python-level ops, and each one saves its own 4h-wide intermediate. Treat the formula as a floor and measure your own model.

Watch one step

Now put all four buckets on a clock. Step through one training iteration: forward, loss, backward, the optimizer step and zero_grad. Then turn on checkpointing (explained next), and then look at the first step on its own.

FIG_004[ ONE TRAINING STEP, IN MEMORY ]
Model

A stacked view of one iteration, computed with the formulas above for a BF16 + FP32 master + AdamW recipe. Activations pile up through forward, the logits spike at the loss, and backward frees each layer's stash as it goes while that layer's gradients appear. The first step has no Adam states until optimizer.step() creates them.

Three things to notice:

  1. The peak is at the turn, between forward and backward, when everything saved is alive at once. Not during the optimizer step, and not at the end.
  2. Gradients and activations trade places. Backward frees layer L’s stash as it creates layer L’s gradients. Activations are usually much bigger than gradients, so memory falls through backward.
  3. For GPT-2 small, the logits are the biggest single item. Its vocabulary (50,257) is 65 times its hidden size (768). For big models it’s the other way around, which is why advice about memory depends so much on model size.

The tools

Every trick for fitting training into memory removes or shrinks one bucket. Here they are, grouped by which bucket they attack.

Activations: gradient accumulation. If batch 32 doesn’t fit, run batch 8 four times, and only step the optimizer after the fourth. Gradients from each micro-batchOne slice of the batch that goes through forward and backward at once. Several micro-batches’ gradients are added together before one optimizer step. add up in the .grad buffers (that’s PyTorch’s default: backward() adds into .grad, it doesn’t overwrite). Divide the loss by 4 so the sum is an average, and the update is mathematically the same as one batch of 32 (up to small floating-point differences, and except for layers like BatchNorm that look across the batch). Activation memory is that of batch 8. Model states don’t change. The cost is time: four smaller passes keep the GPU less busy than one big one, so throughput drops a little, and a lot if the micro-batch gets so small that the GPU idles.

Activations: activation checkpointingAlso called gradient checkpointing. During forward, keep only some layers’ inputs and throw away everything else; during backward, re-run each layer’s forward from its saved input to rebuild the stash just before it’s needed.. Instead of storing every layer’s stash, store only each layer’s input (2 sbh in bf16), and during backward, re-run that one layer’s forward to rebuild its stash right before you need it. Memory drops from L full stashes to L small inputs plus one full stash. You pay with compute: every layer runs forward twice. A forward pass is roughly a third of a training step’s math (backward is about twice the forward: one matmul for the input’s gradient and one for the weight’s, against forward’s one), so the extra forward adds about 33% more compute. Measured slowdowns are usually 20 to 35%. In Hugging Face it’s model.gradient_checkpointing_enable(); in plain PyTorch it’s torch.utils.checkpoint.checkpoint(layer, x, use_reentrant=False). Chen and colleagues’ 2016 paper showed you can go further, checkpointing every √L layers for √L memory, but per-layer is what everyone uses.

Optimizer states: 8-bit optimizers. Store m and v in 8 bits instead of 32. Done naively that destroys them, because a single scale for a whole tensor can’t represent both the huge and tiny values. Tim Dettmers’ bitsandbytes stores them in block-wise quantizationSplitting a tensor into small blocks (2,048 values in bitsandbytes’ optimizers) and quantizing each block with its own scale, so one outlier only hurts its own block., with a non-uniform 8-bit code that spends more of its 256 values near zero. Each value is turned back into fp32 just for the update and re-quantized after. m and v go from 8 bytes to 2 bytes per parameter, the paper reports no loss in quality on the tasks it tested, and it’s a one-line change: bnb.optim.AdamW8bit(model.parameters(), lr=...).

Optimizer states: Adafactor. Shazeer and Stern’s 2018 trick for v: for a weight matrix with n rows and m columns, don’t store n × m second moments. Store a running average for each row (n numbers) and each column (m numbers), and estimate any entry as row × column ÷ total. A 4,096 × 4,096 matrix needs 8,192 numbers instead of 16.8 million. By default it also drops m entirely. The optimizer states become a rounding error: in my check on a small GPT-2, torch.optim.Adafactor’s states were 0.2% of the weights. The trade is that it’s fussier to tune than Adam, and it’s most popular for T5-style models.

Weights and gradients: precision. Mixed precision makes the activations 2 bytes instead of 4, which often matters more than anything it does to the model states. Pure bf16 halves the model states too, at the price of the vanishing-update problem.

Everything: shard across GPUs (ZeRO, FSDP). With N GPUs doing plain data parallelismEvery GPU holds a full copy of the model, processes a different slice of the batch, and the GPUs average their gradients before each optimizer step., each GPU holds a full copy of all 16 bytes per parameter. That’s N copies of the same optimizer states, for no reason except convenience. ZeRO removes the redundancy in three stages: stage 1 splits the optimizer states (each GPU updates only its 1/N slice of the parameters), stage 2 also splits the gradients, and stage 3 splits the weights themselves, fetching each layer’s weights from the other GPUs just before it runs and dropping them right after. PyTorch’s version is FSDPFully Sharded Data Parallel: PyTorch’s implementation of ZeRO stage 3 style sharding. Each GPU stores 1/N of the parameters, gradients and optimizer states, and gathers full layers on the fly.. The distributed training lesson builds it; here is just the memory picture.

FIG_005[ ZERO STAGES ON 4 GPUS ]
NO SHARDING16ΨZERO-1: OPT4Ψ + 12Ψ/NZERO-2: +GRADS2Ψ + 14Ψ/NZERO-3: +PARAMS16Ψ/NPARAMS 2ΨGRADS 2ΨMASTER + M + V 12Ψ · 4 GPUS EACH
Per-GPU model states with mixed-precision Adam (Ψ is the parameter count, so 2Ψ means 2 bytes per parameter). Each stage removes one more redundant copy. Stage 3 gets per-GPU memory to 16Ψ/N, at the cost of moving weights between GPUs every step. Activations are not shown; sharding doesn't touch them.

The ZeRO paper’s own example: a 7.5B model on 64 GPUs needs 120 GB per GPU of model states with plain data parallelism, 31.4 GB with stage 1, 16.6 GB with stage 2, and 1.9 GB with stage 3.

Everything: CPU offload. ZeRO-Offload and FSDP’s CPU offload keep optimizer states (and optionally weights) in the CPU’s RAM and run the Adam update on the CPU. On a normal server that’s a real win: an H100 has 80 GB, the box around it might have 1 to 2 TB of RAM. On the Spark it buys you nothing. As you saw in the virtual memory lesson, the Spark’s CPU and GPU share the same 128 GB. Moving a tensor “to the CPU” moves it to a different label on the same chips. The only offload that adds capacity on the Spark is to the SSD (DeepSpeed’s ZeRO-Infinity can offload to NVMe), and an SSD is hundreds of times slower than memory, as the latency lesson taught you to expect.

Put it together

Now play with every lever at once. The bar is the peak of one training step. The black lines are an H100 (sold as 80 GB; nvidia-smi reports about 79.6 GiB) and the Spark’s 128 GiB; the dashed line is what’s left on the Spark after the operating system, assumed here to take 10 GiB until you measure it.

FIG_006[ TRAINING MEMORY CALCULATOR ]
Model
Optimizer
Precision
States / param
-
Model states
-
Activations
-
Total peak
-

Peak memory for one training step, computed from the formulas in this lesson. Model states depend only on the model, optimizer and precision; activations scale with batch × sequence. The fit line under the bar solves for the largest batch that fits. Approximate: real numbers run higher (unfused ops, fragmentation, library workspaces).

Try these and watch which bucket moves:

What fits on the Spark

Do the arithmetic once, properly. Assume 118 GiB usable after the OS, and leave about 10 GiB for activations (small batches, checkpointing on). That leaves 108 GiB, about 116 GB, for model states.

RecipeBytes / paramLargest full fine-tune
AdamW, BF16 + FP32 master (or AMP)16about 7B
8-bit AdamW, BF16 + FP32 master10about 11B
8-bit AdamW, pure BF166about 19B
Adafactor, pure BF16≈ 4about 29B

These are upper bounds where “fits” means “runs at all”. Training speed is a separate question: the bandwidth lesson applies here too, because every optimizer step has to read all 16 bytes per parameter at least once (and write most of them back), and the Spark’s memory moves about 273 GB/s (a spec-sheet number). For a 7B model that’s at least 112 GB of traffic per optimizer step, 0.4 seconds or more before any of the forward and backward math.

LoRA changes the question. LoRALow-Rank Adaptation (Hu et al., 2021). Freeze the model’s weights and train small extra matrices next to some of them: a d × r and an r × k matrix whose product is added to the frozen d × k weight. Only the small matrices get gradients and optimizer states. freezes the base model and trains small adapter matrices. The frozen weights need no gradients, no master copy and no optimizer states, so they cost just 2 bytes per parameter in bf16. The adapters get the full 16 bytes, but they’re tiny: rank 16 on every linear layer of Llama 3.1 8B is about 42 million parameters (0.5% of the model), 0.7 GB with Adam. So LoRA on 8B is about 16 GB of frozen weights plus 0.7 GB, plus activations.

And the activations don’t go away. Even though the base weights are frozen, the gradient still has to flow backward through every frozen layer to reach the adapters in earlier layers, and flowing backward through a layer needs its saved input. (You don’t need the dW half of the linear layer’s backward, so a bit less is saved, but most of the stash stays.) Checkpointing helps LoRA exactly as much as it helps full training.

So on the Spark: LoRA in bf16 reaches roughly 40-50B models (2 bytes per parameter into ~100 GiB). With QLoRALoRA on top of a base model stored in 4-bit (Dettmers et al., 2023). The frozen weights are de-quantized to bf16 on the fly for each layer; adapters train in bf16. the base model is 4-bit, a little over 0.5 bytes per parameter with scales, so a 70B model’s frozen weights are roughly 35 to 40 GB and fit with plenty of room for activations. Full fine-tuning tops out around 7B; adapter fine-tuning reaches 70B. That is a 10× difference from the same 128 GB, and it is the main reason LoRA is the default way to fine-tune on one machine. The LoRA lesson builds it.

Build It

Everything runs on the Spark, in a folder like ~/workbench/trainmem/. The model is GPT-2 at four sizes, built from a config with random weights. Memory doesn’t care whether the weights are trained, so there is nothing to download and no gated model to request.

Set up the environment and a step harnessAgent: fine

On the Spark (ARM64 + CUDA), the least painful route is NVIDIA’s PyTorch container from NGC, which ships a matching CUDA build; check NGC for the current tag. Inside it:

pip install transformers bitsandbytes
python -c "import torch, bitsandbytes; print(torch.__version__, torch.cuda.is_available(), bitsandbytes.__version__)"

If bitsandbytes has no wheel for your ARM64 + CUDA combination, either build it from source (its docs cover it) or use torchao’s 8-bit AdamW instead (check the torchao docs for the current import path; it has moved between releases).

The harness: build a model, make random token batches, run steps, and report peak memory and time per step. This is boilerplate; let an agent write it if you like.

# harness.py
import time, torch
from transformers import GPT2Config, GPT2LMHeadModel

SIZES = {  # n_embd, n_layer, n_head
    "124m": (768, 12, 12), "355m": (1024, 24, 16),
    "774m": (1280, 36, 20), "1558m": (1600, 48, 25),
}
GiB = 2**30

def build(size: str, ckpt: bool = False, n_positions: int = 1024):
    e, l, h = SIZES[size]
    cfg = GPT2Config(n_embd=e, n_layer=l, n_head=h, n_positions=n_positions,
                     resid_pdrop=0.0, embd_pdrop=0.0, attn_pdrop=0.0)
    model = GPT2LMHeadModel._from_config(cfg, attn_implementation="sdpa").cuda()
    if ckpt:
        model.gradient_checkpointing_enable()
    return model

def batch(b: int, s: int):
    x = torch.randint(0, 50257, (b, s), device="cuda")
    return x, x  # inputs, labels (the model shifts them)

def mem():
    return torch.cuda.memory_allocated() / GiB

def peak():
    return torch.cuda.max_memory_allocated() / GiB

GPT-2’s position table has n_positions rows (1,024 by default), so pass a bigger value for longer sequences; it adds a few million parameters. Note resid_pdrop=0.0 and friends: dropout off, like most fine-tuning, and it lets SDPA use a fused kernel. Keep model.train() on (it is the default after construction) so the step is a real training step.

Predict before you measureHands only

Write a function that predicts the peak, bucket by bucket, using this lesson’s formulas. Do the arithmetic in bytes and convert at the end.

# predict.py
def predict(params: int, h: int, L: int, V: int, b: int, s: int,
            recipe: str = "amp", optimizer: str = "adamw",
            ckpt: bool = False) -> dict[str, float]:
    """Return GiB for: weights, grads, optimizer, cast_cache, activations, logits, total.

    recipe: "amp" (fp32 params + bf16 autocast) is what your harness runs.
    optimizer: "adamw" | "adamw8bit" | "sgd"
    Activations: 34 * s * b * h bytes per layer (bf16, fused attention),
    or, with ckpt, 2 * s * b * h per layer plus one full layer.
    Logits: about 10 bytes per logit, b * s * V of them.
    """
    ...

Count GPT-2’s parameters with sum(p.numel() for p in model.parameters()) (124,439,808 for the small one; it ties the input and output embeddings, so the vocabulary matrix is counted once). Then fill the Predicted column of the Measure table for GPT-2 774M at batch 8, sequence 1,024 before running anything.

Measure one step, phase by phaseHands only

Run five steps of AdamW with AMP, and inside each, record memory at four moments: after forward (loss computed), after backward, after optimizer.step(), after zero_grad(). Also record the peak for the step and the time per step.

# step.py
from harness import *

def step(model, opt, x, y) -> dict[str, float]:
    torch.cuda.reset_peak_memory_stats()
    torch.cuda.synchronize(); t0 = time.perf_counter()
    with torch.autocast("cuda", dtype=torch.bfloat16):
        loss = ...
    after_fwd = mem()
    ...  # backward, record; optimizer step, record; zero_grad(set_to_none=True), record
    torch.cuda.synchronize()
    return {"fwd": after_fwd, ..., "peak": peak(), "sec": time.perf_counter() - t0}

Print one row per step. Things to look for, and to explain before reading on: steps 1 and 2 have different peaks. The “after forward” number minus the “after zero_grad” number is your activations. The “after backward” number is lower than “after forward”.

Hints: torch.cuda.synchronize() before reading the clock, because CUDA calls return before the GPU finishes. On the Spark, run with a fresh process per configuration so the caching allocator starts empty.

Take a memory snapshot and read itAgent: explain only

Numbers tell you the peak. A snapshot shows you the whole story, every allocation with the Python stack that made it. Wrap three steps:

torch.cuda.memory._record_memory_history(max_entries=100_000)
for _ in range(3):
    step(model, opt, x, y)
torch.cuda.memory._dump_snapshot("snapshot.pickle")
torch.cuda.memory._record_memory_history(enabled=None)  # stop recording

Copy snapshot.pickle to your Mac and drag it onto pytorch.org/memory_viz. It runs entirely in your browser. The “Active Memory Timeline” view is Figure 004 drawn from real data. (The underscore means this API is semi-private; check the PyTorch “Understanding CUDA Memory Usage” page if the names have changed.)

Find, and point at with your cursor:

  • The flat band at the bottom: the fp32 weights. And where the Adam states appear during step 1.
  • The staircase rising through forward, and the peak at the loss.
  • The logits: the single biggest block near the peak. Hover it to see the stack trace into the loss.
  • The bf16 cast cache from autocast: one medium block per weight matrix, created in forward. They outlive the autocast region, because the autograd graph saved them for backward, and are freed during backward.

An agent can explain what a block or a stack trace means; finding them is your job.

Count what autograd savesHands only

Measure the activation bucket directly instead of inferring it. saved_tensors_hooks(pack, unpack), from torch.autograd.graph, calls pack(t) for every tensor any operation saves for backward. Use it to sum the bytes.

def saved_bytes(model, x, y) -> int:
    seen: dict[int, int] = {}
    def pack(t):
        ...  # key by t.untyped_storage().data_ptr(), value storage nbytes; return t
    with torch.autograd.graph.saved_tensors_hooks(pack, lambda t: t):
        with torch.autocast("cuda", dtype=torch.bfloat16):
            model(x, labels=y)
    ...  # drop the parameters' own storages (saved weights are not activations)

Key by storage, not tensor: several saved tensors are often views of one storage, and you want to count the bytes once. Run it with SDPA and with attn_implementation="eager", at s = 512, 1,024 and 2,048. Which one grows like s, and which like s²? Compare with 34 × s × b × h × L plus the logits.

Apply one lever at a timeHands only

Starting from the baseline (GPT-2 774M, AMP, AdamW, b = 8, s = 1,024), change exactly one thing per run and record peak memory, seconds per step and tokens per second (b × s ÷ seconds per optimizer step) in the Measure table:

  1. Checkpointing on.
  2. bnb.optim.AdamW8bit instead of torch.optim.AdamW.
  3. Gradient accumulation: micro-batch 2, four accumulation steps, so the same 8 sequences per optimizer step. Divide each micro-batch’s loss by 4.
  4. All three together.

For accumulation, the shape is:

for i, (x, y) in enumerate(micro_batches):
    with torch.autocast("cuda", dtype=torch.bfloat16):
        loss = model(x, labels=y).loss / ACCUM
    loss.backward()            # adds into .grad
    if (i + 1) % ACCUM == 0:
        opt.step(); opt.zero_grad(set_to_none=True)

Time a whole optimizer step (all four micro-batches), not one micro-batch, or the comparison is unfair.

Find the largest batch before OOM, each wayAgent: review after

For each of the five configurations, find the largest batch at s = 1,024 that completes three steps. Double the batch until it fails, then binary-search between the last success and the first failure.

Run each trial in a subprocess (subprocess.run([sys.executable, "trial.py", str(b), config])) and treat a non-zero exit as failure. Two reasons: after an OOM exception the allocator is left in a messy state, and on the Spark the kernel’s OOM killer may end the process instead of raising a Python exception (see the memory math lesson). Watch free -h in another terminal during the biggest runs.

Then have an agent review the search code for unfairness: trials that share a process, a batch that passes step 1 but would fail at step 2, timing that includes model construction.

Done when
  • predict.py produces a bucket-by-bucket prediction, and you wrote predictions in the table before the first run.
  • You can show, in your own snapshot, the weights, the Adam states appearing in step 1, the forward staircase, the logits block and the peak.
  • The Measure table is filled in for all five configurations, including max batch.
  • You can explain every gap of more than 20% between predicted and measured.

Measure

Fill this in for GPT-2 774M, s = 1,024, on the Spark. Use steps 3 to 5 for timing and peak (step 1 under-counts Adam; the first steps also pay one-off setup costs).

ConfigPredicted peakMeasured peakSec / stepTokens / sMax batch
Baseline: AMP + AdamW, b=8
+ checkpointing
+ 8-bit AdamW
grad accum 4 × b=2
all three

What the numbers should look like, and why. My predictions from the formulas, which you should check yourself:

Baseline. Model states 16 bytes × 774M = 11.5 GiB, plus 1.4 GiB of autocast cast cache, plus activations 34 × 1,024 × 8 × 1,280 × 36 bytes ≈ 12 GiB, plus logits 8 × 1,024 × 50,257 × 10 bytes ≈ 3.8 GiB: about 29 GiB. Expect the measurement to come in higher, perhaps 20 to 40%, because HF’s GPT-2 GELU is unfused (Task 4 shows you exactly how much). If your measured activations are far above that, check that SDPA really used a fused kernel; if dropout is on, it may not.

Step 1 vs step 2. The step 2 peak should be higher than step 1 by about 8 bytes × 774M = 5.8 GiB, Adam’s m and v, created in step 1’s optimizer.step() after the peak had already passed.

Checkpointing. Activations drop from ~12 GiB to about 1 GiB (36 layer inputs plus one rebuilt layer), so the peak falls by about 11 GiB to roughly 18 GiB, now dominated by model states and logits. Seconds per step should go up by 20 to 35%. If it went up much less, the step was probably not compute-bound to begin with.

8-bit AdamW. m and v go from 8 bytes to about 2 per parameter: about 4.3 GiB saved, no matter the batch. Step time roughly unchanged; the optimizer step is a small part of the total for a model this size.

Gradient accumulation. Activations and logits shrink by 4× (about 4 GiB instead of 16), model states unchanged. Seconds per optimizer step a little higher than baseline, because four small passes keep the GPU less busy than one big one. This is the only lever that doesn’t change the maths at all, only the schedule.

Max batch. Solve fixed + b × per_sequence ≤ 118 GiB. Baseline: about 14.5 GiB fixed and about 2 GiB per sequence, so roughly 50. Checkpointing cuts per-sequence cost to about 0.6 GiB, almost all of it logits, so roughly 170. 8-bit Adam only moves the fixed part, so it adds just two or three sequences. Your measured numbers will be lower; the ratios between configurations are what to check.

Then check the scaling, the interview question. Repeat the baseline at s = 512, 1,024 and 2,048 (with n_positions=2048). With SDPA, activations should double each time. Then switch to eager attention and repeat: they should grow faster than double, because of the s² score matrices.

Break It

1. Turn off fused attention at long sequences. Use attn_implementation="eager" at s = 4,096 with GPT-2 124M, batch 4 (build it with n_positions=4096). Predict the score-matrix bytes first: 5 × 12 heads × 4,096² × 4 × 12 layers. That is about 48 GB for score matrices alone, for the smallest GPT-2. Watch it fail where SDPA ran comfortably. Lesson: in training the s² term isn’t a spike you pass through, it’s a stash you keep until backward.

2. Forget zero_grad. Comment it out and run five steps. Memory doesn’t keep growing (gradients accumulate into the same .grad buffers), but the loss goes haywire because every step now uses the sum of all previous gradients. Then put it back as zero_grad(set_to_none=False), which fills the buffers with zeros instead of freeing them. Training is correct again, but compare the peak with the default: it’s higher by one full gradient copy (4 bytes × 774M ≈ 2.9 GiB under AMP), because the gradients now sit in memory through the next forward pass, right when activations peak. Find the difference in your snapshot. That’s why PyTorch made set_to_none=True the default.

3. Keep the graph alive by accident. Add losses.append(loss) (not loss.item()) inside the loop and run fifty steps. Memory climbs every step: each stored loss tensor holds a reference to its whole autograd graph, and with it every saved activation of that step. This is the most common real-world “memory leak” in training loops. Fix it with loss.item() or loss.detach(), and see the climb stop.

4. Train in pure bf16 without a master copy. Convert the model with model.to(torch.bfloat16), use plain AdamW, and set a small learning rate like 1e-5. Train on a tiny fixed batch you can overfit. Compare the loss curve to the AMP run. With a small learning rate, many updates round to nothing in bf16 and the loss stalls or falls far more slowly. Memory for model states halves; this is the price.

Explain It Back

Answer out loud, in plain words, as if in an interview. Then check.

1. Why does training a 1B model need about 16 GB before activations, when serving it needs 2?

Serving needs only the weights, 2 bytes each in bf16. Training also keeps a gradient for every weight (2 bytes in bf16), a full-precision fp32 copy of the weights so that tiny updates don’t round away in bf16 (4 bytes), and Adam’s two running averages, the smoothed gradient and the smoothed squared gradient, each in fp32 (8 bytes). 2 + 2 + 4 + 8 = 16 bytes per parameter, so 16 GB per billion parameters. Plain fp32 training and PyTorch AMP land on 16 too, just split differently (4 + 4 + 8). Activations come on top of that and depend on batch and sequence length.

2. Why does the backward pass need the activations from the forward pass, and what does checkpointing trade?

Backward applies the chain rule layer by layer from the loss backwards, and each layer’s local derivative depends on what its input was in the forward pass. A linear layer’s weight gradient is its input transposed times the output gradient; GELU’s slope depends on its input; softmax’s gradient is written in its output. So every operation saves those tensors until backward reaches it, and at the turn from forward to backward they’re all alive at once. Checkpointing keeps only each layer’s input and re-runs that layer’s forward during backward to rebuild the rest. Memory goes from L full stashes to L inputs plus one stash; compute goes up by one extra forward pass, about 33%, since backward is about twice the forward.

3. How does activation memory scale? You double the sequence length; what happens?

Per layer it’s about s × b × h × 34 bytes for a GPT-style block in bf16, times L layers, plus the logits at b × s × vocabulary × ~10 bytes. That’s linear in batch, sequence, hidden size and depth. Doubling the sequence doubles it, if attention is fused. With textbook attention, there’s an extra 5 × heads × s² × b per layer for the stored score matrices, so doubling the sequence roughly quadruples that part, and at long contexts it dominates everything. Model states don’t change at all with sequence length. So at small scale or long context, activations are the bill; for big models at modest context, model states are.

4. What’s the biggest model you can fully fine-tune on the Spark, versus with LoRA? Why doesn’t CPU offload help there?

About 118 GiB usable after the OS, minus around 10 for activations with checkpointing and small batches. At 16 bytes per parameter that’s about 7B. 8-bit Adam (10 bytes) gets about 11B; pure bf16 with 8-bit states (6 bytes) about 19B. LoRA freezes the base weights, so they cost only 2 bytes each (no gradients, no master copy, no optimizer states); only the small adapters pay 16 bytes. That’s roughly 40-50B in bf16, and with QLoRA’s 4-bit base about 70B with room to spare. Activations still apply in LoRA, since gradients must flow back through frozen layers. CPU offload helps on servers because the CPU has its own, much bigger RAM. On the Spark the CPU and GPU share one 128 GB pool, so “offloading to CPU” moves bytes to the same chips. Only offloading to the SSD adds capacity, and it is very slow.

5. You have N GPUs and a model whose states don’t fit on one. How does memory per GPU scale with N under each ZeRO stage?

With Ψ parameters and mixed-precision Adam: plain data parallel keeps all 16Ψ bytes on every GPU, so adding GPUs doesn’t help memory at all. Stage 1 shards the 12Ψ of optimizer states (master copy plus m and v): 4Ψ + 12Ψ/N. Stage 2 also shards gradients: 2Ψ + 14Ψ/N. Stage 3 (FSDP) shards the weights too: 16Ψ/N, so it scales down without limit, at the cost of gathering each layer’s weights from the other GPUs just before using it, every forward and every backward. Activations aren’t sharded by any of these; for that you need tensor, sequence or pipeline parallelism, or checkpointing.

Go Deeper

Connections

This lesson extends How much memory does a model need?: same bytes-per-parameter thinking, same allocator, same unified-memory caveats, with three new buckets that inference gets to skip. The s² activation term is the training half of FlashAttention, and the fp32 master copy is number formats applied to a real failure.

It unlocks the next three lessons in this track. In Train a small LM, end to end you’ll pick batch size, sequence length and checkpointing for a ~100M model on the Spark using exactly this calculator. LoRA builds the adapter trick that turns “7B, at most” into “70B, comfortably”. And distributed training builds the ZeRO picture from Figure 005: when one machine’s 128 GB really isn’t enough, you split the 16 bytes across many.