AI Architectures

Understanding Attention

One equation, six verbs, and every modern variant. Score every query against every key, soften the scores into weights, and mix the values. Everything else is a change of source, connectivity, head count, or execution.

Prerequisites: vectors and matrix multiplication + softmax. That's it. No Transformer internals assumed.
13
Chapters
16
Simulations
9
Runnable Blocks

Chapter 0: Why Attention Appeared

You are translating a fifty word German sentence into English. The system you are using in 2014 works like this: a recurrent network reads the whole German sentence, one word at a time, and finishes holding a single vector. Then a second network reads that one vector and writes English out of it.

Look at what that asks. Every noun, every clause, every negation, every number in fifty words has to survive inside one fixed-length vector. The last German word and the first German word share the same slots. There is no place to put more information if the sentence gets longer, because the vector was sized before the sentence arrived.

Bahdanau, Cho and Bengio named this problem. They argued that this fixed-vector interface could become a bottleneck, especially as source sequences grew longer, and they proposed a fix: let the decoder form a different context vector at each output step, by softly weighting the encoder states according to their relevance to the current decoding state.

That is the whole idea, and it arrived before the Transformer. Instead of one summary for the entire source, build a fresh summary for each output step, and let the model decide which source positions go into it. The weights are not hand written. They come out of a learned compatibility calculation between what the decoder wants now and what each source position offers.

See the Bottleneck Break

The widget below is the 2014 argument, made concrete. In the first mode a source sentence is squeezed through one fixed vector. Drag the source length and watch the capacity per source word collapse. Flip to soft alignment and the same output step draws its own weighted mixture instead, so growing the source adds capacity rather than removing it.

The fixed vector versus soft alignment

The slots inside the shared vector are the room each source word gets. Under attention, each output step draws its own weight row.

Source length 6
Output step 2

With one vector, every output step reads the same 64 numbers. At source length 6 each word has about 10.7 numbers of room. At length 12 it has 5.3. Nothing about the model changed. The sentence simply outgrew the interface.

With soft alignment the picture is different in kind, not degree. Output step 2 builds its context from a weight row over the source. Step 3 builds a different one. Adding source words adds columns to the weight row. It does not shrink anybody's share of a fixed budget.

What Attention Actually Is

Here is the definition that survived, stated the way the Transformer paper states it. An attention function maps a query and a set of key-value pairs to an output. Each key is used to decide how compatible its corresponding value is with the query. The output is a weighted combination of the values.

For one query vector q and key-value pairs (kj, vj), scaled dot-product attention is three lines:

sj = qTkj / √dk      aj = esj / ∑r esr      o = ∑j aj vj

Read it once slowly. The score sj is a learned compatibility signal, because q and kj are themselves outputs of learned projections. Softmax turns those scores into nonnegative weights that sum to one across the allowed keys. The output o is a weighted average in value space.

Mental model: the key chooses the weight, the value supplies the content being mixed. That separation is the conceptual reason attention uses both K and V. If keys and values were the same object you could still build something that works, but you would have tied "what makes me relevant" to "what I contribute," and the model would lose a degree of freedom it uses.

This language is more precise than the common personification, "the query asks a question and the key contains the answer." That picture is memorable but it is not the mechanism. A query and a key are vectors in learned representation spaces. Their dot product supplies a scalar compatibility score. Nothing asks anything.

The Transformer Did Something More Radical

In 2017 the Transformer changed the role of attention. Instead of using recurrence or convolution as the main mechanism for moving information through a sequence, it built its encoder and decoder around attention plus position-wise feed-forward layers. The paper describes self-attention as relating different positions of a single sequence in order to compute new representations of that sequence.

So there are two separate historical facts, and mixing them up causes half the confusion in this field. Attention is a general mechanism for content-dependent aggregation. The Transformer is a particular architecture that made one attention formulation the central sequence-mixing operation.

What this lesson covers. Everything after this chapter is scaled dot-product attention, because that is the form used by the Transformer and by modern LLM attention kernels. Earlier additive-attention mechanisms are historically important, but their scoring function differs, and the systems world runs on the dot-product form.
When to reach for it: cross-attention with a learned alignment is still the right tool whenever a decoder must consume a variable-length source it did not generate. Whisper decodes text tokens while attending over encoder states derived from a 30 second audio window. T5 and the original encoder-decoder translation stacks do the same over source tokens. Reach for a single pooled vector instead only when the source really is one object of bounded complexity, such as a class label or a short retrieval key. The failure mode is exactly the one Bahdanau described: quality that degrades as the source gets longer, because the interface, not the model, ran out of room.
Which statement best describes what "attention" contributed, before the Transformer existed?

Chapter 1: One Query, Many Keys

Forget sequences for a moment. Attention, at its smallest, is one query and a handful of key-value pairs. Get this picture right and every later chapter is bookkeeping on top of it.

Each pair (kj, vj) has two jobs, and they are different jobs. The key decides how much this pair is going to count. The value is what actually gets poured into the output. Two separate vectors, two separate learned projections, two separate spaces.

score sj = q · kj / √dk  →  weight aj = softmax(s)j  →  output o = a1v1 + a2v2 + a3v3

Notice that the query never touches a value directly. It only ever meets keys. And the values never influence a weight. The two halves of the mechanism meet exactly once, at the final sum.

Move the Query, Watch the Mixture Move

On the left is key space, where q and the three keys live. The dot product is a projection: how far q reaches along kj, scaled by the length of kj. On the right is value space, a completely different space, where the three values sit at the corners of a triangle. The output is the weighted average, so it can never leave that triangle.

One query, three key-value pairs

Drag the query with the two sliders. Watch the scores, then the weights, then the mixed output. The output dot lives inside the value triangle.

Query x 1.00
Query y 1.00
v3 height 1.0

Do It by Hand

Set the query to q = [1, 1] and use the keys and values we will meet again in Chapter 7:

k1 = [1, 0]   k2 = [0, 1]   k3 = [1, 1]   ·   v1 = [1, 0]   v2 = [0, 2]   v3 = [3, 1]

The raw dot products are q · k1 = 1, q · k2 = 1 and q · k3 = 1 + 1 = 2. With dk = 2 we divide by √2 = 1.4142, giving scores 0.7071, 0.7071 and 1.4142.

Exponentiate: e0.7071 = 2.0281, e0.7071 = 2.0281, e1.4142 = 4.1133. The sum is 8.1695. Divide each by the sum:

a = [0.2483, 0.2483, 0.5035]

Now mix the values. First coordinate: 0.2483·1 + 0.2483·0 + 0.5035·3 = 0.2483 + 1.5105 = 1.7588. Second coordinate: 0.2483·0 + 0.2483·2 + 0.5035·1 = 0.4966 + 0.5035 = 1.0001. So o = [1.7587, 1.0000] once you carry the full precision.

You just computed the third row of the Transformer's attention output. Chapter 7 does all three rows with the mask included. If the arithmetic above made sense, the rest is the same three steps repeated in a matrix.

Two Knobs, Two Different Effects

Slide v3 in the widget. The weights do not move at all. The triangle deforms and the output slides with it, because you changed what is being mixed, not how much of it. Now move the query instead. The triangle holds still and the output walks across it, because you changed the mixture coefficients.

That is the cleanest demonstration of why attention carries both K and V. A system with keys only can decide relevance but has nothing to deliver. A system with values only can deliver but cannot decide. The learned projections WK and WV are trained to fill those two distinct roles from the same token representation.

The output lives in value space. This trips people up constantly. The scores live in a scalar compatibility space, the weights live on a simplex, and the output is a point in the same space as the values, with the same dimension dv. If your value vectors are 128 wide, the attention output is 128 wide, whatever dk happened to be.
When to reach for it: the one-query view is the right mental picture for a retrieval-augmented generation step and for the decode phase of an LLM. In RAG, the query embedding scores document keys and you mix the retrieved passages. In LLM decoding, the single new token produces one query which scores every cached key and mixes every cached value, so each decode step is literally this diagram with a few thousand pairs instead of three. Reach for the matrix form of the next chapter as soon as you have more than one query at a time, which is every training step and every prompt prefill.
You double the length of key k2 and leave everything else alone. What happens?

Chapter 2: Shapes First

Most attention bugs are shape bugs. Not "I forgot the formula" bugs. Shape bugs. So before any more mathematics, we pin down every dimension and give each one a letter, exactly as the handbook and the PyTorch documentation do.

SymbolMeaningTypical value in a 7B decoder
Bbatch size1 while decoding, 8 to 256 while training
Lnumber of query positions1 during incremental decode, 4096 during prefill
Snumber of key and value positionsthe whole prefix so far, so it grows every step
Hqnumber of query heads32
Hkvnumber of key and value heads32 for MHA, 8 for grouped-query, 1 for multi-query
dhper-head query and key dimension128
dvper-head value dimension128, and it does not have to equal dh

For a single head and a single example we can drop the batch and head axes entirely and write:

Q is L × dh   ·   K is S × dh   ·   V is S × dv

Then the three products follow with no freedom left:

QKT is L × S   ·   A is L × S   ·   AV is L × dv

Why the Transpose Is Forced

Matrix multiplication only works when the inner dimensions agree. Q is L × dh. To contract it against K we need something whose first dimension is dh, and K is S × dh, so we transpose it to dh × S. The result is L × S, one number for each query-key pair. That is the entire reason for the T in QKT.

The same argument fixes the second product. A is L × S, V is S × dv, the S contracts away, and the output is L × dv. One row per query, and the row lives in value space.

dh must be shared by Q and K; dv is free. Queries and keys have to meet in the same space or the dot product is not defined. Values never meet a query, so their width is a separate design choice. Most implementations set dv = dh out of convenience, not necessity.

Explore the Shape Algebra

Three presets matter. In self-attention over one full sequence, L = S. In cross-attention a decoder of length L attends to an encoder memory of length S, and the two are unrelated. In cached decode a single new query attends to the whole prefix, so L = 1 while S keeps climbing. Switch between them and watch which boxes change shape.

Shape explorer

Every box is a real tensor. The number under each box is its element count for the current batch and head configuration.

Query pos L 6
Key pos S 6
Query heads 8
KV heads 8
Head width 64

Drag L to 1 and leave S at 12. The score box becomes a single row of 12 numbers. No triangle, no square, no n × n. That is one decode step of a language model, and it is the most common attention call ever executed in production, by a wide margin.

Do not silently use n × n as the universal shape of attention weights. It is correct for same-length self-attention and it is what every diagram on the internet draws, but it obscures cross-attention and it obscures incremental inference. Current PyTorch scaled dot-product attention documentation uses separate query length L and source length S for exactly this reason.

The Full Batched Layout

Real kernels want the batch and the heads out of the way, so the standard memory layout puts them first:

Q: (B, Hq, L, dh)  ·  K: (B, Hkv, S, dh)  ·  V: (B, Hkv, S, dv)

Attention then runs as a batched matrix multiply over the leading two axes. This is the layout torch.nn.functional.scaled_dot_product_attention expects, and getting it wrong by leaving the sequence axis in position 1 instead of position 2 is mistake number one in every attention codebase.

Second, modern grouped attention allows Hq ≠ Hkv. Standard multi-head attention usually has the same number of query and key and value heads. Multi-query attention uses one KV head. Grouped-query attention uses an intermediate number. Move the two head sliders apart in the widget and watch the KV boxes shrink while the query box does not.

When to reach for it: write the shapes down before you write the kernel, every time. In a serving stack such as vLLM or SGLang the same attention layer is called with L in the thousands during prefill and L = 1 during decode, against a cache whose S grows with every token, and those two calls take completely different code paths inside the library. If you are debugging an attention layer and the loss is wrong but nothing crashes, print the shape of the score tensor first. A square score matrix appearing during cached decode means someone rebuilt the whole prefix instead of using the cache.
During incremental decoding of an LLM, one new token attends over a 4000 token prefix in a model with 32 query heads and head width 128. What is the shape of the score tensor for one batch element?

Chapter 3: Where Q, K, V Come From

So far Q, K and V have arrived by magic. They do not. They are three linear projections of the same input, and understanding that is what stops you believing the folklore about queries asking questions.

The input to an attention layer is a matrix of token representations X, of shape n × dmodel. One row per token, one column per feature. A standard self-attention layer applies three learned linear maps:

Q = XWQ     K = XWK     V = XWV

For one head, WQ and WK are dmodel × dh, and WV is dmodel × dv. In multi-head attention each head conceptually has its own three matrices, or, in practice, one larger projection is computed and reshaped into heads. The Transformer paper defines multi-head attention in terms of separate learned projections per head, and the reshape trick is an implementation detail that computes the same thing faster.

X
n × dmodel token representations, straight out of the previous layer
↓ three separate learned matrices, applied to the same rows
Q = XWQ
n × dh. What this position is looking for.
K = XWK
n × dh. What this position offers as a match target.
V = XWV
n × dv. What this position contributes if chosen.
Q, K and V are not fixed semantic roles attached to tokens. They are learned views of the current hidden representations. The same token has different query, key and value vectors in different layers and in different heads of the same layer. The word "bank" does not carry a query around with it. Layer 7 head 3 produces one from whatever the residual stream happens to hold at that point.
Mental model. The model learns two spaces for deciding compatibility, Q and K, and one space for the information that will be aggregated, V. Three matrices, two jobs.

The Score Matrix, Cell by Cell

Take query row qi and key row kj. Their dot product is one number:

Sij = qiTkj = qi1kj1 + qi2kj2 + … + qidhkjdh

The complete matrix product QKT computes every one of those query-key dot products in parallel. Every row corresponds to one query position. Every column corresponds to one key position. That is the whole content of the score matrix, and a single matmul on a GPU does all L × S of them at once.

Click any cell in the grid below, or use the two sliders, and the widget expands that one cell into its term-by-term arithmetic. Four tokens, head width 3, so every score is a sum of three products you can check on paper.

Open one cell of QKT

Tap a cell in the grid. The panel underneath multiplies the query row by the key row, one component at a time.

Query row i 2
Key col j 1

Two things to notice while you click around. First, the grid is not symmetric. Sij uses query i against key j, and Sji uses query j against key i, which are different vectors from different projections. Second, plenty of cells are negative. A dot product is free to be negative and often is.

The dot product is not a probability. It is an unnormalized compatibility score. It can be positive or negative, and its magnitude depends on the scale and the dimension of the vectors. Nothing has been normalized yet. Chapter 4 handles the magnitude and Chapter 6 handles the normalization.

The Word "Similarity" Is a Half Truth

Calling Sij a similarity is convenient but incomplete. Dot-product attention learns WQ and WK specifically so that the resulting compatibility is useful for the task. It need not behave like a generic semantic similarity metric at all.

A concrete illustration. A head that implements "copy the token that follows the previous occurrence of the current token" will give a high score to a key whose surface meaning has nothing in common with the query's token. It scores high because the two projections were trained to align on a positional and structural relationship, not on meaning. If you insist on reading the score as semantic similarity you will misread that head completely.

When to reach for it: the projection view is what lets you swap the source of each of the three streams independently, which is the whole design space of attention variants. Reach for it when you build a perceiver-style bottleneck, where Q comes from a small learned latent array and K and V come from a huge input; when you build a retrieval head, where K and V come from an external memory; or when you tie WK and WV to save parameters. The one rule that never bends: Q and K must land in the same dh-dimensional space, because they meet in a dot product, and V must have one row per key, because they are consumed as pairs.
An attention head gives score 4.1 to one query-key pair and score 0.02 to another. What do those two numbers tell you on their own?

Chapter 4: Why Divide by √dk

The Transformer does not feed QKT to softmax. It feeds QKT divided by √dk. That divisor is the single most asked about symbol in the equation, and it has a short, honest answer.

S̃ = QKT / √dk

The Variance Derivation

Here is the paper's own motivation, in full. Suppose the components of q and k are independent random variables with mean zero and variance one. Then the dot product is a sum of dk products:

qTk = ∑r=1dk qrkr

Each term qrkr has mean zero, because qr and kr are independent and both have mean zero. Its variance is E[qr2kr2] = E[qr2]E[kr2] = 1 · 1 = 1. Independent terms add their variances, so:

Var(qTk) = ∑r=1dk Var(qrkr) = dk    →    Var(qTk / √dk) ≈ 1

So the typical size of a raw score grows like √dk. At dk = 64 the standard deviation is 8. At dk = 1024 it is 32. Dividing by √dk pulls that illustrative variance back to approximately one, whatever the head width.

What Big Logits Do to Softmax

Take four raw scores from a dk = 64 head: 8.0, 0.1, −0.3 and 0.5. Exponentiate: e8.0 = 2980.958, e0.1 = 1.1052, e−0.3 = 0.7408, e0.5 = 1.6487. The sum is 2984.4527, so the weights are:

[0.9988,   0.0004,   0.0002,   0.0006]

That is a one-hot vector in all but name. Now divide the same scores by √64 = 8, giving 1.0, 0.0125, −0.0375 and 0.0625. Exponentiate those: 2.7183, 1.0126, 0.9632, 1.0645, sum 5.7585, weights:

[0.4720,   0.1758,   0.1673,   0.1849]

The first key still wins, and wins clearly. But the other three still exist. The gradient story is the sharp version of this: the derivative of a softmax weight with respect to its own logit is a(1 − a). At a = 0.9988 that is 0.00117. At a = 0.4720 it is 0.2492, over two hundred times larger. A saturated softmax barely learns.

This derivation is a motivation, not a claim about trained models. It does not assert that learned query and key components in a trained Transformer are always independent, zero-mean, unit-variance random variables. The paper explicitly presents those assumptions as an illustration of why dot products can grow with dimension. The divisor is kept because it works, not because the assumption is audited at each step.

Sample It Yourself

The widget runs a real Monte Carlo. It draws thousands of q and k vectors with unit-variance components, computes the dot products, and histograms them. The warm histogram is unscaled, the teal one is divided by √dk. Drag dk and watch the warm distribution spread out while the teal one stands still.

Score magnitude versus head width

Both histograms are sampled from the same random draws. Only the divisor differs. The readout underneath is the mean largest softmax weight over rows of four keys, which is the saturation you actually care about.

Head width 64

The Same Experiment in PyTorch

This block runs as written. It draws twenty thousand query rows against four keys each, at four head widths, and reports the variance of the raw dot product, the variance after scaling, and the average largest softmax weight in both cases.

python
import torch

torch.manual_seed(0)
rows, keys = 20000, 4          # 20000 query rows, 4 keys each

print("  d_k   var(q.k)   var(scaled)   mean top weight   scaled")
for d_k in (4, 64, 256, 1024):
    q = torch.randn(rows, 1, d_k)      # unit variance, mean zero
    k = torch.randn(rows, keys, d_k)
    s = (q * k).sum(-1)                # raw dot products, one row per query
    scaled = s / d_k ** 0.5
    top = torch.softmax(s, dim=-1).max(-1).values.mean()
    top_scaled = torch.softmax(scaled, dim=-1).max(-1).values.mean()
    print("%5d  %9.2f  %12.2f  %16.4f  %7.4f"
          % (d_k, s.var().item(), scaled.var().item(), top.item(), top_scaled.item()))
printed output
  d_k   var(q.k)   var(scaled)   mean top weight   scaled
    4       4.03          1.01            0.6525   0.4972
   64      63.72          1.00            0.9105   0.5165
  256     255.52          1.00            0.9557   0.5172
 1024    1023.10          1.00            0.9782   0.5169

Read the last two columns. Unscaled, the average winning weight climbs from 0.65 to 0.98 as the head gets wider, purely because the head got wider. The model has not learned anything; the geometry did it. Scaled, that number sits at about 0.51 at every width, so the head's sharpness is something the model chooses through its weights rather than something the dimension imposes.

Mental model. Scaling keeps score magnitudes in a range where softmax behaves usefully as dimensionality grows. It is not a normalization of Q or K into unit vectors. Nothing in the mechanism forces a query or a key to have length one, and cosine similarity is a different operation.
When to reach for it: the divisor is already inside every production kernel, so the practical version of this chapter is knowing when to override it. PyTorch scaled dot-product attention takes a scale argument that defaults to 1/√dh, and people change it when they use QK-normalization, when a head has an unusual width, or when they port weights from a model that trained with a different convention. Reach for a hand-set scale in exactly those cases, and never as a fix for training instability you have not diagnosed. If your logits are exploding after a few thousand steps, the divisor is rarely the cause; unbounded query or key norms usually are.
Why does the Transformer divide the scores by √dk?

Chapter 5: Masks, or Which Connections Are Allowed

Attention as defined so far lets every query see every key. Often that is wrong. A language model must not let a token peek at its own future. A batch with padded short sequences must not attend to padding. A long document model may deliberately restrict each query to a window.

All of these are the same intervention: before softmax, add a mask or bias to the score matrix. For causal language modelling the standard additive mask is:

Mij = 0  if j ≤ i,     Mij = −∞  if j > i
A = softmax( QKT / √dk + M )

Because e−∞ = 0, masked entries receive exactly zero probability after softmax, and the remaining entries renormalize among themselves. The original Transformer decoder implements autoregressive masking exactly this way: illegal pre-softmax connections are set to −∞.

Add the Mask, Then Normalize. Never the Other Way

Take the second query row of the example we keep returning to. Its three raw scaled scores are 0, 0.7071 and 0.7071. Exponentiate: 1.0000, 2.0281, 2.0281, sum 5.0562. The unmasked softmax is:

[0.1978,   0.4011,   0.4011]

Query 2 is not allowed to see key 3. Do it correctly, by setting the third logit to −∞ first: the third exponential becomes 0, the sum becomes 3.0281, and the row is:

[0.3302,   0.6698,   0]

Now do it the tempting way, by running softmax first and then zeroing the illegal entry. You get [0.1978, 0.4011, 0], which sums to 0.5989. The row no longer sums to one, so the output vector is silently scaled down by about forty percent, and every residual connection downstream inherits the shrunken vector. The fix is to renormalize, which recovers 0.3302 and 0.6698, but a lot of code forgets that step and the bug is quiet.

Adding −∞ before softmax is exclusion; zeroing after softmax is deletion. They agree only if you renormalize afterwards. Exclusion before normalization is the definition, it is what every kernel does, and it never needs a second thought.

Paint a Mask

The grid below is the allowed-connection pattern for the current mask. Switch between the four families and watch which cells survive. Toggle the view to see the same mask written as an additive floating-point bias rather than as Booleans.

Mask painter

Rows are query positions, columns are key positions. A filled cell participates in the softmax. An empty cell is excluded before it.

Query pos L 8
Key pos S 8
Window w 1

The Non-Square Trap

Set L to 3 and S to 8 in the widget, then press Flip alignment. Both grids are "lower triangular" in some sense, and they mean completely different things. Aligned to the top left, query 0 sees only key 0. Aligned to the bottom right, query 0 sees keys 0 through 5, because it is really the sixth token of a sequence whose first five keys are already in the cache.

For cached decoding the bottom-right reading is the one that matches reality: a query block of length L sitting at the end of a prefix of length S should see everything up to and including its own position, which means an offset of S − L on every row.

In non-square attention, "lower triangular" needs an alignment convention. Current PyTorch documentation explicitly distinguishes square from non-square causal bias behavior. Do not blindly construct an L × S triangle and hand it to an API. Read what that API expects for your installed version, or use its built-in causal mode and let the library own the convention.

Four Families, One Mechanism

MaskRuleWhy it existsWhere you meet it
Causalkey index at or before the query indexan autoregressive factorization is only valid if position t never sees t+1every decoder-only LLM, every training step
Paddingkey index inside the real length of its sequencebatching packs sequences of different lengths into one rectangleany batched encoder, retrieval rerankers, classifiers
Local windowkey index within w of the query indexbounds the work per query so cost grows linearly with lengthLongformer style long-document models, sliding-window layers
Blockquery and key in the same block, plus chosen global rowskeeps a coarse global path while most pairs are droppedBigBird style sparse attention

Notice that only the first two are about correctness. The last two are about cost, and they change the model, because a connection that is masked out cannot carry information in that layer at all. Chapter 11 comes back to that distinction, because it is the line between FlashAttention and sparse attention.

When to reach for it: use the library's causal flag rather than a hand-built triangle whenever you can, because the flag is the only thing that stays correct when L is not S. Reach for an explicit Boolean mask when you have padding, and be careful about polarity: in current PyTorch scaled dot-product attention a Boolean attn_mask value of True means the position participates, which is the inverse of MultiheadAttention's key_padding_mask convention. Reach for an additive float mask when the bias is not binary, for instance an ALiBi style distance penalty or a learned relative bias. And check that no row is fully masked, because a row of all −∞ makes softmax produce not-a-number and the whole batch goes to garbage.
A colleague applies softmax to the full score matrix and then multiplies the illegal entries by zero, without renormalizing. What is the consequence?

Chapter 6: Row-wise Softmax and Aggregation

Two operations remain, and the second one is the one people skip. Softmax turns scores into weights. Then AV turns weights into an output. The first step gets all the attention. The second step is where information actually moves.

Aij = exp(S̃ij) / ∑r=1S exp(S̃ir)

Look carefully at the index that is being summed over. It is r, the key index, and the sum runs across one row. So for each query i the weights are nonnegative and sum to one across the key positions:

Aij ≥ 0      ∑j=1S Aij = 1

Rows, Not Columns

Why row-wise? Because each query independently produces a distribution over its permitted key positions. That is the object we want: a set of mixing coefficients for one output. A column-wise softmax would normalize across queries instead, answering "which query cares most about this key," which is a different question and a different operation.

The consequence is concrete. Under column normalization a single query's coefficients no longer sum to one, so its output is not a weighted average of values at all. It is an unnormalized combination whose scale depends on how popular its keys happen to be with other queries. Flip the toggle in the widget and watch the row sums stop being one.

Which axis gets normalized

The same score grid, normalized two ways. The numbers in the margins are the sums along each axis. Only one of these is attention.

The Multiplication That Moves Information

The output at query position i is:

oi = ∑j=1S Aij vj        in matrix form,   O = AV

This final multiplication is easy to overlook, but it is where attention actually moves information. The score matrix only decides how much each value contributes. The values are the vectors that get mixed into the new representation.

If a particular query gives weights (0.7, 0.2, 0.1) to three values, its output is 0.7v1 + 0.2v2 + 0.1v3. With our running values that is:

0.7·[1, 0] + 0.2·[0, 2] + 0.1·[3, 1] = [0.7 + 0.3,   0.4 + 0.1] = [1.0, 0.5]

The output remains in the value-vector space, not in score space. Its dimension is dv. Its units, if the values had units, would be the units of the values. Nothing about the score survives into the output except through the coefficients.

Mixing in value space

Three values at the corners, weights on the simplex. The output can reach any point inside the triangle and no point outside it.

Weight a1 0.70
Weight a2 0.20

Try to push the output outside the triangle. You cannot. Softmax weights are nonnegative and sum to one, so the output is a convex combination, and convex combinations live inside the convex hull of their inputs. A single attention head can only ever produce an interpolation of the values available to it. Everything else a Transformer does, including the feed-forward block and the residual stream, exists partly to escape that limitation.

Mental model. Scores answer "how much?" Values answer "what gets aggregated?" The complete attention operation is a content-dependent mixing operator: a set of coefficients chosen by content, applied to a set of vectors chosen by content.
When to reach for it: the row-wise convention is what makes the attention matrix interpretable as a per-query distribution, and it is what you rely on when you inspect a head. If you are debugging a head and its weight rows do not sum to one, you have found the bug before you have found the head. Reach for the convex-combination fact when you reason about saturation: a head whose row is nearly one-hot is copying a single value, which is exactly what induction heads and retrieval heads look like, while a head with a flat row is averaging its whole context, which is what many early-layer heads do. Both are legitimate. Neither is a bug on its own.
A head produces weights (0.5, 0.5) over two values v1 = [4, 0] and v2 = [0, 4]. Which output is impossible for this head?

Chapter 7: The Worked Example, Every Number

This is the chapter that turns the equation into something you own. We compute one causal attention head completely by hand, three tokens, head width two, and every intermediate number stays on screen. If you can explain where each number comes from, the core attention equation is no longer a black box.

Q = K = [[1, 0], [0, 1], [1, 1]]   ·   V = [[1, 0], [0, 2], [3, 1]]   ·   dk = 2

Three query positions and three key and value positions, so this is same-length self-attention and the score matrix is 3 × 3. Setting Q = K is a deliberate simplification: it means position 3's key is [1, 1], which is compatible with both earlier keys, and that shows up in the final weights.

Step 1: The Raw Dot Products

Every entry is a query row dotted with a key row. Row 1 is q1 = [1, 0]. Against k1 = [1, 0] that is 1·1 + 0·0 = 1. Against k2 = [0, 1] it is 1·0 + 0·1 = 0. Against k3 = [1, 1] it is 1·1 + 0·1 = 1. Carry on for the other two rows:

QKT = [[1, 0, 1], [0, 1, 1], [1, 1, 2]]

Step 2: Divide by √2

dk = 2, so the divisor is √2 = 1.41421. Every entry gets smaller by that factor: 1 becomes 0.7071, 2 becomes 1.4142, and 0 stays 0.

QKT/√2 = [[0.7071, 0, 0.7071], [0, 0.7071, 0.7071], [0.7071, 0.7071, 1.4142]]

Step 3: Impose the Causal Mask

Query 1 may use only key 1. Query 2 may use keys 1 and 2. Query 3 may use all three. Everything above the diagonal becomes −∞:

causal = [[0.7071, −∞, −∞], [0, 0.7071, −∞], [0.7071, 0.7071, 1.4142]]

Step 4: Softmax, Row by Row

Row 1. Only one position is legal, so whatever its logit is, it takes all the mass. A1 = [1, 0, 0]. This is worth sitting with: the first token of every causal sequence always attends entirely to itself, in every head of every layer, forever.

Row 2. The legal logits are [0, 0.7071]. Exponentials are e0 = 1.0000 and e0.7071 = 2.0281, sum 3.0281. Divide: 1.0000/3.0281 = 0.3302 and 2.0281/3.0281 = 0.6698. So A2 = [0.3302, 0.6698, 0].

Row 3. The logits are [0.7071, 0.7071, 1.4142]. Exponentials are 2.0281, 2.0281 and 4.1133, sum 8.1695. Divide: 2.0281/8.1695 = 0.2483 twice, and 4.1133/8.1695 = 0.5035. So A3 = [0.2483, 0.2483, 0.5035].

A = [[1, 0, 0], [0.3302, 0.6698, 0], [0.2483, 0.2483, 0.5035]]

Every row sums to one up to rounding, and every causally forbidden position has weight exactly zero. Read what the third row says: the third query assigns about half its attention weight to the third value and about a quarter to each earlier value. These numbers are not annotations we added. They are the normalized result of the learned-compatibility calculation in this toy setup.

Step 5: Multiply by V

Finally O = AV. Each output row is a weighted sum of value rows.

Row 1: 1·[1, 0] = [1, 0]. The first token copies its own value exactly, which follows from its one-hot weight row.

Row 2: 0.3302·[1, 0] + 0.6698·[0, 2] = [0.3302, 1.3396] when you round at four places, and [0.3302, 1.3395] when you carry full precision through.

Row 3: 0.2483·[1, 0] + 0.2483·[0, 2] + 0.5035·[3, 1]. First coordinate: 0.2483 + 0 + 1.5105 = 1.7588. Second coordinate: 0 + 0.4966 + 0.5035 = 1.0001. Carrying full precision gives [1.7587, 1.0000].

O = [[1, 0], [0.3302, 1.3395], [1.7587, 1.0000]]
The entire attention mechanism is now visible. Q, K → QKT → divide by √dk → mask → softmax → A → AV. Everything that follows in this lesson modifies the source of Q, K and V, the permitted connectivity, the head structure, the positional treatment, or the way this computation is executed efficiently. The five steps never change.

Step Through It Yourself

The showcase widget walks the five steps with every matrix on screen. Use Next and Back, or drag the step slider. Pick a row to see that row's arithmetic spelled out underneath, term by term.

The three token head, every intermediate

The highlighted matrix is the one the current step produces. The panel underneath expands the selected row of that step.

Step 0
Row 3

The Same Numbers, Three Ways

Here is the implementation ladder. The same computation in plain Python with no libraries, in NumPy, and in PyTorch. All three print the same A and the same O, and all three agree with the hand arithmetic above. Run them in that order and the abstraction stops feeling like magic, because you wrote the bottom rung yourself.

Rung 1: plain Python, no imports beyond the standard library.

python
import math

Q = [[1, 0], [0, 1], [1, 1]]
K = [[1, 0], [0, 1], [1, 1]]
V = [[1, 0], [0, 2], [3, 1]]
d_k = 2

def dot(a, b):
    return sum(x * y for x, y in zip(a, b))

def softmax(row):
    m = max(row)
    e = [math.exp(v - m) for v in row]
    z = sum(e)
    return [x / z for x in e]

S  = [[dot(q, k) for k in K] for q in Q]
Sc = [[s / math.sqrt(d_k) for s in r] for r in S]
Sm = [[Sc[i][j] if j <= i else -math.inf for j in range(3)] for i in range(3)]
A  = [softmax(r) for r in Sm]
O  = [[sum(A[i][j] * V[j][c] for j in range(3)) for c in range(2)] for i in range(3)]

def show(name, M):
    print(name)
    for r in M:
        print("   " + "  ".join("%8.4f" % v for v in r))

show("QK^T", S)
show("QK^T / sqrt(2)", Sc)
show("A = softmax(masked)", A)
show("O = A V", O)
printed output
QK^T
     1.0000    0.0000    1.0000
     0.0000    1.0000    1.0000
     1.0000    1.0000    2.0000
QK^T / sqrt(2)
     0.7071    0.0000    0.7071
     0.0000    0.7071    0.7071
     0.7071    0.7071    1.4142
A = softmax(masked)
     1.0000    0.0000    0.0000
     0.3302    0.6698    0.0000
     0.2483    0.2483    0.5035
O = A V
     1.0000    0.0000
     0.3302    1.3395
     1.7587    1.0000

Two details in that code earn their place. Subtracting the row maximum before exponentiating is the standard numerically stable softmax: it cannot change the result, because a common factor cancels between numerator and denominator, and it prevents elarge from overflowing. And -math.inf works directly, because math.exp(-inf - m) is exactly 0.0.

Rung 2: NumPy, where the loops become array operations.

python
import numpy as np

Q = K = np.array([[1., 0.], [0., 1.], [1., 1.]])
V = np.array([[1., 0.], [0., 2.], [3., 1.]])

S = Q @ K.T / np.sqrt(Q.shape[-1])
S = np.where(np.tril(np.ones_like(S)) == 1, S, -np.inf)   # causal: keep j <= i
S = S - S.max(axis=-1, keepdims=True)                     # stable softmax
E = np.exp(S)
A = E / E.sum(axis=-1, keepdims=True)
O = A @ V

np.set_printoptions(precision=4, suppress=True)
print("A =\n", A)
print("O =\n", O)
print("row sums:", A.sum(axis=-1))
printed output
A =
 [[1.     0.     0.    ]
 [0.3302 0.6698 0.    ]
 [0.2483 0.2483 0.5035]]
O =
 [[1.     0.    ]
 [0.3302 1.3395]
 [1.7587 1.    ]]
row sums: [1. 1. 1.]

Rung 3: PyTorch, the transparent implementation. This is the handbook's own code, wrapped in a function and applied to our three tokens. It mirrors the mathematics rather than optimizing it.

python
import math
import torch

def attention(q, k, v, causal=False):
    # q, k, v: (..., L/S, d_head)
    scores = q @ k.transpose(-2, -1)
    scores = scores / math.sqrt(q.size(-1))
    if causal:
        L, S = q.size(-2), k.size(-2)
        # This simple mask assumes a square aligned case.
        mask = torch.triu(torch.ones(L, S, dtype=torch.bool, device=q.device), diagonal=1)
        scores = scores.masked_fill(mask, float("-inf"))
    weights = torch.softmax(scores, dim=-1)
    return weights @ v, weights

q = k = torch.tensor([[1., 0.], [0., 1.], [1., 1.]])
v = torch.tensor([[1., 0.], [0., 2.], [3., 1.]])

out, A = attention(q, k, v, causal=True)
torch.set_printoptions(precision=4, sci_mode=False)
print("A =")
print(A)
print("O =")
print(out)
printed output
A =
tensor([[1.0000, 0.0000, 0.0000],
        [0.3302, 0.6698, 0.0000],
        [0.2483, 0.2483, 0.5035]])
O =
tensor([[1.0000, 0.0000],
        [0.3302, 1.3395],
        [1.7587, 1.0000]])

The code exposes the exact conceptual stages: QKT, then scale, then mask, then softmax, then AV. What it does not expose is how a production kernel fuses those operations, handles numerically stable blockwise softmax, chooses hardware backends, represents causal bias in non-square cases, or supports grouped query heads. Chapters 10 through 12 are about exactly those omissions.

The simple triangular mask above is for an aligned square causal example. For real non-square cached decoding, use a library API whose causal alignment semantics are documented rather than assuming this mask is universally correct.
When to reach for it: keep a three token attention head in a scratch file and run it whenever you are unsure about an axis. It is the fastest debugging tool in this whole area: build a tiny tensor, run your layer, and compare against the numbers on this page. When a new kernel, a fused implementation or a quantized path gives you an answer that differs in the fourth decimal place, that is probably precision. When it differs in the first, you have a mask or an axis bug, and the three token case will show you which within a minute.
Using A3 = [0.2483, 0.2483, 0.5035] and V = [[1, 0], [0, 2], [3, 1]], what is the first coordinate of the third output row?

Chapter 8: Self, Cross, Causal, and Many Heads

The attention equation never said Q, K and V had to come from the same sequence. That is a choice made by the layer around it, and swapping that choice gives you a different named mechanism with the same arithmetic.

Two Streams, Three Configurations

In self-attention, all three projections read the same sequence, or more precisely the same layer input:

Q = XWQ    K = XWK    V = XWV

The Transformer encoder uses self-attention in this form. A causal decoder also uses self-attention, but masks future positions.

In cross-attention, the queries come from the decoder side while keys and values come from the encoder outputs:

Q = YWQ    K = XWK    V = XWV

If the target stream has length L and the source has length S, the score matrix is L × S, exactly as Chapter 2 promised.

Where each stream comes from

Two token streams and three projections. Switch the mode and watch which stream each arrow starts from, and whether the mask appears.

ConfigurationQ sourceK and V sourceScore shape
Self-attentionsame sequencesame sequenceL × L
Cross-attentionquery or target streamsource or memory streamL × S
Causal self-attentionsame sequencesame sequence, maskedL × L, lower triangular
"Self-attention" describes where Q, K and V come from. "Causal attention" describes a connectivity constraint. They are not synonyms. Self-attention can be bidirectional, as in a BERT style encoder, or causal, as in a GPT style decoder. Conflating the two is the single most common vocabulary error in this field, and it makes the next three chapters impossible to follow.

Many Heads, and What They Actually Cost

A single attention head produces one learned compatibility-and-aggregation pattern. Multi-head attention performs several attention operations in parallel using different learned projections, concatenates the resulting head outputs, and applies an output projection:

MHA(Q, K, V) = Concat(head1, …, headH) WO
headh = Attention(QWhQ, KWhK, VWhV)

Here is the arithmetic that surprises people. If dmodel is held fixed and each head has dimension about dmodel/H, then increasing the number of heads does not simply multiply the full-dimensional attention cost by H, because each head is narrower. The original Transformer used this design specifically to keep total cost comparable to a single full-dimensional head.

Count it. With dmodel = 512 and H = 8, each head is 64 wide. The score computation for one head is L · S · 64 multiply-adds, and there are 8 heads, giving L · S · 512. With H = 1 and a head of width 512 you get L · S · 512 as well. Same number. The head count moved the work around; it did not add work.

But the attention matrices themselves do grow. There are H of them, each L × S, so the memory to hold all the score matrices is H · L · S and scales linearly with head count even though the multiply-add count does not. That distinction is the seed of Chapter 11.
Head splitter

Hold dmodel fixed and change H. Watch the per-head width shrink, the multiply-add count stay flat, and the score-matrix memory climb.

Heads H 8
Seq length 512

Concat, Then Project

Each head produces an L × dv output. Concatenating H of them along the feature axis gives L × H·dv, which is L × dmodel when dv = dmodel/H. Then WO, of shape dmodel × dmodel, mixes across heads.

WO is not decoration. Without it the head outputs would sit in disjoint slices of the residual stream and could never combine. It is the one place where information from different heads meets inside the layer.

split
one projection of width dmodel, reshaped into H heads of width dmodel/H
↓ H independent attention operations, one batched matmul
attend
H score matrices of shape L × S, H outputs of shape L × dv
↓ concatenate along the feature axis
merge
L × dmodel, then WO mixes across the heads
Do not assume each head corresponds to one clean human-interpretable concept. Different heads can learn different patterns, but head semantics are emergent and not guaranteed to map to simple labels. A head you can name is a lucky find, not the design.
When to reach for it: reach for cross-attention whenever the two streams have genuinely different natures or lengths: a text decoder over image patches in a vision language model, a diffusion U-Net over a text prompt, a speech decoder over audio frames. Reach for causal self-attention when the task is next-token prediction on one stream. And when you tune head count, remember what actually changes: with dmodel fixed, more heads means more, narrower subspaces and more score memory, not more multiply-adds. Very narrow heads, below about 32 dimensions, start to limit what a single head can represent, which is one reason 64 and 128 are the widths that survived.
A model has dmodel = 512 and you change H from 8 to 16, holding dmodel fixed. What happens?

Chapter 9: Position, Then Training versus Decoding

Plain noncausal self-attention, with no position-dependent signal anywhere, is permutation-equivariant. Permute the input rows and the outputs are permuted the same way, unchanged in value. It has no inherent notion that one token is "third" or that another is "five positions away."

The original Transformer therefore added positional encodings to the token embeddings. Modern decoder-only LLMs often use Rotary Position Embedding, or RoPE, which rotates query and key components by position-dependent angles. In a simplified two dimensional block:

Rm(θ) = [[cos(mθ), −sin(mθ)], [sin(mθ), cos(mθ)]]

Now set qm = Rmq and kn = Rnk and compute their dot product. Rotations are orthogonal, so RmTRn = Rn−m, and:

qmTkn = qTRmTRnk = qTRn−mk

The absolute positions cancel and the attention interaction contains relative displacement information. RoFormer presents RoPE as encoding absolute position through rotations while introducing explicit relative-position dependence in self-attention.

Check It by Hand

Take q = [1, 0], k = [0, 1] and θ = 30 degrees. Rotating q by m steps gives [cos(30m), sin(30m)]. Rotating k by n steps gives [−sin(30n), cos(30n)]. Their dot product is

−cos(30m)sin(30n) + sin(30m)cos(30n) = sin(30(m − n))

At m = 2 and n = 1 that is sin(30°) = 0.5. At m = 7 and n = 6, wildly different absolute positions but the same displacement, it is sin(30°) = 0.5 again. Drag both sliders together in the widget and the score readout does not move.

Rotation makes the score depend on the gap

The warm arrow is the rotated query, the teal arrow the rotated key. The second panel plots the score against displacement, with a marker at the current gap.

Query pos m 2
Key pos n 1
Step angle 30
Two nuances that people skip. First, positional encoding is not the same thing as attention. It modifies the representations, or the query-key interaction, so that attention can use position. Second, a causal mask by itself introduces directional order constraints, but it does not provide a rich representation of distance or absolute position. Ordering and distance are different pieces of information.
Being able to evaluate RoPE past the training length is not the same as working past it. Mathematically the rotation is defined at any position. Whether the model behaves reliably there depends on training and positional configuration, not on RoPE alone. For the full treatment of that question, including the base-change fix, see the positional encoding lesson.

Causal Masking Parallelises Training

An autoregressive language model factorizes a token sequence as a product of conditionals:

p(x1:T) = ∏t=1T p(xt | x<t)

When training a decoder-only Transformer on a full sequence, the model can process many positions in parallel, because a causal mask prevents the representation at position t from using future tokens. The masked self-attention at each layer enforces the allowed dependency pattern. For query position t, attention can use keys and values from positions 1 through t, but not t+1 through T.

This is one reason training and generation feel so different computationally. During training, the full known sequence is available and large matrix multiplications process all query positions together. During generation, token t+1 does not exist until token t has been generated, so decoding is sequential across generated tokens.

Mental model. Causal masking makes parallel training over positions compatible with an autoregressive objective. It does not make autoregressive generation parallel, because future generated tokens are not yet known.

Prefill and Decode Are Serving Phases

Consider a causal decoder layer processing a prefix of length T. There are two regimes, and every serving system in production distinguishes them.

In the prefill or training-like pass, many query positions are processed together. Dense attention forms interactions across the relevant query-key pairs, typically through large batched matrix operations.

In incremental decoding, after the prefix has been processed, the model generates a new token. Its new hidden state produces a new query, key and value at each layer. Crucially, the keys and values for earlier positions do not need to be recomputed from scratch: because of causal attention, earlier token representations were already determined without access to future tokens. That single observation leads to the KV cache, which is Chapter 10.

Prefill then decode, on one timeline

Press play. The prefill block computes many query rows at once. Each decode step then adds exactly one row and one cached key and value.

Prompt len 5
Prefill or training-like passIncremental decode
Queries processedmany positionsusually one or a few new positions
Past K and Vcomputed in the passreused from cache
Parallelism across sequencehighgeneration remains sequential
Typical bottleneck emphasiscompute plus memorycache bandwidth and capacity, plus per-token latency
"Prefill" and "decode" describe serving phases, not different mathematical definitions of attention. They expose different shapes and different hardware bottlenecks for the same model. The equation in Chapter 0 is running unchanged in both.
When to reach for it: this distinction is the first thing to reach for when you are asked why an LLM endpoint is slow. Prefill is dominated by matrix multiply throughput and scales with prompt length, so a long prompt costs time before the first token appears, which is your time-to-first-token. Decode is dominated by reading weights and the cache out of memory and scales with the number of generated tokens, which is your inter-token latency. Serving stacks such as vLLM and SGLang schedule the two phases separately for exactly this reason, and a fix that helps one can do nothing at all for the other.
Which statement about causal masking is correct?

Chapter 10: The KV Cache and the Head-Count Decision

Chapter 9 ended on an observation: during causal decoding, the keys and values of earlier positions never change. They were computed without access to future tokens, and no future token can alter them. So do not recompute them. Store them.

That is the KV cache. Each layer keeps the key and value vectors produced for prior tokens. When a new token arrives, the layer computes the new token's query, key and value, appends its key and value to the cache, and uses the new query to attend over all cached keys and values.

Watch the Cache Reproduce the Worked Example

The clearest proof that caching is exact rather than approximate is to run it on the three token example from Chapter 7. Feed one token at a time, keep the cache, and compare the rows.

python
import math
import torch

def step(q_new, k_cache, v_cache):
    # q_new: (1, d_head). No mask: everything in the cache is already past.
    scores = (q_new @ k_cache.transpose(-2, -1)) / math.sqrt(q_new.size(-1))
    w = torch.softmax(scores, dim=-1)
    return w @ v_cache, w

Q = K = torch.tensor([[1., 0.], [0., 1.], [1., 1.]])
V = torch.tensor([[1., 0.], [0., 2.], [3., 1.]])

k_cache = torch.empty(0, 2)
v_cache = torch.empty(0, 2)
torch.set_printoptions(precision=4, sci_mode=False)

for t in range(3):
    k_cache = torch.cat([k_cache, K[t:t + 1]], dim=0)   # append this token's K
    v_cache = torch.cat([v_cache, V[t:t + 1]], dim=0)   # append this token's V
    o, w = step(Q[t:t + 1], k_cache, v_cache)
    print("t=%d  cache=%d rows  weights=%s  out=%s"
          % (t, k_cache.size(0),
             [round(x, 4) for x in w[0].tolist()],
             [round(x, 4) for x in o[0].tolist()]))

print("cache elements after 3 tokens:", k_cache.numel() + v_cache.numel())
printed output
t=0  cache=1 rows  weights=[1.0]  out=[1.0, 0.0]
t=1  cache=2 rows  weights=[0.3302, 0.6698]  out=[0.3302, 1.3395]
t=2  cache=3 rows  weights=[0.2483, 0.2483, 0.5035]  out=[1.7587, 1.0]
cache elements after 3 tokens: 12

Every row matches Chapter 7 exactly. Notice two things. There is no mask anywhere in that loop, because the cache contains only past positions, so causality is enforced by what is in the cache rather than by a triangle. And the weight row grows by one entry per step, which is the cost that never goes away.

How Big Does the Cache Get

For standard attention, an approximate cache-element count per token is:

2 × Llayers × Hkv × dh

The factor 2 accounts for K and V. In bytes, multiply by the storage bytes per element. Take a concrete model: 32 layers, dh = 128, two-byte elements. With 32 KV heads that is 2 · 32 · 32 · 128 · 2 = 524288 bytes, which is 512 KiB for every single token.

python
layers, d_h, bytes_per_elem, tokens = 32, 128, 2, 8192

print("KV heads   cache / token      cache for 8192 tokens")
for h_kv in (32, 8, 1):
    per_token = 2 * layers * h_kv * d_h * bytes_per_elem   # bytes
    total = per_token * tokens
    print("%8d   %8.0f KiB %18.3f GiB"
          % (h_kv, per_token / 1024, total / 1024 ** 3))
printed output
KV heads   cache / token      cache for 8192 tokens
      32        512 KiB              4.000 GiB
       8        128 KiB              1.000 GiB
       1         16 KiB              0.125 GiB

Four gigabytes of cache for one sequence of 8192 tokens, before you have loaded a single weight. That is why the number of key and value heads stopped being an architecture detail and became an inference-systems decision.

What this number does and does not include. The example isolates key and value storage. It ignores allocator overhead, metadata, other activations, the weights themselves, and implementation-specific representations. A real serving system reports a larger figure, and paged allocators exist precisely because the naive contiguous layout wastes a lot of it.
Cache calculator

The curve is cache size against context length for the current configuration. The rows underneath are the handbook figures.

Layers 32
KV heads 32
Head width 128
Bytes/elem 2
Context 8192
"KV caching makes decoding O(1)" is false for standard dense attention. Caching avoids repeatedly recomputing K and V for the entire prefix at every generated token, which is a real and large saving. But the new token's attention work still grows with the number of cached key and value positions, and the cache memory grows with sequence length. Constant work per token is a property of a different architecture, not of a cache.

MHA, MQA, GQA: Reduce the KV Heads, Not the Query Heads

Multi-head attention has separate key and value projections per attention head. If there are H query heads there are typically H key and value heads as well, and the cache formula above uses Hkv = H.

Multi-query attention was proposed by Shazeer for faster incremental Transformer decoding. It keeps multiple query heads but shares a single key head and a single value head across them, which greatly shrinks cached key and value tensors and memory-bandwidth demands.

Grouped-query attention generalizes MQA by using an intermediate number of key and value heads: more than one, but fewer than the query heads. Query heads are divided into groups that share a KV head. Ainslie and colleagues introduced GQA as a way to approach MQA-like inference efficiency while retaining quality closer to MHA in their experiments.

Head sharing

Eight query heads, always. Only the number of key and value heads changes. The lines show which query head reads which cached KV head.

KV heads 2

The code below shows how a grouped layer is actually implemented. The KV tensors are stored with Hkv heads and expanded to Hq heads on the fly, or handed to a kernel that understands grouping directly. Both paths give bit-identical results.

python
import torch
import torch.nn.functional as F

B, L, S, Hq, Hkv, dh = 1, 4, 6, 8, 2, 16
torch.manual_seed(0)

q = torch.randn(B, Hq, L, dh)
k = torch.randn(B, Hkv, S, dh)
v = torch.randn(B, Hkv, S, dh)

group = Hq // Hkv                      # 4 query heads share one KV head
k_rep = k.repeat_interleave(group, dim=1)   # (B, Hq, S, dh)
v_rep = v.repeat_interleave(group, dim=1)

out_manual = F.scaled_dot_product_attention(q, k_rep, v_rep)
out_gqa = F.scaled_dot_product_attention(q, k, v, enable_gqa=True)

print("q     ", tuple(q.shape))
print("k, v  ", tuple(k.shape), "  KV heads =", Hkv)
print("k_rep ", tuple(k_rep.shape), " after repeat_interleave")
print("out   ", tuple(out_gqa.shape))
print("group size Hq / Hkv =", group)
print("max abs difference:", (out_manual - out_gqa).abs().max().item())
print("KV elements cached per token: MHA", 2 * Hq * dh, " GQA", 2 * Hkv * dh)
printed output
q      (1, 8, 4, 16)
k, v   (1, 2, 6, 16)   KV heads = 2
k_rep  (1, 8, 6, 16)  after repeat_interleave
out    (1, 8, 4, 16)
group size Hq / Hkv = 4
max abs difference: 0.0
KV elements cached per token: MHA 256  GQA 64
Mental model. MQA and GQA reduce KV heads, not query heads. Query-head multiplicity can remain high while the cached key and value representation is shared. The output shape of the layer is unchanged; only what you store changes.

MLA: Compress What You Cache

Multi-head Latent Attention, introduced in DeepSeek-V2, approaches KV cache reduction differently from MQA and GQA. Instead of primarily sharing key and value heads, MLA uses low-rank joint compression of key and value information. A compressed latent is formed from the hidden state,

ctKV = WDKVht

and key and value content is then derived through learned up-projections. The core motivation is that the cached state can be much smaller than storing conventional per-head keys and values.

The caveat that most summaries drop. DeepSeek-V2 also uses decoupled RoPE. Position-sensitive key components associated with RoPE are handled separately, because directly applying RoPE inside the compressed-key path would prevent some projection absorption used for efficient inference. The paper therefore reports a cache containing both the compressed latent and a decoupled positional key component, not literally only one bare latent vector. And MLA is not just "GQA with fewer heads": it changes the representation being cached and the projection algebra used during inference.
MechanismMain KV-cache ideaKV heads
MHAstore per-head K and Vmany
MQAshare one K and V headone
GQAshare K and V within groupsintermediate
MLAlow-rank joint KV compression, plus a positional componenta different formulation
When to reach for it: grouped-query attention is the default for open-weight decoders today, and the shipped numbers make the tradeoff concrete. Llama 3 8B uses 32 query heads with 8 key and value heads, and Mistral 7B uses the same 32 and 8 split, which cuts cache per token by a factor of four against full multi-head attention at the same query-head count. Reach for multi-query attention when memory bandwidth dominates everything and you can afford to retrain or uptrain for the quality change. Reach for MLA only if you are designing the architecture, since it is not a drop-in swap: the cached object and the inference-time algebra both change. And whatever you choose, use Hkv and never Hq in your cache formula, because that confusion is a standard way to underestimate memory by a factor of four.
A model has 32 query heads, 8 KV heads, 32 layers, head width 128, and two-byte cache elements. Which statement is true?

Chapter 11: What "Quadratic" Means, and FlashAttention

"Attention is quadratic" is one sentence doing the work of three. Pulling those three apart is the difference between understanding why FlashAttention exists and repeating a slogan about it.

For dense self-attention with sequence length n and representation and head dimensions held fixed, the original Transformer analysis gives a per-layer self-attention term of order n2d. But three separate quantities hide inside that:

1. Arithmetic work
Computing all dense query-key interactions and applying them to values has a quadratic sequence-length term. This is the multiply-add count.
2. Materialized memory
A naive implementation explicitly stores the n × n score and probability matrix, giving quadratic intermediate storage.
3. Memory traffic
Reading and writing those intermediates between GPU memory levels can dominate runtime even if the arithmetic count is unchanged.

These distinctions explain how two statements can both be true. Dense attention still performs quadratic pairwise interactions. And an exact attention implementation can avoid materializing the entire quadratic attention matrix in high-bandwidth memory and run much faster. That second statement is the core of FlashAttention.

Mental model. Complexity, memory footprint, memory traffic and wall-clock time are related but not interchangeable. Never use one as a synonym for another. Most confused conversations about efficient attention are two people optimizing different ones of these four.

The Memory Hierarchy Is the Point

FlashAttention is an IO-aware exact attention algorithm. Its key insight is to account explicitly for the GPU memory hierarchy: transfers between high-bandwidth memory and faster on-chip SRAM can be a dominant cost. Instead of materializing the full attention matrix in HBM, FlashAttention tiles the computation so that blocks of Q, K and V and intermediate statistics can be processed on chip, reducing HBM reads and writes.

The mathematical target remains scaled dot-product attention. The algorithm uses an online, blocked softmax strategy so that exact results can be assembled without storing all scores at once in HBM.

Materialize versus tile

Press play and watch the data move. The three counters underneath are the three quantities above, tracked separately. Only one of them changes when you switch strategy.

Length n 4096
Tile 64

The multiply-add counter does not move when you switch strategies. That is the whole point. The tiled path performs the same arithmetic on the same numbers and produces the same answer. What collapses is the peak intermediate held in HBM, from the full n × n matrix down to one tile, and with it the traffic between the two memory levels.

FlashAttention does not turn standard dense attention arithmetic from O(n2) into O(n). It reduces memory traffic and avoids quadratic HBM materialization while computing exact dense attention. Anyone who tells you FlashAttention is "linear attention" has confused a memory result with a complexity result.

Online Softmax, and Why It Is Exact

The trick that makes tiling possible is a running softmax. Softmax needs the maximum and the sum of exponentials over the whole row, which you do not have while you are still streaming tiles. So carry three running quantities: the maximum seen so far, the running sum of exponentials, and the running weighted sum of values. When a new tile arrives with a larger maximum, rescale the two accumulators by eold max − new max and carry on.

The code below does exactly that against a full-row reference, on a real 512 key row, and reports the difference.

python
import math
import torch

torch.manual_seed(0)
S, d = 512, 64
q = torch.randn(d)
K = torch.randn(S, d)
V = torch.randn(S, d)
scale = 1.0 / math.sqrt(d)

# Reference: materialise the whole score row, then softmax, then mix.
ref = torch.softmax(K @ q * scale, dim=-1) @ V

# Tiled online softmax: never hold more than one tile of scores.
tile = 64
m = torch.tensor(float("-inf"))   # running max
l = torch.tensor(0.0)             # running sum of exponentials
acc = torch.zeros(d)              # running weighted sum of values
peak = 0
for start in range(0, S, tile):
    s = K[start:start + tile] @ q * scale     # one tile of scores
    peak = max(peak, s.numel())
    m_new = torch.maximum(m, s.max())
    correction = torch.exp(m - m_new)
    p = torch.exp(s - m_new)
    l = l * correction + p.sum()
    acc = acc * correction + p @ V[start:start + tile]
    m = m_new
out = acc / l

print("scores held at once: full row %d, tiled %d" % (S, peak))
print("max abs difference:", (ref - out).abs().max().item())
printed output
scores held at once: full row 512, tiled 64
max abs difference: 5.960464477539063e-08

Six times ten to the minus eight is float32 rounding, not an approximation error. The two paths compute the same function. One of them just never held more than 64 scores at a time.

Generations: Optimize the Bottleneck You Actually Have

The durable concept is IO-aware exact attention. Later FlashAttention versions refine how that computation maps to newer GPU hardware.

FlashAttention-2 improved parallelism and work partitioning, including better distribution of work across thread blocks and warps and fewer non-matrix-multiply operations.

FlashAttention-4, introduced in 2026 for Blackwell-class GPUs, addresses a different hardware balance. Tensor-core throughput had grown faster than some surrounding resources, so bottlenecks shifted toward softmax and other non-matmul work, shared-memory traffic, and pipeline structure. Its contribution is therefore not a new definition of attention but a hardware-aware algorithm and kernel co-design for a changed machine.

Mental model. An algorithm can be mathematically unchanged while its fastest implementation changes as hardware changes. "Attention" is the mathematical operation. "FlashAttention N" is a family of increasingly hardware-aware ways to execute that operation.

The evergreen version of the lesson, in five lines:

PrincipleWhat it looks like in an attention kernel
Avoid unnecessary materializationnever write the n × n probability matrix to HBM
Tile data to exploit faster memoryload blocks of Q, K and V into SRAM and registers
Fuse compatible operationsscale, mask, softmax and the value matmul in one kernel
Keep expensive compute units fedoverlap loads with matmuls so tensor cores never stall
Reconsider bottlenecks when hardware ratios changesoftmax work becomes the limiter once matmul gets fast enough

Benchmark numbers from individual FlashAttention generations are intentionally omitted here, because they are hardware, shape, precision and software-version dependent. A speedup quoted without all four of those is not a fact you can carry anywhere.

Sparse and Local Attention Change Something Else Entirely

Dense attention connects every query to every key, producing quadratically many pairwise interactions for same-length self-attention. A completely different family of approaches changes the connectivity pattern itself.

Longformer combines local windowed attention with task-motivated global attention, and was designed so that attention cost scales linearly with sequence length under its fixed-window pattern. BigBird combines sparse components such as local, global and random connections, also reducing the full quadratic dependency while retaining important theoretical properties under its design.

ApproachWhat changesExact full dense attention?
FlashAttentionexecution and IO strategyyes
Local or sparse attentionwhich query-key pairs are computed at allno, connectivity is restricted or structured

Sparse attention can reduce arithmetic work by not computing all pairs. The trade-off is that the model no longer has unrestricted direct connectivity in a single layer, so pattern design matters. A token pair that is never scored in layer 7 can only communicate through some multi-hop path across layers, if one exists at all.

"Efficient attention" is not one technique. Some methods preserve exact dense attention but execute it better. Others change the attention pattern or introduce an approximation to reduce the number of interactions. Those are different products with different risks, and a benchmark that compares them as if they were interchangeable is measuring the wrong thing.
When to reach for it: FlashAttention is the default and you are almost certainly already using it. PyTorch scaled dot-product attention dispatches to a fused implementation when the shapes, dtype and mask allow it, which is why an explicit float mask can quietly cost you the fast path while is_causal keeps it. Reach for sliding-window or sparse attention only when the context is long enough that the quadratic term genuinely dominates and you accept a modelling change, which in practice means document scale work of the kind Longformer and BigBird targeted. And when you profile, measure the three quantities separately: if you are memory-traffic bound, more arithmetic efficiency buys you nothing.
Which pair of statements about FlashAttention is correct?

Chapter 12: Code, Traps, and the Complete Mental Model

You can now derive attention, mask it, cache it, split it into heads and explain why a kernel tiles it. This chapter is the part you keep next to the keyboard: the production call, the seven ways people break it, the eight things people believe about it that are not true, and the six verbs that hold the whole thing together.

The Production Call, and Its One Sharp Edge

Current PyTorch exposes scaled dot-product attention as:

signature
torch.nn.functional.scaled_dot_product_attention(
    query, key, value,
    attn_mask=None,
    dropout_p=0.0,
    is_causal=False,
    scale=None,
    enable_gqa=False,
)

The documentation defines query length L, source and key length S, optional Boolean or additive masks, default scaling 1/√d, causal bias behavior, dropout, and experimental grouped-query support. Depending on inputs and backend, PyTorch can dispatch to optimized implementations rather than literally executing the pedagogical Python of Chapter 7.

Here is the call on our three tokens, done two ways, so you can see the mask polarity for yourself.

python
import torch
import torch.nn.functional as F

q = k = torch.tensor([[1., 0.], [0., 1.], [1., 1.]])
v = torch.tensor([[1., 0.], [0., 2.], [3., 1.]])

# SDPA wants (batch, heads, seq, head_dim): add two leading axes.
qh, kh, vh = (t[None, None] for t in (q, k, v))

out_causal = F.scaled_dot_product_attention(qh, kh, vh, is_causal=True)

# The same thing with an explicit Boolean mask.
# In PyTorch SDPA, True in attn_mask means the position PARTICIPATES.
allowed = torch.tril(torch.ones(3, 3, dtype=torch.bool))
out_mask = F.scaled_dot_product_attention(qh, kh, vh, attn_mask=allowed)

torch.set_printoptions(precision=4, sci_mode=False)
print("is_causal=True  ->")
print(out_causal[0, 0])
print("attn_mask=tril  ->")
print(out_mask[0, 0])
print("max abs difference:", (out_causal - out_mask).abs().max().item())
printed output
is_causal=True  ->
tensor([[1.0000, 0.0000],
        [0.3302, 1.3395],
        [1.7587, 1.0000]])
attn_mask=tril  ->
tensor([[1.0000, 0.0000],
        [0.3302, 1.3395],
        [1.7587, 1.0000]])
max abs difference: 0.0

Same numbers as the hand calculation, one more time, now through the production API. Four implementation lessons follow from that signature.

API semantics matter, and masks are not interchangeable across libraries. In current PyTorch scaled dot-product attention, a Boolean attn_mask value of True means the position participates in attention. That is the inverse of MultiheadAttention's Boolean key_padding_mask convention, where True means the position is padding and should be ignored. Invert this by accident and your model attends to exactly the positions it was supposed to avoid, with no error and a plausible-looking loss curve.
LessonWhat to actually do
Mask polarity differs by APIread the docstring for the function you are calling, every time, and unit-test one padded batch
Dropout must be controlled explicitlypass dropout_p from a flag tied to train and eval mode, rather than leaving a constant in the call
GQA has shape and divisibility constraintsassert that the query head count divides evenly by the KV head count before you reshape anything
Kernel selection depends on many thingsdevice, dtype, shape, masks and feature support all steer the backend, so profile the configuration you ship

Implementation APIs evolve. The mathematics in this lesson is evergreen; production code should always be checked against the documentation for the installed framework version.

The Seven Common Implementation Mistakes

#MistakeWhy it is wrong
1Softmax on the wrong dimensionAttention normalizes across keys for each query. Applying softmax across query rows changes the operation into something else entirely.
2Forgetting the scaleUnscaled dot-product attention is not the Transformer formulation and can behave differently as head dimension changes.
3Masking after softmaxSetting probabilities to zero after softmax without renormalizing is generally not equivalent to excluding those logits before normalization.
4Using a square-mask assumption for L ≠ SCached decoding and cross-attention often have different query and key lengths. Causal alignment must match the framework's convention.
5Confusing head count with KV-head countIn grouped-query attention, query heads and key and value heads differ. Shape logic and cache formulas must use Hkv for K and V storage.
6Treating attention weights as the layer outputThe weights are an intermediate. The output is AV, followed in multi-head attention by concatenation and output projection, and then the surrounding block operations.
7Comparing "attention memory" without defining what is countedWeights, temporary score matrices, KV cache, activations and allocator overhead are different quantities. A number without a definition is not comparable.
Mental model. Most attention bugs are shape, axis and semantics bugs, not failures to remember the main formula. Track each dimension explicitly. The fastest way to find one is the three token example from Chapter 7, where every correct number is printed on this page.

Eight Misconceptions, Corrected

MisconceptionCorrection
"Q is a question, K is an answer."Q and K are learned vector projections whose dot product supplies compatibility. The analogy is not the mechanism.
"Attention is just similarity search."Dot products are learned task-specific compatibility scores; values are then aggregated.
"Attention weights are the output."They are coefficients. The output is a weighted sum of V.
"Self-attention means causal attention."Self-attention says Q, K and V come from the same stream. Causality is a mask and connectivity constraint.
"FlashAttention is linear attention."FlashAttention computes exact dense attention with an IO-aware execution strategy.
"KV caching makes attention constant time."It reuses past K and V, but a new dense query still interacts with a growing prefix.
"MQA and GQA reduce query heads."Their defining change is sharing or reducing KV heads relative to query heads.
"RoPE gives unlimited context."Position representation alone does not guarantee trained long-context competence.

A good technical explanation removes these shortcuts early, because later systems concepts build directly on the distinctions.

Are Attention Weights Explanations?

Attention weights are visually compelling. They form distributions over input positions, so it is tempting to read a high weight as "the model used this token because it mattered." That conclusion is not generally justified without additional analysis.

Jain and Wallace tested attention weights as explanations across several NLP models and found cases where learned attention was weakly related to gradient-based importance measures, and where very different attention distributions could yield similar predictions. They argued that standard attention weights should not automatically be treated as faithful explanations.

Wiegreffe and Pinter challenged a categorical rejection, arguing that the answer depends on the definition of explanation and on appropriate baselines and tests. They proposed diagnostic conditions under which attention can still be informative.

The safest technical conclusion is narrower than either slogan. Attention weights are internal model quantities that can sometimes support analysis, but they are not automatically faithful causal explanations of a model's prediction. Interpretability claims require a method and validation beyond merely plotting the weights. This matters especially in modern Transformers, where many heads and layers, residual pathways, feed-forward blocks and later computations all contribute to the final output.

The Complete Mental Model: Six Verbs

Attention is easiest to remember as a sequence of six ideas. This is the checklist. Every attention implementation you will ever read does these six things in this order, and every bug you will ever chase is one of them done wrong.

VerbWhat it needsWhat it changesThe bug when you skip it
1. Project
map hidden states into learned Q, K and V
three weight matrices, and Q and K must share dhturns one representation into three learned viewstying K and V collapses the two jobs, so the head can no longer choose independently of what it delivers
2. Compare
score each query against every allowed key
the transpose, so the inner dimensions contractproduces an L × S grid of unnormalized compatibilitiesa missing transpose gives a shape error if you are lucky and a silently wrong contraction if you are not
3. Scale
divide the dot products by √dk
the head width, not the model widthkeeps logit magnitude roughly constant as heads widensaturated softmax rows and gradients two orders of magnitude smaller, which looks like a bad learning rate
4. Constrain
apply the masks or biases that define what is allowed
the right alignment convention when L ≠ Szeroes forbidden pairs before normalizationfuture leakage during training, or attention to padding, both of which train fine and evaluate badly
5. Normalize
row-wise softmax turns scores into weights over keys
the key axis, and only the key axiseach query gets a distribution summing to onecolumn-wise softmax, or masking after softmax, and every output silently changes scale
6. Aggregate
mix the values into the query's new representation
one value row per key rowproduces the L × dv output in value spacereturning the weights instead of AV, the classic "my attention layer outputs a probability matrix" bug
The six verbs, running

One packet of data through the whole operation. The active stage carries the tensor shape it produces.

Everything Else Modifies One Surrounding Dimension

IdeaWhich of the six it touchesWhat it actually changes
Multi-head attentionProjectrepeats the operation in multiple learned subspaces, then merges with WO
RoPE and position methodsProject and Comparemakes attention position-aware without changing the aggregation
Causal maskingConstrainenforces autoregressive information flow
KV cachingexecution, none of the sixreuses past K and V during incremental decoding
MQA and GQAProjectreduces KV-head multiplicity, and with it cache and bandwidth cost
MLAProjectcompresses the cached KV representation with a different projection strategy
FlashAttentionexecution, none of the sixexecutes exact dense attention with less expensive memory traffic
Sparse and local attentionConstrainchanges which pairs are computed, to reduce dense connectivity cost

If these distinctions are clear, you do not merely know the attention equation. You have the conceptual foundation required to understand why modern LLM architectures and inference systems keep changing the structures around it.

When to reach for it: use the six verbs as a review checklist on any attention code you are asked to read, including your own from six months ago. Walk the verbs in order and ask what each line implements. In practice the scan finds the same three things: a softmax on the wrong axis, a mask polarity flipped against the library convention, and a cache formula written with the query-head count. Ten minutes with this table beats an afternoon in a debugger.

Where to Go Next

LessonWhat it adds on top of this one
Transformerthe whole block: residual stream, layer norm, feed-forward, encoder and decoder stacks, and where attention sits inside them
Positional Encodingthe full design space of position: sinusoidal, learned, relative, RoPE, ALiBi, and the group theory that says there are only a few families
microGPTevery line of a working decoder-only language model, training loop included
SSM and Mambathe family that genuinely does make per-token decoding work constant, by giving up dense pairwise interaction

References

Bahdanau, Cho, Bengio. Neural Machine Translation by Jointly Learning to Align and Translate. ICLR 2015. arXiv:1409.0473
Luong, Pham, Manning. Effective Approaches to Attention-based Neural Machine Translation. EMNLP 2015. arXiv:1508.04025
Vaswani et al. Attention Is All You Need. NeurIPS 2017. arXiv:1706.03762
Shazeer. Fast Transformer Decoding: One Write-Head is All You Need. 2019. arXiv:1911.02150
Beltagy, Peters, Cohan. Longformer: The Long-Document Transformer. 2020. arXiv:2004.05150
Zaheer et al. Big Bird: Transformers for Longer Sequences. NeurIPS 2020. arXiv:2007.14062
Su et al. RoFormer: Enhanced Transformer with Rotary Position Embedding. 2021. arXiv:2104.09864
Dao, Fu, Ermon, Rudra, Re. FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness. NeurIPS 2022. arXiv:2205.14135
Ainslie et al. GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints. EMNLP 2023. arXiv:2305.13245
Dao. FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning. 2023. arXiv:2307.08691
DeepSeek-AI et al. DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model. 2024. arXiv:2405.04434
Zadouri et al. FlashAttention-4: Algorithm and Kernel Pipelining Co-Design for Asymmetric Hardware Scaling. 2026. arXiv:2603.05451
Jain, Wallace. Attention is not Explanation. NAACL-HLT 2019.
Wiegreffe, Pinter. Attention is not not Explanation. EMNLP-IJCNLP 2019.
PyTorch documentation, torch.nn.functional.scaled_dot_product_attention, consulted for API shapes, masks, causality, scaling, dropout and grouped-query behavior.

Source. This lesson is built from the handbook Understanding Attention: From Q, K, V to Modern Transformer Attention by @techNmak, whose worked example, tensor-shape discipline, mistake list, misconception table and six-verb mental model form the spine of these thirteen chapters. Its accuracy rule is kept here: named methods are grounded in their primary papers, and claims about historical papers stay within what those papers actually establish.

"What I cannot create, I do not understand."
Richard Feynman. You have now created attention four times on this page, in arithmetic, in plain Python, in NumPy and in PyTorch.