A training loop from scratch: AdamW, accumulation, mixed precision, BPE
Before this lesson: lesson 06 (the from-scratch routine,
stable softmax and cross-entropy). Background: Adam and AdamW
and learning-rate schedules and clipping in the
Optimization track, backpropagation (what loss.backward()
computes), and floating point (machine epsilon,
ulp). Time: about 80 minutes, plus the notebook.
Why this matters #
ML engineer and research engineer interviews for LLM roles often include a round like "write the training step", "implement AdamW" or "implement a BPE tokenizer". Stanford CS336, assignment 1, asks for exactly these pieces, and many interviewers know it. The round tests two things: the exact order of the operations, and the numerics behind them. The bugs here are silent. A loss that is not divided by the number of micro-batches, clipping before unscaling, or weight decay inside Adam's gradient all train without an error, but to a different result. The same loop, scaled up with data parallelism, trains LLMs, vision models and scientific foundation models (lesson 12 covers the memory and the parallelism).
1. Predict first #
2. The picture #
2.1 The five-line loop #
One training step estimates the gradient of the mean loss over a batch, then moves the parameters. The simplest loop:
import torch
torch.manual_seed(0)
model = torch.nn.Linear(3, 1)
opt = torch.optim.SGD(model.parameters(), lr=0.1)
X, y = torch.randn(64, 3), torch.randn(64, 1)
for step in range(100):
loss = ((model(X) - y) ** 2).mean() # forward: build the graph, compute the loss
opt.zero_grad(set_to_none=True) # clear old gradients (backward ADDS into .grad)
loss.backward() # gradients of the loss into p.grad
opt.step() # update the parameters from p.grad
print(round(loss.item(), 3)) # 0.766
The key fact for this lesson: backward() adds into p.grad; it does not overwrite it. This
is why zero_grad is needed, and it is also what makes gradient accumulation work. Each of the four
pieces below fixes one practical problem on top of these lines.
2.2 AdamW: the step size comes from the learning rate, not from the gradient #
SGD moves by : a gradient 1000 times larger gives a step 1000 times larger. Adam divides a running mean of the gradient by the square root of a running mean of the squared gradient. If the gradient is multiplied by , both the top and the bottom are multiplied by , so the step stays the same. The step size is about , the learning rate, in every coordinate.
Move the gradient scale from to . The loss is : the same valley, only steeper or flatter. The orange path is SGD with a fixed learning rate: it barely moves for small and explodes for large . The blue path is Adam: it is the same for every . Then turn bias correction off: the first Adam steps become several times too long (3.2 times at step 1).
Weight decay pulls every weight towards zero a little at each step. AdamW applies it as a separate step, , outside Adam's division. Section 3 shows why this matters.
Adam with learning rate (default betas, tiny ) receives the constant gradient at every step. How far does the parameter move per step?
With a constant gradient the bias-corrected averages are exactly and , so the step is . is the SGD step. The factor appears only if you forget the bias correction.
2.3 Gradient accumulation: many small backward passes, one step #
A batch of 512 sequences may not fit in GPU memory. Split it into micro-batches, run
forward and backward on each one, and let p.grad add up the gradients. Then take one optimizer
step. Gradients are linear in the loss, so if each micro-batch loss is divided by , the sum
equals the gradient of the full-batch mean.
For language models there is a trap. The loss is a mean over tokens, and micro-batches can hold different numbers of target tokens (padding, packing, different lengths). Then "the mean of the micro-batch means" gives different tokens different weights.
Change the token counts of the micro-batches. Each box is one target token; its width is the weight that token gets in the loss. The top row is the mean of the micro-batch means; the bottom row is the correct rule, sum of token losses divided by the total number of tokens. With equal counts the two rows agree. With unequal counts, tokens in the small micro-batch get too much weight, and the loss (and its gradient) is wrong.
Two micro-batches hold 2 and 6 target tokens. You average each micro-batch's token losses and then average the two means. What weight does one token of the first micro-batch get?
Mean of means: , so a token of the first micro-batch gets and a token of the second gets . The full-batch token mean gives every token . Fix: sum the token losses and divide by the total token count, 8.
2.4 Mixed precision: 16-bit numbers are coarse #
A 16-bit number has either few mantissa bits (bf16: 8 significant bits) or a small range (fp16: largest value 65504, and tiny numbers become 0). So the matrix products run in 16 bits, but the weights, the optimizer state and long sums stay in fp32.
Set and look at the bf16 row. The gap between neighbouring bf16 numbers near 1 is . An update of to a weight of 1 is less than half that gap, so in bf16 it is rounded away: the weight never changes. This is why the optimizer keeps fp32 "master" weights. Then try 1e−8: fp16 underflows to 0, bf16 does not. (Colours: bf16, fp16, fp32.)
2.5 BPE: merge the most frequent pair, again and again #
A tokenizer turns text into integer ids. Byte-level BPE (byte-pair encoding) starts with the 256 byte values as the vocabulary. It counts every pair of neighbouring tokens in the training text, merges the most frequent pair into one new token, and repeats. Each merge makes the sequences shorter, and shorter sequences make the model cheaper to run.
Press Merge to learn one merge at a time. The table counts each neighbouring pair, weighted by how often its word occurs. The top pair becomes a new token; a tie is marked and broken by the rule "the lexicographically greater pair wins". Then type a new word, such as slots or owl: it is encoded by applying the learned merges in the order they were learned.
A corpus has the words (with counts) hug 10, pug 5, pun 12, bun 4, hugs 5. Characters are the starting tokens. What is the first merge?
Count each pair, weighted by word counts: (u, g) occurs in hug, pug and hugs, ; (p, u) ; (u, n) ; (h, u) . The most frequent pair wins, not the most frequent word.
3. Definitions and main results #
3.1 AdamW #
Notation (as in the PyTorch documentation): are the parameters, is the gradient at step , is the learning rate, is the weight decay, are the decay rates of the two running averages, and is a small constant. The optimizer state of each parameter tensor is and (same shape as , both start at 0) and the step count .
AdamW, as in torch.optim.AdamW. For :
- (decoupled decay: multiply by );
- (the first moment, a running mean of the gradient);
- (the second moment, elementwise square);
- , (bias correction);
- .
PyTorch defaults: , , , . LLM pretraining usually takes (GPT-3, LLaMA) and , applied to weight matrices only, not to biases and normalization gains.
3.2 Bias correction #
Why it is needed, in one line. starts at 0, so after one step : far too small. The correction divides by exactly the missing factor.
Theorem (bias correction). If every has the same mean , then , so is an unbiased estimate of .
Proof.
- , so unrolling the update gives .
- Take expectations: .
- The geometric sum is , so .
- If every also has the same mean , the same argument gives , so is unbiased.
Two consequences.
- The first step has size in every coordinate. At , and , so . Without the correction the first step is times for the default betas.
- Adam is invariant to the scale of the loss. Replace by with : is multiplied by , by , and the ratio does not change (ignoring ).
In code, is the step count after the increment, so at the first step. With , and you divide by zero.
3.3 Decoupled weight decay vs L2 #
For SGD, an L2 term in the loss and decoupled decay are the same: .
For Adam they differ. With L2, the term is added to , goes into , and is then divided by . A weight with a large gradient history has a large , so its decay is divided by a large number: it is barely regularized. A weight with tiny gradients has a tiny and is pulled to zero fast. AdamW applies the decay outside the adaptive scaling, so every weight shrinks by the same factor per step (Loshchilov and Hutter, 2019). Because PyTorch multiplies by , the decay also follows the learning-rate schedule.
Change the gradient size of weight 2. The chart shows how much the decay term alone has shrunk each weight (the run with decay minus the run without; the figure writes the learning rate as ). With AdamW, both weights shrink by about the same amount, . With Adam + L2, the weight with no gradient (solid) is pulled down fast, and the weight with large gradients (dashed) is decayed much less.
Memory. AdamW keeps two fp32 tensors per parameter, and : 8 bytes. With mixed precision the usual count is 16 bytes per parameter: bf16 weights (2) + bf16 gradients (2) + fp32 master weights (4) + (4) + (4). A 7B-parameter model needs GB before any activations (lesson 12 splits this over GPUs).
3.4 How a PyTorch optimizer is built #
An interviewer may ask for AdamW as a subclass of torch.optim.Optimizer. The class below is
not AdamW; it is sign descent, . It
shows the four parts of the API: parameter groups, lazy per-parameter state, no_grad, and
in-place updates.
import torch
class SignSGD(torch.optim.Optimizer):
"""p <- p - lr * sign(grad). Only here to show the Optimizer API."""
def __init__(self, params, lr=1e-3):
if lr <= 0:
raise ValueError(f"invalid lr: {lr}")
super().__init__(params, defaults={"lr": lr}) # builds self.param_groups
@torch.no_grad() # the update is not part of the graph
def step(self, closure=None):
loss = None if closure is None else closure()
for group in self.param_groups: # a list of dicts: params + hyperparameters
for p in group["params"]:
if p.grad is None: # frozen or unused parameter
continue
state = self.state[p] # per-parameter dict, empty at first
if not state: # lazy init on the first step
state["step"] = 0 # AdamW also adds exp_avg, exp_avg_sq here
state["step"] += 1
p.add_(torch.sign(p.grad), alpha=-group["lr"]) # in place: keep the same tensor
return loss
p = torch.nn.Parameter(torch.tensor([0.0]))
opt = SignSGD([p], lr=0.25)
for _ in range(20):
opt.zero_grad()
((p - 3.0) ** 2).sum().backward()
opt.step()
print(p.item(), opt.state[p]["step"]) # 3.0 20
For AdamW, add exp_avg and exp_avg_sq (torch.zeros_like(p)) to the state, and replace the
update line by steps 1–5 of section 3.1, with = state["step"] after the increment.
3.5 Gradient accumulation #
Theorem (accumulation is exact). Split a batch into micro-batches of equal size, with mean
losses . The full-batch mean loss is . If you call
backward() on for every without zeroing in between, then p.grad equals
(up to float rounding).
Proof.
- The gradient is linear: .
- Each
backward()adds intop.grad, which starts at zero (orNone). - After calls,
p.grad.
The one exception is BatchNorm: it computes statistics per micro-batch, so its result changes.
Token-level losses. For language models the right loss is the mean over all target tokens in
the accumulation window. Let micro-batch hold target tokens. The mean of the micro-batch
means gives each token of micro-batch the weight instead of ; the two
agree only when all are equal. The fix: sum the token losses (reduction="sum") and
divide by the total token count of the window, counted before the backward passes (and summed over
all GPUs with data parallelism). Hugging Face Transformers fixed this bug in its Trainer in 2024.
With DDP (distributed data parallel), every backward() would start an all-reduce of the
gradients over the GPUs. Wrap micro-batches in model.no_sync(), so the
all-reduce runs once per optimizer step instead of times.
3.6 Floating-point formats and the AMP recipe #
| Format | Exponent bits | Mantissa bits | Machine epsilon | Largest | Smallest normal |
|---|---|---|---|---|---|
| fp32 | 8 | 23 | |||
| bf16 | 8 | 7 | |||
| fp16 | 5 | 10 | (subnormals down to ) |
Machine epsilon is the gap between 1 and the next number of the format. The ulp (unit in the last place) of , , is the gap between and the next larger number. Two failure modes follow:
- Underflow (fp16). Values below about (half the smallest subnormal ) round to 0, and values below lose precision as subnormals. Small gradients disappear.
- Stagnation (bf16, and fp16 for large sums). Adding to does nothing when . In bf16, , so an update of to a weight of size 1 is lost.
The AMP recipe (automatic mixed precision):
- Master weights and optimizer state stay in fp32; the optimizer updates fp32 numbers.
- Under
torch.autocast, matrix products and convolutions take 16-bit inputs; the tensor cores accumulate the products in fp32. - Under CUDA autocast, ops with exp, log or long reductions (softmax, log-softmax, layer norm, sums, losses) run in fp32.
- fp16 only: dynamic loss scaling. Multiply the loss by a scale (start ), so small
gradients do not underflow in the backward pass. Unscale the gradients before clipping and
before the step. If any gradient is inf or NaN, skip the step and halve ; after 2000 good
steps in a row, double (the
GradScalerdefaults). - bf16 has the fp32 range, so it needs no loss scaling. It has only 8 significant bits, so long sums and small updates must not happen in bf16.
Which training setup needs dynamic loss scaling (a GradScaler that is enabled)?
Loss scaling fixes a range problem: small fp16 gradients underflow to 0 (fp16's smallest normal number is ). bf16 has the same 8 exponent bits as fp32, so it has the same range and needs no scaling. bf16's problem is precision, and loss scaling does not fix precision; fp32 master weights do.
3.7 The full training step #
The right order is: zero the gradients → forward under autocast → scaled backward (for each
micro-batch) → unscale → clip → step → update the scaler → step the schedule. The code runs on a
CPU with bf16 autocast. On an older GPU with fp16, set amp_dtype = torch.float16; the same code
then uses the scaler.
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR
torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
amp_dtype = torch.bfloat16 # torch.float16 only on GPUs without bf16
use_scaler = amp_dtype == torch.float16 # bf16 has the fp32 range: no loss scaling
W_true = torch.randn(16, 4, device=device) # toy task: a 4-class linear teacher
def get_batch(n=32):
x = torch.randn(n, 16, device=device)
return x, (x @ W_true).argmax(-1)
model = nn.Sequential(nn.Linear(16, 64), nn.GELU(), nn.Linear(64, 4)).to(device)
decay = [p for p in model.parameters() if p.dim() >= 2] # weight matrices
no_decay = [p for p in model.parameters() if p.dim() < 2] # biases (and norm gains)
opt = torch.optim.AdamW([{"params": decay, "weight_decay": 0.1},
{"params": no_decay, "weight_decay": 0.0}],
lr=3e-3, betas=(0.9, 0.95))
n_steps, warmup, K, max_norm = 200, 20, 4, 1.0 # K micro-batches per step
sched = SequentialLR(opt, [LinearLR(opt, 0.05, 1.0, warmup),
CosineAnnealingLR(opt, n_steps - warmup, eta_min=3e-4)], [warmup])
scaler = torch.amp.GradScaler(device, enabled=use_scaler)
for step in range(n_steps):
opt.zero_grad(set_to_none=True) # once per optimizer step, not per micro-batch
total = 0.0
for micro in range(K): # with DDP: model.no_sync() for micro < K - 1
x, y = get_batch()
with torch.autocast(device_type=device, dtype=amp_dtype):
logits = model(x) # matmuls in bf16, weights stay fp32
loss = F.cross_entropy(logits.float(), y) # loss in fp32
scaler.scale(loss / K).backward() # outside autocast; grads add up in p.grad (fp32)
total += loss.item() / K
scaler.unscale_(opt) # true gradients, before clipping
norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)
scaler.step(opt) # skips the step if a gradient is inf/NaN
scaler.update() # adapts the scale (fp16 only)
sched.step() # once per optimizer step
if step in (0, n_steps - 1):
print(step, round(total, 3), round(sched.get_last_lr()[0], 5))
# 0 1.42 0.00029
# 199 0.261 0.0003
The loss falls, and the learning rate first rises (warmup: linear, from 5% of the peak over 20
steps) and then decays along a cosine to its minimum. Gradient clipping rescales all gradients
together when their global norm is above max_norm.
In an fp16 run a colleague calls clip_grad_norm_(model.parameters(), 1.0) and then scaler.unscale_(opt). What goes wrong?
Before unscale_, p.grad holds times the true gradient. Clipping to norm 1.0 then means a true norm of at most : the updates become tiny and training almost stops, without an error. Unscale first, then clip.
3.8 Byte-level BPE #
Training (GPT-2 style, as in CS336 §2.4):
- Vocabulary: the 256 single bytes, plus special tokens such as
<|endoftext|>. - Pre-tokenize: split the text into chunks (GPT-2 uses a regular expression; this lesson splits on spaces) and count each distinct chunk. Merges never cross chunk boundaries or special tokens.
- Count pairs: write each chunk as a tuple of tokens (bytes at first). Count adjacent pairs, each weighted by the chunk's count.
- Pick a pair: the most frequent one. Ties: CS336 takes the lexicographically greater pair (compare the byte strings, first element first). Libraries differ, so state your rule.
- Merge: replace every occurrence of the pair by one new token, append the pair to the list of merges, and go back to step 3. Stop when the vocabulary reaches the target size (256 + specials + number of merges).
Encoding: pre-tokenize the same way, then inside each chunk apply the merges in the order they
were learned. Decoding: concatenate the bytes of the ids and decode UTF-8 with
errors="replace", because an arbitrary id sequence may not be valid UTF-8.
No unknown token. Every string is a sequence of bytes 0–255, and all 256 bytes are in the vocabulary, so byte-level BPE can encode any text.
Cost. The simple version recounts all pairs for every merge: for merges and symbols in the table of distinct chunks. The fast version keeps the pair counts and an index from each pair to the chunks that contain it, and updates only the chunks that a merge touches.
Answers to section 1. (1) With a constant gradient, and exactly at every step, so the step is for both and . Adam's step size comes from , not from the gradient. (2) SGD: the gradient is 4 times larger, which acts like a 4 times larger learning rate. AdamW: does not depend on the gradient scale, so the update hardly changes. But clipping now sees a norm 4 times larger: it starts when the true norm is instead of . So the steps where clipping is active change, and with them the training run.
4. Worked example #
(a) AdamW against Adam with L2. One parameter, , constant gradient , , , , negligible.
AdamW, step 1.
- Decay: .
- , .
- , .
- Step . So .
AdamW, step 2.
- Decay: .
- , .
- , .
- Step . So .
Adam with L2 ( added to the gradient). Step 1: ; with a single gradient, again, so . Step 2: , and . Plain Adam without any decay gives and . The L2 term almost vanished inside the adaptive scaling; AdamW shrank the weight by 1% per step, as intended.
(b) Accumulation with unequal token counts. Micro-batch 1 has 2 target tokens with losses 4 and 2; micro-batch 2 has 4 tokens with loss 1 each.
- Correct: total loss over 6 tokens: .
- Mean of means: . Tokens of micro-batch 1 get weight , tokens of micro-batch 2 get , instead of each.
(c) 16-bit numbers.
- A gradient of in fp16 is 0: it is below half the smallest subnormal, . Scaled by it is , a normal fp16 number. Unscaled in fp32 it is (fp16 keeps about 3 decimal digits).
- Add 1.0 a thousand times to a bf16 accumulator. The sum stops at 256: there , and is a tie that rounds to the even neighbour, 256. An fp16 accumulator stops at 2048 for the same reason.
- A bf16 weight plus an update of stays 1, because .
(d) BPE by hand. Corpus after pre-tokenization: {low: 3, lot: 2, slow: 3, slot: 1}.
| Round | Chunks (count) | Pair counts | Merge |
|---|---|---|---|
| 1 | l o w (3), l o t (2), s l o w (3), s l o t (1) |
(l,o) 9; (o,w) 6; (s,l) 4; (o,t) 3 | (l, o) → lo |
| 2 | lo w (3), lo t (2), s lo w (3), s lo t (1) |
(lo,w) 6; (s,lo) 4; (lo,t) 3 | (lo, w) → low |
| 3 | low (3), lo t (2), s low (3), s lo t (1) |
(lo,t) 3; (s,low) 3; (s,lo) 1 | tie: (s, low) → slow |
| 4 | low, lo t, slow, s lo t |
(lo,t) 3; (s,lo) 1 | (lo, t) → lot |
| 5 | low, lot, slow, s lot |
(s,lot) 1 | (s, lot) → slot |
In round 3, (lo, t) and (s, low) both have count 3. Compare the first elements: s > lo (the
byte s is greater than the byte l), so (s, low) wins.
Merges: [(l,o), (lo,w), (s,low), (lo,t), (s,lot)]; vocabulary size plus specials.
Encoding new words:
slots:s l o t s→ merge 1 →s lo t s→ merges 2 and 3 do not apply → merge 4 →s lot s→ merge 5 →slot s: 2 tokens.lowest→low e s t.owl→o w l: the pair (o, w) is not a merge; only (lo, w) is.
5. In code #
PyTorch checks the numbers above (the notebook does the same in NumPy, and has the exercises).
import torch
import torch.nn.functional as F
from collections import Counter
f64 = torch.float64
# (a) AdamW vs Adam + L2 vs Adam, constant gradient 0.5
def run(opt_cls, **kw):
p = torch.nn.Parameter(torch.tensor([1.0], dtype=f64))
opt, out = opt_cls([p], lr=0.1, **kw), []
for _ in range(2):
p.grad = torch.tensor([0.5], dtype=f64)
opt.step()
out.append(round(p.item(), 7))
return out
print(run(torch.optim.AdamW, weight_decay=0.1)) # [0.89, 0.7811]
print(run(torch.optim.Adam, weight_decay=0.1)) # [0.9, 0.8000473] (Adam's weight_decay is L2)
print(run(torch.optim.Adam)) # [0.9, 0.8]
# Adam is (almost) invariant to the gradient scale; SGD is not (prediction 2)
def final(opt_cls, scale):
torch.manual_seed(0)
p = torch.nn.Parameter(torch.randn(5, dtype=f64))
opt = opt_cls([p], lr=0.01)
for _ in range(20):
opt.zero_grad()
(scale * (p ** 2).sum()).backward()
opt.step()
return p.detach()
for cls in (torch.optim.AdamW, torch.optim.SGD):
print(cls.__name__, f"{(final(cls, 1.0) - final(cls, 4.0)).abs().max().item():.1e}")
# AdamW 2.6e-09
# SGD 1.0e+00
# (b) per-micro-batch means weight tokens unequally
mb1, mb2 = torch.tensor([4.0, 2.0]), torch.tensor([1.0, 1.0, 1.0, 1.0])
print((mb1.mean() + mb2.mean()).item() / 2, (mb1.sum() + mb2.sum()).item() / 6) # 2.0 1.666...
# accumulation with equal micro-batches is exact
torch.manual_seed(0)
X, y = torch.randn(32, 3, dtype=f64), torch.randn(32, dtype=f64)
w = torch.zeros(3, dtype=f64, requires_grad=True)
((X @ w - y) ** 2).mean().backward()
g_full, w.grad = w.grad.clone(), None
for Xb, yb in zip(X.chunk(4), y.chunk(4)):
(((Xb @ w - yb) ** 2).mean() / 4).backward() # grads add up in w.grad
print(f"{(g_full - w.grad).abs().max().item():.1e}") # 1.1e-16
# (c) 16-bit formats
g = torch.tensor(1e-8)
print(g.half().item(), (g * 2**16).half().item()) # 0.0 0.0006551742553710938
s = torch.tensor(0.0, dtype=torch.bfloat16)
for _ in range(1000):
s += 1.0
print(s.item()) # 256.0
w = torch.tensor(1.0, dtype=torch.bfloat16)
print((w + 1e-3).item(), torch.finfo(torch.bfloat16).eps) # 1.0 0.0078125
# which dtypes autocast picks (CPU, bf16)
lin = torch.nn.Linear(8, 4)
with torch.autocast("cpu", dtype=torch.bfloat16):
out = lin(torch.randn(2, 8))
loss = F.cross_entropy(out, torch.tensor([0, 1]))
loss.backward()
print(lin.weight.dtype, out.dtype, loss.dtype, lin.weight.grad.dtype)
# torch.float32 torch.bfloat16 torch.float32 torch.float32
# (d) BPE round-1 pair counts, weighted by word counts
corpus = {"low": 3, "lot": 2, "slow": 3, "slot": 1}
pairs = Counter()
for word, c in corpus.items():
for a, b in zip(word, word[1:]):
pairs[a + b] += c
print(pairs.most_common()) # [('lo', 9), ('ow', 6), ('sl', 4), ('ot', 3)]
Only the matrix-product output is bf16; the weights, the gradients and the loss stay fp32. On the CPU, autocast does not move softmax and layer norm to fp32; the CUDA op lists do.
6. Where it shows up #
- LLM pretraining. Karpathy's build-nanogpt uses exactly this loop: AdamW with
, weight decay 0.1 on parameters with
dim() >= 2, gradient accumulation up to 524,288 tokens per step, bf16 autocast, clipping at 1.0, linear warmup then cosine decay, and gradient synchronization only on the last micro-step (the manual form ofno_sync). - Large-scale training with FSDP. The mixed-precision policy sets the dtype of the parameters, of the gradient reduction and of the buffers separately; reducing gradients in fp32 is the safe default. On A100 and H100 GPUs, bf16 removes the need for loss scaling.
- Scientific ML. PDE residuals and spectral loss terms can span many orders of magnitude. Compute them in fp32, even when the network runs in bf16; an fp16 residual overflows above 65504.
- Tokenizers. BPE (GPT-2, LLaMA 3 and most LLMs) or a close relative decides the sequence length and therefore the cost. Science models often replace tokenizers by patches of a grid, but the trade-off is the same: fewer, larger tokens make attention cheaper and each token harder to model.
7. Common mistakes #
- Loss not divided by , or per-micro-batch means with unequal token counts. Divide the summed token loss by the total token count of the window.
zero_gradinside the micro-batch loop. Only the last micro-batch's gradient survives. Zero once per optimizer step.- Clipping before
unscale_. The threshold then acts on gradients multiplied by . Unscale, then clip. scaler.update()or the scheduler stepped every micro-batch. Both belong once per optimizer step.- Backward or the optimizer step inside
autocast. Only the forward pass belongs there. Adam(weight_decay=...)when AdamW was meant, and decay on biases, norm gains or embeddings. Use AdamW with two parameter groups.- Bias correction with starting at 0. : a division by zero. Increment first.
- Optimizer state bugs: updating
poutsidetorch.no_grad(), assigning a new tensor (p = p - ...) instead of an in-place update, or keeping and in bf16 instead of fp32. - fp16 without a scaler, or long sums and weight updates in bf16.
- BPE bugs: merges across chunks or special tokens, pair counts without the chunk weights, a tie rule that depends on dictionary order, and encoding by greedy longest match instead of merge order.
8. Check yourself #
- Adam without bias correction, . How large is the first step ,
relative to ?
Answer
and , so times : about 3 times the intended step. - AdamW with and , and zero gradient. By which factor does a
weight shrink per step, and after 10,000 steps?
Answer
per step; after 10,000 steps. - DDP on 16 GPUs, micro-batches of 4 sequences of 1024 tokens, . How many tokens per
optimizer step, and how many gradient all-reduces per step with
no_syncused correctly?Answer
tokens. One all-reduce of the gradients per step (sent in buckets), during the backward pass of the last micro-batch. - fp16 training with scale . One step produces an inf gradient. What does
GradScalerdo?Answer
`scaler.step` skips `optimizer.step()` for this step, and `scaler.update()` halves the scale to . After 2000 steps in a row without inf or NaN, it doubles the scale again. - Add the chunk
lotswith count 4 to the corpus of section 4(d). What are the first three merges?Answer
(l, o) with count 13; then (lo, t) with 7, which now beats (lo, w) with 6; then (lo, w). - Why does BPE training count pairs inside pre-tokenized chunks, weighted by chunk counts, instead
of scanning the raw text?
Answer
Speed: each distinct chunk is processed once, times its count, instead of once per occurrence. Quality: merges cannot cross word or punctuation boundaries, so `dog.` and `dog!` share the token for `dog`.
Write the AdamW update for one step (as in PyTorch).
; ; ; , ; .
Why does Adam divide by ?
starts at 0, so for gradients with mean . Dividing removes this bias towards 0; without it the early steps have the wrong size.
How large is Adam's first step without bias correction, for ?
times , instead of 1.
Why use AdamW instead of Adam with an L2 term?
With L2, the decay term passes through Adam and is divided by , so weights with large gradients are barely decayed. AdamW shrinks every weight by , outside the adaptive scaling.
Where do the 16 bytes per parameter of mixed-precision Adam come from?
bf16 weights (2) + bf16 gradients (2) + fp32 master weights (4) + fp32 (4) + fp32 (4).
How do you accumulate gradients over equal micro-batches so the result equals the full batch?
Call
backward()on (micro-batch mean loss) for each micro-batch, without zeroing in between, then take one optimizer step.Micro-batches hold different numbers of target tokens. How do you normalize the loss?
Sum the token losses of all micro-batches and divide by the total number of target tokens in the window (over all GPUs), not by times each micro-batch mean.
bf16 vs fp16 in one line each.
bf16: 8 exponent bits (fp32 range, no loss scaling) but only 8 significant bits. fp16: 11 significant bits but a small range (max 65504), so it needs loss scaling.
State the dynamic loss scaling rule of
GradScaler.Multiply the loss by (start ). If a gradient is inf or NaN, skip the step and halve ; after 2000 good steps in a row, double .
Why does byte-level BPE never need an unknown token?
Its base vocabulary is all 256 byte values, and every string is a sequence of bytes, so every text can be encoded.
9. Interview angle #
Recognize the task from the wording.
| The prompt says | What to write first |
|---|---|
| "implement Adam / AdamW", "write an optimizer" | the state per parameter (, , ), then steps 1–5; test against torch.optim.AdamW in float64 |
| "the batch does not fit in memory" | gradient accumulation; divide by or by the token count |
| "make training faster on the GPU" | bf16 autocast for the forward pass; fp32 master weights; say why no scaler |
| "old GPU, fp16 only", "loss becomes NaN" | GradScaler; unscale before clipping; check for inf |
| "loss spikes" | clipping, warmup, , check the learning rate |
| "implement a tokenizer", "BPE" | vocab: dict[int, bytes], merges: list[tuple[bytes, bytes]], the count–pick–merge loop |
What to say aloud before coding AdamW (about 30 seconds): "The state per parameter is the step
count and two fp32 tensors, and , initialized to zero. Each step: decay the weight by
, update and , correct both by with starting at 1, and step
by . All updates in place, under no_grad. I will test
20 steps on a random quadratic against torch.optim.AdamW in float64."
A plan for 45 minutes.
- 0–5 min: clarify (may you subclass
torch.optim.Optimizer? usenn.Module? theregexpackage?), write the signatures, list the state. - 5–20 min: the core (AdamW steps 1–5, or the BPE count–pick–merge loop).
- 20–30 min: a test. AdamW: the same random quadratic, 20 steps, against
torch.optim.AdamWin float64. BPE: the merges of section 4(d) and the 12 merges of CS336's small example. - 30–40 min: extensions (parameter groups with no decay on biases; special tokens; incremental pair counts; accumulation and AMP in the loop).
- 40–45 min: complexity, memory (16 bytes per parameter), failure modes.
Typical questions and short model answers.
- "Why AdamW and not Adam with L2?" — "With L2, the decay gradient goes into and is divided by . So weights with a large gradient history are barely regularized, and weights with tiny gradients are decayed too much. AdamW applies the decay directly to the weight, , so every weight shrinks at the same rate. The paper shows that this also makes the best weight decay less dependent on the learning rate."
- "Why bias correction?" — " and start at zero, so in the first steps they are too small: . Dividing by makes them unbiased. Without it the first step is times too large for the default betas, and the error lasts for hundreds of steps through ."
- "Why for LLMs?" — " averages over about steps: 20 instead of 1000. When the gradient scale grows suddenly, a stale, too small makes very large steps, which can start a loss spike. A shorter memory follows the change faster."
- "bf16 or fp16?" — "bf16 when the hardware supports it: it has the fp32 range, so there is no loss scaling and no overflow. It has only 8 significant bits, so I keep master weights, optimizer state and reductions in fp32. fp16 has more precision but needs dynamic loss scaling."
- "How do you make accumulation exact for language models?" — "Count the target tokens of the whole window first, summed over GPUs. Then use the summed token loss of each micro-batch divided by that total. Then every token has the same weight, as in the full batch."
- "Why byte-level BPE?" — "The 256-byte base vocabulary covers every string, so there is no unknown token, and the merges make frequent text short. Shorter sequences mean less attention cost and a longer effective context."
Typical follow-ups. "What is the memory of AdamW for a 7B model?" (112 GB at 16 bytes per
parameter, before activations). "Where does clipping go with accumulation?" (once, after the last
micro-batch and after unscale_). "How would you make BPE training fast?" (pair counts with an
index from pairs to chunks; update only the chunks a merge touches; a heap for the best pair).
"What happens to the decay when the learning rate is warmed up?" (PyTorch multiplies by
, so the decay follows the schedule).
10. Practice #
The notebook for this lesson runs in Colab, in NumPy. Each exercise has tests against a reference:
adamw_step: one AdamW step in NumPy, checked against the worked example and a reference run.bias_correction: check numerically that for random gradients.accumulate_token_loss: the token-level loss over unequal micro-batches, equal to the full-batch token mean.bf16_round: round float32 to bf16 with bit operations (round to nearest, ties to even), then show that a bf16 accumulator stops at 256.train_bpe,encodeanddecode: byte-level BPE on a toy corpus, with the CS336 example as a test and a round-trip check.
Timed practice (25–30 minutes each, talking aloud): "implement AdamW as a
torch.optim.Optimizer"; "implement clip_grad_norm_ with the global norm"; "write the training
step with accumulation and AMP from memory"; "implement BPE training and encoding"; the CS336
assignment 1 tests for the AdamW, learning-rate schedule, gradient clipping and BPE functions.
Say it aloud drill. Without notes, explain in 2 minutes: the order of the training step and what each line protects against. Then explain in 1 minute why bf16 needs no loss scaling.
11. Watch and read #
- Stanford CS336 (Spring 2025), Lecture 2: PyTorch, resource accounting (video on YouTube; embedding is disabled): floating-point formats, FLOP and memory accounting, and the memory of the optimizer state.
- Stanford CS336, assignment 1 handout and tests: §2 (BPE training, the small example, encoding), §4 (AdamW as Algorithm 1, the cosine schedule, gradient clipping), §5 (the training loop).
- Karpathy, build-nanogpt,
train_gpt2.py: accumulation, bf16 autocast, clipping and AdamW parameter groups in one file. - PyTorch, Automatic Mixed Precision examples:
gradient clipping and accumulation with
GradScaler. - PyTorch,
torch.optim.AdamW: the algorithm box that section 3.1 follows. - Loshchilov and Hutter (2019), Decoupled Weight Decay Regularization, §2 and Algorithm 2.
- Kingma and Ba (2015), Adam: A Method for Stochastic Optimization, §3 (bias correction).
- Sennrich, Haddow and Birch (2016), Neural Machine Translation of Rare Words with Subword Units: the original BPE for NLP.
Runs in your browser with nothing to install. The notebook repeats the key demos and has exercises with checks; solutions are inside, folded.
No Google account needed: Python runs inside your browser. The first start downloads about 40 MB; your changes are saved in this browser only.