The transformer block
Attention, MLP, residuals and norms together.
Create a free account Try part of it here
Why it matters
A transformer is one block, repeated. GPT-2 small stacks 12 of them. The largest open models stack around 80 or more. Everything from this phase and the last one meets here: attention, the MLP, residual connections and layer normalization. Once you can write one block, a GPT is mostly a loop over blocks. That's the next lesson.
What you'll be able to do
- Assemble a block from attention, an MLP, residual connections and layer norms
- Explain the division of labor: attention moves information between tokens, the MLP processes each token
- Count a block's parameters (about 12·d² for width d)
- Verify the block keeps shapes and stays causal
What it covers
- Communicate, then compute
- Inside the MLP
- Why about 12·d²
- Variations you'll see in model cards
- Worked example: one block by hand
Communicate, then compute
Picture a book club that meets every week. First the members talk, and each one hears what the others noticed. Then everyone goes home and thinks it over alone. Next week they meet again, and each person arrives with a sharper view.
A transformer block is one round of that. A transformer runs many rounds in a row. (One difference: in a GPT, each word can only hear from the words that came before it.)
Each token (a word or piece of a word) arrives at the block as a list of numbers, its vector. The length of that list is the model's width. Earlier lessons wrote it as . This lesson shortens it to .
Here is a modern block, written as two lines of Python-style shorthand:
x = x + attention(layer_norm_1(x)) # communicate: each token gathers information from earlier tokens x = x + mlp(layer_norm_2(x)) # compute: each token processes what it gathered, on its own
Here's what each piece does.
- The layer norms come first in each line. A layer norm rescales a token's numbers into a steady range, so they don't drift too large or too small as blocks pile up.
- Attention is the communicate step. Each token gathers information from earlier tokens. It's the only place in the block where tokens exchange anything.
- The MLP is the compute step. It's a small two-layer neural network, also called the feed-forward network. It runs on every token separately, with the same weights for each one. It holds most of the block's parameters. Researchers believe it stores much of a model's factual knowledge.
- The shortcut. The
x = x + ...in each line is a residual connection. Each step adds its result onto the token's vector instead of replacing it. You met this running total in Phase 4 as the residual stream.
In plain words: first each token reads from the tokens before it. Then each token works on what it read, alone. Both results are added onto what the token already had.
This layout is called pre-norm, because the norm comes before each step, inside the shortcut. It's also a decoder block. That means it's built for models that write text one token at a time, so attention only looks backward.
Show the answer
Attention
The MLP works on each token on its own. Only attention mixes information across positions.
Inside the MLP
The MLP does three things to each token, one token at a time:
- Expand. A linear layer turns the token's numbers into numbers. With a width of 64, that's 256 numbers.
- Bend. GELU, the smooth activation from Phase 4, bends those numbers. Without a bend, the two layers could only learn straight-line patterns.
- Project back. A second linear layer turns the numbers back into .
The result has to be numbers long, because it gets added onto the token's vector. You can't add a list of 256 numbers to a list of 64.
A complete block
The code below builds a full block in PyTorch. Here's a map of it before you read it.
self.ln1andself.ln2are the two layer norms.self.qkvmakes the queries, keys and values for all heads in one go.self.projis the output projection, . Theattentionmethod is the multi-head attention from earlier in this phase, causal mask included.self.mlpis the expand, GELU, project-back network.forwardis the two lines from the top of this lesson.
The code then runs 2 sequences of 10 tokens each through a block of width 64 with 4 heads. Finally, it counts the parameters in each part.
import math import torch import torch.nn as nn class Block(nn.Module): def __init__(self, d, n_heads): super().__init__() self.h = n_heads self.ln1, self.ln2 = nn.LayerNorm(d), nn.LayerNorm(d) self.qkv = nn.Linear(d, 3 * d) # queries, keys, values for all heads self.proj = nn.Linear(d, d) # attention output projection (W_O) self.mlp = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d)) def attention(self, x): B, T, d = x.shape q, k, v = self.qkv(x).split(d, dim=-1) q, k, v = (t.view(B, T, self.h, d // self.h).transpose(1, 2) for t in (q, k, v)) s = q @ k.transpose(-2, -1) / math.sqrt(d // self.h) s = s.masked_fill(~torch.tril(torch.ones(T, T)).bool(), float("-inf")) y = (torch.softmax(s, dim=-1) @ v).transpose(1, 2).reshape(B, T, d) return self.proj(y) def forward(self, x): x = x + self.attention(self.ln1(x)) # communicate x = x + self.mlp(self.ln2(x)) # compute return x torch.manual_seed(0) d, heads = 64, 4 block = Block(d, heads) x = torch.randn(2, 10, d) print("in:", tuple(x.shape), " out:", tuple(block(x).shape)) counts = {name: sum(p.numel() for p in mod.parameters()) for name, mod in [("attention", nn.ModuleList([block.qkv, block.proj])), ("MLP", block.mlp), ("layer norms", nn.ModuleList([block.ln1, block.ln2]))]} total = sum(counts.values()) for name, n in counts.items(): print(f"{name:<12} {n:>6,} parameters ({n / total:.0%})") print(f"total {total:>6,} vs 12·d² = {12 * d * d:,}")
Look at the first line it prints. The output shape, (2, 10, 64), matches the input. That's what lets you stack blocks: each block's output is a valid input for the next one.
Now the counts. The MLP holds about two-thirds of the parameters (66%). Attention holds about a third (33%). The layer norms hold only 256 numbers, well under 1%. Python rounds that share up, so it prints 1%.
The block also stays causal: a token's output never depends on later tokens. The mask in attention makes sure of that. The layer norms and the MLP can't break it, because they never look at other tokens.
Here torch is the course's in-browser teaching version of PyTorch. It covers the parts of PyTorch this course uses.
Communicate, then compute, with a skip connection around each. Stack dozens of these and you have a GPT.
Show the answer
(3, 20, 128)
A block keeps the shape. The MLP widens each token to 4 × 128 = 512 inside, then projects back to 128.
Why about 12·d²
The code printed a total close to "12·d²". Here's where that rule of thumb comes from.
A grid of weights (a matrix) that turns 3 numbers into 5 numbers holds 3 × 5 = 15 weights. So a matrix holds weights, times .
- Attention has four matrices: queries, keys, values and the output projection. That's .
- The MLP has a matrix to expand and a matrix to come back. Each holds , so together they hold .
In plain words: square the width, then multiply by 12. A third of that is attention, and two-thirds is the MLP. The sign means "about".
Try it with a tiny width of 4. Then .
- Attention: 4 × 16 = 64.
- MLP: 8 × 16 = 128.
- Total: 64 + 128 = 192, which is 12 × 16.
The same count in Python, plus the estimate for a real model:
d = 4 attention = 4 * d * d # queries, keys, values and output: four d x d grids mlp = d * (4 * d) + (4 * d) * d # expand d to 4d, then come back from 4d to d print(attention, mlp, attention + mlp, 12 * d * d) blocks, width = 12, 768 # GPT-2 small print(f"{blocks * 12 * width * width:,}")
The first line prints 64, 128, 192 and 192. The second line is GPT-2 small: width 768, 12 blocks. It prints 84,934,656, about 85 million parameters in its blocks. The rest of GPT-2's parameters sit in its token and position embedding tables.
The block you built earlier has width 64, so the estimate is 12 × 64² = 49,152. The code counted 49,984. The extra 832 are biases and layer norms. They grow with , not , so in a big model they barely register.
Go deeper Where the extra 832 come from
Each linear layer has one bias, an extra number it adds, per output number.
qkvoutputs 3 × 64 = 192 numbers, so 192 biases.projoutputs 64, so 64 biases.- The MLP's expand layer outputs 256, so 256 biases. Its project-back layer outputs 64, so 64.
That's 192 + 64 + 256 + 64 = 576 biases.
Each layer norm learns a scale and a shift for each of the 64 numbers: 2 × 64 = 128. Two layer norms make 256.
576 + 256 = 832, and 49,152 + 832 = 49,984, the total the code printed.
d = 100 print(12 * d * d)
Show the answer
120000
100 squared is 10,000. Times 12 gives 120,000, which prints as 120000: the rough parameter count of a block with width 100.
Variations you'll see in model cards
A model card is the fact sheet published alongside a model. Model cards often list small changes to the block:
- Post-norm vs pre-norm. The original 2017 transformer normalized after adding the residual. Almost every model since puts the norm first. That trains more stably when many blocks are stacked.
- RMSNorm instead of LayerNorm. It's a simpler normalization: it rescales the numbers but skips subtracting their average. Llama-style models use it.
- SwiGLU instead of GELU in the MLP, also in Llama-style models. It's a gated activation: one part of the MLP decides how much of another part gets through. The hidden size is adjusted to keep the parameter count similar.
- Grouped-query attention. Several query heads share the same keys and values. There are fewer key and value heads than query heads, which saves memory while the model generates text.
- Mixture of experts. The single MLP is replaced by several MLPs and a router. The router picks which MLPs each token uses. A later lesson covers it.
The skeleton is the same in all of them: attention, then the MLP, each wrapped in a norm and a residual connection.
Go deeper Pre-norm and post-norm side by side
Here are the two orders for the attention step, in the same shorthand as before:
x = layer_norm(x + attention(x)) # post-norm (2017): add first, then normalize x = x + attention(layer_norm(x)) # pre-norm (today): normalize only the step's input
In post-norm, the running total itself is rescaled after every step. In pre-norm, it never is inside the blocks. Only a copy that feeds each step gets normalized. A common explanation for pre-norm's stability is that this leaves a clean shortcut from the first block to the last. The training signal can travel back along it to the early blocks.
Worked example: one block by hand
Three tokens, each with only 2 numbers. To keep the arithmetic easy, "attention" here gives equal weight to a token and everything before it. The "MLP" is a fixed stand-in: subtract 1 from each number, and turn anything negative into 0. Follow the two residual steps.
import numpy as np x = np.array([[1.0, 0.0], # token 0 [0.0, 2.0], # token 1 [2.0, 1.0]]) # token 2 T = len(x) # Communicate: each token averages itself and the tokens before it (causal, equal weights). weights = np.tril(np.ones((T, T))) weights = weights / weights.sum(axis=1, keepdims=True) x = x + weights @ x print("after attention:", x.tolist()) # Compute: the same small function runs on each token separately. def mlp(token): return np.maximum(0, token - 1) x = x + np.array([mlp(t) for t in x]) print("after MLP: ", x.tolist())
Communicate step. Token 0 can only see itself, so its average is [1, 0]. Added on, it becomes [2, 0]. Token 1 averages tokens 0 and 1: [0.5, 1]. Added to [0, 2], that gives [0.5, 3]. Token 2 averages all three: [1, 1]. Added to [2, 1], that gives [3, 2].
Compute step. The MLP looks at one row at a time. [2, 0] becomes [1, 0], and adding gives [3, 0]. [0.5, 3] becomes [0, 2], giving [0.5, 5]. [3, 2] becomes [2, 1], giving [5, 3].
The shape never changed: 3 tokens of 2 numbers, in and out. A real block uses queries and keys for the weights, a learned MLP and layer norms. The flow of information is the same.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
Why must a transformer block's output have the same shape as its input?