Home › Blog › How a Transformer Works
Explainer · With runnable code ⚙️

How a Transformer Works: A Decoder-Only Model, End to End

Follow the sentence "The cat sat on the" through every stage of a GPT-style model — tokens, embeddings, attention, the feed-forward layer, logits, sampling — watch the tensor shapes at each step, count the parameters, and see how backpropagation trains it. Then build the whole thing in about 110 lines of PyTorch, train it for a few seconds, and see it predict the next word.

How to read this: every tensor shape quoted below was printed by the runnable script in the Build It section, not written by hand. The architecture described is the GPT-2-style block — pre-norm, learned positions, GELU — checked against OpenAI's released gpt-2/src/model.py. For how the modern variants (RMSNorm, RoPE, SwiGLU) differ, see Language Modeling from Scratch, Part 4.

The Big Picture

A decoder-only transformer does one job: given a sequence of tokens, produce a probability for every possible next token. Everything else — chatbots, code completion, long essays — comes from running that one job in a loop. Here is the whole path for our example prompt, with the shape of the data at each stage. T is the number of tokens (5 here), d is the model width, and V is the vocabulary size.

  1. Input text"The cat sat on the"string
  2. Tokenizesplit into pieces a model knows (BPE)5 tokens
  3. Token IDseach piece becomes an integer(T)
  4. Token embeddingslook up one learned vector per ID(T, d)
  5. + Position embeddingsadd a learned vector per position(T, d)
  6. N transformer blocksattention + feed-forward, repeated; shape never changes(T, d)
  7. Final LayerNormone last normalization(T, d)
  8. LM headproject every position onto the vocabulary(T, V)
  9. Softmaxturn the last position's scores into probabilities(V)
  10. Samplepick one token, append it, repeat1 token

The single most useful fact to hold on to: inside the stack, the data is always a T × d matrix — one d-dimensional vector per token. Every block reads that matrix and writes a matrix of the same shape. What changes is what those vectors mean: at the bottom each vector describes one word in isolation; by the top, each vector encodes that word in the context of everything before it.

From Text to Vectors

Tokenization splits text into pieces from a fixed vocabulary — common words become one token, rare words break into several. Real models use byte-pair encoding, covered from scratch in Part 2 of the CS336 series. Each token then becomes an integer ID; GPT-2's tokenizer, for example, maps our five words to five IDs. No learning happens here — it's a lookup.

Token embeddings are the first learned component: a table E with one row of d numbers per vocabulary entry. Looking up five IDs gives a 5 × d matrix. Training moves these rows around so that tokens used in similar ways end up with similar vectors.

Position embeddings fix a real problem: attention, as we'll see, treats its input as an unordered set. Without position information, "the cat sat on the mat" and "the mat sat on the cat" would look identical. GPT-2's answer is a second learned table P with one row per position; row 0 is added to the first token, row 1 to the second, and so on. (Most newer models use RoPE instead, which rotates vectors by an angle that depends on position and has no learned table — see Part 4.)

X = E[ids] + P[0 … T−1]
Shape (T, d). This matrix is the input to the first block.

Inside One Block

A block has two halves, and each half follows the same pattern: normalize, transform, add back. Attention lets each token gather information from earlier tokens; the feed-forward layer then processes each token on its own. The "add back" — the residual connection — means each half only has to learn a change to the vector, not rebuild it, which is a large part of why very deep stacks train at all.

residual: x + … residual: x + … x (T, d) LayerNorm rescale each token vector Masked multi-head self-attention tokens read from earlier tokens + LayerNorm rescale each token vector Feed-forward (MLP) each token processed alone d → 4d → GELU → d + x_out (T, d) Communication mixes information across positions Computation transforms each position in place
A pre-norm block, as in GPT-2: normalization happens before each sub-layer, and the residual path carries the unnormalized vector straight through. Shape in = shape out = (T, d). (Original diagram.)

In code, the whole block is two lines — the rest of this article is about what's inside them:

x = x + attention(layer_norm_1(x))  # mix across tokens
x = x + mlp(layer_norm_2(x))        # per-token

Attention, Step by Step

Attention is how the vector for "the" (the last word of our prompt) can pick up that the sentence is about a cat sitting on something. Each token asks a question, every earlier token offers an answer, and each token takes a weighted blend of the answers that match its question best. Concretely, with the shapes our script printed (T = 5, d = 64, h = 4 heads, so dk = 16 per head):

  1. Project to queries, keys and values(5, 64) eachQ = X·WQ ("what am I looking for"), K = X·WK ("what do I contain"), V = X·WV ("what I'll hand over").
  2. Split into h heads(4, 5, 16)Reshape, don't recompute: 4 heads each get a 16-number slice, so they can learn to look for different things.
  3. Score every pair: QKᵀ / √dk(4, 5, 5)A 5 × 5 grid per head: how well each token's query matches each token's key. Dividing by √dk keeps the numbers in a range where softmax still has useful gradients.
  4. Apply the causal mask(4, 5, 5)Set every score above the diagonal to −∞, so no token can see a later one. This is what makes it a decoder.
  5. Softmax each row(4, 5, 5)Each row becomes attention weights that are ≥ 0 and sum to 1; masked positions become exactly 0.
  6. Weighted sum of values(4, 5, 16)Each token's output is its row of weights times V: a blend of what the earlier tokens offered.
  7. Concatenate heads, project with WO(5, 64)Glue the 4 heads back into 64 numbers per token, then mix them with one more linear layer. Back to (T, d).

Try it: who can see whom?

Click a token on the left to see which positions it's allowed to attend to. Each row is one token's view; the last row — the token whose output predicts the next word — sees the whole prompt.

Click a row label.
Blue: allowed (the token itself and everything before it). Grey: masked to −∞ before softmax, so its weight is exactly zero.

A tiny example with real numbers

Three tokens, one head, dk = 2, with small hand-picked vectors so you can check the arithmetic: Q = [[1,0], [0,1], [1,1]], K = [[1,0], [1,1], [0,1]], V = [[1,0], [0,1], [2,2]]. Computed with PyTorch:

1. Scores QKᵀ/√2
0.710.710.00
0.000.710.71
0.711.410.71
2. Causal mask
0.71−∞−∞
0.000.71−∞
0.711.410.71
3. Softmax
1.000.000.00
0.330.670.00
0.250.500.25

Read the rows. Token 1 can only see itself, so its weight is 1.00 on itself and its output is simply its own value, [1, 0]. Token 2 matches token 2's key more strongly (0.71 vs 0.00), so it takes two thirds of its answer from token 2. Token 3's query [1, 1] lines up best with key [1, 1], giving weights 0.25 / 0.50 / 0.25 and output 0.25·[1,0] + 0.50·[0,1] + 0.25·[2,2] ≈ [0.75, 1.00] (0.744 exactly, before rounding the weights). That blend of earlier information is what attention adds to a token.

The Feed-Forward Layer

After attention has mixed information across positions, the feed-forward network (MLP) processes each token independently, with the same weights at every position:

MLP(x) = Wdown · GELU(Wup · x)
up: d → 4dGELUdown: 4d → d
GPT-2 uses a hidden width of 4d (4 × 768 = 3,072 for GPT-2 small). The MLP holds about two thirds of each block's parameters — the exact breakdown is in Part 4's parameter count.

The expansion to 4d gives the layer room to compute many intermediate features, and GELU — x · Φ(x), where Φ is the standard normal CDF — is the nonlinearity that makes those features more than a linear reshuffle. A useful mental model, backed by interpretability research, is that attention moves information between tokens while MLPs store and apply much of what the model knows.

From Vectors to a Prediction

Stack N blocks (12 in GPT-2 small, 2 in our toy model) and the shape is still (T, d). Three steps turn it into a prediction:

1Final LayerNorm — one last normalization of each token vector.
2LM head — a linear layer from d to V, giving one score (logit) per vocabulary entry at every position: shape (T, V). Many models reuse the token-embedding matrix here ("weight tying").
3Softmax on the last position — only the final row predicts the next token; the earlier rows predicted tokens we already have. (During training, all T rows are used at once — each one is a separate next-token prediction, which is why training on a sequence is so efficient.)

Try it: temperature and top-k

The softmax output is a probability for every token; how you pick from it changes the text a lot. The scores below are illustrative (not from a real model) for continuing "The cat sat on the". Move the sliders:

Context: The cat sat on the ___

Low temperature sharpens the distribution toward the single most likely token (greedy decoding, the limit as temperature → 0, always picks "mat"). High temperature flattens it, making unusual continuations more likely. Top-k keeps only the k highest-scoring tokens and renormalizes; nucleus (top-p) sampling instead keeps the smallest set whose probabilities sum to p, so the cutoff adapts to how confident the model is — the method introduced by Holtzman et al. to avoid the bland, repetitive text that pure likelihood-maximizing decoding produces.

The Generation Loop

A transformer predicts one token. Text comes from a loop: predict, sample, append, run the model again on the longer sequence. That's what "autoregressive" means.

Each step: run the model on everything so far → sample one token → append it.

Two practical consequences follow. First, generation is sequential — token 100 can't be computed before token 99 exists — which is why output tokens are slower and cost more than input tokens. Second, running the full model over the whole sequence at every step would repeat almost all of the work; real inference engines cache each layer's keys and values (the KV cache) so each new step only computes attention for the one new token.

How Many Parameters?

Only a few stages have learned weights: the embedding tables, the four attention projections and two MLP matrices in each block, the norms, and the LM head. Ignoring biases and norm scales (a tiny fraction), the count is:

total ≈ V·d + N·(4d² + 2·d·dff) + d·V
token embeddings V·dattention 4d² per blockMLP 2·d·dff per blockLM head d·V (0 if tied)
With dff = 4d each block is 12d². Learned positions add Tmax·d; RoPE adds nothing.

Worked example at the size of a typical "7B" model — d = 4,096, dff = 16,384, N = 32, V = 50,000:

PieceArithmeticParameters
Token embeddings50,000 × 4,096204,800,000
Attention, one block4 × 4,096²67,108,864
MLP, one block2 × 4,096 × 16,384134,217,728
One block12 × 4,096²201,326,592
32 blocks32 × 201,326,5926,442,450,944
LM head4,096 × 50,000204,800,000
Total, untied≈ 6.85B
Total, tiedminus one 204.8M matrix≈ 6.65B
A common slip: writing the per-block count as 2·d·dff ≈ 134M and forgetting attention's 4d². That drops a third of every block and makes this model look like ~4.7B instead of ~6.85B. A quick sanity check: with dff = 4d a block is 12d², and 12 × 4,096² ≈ 201M. The same shape with Llama 2's settings (SwiGLU at dff = 11,008, V = 32,000, untied, RMSNorm scales included) comes to 6,738,415,616 by the same kind of arithmetic — the figure usually reported for Llama 2 7B.

For every term, including biases and norm scales, and a PyTorch script that counts them from a real model, see Part 4's parameter count.

How It Learns: Backpropagation

Everything so far was the forward pass: run the model and get a prediction. Training adds a backward pass that works out, for every one of those parameters, which direction to nudge it so the prediction gets better. The training loop in our script is three lines:

logits = model(data[:, :-1])                                            # forward: predict each next token
loss = F.cross_entropy(logits.reshape(-1, V), data[:, 1:].reshape(-1))  # how wrong were we?
opt.zero_grad(); loss.backward(); opt.step()                            # backward, then update

loss.backward() computes a gradient for every parameter; opt.step() uses them to update the weights. The rest of this section is what those two calls do.

1. The loss

Training text supplies the right answer at every position: after "The cat sat on the" the next token really was "mat". The loss is cross-entropy — the negative log of the probability the model gave the correct token:

L = −log p(correct token)
Using the scores from the sampler above (temperature 1), p(mat) = 0.454, so L = −log 0.454 = 0.790. A perfect prediction (p = 1) gives L = 0; a confident wrong one gives a large L. In training this is averaged over every position of every sequence in the batch.

2. The first gradient: p − y

Backpropagation starts at the loss and applies the chain rule backwards. The first step has a famously clean answer: for softmax followed by cross-entropy, the gradient of the loss with respect to each logit is simply predicted probability minus target, where the target y is 1 for the correct token and 0 for everything else. Checked with PyTorch's autograd:

Tokenp (predicted)y (target)∂L/∂z = p − y
mat0.4541−0.546
floor0.2040+0.204
sofa0.1510+0.151
bed0.1120+0.112
dog0.0460+0.046
roof0.0340+0.034

Read it as instructions. Gradient descent moves opposite the gradient, so "mat"'s score gets pushed up (its gradient is negative), and every other token's score gets pushed down in proportion to how much probability it wrongly took. The rows sum to zero: probability can only move between tokens.

3. The gradient flows backward through the model

forward: compute activations backward: compute gradients Tokenization → IDs discrete lookup: no gradient, nothing to learn Token + position embeddings ∂L/∂E: only rows of tokens in the batch N blocks, visited in reverse MLP branch first ∂L/∂W_up, ∂L/∂W_down, ∂L/∂γ (norm 2) then the attention branch ∂L/∂W_Q, W_K, W_V, W_O, ∂L/∂γ (norm 1) Final LayerNorm ∂L/∂γ, ∂L/∂β LM head ∂L/∂W_vocab = xᵀ · (p − y) Softmax + cross-entropy → L start here: ∂L/∂z = p − y
The forward pass runs top to bottom and ends in a single number, the loss. The backward pass runs bottom to top, and every learned component it passes collects a gradient for its own weights while handing a gradient for its input to the layer before it. (Original diagram.)

Every component does the same two things on the way back: compute the gradient for its own weights (to be updated), and the gradient for its input (to pass further back). Four pieces are worth understanding:

1Linear layers — most weights in the model, and the rule is short. For Y = X·W, with the incoming gradient G = ∂L/∂Y: ∂L/∂X = G·Wᵀ is passed back, and ∂L/∂W = Xᵀ·G updates the weights. The shapes force it: X is (T, din), W is (din, dout), G is (T, dout), and those are the only products that give back the right shapes. The same rule updates WQ, WK, WV, WO, both MLP matrices and the LM head.
2Residual connections — for y = x + f(x), the gradient reaching x is ∂L/∂y plus whatever comes back through f. So even when a block's branch has learned almost nothing, the gradient passes through undiminished. In our check, a branch scaled to 0.001 still delivered a gradient of about 1.00 to its input. This "gradient highway" is why 32- or 100-block models train at all.
3LayerNorm — the forward pass subtracts the mean and divides by the standard deviation, so the backward pass has to account for every element's effect on that mean and variance. Autograd handles it; the result is gradients for the scale γ and shift β, and a gradient for the input with its mean component removed.
4Attention — the gradient flows back through the weighted sum into V, through the softmax into the scores, and through QKᵀ into both Q and K, then down the three projections. Masked positions had weight exactly zero, so no gradient flows through them: a token's loss can only shape how it reads from earlier tokens.

Two things never receive a gradient: tokenization (a discrete lookup, not a differentiable function), and the embedding rows of tokens that didn't appear in the batch — their gradient is exactly zero that step.

4. The update: Adam

With a gradient g for every parameter θ, the simplest update is θ ← θ − η·g (plain gradient descent, learning rate η). Language models almost always use Adam, which keeps two running averages per parameter and scales each step by them:

m ← β₁m + (1−β₁)g  ·  v ← β₂v + (1−β₂)g²
θ ← θ − η · m̂ / (√v̂ + ε)
m is a running average of the gradient (momentum), v of its square (scale); the hats mean bias-corrected for the early steps (m̂ = m / (1−β₁ᵗ), v̂ = v / (1−β₂ᵗ)). Typical values: β₁ = 0.9, β₂ = 0.95–0.999, ε = 10⁻⁸.

Dividing by √v̂ gives every parameter its own step size. It shows up clearly on the very first step: our check applied gradients of 0.5 and −1.5 to two parameters with η = 0.1, and both moved by exactly 0.1, because m̂/√v̂ reduces to the sign of the gradient when there's only one step of history. Our training script uses AdamW, which applies weight decay directly to the weights instead of mixing it into the gradient — the standard choice for transformers. Adam also stores m and v for every parameter, which is why optimizer state takes more memory than the model itself (see Part 3).

Check every claim in this section

This script verifies each rule above with PyTorch's autograd: p − y, both linear-layer rules, the residual gradient, and one Adam step by hand versus torch.optim.Adam.

import torch
import torch.nn.functional as F

torch.manual_seed(0)

# 1. Softmax + cross-entropy: the gradient of the loss w.r.t. the logits is p - y
vocab = ["mat", "floor", "sofa", "bed", "dog", "roof"]
z = torch.tensor([3.2, 2.4, 2.1, 1.8, 0.9, 0.6], requires_grad=True)   # logits
target = torch.tensor(0)                                                # true next token: "mat"
loss = F.cross_entropy(z[None], target[None])
loss.backward()
p = z.softmax(-1).detach()
y = F.one_hot(target, len(vocab)).float()
print(f"loss = -log p(mat) = {loss.item():.3f}   (p(mat) = {p[0]:.3f})")
print("autograd dL/dz:", [round(g, 3) for g in z.grad.tolist()])
print("p - y        :", [round(g, 3) for g in (p - y).tolist()])

# 2. Linear layer Y = X W: dL/dX = dL/dY W^T and dL/dW = X^T dL/dY
X = torch.randn(5, 8, requires_grad=True)       # 5 tokens, d_in = 8
W = torch.randn(8, 3, requires_grad=True)       # d_out = 3
Y = X @ W
G = torch.randn_like(Y)                         # pretend upstream gradient dL/dY
Y.backward(G)
print("dL/dX == dL/dY @ W^T :", torch.allclose(X.grad, G @ W.T))
print("dL/dW == X^T @ dL/dY :", torch.allclose(W.grad, X.T @ G))

# 3. Residual y = x + f(x): the gradient reaching x is dL/dy PLUS the branch's contribution
x = torch.randn(4, requires_grad=True)
f = lambda t: 0.001 * torch.tanh(t)              # a branch that has barely learned anything
y = x + f(x)
y.sum().backward()
print("residual grad at x (~1.0 even though f is tiny):", [round(g, 4) for g in x.grad.tolist()])

# 4. One Adam step (bias-corrected), by hand vs torch.optim.Adam
theta = torch.tensor([1.0, -2.0], requires_grad=True)
opt = torch.optim.Adam([theta], lr=0.1, betas=(0.9, 0.999), eps=1e-8)
g = torch.tensor([0.5, -1.5])
theta.grad = g.clone()
m = 0.1 * g                      # m = b1*0 + (1-b1)*g
v = 0.001 * g ** 2               # v = b2*0 + (1-b2)*g^2
m_hat, v_hat = m / (1 - 0.9), v / (1 - 0.999)
by_hand = theta.detach() - 0.1 * m_hat / (v_hat.sqrt() + 1e-8)
opt.step()
print("Adam step by hand:", [round(t, 4) for t in by_hand.tolist()], " torch:", [round(t, 4) for t in theta.tolist()])

Output:

loss = -log p(mat) = 0.790   (p(mat) = 0.454)
autograd dL/dz: [-0.546, 0.204, 0.151, 0.112, 0.046, 0.034]
p - y        : [-0.546, 0.204, 0.151, 0.112, 0.046, 0.034]
dL/dX == dL/dY @ W^T : True
dL/dW == X^T @ dL/dY : True
residual grad at x (~1.0 even though f is tiny): [1.0, 1.0008, 1.0009, 1.0002]
Adam step by hand: [0.9, -1.9]  torch: [0.9, -1.9]

Build It in PyTorch

Everything above in one runnable script: a 2-block, 64-wide GPT with 103,296 parameters, a toy word-level vocabulary standing in for BPE, a shape trace at every stage, a 200-step training run on a 20-word corpus, and the generation loop with temperature and top-k. Install PyTorch (pip install torch), save as tiny_gpt.py, and run python tiny_gpt.py. It finishes in seconds on a laptop CPU.

import math
import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(0)

# --- 1-3. Text -> tokens -> IDs (a toy word-level vocabulary instead of BPE) ---
vocab = ["<unk>", "The", "cat", "sat", "on", "the", "mat", "dog", "."]
stoi = {w: i for i, w in enumerate(vocab)}
prompt = "The cat sat on the"
ids = torch.tensor([[stoi.get(w, 0) for w in prompt.split()]])   # (B=1, T=5)

V, d_model, n_heads, d_ff, n_layers, T_max = len(vocab), 64, 4, 256, 2, 32
TRACE = True
def show(name, x):
    if TRACE:
        print(f"{name:<34} {tuple(x.shape)}")


class CausalSelfAttention(nn.Module):
    def __init__(self):
        super().__init__()
        self.h, self.d_k = n_heads, d_model // n_heads
        self.W_q = nn.Linear(d_model, d_model)
        self.W_k = nn.Linear(d_model, d_model)
        self.W_v = nn.Linear(d_model, d_model)
        self.W_o = nn.Linear(d_model, d_model)

    def forward(self, x):
        B, T, _ = x.shape
        q, k, v = self.W_q(x), self.W_k(x), self.W_v(x)                  # (B, T, d_model) each
        show("  Q = X W_q", q)
        # split into h heads: (B, T, d_model) -> (B, h, T, d_k)
        q, k, v = (t.view(B, T, self.h, self.d_k).transpose(1, 2) for t in (q, k, v))
        show("  split heads", q)
        scores = q @ k.transpose(-2, -1) / math.sqrt(self.d_k)           # (B, h, T, T)
        show("  QK^T / sqrt(d_k)", scores)
        mask = torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1)
        scores = scores.masked_fill(mask, float("-inf"))                  # hide future tokens
        weights = scores.softmax(dim=-1)                                  # each row sums to 1
        show("  softmax weights", weights)
        out = weights @ v                                                 # (B, h, T, d_k)
        show("  weights x V (per head)", out)
        out = out.transpose(1, 2).reshape(B, T, d_model)                  # concat heads
        show("  concat heads", out)
        return self.W_o(out)


class Block(nn.Module):
    def __init__(self):
        super().__init__()
        self.ln1, self.attn = nn.LayerNorm(d_model), CausalSelfAttention()
        self.ln2 = nn.LayerNorm(d_model)
        self.mlp = nn.Sequential(nn.Linear(d_model, d_ff), nn.GELU(), nn.Linear(d_ff, d_model))

    def forward(self, x):
        x = x + self.attn(self.ln1(x))     # pre-norm attention + residual
        x = x + self.mlp(self.ln2(x))      # pre-norm MLP + residual
        show("  block output", x)
        return x


class TinyGPT(nn.Module):
    def __init__(self):
        super().__init__()
        self.tok_emb = nn.Embedding(V, d_model)
        self.pos_emb = nn.Embedding(T_max, d_model)
        self.blocks = nn.ModuleList(Block() for _ in range(n_layers))
        self.ln_f = nn.LayerNorm(d_model)
        self.lm_head = nn.Linear(d_model, V, bias=False)

    def forward(self, ids):
        B, T = ids.shape
        show("token IDs", ids)
        x = self.tok_emb(ids) + self.pos_emb(torch.arange(T))            # (B, T, d_model)
        show("embeddings + positions", x)
        for i, block in enumerate(self.blocks):
            if TRACE: print(f"block {i}")
            x = block(x)
        logits = self.lm_head(self.ln_f(x))                              # (B, T, V)
        show("logits", logits)
        return logits


model = TinyGPT()
logits = model(ids)
probs = logits[0, -1].softmax(dim=-1)            # only the LAST position predicts the next token
print("next-token probabilities (untrained, so essentially random):")
for p, i in sorted(zip(probs.tolist(), range(V)), reverse=True)[:4]:
    print(f"  {vocab[i]!r:>8}: {p:.3f}")

# --- Train for a few seconds on a tiny corpus (next-token prediction) ---
TRACE = False
corpus = ("The cat sat on the mat . The dog sat on the mat . "
          "The cat sat on the mat . The dog sat on the cat .").split()
data = torch.tensor([[stoi[w] for w in corpus]])
opt = torch.optim.AdamW(model.parameters(), lr=3e-3)
for step in range(200):
    logits = model(data[:, :-1])                                         # predict token t+1 from tokens <= t
    loss = F.cross_entropy(logits.reshape(-1, V), data[:, 1:].reshape(-1))
    opt.zero_grad(); loss.backward(); opt.step()
    if step in (0, 199):
        print(f"step {step:>3}  loss {loss.item():.3f}")

probs = model(ids)[0, -1].softmax(dim=-1)
print("next-token probabilities after training:")
for p, i in sorted(zip(probs.tolist(), range(V)), reverse=True)[:4]:
    print(f"  {vocab[i]!r:>8}: {p:.3f}")

# --- Autoregressive generation loop ---
@torch.no_grad()
def generate(ids, steps, temperature=1.0, top_k=None):
    for _ in range(steps):
        logits = model(ids)[:, -1, :] / temperature                      # (B, V)
        if top_k is not None:
            kth = torch.topk(logits, top_k).values[:, -1, None]
            logits = logits.masked_fill(logits < kth, float("-inf"))
        next_id = torch.multinomial(logits.softmax(-1), num_samples=1)   # sample 1 token
        ids = torch.cat([ids, next_id], dim=1)                           # append and repeat
    return ids

out = generate(ids, steps=4, top_k=3)
print("generated:", " ".join(vocab[i] for i in out[0].tolist()))
print(f"parameters: {sum(p.numel() for p in model.parameters()):,}")

Output (PyTorch 2.14, CPU; the seed makes it reproducible, but exact numbers can differ on other versions or hardware):

token IDs                          (1, 5)
embeddings + positions             (1, 5, 64)
block 0
  Q = X W_q                        (1, 5, 64)
  split heads                      (1, 4, 5, 16)
  QK^T / sqrt(d_k)                 (1, 4, 5, 5)
  softmax weights                  (1, 4, 5, 5)
  weights x V (per head)           (1, 4, 5, 16)
  concat heads                     (1, 5, 64)
  block output                     (1, 5, 64)
block 1
  Q = X W_q                        (1, 5, 64)
  split heads                      (1, 4, 5, 16)
  QK^T / sqrt(d_k)                 (1, 4, 5, 5)
  softmax weights                  (1, 4, 5, 5)
  weights x V (per head)           (1, 4, 5, 16)
  concat heads                     (1, 5, 64)
  block output                     (1, 5, 64)
logits                             (1, 5, 9)
next-token probabilities (untrained, so essentially random):
     'cat': 0.256
     'mat': 0.175
     'sat': 0.127
     'The': 0.090
step   0  loss 2.511
step 199  loss 0.001
next-token probabilities after training:
     'mat': 0.999
     'cat': 0.000
      'on': 0.000
   '<unk>': 0.000
generated: The cat sat on the mat . The dog
parameters: 103,296

Three things to notice in that output. The shape trace matches the diagram exactly: (1, 5, 64) in, split into (1, 4, 5, 16) heads, a (1, 4, 5, 5) attention grid, back to (1, 5, 64), and (1, 5, 9) logits over the 9-word vocabulary. Before training, the next-token guess is essentially random. After 200 steps the loss falls from 2.51 to 0.001 and the model puts 0.999 on "mat". Be clear about what that means: with 103K parameters and a 20-word corpus, the model has memorized the text rather than learned English. The mechanism is identical to a frontier model's; the difference is scale and data.

Shape Cheat Sheet

StageShapeLearned?
Token IDs(T)no
Embeddings + positions(T, d)yes — E and P
Q, K, V(T, d) eachyes — WQ, WK, WV
Split into heads(h, T, dk), dk = d / hno — a reshape
Attention scores / weights(h, T, T)no
Attention output (per head)(h, T, dk)no
Concat + WO(T, d)yes — WO
MLP hidden(T, 4d)yes — Wup, Wdown
Block output(T, d)—
Logits(T, V)yes — LM head
Next-token probabilities(V), last position onlyno — softmax

Add a leading batch dimension B to every row for batched inputs, as in the script's (1, 5, 64). The (T, T) attention grid is why attention's cost grows with the square of the context length — the reason long-context models need tricks like FlashAttention and sparse or linear attention.

The whole idea in four sentences
Turn tokens into vectors and add position. Pass them through a stack of blocks, where attention lets each token gather information from earlier tokens and an MLP processes each token on its own, with residual connections carrying the signal through. Project the last token's vector onto the vocabulary to get next-token probabilities. Sample one, append it, and repeat — and to train it, measure how wrong each prediction was, send that error backward through every layer, and nudge every weight to make it a little less wrong.

⚠️ Notes

The architecture described (pre-norm placement, 4d GELU MLP, final LayerNorm, causal mask) was checked line by line against OpenAI's released gpt-2/src/model.py. All tensor shapes, the attention worked example, the training loss and the generated text are real outputs from the scripts described, not hand-written. The temperature/top-k widget uses illustrative scores, labeled as such. The backpropagation rules (softmax + cross-entropy gradient p − y, the two linear-layer rules, the residual gradient) and the Adam update were each verified numerically with PyTorch autograd; the script and its output are included. The 7B-shape parameter example was computed from the formula, and the Llama 2 7B cross-check (6,738,415,616) matches the size usually reported for that model. The claim that MLPs store much of a model's knowledge is a simplification of an active interpretability research area, not a settled result. All diagrams are original.

🔗 References