← All posts

Model development

Post 6 of 26

A complete small training run: from raw text to a checkpoint you can trust

Posts in this series · 26

The posts so far took one piece of training at a time: one update, the corpus, mixtures, the tokenizer and the architecture. This post puts them together in a run small enough to do on a laptop CPU in about a minute and a quarter, so that every step can be checked. The model is tiny and the text is 1 MB of Shakespeare, but the steps, and the ways they go wrong, are the ones a large run has.

We train the same model five ways, keep everything else fixed, and read the curves.

1. Prepare the data

The corpus is Tiny Shakespeare: 1,115,394 characters of plays. Four decisions come before any training:

2. Build the model and check its starting point

The model is the modern block from the architecture post at toy size: 4 layers, width 128, 4 heads, a SwiGLU feed-forward network of width 352, rotary positions, RMSNorm before each sub-layer, and input and output embeddings tied. That is 1,066,112 parameters, a quarter of them in the 2,048 × 128 embedding.

Before training, check the loss. A freshly initialized model with small random weights predicts close to uniformly over the vocabulary, so its loss should be about ln⁡V\ln V.

Try it: predict the first loss. What loss should the untrained model have on a vocabulary of 2,048 tokens?

Answer. About ln⁡2,048≈7.62\ln 2{,}048 \approx 7.62 nats per token, the loss of a uniform guess. The measured value was 7.65. A first loss far above ln⁡V\ln V, say 30, would mean the initialization makes some logits huge, so the model starts out confidently wrong; one far below would mean the targets leak into the inputs. Both are bugs, and this check catches them for free.

3. The training loop

The loop is the update from the first post, repeated:

for step in range(600):
    lr = schedule(step)                       # warmup, then cosine decay
    for g in opt.param_groups:
        g["lr"] = lr
    x, y = next_batch(train_ids)              # y is x shifted by one token
    loss = F.cross_entropy(model(x).view(-1, V), y.view(-1))
    opt.zero_grad(set_to_none=True)
    loss.backward()
    norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    opt.step()
    if step % 25 == 0:
        log(step, loss, evaluate(model), norm)   # held-out loss on fixed batches

The optimizer is AdamW with betas (0.9, 0.95) and weight decay 0.1 on the weight matrices only (norm weights are not decayed), as in many open recipes. Gradients are clipped to a global norm of 1.

4. The learning-rate schedule

The learning rate rises linearly from almost zero to its peak over the first 50 steps, the warmup, then follows a cosine curve down to 10% of the peak at the end. Both halves have reasons.

Warmup. In the first steps Adam’s moment estimates are based on a handful of gradients, and as the first post showed, its first update moves every parameter by about the full learning rate, whatever its gradient. With random weights and a large learning rate, those early steps can push the model somewhere it never recovers from. Starting small gives the moment estimates time to settle.

Decay. Late in training, a high learning rate keeps the weights bouncing around a good region instead of settling into it. Decaying it lets the loss fall further at the end, which is also why a schedule should be set for the length the run will actually have: the Chinchilla review shows how a mismatched schedule distorts comparisons.

5. Precision

Large runs compute in BF16 to halve memory traffic and use fast matrix units, while keeping optimizer state, and often a master copy of the weights, in FP32. The reason is visible in one line: in BF16 the next number above 1.0 is 1.0078, so 1.0 + 0.001 is exactly 1.0. A small weight update applied directly to BF16 weights can vanish; applied to an FP32 copy it survives. (The efficient-execution post covers FP16, which also needs loss scaling, and FP8.)

This run trains in FP32 on CPU, but checks the other direction: evaluating the trained model with BF16 autocast gives a validation loss of 4.1263, identical to FP32 to four decimals. For a model this size, computing the forward pass in BF16 costs nothing measurable; the risk is in accumulating updates, not in computing activations.

6. Evaluate as you go

Every 25 steps the model is evaluated on the same 20 validation batches. Using fixed batches matters: random batches would add sampling noise to every point and hide small differences between runs. The baseline’s validation loss falls from 7.64 to 4.13, a perplexity of e4.13≈62e^{4.13} \approx 62: the model is as uncertain, on average, as if choosing among 62 equally likely tokens.

The training loss, smoothed over 25 steps, ends near 3.88, about 0.25 below validation. Some of that gap is the model fitting text it has seen three or four times, which is the cost of repetition from the mixtures post; some is that the last tenth of the file is a different stretch of the plays. Watching the gap during training is how you would notice the model starting to memorize.

Loss is not the only evaluation. A sample from the trained model, prompted with “ROMEO:”, reads:

ROMEO:
Ay, even thy tions to drink them, what
To over one poor behe's steeps, if you yet
Benvouned in the field, adrimling thousand

It has learned the layout of a play, line lengths, punctuation and a vocabulary of real and plausible words, but not meaning. Larger models are evaluated with benchmarks; the post-training and application posts return to evaluation in detail.

7. Checkpoints that resume exactly

A checkpoint must contain everything that determines what happens next: the model weights, the optimizer’s moments and step count, the position in the learning-rate schedule, and the state of the data sampler and random number generators. Leave out the optimizer state and the run restarts Adam from zero moments, which behaves like a sudden warmup-free jump; leave out the data state and it re-reads data it has already seen.

The test is simple and worth automating: stop a run halfway, save, rebuild everything from the checkpoint and continue, then compare with a run that never stopped. Stopping this run at step 300 and resuming reproduced the uninterrupted run’s training loss exactly, at every one of the remaining 300 steps. On GPUs, nondeterministic kernels make bitwise identity harder, but the curves should agree within noise.

8. Read the curves

Validation loss of five training runs and gradient norms with and without clipping456780100200300400500600ln 2048 = 7.62: a uniform guesstraining step (2,048 tokens each)loss (nats per token)baseline (peak 3e-3)4.13no warmup4.44peak 3e-24.77peak 1e-15.40peak 1e-1, no clipping5.46baseline training loss (smoothed)final validation lossgradient norm, max per 5 steps0816clip threshold 1
Figure 1. Five real runs of the same model. Only the learning-rate schedule and clipping change. Without warmup the model is ahead for a few dozen steps and then falls behind for good; with too high a learning rate it stalls far above the baseline. The dotted line marks the loss of a uniform guess, where every run starts.

Four runs change one thing each:

Notice what did not happen: no run diverged to infinity or NaN. A small model with tied embeddings and normalization before every sub-layer is hard to blow up; too high a learning rate shows up here as a stall. Large models fail more dramatically, with sudden loss spikes that may or may not recover, which is why large runs monitor gradient norms, keep clipping on, and save checkpoints they can roll back to.

Try it: diagnose a curve. A colleague’s run starts at a loss of 7.6, falls to 6 in 50 steps, and then sits at 6 for the rest of training while gradient norms jump between 0.5 and 10. What would you change first?

Answer. The starting loss is right and early learning works, so the data and model are probably fine. A plateau with spiking gradient norms is the signature of a learning rate that is too high for the later phase, as in the peak-0.1 runs above. Lower the peak (or check that the decay schedule is actually being applied), keep clipping on, and compare validation curves over the first few hundred steps before committing to a long run.

What to remember

Sources