llm_from_scratch / lessons/08 · assemble & generatelesson 9 / 16

Part III — The GPT

Assembling GPT & generating text

Lesson 07 built one transformer block: a self-contained unit that maps a tensor of shape (B, T, d) back to the same shape, so it can be stacked. But a single block is shallow, and its output is still a stream of vectors — not text. This lesson stacks L of those blocks, caps them with a final normalization and a head that projects back to the vocabulary, and then runs the autoregressive loop from lesson 00 over the real full-model shapes to emit actual tokens.

What the last step left broken
The block from lesson 07 is complete but insufficient in two ways. It is shallow — one round of "mix across positions, then transform each position" cannot capture the layered structure of language — and it is mute: its output is a (B, T, d) tensor of context vectors living in the residual stream, with no way to say which of the 50,257 vocabulary tokens should come next. We built a block precisely so it would tile; now we must actually tile it, translate the final vectors back into vocabulary scores, and wire the whole thing into the generation loop.
Linear position
Forced by: one transformer block (07) is shallow and outputs vectors in the residual stream, not a distribution over the vocabulary — it cannot, on its own, emit text.
New idea: stack L identical blocks for depth, cap the stack with a final LayerNorm and an LM head — a linear map d → V (optionally weight-tied to the token embedding) that turns each position's vector into logits over the whole vocabulary — then run the lesson-00 loop for real: logits → pick the next id → append → crop to the context length → repeat.
Forces next: the assembled model has the right shape but random weights, so its generations are gibberish — it has no notion of "wrong." Lesson 09 defines the single number, the loss, that measures how wrong so gradient descent has something to minimize.
The plan
Four moves. (1) Assemble the full stack end to end — token + position embeddings, L transformer blocks, a final LayerNorm, and an LM head — and follow the shapes from (B,T) to (B,T,V). (2) Count the parameters: derive N ≈ 12Ld² + Vd, plug in the GPT-2 config, and land on ~124M. (3) Turn logits into text with greedy decoding — softmax, argmax, append, crop, repeat — the function the book calls generate_text_simple. (4) See why a fresh model outputs nonsense, which is exactly the hole lesson 09 fills. Then drive both halves in the widget.

1 · The full stack, end to end

Everything in the last seven lessons was a component. Now we bolt them into one module. Reading bottom to top, a GPT is five stages, and it is worth tracing the tensor shape through each so nothing feels like magic.

token ids(B, T)
+ token & pos emb(B, T) → (B, T, d)
L × transformer block(B, T, d) → (B, T, d)
final LayerNorm(B, T, d) → (B, T, d)
LM head (d → V)(B, T, d) → (B, T, V)

Start with a batch of token ids, shape (B, T)B sequences, each T tokens long. The token embedding table (lesson 02) looks up a d-vector per id, and the positional embedding table adds a per-slot vector so the model can tell position 0 from position 5; their sum, after a dropout, is the input to the stack: shape (B, T, d). This is the residual stream — the width-d bus that every block reads from and writes back to.

Then the heart: L transformer blocks in sequence, each the pre-norm causal-attention-plus-MLP unit from lesson 07. Because a block preserves shape, the output after block 12 is still (B, T, d) — but every vector has now been rewritten L times, absorbing context from the whole (masked) sequence at every layer. A final LayerNorm standardizes those vectors one last time so the head sees a well-scaled input.

Finally the LM head: a single linear map from d to V, no bias. It takes each position's d-vector and produces V numbers — one raw score (a logit) per vocabulary token. The output is (B, T, V): for every position in every sequence, a full slate of next-token scores. That is the type signature from lesson 00 made concrete.

class GPTModel:
    tok_emb   : Embedding(V, d)          # id  -> vector
    pos_emb   : Embedding(context_len, d)# slot-> vector
    blocks    : [TransformerBlock] * L   # the stack (lesson 07)
    final_ln  : LayerNorm(d)
    lm_head   : Linear(d, V, bias=False) # vector -> V logits

    forward(ids):                        # ids : (B, T)
        x = tok_emb(ids) + pos_emb(range(T))   # (B, T, d)
        x = dropout(x)
        for block in blocks: x = block(x)       # (B, T, d)
        x = final_ln(x)
        return lm_head(x)                       # (B, T, V)  logits
Weight tying (optional)
The token embedding is a (V, d) table (id → vector); the LM head is a (d, V) matrix (vector → logits) — the same shape transposed. Original GPT-2 ties them, reusing one matrix for both directions, which removes V·d parameters and reflects a nice symmetry: "the vector for token t" should also be "the direction you point to score token t." Tying is why GPT-2-small is quoted at 124M rather than the ~163M you get with a separate head (we do the arithmetic next). Many modern models keep the head separate for a small quality gain; both are correct.

2 · Counting the parameters

The stack looks intricate, but its parameter count collapses to two terms, and the derivation is worth doing once because it explains where a GPT's mass actually lives. Almost everything is in the L repeated blocks; the embeddings and head are a thin skin on top.

Inside one block, ignore the small LayerNorm scale/shift vectors (they contribute only O(d) each). Two big pieces remain:

So one block holds about 4d² + 8d² = 12d² parameters, and L of them give 12Ld². Outside the stack, the dominant term is the vocabulary-sized embedding (or head), V·d. Positional embeddings add only context\_length·d, small by comparison. The whole model is therefore, to leading order,

N ≈ 12·L·d² + V·d

Now plug in GPT_CONFIG_124M — the canonical GPT-2-small configuration: V = 50,257, context\_length = 1,024, d = 768, h = 12 heads, L = 12 layers, drop_rate 0.1, no QKV bias (so d\_head = 768/12 = 64):

12 · 12 · 768² ≈ 85.0M   (the blocks)
50,257 · 768 ≈ 38.6M   (the vocab embedding / head)
N ≈ 85.0M + 38.6M ≈ 124M  (with weight tying)

That is the "124M" in the name. If you keep a separate LM head, you pay the V·d term twice — once for the embedding, once for the head — which is why an actual PyTorch numel() on the untied model reports ~163M, and subtracting the head's 38.6M lands you back at the tied 124M. At float32 (4 bytes each) that untied model is about 622 MB of weights.

Read the split
The blocks (12Ld² ≈ 85M) dominate the vocabulary skin (Vd ≈ 39M), and inside each block the MLP's 8d² is twice the attention's 4d² — most of a transformer's parameters are the position-wise MLP, not attention. This is why scaling a model mostly means growing d and L: the count is quadratic in width and linear in depth. GPT-3 pushes d to 12,288 and L to 96, reaching 175B by the same formula.

3 · From logits to text

The model emits logits, but a person wants words. Bridging that gap is the generation loop — and it is exactly the loop you already drove in lesson 00, now running over the real (B, T, V) shapes instead of a toy score table.

Given a prompt of ids, do one forward pass. The output is (B, T, V), but only the last position matters: the logits at slot T-1 are the model's prediction for the token that comes after the prompt (this is why the causal mask from lesson 06 was essential — position T-1 has legitimately seen all of 0..T-1 and nothing after). Slice that row out — shape (B, V) — turn it into a probability distribution with softmax, and pick a token. The simplest rule is greedy decoding: take the argmax, the single highest-scoring token.

generate(model, ids, n_new, context_len):     # ids : (B, T)
    for _ in range(n_new):
        ids_cond = ids[:, -context_len:]       # crop to the last context_len tokens
        logits   = model(ids_cond)             # (B, T', V)
        logits   = logits[:, -1, :]            # last position only -> (B, V)
        probs    = softmax(logits, dim=-1)     # (B, V)
        next_id  = argmax(probs, dim=-1)       # greedy -> (B, 1)
        ids      = concat([ids, next_id], dim=1)  # append, sequence grows by 1
    return ids

Two subtleties earn their keep. First, softmax before argmax is technically redundant for greedy decoding — softmax is monotonic, so the argmax of the probabilities equals the argmax of the raw logits. We keep it because it names the right mental model (logits → distribution → sample) and because lesson 11 will replace argmax with real sampling, where the probabilities genuinely matter. Second, and non-negotiable: crop to the context length. The model has a fixed positional table of size context\_length (1,024 for GPT-2); feed it a sequence longer than that and there is no position embedding for the overflow and the forward pass breaks. So each step keeps only the last context\_length tokens as input. The generated text can be arbitrarily long; the window the model conditions on is bounded.

That is the whole generation algorithm: forward, slice the last position, pick, append, crop, repeat. Wrap it in a loop with a token budget and you have generate_text_simple.

4 · Why raw output is gibberish

Assemble the model, initialize it, hand it the prompt "Hello, I am" and generate a few tokens. Out comes something like "Hello, I am Featureiman Byeswickattribute argue" — grammatically shaped noise. Nothing is broken. The architecture is exactly GPT-2's, the shapes all check out, the loop runs. The problem is upstream of all of it: the weights are random.

Every matrix — the embeddings, all 12·12 attention and MLP matrices, the head — was filled with small random numbers at initialization. So the logits are essentially random, the argmax picks a near-arbitrary token, and the loop faithfully strings arbitrary tokens together. The model has a perfectly good mechanism for turning context into a next-token distribution; it has simply never been told which distributions are good. It has no notion of "wrong."

This is the wall that ends Part III and opens Part IV. We have a machine with the right shape and 124 million knobs, and no signal telling us how to turn them. To train it, we need a single number that says how far the model's predicted distribution is from the truth at each position — a number that is small when the model is confident and correct and large when it is confident and wrong. That number is the cross-entropy loss, and defining it is the entire job of lesson 09.

5 · Assemble it, then watch it generate

The widget has two halves that together prove the whole lesson. On the left, an assembler: drag d and L (heads and vocabulary are pinned to the GPT-2 config) and watch the parameter count N = 12Ld² + Vd update live, with a bar splitting the total into its "blocks" and "vocab" parts — proving where a GPT's mass lives. On the right, the generation loop from lesson 00, now framed as running over these full-model shapes: Step does one forward-pick-append, Run loops it, and the context-window slider crops the input so you can watch the loop conditioning only on the last few tokens even as the generated sequence grows past the window. The point: a GPT is embeddings + L blocks + a head, and generation is nothing more than lesson 00's loop over real shapes.

GPT assembler + generate
Proves two things at once: (a) a GPT's parameter count is N = 12Ld² + Vd — the stacked blocks plus the vocabulary skin — and (b) generation is the lesson-00 loop (predict → pick → append → crop to context → repeat) running over the assembled model's shapes.
params N (tied)
blocks 12Ld²
vocab Vd

Fixed: V = 50,257 · h = 12 heads · context_length = 1,024. At d = 768, L = 12 this is GPT_CONFIG_124M → ~124M.

tokens generated
0
context window
4
next token

Failure modes & checklist

Failure modes

  • Forgetting to crop to the context length. Once the sequence exceeds context\_length, there is no positional embedding for the overflow and the forward pass errors (or silently misbehaves). Always slice ids[:, -context_len:].
  • Reading the wrong position. Only the last position's logits predict the next token; using an earlier row generates from stale context.
  • Skipping the final LayerNorm. The head then sees un-normalized, drifting activations from the deep stack — the same instability lesson 07 fixed inside the blocks.
  • Expecting sense from an untrained model. Correct architecture ≠ trained model; random weights give gibberish. That is a feature of the setup, not a bug.
  • Double-counting parameters. With weight tying the V·d term is charged once; report ~124M, not ~163M, if the head is tied.

Checklist

  • Token emb + positional emb → dropout → L blocks → final LayerNorm → LM head.
  • Shapes flow (B,T) → (B,T,d) → (B,T,d) → (B,T,V).
  • Head is Linear(d, V, bias=False); tie to the token embedding to save V·d.
  • Generation loop: forward → last-position logits → softmax → argmax → append → crop → repeat.
  • Parameter estimate N ≈ 12Ld² + Vd matches the config to within the small terms.

Checkpoint

Try it
On the left, set d = 768 and L = 12 and confirm N reads ≈ 124M with the blocks bar taking about two-thirds of the total — that is GPT_CONFIG_124M. Now push L to 48 and d to 1,600 (GPT-2 XL's shape) and watch N leap past a billion, because the count is quadratic in d. On the right, set the context window to 2 and press Run: the sequence keeps growing, but each step only ever conditions on the last two tokens — you can see the loop crop. Ask yourself: if the model here had trained weights instead of a toy table, which of lesson 00's three words — accurate, trainable, steerable — would still be missing until we run lesson 10?

Where this points next

You have now assembled the entire architecture — stack, norm, head — and watched it generate text by the same loop that opened the series. But the text is nonsense, and nothing in the model can tell it so. The forward pass produces a distribution; we have no way to say how good that distribution is against the actual next token in the data. Before we can train a single weight, we need a scalar objective: a number that is large when the model puts low probability on the true next token and small when it puts high probability there. That number is cross-entropy, and its exponential is perplexity — the subject of lesson 09, The loss — cross-entropy & perplexity.

Takeaway
A GPT is embeddings + L stacked transformer blocks + a final LayerNorm + an LM head, carrying a batch of ids from shape (B,T) to logits of shape (B,T,V). Its parameter count is N ≈ 12Ld² + Vd — mostly the stacked blocks, dominated by their MLPs — which for the GPT-2-small config (d=768, L=12, V=50,257) gives ~124M with the head tied to the embedding. Generation is lesson 00's loop over these real shapes: forward, take the last position's logits, softmax, pick (greedy = argmax), append, crop to the context length, repeat. A freshly initialized model runs this loop perfectly and produces gibberish — the architecture is right but the weights are random, so it has no notion of "wrong." That missing notion is the loss.

Interview prompts

Companion reads: cs336 · 02 The Transformer counts the same parameters and the matching FLOPs at scale; gpt_mini · 01 Architecture shows this assembled stack as runnable code.