LET'S BUILD GPT // FIELD MAP
← field map
PART 05 · SELF-ATTENTION71:38–79:11 · 8 min

Six notes on attention

Andrej Karpathy · Let's build GPT (2023) · part 05 of 8

Transcript: this part, with timestamps

TL;DR — No code changes in these eight minutes. Having built self-attention in P4, Karpathy stops and says what it is: a communication mechanism between nodes of a directed graph, where every node emits a query and a key, and the edge weights are computed from the data rather than baked in. Everything else about the version you just wrote — the position embeddings, the triangular mask, the fact that queries, keys and values all come from the same tensor, the mysterious * head_size**-0.5 — is a choice, and each of the six notes names one choice and what happens if you make it differently. The one to carry: attention has no built-in notion of space, of causality, or of "self"; those come from the mask, the position embedding, and where you source K and V.

This is the part of the lecture that turns a working block of PyTorch into a mental model. In P4 you watched wei go from a constant lower-triangular average to a data-dependent matrix of query·key affinities, and you saw values get aggregated instead of raw embeddings. That's the mechanism. But a mechanism you can only run is not a mechanism you can modify, and the next thirty minutes of the video (P6) will modify it hard — four heads in parallel, a feed-forward on top, residuals around both. So Karpathy pauses and draws the boundary between the operation and its costume: six short notes, each isolating one thing you could change without attention noticing.

Outline, with timestamps

Note 1 — attention is message passing, and the graph is an input

The framing that unlocks the rest: forget sequences for a moment and picture a directed graph. Every node holds a vector. Every node also advertises two things — a query, "here is what I'm looking for", and a key, "here is what I contain" — and the strength of the edge from node j to node i is the dot product of i's query with j's key. Normalise those strengths over i's in-neighbours, and each node updates itself with the weighted sum of the values its neighbours are willing to broadcast.

Attention is a communication mechanism.Karpathy, 71:38

Nothing in that description mentions text, order, or time. The graph is a parameter of the problem, not of the model: attention is defined for any set of nodes and any pattern of edges you care to allow. What the lecture's code happens to instantiate is one particular, very rigid graph — the autoregressive one. With block_size = 8 in the notebook, node 1 has exactly one in-edge (itself), node 2 has two, node 8 has eight. That's the lower-triangular pattern, and it lives in the model only as a tril buffer that decides which entries of wei get set to -inf before the softmax. Swap that buffer for a different 0/1 matrix and you have a different graph, with the same weights, the same shapes, and the same training loop.

This is worth internalising because it is the fork in the road that produced the whole zoo. Bidirectional encoders (BERT-style) use the all-ones matrix. Sliding-window attention uses a band. Prefix-LM uses a matrix that is dense in the prompt region and triangular after it. None of those are new operations; they are new masks.

Notes 2 and 3 — the two things attention genuinely cannot do

Note 2 is the sharpest one, and it explains a piece of P3 that can otherwise feel arbitrary. A convolution has spatial structure welded into it: a 3×3 filter means "left neighbour, me, right neighbour", and moving a pixel changes the answer. Self-attention has no such thing. The softmax-weighted sum is permutation-equivariant over the time axis — shuffle the tokens and the outputs come back shuffled the same way, unchanged in content. The model is looking at an unordered bag of vectors.

Which is exactly why position_embedding_table exists. In P3 Karpathy added a second embedding table indexed by torch.arange(T) and summed it into the token embeddings, and at the time it looked like a small nicety. It isn't: without it the model literally cannot tell "abc" from "cba", because the only thing carrying order was thrown away the moment we started doing weighted sums over a set.

One honest wrinkle Karpathy skips. The causal mask is itself indexed by position, so a decoder-only stack is not fully position-blind: token i can attend to exactly i+1 predecessors, and that count is a weak positional signal a network can learn to exploit. Later work found that decoder-only Transformers train tolerably with no explicit position encoding at all for this reason. The note is still the right note — attention proper has no notion of space — but the mask leaks a little of it back in.

Note 3 is smaller and is really a shape-reading exercise. The batch dimension is inert. wei has shape (B, T, T), and the matmuls that produce and consume it are batched matmuls: PyTorch runs B independent (T, T) problems in parallel and never mixes them. In the notebook's directed-graph picture that means not one graph of eight nodes but four separate graphs of eight nodes each — thirty-two nodes, twenty-eight of which any given node is forbidden from ever seeing.

TensorShape (notebook, v4)Shape (final gpt.py, per head)What it is
x(4, 8, 32)(64, 256, 384)B, T, C — token + position embeddings
k, q, v(4, 8, 16)(64, 256, 64)three bias-free linear projections of x
wei(4, 8, 8)(64, 256, 256)affinities; row i is a distribution over j ≤ i
tril[:T,:T](8, 8)(256, 256)not a parameter; broadcast across B
out(4, 8, 16)(64, 256, 64)head output — head_size wide, not C

Note the asymmetry that makes note 3 non-trivial rather than obvious: tril is shared across the batch and constant across training, while wei is different for every one of the B sequences and changes every step. The mask says which edges may exist; the data says how much each one carries. And because nothing crosses the batch, any layer that does mix across batch — BatchNorm, most obviously — would be a genuinely different kind of object here. That's a quiet setup for the LayerNorm discussion in P6.

Note 4 — encoder and decoder blocks differ by one line

If you want every token to see every other token — you're classifying the sentiment of a whole sentence, say, and there is no "future" to protect — you delete the masked_fill. That's the entire difference. Karpathy's terminology follows the original paper: a block with the triangular mask is a decoder block (it decodes autoregressively, so it must not see ahead), and one without is an encoder block.

Attention supports arbitrary connectivity between nodes.Karpathy, 75:09

Why the mask is load-bearing here and not just a design flourish: recall from P1 that the targets y are the inputs x shifted left by one. Position i's label is token i+1. If position i may attend to position i+1, the answer is sitting in the input. Training loss will fall through the floor, validation loss will follow it — because validation is scored the same leaky way — and generation, which has no future to peek at, will produce noise. This is the single most common way people break the model while "just experimenting", and it fails silently and impressively.

Two things the "one line" framing glosses over, worth knowing before you go read a real encoder-decoder implementation. First, a full encoder-decoder Transformer differs by more than the mask: the decoder carries a third sublayer, cross-attention into the encoder's output, which the lecture's model never grows. Second, real encoders still use masks — just padding masks, zeroing out attention to filler positions in variable-length batches. Karpathy's dataset has fixed-length chunks, so the issue never arises here.

Note 5 — "self" is a statement about where Q, K and V come from

In Head.forward, all three projections are applied to the same argument: self.key(x), self.query(x), self.value(x). One tensor in, three views out, nodes talking to themselves as a group. That, and only that, is what makes it self-attention.

Break the tie and you get cross-attention: queries from one stream, keys and values from another. The rule that keeps it straight — queries come from the stream you are writing into; keys and values come from the stream you are reading from. A translation decoder produces its queries from the partial output it has generated so far and reads keys and values off the encoded source sentence. A vision-language model produces queries from text tokens and reads image patches. The mechanism is byte-for-byte the same; the two nn.Linear calls just take a different argument.

The shape consequence is the one people trip on. In self-attention wei is square, (B, T, T), because the query set and the key set are the same set. In cross-attention it is (B, T_q, T_kv) — a rectangle, one row per node you're updating, one column per node you're reading. A triangular mask is meaningless on a rectangle and is normally absent: there is no ordering relationship between "the fourth word I'm generating" and "the ninth patch of the image".

The carry-away for all five notes so far: attention itself is one line — a softmax over scaled dot products, times values. Causality is the mask. Order is the position embedding. "Self" is an argument-passing decision. Batch independence is free. Every architecture variant you'll meet later is a different setting of those dials, not a different mechanism, which is why the code in Head barely changes as the models get enormous.

Note 6 — why * head_size**-0.5, in numbers

The last note is the one with actual arithmetic behind it, and it's the reason the paper says "Scaled Dot-Product Attention" rather than just "Dot-Product Attention". Take q and k to be roughly unit-Gaussian — zero mean, unit variance per channel — which is approximately true at initialisation with sensible init. Their dot product sums head_size independent products of unit-variance terms, so its variance is on the order of head_size: 16 in the notebook, 64 in the final gpt.py, 128 in GPT-3-scale models. Multiplying by head_size**-0.5 divides the variance by head_size and puts it back at 1, independently of how wide you made the head.

Why unit variance rather than any other constant: wei feeds a softmax, and softmax's sharpness is set by the scale of its inputs, not their pattern. Logits near zero come out near uniform; multiply the same logits by eight and softmax converges toward a one-hot vector, putting essentially all mass on the single largest entry. A head whose attention is one-hot at initialisation is a head that copies one neighbour instead of averaging its neighbourhood — it has thrown away the aggregation that makes attention useful before the first gradient step, and the gradients that reach the non-selected entries are vanishing.

Numbers, from sampling q, k ~ N(0,1) with T = 8 and applying the causal mask, then measuring how much probability mass the last row's softmax puts on its single largest entry (uniform over eight would be 0.125):

head_sizevar(wei) unscaledpeak mass, unscaledvar(wei) scaledpeak mass, scaled
16 (notebook)16.00.751.000.36
64 (final gpt.py)64.00.881.000.36
256256.00.941.000.36

Read the two right-hand columns as the point of the whole note: with the scaling, the initial attention distribution is the same shape no matter how wide the head is. Without it, widening the head silently sharpens attention — a hyperparameter change quietly becoming an initialisation change, which is exactly the kind of coupling that makes deep networks refuse to train and gives you nothing to debug.

# the argument, reproduced in four lines
q = torch.randn(B, T, head_size)
k = torch.randn(B, T, head_size)
wei = q @ k.transpose(-2, -1)                      # wei.var() ~ head_size
wei = q @ k.transpose(-2, -1) * head_size**-0.5    # wei.var() ~ 1
The scaling is used just to control the variance at initialization.Karpathy, 78:44

Notice the hedge, and take it seriously: at initialization. Nothing constrains q and k to stay unit-Gaussian once training starts — the projections are learned, and a head is perfectly free to grow its weights and sharpen its own attention, which is often what you want a trained head to do. The 1/√d_k is not a running normaliser; it is a one-time choice of units that stops the model from starting in a degenerate place. Modern stacks that train at scales where even this isn't enough add an actual normaliser — QK-Norm, applying LayerNorm to q and k before the dot product — precisely because the initialisation-time argument doesn't cover step 50,000.

The code at the end of this part

These eight minutes change no code — they annotate code you already wrote in P4. Here is the class as it appears in the finished gpt.py, with each of the six notes pointing at its line:

class Head(nn.Module):
    """ one head of self-attention """

    def __init__(self, head_size):
        super().__init__()
        self.key = nn.Linear(n_embd, head_size, bias=False)
        self.query = nn.Linear(n_embd, head_size, bias=False)
        self.value = nn.Linear(n_embd, head_size, bias=False)
        self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))

        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        # input of size (batch, time-step, channels)
        # output of size (batch, time-step, head size)
        B,T,C = x.shape
        k = self.key(x)   # (B,T,hs)
        q = self.query(x) # (B,T,hs)
        # compute attention scores ("affinities")
        wei = q @ k.transpose(-2,-1) * k.shape[-1]**-0.5 # (B, T, hs) @ (B, hs, T) -> (B, T, T)
        wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf')) # (B, T, T)
        wei = F.softmax(wei, dim=-1) # (B, T, T)
        wei = self.dropout(wei)
        # perform the weighted aggregation of the values
        v = self.value(x) # (B,T,hs)
        out = wei @ v # (B, T, T) @ (B, T, hs) -> (B, T, hs)
        return out

Line by line, against the notes (gpt.py L64–L90):

Where people get stuck

Go deeper, verified

Exercises

  1. Break the mask on purposecode — in the Colab, comment out the masked_fill line and retrain the single-head model for the same number of steps. (1) Record train and val loss. (2) Generate 300 characters. (3) Explain in one sentence why the loss is far lower and the text far worse. (4) Now keep the mask but shift y by two instead of one in get_batch and predict two tokens ahead — does the mask still leak? A good answer names the shifted-targets relationship explicitly and states which of loss and sample quality you should trust when they disagree.
  2. Measure the variance argument yourselfcode — sample q, k = torch.randn(B, T, hs) for hs in {16, 64, 256}. (1) Print wei.var() with and without * hs**-0.5. (2) Apply the causal mask and softmax, then print the mean of the largest probability in the last row. (3) Plot peak mass against hs for both. (4) Repeat with q and k scaled by 3 to simulate a trained model. You should reproduce the table above — roughly 0.36 peak mass at every head size when scaled, climbing toward 1.0 when not — and see that the scaling fixes the width dependence, not the magnitude of trained weights.
  3. Turn the head into a cross-attention headcode — write CrossHead.forward(self, x, context) that takes queries from x and keys/values from context, with no mask. (1) Assert the shape of wei is (B, T_q, T_kv) for T_q != T_kv. (2) Confirm the output has T_q rows. (3) Say in one line why tril cannot be applied here. A good answer notes that Head and CrossHead differ only in the argument passed to two nn.Linear calls.

None of Karpathy's own EX1–EX4 belongs to this part — EX1 (batched heads) lands in P7, and the rest are whole-model exercises. Exercise 3 above is a decent warm-up for EX4 if you plan to implement a cross-attending variant.

Next: P06 Assembling the block: heads, feed-forward, residuals, LayerNorm · Back to the map.