Index Ai Academy
Phase 5 · Transformers · The transformer

The transformer block

Attention, MLP, residuals and norms together.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Communicate, then compute
  2. Inside the MLP
  3. Why about 12·d²
  4. Variations you'll see in model cards
  5. 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 dmodeld_{\text{model}}. This lesson shortens it to dd.

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.

Quick check
In a transformer block, which sub-layer lets tokens exchange information with each other?
  1. The MLP
  2. Attention
  3. The residual connection
  4. The layer norm
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:

  1. Expand. A linear layer turns the token's dd numbers into 4d4d numbers. With a width of 64, that's 256 numbers.
  2. Bend. GELU, the smooth activation from Phase 4, bends those numbers. Without a bend, the two layers could only learn straight-line patterns.
  3. Project back. A second linear layer turns the 4d4d numbers back into dd.

The result has to be dd 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.ln1 and self.ln2 are the two layer norms.
  • self.qkv makes the queries, keys and values for all heads in one go. self.proj is the output projection, WOW_O. The attention method is the multi-head attention from earlier in this phase, causal mask included.
  • self.mlp is the expand, GELU, project-back network.
  • forward is 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:,}")
It runs right here in your browser, with nothing to install.

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.

Try it now

Communicate, then compute, with a skip connection around each. Stack dozens of these and you have a GPT.

Quick check
A block receives an input of shape (3, 20, 128): 3 sequences, 20 tokens, width 128. What shape comes out?
  1. (3, 128)
  2. (20, 128)
  3. (3, 20, 128)
  4. (3, 20, 512)
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 d×dd \times d matrix holds d2d^2 weights, dd times dd.

  • Attention has four d×dd \times d matrices: queries, keys, values and the output projection. That's 4d24d^2.
  • The MLP has a d×4dd \times 4d matrix to expand and a 4d×d4d \times d matrix to come back. Each holds 4d24d^2, so together they hold 8d28d^2.
parameters per block≈4d2+8d2=12d2\text{parameters per block} \approx 4d^2 + 8d^2 = 12d^2

In plain words: square the width, then multiply by 12. A third of that is attention, and two-thirds is the MLP. The ≈\approx sign means "about".

Try it with a tiny width of 4. Then d2=16d^2 = 16.

  • 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 dd, not d2d^2, 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.

  • qkv outputs 3 × 64 = 192 numbers, so 192 biases.
  • proj outputs 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.

Quick check
What does this print?
d = 100
print(12 * d * d)
  1. 12000
  2. 1200000
  3. 1200
  4. 120000
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

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())
In the lesson you can change this and run it.

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

Create a free account

Try a quiz question

Why must a transformer block's output have the same shape as its input?

  1. So blocks can be stacked and added to the residual stream
  2. So the loss is easier to compute at the end of each block
  3. Because attention only works on square matrices of one size
  4. It doesn't have to: each block can set its own output width

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson