GPT Model Assembly
A GPT model is twelve stacked blocks, a token embedding, a learned position embedding, a final LayerNorm, and a language model head tied to the token embedding. Assemble them, count the parameters against the 124 million reference, then generate text with multinomial sampling, temperature, and top-k.
Learning Objectives
- Assemble the transformer block from lesson 34 into a full GPT model: token embedding, position embedding, N blocks, final LayerNorm, language model head.
- Reproduce the 124 million parameter configuration: vocab 50257, context 1024, embedding 768, twelve heads, twelve layers.
- Tie the language model head weights to the token embedding and explain why that saves ~38 million parameters at this scale.
- Generate text from a prompt with multinomial sampling, temperature scaling, and top-k truncation, holding context length with a sliding window.
- Measure parameter count and forward pass cost against the 124M target.
The Problem
A transformer block maps vectors to vectors and nothing else. To make a language model you wrap it in four steps: turn token ids into vectors, mix in position information, run the result through the stack, and project back to vocabulary logits. Skip one and the model fails to forward, loses track of word order, or has no way to emit a token.
The shape of the model matters too. GPT-2 small has 124 million parameters at exactly the configuration above, and the number falls out of the parts. Vocab 50257 times embedding 768 is the token table. Position 1024 times 768 is the position table. Twelve blocks at roughly 7 million parameters each come to 84 million. The final head reuses the token table through weight tying, so it adds nothing. Sum the pieces and you land on 124 million. If your parameter count misses the reference, you wired something wrong.
The Concept
A GPT model is a token embedding, a position embedding, a stack of transformer blocks, a final LayerNorm, and a language model head that maps back to vocabulary logits. Token ids become token vectors and position ids become position vectors. The two are added and sent through the stack. The final LayerNorm is the one piece outside the blocks that survives every modern variant, and the LM head reuses the token embedding matrix, which is what weight tying means.
Weight tying
Weight tying means the LM head and the token embedding are the same parameter tensor, used twice. The embedding has shape (vocab, d_model), and the head has to project from d_model back to vocab, which is the transpose of that shape. At vocab 50257 and d_model 768 the matrix holds 38 million parameters. Untied, you pay for it twice. Tied, you pay once, and you also get a slightly cleaner gradient signal because the embedding and the head update together.
Position embedding is learned, not sinusoidal
A learned position embedding is a table of trainable vectors, one per position. GPT-2 uses one parameter tensor of shape (1024, 768). At every forward the model looks up positions 0 through T-1 and adds the result to the token embedding. It is the simplest of the position schemes (RoPE, ALiBi, and T5 relative bias are the alternatives), and it is what the 124M reference uses.
Generation: temperature, top-k, multinomial
Generation is autoregressive: the model produces one token, appends it to the sequence, and runs again. At each step it returns logits over the full vocabulary at every position. You keep the last position only, divide by temperature, optionally mask all but the top k logits to negative infinity, softmax to get probabilities, and sample one token from that distribution.
The three knobs change behavior in different ways. Temperature near zero collapses to greedy, and temperature one matches the model's natural distribution. Top-k of one is greedy, while top-k of forty filters the long tail. Combinations matter, and the next lesson on training uses generation as a qualitative eval signal.
Build It
code/main.py implements:
class GPTConfig, a dataclass with the 124M defaults:vocab_size=50257,context_length=1024,d_model=768,num_heads=12,num_layers=12,mlp_expansion=4,dropout=0.1,use_bias=True,weight_tying=True.class GPTModelwith token embedding, position embedding, embedding dropout, twelveTransformerBlocks, a final LayerNorm, and anlm_headthat ties to the token embedding when the flag is set.- A
count_parametershelper that returns the unique parameter count, so weight tying is honored in the count. - A
generatefunction that does temperature, top-k, multinomial sampling, and a sliding window context. - A demo that builds the model, prints the parameter count next to the reference 124M, and generates a short sequence from a fixed five-token prompt (
[1, 2, 3, 4, 5]) on a tiny config to show the pipeline end to end.
Run it:
python3 code/main.pyOutput: the parameter count alongside the 124M reference, a confirmation that the LM head and token embedding share storage when tying is on, an untied recount whose delta must equal vocab_size * d_model (asserted), the logits shape from one forward pass, generated token ids from the fixed prompt, and a sliding-window check that an 80-token random prompt on a 64-token context still generates 4 more tokens (84 total).
To keep the demo fast, the script also runs a tiny config (vocab_size=512, context_length=64, d_model=64, num_heads=4, num_layers=2, dropout=0.0) end to end and prints the generated token sequence inline. The 124M config is built (twice, once tied and once untied), but only its parameter count and one forward pass on a random 32-token input are exercised.
Stack
torchfor the tensor math, autograd, and module plumbing.code/main.pyreimplements the block pattern from lesson 34 locally.
Production patterns in the wild
Three patterns separate a model that runs from a model that ships.
Initialize the residual projections small. The attention output projection and the second MLP linear both feed straight into a residual add. Give them the same standard deviation as every other linear and the residual stream grows with depth, pushing the final LayerNorm into a hot regime. Scale their std by 1 / sqrt(2 * num_layers) and the stream stays in a sane range through twelve layers.
Cache the position id tensor. torch.arange(T) allocates fresh memory at every forward. Allocate once in __init__ for the maximum context, slice the first T entries per call, and skip the allocator round trip.
Tie weights at the parameter level. Setting lm_head.weight = token_embedding.weight shares the tensor, and copying does not. The optimizer should update one parameter and autograd should accumulate into one. Copy instead and the head drifts away from the embedding, so tying buys you nothing.
Use It
- The model class here has the same shape as the one the next lesson trains.
- Swap the learned position embedding for RoPE and you get the LLaMA family, with no change to the block or the head.
- Swap GELU for SiLU and LayerNorm for RMSNorm and you get the rest of the LLaMA changes.
- The generation function works with any logits source, not only this model. In lesson 37 you can pull logits from a pretrained GPT-2 file and reuse the same loop.
Exercises
- Untie the LM head from the token embedding and recount parameters. Verify the delta is 50257 times 768 = 38 million.
- Replace the learned position embedding with a sinusoidal table computed at construction time. Confirm the model still forwards and the parameter count drops by 786,432.
- Add a
greedy=Trueflag to generation that skips sampling and picks argmax. Confirm the sequence is deterministic across runs. - Add a
repetition_penaltyknob that divides the logit of any token in the prompt or generated history by a constant before softmax. Show on a fixed prompt that values above one reduce repeat counts in the output. - Add
top_p(nucleus) sampling next totop_k. Two-line check that the sum of probabilities of the kept tokens exceedstop_p.
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| Weight tying | "Tied embeddings" | The LM head and the token embedding share the same parameter tensor; saves vocab times d_model parameters and matches the GPT-2 reference |
| Position embedding | "Learned positions" | A separate table of shape (context length, d_model) added to token vectors; learned end to end |
| Sliding window context | "Context cap" | When the prompt plus generated tokens exceed the context length, drop the oldest tokens so the active window fits |
| Top-k sampling | "K truncation" | Keep the K logits with the highest values, mask the rest to negative infinity, softmax over the remainder |
| Temperature | "Sampling temperature" | Divide logits by T before softmax; T less than 1 sharpens, T equal to 1 keeps the natural distribution, T greater than 1 flattens |
My bet is that weight tying is the first thing you should reach for at this scale and the last thing you should skip to "keep the code simple." The 38 million parameters it saves are a real fraction of a 124 million model, and the token embedding is already the matrix the head needs. I'd also build the untied version once, as in exercise 1, and check that the delta is exactly vocab times d_model. That check tells you the tie is wired at the parameter level and not copied. If the count misses the 124M reference, I'd suspect the tie or the block wiring before I'd suspect the arithmetic.
Further Reading
- Phase 19 lesson 34 for the block this model stacks.
- Phase 19 lesson 36 for the training loop that drives this model with cross entropy loss.
- Phase 19 lesson 37 for loading pretrained GPT-2 weights into this exact architecture.
- Phase 7 lesson 07 (GPT causal language modeling) for the math of next token prediction.
- Phase 10 lesson 04 (pre training mini GPT) for the original training procedure on the same architecture.
Code
- main.py (python, 297 lines)
- tests/test_model.py (python, 124 lines)