WORKBENCH
06 · TrainingLesson 6.4

Train a small LM, end to end.

What does the full loop of training a language model actually look like?

Listen · 14 min0:00 / 14:07

Training a language model means showing a program billions of words of text and, every time it guesses the next word wrong, nudging its numbers a tiny bit so it guesses better next time. You have built the model itself, the arrangement of numbers that does the guessing, several times. What you have never done is the whole job, end to end: choose the text, chop it up, feed it in fast enough, keep the nudging going all night, save your progress, and then check whether what came out is any good.

That whole job matters more than it sounds. Almost every idea you will want to test later, a new twist on the model’s design, a cleaner pile of text, a small helper model that speeds up the server on your Spark, gets judged by comparing two training runs. A broken run does not crash. It just quietly scores worse, and a good idea looks like a bad one. So what you are really building here is a run you can trust, plus the habits that catch the silent mistakes in the first five minutes instead of the fifth hour.

This pulls together a lot of what came before: Tokenizers, fp32, bf16, fp16, fp8, Why training needs so much memory and the crash-safe save from Files and disks. And it becomes the yardstick for the rest of the track: Data a model can eat changes only the text, Train your own draft model reuses this loop, and Comparing transformer variants finally gives your own ideas a fair test.

Today

You need to have written a GPT-2 forward pass before, which you have, several times. It helps, but is not required, to have done Tokenizers, fp32, bf16, fp16, fp8, Why training needs so much memory, and the crash-safe save from Files and disks. Plan on two evenings of building, one overnight run and one morning of evaluating.

The Problem

You have built GPT-2 from scratch more than once. Attention, the causal mask, RoPE in the Llama version: all fine. And yet every time you tried something of your own, a new attention variant, a different normalisation, a trick from a paper, the result was the same. The loss went down a bit, the samples were mush, and you could not tell whether your idea was bad or your training was.

That second possibility is the whole problem. A training run has maybe twenty settings that each, if wrong, quietly cost you a factor of two. A learning rate 10× too low does not crash; it just makes a model that looks dumb. Forgetting warmup does not crash; it costs you a few percent. Feeding the model the wrong targets does not crash; it gives you a beautiful loss curve. None of these throw an exception. All of them make your creative idea look like it failed.

People who get good results are not mostly more creative. They have a baseline that works, a run they trust, and they change one thing at a time against it. They also have a checklist that catches the silent bugs before they burn a night of compute. This lesson builds both: one trusted, end-to-end training run of a small language model on the Spark, plus the habits around it.

The Idea

The whole loop on one page

Here is everything that happens between “a pile of web pages” and “a model that writes”.

FIG_001[ THE TRAINING LIFECYCLE ]
RAW TEXTfineweb-eduparquet / streamTOKENIZERgpt-2 bpe+ EOT per docTOKEN FILESuint16 · 2 B/tokentrain_.bin val_.binRUNS ONCETHE TRAINING LOOP · ~5,000 STEPSLOADERx, y = (B, T)GPT · 124Mbf16 · compiledLOSScross-entropyADAMWclip · lr(t)updateNEW WEIGHTSCHECKPOINTmodel + optim+ step + data posEVERY N STEPSSAMPLE · temp, top-pEVAL · val ppl, hellaswagTHE SILENT BUGS:· int32 → uint16 overflow· y not shifted from x· val docs also in train· loader replays one shard
Six stages. The first two run once and write files. The middle loop runs thousands of times. The last two read a checkpoint. Most silent bugs live in the arrows, not the boxes: wrong dtype into the file, targets not shifted, val data leaking into train.

You have built the blue box in the middle many times. This lesson is about every other box, and about the arrows, because that is where runs die quietly.

Choosing the text

The model can only become as good as what it reads, so the first real decision is the dataset. Two good choices, for two different jobs:

Recommendation: FineWeb-Edu sample-10BT for the real run, TinyStories for the five-minute smoke tests. You are not trying to make the best 124M model in the world. You are trying to make a run you can trust, and the fastest way to trust a run is to have someone else’s run to hold it up against. TinyStories is your unit test; FineWeb-Edu is your integration test.

You will not use all 10B tokens. Below you will compute that about 2.5B is the sensible budget for a 124M model, which is a single night on the Spark.

Tokenize once, store as uint16

Tokenizing is slow, Python-heavy work (see Tokenizers for what BPE is actually doing). Training wants to read millions of tokens a second. So you never tokenize during training. You tokenize once, up front, and write the token IDs to disk as one flat array of integers. During training you just read slices of that array.

Three decisions make this file right:

  1. One integer type, the smallest that fits. GPT-2’s tokenizer has 50,257 tokens, so every ID is between 0 and 50,256. An unsigned 16-bit integer holds 0 to 65,535. So each token takes 2 bytes as a uint16. 2.5B tokens is 5 GB on disk. As int64, the default for torch.tensor, it would be 20 GB. (If you ever switch to a tokenizer with more than 65,536 tokens, like Llama 3’s 128K vocabulary, you need uint32, and a silent cast to uint16 will wrap the big IDs around to garbage. Assert the max before you cast.)
  2. A separator between documents. Put GPT-2’s special end-of-text tokenToken 50256, written <|endoftext|>. It marks the boundary between two documents so the model learns that what follows is unrelated to what came before. in front of every document. Then concatenate everything, ignoring document boundaries. A training window of 1,024 tokens will often span the end of one page and the start of the next; the separator tells the model “context resets here”. This is the simplest form of packingConcatenating many documents into one long token stream and cutting it into fixed-length windows, so no compute is wasted on padding., and it is what GPT-2 and nanoGPT do.
  3. Read it with a memory map. np.memmap(path, dtype=np.uint16, mode="r") gives you an array-shaped view of the file without reading it. Slicing it reads just those bytes, through the page cache, on demand. That is the mmap from Virtual memory and Files and disks: the OS keeps hot pages in RAM, and with 128 GB of unified memory on the Spark, your whole 5 GB dataset will end up cached after the first pass.
FIG_002[ TOKENS ON DISK, WINDOWS IN MEMORY ]
train_0001.bin · uint16 · one flat arrayEOT464379728613EOT818257206811340373257· · ·DOC 1DOC 2ONE WINDOW: T+1 TOKENS FROM A RANDOM STARTx28613EOT818257y13EOT8182572068x = buf[s : s+T]y = buf[s+1 : s+T+1]EACH x[i] IS SCORED ON y[i]T = 1,024 in the real run; 5 shown. B windows stacked make one micro-batch of shape (B, T).
The file is one long row of 2-byte IDs, documents separated by EOT. A training example is any T+1 consecutive tokens. x is the first T, y is the same window shifted left by one, so position i is asked to predict token i+1. Get this shift wrong and the loss becomes a lie.

The loader: random windows, a held-out split

The loader’s whole job is: given a batch size B and a context length T, hand back two integer tensors x and y of shape (B, T), where y is x shifted by one. Three details make it correct.

Hold out validation data before anything else. Validation data has to be text the model never trains on, or the validation loss means nothing. The cleanest split is by file: the first shard is val, the rest are train. Never split by sampling random windows from one big file into “train” and “val”; windows overlap, so the same sentences end up in both.

Cover the data, don’t just sample it. The tempting loader picks B random start positions each step. That works, but it samples with replacement.

Predict firstwrite it down

Your train set is cut into one million non-overlapping windows. Each step you draw random windows with replacement, and you draw one million in total (one “epoch” worth). What fraction of the windows does the model ever see?

Reveal

About 63%. Each window has a (1 − 1/n) chance of being missed on each draw, and (1 − 1/n)ⁿ → 1/e ≈ 0.37 for large n. So 37% of your data is never seen, and many windows are seen twice. For a single-epoch run that is a real waste. The fix is cheap: cut the data into windows, shuffle the list of window start positions once per epoch, and walk through it. Every window exactly once.

Keep the integers small until the last moment. Slice uint16 from the memmap, convert to int64 (PyTorch’s embedding lookup wants long), then move to the GPU. On the Spark the CPU and GPU share the same physical memory, but a .to("cuda") still goes through the CUDA allocator; at these sizes it is noise either way.

The model: GPT-2 small, with two small changes

Use the GPT-2 124M configuration: 12 layers, 12 heads, model width 768, context 1,024. You know this model. Two details worth getting exactly right:

Initialise like GPT-2: linear and embedding weights from a normal distribution with standard deviation 0.02, biases at zero, and the two projections that write back into the residual stream (attention output, MLP output) scaled down by 1/√(2 × layers), so the residual stream does not grow with depth.

What the loss number means

The loss is cross-entropyFor each position, minus the log of the probability the model gave to the correct next token. Averaged over all positions in the batch. Zero only if the model is certain and right., measured in natsThe unit you get when you use the natural log (ln). One nat is about 1.44 bits.. If the model gives the correct next token probability 0.05, that position contributes −ln(0.05) ≈ 3.0.

Predict firstwrite it down

You build the 124M model with the init above and, before any training, run one batch of real text through it. What loss do you expect, roughly? What would you conclude if you saw 68?

Reveal

About ln(50,257) ≈ 10.82. With small random weights the logits are all close to zero, so the softmax is close to uniform: every one of the 50,257 tokens gets probability ≈ 1/50,257, and −ln(1/V) = ln(V). (With the padded vocabulary, ln(50,304) = 10.83; the same thing.) I measured 10.98 for this exact model at init: slightly above, because the logits are small but not zero. Anything from about 10.8 to 11.2 is healthy.

A 68 means the model is extremely confident and wrong at step 0, which only happens when the logits are huge: an init that is far too large. I got exactly that by initialising the same architecture with standard deviation 1.0 instead of 0.02: initial loss 68.2. A network starting there spends its first thousand steps undoing the init, and often never recovers fully.

This is the single cheapest sanity check in deep learning, and it catches broken inits, wrong vocab sizes, mismatched loss reductions, and labels from the wrong tokenizer.

To make loss numbers feel like something, convert them to perplexitye raised to the loss. Read it as: “the model is as uncertain as if it were choosing uniformly among this many tokens.”:

Loss (nats)PerplexityWhat it feels like
10.83~50,000Random guessing over the whole vocabulary. Step 0.
7~1,100Knows which tokens are common, nothing about order.
5~150Local word patterns, grammar-ish. Samples are word salad with structure.
4~55Mostly grammatical sentences; topic drifts.
~3.3~27Where a 124M model ends up on web text after a few billion tokens. Paragraphs that hold a topic.
0.05~1.05Impossible on real text. You have a bug (see below).

Two warnings. Losses are only comparable on the same tokenizer and the same validation text. A loss of 3.3 on FineWeb-Edu and 1.5 on TinyStories are not comparing models, they are comparing datasets (stories are far more predictable). And small differences matter: at this scale, a change that drops validation loss by 0.02 nats is a real improvement if it is consistent, and a change that costs 0.05 is a real regression.

The optimizer: AdamW, with weight decay on the right things

AdamWAdam with “decoupled” weight decay: the weights shrink a little each step, separately from the gradient update. Adam itself keeps a running average of each weight’s gradient (momentum) and of its square, and divides one by the square root of the other, so every weight gets its own step size. is the default for transformers, and the GPT-3 settings are a good place to stand: betas (0.9, 0.95), epsilon 1e-8, weight decay 0.1. The 0.95 (instead of Adam’s usual 0.999) makes the squared-gradient average forget faster, which helps when gradient sizes swing around, as they do in language models.

Weight decayShrinking every weight slightly toward zero each step. A gentle pressure toward small weights that acts as regularisation. is where people get it subtly wrong. Apply it to the 2-D weight matrices (every linear layer and the embedding table), and not to biases or LayerNorm gains and offsets. Decaying a LayerNorm gain pulls it toward zero, which literally scales down that layer’s output; there is no reason to want that. The rule that implements this is one line: decay a parameter if p.dim() >= 2. For the 124M model that puts 124,354,560 parameters in the decay group and 121,344 (biases and norms) in the no-decay group, which I also counted. If your no-decay group is empty, or holds tens of millions of parameters, something is off.

The learning rate: warm up, then cosine down

The learning rate is the most important number in the run. Get it within a factor of two of good and most other settings stop mattering much.

The shape that works: linear warmup from near zero to the peak over a few hundred steps, then cosine decay down to about 10% of the peak by the last step.

FIG_003[ WARMUP + COSINE ]
6e-46e-50LRSTEP 5,000300WARMUPlinear, ~5% of runCOSINE DECAYhalf a cosine wave from peak to minMIN = 10% OF PEAK
Peak 6e-4, 300 warmup steps, cosine down to 6e-5 at step 5,000. The warmup is a sliver of the run but protects the most fragile moment: step 0, when the weights are random and Adam has no history. The slow tail of the cosine is where the last few hundredths of a nat come from.

Why each part is there:

Batch size is measured in tokens

For language models, think of batch size as tokens per optimizer step, not sequences. GPT-3 Small used about 0.5M tokens per step; nanoGPT uses 2¹⁹ = 524,288 (a power of two near 0.5M). With T = 1,024 that is 512 sequences per step.

That will not fit in one forward pass on the Spark, and it does not need to. Gradient accumulationRunning several small forward/backward passes and adding up their gradients before taking one optimizer step. Mathematically the same as one big batch, just slower per step and lighter on memory. splits the big batch into micro-batches: run, say, 32 micro-batches of 16 × 1,024 tokens each, summing gradients, then step once.

FIG_004[ ONE STEP, 32 MICRO-BATCHES ]
μB 116×1024μB 216×1024μB 316×1024· · ·μB 3216×1024FORWARD + BACKWARD, loss / 32.grad += ∂(loss/32)/∂w (accumulates)CLIP NORM ≤ 1log the normOPT.STEP()then zero_grad32 × 16 × 1,024 = 524,288 TOKENS PER OPTIMIZER STEPBigger micro-batch if memory allows: fewer, fatter passes usually run faster.
Gradients from each micro-batch add up in .grad. Only after the last one does the optimizer move the weights. Because .grad adds, each micro-batch's loss must be divided by the number of micro-batches, or you are summing 32 losses instead of averaging them.

Why not just use a small batch and skip the accumulation? Because the batch size is a hyperparameter of the recipe. The LR of 6e-4 was tuned for 0.5M-token batches. Smaller batches give noisier gradients and want a smaller LR; if you change one, you have changed the other. Keeping the batch at 0.5M tokens means published numbers still apply to you.

Gradient clipping

Every so often a batch produces a gradient far larger than usual: a weird document, a rare token, an unlucky moment. One giant step can undo hours of progress. Gradient clippingBefore the optimizer step, measure the size (L2 norm) of the whole gradient, all parameters together. If it is bigger than a threshold, scale it down to the threshold. Direction kept, size capped. at a norm of 1.0 caps the damage. torch.nn.utils.clip_grad_norm_ returns the norm before clipping; log it every step. A healthy run’s gradient norm settles somewhere below 1 after the early phase and drifts slowly. Spikes in the norm come before spikes in the loss, so it is your early warning.

Making it fast: four switches

None of these change what the model learns (to within noise). All of them change how many tokens per second you get.

  1. bf16 autocast. Wrap the forward pass and loss in torch.autocast(device_type="cuda", dtype=torch.bfloat16). Matrix multiplies run in bf16A 16-bit float with the same exponent range as fp32 but fewer digits of precision. Big range means no overflow/underflow trouble, so no loss scaling is needed (unlike fp16). on the tensor cores; the weights, the optimizer state, and sensitive operations like softmax and the loss stay in fp32. You do not need a gradient scaler with bf16. That is fp16’s problem, and why fp32, bf16, fp16, fp8 says everyone switched.
  2. TF32 for whatever is left in fp32. torch.set_float32_matmul_precision("high") lets fp32 matmuls use tensor cores at reduced precision. Free speed, harmless for training.
  3. Flash attention. Use F.scaled_dot_product_attention(q, k, v, is_causal=True) instead of writing out softmax(QKᵀ)V by hand. PyTorch picks a fused kernel that never writes the T × T attention matrix to memory, which is the whole story of FlashAttention.
  4. torch.compile(model). Traces your model and fuses chains of small operations (add, LayerNorm, GELU, dropout) into single kernels. On the Spark this matters more than on an H100: its memory bandwidth (273 GB/s, spec sheet) is low relative to its compute, so every unfused elementwise op that reads and writes a whole activation tensor is relatively expensive. The first step takes a minute or more while it compiles; the steps after are faster. How much faster is one of the things you will measure.

Also pass fused=True to AdamW if your PyTorch version supports it on your device: the optimizer update becomes one kernel instead of a loop over parameter tensors.

Reading loss curves

Now the part that would have saved your earlier experiments. Every training run produces a loss curve, and the shape tells you far more than the final number. Below are the six curves you will meet most often. They are shaped from the real behaviour of runs like this one, but they are illustrations, not measurements. Click through them all and read each annotation.

FIG_005[ LOSS-CURVE EXPLORER ]

final train - · final val - · val perplexity -

What you see

What it means

What to do

Six typical runs of a 124M GPT on web text, 5,000 steps of 0.5M tokens. Blue is the noisy per-step training loss, black the validation loss. The strip underneath is each run's learning rate on a log scale. Switch to a log step axis to see the first hundred steps, where warmup problems live.

The patterns to burn in:

Two more habits. Evaluate validation loss on a fixed set of batches (say 20 batches, the same ones every time), so the val number moves only when the model changes. And always plot new runs on top of your baseline, same axes. Most “did my idea work?” questions are answered by whether the new curve is below the old one at the same step.

How big, how long: scaling laws and 6·N·D

How many tokens should a 124M model see? In 2022, DeepMind’s Chinchilla paper (Hoffmann et al.) trained over 400 models of different sizes on different amounts of data, all for different compute budgets, and asked: for a fixed compute budget, what split between model size and data gives the lowest loss? The answer, roughly: grow them together, with about 20 tokens per parameter. Earlier work (Kaplan et al., 2020) had said to favour bigger models; Chinchilla’s correction was that most big models of the time were badly undertrained.

So for 124M parameters: about 2.5B tokens. That is the “compute-optimal” budget: the cheapest way to train to a given loss. (Plenty of real models go far past 20 tokens per parameter on purpose: Llama 3 8B saw over 15 trillion tokens, because a small model trained long is cheaper to serve. That trade is about inference cost, not training.)

Now the compute. Training a transformer costs about 6 FLOPs per parameter per token:

Total training compute ≈ 6·N·D, where D is the number of training tokens. This ignores the attention scores themselves (QKᵀ and the weighted sum of V), which add about 12 × layers × width × T FLOPs per token. For our model at T = 1,024 that is 113M on top of 6N = 747M, about 15% more, so a fairer count is ~860M FLOPs per token. Both numbers below; neither is exact.

For 124M parameters and 2.5B tokens: 6 × 1.245e8 × 2.5e9 ≈ 1.9e18 FLOPs (2.2e18 with attention).

What the Spark can do: about 125 TFLOP/s of dense bf16 at most. That is not a published spec; it is the estimate derived in Bandwidth bound from NVIDIA’s marketing number, and you will measure the real one. No training run gets the peak. The fraction it does get is the MFUModel FLOPs utilization: the FLOPs your model’s math actually needs per second (tokens/s × FLOPs per token), divided by the hardware’s peak. Counts only useful model math, not recomputation or overhead. Introduced in Google’s PaLM paper.. Well-tuned large-scale runs reach about 40-60%; llm.c’s GPT-2 run on 8 A100s works out to roughly 60% by this formula (10B tokens in 90 minutes is about 1.85M tokens/s). For a single small model on the Spark, 30-45% would be a good result. I do not know your number; that is why you will measure it.

At 40% MFU, 50 TFLOP/s achieved: 1.9e18 / 5e13 ≈ 37,000 s ≈ 10 hours (about 12 with attention). At 25% MFU, more like 17-19 hours. That is an overnight run, and it tells you something important before you write any code: you get about one full-size run per day. So everything you want to learn about hyperparameters has to be learned on smaller, faster runs first.

Play with the budget. Then try the reverse panel with the tokens/second you measure later.

FIG_006[ COMPUTE CALCULATOR ]
Total compute
-
Wall clock
-
Tokens / second
-
Tokens per param
-

Reverse it: what MFU did I get?
→ MFU -
Training compute ≈ 6 × params × tokens. Divide by the FLOP/s you actually achieve (peak × MFU) to get wall-clock time. Spark peak is the site's 125 TFLOP/s bf16 estimate; A100 and H100 are spec-sheet dense bf16. The 6N rule ignores attention (about +15% for this model at T = 1,024), so treat every number as ±20%.

The debugging recipe

Karpathy’s “A Recipe for Training Neural Networks” (2019) boils down to: neural nets fail silently, so be paranoid, and build up from things you can verify. For a language model, in this order, before any long run:

  1. Look at the data. Decode a few training windows back to text and read them. Is it text? Are there EOT tokens between documents? Print one (x, y) pair side by side and check y is x moved left by one.
  2. Check the init loss. One forward pass, no training. ≈ ln(V)? (Above.)
  3. Overfit one batch. Take a single small batch and train on it, over and over, for a few hundred steps. A working model and optimizer must drive the loss to near zero, because memorising 256 tokens is trivial. I ran this on my Mac with a 4-layer GPT on one batch of 4 × 64 tokens at LR 1e-3: loss went from 10.87 to 0.08 in 50 steps and 0.005 by step 300. If yours plateaus at 5, something between the loss and the weights is broken: wrong targets, detached tensors, an optimizer that was given the wrong parameters, an LR of zero.
  4. Train on random tokens for a minute. The loss must stay at or above ln(V). If it drops, the model can see its targets.
  5. Print the LR every step for the first few. Is warmup happening? Is it the number you think? (Off-by-one-order-of-magnitude LRs are the most common silent bug.)
  6. A short real run on TinyStories or a small FineWeb slice. Does the curve look like the “good run” in Figure 005? Do samples get better?
  7. Only then, the long run. And keep notes.

Checkpoints

A 10-hour run will be interrupted at some point: a reboot, an OOM, a Ctrl-C you regret. A checkpoint is everything needed to continue as if nothing happened:

Write it with the crash-safe recipe from Files and disks: write to a temp file in the same directory, fsync, os.replace over the old one, fsync the directory. A checkpoint that is half-written when the machine dies is worse than none, because it overwrote the good one. Keep the last two or three, plus a few milestones, so a bad stretch of training doesn’t leave you with only bad checkpoints.

How big? Weights in fp32: 124.5M × 4 bytes ≈ 0.5 GB. Adam state: 1.0 GB. About 1.5 GB per checkpoint. (See Why training needs so much memory for where those multiples come from.)

Sampling: turning probabilities into text

The model outputs a probability for every token. How you pick from them changes the text a lot, and none of it changes the model.

For a 124M model, temperature 0.8 to 1.0 with top-p 0.9 or top-k 50 is a reasonable start. Fix a prompt and a seed, so samples from different checkpoints are comparable.

Small evals

Validation loss is your main number: cheap, smooth, and directly what you optimised. But loss does not tell you whether the model knows anything, so add one small task eval.

HellaSwag (Zellers et al., 2019) gives a short context and four possible endings; one is right, three are machine-generated traps. A language model can do it without any instruction following: compute the model’s average loss on each ending’s tokens given the context, and pick the ending with the lowest loss. Random guessing scores 25%. Karpathy measured OpenAI’s GPT-2 124M at about 29.5% on the 10,042-example validation set this way; a 124M model is barely above chance, which is exactly why it is a good small eval: it moves as you train. The standard error on 10,042 examples is about ±0.45 points, so differences under about one point between two runs are noise (more on that in Statistics for evals).

Build It

Everything runs on the Spark except where noted. Keep one folder, ~/tinylm/, under git. Commit before every run, so every result in your log points at the exact code that made it.

Set up the Spark and measure its matmul peakAgent: fine

The lowest-friction way to get a CUDA PyTorch build for the Spark’s ARM CPU and Blackwell GPU is NVIDIA’s NGC PyTorch container; a pip wheel built for aarch64 + CUDA may also work. Check NVIDIA’s DGX Spark documentation for the current recommendation; it has changed over time. Then:

pip install tiktoken datasets numpy matplotlib
python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.get_device_name())"

If you did Bandwidth bound, you already have flops.py. If not, time a large bf16 matmul (8192 × 8192 is a good size), with warmup and torch.cuda.synchronize() before stopping the clock. A square n × n matmul is 2n³ FLOPs. Write the plateau down: this measured peak is what you’ll compute MFU against, alongside the 125 estimate.

Tokenize FineWeb-Edu into uint16 shardsAgent: fine

Boilerplate. It streams the 10BT sample (so you don’t download all of it), tokenizes in parallel, writes 100M-token shards, and stops after the budget. Shard 0 is validation.

# prepare.py
import os, numpy as np, tiktoken, multiprocessing as mp
from datasets import load_dataset

OUT = os.path.expanduser("~/data/fineweb_edu")
SHARD = 100_000_000            # tokens per shard = 200 MB of uint16
BUDGET = 2_700_000_000         # val shard + ~2.6B train tokens
enc = tiktoken.get_encoding("gpt2")
EOT = enc.eot_token            # 50256

def tok(doc):
    ids = np.array([EOT] + enc.encode_ordinary(doc["text"]), dtype=np.int64)
    assert ids.max() < 2**16, "token id does not fit in uint16"
    return ids.astype(np.uint16)

if __name__ == "__main__":
    os.makedirs(OUT, exist_ok=True)
    ds = load_dataset("HuggingFaceFW/fineweb-edu", name="sample-10BT",
                      split="train", streaming=True)
    buf = np.empty(SHARD, dtype=np.uint16)
    n = shard = total = 0
    with mp.Pool(max(1, os.cpu_count() - 2)) as pool:
        for ids in pool.imap(tok, ds, chunksize=64):
            while len(ids):
                take = min(SHARD - n, len(ids))
                buf[n:n + take] = ids[:take]
                n += take
                ids = ids[take:]
                if n == SHARD:
                    split = "val" if shard == 0 else "train"
                    buf.tofile(f"{OUT}/{split}_{shard:04d}.bin")
                    total += n
                    shard += 1
                    n = 0
                    print(f"wrote shard {shard}, {total / 1e9:.2f}B tokens", flush=True)
            if total >= BUDGET:
                break

Time it. Note the tokens per second in your log; it tells you whether tokenizing on the fly could ever have kept up with training (it couldn’t, but check). Then do the same for TinyStories (roneneldan/TinyStories, field text) into ~/data/tinystories/, with smaller shards. Dataset names and fields can change; check the dataset cards if a load fails.

Last, read your data back: memmap one train shard, take 300 tokens from a random offset, enc.decode them, and read the text. Find an EOT in it.

Write the data loaderHands only

Yours to write. The shape:

class Loader:
    def __init__(self, data_dir: str, split: str, B: int, T: int, seed: int = 0):
        """Memmap every {split}_*.bin shard (np.memmap, dtype=np.uint16, mode='r').
        Build the list of (shard, start) window positions, stride T, each window
        needing T+1 tokens. Shuffle it with a seeded generator."""

    def next_batch(self) -> tuple[torch.Tensor, torch.Tensor]:
        """Return x, y: int64 tensors of shape (B, T) on the GPU. y is x shifted by one.
        Walk the shuffled window list; when it runs out, bump the epoch and reshuffle."""

    def state_dict(self) -> dict: ...      # epoch, position, seed: enough to resume exactly
    def load_state_dict(self, d: dict): ...

Hints: convert with torch.from_numpy(arr.astype(np.int64)). Write a three-line test: for one batch, assert (x[:, 1:] == y[:, :-1]).all(). For val, you don’t need shuffling; you need the same first 20 batches every time.

The model: your GPT-2, with the checklistAgent: review after

Use your own GPT-2 implementation. Before moving on, make it pass this checklist, then have an agent review the diff against nanoGPT’s model.py:

  • Config: n_layer=12, n_head=12, n_embd=768, block_size=1024, vocab_size=50304.
  • LM head weight tied to the token embedding (self.lm_head.weight = self.wte.weight).
  • Init std 0.02; residual output projections scaled by (2 * n_layer) ** -0.5; biases zero.
  • Attention through F.scaled_dot_product_attention(..., is_causal=True).
  • forward(idx, targets=None) returns (logits, loss), loss computed in fp32 over the flattened (B*T, V) logits.
  • A parameter count that prints 124,475,904 (with untied embeddings you would see 163M).
The two sanity checks: init loss and overfit one batchHands only

Write sanity.py. Three checks, each prints PASS or FAIL:

  1. Init loss. One batch of real data through the fresh model. Expect 10.8-11.2. Print math.log(50257) next to it.
  2. Overfit one batch. B = 4, T = 64 (keep it tiny), plain AdamW at 1e-3, no schedule, 300 steps on the same batch. Expect the loss under 0.1 well before the end.
  3. Random tokens. Fresh model, fresh random token batches each step, correct shifting, 100 steps. Loss must never go meaningfully below ln(V). Then flip y = x and watch it fall, so you have seen what the bug looks like.

Run this on the Mac too, on CPU with a 4-layer model. It is fast, and it means you can catch model bugs without touching the Spark.

The training loopHands only

This is the core of the lesson; write it yourself. Skeleton and spec:

# train.py (shape, not a solution)
@dataclass
class TrainConfig:
    data_dir: str = "~/data/fineweb_edu"
    total_batch: int = 524_288        # tokens per optimizer step
    B: int = 16; T: int = 1024        # micro-batch
    max_lr: float = 6e-4; min_lr_frac: float = 0.1
    warmup_steps: int = 300; max_steps: int = 4_800   # ~2.5B tokens
    weight_decay: float = 0.1; grad_clip: float = 1.0
    eval_every: int = 250; ckpt_every: int = 500; compile: bool = True

def get_lr(step: int, cfg: TrainConfig) -> float:
    """Linear warmup to max_lr over warmup_steps, then cosine to max_lr*min_lr_frac at max_steps."""

def configure_optimizer(model, cfg) -> torch.optim.Optimizer:
    """Two param groups: dim >= 2 gets weight_decay, the rest gets 0.
    Print both group sizes. AdamW, betas=(0.9, 0.95), eps=1e-8, fused if available."""

def save_checkpoint(path, raw_model, opt, step, loader, cfg): ...   # atomic: tmp, fsync, os.replace, fsync dir

# main loop, per step:
#   set lr on every param group from get_lr(step)
#   for micro in range(grad_accum):  (grad_accum = total_batch // (B*T); assert it divides)
#       x, y = loader.next_batch()
#       with torch.autocast("cuda", dtype=torch.bfloat16): _, loss = model(x, y)
#       (loss / grad_accum).backward()
#   norm = clip_grad_norm_(model.parameters(), cfg.grad_clip)
#   opt.step(); opt.zero_grad(set_to_none=True)
#   torch.cuda.synchronize(); time the step; log

Log one line per step to stdout and to log.jsonl:

step 1200 | loss 3.8123 | lr 5.81e-04 | norm 0.42 | dt 9012ms | tok/s 58,170 | mfu 39.9%

Compute MFU in the loop: tok_per_s * flops_per_token / peak, with flops_per_token = 6*N + 12*n_layer*n_embd*T, and print it against both the 125 TFLOP/s estimate and your measured matmul peak from t0. Every eval_every steps, compute val loss on the fixed 20 batches under torch.no_grad() and log it. Every ckpt_every, save. Add --resume path that restores everything, including the loader position, and check that a resumed run’s next loss matches what the uninterrupted run logged.

Then, before the real thing, run it for 200 steps on TinyStories with a 6-layer model and confirm the curve looks like the good run.

One controlled experiment: an LR sweep at small scaleAgent: review after

You get one long run a day, so learn the LR on small runs. Use a 6-layer, width-384, 6-head model (about 30M parameters, a lot of it embedding), total batch 65,536 tokens (B=16, T=1,024, accumulation 4), warmup 30, 300 steps, FineWeb-Edu. That is about 20M tokens per run, a few minutes each on the Spark.

Sweep the peak LR over [1e-4, 3e-4, 1e-3, 3e-3, 1e-2]. Change nothing else, same seed, same data order. Save each run’s log.jsonl. Then write plot.py with matplotlib: one figure with all five training curves (smoothed with an exponential moving average, raw curves faint behind them), and a second figure of final val loss against LR on a log x-axis.

Have an agent review the plot and your conclusion, not write them. Expect a U shape in the second plot: too low is slow, too high is unstable, the bottom is usually just below where instability starts. Caveat: the best LR at 30M is not exactly the best LR at 124M (smaller models usually tolerate higher LRs). Use the sweep to sanity-check 6e-4 and to see the U with your own eyes, not to pick a number to four digits.

Measure throughput and MFUHands only

With the full 124M config, run 30 steps four ways and record steady-state tok/s (skip the first few steps, and with compile on, skip the compile time):

  1. fp32, no compile
  2. bf16 autocast, no compile
  3. bf16 + torch.compile
  4. (3) with micro-batch B = 32 or 64 if memory allows (accumulation shrinks to match)

Compute MFU for each against both peaks. Then, with the best setting, use the calculator in Figure 006 (reverse panel) to check your arithmetic, and set max_steps so the main run fits a night.

The main run, with a training logAgent: fine

Start it inside tmux (so a dropped SSH session from SSH and Tailscale doesn’t kill it) and send output to a file:

tmux new -s train
cd ~/tinylm && git commit -am "main run: 124M, 2.5B tok, lr 6e-4" && \
python train.py 2>&1 | tee runs/main/stdout.log
# detach: Ctrl-b d      reattach: tmux attach -t train

Keep LOG.md in the repo, one entry per run, written before the run starts and finished after:

## 2026-10-18 main-124m
commit: a1b2c3d   config: 124M, 2.5B tok, B=16x1024x32, lr 6e-4, warmup 300
hypothesis: matches build-nanogpt's curve at equal tokens, within ~0.05
started: 22:10   expected: ~10-12 h at 40% MFU
result: (fill in) final val, hellaswag, tok/s, MFU, anything weird and at which step

Check on it after 10 minutes (warmup done? loss below 7?), then go to bed.

Sample from your checkpointsHands only

Write sample.py: load a checkpoint into the uncompiled model, encode a prompt, generate 200 tokens with temperature, top-k and top-p as flags. Implement top-p yourself: sort probabilities descending, cumulative sum, mask everything after the point where the sum passes p (keep at least one token), renormalise, torch.multinomial.

Fix the prompt (“The most important thing about learning is”) and the seed. Generate from the step-500, step-2,000 and final checkpoints, and paste all three into LOG.md. Then, from the final checkpoint, compare greedy, τ = 0.7, τ = 1.0 and τ = 1.5 with top-p 0.9.

Evaluate: val perplexity and HellaSwagAgent: review after

Two numbers for the final checkpoint:

  1. Val loss and perplexity on the first 100 batches of the val shard (more than the 20 used during training, for a steadier number).
  2. HellaSwag accuracy on the 10,042 validation examples. Download the val split (the original release is a JSONL file in the rowanz/hellaswag GitHub repo; build-nanogpt’s hellaswag.py shows a loader). For each example, build four token sequences context + ending, compute the mean cross-entropy over just the ending tokens, and pick the lowest. Also evaluate OpenAI’s GPT-2 124M weights (from Hugging Face) with the same code, so you have a reference measured by your harness.

Have an agent review the eval code for the classic bugs: scoring the context tokens too, an off-by-one in which positions count as “ending”, or padding tokens included in the mean.

Done when
  • sanity.py passes all three checks, and you have seen the random-token check fail with y = x.
  • An LR sweep plot (five curves plus the U-shaped summary) exists, made from five runs that differ only in LR.
  • You have measured tok/s and MFU for four settings and know which switch bought the most.
  • A ~2.5B-token run of the 124M model finished (or resumed from a checkpoint and then finished), with a log.jsonl and a LOG.md entry.
  • sample.py produces readable paragraphs from your final checkpoint.
  • You have a val loss, a perplexity and a HellaSwag accuracy for your model and for OpenAI’s GPT-2 124M, measured by the same code.

Measure

Fill this in as you go. The “expect” column is my best guess from the arithmetic above and from published reproductions; where I can’t know (anything Spark-specific), it says so.

MeasurementYoursExpectWhy
Init loss, 124M10.8-11.2 (I got 10.98)ln(50,257) = 10.82; near-uniform softmax
Overfit one batch, loss at step 300under 0.1 (I got 0.005 on a 4-layer model)memorising 256 tokens is trivial
Measured bf16 matmul peak≤ 125 TFLOP/s (estimate)check yourself
tok/s, fp32, no compilelowest of the fourno tensor cores for most math
tok/s, bf16a few × fp32tensor cores
tok/s, bf16 + compile+10-50% over bf16 (check)fused elementwise ops; matters more on low bandwidth
MFU, best setting (vs 125)25-45% (approximate)40% ⇒ ~58K tok/s for this model
Hours for 2.5B tokens~10-196ND / (peak × MFU), Figure 006
LR sweep best peak LR (30M model)around 1e-3 to 3e-3 (check)smaller models tolerate higher LR
Final val lossroughly 3.2-3.5FineWeb-Edu at 2.5B tokens; compare build-nanogpt’s curve at the same token count
Final val perplexity~25-35e^loss
HellaSwag, your model~27-30% (approximate)random is 25%; GPT-2 124M ≈ 29.5%
HellaSwag, OpenAI GPT-2 124M, your harness~29-30%if not, your harness has a bug
Checkpoint size~1.5 GB0.5 GB weights + 1 GB Adam state

What to look for:

Break It

Each of these is a short run (a few hundred steps of the 30M model is enough) plotted on the same axes as a good run. The point is to see the failure once, so you recognise it the next time it happens by accident.

  1. Unshifted targets. Set y = x in the loader. The loss dives toward zero and both train and val look superb. Then sample from it: garbage. Lesson: the loss measures what you told it to measure; nothing more. Leaking validation data into training is the same bug, slower.
  2. No warmup, 5× the LR. Set warmup_steps=0 and multiply max_lr by 5. Watch the first 100 steps on a log axis and watch the gradient-norm column: the norm spikes before the loss does. Then turn clipping off as well and see whether it diverges completely. Lesson: warmup and clipping exist for the first and worst moments of the run.
  3. Forget to divide by grad_accum. Use loss.backward() instead of (loss / grad_accum).backward().
Predict firstwrite it down

With AdamW, accumulation 32, and clipping at 1.0, how bad is forgetting the division? Is it like a 32× higher LR?

Reveal

Probably much less bad than you’d guess, and that is the interesting part. With plain SGD it would be a 32× bigger step. But Adam divides each gradient by the square root of its own running squared average, so multiplying every gradient by 32 mostly cancels out (only epsilon notices). What does change: the gradient norm is now 32× larger, so clipping at 1.0 fires on every single step, quietly turning your optimizer into “normalised-gradient Adam”; and your logged loss is 32× too big if you log the summed value. Run it and compare curves; I expect a small difference rather than a disaster, but measure it. The general lesson: knowing why a bug is harmless or harmful requires knowing what the optimizer does with gradient scale.

  1. Replay one shard. Make the loader reshuffle and restart after the first shard (100M tokens) instead of moving to the next one, and run the 30M model long enough for several passes. You will see the “tiny data” curve from Figure 005: train keeps dropping with a cliff at each pass, val turns up. Lesson: in pretraining, rising val loss almost always means a data-pipeline bug, not a model problem.

Explain It Back

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

1. Why is the loss of a freshly initialised language model about ln(vocab size), and what do you conclude if it isn’t?

With small random weights, the logits are all close to zero, so the softmax gives every token about the same probability, 1/V. Cross-entropy is −ln of the probability of the correct token, so −ln(1/V) = ln(V): 10.82 for GPT-2’s 50,257 tokens. If it is much higher, the model starts out confidently wrong, which means the logits are big: init too large, a missing scaling, or the wrong vocab size. If it is much lower, it can already see the answer: a leak. Either way you stop before training.

2. Walk me through the training recipe for a 124M GPT and why each piece is there.

AdamW with betas (0.9, 0.95) and weight decay 0.1 applied only to the weight matrices, not to biases or LayerNorm parameters, because shrinking a norm’s gain just shrinks that layer’s output. Batch size of about 0.5M tokens per step, built from small micro-batches with gradient accumulation, dividing each micro-batch’s loss by the number of micro-batches. Learning rate: linear warmup over a few hundred steps, because the random network and empty Adam statistics make the first steps fragile, then cosine decay to 10% of the peak so the model can settle into the bottom of the valley at the end. Gradient clipping at norm 1.0 to cap the damage from a freak batch, with the norm logged as an early warning. bf16 autocast, flash attention and torch.compile for speed; none of them change what the model learns. Checkpoints that include optimizer state, step and data position, saved atomically.

3. You have a fixed compute budget. How do you decide model size versus number of tokens, and how long will it take?

Training compute is about 6 × N × D FLOPs: 2N per token for the forward pass and 4N for the backward. Chinchilla found that for a fixed budget, the lowest loss comes from growing N and D together, with about 20 tokens per parameter. So for C FLOPs, N ≈ √(C / 120) and D ≈ 20N. For 124M that’s 2.5B tokens and about 1.9e18 FLOPs. Time is that divided by achieved FLOP/s: peak times MFU. On the Spark, at an estimated 125 TFLOP/s peak and 40% MFU, about 10 hours. If you expect to serve the model a lot, you deliberately go past 20 tokens per parameter with a smaller model, because inference cost scales with N.

4. Your loss dropped nicely, spiked twice, and is now flat around 7. Diagnose it.

The learning rate is too high (or warmup too short, or clipping off). The first spikes were overshoots it recovered from; the last one knocked the weights into a region where the model only predicts token frequencies, which is roughly what a flat line in the 7s means on GPT-2 tokens. Look at the gradient-norm log right before each spike. Restore from the last checkpoint before the first spike with a 2-3× lower peak LR, or restart; check clipping is on. If spikes keep appearing at the same step across runs, inspect the batch at that step: a corrupted or bizarre document can do it too.

5. What is MFU, how do you compute it for your run, and how does it change if you scale to a 1B model or to 8 GPUs?

Model FLOPs utilization: the FLOPs your model’s math needs per second divided by the hardware’s peak. For a run, tokens per second × (6N + 12·layers·width·T) ÷ peak FLOP/s. It counts only useful math, so recomputation or overhead lowers it. Bigger models usually get higher MFU on the same GPU, because the matrices get larger and the fixed overheads (Python, kernel launches, elementwise ops bound by memory bandwidth) become a smaller fraction of each step. Going to 8 GPUs adds communication: every step, gradients must be averaged across devices, so MFU drops unless that communication overlaps with compute, and it drops more on slow interconnects. That is the subject of Distributed training.

Go Deeper

Connections

This lesson cashes in several earlier ones. The uint16 memmap is Virtual memory and Files and disks in practice, and the checkpoint uses the latter’s crash-safe save. bf16 autocast is the payoff of fp32, bf16, fp16, fp8; the checkpoint and optimizer sizes come from Why training needs so much memory; the attention kernel is FlashAttention; the peak FLOP/s and the “why compile matters more on the Spark” argument come from Bandwidth bound; the tokenizer is the one you built in Tokenizers.

Looking ahead, this run is the baseline for everything else in the track. Data a model can eat changes only the data and measures the effect against this curve. Train your own draft model reuses this loop with a distillation loss. Comparing transformer variants is where your creative ideas finally get a fair test: one change at a time, plotted against a run you trust. And Distributed training is what you reach for when one night per run stops being enough.