Part 2 · Large language models · module 1 of 4 · 24 min
Build a GPT
Assemble a working GPT from an embedding table upwards — softmax and sampling, layernorm, causal attention, the transformer block, multi-head attention, and the GPU.
The essence
A GPT is an embedding lookup, a stack of identical blocks that each read from and write to a shared running vector, and a projection back to vocabulary scores. Every block does exactly two things: attention lets each position gather information from earlier positions, and an MLP does per-position computation on the result. Nothing else. The apparent sophistication comes from stacking that pair a few dozen times.
Why it matters
You cannot interpret, debug, fine-tune or reason about an LLM you cannot draw. Everything in the interpretability parts of this curriculum — hooks, ablations, the logit lens, activation patching — refers to specific tensors inside this architecture. Building it once, small, is what makes those names mean something rather than being incantations.
Topics covered (28)
- 01Why build one when you can download one
- 02Model 1: embedding (input) and unembedding (output)
- 03Understanding nn.Embedding vs. nn.Linear
- 04GELU vs. ReLU activations
- 05Softmax and temperature: maths, NumPy and PyTorch
- 06Sampling tokens with torch.multinomial
- 07Sampling strategies: greedy, top-k, top-p
- 08Extreme-value behaviour of softmax
- 09What, why, when and how to layernorm
- 10Model 2: position embedding, layernorm, tied output, temperature
- 11Temporal causality via linear algebra
- 12Averaging the past while ignoring the future
- 13The attention algorithm
- 14Coding attention manually and with PyTorch
- 15Model 3: one attention head
- 16The transformer block in theory
- 17The transformer block in code
- 18Model 4: multiple transformer blocks
- 19Multi-head attention: theory and implementation
- 20Working on the GPU
- 21Model 5: a complete GPT-2 on the GPU
- 22Timing a model on CPU vs. GPU
- 23Inspecting OpenAI's GPT-2
- 24Summarising GPT as a set of equations
- 25Visualising a nano-GPT
- 26Counting parameters
- 27Distributions of GPT-2's trained weights
- 28Do we really need Q? Ablating a projection
Why build one
Downloading GPT-2 takes one line, and you will download it constantly. But a downloaded model is opaque: you cannot say what `hidden_states[6]` is, why ablating a head changes an output, or where a shape error comes from.
Building up in five stages — embedding only, then positions and layernorm, then one attention head, then a stack of blocks, then the full GPU-resident model — means every tensor in the finished thing has a place you can point to.
import torch, torch.nn as nn
class Model1(nn.Module):
"""Predicts the next token from the current token alone. No context."""
def __init__(self, vocab, dim=64):
super().__init__()
self.tok_emb = nn.Embedding(vocab, dim) # ids -> vectors
self.head = nn.Linear(dim, vocab) # vector -> scores
def forward(self, idx): # idx: [B, T] integers
x = self.tok_emb(idx) # [B, T, dim]
return self.head(x) # [B, T, vocab]
# This is a learned bigram model. It cannot use context, so its loss floors out
# early — which is exactly the point: everything added later exists to fix that.Softmax, and temperature
Softmax turns arbitrary real scores into a probability distribution: exponentiate each, divide by the sum. Exponentiating is what makes it winner-favouring — a score two points higher becomes about seven times more probable.
Temperature divides the logits before the exponential. Below 1 the distribution sharpens toward the argmax; above 1 it flattens toward uniform. At temperature 0 it is pure greedy selection. Nothing about the model changes — only how peaked the distribution you sample from is.
In practice softmax must be computed by subtracting the maximum logit first. exp(1000) overflows to infinity in float32; subtracting the max leaves the result mathematically identical and numerically safe. Every library does this, and it is worth knowing because it is the first thing to suspect when you see NaNs.
import numpy as np, torch, torch.nn.functional as F
def softmax(logits, temperature=1.0):
z = np.asarray(logits, dtype=np.float64) / temperature
z = z - z.max() # shift for stability; result unchanged
e = np.exp(z)
return e / e.sum()
logits = [2.0, 1.0, 0.1]
print(softmax(logits, 1.0)) # [0.659 0.242 0.099]
print(softmax(logits, 0.5)) # sharper
print(softmax(logits, 2.0)) # flatter
# Without the shift, large logits overflow:
np.exp(np.array([1000.0])) # inf -> nan after division
# PyTorch, on the last dimension:
print(F.softmax(torch.tensor(logits) / 0.7, dim=-1))Sampling strategies
| Strategy | How | Character |
|---|---|---|
| Greedy | Always take the argmax | Deterministic; repetitive and prone to loops |
| Pure sampling | Sample from the full distribution | Diverse; occasionally incoherent |
| Top-k | Keep the k highest, renormalise, sample | Fixed cut ignores how peaked the distribution is |
| Top-p (nucleus) | Keep the smallest set summing to p | Adapts: narrow when confident, wide when not |
| Temperature | Rescale logits before any of the above | Orthogonal dial, combines with all of them |
import torch
def sample_next(logits, temperature=1.0, top_k=None, top_p=None):
logits = logits / max(temperature, 1e-8)
if top_k is not None:
kth = torch.topk(logits, top_k).values[..., -1, None]
logits = logits.masked_fill(logits < kth, float("-inf"))
if top_p is not None:
srt, idx = torch.sort(logits, descending=True)
cum = torch.softmax(srt, dim=-1).cumsum(dim=-1)
drop = cum - torch.softmax(srt, dim=-1) > top_p # keep the first one always
srt = srt.masked_fill(drop, float("-inf"))
logits = torch.empty_like(logits).scatter_(-1, idx, srt)
probs = torch.softmax(logits, dim=-1)
return torch.multinomial(probs, num_samples=1) # samples proportionally
@torch.no_grad()
def generate(model, idx, max_new=50, **kw):
for _ in range(max_new):
logits = model(idx[:, -model.block_size:]) # crop to context length
nxt = sample_next(logits[:, -1, :], **kw) # only the last position
idx = torch.cat([idx, nxt], dim=1) # append and repeat
return idxNote: torch.multinomial samples indices in proportion to the given weights. Using argmax here instead is the difference between sampling and greedy decoding.
GELU vs. ReLU
ReLU zeroes anything negative. It is fast and works, but its derivative is exactly zero for all negative inputs, so a neuron pushed into that region receives no gradient and can stop learning permanently.
GELU multiplies the input by the probability that a standard normal is below it, giving a smooth curve that passes slightly below zero for small negatives. Because the gradient is never exactly zero, nothing dies. Transformers use GELU throughout — GPT-2 included — and the empirical gain is small but consistent.
Layer normalisation
Layernorm rescales each token's vector to zero mean and unit variance across its own features, then applies a learned gain and bias. Crucially it normalises per token, not across the batch, so it is independent of batch size and behaves identically at training and inference — which is why transformers use it rather than batchnorm.
It exists to keep activations in a well-behaved range as depth grows. Without it, a fifty-layer residual stream drifts to wildly different scales and training diverges.
Placement matters. The original transformer put normalisation after the sublayer; every modern model, GPT-2 included, puts it before (pre-norm), which leaves a clean unnormalised residual path from input to output and trains far more stably at depth.
import torch, torch.nn as nn
class Model2(nn.Module):
def __init__(self, vocab, dim=64, block_size=32):
super().__init__()
self.block_size = block_size
self.tok_emb = nn.Embedding(vocab, dim)
self.pos_emb = nn.Embedding(block_size, dim) # learned, like GPT-2
self.ln = nn.LayerNorm(dim)
self.head = nn.Linear(dim, vocab, bias=False)
# Weight tying: the unembedding IS the transposed embedding. Saves
# vocab*dim parameters and usually improves quality.
self.head.weight = self.tok_emb.weight
def forward(self, idx):
B, T = idx.shape
pos = torch.arange(T, device=idx.device)
x = self.tok_emb(idx) + self.pos_emb(pos) # broadcast over batch
return self.head(self.ln(x))Causality as linear algebra
A causal language model must never see the future: when predicting position 5 it may use positions 0–5 and nothing beyond. The clean way to express 'average everything up to here' is multiplication by a lower-triangular matrix of weights.
That reframing is the whole trick. Once mixing over positions is a matrix multiply, making the weights depend on content rather than being a fixed average gives you attention. Attention is a content-dependent lower-triangular mixing matrix — that is the entire idea.
import torch
T = 4
ones = torch.tril(torch.ones(T, T)) # lower triangular
w = ones / ones.sum(dim=1, keepdim=True) # each row averages its prefix
print(w)
# [[1.00, 0, 0, 0 ],
# [0.50, 0.50, 0, 0 ],
# [0.33, 0.33, 0.33, 0 ],
# [0.25, 0.25, 0.25, 0.25]]
x = torch.randn(T, 8)
print(torch.allclose(w @ x, torch.stack([x[:i+1].mean(0) for i in range(T)])))
# Equivalent, and the form attention actually uses: mask with -inf, then softmax.
scores = torch.zeros(T, T).masked_fill(ones == 0, float("-inf"))
print(torch.softmax(scores, dim=-1)) # same uniform prefix weights
# -inf is used because exp(-inf) = 0 exactly. Masking after softmax would
# leave the removed positions contributing to the denominator.The attention algorithm
Each token produces three vectors by linear projection. The query says what this position is looking for; the key says what this position offers; the value says what it passes on if selected.
Score every pair by the dot product of query and key. Divide by the square root of the head dimension — without that, dot products of d-dimensional vectors grow like √d, softmax saturates, and gradients vanish. Mask the future to −∞, softmax each row into weights, and take the weighted sum of the value vectors.
So attention is: decide how much each earlier position matters to me, then average their values accordingly. Q and K only ever appear together in the score, which is why the natural question 'do we really need both?' has a subtle answer — a single low-rank matrix QᵏK could replace the pair, but factorising it into two smaller matrices is what makes multi-head attention cheap and gives each head a limited-rank view.
import torch, torch.nn as nn, torch.nn.functional as F
class Head(nn.Module):
def __init__(self, dim, head_dim, block_size):
super().__init__()
self.key = nn.Linear(dim, head_dim, bias=False)
self.query = nn.Linear(dim, head_dim, bias=False)
self.value = nn.Linear(dim, head_dim, bias=False)
# A buffer moves with .to(device) but is not a parameter.
self.register_buffer("mask", torch.tril(torch.ones(block_size, block_size)))
def forward(self, x): # x: [B, T, dim]
B, T, _ = x.shape
k, q, v = self.key(x), self.query(x), self.value(x)
att = q @ k.transpose(-2, -1) * k.size(-1) ** -0.5 # [B, T, T]
att = att.masked_fill(self.mask[:T, :T] == 0, float("-inf"))
att = F.softmax(att, dim=-1)
return att @ v # [B, T, head_dim]
# PyTorch's fused equivalent — same maths, far less memory:
# F.scaled_dot_product_attention(q, k, v, is_causal=True)Note: The scale is 1/√head_dim, not 1/√dim. Getting that wrong trains, just badly, which makes it hard to spot.
Multi-head attention
One head produces one mixing pattern. Splitting the model dimension into h independent heads lets the model attend to several things at once — one head tracking syntactic agreement, another tracking a repeated name.
The implementation detail that matters: you do not run h separate attentions. You project once to the full dimension, reshape to [batch, heads, time, head_dim], and let the batched matmul handle all heads simultaneously. Concatenate the outputs and pass through one more linear projection.
Note that head_dim = dim / h, so more heads means narrower heads. The parameter count is unchanged; you are trading each head's expressiveness for the number of distinct patterns.
import torch, torch.nn as nn, torch.nn.functional as F
class MultiHeadAttention(nn.Module):
def __init__(self, dim, n_heads, block_size, dropout=0.1):
super().__init__()
assert dim % n_heads == 0
self.n_heads, self.head_dim = n_heads, dim // n_heads
self.qkv = nn.Linear(dim, 3 * dim, bias=False) # all three at once
self.proj = nn.Linear(dim, dim)
self.drop = nn.Dropout(dropout)
self.register_buffer("mask", torch.tril(torch.ones(block_size, block_size)))
def forward(self, x):
B, T, C = x.shape
q, k, v = self.qkv(x).split(C, dim=2)
# [B, T, C] -> [B, heads, T, head_dim]; heads become a batch dimension.
shape = lambda t: t.view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
q, k, v = shape(q), shape(k), shape(v)
att = (q @ k.transpose(-2, -1)) * self.head_dim ** -0.5
att = att.masked_fill(self.mask[:T, :T] == 0, float("-inf"))
att = self.drop(F.softmax(att, dim=-1))
out = (att @ v).transpose(1, 2).contiguous().view(B, T, C) # re-merge
return self.drop(self.proj(out))Note: .contiguous() before .view() is required because transpose only changes strides. Omitting it raises a runtime error about non-contiguous memory.
import torch, torch.nn as nn
class Block(nn.Module):
"""Pre-norm: normalise, sublayer, add back to the residual stream."""
def __init__(self, dim, n_heads, block_size, dropout=0.1):
super().__init__()
self.ln1 = nn.LayerNorm(dim)
self.attn = MultiHeadAttention(dim, n_heads, block_size, dropout)
self.ln2 = nn.LayerNorm(dim)
self.mlp = nn.Sequential(
nn.Linear(dim, 4 * dim), # the 4x expansion is conventional
nn.GELU(),
nn.Linear(4 * dim, dim),
nn.Dropout(dropout),
)
def forward(self, x):
x = x + self.attn(self.ln1(x)) # communicate across positions
x = x + self.mlp(self.ln2(x)) # compute within each position
return x
class GPT(nn.Module):
def __init__(self, vocab, dim=384, n_heads=6, n_layers=6, block_size=256, dropout=0.1):
super().__init__()
self.block_size = block_size
self.tok_emb = nn.Embedding(vocab, dim)
self.pos_emb = nn.Embedding(block_size, dim)
self.drop = nn.Dropout(dropout)
self.blocks = nn.ModuleList(
[Block(dim, n_heads, block_size, dropout) for _ in range(n_layers)])
self.ln_f = nn.LayerNorm(dim)
self.head = nn.Linear(dim, vocab, bias=False)
self.head.weight = self.tok_emb.weight # tied
def forward(self, idx):
B, T = idx.shape
assert T <= self.block_size, "sequence longer than the context window"
pos = torch.arange(T, device=idx.device)
x = self.drop(self.tok_emb(idx) + self.pos_emb(pos))
for block in self.blocks:
x = block(x)
return self.head(self.ln_f(x))GPU, and what actually costs time
Moving to the GPU means putting both model and data on the device; a mismatch is the most common runtime error in PyTorch. Speed-up on a small model is modest and can even be negative, because transfer overhead dominates — the win appears once matrices are large enough to saturate the hardware.
Timing correctly requires care: CUDA calls are asynchronous, so you must call torch.cuda.synchronize() before reading the clock, and discard the first iterations as warm-up.
import time, torch
def benchmark(model, x, n=20):
device = x.device
for _ in range(3): # warm-up: kernels, autotuning
model(x)
if device.type == "cuda":
torch.cuda.synchronize()
t0 = time.perf_counter()
for _ in range(n):
model(x)
if device.type == "cuda":
torch.cuda.synchronize() # without this you time the queue
return (time.perf_counter() - t0) / n
x = torch.randint(0, vocab, (16, 256))
print("cpu", benchmark(model.cpu(), x.cpu()))
if torch.cuda.is_available():
print("gpu", benchmark(model.cuda(), x.cuda()))Counting parameters, and where they live
Parameter counts are a good sanity check on your understanding. Token embeddings are vocab × dim, which dominates small models — GPT-2's embedding table alone is 38.6M of its 124M parameters. Each block holds 4·dim² for attention (Q, K, V and the output projection) plus 8·dim² for the MLP's two 4× layers: 12·dim² per block, ignoring biases and layernorms.
So total ≈ vocab·dim + block_size·dim + 12·n_layers·dim². For GPT-2 small that is 50257·768 + 1024·768 + 12·12·768² ≈ 124M, which matches. Being able to reproduce that number means you know what is in the model.
from transformers import AutoModelForCausalLM
import torch
def count(model):
total = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
return total, trainable
gpt2 = AutoModelForCausalLM.from_pretrained("gpt2")
print(count(gpt2)) # (124439808, 124439808)
# Where they are:
for name, p in gpt2.named_parameters():
if p.dim() > 1:
print(f"{name:40} {tuple(p.shape)} {p.numel():>10,}")
# Trained weight distributions are informative: most are near-Gaussian and
# tightly centred, layernorm gains sit near 1, and a small number of very
# large outlier weights appear in specific dimensions.
w = gpt2.transformer.h[0].attn.c_attn.weight.detach()
print(w.mean().item(), w.std().item(), w.abs().max().item())GPT in equations
| Step | Equation |
|---|---|
| Input | x₀ = E[idx] + P[pos] |
| Attention scores | A = softmax( mask(QKᵀ / √d_h) ) |
| Attention out | attn(x) = A·V·W_O |
| Block | x' = x + attn(LN(x)); x'' = x' + MLP(LN(x')) |
| MLP | MLP(x) = W₂·GELU(W₁x) |
| Output | logits = LN_f(x_L)·Eᵀ |
| Loss | L = −Σ log p(token_{t+1} | tokens_{≤t}) |
Worth remembering
- A transformer block is attention (mix across positions) plus an MLP (compute per position), each wrapped in a residual connection and a layernorm.
- Causality is enforced by adding −∞ to the upper triangle of the attention scores before softmax.
- Multi-head attention is one matmul reshaped, not several separate attentions.