TL

Inference

Attention, from First Principles

2026-06-29 · 12 min read

Attention lets every token look back over the sequence and pull in the context it needs — and it’s exactly where the KV cache comes from. In this post, we will explore what attention is and how it is constructed.

What attention replaced

Until 2017, sequences were modeled with recurrence — RNNs and LSTMs that walk the tokens one at a time, carrying a hidden state forward like a running summary. Two problems: it can’t parallelize (token N waits on token N−1), and the summary blurs (by the end of a long sentence, the beginning has been squeezed through hundreds of updates). Attention Is All You Need threw recurrence out entirely — keep only attention, and any token reaches any other directly, in parallel, with no distance penalty. The original was an encoder–decoder translation model; the LLMs I care about are decoder-only and causal — every token sees the ones before it, never the ones after. That’s the version that produces the cache, so it’s the one I’ll build.

Query, key, value

Take the sentence I’ll use throughout: “The cat sat because it was tired.” What does it refer to? You know instantly — the cat — but the model’s embedding for it doesn’t: an embedding is the token in isolation, and the same token means different things in different sentences. A token’s real meaning lives in its context, and attention is the mechanism that fetches it — each token looks back over the earlier ones, decides which matter, and pulls in a blend of their information. it reaches back, finds cat, and becomes a richer vector that means it, the cat.

Here’s the move that makes that work. From each token’s embedding, the model produces three vectors, by multiplying it by three learned weight matrices:

  • a query (Q=XWqQ = X W_q) — what this token is looking for;
  • a key (K=XWkK = X W_k) — what this token offers to others;
  • a value (V=XWvV = X W_v) — what this token actually hands over if attended to.

The retrieval analogy is exact: a query is a search request, a key is the label on a drawer, a value is what’s inside. Attention matches each token’s query against every token’s key to decide which drawers to open, then pulls out a weighted mix of their values. The three roles are separate on purpose — what a token advertises (KK), what it searches for (QQ), and what it contributes (VV) are different — so the model learns three independent sets of weights.

The shapes matter, because they’re where the cost will come from. If the sequence has seq\text{seq} tokens and each embedding has width dmodeld_\text{model}, then XX is seq×dmodel\text{seq} \times d_\text{model}. Each weight matrix is dmodel×dheadd_\text{model} \times d_\text{head}, so each of QQ, KK, VV comes out seq×dhead\text{seq} \times d_\text{head} — one row per token (Figure 1). Hold onto KK and VV in particular: those two are exactly what the KV cache stores.

Figure 1 — Each token’s embedding is projected three ways by learned matrices Wq, Wk, Wv into a query, key, and value. Q, K, and V each have one row per token; K and V are precisely what gets written to the KV cache.
Figure 1 — Each token’s embedding is projected three ways by learned matrices Wq, Wk, Wv into a query, key, and value. Q, K, and V each have one row per token; K and V are precisely what gets written to the KV cache.

Scaled dot-product attention

The whole operation is one line:

Attention(Q,K,V)=softmax ⁣(QKdhead)V\mathrm{Attention}(Q, K, V) = \mathrm{softmax}\!\left(\frac{Q K^\top}{\sqrt{d_\text{head}}}\right) V

Score, scale, mask, normalize, blend — five steps, each earning its place. With QQ, KK, and VV in hand:

Score every pair. Take the dot product of each token’s query with every token’s key: scores=QK\text{scores} = Q K^\top. A dot product is large when two vectors point the same way and near zero when they’re unrelated, so it’s a natural similarity score: scoresij\text{scores}_{ij} is how much token i’s query matches token j’s key — how relevant token j is to token i. The whole point of training WqW_q and WkW_k is to learn to aim a token’s query in the same direction as the keys of the tokens it ought to care about. With seq\text{seq} tokens this is a seq×seq\text{seq} \times \text{seq} grid, one row per querying token, one column per token being looked at.

Scale it down. Divide every score by dhead\sqrt{d_\text{head}}. This looks like a fudge factor and isn’t. A dot product over dheadd_\text{head} dimensions is a sum of dheadd_\text{head} products; the more dimensions, the larger that sum tends to grow, and big raw scores push the next step — softmax — into a corner where one token grabs nearly all the weight and the gradients flatten out. Dividing by dhead\sqrt{d_\text{head}} keeps the scores in a sane range so the model can actually learn which tokens matter.

Note

Why the square root specifically? If a query and key have components that are independent with unit variance, their dot product over dheadd_\text{head} dimensions has variance dheadd_\text{head} — so its standard deviation is dhead\sqrt{d_\text{head}}. Dividing by dhead\sqrt{d_\text{head}} rescales the scores back to roughly unit variance before the softmax, which is exactly the range where softmax stays sensitive. The scale factor isn’t a magic number; it’s the standard deviation of the thing it’s correcting.

Mask the future. This is the causal part, and it’s load-bearing for everything downstream. Token it may attend to The cat sat because, but not to was tired — those come after it, and at generation time they don’t exist yet. So before the next step we set every score where j>ij > i to negative infinity, which the softmax will turn into zero weight. The grid becomes lower-triangular: each token sees itself and everything to its left, nothing to its right (Figure 2).

Normalize into weights. Run softmax across each row. Now every row is a set of non-negative weights that sum to one — a probability distribution over “how much of my attention goes to each earlier token.” Why softmax and not just divide each row by its sum? The exponential inside it rewards the largest scores disproportionately, so attention can commit — pour almost all of its weight onto the one or two tokens that matter — while staying smooth and differentiable, a soft version of “pick the best.” Plain normalization would smear the weight too evenly to resolve a sharp reference. In our sentence, the row for it puts most of its weight on cat; that single bright cell is attention doing its one job.

Blend the values. Finally, multiply those weights by VV: output=softmax(scores/dhead)V\text{output} = \mathrm{softmax}(\text{scores} / \sqrt{d_\text{head}})\, V. Each token’s output is a weighted average of the value vectors of the tokens it attended to. it walks away carrying mostly cat’s value — it has pulled the context it needed. That enriched vector is what attention contributes, and it gets added back into the token’s residual stream — the skip-connection from the last post — so the next layer starts from an it that already knows it’s the cat.

Figure 2 — The attention-weight grid for “The cat sat because it was tired.” Each cell starts as a query-key dot product, then gets scaled, causally masked, and softmaxed so every row sums to 1; the shading is the resulting weight, darker meaning more. The upper triangle is masked to −∞ and gets zero weight. The “it” row concentrates on “cat” — attention resolving the reference.
Figure 2 — The attention-weight grid for “The cat sat because it was tired.” Each cell starts as a query-key dot product, then gets scaled, causally masked, and softmaxed so every row sums to 1; the shading is the resulting weight, darker meaning more. The upper triangle is masked to −∞ and gets zero weight. The “it” row concentrates on “cat” — attention resolving the reference.

Everything else in this post is consequences.

Many heads

One set of QQ, KK, VV learns one notion of relevance, but tokens relate in several ways at once (it wants cat; a verb wants its subject). So real transformers run multi-head attention: split the projections into hh independent (Q,K,V)(Q, K, V) triples — each dheadd_\text{head} wide, with its own weights — run the same scaled-dot-product in each in parallel, then concatenate the hh outputs and pass them through one final matrix, WoW_o (Figure 3).

That last matrix isn’t bookkeeping. Concatenation only stacks the heads side by side — they haven’t interacted yet. WoW_o (shape dmodel×dmodeld_\text{model} \times d_\text{model}) is what mixes them: it blends each head’s separate finding into one vector and projects it back into the residual stream, so the next layer sees one combined representation, not hh disjoint ones.

The heads share the width rather than each taking the full dmodeld_\text{model}, so dmodel=h×dheadd_\text{model} = h \times d_\text{head}. Llama 2 7B splits dmodel=4096d_\text{model} = 4096 into h=32h = 32 heads of dhead=128d_\text{head} = 128 — the same arithmetic, reshuffled into parallel subspaces. So extra heads are nearly free in compute.

The cache is the exception. Every head keeps its own keys and values, so the cache isn’t one KK/VV per token — it’s num_heads\text{num\_heads} copies wide (32 for Llama 2 7B). What’s free in arithmetic is paid for in memory; that asymmetry is the hinge between this post and the next.

Figure 3 — Multi-head attention runs h independent attention computations in parallel subspaces, then concatenates their outputs and mixes them with Wo. Each head keeps its own K and V, so the KV cache is num_heads wide — and the whole block repeats num_layers times down the stack.
Figure 3 — Multi-head attention runs h independent attention computations in parallel subspaces, then concatenates their outputs and mixes them with Wo. Each head keeps its own K and V, so the KV cache is num_heads wide — and the whole block repeats num_layers times down the stack.

Where it sits, and what it costs

For one sequence, the KV cache is a product:

cache=num_layers×num_heads×2×seq_len×dhead×bytes\text{cache} = \text{num\_layers} \times \text{num\_heads} \times 2 \times \text{seq\_len} \times d_\text{head} \times \text{bytes}

Read it as a stack of for every: for every layer, every head stores a key and a value (the 22), each dheadd_\text{head} wide, for every one of the seq_len\text{seq\_len} tokens — times the bytes per number. These keys and values are computed once and kept; recompute them each decode step and generation goes quadratic. And since the causal mask only lets a token look left, earlier entries never change — each step appends one new KK/VV and reads the rest. It’s the KK and VV of Figure 1, saved once per head, once per layer.

Take Llama 2 7B — 32 layers, 32 heads, dheadd_\text{head} 128, fp16, a 4,096-token context:

32×32×2×4,096×128×2 bytes2 GB32 \times 32 \times 2 \times 4{,}096 \times 128 \times 2\ \text{bytes} \approx 2\ \text{GB}

That’s 2 GB for a single sequence — not the weights, not the batch, just one conversation’s keys and values. It’s the number behind “the KV cache caps concurrency”: serve a handful at once and the cache, not the math, is what fills the GPU.

KV cache + weights — GPU memory

Real models that use full multi-head attention (pre-GQA). Pick one — the KV cache (per sequence) and the weights (loaded once), each computed, and what they total.

Llama 2 7B · 32 layers · 32 heads · d_head 128 · 4,096 ctx · fp16

KV cache · per sequence

layers × heads × 2 × seq × d_head × bytes

32 × 32 × 2 × 4,096 × 128 × 2 B

2.0 GB

Weights · loaded once

params × bytes

7B × 2 B

14 GB

total · 1 sequence16 GB
each added sequence+ 2.0 GB

Llama 2 13B · 40 layers · 40 heads · d_head 128 · 4,096 ctx · fp16

KV cache · per sequence

layers × heads × 2 × seq × d_head × bytes

40 × 40 × 2 × 4,096 × 128 × 2 B

3.1 GB

Weights · loaded once

params × bytes

13B × 2 B

26 GB

total · 1 sequence29 GB
each added sequence+ 3.1 GB

CodeLlama 13B · 40 layers · 40 heads · d_head 128 · 16,384 ctx · fp16

KV cache · per sequence

layers × heads × 2 × seq × d_head × bytes

40 × 40 × 2 × 16,384 × 128 × 2 B

12.5 GB

Weights · loaded once

params × bytes

13B × 2 B

26 GB

total · 1 sequence38.5 GB
each added sequence+ 12.5 GB

Llama 1 65B · 80 layers · 64 heads · d_head 128 · 2,048 ctx · fp16

KV cache · per sequence

layers × heads × 2 × seq × d_head × bytes

80 × 64 × 2 × 2,048 × 128 × 2 B

5.0 GB

Weights · loaded once

params × bytes

65B × 2 B

130 GB

total · 1 sequence135 GB
each added sequence+ 5.0 GB

GPT-3 175B · 96 layers · 96 heads · d_head 128 · 2,048 ctx · fp16

KV cache · per sequence

layers × heads × 2 × seq × d_head × bytes

96 × 96 × 2 × 2,048 × 128 × 2 B

9.0 GB

Weights · loaded once

params × bytes

175B × 2 B

350 GB

total · 1 sequence359 GB
each added sequence+ 9.0 GB

GPT-3 175B · 96 layers · 96 heads · d_head 128 · 2,048 ctx · fp8

KV cache · per sequence

layers × heads × 2 × seq × d_head × bytes

96 × 96 × 2 × 2,048 × 128 × 1 B

4.5 GB

Weights · loaded once

params × bytes

175B × 1 B

175 GB

total · 1 sequence180 GB
each added sequence+ 4.5 GB

Weights load once; the KV cache is per sequence, so at high concurrency the KV cache — not the weights — is what fills the GPU. Totals are weights + KV in the listed precision, before activation and framework overhead. All full multi-head; GQA / MQA / MLA shrink the KV cache (next post).

And it’s the same row you watched grow in the last post’s KV-cache figure — only now you can see what’s in each entry. Each entry is a key and a value, born from XWkX W_k and XWvX W_v, multiplied out by head and by layer. The memory wall wasn’t a property of the serving system. It was sitting in the attention formula the entire time.

What comes next

That’s attention from the bottom up. The KK and VV it stores for every head, in every layer, are the KV cache. Which raises the obvious question: if the cost is a copy of KK and VV for every head in every layer, can we keep fewer without losing what attention buys us? That’s the next post — MQA, GQA, MLA, and FlashAttention, which never even writes the seq×seq\text{seq} \times \text{seq} score grid to memory. Same operation, reshaped to be cheap.