The Mathematics of Large Language Models

Step 5 — Assembling the GPT

Released with Lecture 5 · Starts from: Step 4 (or solutions/step-04.ipynb) · Solution: notebook · read online

Goal

Assemble Step 4’s head into the complete GPT architecture: multi-head attention, MLP blocks, residual connections, LayerNorm, and the embedding/unembedding maps. At the end you have the full model class — untrained, but verified for shapes, causality, and an exact parameter count you derive on paper first. Step 6 trains it.

On paper first

  1. The architecture as one formula. With LL blocks, write the model as the composition (pre-norm convention, as in GPT-2):

    X(0)=TokEmb(x)+PosEmb,X()+=MHA(LN1()(X()))X()+=MLP(LN2()(X()))Z=LNf(X(L))WU.X^{(0)} = \text{TokEmb}(x) + \text{PosEmb},\qquad \begin{aligned} X^{(\ell)} &{}\mathrel{+}= \text{MHA}(\text{LN}_1^{(\ell)}(X^{(\ell)}))\\ X^{(\ell)} &{}\mathrel{+}= \text{MLP}(\text{LN}_2^{(\ell)}(X^{(\ell)})) \end{aligned} \qquad Z = \text{LN}_f(X^{(L)})\,W_U.

    The += structure is the residual stream: every sublayer reads from and writes small updates into a running state in RT×d\mathbb{R}^{T\times d}. (Lecture 5 develops why this matters for both optimization and interpretation.)

  2. Parameter count. Derive, term by term, the parameter count of your model as a function of (V,Tmax,d,L)(V, T_{\max}, d, L) with dff=4dd_{ff} = 4d and biases where your code has them. Show the blocks contribute 12Ld2\approx 12 L d^2 and identify exactly which matrices the 1212 counts. You will check this number against numel() to the last parameter.

  3. LayerNorm. For uRdu \in \mathbb{R}^d: LN(u)=γuuˉ1σ(u)+β\text{LN}(u) = \gamma \odot \frac{u - \bar u\,\mathbf 1}{\sigma(u)} + \beta. Show the normalization part is: orthogonal projection onto 1\mathbf 1^\perp, then radial projection onto the sphere of radius d\sqrt d. So the stream is repeatedly pinned to a (d2)(d{-}2)-sphere — a strong geometric constraint to keep in mind whenever you picture “vectors in the residual stream.”

Tasks

5.1 Multi-head attention

HH heads with dhead=d/Hd_{\text{head}} = d/H, run in parallel, concatenated, then mixed by an output projection:

class MultiHeadAttention(nn.Module):
    def __init__(self, d, H, T_max):
        super().__init__()
        self.heads = nn.ModuleList(Head(d, d // H, T_max) for _ in range(H))  # generator expression; // = integer division
        self.proj = nn.Linear(d, d)

    def forward(self, x):
        return self.proj(torch.cat([h(x) for h in self.heads], dim=-1))  # list comprehension
Python background: integer division (//)

/ always produces a float, even when the division is exact; // divides and rounds down to an integer. Sizes and indices must be integers, which is why the per-head dimension is d // H and not d / Hnn.Linear(d, 32.0) would be an error. (The _ in for _ in range(H) is the same throwaway name from Step 2’s tuple foldout: the loop runs H times and never uses the counter.)

print(7 / 2)          # ordinary division: always a float
print(7 // 2)         # integer division: rounds down
print(128 / 4)        # exact — but still a float!
print(128 // 4)       # an int, usable as a size or index
for _ in range(3):    # run 3 times, counter unused
    print("another head")

(This loop-over-heads version is deliberately transparent; the “going further” refactor batches it into one tensor op.)

5.2 The MLP block

Position-wise: hidden width 4d4d, GELU nonlinearity, back to dd. Two nn.Linears and an F.gelu; note it acts on each of the TT positions independently — all cross-position communication in the entire model happens inside attention, nowhere else.

5.3 The block and the model

class Block(nn.Module):
    def __init__(self, d, H, T_max):
        super().__init__()
        self.ln1, self.attn = nn.LayerNorm(d), MultiHeadAttention(d, H, T_max)  # parallel assignment (tuple unpacking)
        self.ln2, self.mlp  = nn.LayerNorm(d), MLP(d)

    def forward(self, x):
        x = x + self.attn(self.ln1(x))     # read, transform, write back
        x = x + self.mlp(self.ln2(x))
        return x

Then GPT(nn.Module) holding: token embedding (V, d), positional embedding (T_max, d), L blocks, final LayerNorm, and the unembedding nn.Linear(d, V, bias=False). forward(idx, targets=None) returns logits (B, T, V) and, if targets are given, F.cross_entropy on the flattened logits. Also port your Step 3 generate (crop the context to the last T_max tokens each iteration — why is cropping required for this model when it wasn’t for the MLP?).

Course configuration (matches Step 6’s training budget):

V=65, T_max=64, d=128, H=4, L=4        # just over 0.8M parameters — your baby GPT

5.4 Verification suite

  1. Parameter count: sum(p.numel() for p in model.parameters()) must equal your paper formula exactly. Chase any discrepancy to the guilty term (biases and LayerNorms are the usual suspects). Report the percentage of parameters in: embeddings, attention, MLPs.
  2. Causality, end-to-end: Step 4’s perturbation test on the full model. It must still pass — residuals, LayerNorm, and MLPs are all position-wise, so the mask remains the only cross-position gate.
  3. Initial loss: on a random batch, loss should land just above ln65=4.174\ln 65 = 4.174 (see checkpoints for the exact figure and why it is not equal). If it’s 8, your init is too hot (logits too spread — softmax collapsed); GPT-2’s fix, which you should apply: scale the output projections in each block by 1/2L1/\sqrt{2L} (Lecture 6 derives why: 2L2L writes into the stream, variance adds).
  4. A shape table: print x.shape after every stage for one forward pass — embeddings, each block, final LN, logits. This table is the architecture; keep it next to the formula from “on paper” 1.

Checkpoints

If you’re stuck

Going further (optional)

Catch-up

Run solutions/step-04.ipynb, whose Head class is this step’s only dependency; everything else is fresh construction from Lecture 5.