← All posts

Model development

Post 1 of 26

One training update: from tokens to an AdamW step

Posts in this series · 26

When people talk about training a large language model, the exciting parts seem to be elsewhere: the trillions of tokens, the architecture, the thousands of GPUs. But all of that machinery serves one small operation, repeated a few hundred thousand times: take a batch of tokens, measure how wrong the model’s predictions are, and nudge the weights. Everything else, from data cleaning to distributed training to fine-tuning, is about feeding that operation or running it faster without changing what it means.

So in this post we follow one update from start to finish. On the way we will meet several quiet conventions that decide what the model actually learns: which positions count as targets, what each position may look at, how the losses are averaged, and how AdamW turns a gradient into a step. Get any of them wrong and training still runs. It just optimizes something you did not intend.

If backpropagation, cross-entropy or Adam are new to you, the Foundations posts on backpropagation, softmax and cross-entropy, optimisers and language models cover them.

What one update has to do

A language model reads text left to right and, at every position, guesses the next token. One training update does four things:

  1. Decide which positions are targets, and what the correct next token is at each.
  2. Run the model, letting each position look only at what it is allowed to see.
  3. Turn the guesses into one number, the loss, by averaging how surprised the model was at each target.
  4. Compute how each weight should change to lower that number, then let the optimizer take a step.

The first three steps define what the model is trained to do. The fourth decides how fast and in what shape it moves there. We take them in order.

Targets: each position predicts the next token

Take the sentence <bos> the cat sat <eos>, where <bos> and <eos> mark the beginning and end of a document. At every position the model predicts the next token:

Position01234
Sees up to<bos>thecatsat<eos>
Should predictthecatsat<eos>nothing

Five tokens give four targets: the targets are the inputs shifted left by one, and the last position has nothing to predict.

In code, the targets usually start as a copy of the inputs, called labels, and the shift happens inside the loss. In Hugging Face Transformers 4.51.3, ForCausalLMLoss appends one extra ignored label and drops the first one, so position tt is scored against the token at t+1t + 1. Any label set to -100 is ignored. Some codebases, such as nanoGPT, shift the targets in the data loader instead and use -1 as the ignore value. Either is fine; mixing the two conventions is not.

What does “ignored” mean for learning? A label of -100 removes the loss term at that output position, so that position’s prediction sends no direct gradient back. It does not freeze the token. Its input embedding and hidden states can still receive gradients through later positions that attend to it. A tiny example: if position 1 averages the inputs of positions 0 and 1, h1=(x0+x1)/2h_1 = (x_0 + x_1)/2, and position 1 has a loss ℓ1=h12\ell_1 = h_1^2, then with x0=1x_0 = 1 and x1=3x_1 = 3 we get h1=2h_1 = 2 and

∂ℓ1∂x0=2h1⋅12=2.\frac{\partial \ell_1}{\partial x_0} = 2h_1 \cdot \frac{1}{2} = 2 .

Position 0 has no target of its own, yet its input moves, because position 1’s prediction depends on it. This is exactly what happens to prompt tokens when fine-tuning masks their labels.

A batch with packing and padding

Real batches hold several rows of the same length. Suppose three short documents must fill two rows of six positions:

Row 0 packs A and B together so no slot is wasted. Row 1 holds C and is padded with two <pad> tokens.

Everyday picture: packing is like two small groups sharing a minibus so no seat goes empty. The attention mask, coming next, is the partition that stops one group listening to the other’s conversation.

Three kinds of position get no target:

That leaves 4 targets in row 0 and 3 in row 1: seven target positions out of twelve.

Attention masks: what each position may see

Labels say what each position must predict. The attention mask says what it may look at while predicting. They are separate decisions. In a decoder, position tt may attend to position ss only if:

  1. s≤ts \le t, so it never sees the future;
  2. ss is not padding;
  3. in a packed row, ss belongs to the same document.

The third rule is the one people skip, so look at a position where it matters. In row 0, position 4 is B’s <bos>, and it must predict cat. Give the five tokens it could see the illustrative values 1, 2, 3, 4 and 1, and let attention simply average whatever is allowed:

Same token, same target, different input to the prediction. Without the document mask, B’s prediction depends on an unrelated document A. Llama 3, §3.2: “We use an attention mask that prevents self-attention between different documents within the same sequence.” The paper reports that this had limited impact in standard pre-training but was important in continued pre-training on very long sequences, so it is worth measuring on your own data rather than assuming either way.

The padding at the end of row 1 never reaches a real position, even without rule 2: padding sits after every real token, so no real position satisfies s≤ts \le t for a padded ss. Rule 2 matters for left padding, used when generating from a batch of prompts of different lengths, where the padding comes first.

Position IDs are a third, separate choice: the position number each token gets for positional encoding. Isolating documents is the attention mask’s job and does not require restarting position IDs. With rotary position embeddings (RoPE), the attention score between two positions depends only on the distance between them, so shifting a whole document’s positions by a constant leaves its internal attention unchanged. Whether a codebase restarts positions at each document matters more for learned absolute positions and for how the model sees long contexts; codebases differ, so check yours.

Two rows of tokens with their next-token targets, ignored positions, and the attention pattern of the packed row
Figure 1. From a batch to targets and masks. Step through the shift, the seven target positions, and row 0’s attention under a causal mask and then a document mask. Position 4 predicts cat, and with the document mask it can see only itself.

The loss for one position

The model outputs a score, a logit, for every token in the vocabulary, and softmax turns the scores into probabilities. The loss at one position is how surprised the model is by the correct token: minus the log of the probability it gave that token.

Two quick cases with tiny vocabularies:

In general, for logits zz over the vocabulary and target token yy,

ℓ=−log⁡ezy∑vezv=log⁡∑vezv  −  zy.\ell = -\log \frac{e^{z_y}}{\sum_v e^{z_v}} = \log\sum_v e^{z_v} \;-\; z_y .

Implementations compute the log-sum-exp after subtracting the largest logit, which changes nothing mathematically and prevents overflow.

Averaging: every token, or every row?

The batch has seven target positions, each with a loss. The update needs one number, and there are two natural ways to average. Use made-up losses to see the difference: row A has three targets with loss 1 each, and row B has one target with loss 3.

They differ because they weight tokens differently. The token mean gives each of the four tokens weight 1/41/4. The row mean gives each of A’s tokens weight 12⋅13=16\frac12 \cdot \frac13 = \frac16 and B’s single token weight 12\frac12: three times as much as one of A’s.

Everyday picture: a school averaging exam marks. The token mean averages every paper in the school; the row mean averages each class first, so one paper in a class of 3 counts ten times as much as one paper in a class of 30.

Neither is wrong in itself, but they are different objectives, not two estimates of one. Pre-training normally intends the token mean, so that every token of text matters equally:

L(θ)=−1N∑(b,t) ∈ Tlog⁡pθ ⁣(xb,t+1∣what position (b,t) may see).\mathcal{L}(\theta) = -\frac{1}{N}\sum_{(b,t)\,\in\,\mathcal{T}} \log p_\theta\!\left(x_{b,t+1} \mid \text{what position } (b,t) \text{ may see}\right).

Hugging Face’s ForCausalLMLoss uses the token mean by default: the cross-entropy is averaged over the labels that are not -100.

Gradient accumulation must keep the same objective

When a batch does not fit in memory, the step is split into microbatches: run each one, add up the gradients, and take one optimizer step at the end. Suppose each row of the example above is one microbatch.

The natural code averages each microbatch and divides by the number of microbatches:

12(1+1+13+31)=2,\frac{1}{2}\left(\frac{1+1+1}{3} + \frac{3}{1}\right) = 2,

which is the mean of row means, not the token mean. The fix is to divide each microbatch’s summed loss by the total number of targets in the whole step, N=4N = 4:

1+1+14+34=1.5.\frac{1+1+1}{4} + \frac{3}{4} = 1.5 .

Because gradients add up linearly, the summed gradients then equal the gradient of the token mean exactly. Dividing before backward needs NN in advance; equally valid is to backpropagate the plain sums and divide the accumulated gradients by NN once at the end, before clipping. Both are mathematically the same objective. They need not be bitwise identical, because floating-point additions happen in a different order.

How large can the distortion get? If one microbatch has 1,000 target tokens and another has 10, mean-then-divide gives each token of the small microbatch 12⋅110\frac12 \cdot \frac{1}{10} and each token of the large one 12⋅11000\frac12 \cdot \frac{1}{1000}: a 100-fold difference. Packing rows to a fixed length makes counts similar and hides this; variable-length batches expose it.

Two edge cases need explicit handling:

The backward pass

Start with one position. Using the three-token example, the probabilities are p=(12,14,14)p = \left(\frac12, \frac14, \frac14\right) and the target is the first token, written as the one-hot vector ey=(1,0,0)e_y = (1, 0, 0). The gradient of the loss with respect to the logits is

∂ℓ∂z=p−ey=(−12, 14, 14).\frac{\partial \ell}{\partial z} = p - e_y = \left(-\tfrac12,\ \tfrac14,\ \tfrac14\right).

Read it as instructions: raise the target’s logit by 12\frac12, and lower each wrong token’s logit in proportion to the probability it was given. The entries sum to zero, as p−eyp - e_y always does. Under the token mean each position’s loss is divided by NN, so with N=4N = 4 this position contributes (−18,116,116)\left(-\frac18, \frac1{16}, \frac1{16}\right). Ignored positions contribute nothing.

Now one layer of weights. If the logits come from a hidden vector hh through an output matrix WW, z=hWz = hW, the chain rule gives an outer product. With h=(1,2)h = (1, 2):

∂L∂W=h⊤ ∂L∂z=(12)(−18, 116, 116)=(−18116116−141818).\frac{\partial \mathcal{L}}{\partial W} = h^{\top}\,\frac{\partial \mathcal{L}}{\partial z} = \begin{pmatrix} 1 \\ 2 \end{pmatrix}\left(-\tfrac18,\ \tfrac1{16},\ \tfrac1{16}\right) = \begin{pmatrix} -\tfrac18 & \tfrac1{16} & \tfrac1{16} \\[0.4ex] -\tfrac14 & \tfrac18 & \tfrac18 \end{pmatrix}.

Each weight’s gradient is the size of the hidden feature it reads times the error of the logit it writes. Over a whole batch, the gradients from every target position are summed:

∂L∂W=∑(b,t) ∈ Thb,t⊤ 1N(pb,t−eyb,t).\frac{\partial \mathcal{L}}{\partial W} = \sum_{(b,t)\,\in\,\mathcal{T}} h_{b,t}^{\top}\,\frac{1}{N}\left(p_{b,t} - e_{y_{b,t}}\right).

In a real Transformer the same p−eyp - e_y signal enters the output layer and then flows back through every block.

Clipping the gradient

Before the optimizer step, most pre-training recipes cap the overall size of the gradient. The global norm treats every gradient entry of every parameter as one long vector. With just two entries, (3,4)(3, 4):

In general, with gradient vector gg and cap cc,

g←g⋅min⁡ ⁣(1, c∥g∥+10−6).g \leftarrow g \cdot \min\!\left(1,\ \frac{c}{\|g\| + 10^{-6}}\right).

PyTorch 2.6’s clip_grad_norm_ computes exactly this, so the result is (0.6,0.8)(0.6, 0.8) up to the tiny 10−610^{-6} in the denominator. Because the norm is global, one parameter with a huge gradient shrinks every other parameter’s gradient too.

Three practical points:

One AdamW step on one number

Adam keeps two running averages for each parameter: mm, of the gradients, and vv, of the squared gradients, both starting at zero. Follow one parameter θ=1\theta = 1 with learning rate γ=0.1\gamma = 0.1, β1=0.9\beta_1 = 0.9 and β2=0.99\beta_2 = 0.99, and ignore the tiny ϵ\epsilon for now.

Step 1, gradient g=0.2g = 0.2:

Now repeat with a gradient a hundred times smaller, g=0.002g = 0.002. Every quantity shrinks, but m^/v^\hat m / \sqrt{\hat v} is again 0.002/0.002=10.002 / 0.002 = 1, and the step is again 0.10.1. On the first step, every parameter with a nonzero gradient moves by the learning rate, whatever the gradient’s size, in the direction of its sign.

Everyday picture: a crowd told “everyone take one step towards your exit”. On the first step only the direction each person faces matters, not how hard they were pushed. Adam learns how much to trust each push only as its averages fill up.

That is the special case. In general, at step kk with gradient gg:

m←β1m+(1−β1) g,v←β2v+(1−β2) g2,m^=m1−β1 k,v^=v1−β2 k,θ←θ−γ m^v^+ϵ.\begin{aligned} m &\leftarrow \beta_1 m + (1-\beta_1)\,g, \qquad v \leftarrow \beta_2 v + (1-\beta_2)\,g^2, \\ \hat m &= \frac{m}{1-\beta_1^{\,k}}, \qquad \hat v = \frac{v}{1-\beta_2^{\,k}}, \qquad \theta \leftarrow \theta - \gamma\,\frac{\hat m}{\sqrt{\hat v} + \epsilon}. \end{aligned}

Step 2 shows that history matters. Continue from step 1 with gradient 0.2:

So Adam is insensitive to a consistent rescaling of all gradients, but not to rescaling one step relative to the others. That is why the Trainer’s varying count above is not harmless just because Adam is “scale invariant”.

Weight decay, in two conventions

AdamW adds weight decay: each step, every decayed weight shrinks slightly towards zero, separately from the gradient step. PyTorch 2.6 applies it first, multiplying the weight by 1−γλ1 - \gamma\lambda, where λ\lambda is the weight_decay argument. Continuing the scalar example with λ=0.1\lambda = 0.1:

θ=1×(1−0.1×0.1)−0.1=0.99−0.1=0.89.\theta = 1 \times (1 - 0.1 \times 0.1) - 0.1 = 0.99 - 0.1 = 0.89 .

The original paper writes it differently. Loshchilov and Hutter (2019), Algorithm 2 update θ←θ−ηt(α m^/(v^+ϵ)+λθ)\theta \leftarrow \theta - \eta_t\left(\alpha\,\hat m/(\sqrt{\hat v}+\epsilon) + \lambda\theta\right), where α\alpha is the base learning rate and ηt\eta_t a schedule multiplier. The decay is multiplied by the schedule, but not by the learning rate.

To convert, compare the shrink per step. In PyTorch the learning rate at step tt is γt=α ηt\gamma_t = \alpha\,\eta_t, so the shrink is α ηt λPyTorch\alpha\,\eta_t\,\lambda_{\text{PyTorch}}. In the paper it is ηt λpaper\eta_t\,\lambda_{\text{paper}}. They match when

λpaper=α λPyTorch.\lambda_{\text{paper}} = \alpha\,\lambda_{\text{PyTorch}} .

A recipe of “learning rate 3×10−43\times10^{-4}, weight decay 0.1” in PyTorch corresponds to λpaper=3×10−5\lambda_{\text{paper}} = 3\times10^{-5}. The same name, “weight decay”, differs by a factor of the learning rate between the two conventions, which matters whenever you copy a recipe from one codebase to another.

Which parameters decay is a separate convention. nanoGPT, for example, decays every parameter with two or more dimensions (the weight matrices and embeddings) and leaves biases and normalization weights alone. Recipes differ, so record the choice.

The same step in real code

A minimal step for a Hugging Face causal language model, written so that accumulation keeps the token mean and bad steps are skipped:

num_targets = sum((mb["labels"][:, 1:] != -100).sum() for mb in microbatches)
if num_targets == 0:
    skip_step()                       # nothing to learn from; never divide by zero
else:
    for mb in microbatches:
        logits = model(input_ids=mb["input_ids"],
                       attention_mask=mb["attention_mask"]).logits
        loss_sum = F.cross_entropy(
            logits[:, :-1].reshape(-1, logits.size(-1)).float(),  # predict t+1 from t
            mb["labels"][:, 1:].reshape(-1),
            ignore_index=-100, reduction="sum")
        (loss_sum / num_targets).backward()        # gradients add up to the token mean
    norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
    if torch.isfinite(norm):
        optimizer.step()
    scheduler.step()
    optimizer.zero_grad(set_to_none=True)

The slicing logits[:, :-1] against labels[:, 1:] performs the shift; do not also pass pre-shifted labels. In Hugging Face, attention_mask marks padding, not document boundaries: isolating packed documents needs a separate mechanism, such as variable-length attention kernels driven by document lengths.

At realistic sizes the loss itself costs memory. ForCausalLMLoss upcasts the logits to 32-bit floats before the cross-entropy, a precision safeguard. With a vocabulary of 32,000 and rows of 4,096 tokens, that is 4096×32000×4≈5244096 \times 32000 \times 4 \approx 524 MB per row, which is why large training codebases often compute the loss in chunks.

Failure modes

MistakeWhat happensHow it shows upCheck
Shifting twice, or not at allThe model learns to predict the token after next, or to copy its inputLoss unusually high, or near zero within a few hundred stepsDecode one row’s inputs and targets side by side before training
Padding not ignoredThe model learns to predict <pad>Training loss lower than it should be; worse textCount targets per batch; padding must contribute none
Mean-then-divide accumulation with variable lengthsShort sequences over-weightedLoss changes when you change the microbatch size at a fixed batchNormalize by the step’s total target count
Non-finite gradients clipped silentlyOne NaN turns every gradient into NaNWeights become NaN, loss becomes NaNCheck that the clip norm is finite; skip the step if not
Weight-decay convention mixed upEffective decay off by a factor of the learning rateA copied recipe does not reproduceCompute the per-step shrink explicitly

Where this update goes next

This one update is what the rest of the series keeps coming back to. Next we go upstream, to where the tokens come from: how a pre-training corpus is built, then how text becomes tokens. Later posts ask how to compute exactly this update when the batch, and then the model, no longer fit on one GPU. For a head start on that side, the paper reviews of ZeRO and Megatron-LM cover sharding the optimizer state and splitting the model across devices.

What to remember

Try it yourself

Sources