Module 27: Training at scale 35 min9 min quick readDeep learning
Gradient checkpointing
Trade compute for memory by storing only some activations and recomputing the rest during backward; size activation memory and pick a checkpointing policy.
Before this: Backpropagation, The transformer architecture, Gradient clipping and mixed precision
Quick read: the intuition, interactive visualization, summary, and interview questions. The math, implementation, engineering, and knowledge-check sections are hidden.
Mental model. Backpropagation is like retracing a hike in reverse: you need to know where you were at every point. You can photograph every step on the way up (lots of memory), or photograph only a few landmarks and re-walk each short stretch from its landmark when you need it (more walking, far fewer photos).
You will learn to
- Explain which tensors the backward pass needs and why they make activation memory grow with layers and tokens.
- Implement checkpointing: keep segment inputs in the forward pass, re-run each segment's forward during backward, and get identical gradients.
- Compute activation memory for a transformer with and without checkpointing, and the extra compute it costs.
- Choose between full, selective, and no checkpointing, or offloading, for a given memory budget.
Why it matters. Weights, gradients, and optimizer state are fixed per parameter, but activations grow with batch size and sequence length. Long-context fine-tuning hits the activation wall first. Gradient checkpointing is the standard fix, it is on by default in most fine-tuning recipes, and "what does it cost?" is a classic interview follow-up.
1. Intuition
What backward needs. To compute the gradient of a linear layer with respect to , backward needs the input . To push the gradient through a ReLU it needs to know which inputs were positive. So the forward pass keeps, for every layer, the intermediate tensors that backward will read. Autograd does this for you.
Why it adds up. Those saved tensors scale with tokens times hidden size, for every layer. A 7B-style model with 32 layers at 2,048 tokens keeps about 9 GB of them. At 8,192 tokens it is about 37 GB, more than the bf16 weights.
The trade. Don't keep everything. Keep only the input to each segment of layers (a checkpoint). During backward, when you reach a segment, run its forward again from the saved input to rebuild the intermediates, then do its backward and throw them away. Only one segment's intermediates exist at a time.
The price. Each layer's forward runs twice. A backward pass costs about twice a forward, so a step goes from units to units: about 33% more compute. The gradients are exactly the same.
Smarter options.
- Selective recomputation keeps the cheap-to-store, expensive-to-recompute tensors and recomputes only the ones that are big but cheap to redo (classically the attention scores). Korthikanti et al. (2022) report it recovers most of the memory for an overhead of a few percent.
- FlashAttention already recomputes attention scores tile by tile in its backward, so the score matrix is never stored even without checkpointing.
- Offloading copies activations to CPU memory and brings them back for backward, trading PCIe bandwidth for recompute.
2. Visualization

Synthetic 7B-style shape (32 layers, hidden size 4096). Estimates from the rule of thumb in section 3, the same one the LoRA lab uses; real numbers depend on the kernels.
Try it (predict first, then run code/27-training-at-scale/gradient_checkpointing.py and edit it):
- In the toy network, predict the peak floats for
k = 8(one segment covering all 8 layers) compared with full storage. Then check: why is it slightly higher? - Change
34to60inactivation_bytes(a model without fused attention kernels). Predict whetherk = 1is still the best segment size. - Set
Lt = 80andD = 8192(a 70B-style shape). Predict the no-checkpointing activation memory at 2,048 tokens before running. - Count the recomputed layer forwards for every
k. Predict why it is always 8, even though peak memory changes.
3. The math
Symbols
| Symbol | Meaning | Value in the example |
|---|---|---|
| number of layers | 32 | |
| tokens per micro-batch (batch times sequence length) | 2,048 | |
| hidden size | 4,096 | |
| bytes of saved activations per token per hidden unit per layer | 34 (16-bit, fused attention) | |
| bytes per value of a stored layer input | 2 (bf16) | |
| layers per checkpoint segment | 1 | |
| compute of one forward pass | 1 unit |
Memory
Without checkpointing every layer keeps its activations:
With a checkpoint every layers, we keep segment inputs of size , and during backward one segment's full activations, :
The rule of thumb (Korthikanti et al. 2022, for a standard transformer layer with fused attention, ignoring the term) is rough; it varies with the MLP width and the kernels. The point is the ratio: a layer's input is bytes per value, its full saved state about 17 times more.
Minimizing over k. If and were equal, the two terms would balance at : the classic result of Chen et al. (2016). Here is 17 times , so the second term dominates and the best is small. For : gives units of bytes, gives , gives . Frameworks checkpoint every transformer block () for this reason.
Worked example: 7B-style model, 2,048 tokens
- .
- No checkpointing: bytes GB.
- Checkpointing every layer, stored inputs: bytes.
- One layer's full activations during its backward: bytes.
- Total: bytes GB, about 11 times less.
This 0.822 GB is the activation term in the fine-tuning lesson's 108.63 GB full fine-tuning estimate.
At 8,192 tokens everything is 4 times bigger: GB without checkpointing, GB with it.
Compute
Per layer, forward costs and backward about (one matrix multiply for the input gradient, one for the weight gradient). With checkpointing, every layer's forward runs once more:
This is the same whatever is: every layer except possibly the last gets recomputed once. Segment size changes memory, not compute. (Some implementations skip recomputing the final segment, saving of the overhead.)
4. Implementation
Build it from scratch
A residual MLP block stands in for a transformer layer. Its forward returns the output plus the cache backward needs: the input x, the pre-activation h, and the post-ReLU a.
def layer_forward(x, w1, w2):
"""y = x + relu(x W1) W2 (a residual MLP block). Returns y and what backward needs."""
h = x @ w1
a = np.maximum(h, 0)
return x + a @ w2, (x, h, a)
def layer_backward(gy, cache, w1, w2):
x, h, a = cache
gw2 = a.T @ gy
gh = (gy @ w2.T) * (h > 0)
gw1 = x.T @ gh
gx = gy + gh @ w1.T # residual path + MLP path
return gx, (gw1, gw2)Predict: which of x, h, a could backward rebuild from the others without re-running a matrix multiply?
The ordinary step keeps every layer's cache alive until backward reaches it:
def backward_full(x, params, gout):
caches, stored = [], 0
for w1, w2 in params:
x, cache = layer_forward(x, w1, w2)
caches.append(cache)
stored += sum(t.size for t in cache) # every intermediate stays alive
grads = [None] * len(params)
for i in reversed(range(len(params))):
gout, grads[i] = layer_backward(gout, caches[i], *params[i])
return grads, stored, 0Predict: doubling the batch doubles stored. What does doubling the number of layers do?
The checkpointed step keeps only the input of every k-th layer. In backward, it walks segments from last to first, re-runs each segment's forward from its saved input, and back-propagates through it.
def backward_checkpointed(x, params, gout, k):
L = len(params)
boundaries = {} # segment start index -> its input
for i, (w1, w2) in enumerate(params):
if i % k == 0:
boundaries[i] = x # the only tensors kept from the forward pass
x, _ = layer_forward(x, w1, w2) # cache thrown away
grads, recomputed, peak = [None] * L, 0, 0
for start in reversed(range(0, L, k)):
end = min(start + k, L)
h, caches = boundaries[start], []
for i in range(start, end): # re-run this segment's forward
h, cache = layer_forward(h, *params[i])
caches.append(cache)
recomputed += 1
live = sum(b.size for b in boundaries.values()) + sum(t.size for c in caches for t in c)
peak = max(peak, live)
for j in reversed(range(start, end)):
gout, grads[j] = layer_backward(gout, caches[j - start], *params[j])
return grads, peak, recomputedPredict: the recomputed forward uses exactly the same weights and inputs. Will the gradients match the full version to the last bit, or only approximately?
Use the library
In PyTorch, wrap each block's forward in torch.utils.checkpoint.checkpoint:
from torch.utils.checkpoint import checkpoint
class Model(nn.Module):
def forward(self, x):
for block in self.blocks:
x = checkpoint(block, x, use_reentrant=False) if self.training else block(x)
return xHugging Face models expose the same thing as model.gradient_checkpointing_enable() (or gradient_checkpointing=True in TrainingArguments). Megatron-Core offers full and selective recomputation, and PyTorch's FSDP2 and DeepSpeed can offload activations to CPU instead. use_reentrant=False is the recommended implementation; it supports keyword arguments and inputs that don't require gradients.
Watch out for randomness: dropout in the recomputed forward must use the same random mask. PyTorch's checkpoint saves and restores the RNG state by default (preserve_rng_state=True).
Run it
uv run code/27-training-at-scale/gradient_checkpointing.pyKey lines:
full storage: 4,608 floats kept, 0 layers recomputed
k = 1: peak 1,088 floats, 8 layer forwards recomputed, max grad diff 0.0e+00
k = 2: peak 1,408 floats, 8 layer forwards recomputed, max grad diff 0.0e+00
k = 8: peak 4,672 floats, 8 layer forwards recomputed, max grad diff 0.0e+00
no checkpointing: 1 + 2 = 3 units; checkpointing: 1 + 1 + 2 = 4 units -> +33.3% per step
no checkpointing: 32 x 34 x 2048 x 4096 = 9.127 GB
checkpoint every 1 layers: 0.822 GB
checkpoint every 8 layers: 2.349 GB
at 8,192 tokens: none 36.51 GB, k = 1 3.29 GBThe gradient difference is exactly zero: recomputation is deterministic. With k = 8 (one segment) the peak is slightly above full storage because the segment's input is stored on top of all the caches: checkpointing everything into one segment is all cost and no saving.
5. Engineering
When to turn it on. When activations, not weights or optimizer state, are what doesn't fit: long sequences, large micro-batches, or after you have already sharded the weights and optimizer (see ZeRO and FSDP). Most fine-tuning recipes turn it on by default because sequence length is the usual limit.
Spend the memory. Checkpointing frees memory; use it for a bigger micro-batch or a longer context. A bigger micro-batch can win back part of the 33%, because larger matrix multiplies run the GPU more efficiently.
Selective first. If your framework supports selective recomputation, try it before full checkpointing: most of the memory saving for a few percent of compute. With FlashAttention the attention scores are already not stored, so the remaining activations are mostly linear-layer inputs and MLP intermediates.
Offload when bandwidth is idle. Activation offload to CPU costs no extra FLOPs but needs PCIe transfers to overlap with compute. It helps most when the GPU is memory-bound but the host link is underused.
Interactions. With pipeline parallelism, each stage stores activations for every micro-batch in flight, so checkpointing per stage is common. With tensor parallelism, recompute repeats the layer's communication too. With FP8 training, recomputation must reuse the same scaling factors as the original forward.
Common mistakes
- Expecting checkpointing to reduce weight, gradient, or optimizer memory: it only touches activations.
- Checkpointing the whole model as one segment: you store the input plus everything else during backward, so you save nothing.
- Forgetting RNG state: if dropout masks differ between the forward and the recomputation, gradients are wrong. Keep
preserve_rng_state=True. - Leaving checkpointing on at inference or evaluation: there is no backward pass, so it only costs time. Guard it with
self.training. - Assuming the cost is free: plan for about a third more compute per step with full recomputation.
6. Knowledge check
Quiz: Gradient checkpointing
What does gradient checkpointing reduce?
Practice exercise. A synthetic 13B-style model has layers and . Using and , compute activation memory at 4,096 tokens with no checkpointing and with checkpointing every layer.
Solution
. No checkpointing: bytes GB. Every layer: bytes GB. That is times less.
Implementation challenge. Add an offload variant to the script: in the forward pass, put each layer's cache in a separate list called cpu (standing in for host memory) and count the floats "transferred". In backward, take caches back from cpu. Report peak GPU floats and floats transferred, and compare with k = 1 checkpointing.
Solution
def backward_offload(x, params, gout):
cpu, moved = [], 0
for w1, w2 in params:
x, cache = layer_forward(x, w1, w2)
cpu.append(cache) # "copy to host"
moved += sum(t.size for t in cache)
grads = [None] * len(params)
for i in reversed(range(len(params))):
cache = cpu.pop() # "copy back"
moved += sum(t.size for t in cache)
gout, grads[i] = layer_backward(gout, cache, *params[i])
return grads, movedGPU peak is about one layer's cache (plus the in-flight tensor), lower than checkpointing, and no forward is recomputed. The cost is moving every cache twice: floats here. In practice it pays off only if those copies overlap with compute.
Summary
- Backward needs each layer's saved intermediates, so activation memory scales with layers × tokens × hidden size: about 9.13 GB for a 7B-style model at 2,048 tokens.
- Checkpointing keeps only segment inputs and recomputes each segment's forward during backward; gradients are identical.
- Checkpointing every layer brings that 9.13 GB down to 0.822 GB, for about 33% more compute per step whatever the segment size.
- The rule assumes stored inputs cost as much as full activations; in transformers they are about 17 times cheaper, so per-block checkpointing wins.
- Selective recomputation, FlashAttention, and offloading get most of the saving for less compute.
Next: Collective operations from scratch
Related: Backpropagation · Fine-tuning in practice · ZeRO and FSDP
Go deeper, code-first: AI Engineering from Scratch, gradient checkpointing.
Parts of this lesson are adapted from AI Engineering from Scratch by Rohit Ghumare (MIT license, notice): Gradient Checkpointing and Activation Recomputation.
Interview angle
What does gradient checkpointing save, and what does it cost?
It saves activation memory only: the intermediates the forward pass keeps for backward. Instead of keeping them for every layer, you keep the input to each checkpointed block and, during backward, re-run that block's forward to rebuild them. For a 7B-style model at 2,048 tokens that takes activations from about 9.1 GB to about 0.8 GB, and the saving grows linearly with sequence length. The cost is compute: each block's forward runs twice. With backward costing about two forwards, a step goes from 3 units to 4, roughly 33% more, regardless of segment size. Weights, gradients and optimizer state are untouched, so if those don't fit, you need sharding or LoRA instead. Gradients are bit-for-bit identical as long as recomputation is deterministic, which is why PyTorch restores the RNG state for dropout.
The classic advice is a checkpoint every square root of L layers. Why do frameworks checkpoint every block instead?
The square-root rule minimizes memory when a stored checkpoint costs as much as one layer's full activations: you keep L/k checkpoints plus k layers during a segment's backward, and L/k + k is smallest at k = sqrt(L). In a transformer the two are very different. A block's input is one tensor of tokens × hidden size, about 2 bytes per value, while everything the block saves for backward (attention inputs, projections, MLP intermediates) is roughly 34 bytes per token per hidden unit. With stored inputs about 17 times cheaper, the k-layer term dominates and the optimum is k = 1. For 32 layers, k = 1 needs 98 units, k = 6 needs 216. Since compute is the same for any k (every block is recomputed once), checkpointing every block is the natural default.
You turned on checkpointing and loss curves no longer match the run without it. What do you check?
Checkpointing should give identical gradients, so a mismatch means the recomputed forward differs from the original. The most common cause is randomness: dropout or stochastic depth draws a different mask on recompute. I check that preserve_rng_state is on, or that no custom code reseeds the generator. Next, anything stateful in the forward: batch-norm running statistics updated twice, a counter or cache incremented in forward, or a module that behaves differently on the second call. Third, non-deterministic kernels: some fused or atomic GPU operations give slightly different results each run, which is usually harmless noise, not a trend. To isolate it, I run one step both ways with dropout off and compare gradients tensor by tensor; the first block with a mismatch is the culprit. With the reentrant implementation, also check that inputs require gradients, or some parameters silently get none.
Quick calculation: how much activation memory does a 70B-style model need at 2,048 tokens?
Use the rule of thumb of about 34 bytes per token per hidden unit per layer in 16-bit with fused attention. A 70B-style model has 80 layers and hidden size 8,192. Tokens times hidden size is 2,048 × 8,192, about 16.8 million. Without checkpointing: 80 × 34 × 16.8 million, about 45.6 GB per micro-batch. With checkpointing every block: stored inputs are 80 × 2 × 16.8 million, about 2.7 GB, plus one block's activations, 34 × 16.8 million, about 0.57 GB, so roughly 3.3 GB. That is about 14 times less, for one extra forward per step. At 8,192 tokens multiply by 4: about 182 GB without, 13 GB with. Either way it is small next to the roughly 1.1 TB of weights, gradients and Adam state, which is why checkpointing is combined with sharding.