Everything from the earlier chapters, assembled into one working language model. You will prepare the data, write the model, train it, sample from it, make it fast, measure it, and see exactly what changes on the way to a real one.
Chapter 6 · Section 6.1 · 40 min

The data pipeline

This chapter builds a GPT, the same kind of model as GPT-2, and every line of it is ours. Over eight sections you'll write the data pipeline, the model, the training loop, the sampler, the KV cache, and the evaluation code, see exactly what changes when the model is a thousand times bigger, and find out what the finished model can do from its prompt alone. Everything earlier in the book feeds into it, from tokenizers and attention to the Transformer block.

sectionyou buildthe question it answers
6.1the batch samplerwhat does the model actually see?
6.2the full model, in NumPy and PyTorchwhat is GPT-2, exactly?
6.3the training loophow do we make the loss go down, and know it is going down for the right reason?
6.4every decoding methodhow does a distribution become text?
6.5the KV cachewhy is naive generation slow, and how much memory does the fix cost?
6.6the evaluation codehow good is the model, in numbers we can compare?
6.7the LLaMA-style changes and the budgetwhat changes on the way to a real model?
6.8prompts and few-shot testswhat can a pretrained model do without any fine-tuning?

We start with the least glamorous piece, and the one where the silent bugs live. A GPT never sees a poem, a sentence, or a file. It sees long rows of integers, and everything it learns about where documents begin and end, and about what counts as training and what counts as test, comes from decisions we make before the model exists.

Here's a puzzle to keep in mind. You train a small model and its loss falls to almost exactly zero within a hundred steps, on the training set and on the validation set. Is that a great model? Almost certainly not. Two different one-line bugs in the data pipeline produce exactly that curve. By the end of this section you'll be able to name both, and a third bug that makes validation loss look better than it is.

Our corpus, eight sonnets

We'll train on a corpus small enough to read in two minutes and big enough to learn from, eight of Shakespeare's sonnets (numbers 1, 18, 29, 30, 55, 60, 73, and 116, from the public-domain Project Gutenberg edition). That's about 4,900 characters. Real pretraining corpora are millions to billions of times larger (§8.2 shows where they come from), but every step of the pipeline is the same.

Python · cell 1

From characters to integers

The model needs integers, so the first decision is the tokenizer. §2.3 built the real one, byte-level BPE, which GPT-2 uses with a vocabulary of V=50,257V = 50{,}257 (256 byte tokens, 50,000 merges, and one special token). For this chapter we use the simplest tokenizer there is. Every distinct character is a token.

Why characters? Three reasons. Every token is readable, so you can check the pipeline by eye. The vocabulary is tiny, so the embedding table and the output softmax cost almost nothing. And the model trains in seconds on a CPU. The price is length. A character model needs about four times as many positions as GPT-2's tokenizer for the same English text, so its context window covers about a quarter as much text, and attention, whose cost grows with the square of the length (§4.7), pays for it. Nothing else in this chapter depends on the choice. Swap in the BPE tokenizer from §2.2 and every function below works unchanged.

We add one special token, <|endoftext|> (we'll write EOT), the same separator GPT-2 uses. It's not a character. It's an id with no spelling, and we'll put one before every document. Its job is to tell the model "what came before is unrelated".

Python · cell 2

Notice that stoi has no entry for EOT. If a document happens to contain the literal string <|endoftext|>, it is encoded as thirteen ordinary characters, never as the special id. Only our pipeline can insert the real separator. Production tokenizers make the same distinction on purpose (tiktoken refuses to encode special-token strings unless you allow it), because otherwise anyone who can put text in front of the model can forge a document boundary. §2.5 covers special tokens in depth.

Warm-up

A character tokenizer with an end-of-text token

Write build_tokenizer(docs) returning (encode, decode, V, eot). The vocabulary is the sorted set of characters in docs, followed by one EOT id at the end, so eot == V - 1. encode(s) maps a string to a list of ints and must raise KeyError for a character outside the vocabulary. decode(ids) maps ids back to a string and renders the EOT id as "<|endoftext|>". Encoding the literal string "<|endoftext|>" must give thirteen ordinary character ids, never the EOT id.

Hidden tests, hints, and AI help, from $1.99 a month.

Split by document, never by position

Before any training we set aside text the model will never train on, so we can measure how well it does on text it hasn't seen. The question is how to cut.

The tempting way is to shuffle positions, or to take every tenth character, or every tenth window. That is a leak. Neighboring characters are almost perfectly predictable from each other, so a validation character whose neighbors are in the training set has effectively been seen. Validation loss comes out nearly equal to training loss, and you conclude the model generalizes when it has only memorized. This is the third bug from the puzzle.

The rule is to split at the level of the unit you want to generalize across. For a language model that unit is the document. We hold out the eighth sonnet (116) and train on the other seven. At scale the same rule becomes "split by document, then remove near-duplicates across the split", because the web contains many copies of the same text (§8.3 covers deduplication and benchmark contamination).

Each split becomes one long stream of ids, with an EOT before every document and one at the very end.

Python · cell 3

Putting EOT before each document (not only after) matters for generation. The model then learns what tends to follow EOT, which is the opening of a sonnet. To generate a fresh sonnet later, we'll prompt it with a single EOT. OpenAI's GPT-2 code starts its unconditional samples the same way.

The one-line trick, shifting by one

A language model assigns a probability to a whole sequence by the chain rule of probability (§3.1),

p(w1,…,wn)=∏t=1np(wt∣w1,…,wt−1),p(w_1, \dots, w_n) = \prod_{t=1}^{n} p(w_t \mid w_1, \dots, w_{t-1}),

where wtw_t is the token at position tt and nn is the number of tokens. In plain words, the probability of a text is the probability of its first token, times the probability of the second given the first, and so on. So training a language model means training one thing, a predictor of the next token from all the tokens before it.

Take any window of T+1T + 1 consecutive tokens s0,…,sTs_0, \dots, s_T from the stream. Split it into an input and a target that is the same text moved one step left,

x=(s0,s1,…,sT−1),y=(s1,s2,…,sT),yt=xt+1.\mathbf{x} = (s_0, s_1, \dots, s_{T-1}), \qquad \mathbf{y} = (s_1, s_2, \dots, s_T), \qquad y_t = x_{t+1}.

The training loss for the window averages the negative log-probability of every target,

L=−1T∑t=0T−1log⁡pθ ⁣(yt∣x0,…,xt).\mathcal{L} = -\frac{1}{T} \sum_{t=0}^{T-1} \log p_\theta\!\left(y_t \mid x_0, \dots, x_t\right).

In plain words, position tt reads the tokens up to and including xtx_t and must predict yty_t, the token right after it. The model with parameters θ\theta produces all TT predictions in one forward pass.

That last sentence is the trick that makes GPT training efficient. Because the causal mask (§4.4) stops position tt from looking at anything after xtx_t, one window of length TT is really TT training examples, with contexts of length 1, 2, up to TT, computed together for roughly the price of one. Here's what one window of seven characters from sonnet 18 contains.

Python · cell 4

Now the puzzle has two of its answers. If you forget the shift and train with y = x, position tt is asked to predict xtx_t, which it can see. Loss goes to zero in a few steps, and the model has learned to copy. If you shift the wrong way, y[t] = x[t-1], the target is again visible, with the same result. Both bugs give a perfect-looking curve on training and validation data, which is how you recognize them. A real language model on real text never gets close to zero loss, because text is genuinely uncertain.

Context windows and random crops

The model has a maximum context, the block size TT. It exists for two reasons. GPT-2 has a learned position embedding for each position 0,…,T−10, \dots, T-1 (§5.4), so there is no vector for position TT. And attention costs grow as T2T^2. GPT-1 used T=512T = 512, GPT-2 used 1024, and GPT-3 used 2048.

So we train on windows of length TT cut from the stream. The simplest sampler picks a uniformly random start ii for each row of the batch. The last target of a window starting at ii is token i+Ti + T, which must exist, so with nn tokens in the stream

i∈{0,1,…,n−T−1},which is n−T possible windows.i \in \{0, 1, \dots, n - T - 1\}, \qquad \text{which is } n - T \text{ possible windows}.

That range is where off-by-one bugs live. With np.random.default_rng().integers(0, n - T), the upper bound is exclusive, so you get exactly the right set. Write n - T + 1 and once in a while the target row comes back one token short, which crashes, or worse, gets silently padded. Write n - T - 1 and the very last window can never be chosen, which nobody notices.

What exactly is in one training batch?
Each row is one random window cut from the training stream of seven sonnets. The top line is the input x and the bottom line is the target y, the same text one step ahead. Click any target to see which inputs the model may use to predict it.
Loading interactive figure…

How often is each token used?

Random crops don't use every token equally. A target at stream position jj (for 1≤j≤n−11 \le j \le n-1) is inside the window starting at ii exactly when i+1≤j≤i+Ti + 1 \le j \le i + T, so the number of windows that contain it is

cj=min⁡(j,  T,  n−j).c_j = \min(j,\; T,\; n - j).
∂ Counting the windows that contain position j

The window starting at ii holds the targets at positions i+1,…,i+Ti+1, \dots, i+T. So position jj is covered when j−T≤i≤j−1j - T \le i \le j - 1. The start must also be valid, 0≤i≤n−T−10 \le i \le n - T - 1. The number of integers in the intersection is

min⁡(j−1,  n−T−1)−max⁡(0,  j−T)+1.\min(j - 1,\; n - T - 1) - \max(0,\; j - T) + 1 .

There are three cases. In the middle (T≤j≤n−TT \le j \le n - T) both bounds come from the window, giving (j−1)−(j−T)+1=T(j-1) - (j-T) + 1 = T. Near the start (j<Tj \lt T) the lower bound is 0, giving (j−1)+1=j(j-1) + 1 = j. Near the end (j>n−Tj \gt n - T) the upper bound is n−T−1n - T - 1, giving (n−T−1)−(j−T)+1=n−j(n-T-1) - (j-T) + 1 = n - j. All three are the single expression min⁡(j,T,n−j)\min(j, T, n-j), which holds as long as n≥2Tn \ge 2T.

In plain words, every token is used equally often, except the first TT and the last TT of the stream, which ramp down linearly to almost never. With one stream of millions of tokens this edge effect is negligible. It matters when you crop inside short documents instead of across one long stream, because then every document has edges, and the openings and endings of documents, often the most informative parts, get undertrained. Let's check the formula against a simulation.

Python · cell 5

Random crops have a quiet advantage too. The same token lands at a different position in every window that contains it, with a different amount of context before it. The model must learn to predict from short contexts and long ones, at every position, which is exactly what generation will ask of it.

The alternative is to cut the stream into consecutive, non-overlapping chunks and walk through them in order, so that every token is used exactly once per pass over the data (an epoch). That's what large runs do. They see each token about once, so the randomness comes from shuffling documents and data shards before training, and the loader just reads the next B×T+1B \times T + 1 tokens and reshapes them. Karpathy's GPT-2 reproduction uses exactly this "read the next chunk" loader.

Interview

The batch sampler

Implement sample_batch(data, B, T, rng) for a 1-D integer array data. It returns (x, y), both of shape (B, T) and dtype int64, where each row is a window starting at an independent uniform random offset i from rng.integers, x[b] = data[i:i+T], and y[b] = data[i+1:i+T+1]. Every valid start, including the very last one, must be possible, and no row may run off the end. Do it without a Python loop over the batch.

Hidden tests, hints, and AI help, from $1.99 a month.

Batching, and the numbers at scale

A batch is BB windows stacked into two integer arrays of shape (B,T)(B, T). Each batch carries B×TB \times T predictions. With the tiny settings we'll train with in §6.3 (16 windows of T=64T = 64 per step, as two micro-batches of 8) that's 1,024 predictions per step. Here's our sampler, written once for the rest of this section.

Python · cell 6

Real runs are big enough that the storage format matters. Ids are stored as uint16, because GPT-2's 50,257 ids fit under 65,536. That's 2 bytes per token instead of 8 for int64, so ten billion tokens fit in 20 GB, read straight from disk with a memory map and converted to int64 one batch at a time. (nanoGPT's data preparation writes exactly such a file.) The batch is described by how many tokens go into each optimizer step,

tokens per step=B×T×G×ngpu,\text{tokens per step} = B \times T \times G \times n_{\text{gpu}},

where GG is the number of micro-batches whose gradients are accumulated before each update (§6.3) and ngpun_{\text{gpu}} is the number of data-parallel GPUs. GPT-2 trained with 512 sequences of 1,024 tokens per step. The common reproduction targets 219=524,2882^{19} = 524{,}288 tokens per step, for example B=64B = 64, T=1024T = 1024, and 8 GPUs with G=1G = 1, or one GPU with G=8G = 8.

Document boundaries, packing and masking

Our documents are about 600 characters and our window is 64, but at scale the opposite is common. With T=2048T = 2048, most web pages are shorter than one window. There are three ways to fill a window, and every production pipeline picks one of them.

Padding. Put one document per row and fill the rest with a pad token that the loss ignores. It's clean, because no document sees another. But it wastes compute on every pad position. A document of length ℓ\ell chopped into rows of length TT uses ⌈ℓ/T⌉\lceil \ell / T \rceil rows and wastes T⌈ℓ/T⌉−ℓT\lceil \ell / T \rceil - \ell positions.

Packing. Concatenate documents with EOT between them, as we did, and cut windows from the stream. Nothing is wasted, but a window may contain the end of one document and the start of the next, and the second can attend to the first. GPT-3 trained this way. Its paper says documents packed into one sequence were not masked in any special way, only separated by the end-of-text token, which gives the model the information that the context before it is unrelated (Brown et al., 2020). The model learns to mostly ignore what comes before an EOT.

Packing with a document mask. Pack as before, but make attention block-diagonal, so that each token attends only to earlier tokens of its own document, and restart the position ids at each document. You get no waste and no cross-talk. (With learned absolute positions, as in GPT-2, the restart matters, because otherwise a document that begins mid-window is read at the wrong positions. With rotary embeddings (§5.5) attention scores depend only on the offset between two tokens, so once the mask keeps documents apart, restarting the positions changes nothing.) The cost is an attention kernel that accepts variable-length segments (FlashAttention's variable-length interface, or PyTorch's FlexAttention, both in §12.4). LLaMA 3 trained with such a mask and reported that it had limited effect in standard pretraining but mattered for continued pretraining on very long sequences (Dubey et al., 2024).

Python · cell 7

With our 600-character sonnets and T=2048T = 2048, padding wastes about 70 percent of every batch. Real corpora have a long tail of short documents, which is why nearly everyone packs.

strategywasted computecross-document attentionpositionsused by
one document per row, paddedgrows as documents get shorter than TTnonestart at 0 per documentmany fine-tuning setups
packing with EOT separatorsnoneyes, the model learns to ignore itrun across documentsGPT-2, GPT-3, nanoGPT
packing with a block-diagonal masknonenonerestart at 0 per documentLLaMA 3, many SFT pipelines (§14.3)
Core

A document mask for packed windows

Write packed_mask(x, eot) for one window x of shape (T,). A new document starts at every EOT token, and the EOT belongs to the document it opens, so that the prediction made at an EOT position (the first character of the next document) depends on nothing before it. Tokens before the first EOT in the window belong to a document that began before the window. Return (mask, pos), where mask has shape (T, T) and dtype bool, mask[i, j] is True exactly when j <= i and tokens i and j are in the same document, and pos gives each token's position inside its document as seen within the window (it restarts at 0 on each EOT, and starts at 0 for the window's first token).

Hidden tests, hints, and AI help, from $1.99 a month.

In PyTorch

The PyTorch version stores ids compactly, gathers each batch with one indexing operation, and adds the two lines that matter on a GPU. Pinned (page-locked) host memory lets the copy to the GPU run asynchronously, overlapping with compute.

🔥 In PyTorchfor a machine with PyTorch

buf[1:].view(B, T) is worth a second look. Because the loader reads B×T+1B \times T + 1 tokens and the next batch starts B×TB \times T later, the last target of each batch is the first input of the next one. No token is lost between batches, and none is a target twice.

Read more

Watch next

🎬 Let's build GPT: from scratch, in code, spelled out. · Andrej Karpathy · starts at 9:27Karpathy tokenizes Tiny Shakespeare, splits it, and builds the batch loader that this section formalizes.
🎬 Let's reproduce GPT-2 (124M) · Andrej Karpathy · starts at 45:50The same pipeline at GPT-2 scale, from (B, T) batches to a small sequential data loader.