Generative AI Engineer Series · Article 05 · Phase 1: Foundations

Attention Mechanism Deep Dive

Every symbol in the attention equation, built from the problem it solves. Then we look inside a real model and watch its attention heads at work, including one that looks smarter than it is.

The whole mechanism
1 linesoftmax(QKᵀ / √d_k) · V
A real head, "it" → "trophy"
0.58all-MiniLM-L6-v2, layer 4, head 2
A real "previous word" head
1.00layer 1, head 3 looks only one step back
Double the length
4×the attention work, because every token meets every token

§ 01 · HookA Promise Kept

Here is the whole thing in one line, from the 2017 paper:

The attention equationVaswani et al., 2017
Attention(Q, K, V) = softmax( Q·Kᵀ / √d_k ) · V

By the end of this article, every symbol in that line will make sense, and you'll know why each piece is there. We'll build up to it from scratch, introducing each part only after you've seen the problem it solves.

This matters beyond theory. Engineers who understand attention can reason about why a long prompt gets expensive, what a model card means by "RoPE" or "FlashAttention", and why a model sometimes "loses" information buried in the middle of a long context.

📹 YouTube note Open with the equation on screen. Say: "By the end of this video, every symbol here will make sense, and you'll know why each piece is there." Let it sit for three seconds, then: "Let's build up to it from scratch."

§ 02 · The Problem, PreciselyThe Problem Attention Solves

After tokenisation, each token ID is swapped for a vector from the embedding table (Article #02, §03). That vector is static: it's a single row of a table, the same every time the token appears, whatever surrounds it.

So "bank" enters the Transformer with exactly the same vector in "the river bank eroded" and "the savings bank collapsed". (In BERT-base that's 768 numbers; in the small model we'll use below, 384.) Yet the two uses mean completely different things. The input vector can't know which one it is.

Attention's job is to update each token's vector by letting it gather information from the rest of the sentence. What comes out of an attention layer isn't the original static vector; it's a new, context-aware one that has "looked at" the whole sentence and blended in whatever was relevant.

Figure 1From one static vector to context-aware vectors
Going in: one static vectorComing out: context-aware vectors"the river bank eroded"bankriver"the grassy bank of the river"bankriver"the savings bank collapsed"bankmoney"deposited cash at the bank"bankmoney6 layers ofattention + FFNcosine, afterriver ↔ river0.87money ↔ money0.76river ↔ money0.72the same embedding-table row every timeReal token vectors from all-MiniLM-L6-v2. Same-sense pairs end up closer than cross-sense pairs.

← swipe to see the whole figure →

Measured in a real model: all four "bank" tokens start from the same row of the embedding table (only a small position signal differs). After six layers, the two river uses score 0.87 with each other, while a river use and a money use score only 0.72.
The bridge from Article #04

Embeddings give each token a starting point. Attention is how that meaning becomes context-sensitive. The contextual sentence vectors you built in Article #04 were exactly this: token vectors after attention, averaged together.

§ 03 · The Core MechanismQueries, Keys, and Values

This is the heart of the article, so take it slowly. We'll build it in three stages.

Stage 1: the search story, generalised

Article #02 used a video-search example. Here's the general version: every token is both a searcher and a search result, at the same time.

As a searcher, each token asks, "What information do I need from the rest of this sentence?" That question is its Query. As a result, each token offers two things: a Key, a label other tokens match their questions against, and a Value, the actual content handed over when there's a match. The better a query matches a key, the more of that token's value gets retrieved. Every token searches all the others and is searched by all the others, simultaneously.

Stage 2: three learned projections

Each token's vector x is multiplied by three different learned matrices:

Three projectionsone token, three roles
Q = x · W_Q    # what am I looking for?
K = x · W_K    # what do I advertise?
V = x · W_V    # what do I deliver?

W_Q, W_K, and W_V are ordinary parameters, learned by gradient descent (Article #01, §05) along with everything else. They're deliberately separate, because "what I'm looking for" and "what I have to offer" are different things. A pronoun like "it" is looking for a noun; the noun "trophy" is offering itself as one. Separate matrices let the model learn those roles independently.

The projected vectors are usually smaller than the original. In the 2017 paper, each token vector has 512 numbers and there are 8 attention heads, so each head's Q, K, and V have 512 ÷ 8 = 64 numbers.

Figure 2One token, projected three ways
one token's vector x512 numbers× W_Qlearned, 512 × 64QQuery"what am I looking for?"× W_Klearned, 512 × 64KKey"what do I advertise?"× W_Vlearned, 512 × 64VValue"what do I deliver?"each: 64 numbers per head

← swipe to see the whole figure →

The same input vector, multiplied by three different learned matrices, gives three different vectors with three different jobs.

What does "multiply by a matrix" actually mean? If you haven't done matrix maths since school, this phrase can feel like a black box. It isn't. Each number in the output is just a dot product: take the token's vector, pair it up with one column of the matrix, multiply matching positions, and add. Do that once per column and you have the new vector. Figure 3 shows it with small numbers you can check in your head.

Figure 3A projection, worked by hand
token vector xlearned matrix W_Qquery q10211 × 4×0.511−100.5204 × 2=2.52.01 × 2The 4 numbersacross x must matchthe 4 rows down W.4 numbers in,2 numbers out.q[1] = 1×0.5 + 0×1 + 2×0 + 1×2 = 2.5q[2] = 1×1 + 0×(−1) + 2×0.5 + 1×0 = 2.0each output = x · one column

← swipe to see the whole figure →

A 4-number token vector times a 4 × 2 matrix gives a 2-number query. The first output uses the first column, the second output uses the second column. Real models do exactly this, just with 512 numbers in and 64 out.

Two things to notice. First, the sizes have to line up: the 4 numbers across the vector must match the 4 rows down the matrix. Second, the number of columns decides how long the result is. That's how a matrix can shrink a 512-number vector into a 64-number query: it simply has 64 columns. W_K and W_V work the same way, just with different numbers inside, which is why the same token comes out as three different vectors.

Stage 3: the four steps, each with a reason

Step 1, dot product. To decide how much token i should attend to token j, compute score(i, j) = Q_i · K_j. A dot product multiplies matching positions of two vectors and adds them up (Article #02, §04); it's large when the two vectors point the same way. A high score means i's question is well matched by j's label.

Step 2, scale by √d_k. Here's why this step exists. A dot product of two 64-number vectors adds up 64 products, so as vectors get longer, the scores naturally get bigger. Big scores push the next step, softmax, into an extreme where one token gets almost all the weight and the rest get almost none. In that state the gradients become tiny and learning stalls, the same vanishing-gradient problem from Article #02, §02. Dividing by √d_k (√64 = 8 for 64-number keys) keeps the scores in a healthy range.

Step 3, softmax. Turn each token's row of scores into weights between 0 and 1 that add up to 1 (Article #01, §08). Each weight says how much of token j's information token i should take in. Softmax works on one row at a time, so each token gets its own set of weights.

Now we can see what "the extreme" from Step 2 looks like. Take three scores, 1, 2 and 3. Softmax turns them into 0.09, 0.24 and 0.67: the best match wins, but the others still count. Multiply the same scores by 8 and softmax gives 0.00, 0.00 and 1.00. One token takes everything. It's like a microphone turned up too loud, where the loudest voice drowns out everyone else in the room. And because the losing tokens are stuck at zero, small changes to them make no difference, so the model gets almost no signal about how to improve them.

Figure 4Why the scores need scaling
Scores 1, 2, 30.09token 10.24token 20.67token 3softmax weights: a sensible spreadSame scores × 8: 8, 16, 240.00token 10.00token 21.00token 3softmax weights: winner takes allWhy scores get big: typical size of a dot product between two random vectorsvectors of 4 numbers≈ 2.0 (√d = 2)vectors of 64 numbers≈ 8.0 (√d = 8)vectors of 512 numbers≈ 22.7 (√d = 22.6)Measured over 100,000 random pairs. Dividing by √d brings the typical size back to about 1 at any length.

← swipe to see the whole figure →

Top: the same three scores, before and after being multiplied by 8. Bottom: longer vectors give bigger dot products, and the typical size grows like √d. That's exactly why the fix is to divide by √d_k.

Why divide by √d_k and not some other number? We measured it (bottom of Figure 4). For random vectors, the typical dot product between two 4-number vectors is about 2, between two 64-number vectors about 8, and between two 512-number vectors about 22.6. Those are exactly √4, √64 and √512. So dividing by √d_k cancels out the growth and brings scores back to a similar size, however long the vectors are.

Step 4, weighted sum of Values. Multiply each Value vector by its weight and add them all up: output_i = Σ weight_ij × V_j. The result is a blend of every token's Value, dominated by the most relevant ones.

A blend is easier to picture with something familiar. Say it's Friday movie night, and your mood splits 50% towards a heist thriller, 30% towards a family comedy and 20% towards a cricket drama. Describe each film with three numbers: action (out of 10), comedy (out of 10) and running time in minutes.

FilmYour weightActionComedyMinutes
Heist thriller0.592150
Family comedy0.329130
Cricket drama0.254160
Your blended "mood"sums to 16.14.5146

Each column of the last row is the same recipe: 0.5 × 9 + 0.3 × 2 + 0.2 × 5 = 6.1 for action, and so on. The result isn't any one of the three films. It's a new description that leans towards the one you cared about most. In attention, the weights come from softmax, the films are the tokens' Value vectors, and the blended mood is the token's new, context-aware vector. When "it" puts most of its weight on "trophy", the new vector for "it" ends up carrying a good share of what "trophy" means.

Two things beginners often mix up

The weights behave like probabilities; the output doesn't. The weights are between 0 and 1 and add up to 1. The output is a blend of Values, so it can be any size and can be negative. 146 minutes is a perfectly good answer above, and you'll see negative numbers in the NumPy output below.

Q and K never reach the output. Queries and keys are used only to work out the weights, that is, how much each token should look at each other token. The Values are the only thing that actually flows into the result, so they decide what gets passed on.

Attention from scratch in NumPy

It's short enough to write yourself. The weights below are random (nothing is trained), so the pattern of who looks at whom means nothing. The point is to watch the mechanics.

Pythonscaled dot-product attention, from scratch
import numpy as np
np.set_printoptions(precision=2, suppress=True)

def softmax(x):
    x = x - x.max(axis=-1, keepdims=True)          # subtract the max for numerical safety
    e = np.exp(x)
    return e / e.sum(axis=-1, keepdims=True)

def attention(Q, K, V, causal=False):
    d_k = Q.shape[-1]
    scores = Q @ K.T / np.sqrt(d_k)                 # steps 1 and 2: dot products, then scale
    if causal:                                      # decoder models: hide the future
        future = np.triu(np.ones_like(scores), k=1).astype(bool)
        scores = np.where(future, -np.inf, scores)
    weights = softmax(scores)                       # step 3: each row sums to 1
    return weights @ V, weights                     # step 4: blend the Values

rng = np.random.default_rng(0)
tokens = ["the", "cat", "sat", "down"]
X = rng.normal(size=(4, 8))                         # 4 token embeddings, 8 numbers each
W_Q, W_K, W_V = (rng.normal(size=(8, 4)) for _ in range(3))

Q, K, V = X @ W_Q, X @ W_K, X @ W_V                 # three projections, 4 numbers each
out, w = attention(Q, K, V)
print(w)
print("row sums:", w.sum(axis=1))
print("shapes:", X.shape, Q.shape, w.shape, out.shape)
print("new vector for 'cat':", out[1])

# [[0.38 0.13 0.46 0.03]
#  [0.01 0.   0.13 0.86]
#  [0.91 0.   0.08 0.  ]
#  [0.01 0.25 0.08 0.66]]
# row sums: [1. 1. 1. 1.]
# shapes: (4, 8) (4, 4) (4, 4) (4, 4)
# new vector for 'cat': [ 0.94 -0.4  -0.03  0.59]

Each row of w is one token's attention: how much it takes from each of the four tokens. Every row sums to 1. The shapes tell the story too: 4 tokens of 8 numbers go in, each is projected down to 4 numbers, the weights form a 4 × 4 grid (every token against every token), and out comes one new 4-number vector per token. Notice that the new vector for "cat" has negative numbers in it. That's fine: it's a blend of Values, not a probability. Figure 5 follows the "cat" row through all four steps.

Figure 5The four steps for one token
token1 · Q(cat) · K2 · ÷ √d_k = 23 · softmax4 · blendthe-4.03-2.010.010.01 × V(the)cat-10.99-5.500.000.00 × V(cat)sat+0.77+0.380.130.13 × V(sat)down+4.58+2.290.860.86 × V(down)sums to 1.00new vector for "cat"Real numbers from the NumPy code in §03 (random, untrained weights, so the pattern itself means nothing).Watch the mechanics: scale, squash into weights that sum to 1, then blend.

← swipe to see the whole figure →

Raw scores, scaled by √4 = 2, turned into weights by softmax, then used to blend the Values into a new vector.

The paper's diagram, decoded

If you open the 2017 paper, you'll find this recipe drawn as a small stack of boxes called "Scaled Dot-Product Attention". It looks intimidating at first, but you now know every box in it. Figure 6 redraws it top to bottom, with the shapes from our NumPy example written beside each step.

Figure 6"Scaled Dot-Product Attention", with shapes
QKVMatMul Q · KᵀScale ÷ √d_kMask (optional)SoftmaxMatMul weights · Vnew vector per tokenQ and K decideHOW MUCHeach token looksat each otherV decides WHATgets passed oneach 4 × 4: 4 tokens, 4 numbers4 × 4 scoresrows: tokens looking; columns: looked atsame shape, ÷ 2 herebecause d_k = 4decoders only (§04)future scores → −∞4 × 4 weightseach row sums to 14 × 4 output

← swipe to see the whole figure →

Q and K travel together through the first four boxes to produce the weights. V takes its own path and only joins at the end. The dashed Mask box is optional; decoder models use it to hide future tokens (§04).

The diagram answers a question many learners ask: why do Q and K travel together while V takes a separate route? Because Q and K have one job, working out the weights, and V has a different job, supplying the content. "MatMul" is simply the paper's short name for matrix multiplication.

The equation, fully motivated

Attention(Q, K, V) = softmax(QKᵀ / √d_k) · V

QKᵀ computes every query-key dot product at once: how well each token's question matches each token's label. / √d_k keeps the scores from growing so large that softmax saturates. softmax(…) turns each row into weights that sum to 1. · V blends the content using those weights. Nothing in the formula is arbitrary; every piece fixes a specific problem.

Notice, too, that there's no loop over tokens anywhere. Every token's attention is worked out in the same two matrix multiplications, all at once. That's the parallelism Article #02 contrasted with RNNs, which had to read one word at a time, and it's a big reason Transformers can be trained on so much text.

📹 YouTube note Work through a three-token example on a whiteboard with small numbers, and compute one row by hand. Say: "Don't memorise the formula. Remember the story: I have a question (Q), I match it against everyone's label (K), and I collect their content (V)."

§ 04 · VariantsSelf-Attention, Causal Masking, and Cross-Attention

The same mechanism comes in three variants, and mixing them up causes real confusion when you read model cards and papers.

Figure 7Three variants of one mechanism
Self-attentionbidirectional (BERT)q1q2q3q4q5k1k2k3k4k5every token sees every tokenrows = queries, columns = keysCausal self-attentionevery decoder LLMq1q2q3q4q5k1k2k3k4k5−∞(masked)each token sees only the pastrows = queries, columns = keysCross-attentionencoder-decoder, diffusiond1d2d3d4e1e2e3e4e5e6decoder rows read encoder columnsQ from decoder, K and V from encoder

← swipe to see the whole figure →

A filled square in row r, column c means "query r may look at key c". Causal attention, in the middle, is the one inside every text-generating LLM.

Self-attention (bidirectional)

Q, K, and V all come from the same sequence, and every token can look at every other token, before and after it. This is what encoder models like BERT use, for understanding tasks such as classification, named entity recognition (finding and labelling names of people, places, and organisations in text), and embeddings.

Causal (masked) self-attention

Q, K, and V still come from the same sequence, but each token may only look at the tokens before it. This is what decoder-only LLMs use: the GPT, Llama, and Mistral families, and, as far as their makers have described them, most of today's chat models.

Cross-attention

The queries come from one sequence and the keys and values from another. In an encoder-decoder model like T5, the decoder's tokens ask questions of the encoder's output. Image generators such as Stable Diffusion use cross-attention too, letting the picture being generated "consult" the words of your prompt.

Why causal masking is necessary

During training, the model sees the whole sequence at once, because that's what makes training parallel (Article #02). Without a mask, token 5 could look at token 6 while being trained to predict token 6. It would be copying the answer.

The fix is to set the scores for all future positions to −∞ before the softmax. Since e−∞ is 0, those tokens get exactly zero weight, as if they didn't exist yet. You can see this in the NumPy code: pass causal=True and every weight above the diagonal becomes 0. It's the same rule as your phone's predictive text (Article #01, §08): it can only use what you've typed so far, never the word you haven't typed yet.

At generation time, the mask is almost automatic. The model produces one token at a time, left to right, so future tokens genuinely don't exist yet.

§ 05 · Multiple RelationshipsMulti-Head Attention

One attention operation tends to capture one kind of relationship. But a sentence is a web of relationships at once: who did what, what "it" refers to, which words sit next to each other, which words share a topic. So Transformers run several attention operations side by side, each with its own W_Q, W_K, and W_V, then join the results. This is the mechanical version of Article #02's "reading the same novel several times, each time looking for something different".

Multi-head attentionh heads, then one output matrix
MultiHead(Q, K, V) = Concat(head₁, …, head_h) · W_O
    where head_i = Attention(Q · W_Q⁽ⁱ⁾, K · W_K⁽ⁱ⁾, V · W_V⁽ⁱ⁾)

Each head works in a smaller space. With 512-number token vectors and 8 heads, each head gets 64-number queries, keys, and values. So adding heads doesn't multiply the work; the same total is split into several parallel, specialised jobs whose outputs are concatenated and mixed by one final matrix, W_O.

"Concatenated" just means placed side by side. Each of the 8 heads hands back a 64-number vector for every token. Lay the 8 vectors end to end and you get 8 × 64 = 512 numbers, the same size the token started with. W_O then lets the model mix what the different heads found. In a sentence like "I ate the cake and it was delicious", one head might link "it" to "cake", another might link "ate" to "cake", and a third might carry the positive feeling of "delicious" over to "cake". Joining the heads puts all of that into one vector.

What heads actually learn

Nobody tells a head what to specialise in. Researchers who study what trained models have learned inside (an area nicknamed "BERTology") have found heads that track recognisable patterns:

  • Neighbour heads that look at the previous or next word.
  • Syntax heads where verbs attend to their subjects, or objects to their verbs.
  • Coreference-like heads, where a pronoun such as "it" attends to an earlier noun. (Coreference means working out which earlier thing a word like "it" or "she" refers to.)
  • Separator heads that park most of their attention on special tokens like [SEP] when they have nothing better to do.

You don't have to take this on trust. Figure 8 shows three heads from a real model, all reading the same sentence.

Figure 8Three real heads, one sentence, three behaviours
Layer 1 · Head 3looks at the previous tokenaverage weight on previous token: 1.00Layer 2 · Head 9looks at the next tokenaverage weight on next token: 0.58Layer 4 · Head 2"it" looks back at "trophy"it → trophy: 0.58Real attention from all-MiniLM-L6-v2 on "The trophy didn't fit in the suitcase because it was too big."Each grid: 17 tokens × 17 tokens. Row = the token doing the looking. Darker = more attention.

← swipe to see the whole figure →

Layer 1, head 3 puts all its weight on the previous token. Layer 2, head 9 mostly looks one token ahead. Layer 4, head 2 sends 58% of "it"'s attention back to "trophy". None of this was programmed.

§ 06 · Hands-on CodeSee Real Attention with bertviz

bertviz is an open-source tool that draws a Transformer's actual attention weights as interactive coloured lines inside a Jupyter notebook. You can click through layers and heads and see the patterns from §05 for yourself. We'll use all-MiniLM-L6-v2, the small BERT-style model from Article #04 (6 layers, 12 heads each), because it downloads quickly. The same code works with bert-base-uncased (12 layers, 12 heads).

Bashsetup
$ pip install bertviz transformers torch
Pythoninteractive attention view in Jupyter
import torch
from bertviz import head_view
from transformers import AutoTokenizer, AutoModel

name = "sentence-transformers/all-MiniLM-L6-v2"
tokenizer = AutoTokenizer.from_pretrained(name)
model = AutoModel.from_pretrained(name, output_attentions=True,
                                  attn_implementation="eager")   # needed to return attention weights
model.eval()

sentence = "The trophy didn't fit in the suitcase because it was too big."
inputs = tokenizer(sentence, return_tensors="pt")

with torch.no_grad():
    outputs = model(**inputs)

attention = outputs.attentions            # one tensor per layer: (batch, heads, seq_len, seq_len)
tokens = tokenizer.convert_ids_to_tokens(inputs["input_ids"][0])

print(len(attention), attention[0].shape[1], len(tokens))
# 6 12 17     6 layers, 12 heads, 17 tokens

print(tokens)
# ['[CLS]', 'the', 'trophy', 'didn', "'", 't', 'fit', 'in', 'the', 'suitcase',
#  'because', 'it', 'was', 'too', 'big', '.', '[SEP]']

head_view(attention, tokens)             # interactive view, renders in Jupyter

Notice there are 17 tokens, not 12 words: BERT-style tokenisers add [CLS] and [SEP], and split "didn't" into didn, ', and t (Article #03 in action). In the interactive view:

  • Click between heads and watch the patterns change completely.
  • Find a head where every token points one step back: a neighbour head.
  • Find a head where "it" sends a thick line to "trophy".
  • Earlier layers tend to show more local, syntax-like patterns; later layers tend to show longer-range ones.

Find the "it" head automatically, then draw it

Instead of hunting by eye, let the code search all heads for the one where "it" attends most strongly to "trophy":

Pythonsearch every head, then plot a heatmap
import matplotlib.pyplot as plt

it, trophy = tokens.index("it"), tokens.index("trophy")
layer, head = max(
    ((l, h) for l in range(len(attention)) for h in range(attention[0].shape[1])),
    key=lambda lh: attention[lh[0]][0, lh[1], it, trophy].item(),
)
weights = attention[layer][0, head].numpy()     # rows = token looking, columns = token looked at
print(f"layer {layer+1}, head {head+1}: it → trophy = {weights[it, trophy]:.2f}")
# layer 4, head 2: it → trophy = 0.58

fig, ax = plt.subplots(figsize=(8, 6))
im = ax.imshow(weights, cmap="Oranges", vmin=0, vmax=1)
ax.set_xticks(range(len(tokens)), tokens, rotation=60, ha="right", fontsize=9)
ax.set_yticks(range(len(tokens)), tokens, fontsize=9)
ax.set_title(f"Attention weights: layer {layer+1}, head {head+1}")
plt.colorbar(im, ax=ax, label="attention weight")
plt.tight_layout()
plt.savefig("attention_heatmap.png", dpi=150)
plt.show()
Figure 9What the heatmap shows
[CLS]thetrophydidn'tfitinthesuitcasebecauseitwastoobig.[SEP][CLS]thetrophydidn'tfitinthesuitcasebecauseitwastoobig.[SEP]← the "it" rowtrophy 0.58suitcase 0.23[CLS] 0.0901.0Real weights: all-MiniLM-L6-v2, layer 4, head 2. Rows = the token doing the looking; columns = the token looked at.

← swipe to see the whole figure →

The "it" row: 58% of its attention goes to "trophy" and 23% to "suitcase". Nobody labelled this; it came out of training.
A caution worth learning early

Now change "too big" to "too small". The correct answer flips: the suitcase was too small. We ran it, and this head still points at "trophy" (0.61 vs 0.19 for "suitcase"). So it isn't really resolving the pronoun; it's more like "look back at the earlier noun", which happens to be right for "big" and wrong for "small".

That's a general lesson. Attention weights are clues about what a model is doing, not proof of understanding. A single head can look impressively smart on one example and turn out to follow a much simpler rule. Larger models spread this kind of reasoning across many heads and layers, which is why researchers test many sentences before drawing conclusions. Try both versions yourself in the exercise.

📹 YouTube note Run head_view live and pause on the head linking "it" to "trophy". Then change "big" to "small" and rerun. Say: "Looks like understanding, right? Watch what happens when the right answer changes." It's a memorable way to teach healthy scepticism.

§ 07 · PositionPositional Encoding Revisited

Attention has a built-in blind spot: it's permutation-invariant. Scores come from dot products between pairs of tokens, and nothing in that calculation knows which token came first. Shuffle the sentence and each token ends up with the same attention weights for the same partners. "Dog bites man" and "man bites dog" would look identical. Word order carries meaning, so that would be a disaster.

Article #02, §03 introduced the fix: add position information. Here are the three main ways it's done.

Figure 10Three ways to tell attention about order
Sinusoidaloriginal 2017 paperLearned tableGPT-2RoPE (rotary)Llama, Mistral, most new LLMsdim 0dim 2dim 4fixed waves at differentspeeds; no training neededadded to each token embeddingpos 0pos 1pos 2pos 3pos 4pos 5pos 6pos 7pos 1024? no rowone learned row per position;hard limit at the table sizepos 1pos 3pos 5same gap,same angledifferencerotates Q and K by an angleset by positiondot products depend on distance

← swipe to see the whole figure →

Fixed waves, a learned lookup table, or a rotation. Most recent LLMs use the rotation, RoPE.

Sinusoidal (the original 2017 paper)

Each position gets a fixed pattern built from sine and cosine waves of different speeds, added to the token's embedding. Nothing is learned, and a vector can be computed for any position, however large. In practice, though, models trained this way still get worse on sequences much longer than the ones they were trained on.

Learned position table (GPT-2)

A simple lookup table with one learned vector per position, added to the token's embedding. It's easy and works well, but it has a hard limit: GPT-2's table has 1,024 rows, so there's simply no vector for position 1,025.

RoPE, Rotary Position Embedding (Llama, Mistral, and most new LLMs)

Instead of adding a vector, RoPE rotates the query and key vectors by an angle that depends on each token's position, just before the dot product. Picture each vector as a clock hand, turned further for tokens later in the sentence.

The clever part: when you take the dot product of two rotated vectors, what matters is the difference between their angles, so attention ends up depending on how far apart two tokens are rather than on their absolute positions. It's like a stopwatch: you can tell five minutes passed by comparing the two hand positions, without knowing what time you started.

RoPE has no lookup table to run out of, and it has become the standard for decoder-only LLMs. It doesn't magically work at any length, though. Models still get worse beyond the length they were trained on unless they're extended with techniques such as position interpolation or YaRN. That's how many models are stretched to much longer context windows after their main training.

Reading model cards

"RoPE" means rotary, distance-based position encoding, often with a note about how the context was extended (for example "RoPE scaling"). "ALiBi" (Attention with Linear Biases) is a different approach that skips rotation and simply subtracts a penalty from each attention score in proportion to how far apart the two tokens are. It's simple, and it handles inputs somewhat longer than the training length well.

§ 08 · Engineering RealityAttention Cost and the Long-Context Problem

Standard attention compares every token with every other token. For n tokens, that's roughly n × n comparisons, written O(n²). Double the length and you quadruple the work and memory for attention.

Think of a room where everyone must shake hands with everyone else. Ten people make 45 handshakes. Double it to twenty people and you don't get 90, you get 190, because every newcomer has to greet everyone, including the other newcomers. Article #03 used exactly this reasoning to explain why character-level tokens would be so expensive.

Figure 11Why long inputs get expensive fast
Attention work grows with the square of the length1×1k tokens4×2k tokens16×4k tokens64×8k tokensHandshakes10 people: 4520 people: 190double people ≈ 4× workRelative number of token-to-token comparisons, compared with a 1,000-token input.

← swipe to see the whole figure →

Going from 1,000 to 8,000 tokens means 64 times as many token pairs to compare.

With context windows now reaching hundreds of thousands of tokens, and a million or more in some models, this is a serious engineering problem. Here's what the field has done about it.

FlashAttention (Dao et al., 2022)

FlashAttention computes exactly the same result, still O(n²), but reorganises the work so the GPU spends far less time moving data between its small, fast on-chip memory and its larger, slower main memory. That data movement turned out to be the real bottleneck. Think of a chef who doesn't chop any faster, but stops walking to a distant pantry for every ingredient and keeps what the next few steps need within arm's reach. It typically runs attention several times faster and uses much less memory, and it's now the default in most frameworks. FlashAttention-2 (2023) and FlashAttention-3 (2024, tuned for NVIDIA's H100 GPUs) push the same idea further.

Sliding-window attention (for example Mistral 7B)

Each token attends only to a fixed window of recent tokens, so the cost grows with n × window instead of n². It works because most attention weight is local anyway, and information from further back can still travel forward through the stacked layers.

Sparse attention (Longformer, BigBird)

Only certain pairs are computed: local windows plus a few "global" tokens that everything can see. This was designed for document-length inputs.

What this means for you

You won't implement any of these, but the cost is real. Sending 100,000 tokens of context when 5,000 would do costs more money (Article #03), more time, and more compute. Long contexts can also hurt quality: models are often worse at using information buried in the middle of a very long prompt. Use context deliberately; the retrieval techniques in Phase 5 exist largely to send less, better-chosen text.

§ 09 · SummaryThe Complete Mental Model

Seven things to take with you

  • Attention fixes static embeddings. It updates each token's vector by blending in information from the rest of the sentence, weighted by relevance.
  • Q, K, and V are three learned projections of each token. The query asks, the key advertises, the value delivers. Q and K decide how much each token looks at each other token; V decides what gets passed on.
  • The whole mechanism is softmax(QKᵀ / √d_k) · V. Dot products measure matches, √d_k keeps softmax healthy, softmax makes weights that sum to 1, and V supplies the content.
  • Causal masking sets future scores to −∞, so decoder models can't peek at the answer during training.
  • Multi-head attention runs several heads in parallel. Real heads learn recognisable patterns (neighbours, syntax, "it" → earlier noun), but a head's pattern is a clue, not proof of understanding.
  • Position has to be added, because attention alone ignores order. RoPE, which rotates queries and keys, is the modern standard; long contexts still need extension tricks.
  • Standard attention costs O(n²). FlashAttention computes it far more efficiently; sliding-window and sparse attention change what's computed to save more.

📝 Learner exercise

  1. Break the head. Run the §06 code on "…because it was too big." and "…because it was too small." Which head wins for each, and does any head switch from "trophy" to "suitcase"? Try bert-base-uncased too, and compare.
  2. Mask it yourself. In the NumPy code, print the causal weights (attention(Q, K, V, causal=True)). Check that everything above the diagonal is 0 and that each row still sums to 1. Why does the first row become [1, 0, 0, 0]?
  3. Feel the scaling. Multiply Q by 10 before calling attention, which is similar to what happens without the √d_k division in big models. What happens to the weights? Compare with Figure 4. Why would that make learning harder?