Concept Deep-Dive

From Self-Attention to Grouped-Query Attention

Built up from scratch, in order: what a transformer block actually does, the intuition and exact math of self-attention, how prefill and decode differ at inference time, why that forces you to keep a KV cache, and finally why almost every serving-grade model today replaces plain multi-head attention with Grouped-Query Attention (GQA) to make that cache affordable.

The one-paragraph version

Self-attention lets every token pull context from every other token: it asks a question (query), the others answer with labels (keys), and it absorbs a weighted blend of their content (values). At inference, a prompt is processed once in parallel (prefill), then tokens are generated one at a time (decode) — and because decode would otherwise recompute every past key and value on every single step, you cache them instead: the KV cache. That cache is one of the two matrices per layer, per head — and it's expensive: it grows with context length and must be re-read from memory on every decode step. Grouped-Query Attention shrinks it by having several query heads share one key/value head, cutting cache size and memory-bandwidth cost by the group size, at a small, controllable cost in quality.

01The transformer skeleton, briefly

Before the mechanism, the shape it lives in. A transformer takes a sequence of T tokens and turns them into a matrix — T rows, one per token, each row a vector of length dmodel (the embedding dimension). Everything that follows just transforms that (T, dmodel) matrix, over and over, without ever changing its shape.

Sequence length
T
number of tokens
Model width
dmodel
e.g. 1024
Query heads
hq
e.g. 8
Per-head dim
dk
dmodel / hq = 128
  1. Tokenize & embed. Token IDs index into an embedding table → X ∈ (T, dmodel).
  2. Add positional information (learned position embeddings, or rotary position encoding applied later inside attention) — self-attention alone has no notion of order, since it treats the sequence as a set.
  3. Stack N identical blocks. Each block: x ← x + SelfAttention(LayerNorm(x)), then x ← x + FFN(LayerNorm(x)). Pre-norm and residual connections everywhere, so gradients and information have a direct path through the whole depth.
  4. Final norm → LM head. Project the last token's dmodel vector to vocabulary size, softmax → a probability over the next token.
Diagram · Figure 1
Two tokens, "Thinking" and "Machines," flowing through one transformer block. Self-attention is the only place they interact; every other step runs on each token alone.
TRANSFORMER BLOCK · × N Add & Normalize LayerNorm( x + FFN(x) ) Feed Forward Feed Forward z1 z2 Add & Normalize LayerNorm( X + Z ) skip connection carries X around self-attention Z1 Z2 Self-Attention the only sub-layer where tokens exchange information X1 X2 + + positional encoding Thinking Machines
Figure 1. Two residual "skip connections" (dashed) carry each token's pre-sublayer value around self-attention and around the feed-forward layer, so both sub-layers only ever have to learn a correction — Add & Normalize is where that correction gets folded back in.

A note on the two norm placements above: the figure shows post-norm — LayerNorm after the residual add, exactly as in the original 2017 paper: x ← LayerNorm(x + Sublayer(x)). Most modern decoder LLMs (GPT, Llama, Qwen) instead use pre-norm — normalizing before the sub-layer, as written in step 3 above and used for the rest of this piece — because it trains more stably at depth. Both wrap the exact same residual highway; only the order of Norm relative to the + differs.

02Self-attention, the intuition

Here's the picture worth holding onto: we have a set of tokens, and after embedding and adding positional information, each token is a vector. For every token, self-attention builds three separate projections of that vector — a query, a key, and a value.

The query is the question a token asks: "what context do I need?" The key is how every other token advertises what it has to offer. So each token's query gets compared against every other token's key — q · k — and that comparison becomes a weight for how much attention to pay. Then, using those weights, the token takes a weighted sum of everyone's values and adds that blend back onto its own embedding as a residual. That weighted blend is the extra context the token picked up by looking at the rest of the sequence.

The one-sentence version

Query asks, keys answer with a match score, softmax turns scores into weights, and the token absorbs a weighted sum of everyone's values as its update.

03Self-attention, the exact math

Now the same story, formalized term by term. Dimensions used below, matching a small real config:

T = sequence length  ·  dmodel = 1024  ·  hq = 8 query heads  ·  dk = dv = 128 (so hq · dk = dmodel). We'll do one head first, then stack heads in §4.

Shape pills: T×128activation 1024×128weight T×Tscores (the O(T²) object)

1 · Three projections per token. Learned weight matrices turn each token's embedding into a query, key, and value:

X = the whole sequence, stacked — then three projections
QT×128 = XT×1024 WQ1024×128
KT×128 = XT×1024 WK1024×128
VT×128 = XT×1024 WV1024×128

Row t of Q is token t's question; row t of K is what token t advertises; row t of V is what token t actually hands over once picked.

2 · Every query against every key, at once. A single matrix multiply computes all pairwise qt · ki dot products in one shot:

raw scores — one number per (query, key) pair
QT×128 Kᵀ128×T = scoresT×T

This is the T×T object that makes attention O(T²): row t holds token t's query compared against every key. Entry (t, i) is literally qt · ki, exactly the "query asks, key answers" comparison from the intuition above.

3 · Scale by √dk. If each component of q and k is roughly unit-variance and independent, the dot product q · k (a sum of dk such terms) has variance proportional to dk. Bigger heads → bigger raw scores → softmax saturates into a near one-hot distribution and gradients vanish. Dividing by √dk cancels that growth and keeps the scale roughly constant regardless of head size:

QT×128 Kᵀ128×T / √dk = scaled scoresT×T
still (T×T), but scale-stable in dk

There's an interactive for exactly this effect below — it's not a minor detail, it's the difference between attention training stably or not.

4 · Causal mask, then softmax per row. For language modeling, token t may only look at tokens ≤ t (it can't peek at the future it's trying to predict), so entries above the diagonal are set to −∞ before the softmax zeroes them out. Softmax is applied row-wise so each token's weights sum to 1:

AT×T = softmaxrow( mask( QKᵀ/√dkT×T ) )
row t of A = token t's attention weights over tokens 1..t  ·  they sum to 1

5 · Weighted sum of values — the payoff. Multiply the weights by V and every token's output is exactly the weighted blend the intuition promised:

AT×T · VT×128 = OT×128
row t of O = Σi≤t At,i · vi  — exactly "weighted sum of everyone's values"

Put together, this is the equation of attention:

Attention(Q, K, V) = softmax( QK / √dk ) V   # causal mask applied inside softmax's argument

Finally the output is projected back and added to the residual stream: x ← x + O·WO, with WO ∈ (128, 1024) mapping the head back to model width (this matters more once there are several heads to recombine, next section).

Why divide by √dk? Watch it happen

Fix five keys with a spread of similarity to one query (from very aligned to very opposed), and grow the head dimension dk. Raw dot-product scores grow with √dk; scaling by 1/√dk should hold the softmax steady no matter how wide the head is. Drag the slider.

Interactive · Figure 2
Same 5 keys, same underlying similarity to the query — only the head dimension changes.
Without scaling: softmax(QKᵀ)
With scaling: softmax(QKᵀ/√dk)
Figure 2. As dk grows, the unscaled distribution collapses onto one key (near-zero gradient everywhere else); the √dk-scaled version stays roughly the same shape regardless of head size.

04Multi-head attention: prefill, decode, and the cost per step

One head can only learn one notion of "relevant." Multi-head attention runs hq of these in parallel, each with its own WQ, WK, WV, each looking at a different dk = dmodel/hq-wide slice of representation space, then concatenates the hq outputs and projects back down:

headi = Attention(X WQi, X WKi, X WVi)    i = 1..hq
MultiHead(X) = Concat(head1, …, headhq) WO   # (T, h_q·d_k) → (T, d_model)

Every head has its own keys and values here — that's Multi-Head Attention (MHA), the original 2017 design and still the highest-quality option. Before asking what it costs, there's a detail the equation hides completely: X isn't always the same shape. At inference time it's either a whole prompt or a single new token — and that distinction changes everything about how expensive this equation is to run.

Two phases: prefill and decode

Training sees a whole sequence at once and predicts every next-token in parallel. Inference is different: you're generating text you don't have yet, one token at a time. That splits a request into two very different regimes.

Diagram · Figure 3
One request, start to finish. Prefill runs once; decode loops, each step feeding its output back in as the next input.
Prefill whole prompt, T tokens processed in parallel → produces K,V for all T positions decodestep 11 new tokencontext T→T+1 decodestep 21 new tokencontext T+1→T+2 stopEOS token sampled,or max length hit sampled token fed back in as next input
Figure 3. Exactly one prefill pass, then a decode loop of length (output tokens). Each decode step depends on everything before it, so it can't be parallelized across steps the way prefill can be parallelized across tokens.

The steps in each phase, and what they cost

Toggle between the two phases below. Same equation, same weights — but the steps that make it up, and which ones dominate the wall-clock time, are different. Each step below is tagged with its actual latency: GPT-2 Medium (dmodel=1024, 24 layers, 16 heads of 64 dims) on an A100 80GB SXM, prefilling T=1,024 tokens or decoding one token at context length t=1,024memory-tagged steps read weights off HBM, compute-tagged steps are matmul arithmetic (derivations in the appendix; numbers are per layer — ×24 for the full model):

1 · Embed the whole prompt
All T prompt tokens → X ∈ (T, d_model) in one batch.
2 · Read WQ, WK, WV from HBM3.1 µs
Three (dmodel×dmodel) weight matrices — ~2.1 MB each in bf16 — stream in once, shared by every one of the T tokens about to use them.
3 · Project Q, K, V for all T positions at once20.7 µs
Three big matmuls, 2·T·dmodel² FLOPs each — GPU-friendly, fully parallel across tokens. The more tokens T share that one weight read, the more it pays for itself.
4 · Causal-masked attention over the whole T×T matrix6.9 µs
QKᵀ, scale, mask, softmax — O(T²·dk) FLOPs per head. Every token attends to everything ≤ its position, all computed together. Pure activation arithmetic, no weight traffic.
5 · Weighted sum with V, concat heads6.9 µs
Another O(T²·dk) matmul, same story as step 4 — activations in, activations out.
6 · Read WO, project back to dmodel7.9 µs
One more weight matrix off HBM (1.0 µs), one more matmul (6.9 µs) — the same read-then-compute shape as steps 2–3.
7 · FFN + residual, then the LM head
Take the last token's output row → logits → sample token #1.

Sum the modeled steps per layer and the split is telling: prefill spends ≈45.4 µs/layer, almost all of it in the matmuls (steps 3–6); a vanilla decode spends ≈18 µs/layer, and — strikingly — almost all of that is step 3, re-projecting K, V for the whole context, the same work redone every step. The two weight reads (steps 2 and 5, ≈4.1 µs together) and the new-token math are tiny beside it. Steps 2 and 6 in prefill (2 and 5 in decode) are memory operations — reading weights off HBM; everything else is arithmetic. Whether memory or arithmetic dominates comes down to one ratio — arithmetic intensity, FLOPs done per byte read. For a single (dmodel×dmodel) projection over T tokens:

FLOPs vs. bytes for one weight matrix, bf16 FLOPs = 2 T dmodel²     bytes = 2 dmodel² arithmetic intensity = FLOPs / bytes = T  — it scales directly with how many tokens share that one weight read

A GPU has its own fixed ratio — peak FLOPs/s divided by HBM bandwidth — sometimes called its ridge point. Roughly ~150 FLOPs/byte on an A100, ~300 on an H100 (bf16). Compare that constant to T above:

A100 ridge point
~150
FLOPs/byte (bf16)
H100 ridge point
~300
FLOPs/byte (bf16)
Prefill: T ≈ thousands
AI ≫ ridge
compute-bound
Decode: T = 1 new token
AI = 1
memory-bound

During prefill, arithmetic intensity blows past the ridge point — the matmuls (steps 3, 5, 6) are what take the time, and the GPU is busy doing math, not waiting on HBM. Decode should be the opposite: only one genuinely new token enters per step, so its own work is AI ≈ 1, far below the ridge point — memory-bound, with the weight reads (steps 2, 5) as the real cost and the new-token math finishing instantly. But vanilla decode never gets there, because step 3 drags all t old tokens back through WK, WV every step — an AI ≈ t recompute that puts decode right back in the compute-bound regime, doing prefill-scale math to reproduce values that never change. (Real numbers for a concrete model and GPU are in the appendix.)

So the open problem isn't step 4's arithmetic — it's step 3's recompute: rebuilding all t keys and values from scratch every step, even though nothing about the old tokens changed. Storing them instead of recomputing — and what that storage costs to keep and to read — is next.

05The KV cache — how the steps change, and what it saves

Decode step 4 needs K, V for every one of the t tokens generated so far, not just the new one. There are exactly two ways to get them: recompute them from scratch every step, or keep them around from when they were first computed. The difference between those two options is the entire reason the KV cache exists.

Option A: recompute — the vanilla path, and why it's a trap

Recomputing is exactly what the vanilla decode in the last section does: step 3 re-projects K,V for all t tokens at every single decode step — effectively a fresh mini-prefill of length t, every step. That's 2·t·dmodel² FLOPs for the K,V projections alone at step t, and summing that over an N-token generation costs O(N²) instead of O(N). Worse, decode is already memory-bandwidth-bound (previous section) — burning compute on work whose answer never changes is pure waste layered on top of an already slow step. Concretely, on the same GPT-2 Medium / A100 setup, re-projecting K,V for t=1,024 prior tokens costs ≈13.8 µs/layer — every single decode step, forever.

Option B is what every real system does: keep the K and V matrices from previous steps around instead of recomputing them — the KV cache. Concretely, per layer, per head, the cache is two growing matrices:

Cachelayer,head = { K ∈ (t, dk),   V ∈ (t, dv) }   # t = tokens seen so far, grows by 1 row per decode step

How the steps change

Two things happen. First, step 3 collapses: with the old K,V already saved, there's nothing to re-project, so it drops from the t-token recompute (≈13.8 µs) back to just the one new row (≈0.02 µs). Second, decode step 4 splits into two cheap moves instead of one expensive recompute (same setup, still t=1,024):

4a · Append knew, vnew to the cache~0 µs
The K,V this step already computed in step 3 get written in — cache grows by exactly one row per layer, per KV head. O(1) work.
4b · Attend qnew over the cache2.1 µs
qnew · Kᵀ against every cached key, softmax, weighted sum of every cached value — no projection recomputation at all, just a read of t rows off HBM (≈2.06 µs) plus the same tiny attention arithmetic from step 4 (≈0.01 µs).
Recompute (Option A)
13.8 µs
per layer, at t=1,024
Cached (Option B)
2.1 µs
per layer, at t=1,024
Speedup
~6.6×
and growing with t
Step 4 only, ×24 layers
330 µs → 50 µs
not the full decode step

That gap isn't fixed — it widens every subsequent token, since recompute's cost grows linearly with t while the cache's new-token projection (step 3) stays flat at O(1); only its cheap read (step 4b) grows with t. That's the O(N²)-vs-O(N) difference from a moment ago, in real microseconds (full derivation in the appendix). Across the whole step, that drops a decode layer from the vanilla ≈18 µs to ≈6.2 µs, and — just as importantly — moves the bottleneck off the recompute matmul and onto memory traffic, restoring the AI ≈ 1, memory-bound decode the previous section said we should have. Prefill changes too, in the mirror-image way: its step 3 (project Q,K,V for all T positions) now also writes all T of those K,V rows into the cache in one shot — that's the entire point of prefill from the cache's perspective, seeding it before decode ever runs.

What it saves

Total cache memory, summed over every layer and every head, in bytes:

bytes_per_param × 2 (K and V) × layers × kv_heads × d_k × T bytes = 2 · L · hkv · dk · T · b

Against the O(N²) recompute cost above, reading this cache is O(N) over a generation — each step reads, at most, t rows once, never redoing earlier work. With plain MHA, hkv = hq — every query head drags its own full K,V along — and that O(N) win comes with two costs of its own:

06Grouped-Query Attention

The cache-size formula above has exactly one term you can shrink without touching sequence length or model width: hkv. The question is how many query heads should share one K/V head — and that single design choice spans a whole family of attention variants.

The spectrum: MHA → GQA → MQA

The math is a one-line change to multi-head attention: query head i no longer uses its own Ki, Vi — it looks up which group it's in and uses that group's shared K, V:

g(i) = ⌊ i / (hq/hkv) ⌋   — which of the h_kv groups head i belongs to
headi = Attention( X WQi,   X WKg(i),   X WVg(i) ) query is still per-head; key & value are per-group

What changes in the steps

Nothing about how many query heads exist changes — GQA never touches representational capacity on the query side, which is a big part of why it recovers so much of MHA's quality. What changes is narrower — same GPT-2 Medium / A100 setup as before, now with hkv=4 (a 4:1 ratio, matching the Qwen3.5 example earlier):

Everything else — the scores, the scaling, the causal mask, the softmax, the weighted sum (§3) — is identical inside each head, and costs exactly what it did under MHA (steps 4/5 in prefill, the attend-arithmetic half of step 4 in decode): those still scan all hq=16 query heads, GQA or not.

What it saves

Both weight and cache savings scale with the same ratio, hq/hkv — but neither hits that full ratio in the per-token total, because steps that don't touch K,V (Q's projection, the attention arithmetic, WO) don't shrink at all:

Decode, per layer
6.2 → 3.1 µs
MHA → GQA 4:1, ~2.0×
Decode, full model
149 → 75 µs
per generated token
Prefill, per layer
45.4 → 33.5 µs
MHA → GQA 4:1, ~1.4×
Prefill, full model
1.09 → 0.80 ms
for the 1,024-token prompt

Drag the slider below to see the actual head wiring (full arithmetic for every number above is in the appendix):

Interactive · Figure 4
8 query heads (blue) sharing hkv key/value heads (teal). Lines show which query heads read which K,V.
GQA · group size 4 · ratio 8:2
top row = query heads (always 8)  ·  bottom row = KV heads (the slider)
Figure 4. h_kv = 1 collapses every connector onto one KV head (MQA); h_kv = 8 gives each query head its own line (MHA, no sharing at all).
Why this doesn't wreck quality

Sharing K,V across a group means those query heads can no longer attend to independently chosen positions within the group — they're all scored against the same keys. In practice this costs surprisingly little: with a moderate group size (4–8), GQA lands within a fraction of a point of MHA on downstream benchmarks while cutting the cache by the same factor, which is why it — or MQA outright — is the default in nearly every serving-oriented model released since 2023.

07References

  1. Attention Is All You Need. Vaswani et al. · arXiv 1706.03762 (2017) — the original transformer and multi-head attention.
  2. Fast Transformer Decoding: One Write-Head is All You Need. Noam Shazeer · arXiv 1911.02150 (2019) — introduces Multi-Query Attention.
  3. GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints. Ainslie, Lee-Thorp, de Jong et al. · arXiv 2305.13245 (2023).
  4. Llama 2: Open Foundation and Fine-Tuned Chat Models. Touvron et al. · arXiv 2307.09288 — GQA at 8:1 in the 70B model.
  5. Inside Qwen3.5-4B: Architecture & Forward Pass. Companion piece — GQA 4:1 in a real production config, plus Gated DeltaNet.

AAppendix: concrete numbers (GPT-2 Medium on an A100)

Optional aside — none of the reasoning in §4 depends on this, but it's satisfying to see real numbers attached to the ridge-point argument. GPT-2 Medium has dmodel=1024, 24 layers, 16 heads of 64 dims each, ~355M parameters — same dmodel as the running example throughout this piece, so the weight-matrix math carries over unchanged. Run it on an NVIDIA A100 80GB SXM: 312 TFLOPS bf16 tensor-core compute, 2,039 GB/s HBM bandwidth (both published spec-sheet numbers) — a ridge point of 312e12 / 2.039e12 ≈ 153 FLOPs/byte, right where the earlier ~150 estimate landed.

Per layer, four (1024×1024) bf16 weight matrices — WQ, WK, WV, WO bytes per matrix = 1024 · 1024 · 2 = 2,097,152 2.1 MB    × 4 matrices = 8.4 MB / layer × 24 layers = ≈ 201 MB, just for attention's Q/K/V/O projections

Two regimes, same weights, same GPU, wildly different bottleneck — the QKVO projection matmuls alone, per layer:

Decode, T=1 — read 8.4MB
4.1 µs
HBM-bound step
Decode, T=1 — compute
0.03 µs
~150× faster than the read
Prefill, T=1024 — compute
27.5 µs
compute-bound step
Prefill, T=1024 — read
4.1 µs
same weights, ~6.7× faster

Sum those over all 24 layers and the split gets stark: decoding one token spends ≈99 µs just streaming Q/K/V/O weights off HBM, while the matmul arithmetic for that same step finishes in well under a microsecond. Prefilling a 1,024-token prompt spends ≈661 µs on QKVO matmul arithmetic across those 24 layers, versus only ≈99 µs reading the identical weights once for the whole batch. Same model, same weights, same GPU — the bottleneck simply flips depending on how many tokens ride along with each weight read. That's the QKVO projection matmuls only (steps 2, 3, 6 in §4's prefill list); the rest of this appendix fills in the remaining steps — attention itself, the KV cache, and GQA — to get the full per-step numbers shown throughout the piece.

The attention matmuls (steps 4–5): QKᵀ and the weighted sum

Unlike the QKVO projections, these two steps touch no weight matrices at all — just activations already in SRAM — so their cost is pure FLOPs, summed across all hq=16 heads:

QKᵀ (scores) and the weighted sum (·V) — same FLOP count, one matmul each FLOPs = 2 hq T² dk = 2 · 16 · 1024² · 64 = 2,147,483,648 2.15 GFLOPs time = 2.15e9 / 312e12 ≈ 6.9 µs per step, per layer — matches the badges in §4

Add those two steps to the QKVO projection numbers above and the full per-layer prefill cost (steps 2–6) is 3.1 + 20.7 + 6.9 + 6.9 + 7.9 ≈ 45.4 µs, ×24 layers ≈ 1.09 ms for the entire 1,024-token prompt — this is the number the piece calls the full prefill total, larger than the ≈661 µs projection-only figure above because it also counts the T² attention arithmetic.

Decode's attend step, and the KV cache read

At T=1 the QKᵀ / weighted-sum arithmetic above shrinks to almost nothing — 4 hq t dk FLOPs (one query row against t keys, both directions), which at t=1,024 is only 4·16·1024·64 ≈ 4.19M FLOPs ≈ 0.013 µs per layer. What actually costs time is fetching the t rows of K,V those FLOPs run against:

Cache read, per layer, MHA (h_kv = h_q = 16) bytes = 2 · hkv · dk · t · 2 = 2 · 16 · 64 · 1024 · 2 = 4,194,304 4.0 MB time = 4.19e6 / 2.039e12 ≈ 2.06 µs  +  0.013 µs compute ≈ 2.1 µs — the 4b badge in §5

Compare that to recomputing K,V for all 1,024 prior tokens instead of reading them back:

Recompute, per layer — a mini-prefill of length t, K and V only FLOPs = 2 · t · dmodel² · 2 = 2 · 1024 · 1024² · 2 = 4,294,967,296 4.29 GFLOPs time = 4.29e9 / 312e12 ≈ 13.8 µs — the §5 recompute figure, ~6.6× slower than the 2.1 µs cache read

Full decode total (steps 2, 3, 4b, 5) with a cache: 3.1 + 0.02 + 2.1 + 1.0 ≈ 6.2 µs/layer, ×24 ≈ 149 µs per generated token at this context length — versus 3.1 + 0.02 + 13.8 + 1.0 ≈ 17.9 µs/layer, ×24 ≈ 430 µs, if every step recomputed K,V from scratch instead.

GQA (h_kv = 4): what actually shrinks

Only WK, WV, and the cache shrink — they go from hq=16 physical heads to hkv=4, a 4× reduction. WQ, WO, and the attention arithmetic itself (steps 4/5 in prefill, the compute half of step 4 in decode) are untouched, since the query side and the score/softmax/weighted-sum math still run over all 16 query heads:

Step 2 read, prefill or decode (bytes independent of T) WQ = 2.1 MB  (unchanged)    WK, WV = dmodel · hkv dk · 2 = 1024 · 256 · 2 = 524,288 0.5 MB each total = 2.1 + 0.5 + 0.5 = 3.1 MB → 1.54 µs (was 3.08 µs under MHA)
Step 4b cache read, decode, t=1,024 bytes = 2 · 4 · 64 · 1024 · 2 = 1,048,576 1.0 MB time = 1.05e6 / 2.039e12 ≈ 0.51 µs  +  0.013 µs compute ≈ 0.53 µs (was 2.1 µs under MHA)

Step 3 (project Q,K,V) also drops a little, since it's now projecting into a narrower K,V output: FLOPs = 2T dmodel(dmodel + 2hkvdk) instead of 2T · 3dmodel², which at T=1,024 works out to ≈10.3 µs (prefill) instead of 20.7 µs. Rolling all of that up:

StepMHAGQA (4:1)
Prefill, per layer (steps 2–6)45.4 µs33.5 µs
Prefill, full 24 layers≈1.09 ms≈0.80 ms
Decode, per layer (steps 2,3,4b,5)6.2 µs3.1 µs
Decode, full 24 layers≈149 µs≈75 µs

The full-model ratios (~1.4× prefill, ~2.0× decode) land well under the raw hq/hkv=4× cache-size reduction from §5 — because WQ, WO, and the attention arithmetic dilute the win. The steps that scale purely with hkv (the cache read, step 4b) get close to the full 4×; the steps that don't (everything touching Q or O) don't move at all.