Skip to content
GKgkml.dev
← LLM mechanisms

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)
  1. 01Why build one when you can download one
  2. 02Model 1: embedding (input) and unembedding (output)
  3. 03Understanding nn.Embedding vs. nn.Linear
  4. 04GELU vs. ReLU activations
  5. 05Softmax and temperature: maths, NumPy and PyTorch
  6. 06Sampling tokens with torch.multinomial
  7. 07Sampling strategies: greedy, top-k, top-p
  8. 08Extreme-value behaviour of softmax
  9. 09What, why, when and how to layernorm
  10. 10Model 2: position embedding, layernorm, tied output, temperature
  11. 11Temporal causality via linear algebra
  12. 12Averaging the past while ignoring the future
  13. 13The attention algorithm
  14. 14Coding attention manually and with PyTorch
  15. 15Model 3: one attention head
  16. 16The transformer block in theory
  17. 17The transformer block in code
  18. 18Model 4: multiple transformer blocks
  19. 19Multi-head attention: theory and implementation
  20. 20Working on the GPU
  21. 21Model 5: a complete GPT-2 on the GPU
  22. 22Timing a model on CPU vs. GPU
  23. 23Inspecting OpenAI's GPT-2
  24. 24Summarising GPT as a set of equations
  25. 25Visualising a nano-GPT
  26. 26Counting parameters
  27. 27Distributions of GPT-2's trained weights
  28. 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.

Model 1 — embed, unembed, and nothing else
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.

Softmax with temperature, safely
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

StrategyHowCharacter
GreedyAlways take the argmaxDeterministic; repetitive and prone to loops
Pure samplingSample from the full distributionDiverse; occasionally incoherent
Top-kKeep the k highest, renormalise, sampleFixed cut ignores how peaked the distribution is
Top-p (nucleus)Keep the smallest set summing to pAdapts: narrow when confident, wide when not
TemperatureRescale logits before any of the aboveOrthogonal dial, combines with all of them
Sampling, and generation
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 idx

Note: 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.

Model 2 — positions, layernorm, tied weights, temperature
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.

Averaging the past with a triangular matrix
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.

One attention head, by hand and with PyTorch
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.

Multi-head attention as one reshaped matmul
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.

The transformer block, and the full model
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.

Timing CPU against GPU honestly
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.

Counting, and inspecting the real GPT-2
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

StepEquation
Inputx₀ = E[idx] + P[pos]
Attention scoresA = softmax( mask(QKᵀ / √d_h) )
Attention outattn(x) = A·V·W_O
Blockx' = x + attn(LN(x)); x'' = x' + MLP(LN(x'))
MLPMLP(x) = W₂·GELU(W₁x)
Outputlogits = LN_f(x_L)·Eᵀ
LossL = −Σ 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.