S108 · Q/K/V Math — Scaled Dot-Product Attention Derived
The Transformer's atomic operation, derived from scratch. Where Q, K, V come from, why we scale by √d_k, and how backprop through attention actually works.
🎯 Derive scaled dot-product attention from information-retrieval first principles, prove why √d_k scaling is the correct constant, and hand-trace one backward pass.
Why this session exists
Every LLM paper you'll ever read assumes you can read Attention(Q, K, V) = softmax(QK^T/√d_k)V and immediately know what it computes, why each part is there, and how gradients flow through it. Session S107 gave you the intuition; this one gives you the math. After this you'll be able to derive attention on a whiteboard, argue about why FlashAttention works, and read the Transformer paper's Section 3.2.1 without a translator.
- Derive the attention formula from a 'soft dictionary lookup' loss.
- Prove why the scaling factor is sqrt(d_k) and not d_k or 1.
- Compute the shapes of Q, K, V, and attention output for any (B, T, d) input.
- Hand-trace the forward pass for a 3-token, 2-dim toy example.
- Describe how gradients flow backward through softmax and matmul in attention.
Prerequisites
- S107 · Attention Intuition — the metaphor comes first.
- S099 · Backpropagation — you need Jacobians and the chain rule.
- S097 · Linear Algebra for ML — matrix multiplication, transpose, dot products, norms.
- S098 · Probability & Softmax — softmax and its gradient.
(a) Intuition · 5 min
You Google "how to fix a leaking tap." The engine doesn't return exactly one webpage — it computes a relevance score for millions of pages, ranks them, and shows you the top 10. The final answer you form is a mental weighted average of those 10 pages, weighted by how relevant each felt.
Now imagine returning a SOFT AVERAGE of every webpage in the index, where each page contributes proportional to its relevance score. That's attention.
Attention computes: `output = Σ (relevance(query, doc_i) · content(doc_i))`. The 'relevance' function is a dot product between projected vectors. The 'content' is a projected vector. Softmax turns raw scores into weights summing to 1.
Q, K, V are the three vectors doing three jobs: Q = 'what I want to know,' K = 'what each item advertises,' V = 'what each item actually contains.' Three separate linear projections of the SAME input give the model three roles per token.
The four questions this session answers
- Why three separate matrices W_q, W_k, W_v — why not just one W?
- Why divide the scores by sqrt(d_k) specifically? Why not d_k, or leave them raw?
- Where do softmax and the exponential come from — why not a linear normalisation?
- How does the gradient flow backward through attention? What breaks if softmax saturates?
(b) Visual walkthrough · 15 min
The full formula, dissected
Why sqrt(d_k) — the variance derivation
Reasonable — that's what Xavier/Kaiming init aims for after each layer.
q · k = Σ q_i · k_i, sum of d_k independent zero-mean products. Each product has variance 1 (independence + unit variances). Sum of d_k independent zero-mean unit-variance = variance d_k.
For d_k=64, scores have std ≈ 8. That's HUGE — softmax input with std 8 is basically saturating to one-hot.
scores / sqrt(d_k) → variance 1. Softmax now sees moderate values, produces smooth distributions, gradients flow.
The paper's footnote 4 does exactly this argument. If you skip the scaling, training diverges immediately for any nontrivial d_k.
Why softmax and not linear normalisation
The wrong choice
- Requires all scores positive
- No sharpening — differences preserved but never amplified
- Doesn't give a proper distribution if scores can be negative
- Not differentiable everywhere
Why it's the right choice
- Handles any real-valued scores
- Exponential SHARPENS — big scores get much bigger weights
- Always produces a valid probability distribution
- Differentiable everywhere, gradient is simple
Hand trace: a 3-token, 2-dim toy
Backward pass — the gradient anatomy
How gradients flow through attention
"Q, K and V are three fundamentally different things the model computes. The separation must encode something semantic — query vs key is like question vs answer."
They are three learned linear projections of the same input vector. The asymmetry that matters is structural, not semantic: Q and K only ever meet inside a bilinear form, so what the model actually learns is the single matrix WQWKT. V is genuinely separate because it lives on the output side — it is the only part whose content survives into the residual stream.
Because the database analogy that makes attention teachable also implies three independent objects, and the analogy is never revisited. Once you write the score as xiT(WQWKT)xj it is obvious that Q and K are not separately identifiable — you can rotate one and inverse-rotate the other and change nothing. That is why interpretability work analyses the QK circuit and the OV circuit, not Q, K, V individually.
Rotate Q and K by inverse orthogonal matrices and confirm the attention pattern is unchanged:
import torch
d = 16
X = torch.randn(5, d)
Wq, Wk = torch.randn(d, d), torch.randn(d, d)
R, _ = torch.linalg.qr(torch.randn(d, d)) # orthogonal
def attn(Wq, Wk):
return torch.softmax((X@Wq) @ (X@Wk).T / d**0.5, -1)
A1 = attn(Wq, Wk)
A2 = attn(Wq @ R, Wk @ R) # same bilinear form
print(torch.allclose(A1, A2, atol=1e-5)) # TrueWhy is the causal mask implemented as adding −∞ before the softmax, rather than zeroing weights after it?
- 1A causal model must guarantee that position i's output depends on no input at position j > i.forced by · otherwise training-time teacher forcing leaks the answer and the model degenerates to copying the next token
- 2The output is
Σj aijvj, so the requirement is exactlyaij = 0for j > i.forced by · attention can only mix values, so zero weight is zero influence - 3Zeroing after softmax gives
aij=0but leaves the remaining weights summing to less than 1, and by a different amount for every row.forced by · softmax normalised over the full row; removing terms afterwards breaks the normalisation non-uniformly - 4A row whose weights sum to s produces an output scaled by roughly s, so early positions (which lose the most mass) get systematically smaller activations than late ones — a position-dependent scale the model must waste capacity correcting.forced by · the residual stream and LayerNorm downstream see magnitude, and magnitude now encodes position
- 5Adding −∞ to the masked logits instead makes
e−∞=0inside the normaliser, so the denominator only ever sums over legal positions and every row sums to exactly 1.forced by · masking before normalisation makes the constraint part of the distribution rather than a correction to it
Therefore the mask must be pre-softmax: it is the only placement that keeps the output a proper convex combination of legal values.
And note what this predicts: use a large finite number instead of −∞ and in fp16 it can round into the representable range, producing tiny but non-zero future leakage — which shows up as a model that is mysteriously better in training than at inference. It also predicts that the first row of the attention matrix attends only to itself with weight exactly 1, regardless of what the query says. That degenerate row is the origin of the "attention sink" on token 0.
An attention head is two independent circuits sharing one softmax. The QK circuit (WQWKT) reads the residual stream and decides which position to route from. The OV circuit (WVWO) decides what gets written back once routing is fixed.
Everything a head does factorises into those two questions. If a head behaves strangely, ask which circuit is responsible before touching anything else.
- Score =
QKT/√dk. The scale is variance normalisation at initialisation, nothing more. - Q and K are only identifiable as a product; V and the output projection are only identifiable as a product. Four matrices, two circuits.
- Shapes: Q is (T,dk), K is (T,dk), V is (T,dv); scores are (T,T); output is (T,dv). dk and dv need not be equal — only the score dimension must match.
- In cross-attention, Q comes from the decoder and K,V from the encoder. Sequence lengths then differ and the score matrix is (Tdec, Tenc) — rectangular, which is a good shape-bug detector.
Fire this model the moment you see: a shape mismatch in an attention implementation · a debate about whether Q and K should share weights · a KV-cache design question (note only K and V are cached — Q is never reused) · an interpretability claim about "what a head does" · future leakage in a causal model.
Additive (Bahdanau) attention scores with a small MLP; dot-product attention scores with a matrix multiply. Which, and why did the field converge?
√d correctionQKT GEMM, saturating tensor cores; and the resulting structure (a bilinear form) is what makes fused kernels like FlashAttention possible at allThe field converged on dot-product not because it scores better but because it turns attention into matrix multiplication, and matrix multiplication is the one operation GPUs do at near-peak efficiency. The lost expressiveness is bought back with more heads and more depth, which are also matmuls.
This is the recurring lesson of the last decade of deep learning: an architecture that is slightly worse per parameter but maps cleanly onto dense linear algebra will beat a more expressive one, because it can be scaled. Judge new architectural ideas by asking what shape of kernel they produce, not only by their inductive bias.
(c) Hands-on · 25 min
Implement scaled dot-product attention two ways — once explicitly, once with F.scaled_dot_product_attention. Verify they agree, benchmark speed, and inspect the attention weights.
# qkv_math.py — scaled dot-product attention, from scratch AND with the PyTorch primitive.
# Also verifies the sqrt(d_k) scaling by showing what happens without it.
import math
import time
import torch
import torch.nn as nn
import torch.nn.functional as F
def attention_scratch(
Q: torch.Tensor, K: torch.Tensor, V: torch.Tensor,
mask: torch.Tensor | None = None,
scale: bool = True,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Scaled dot-product attention — every step explicit."""
d_k = Q.size(-1)
scores = Q @ K.transpose(-2, -1) # [B, T, T]
if scale:
scores = scores / math.sqrt(d_k)
if mask is not None:
scores = scores.masked_fill(mask, float("-inf"))
weights = F.softmax(scores, dim=-1) # [B, T, T]
output = weights @ V # [B, T, d_v]
return output, weights
def attention_builtin(
Q: torch.Tensor, K: torch.Tensor, V: torch.Tensor,
is_causal: bool = False,
) -> torch.Tensor:
"""The optimised PyTorch primitive — uses FlashAttention when available."""
return F.scaled_dot_product_attention(Q, K, V, is_causal=is_causal)
def verify_equivalence() -> None:
torch.manual_seed(0)
B, T, D = 2, 8, 16
X = torch.randn(B, T, D)
W_q = torch.randn(D, D) * 0.1
W_k = torch.randn(D, D) * 0.1
W_v = torch.randn(D, D) * 0.1
Q, K, V = X @ W_q, X @ W_k, X @ W_v
out_scratch, weights = attention_scratch(Q, K, V)
out_builtin = attention_builtin(Q, K, V)
diff = (out_scratch - out_builtin).abs().max().item()
print(f"Max diff (scratch vs builtin): {diff:.2e}")
print(f" → should be ~1e-6 (float precision noise)")
print(f"\nAttention weights row 0: {weights[0, 0].tolist()}")
print(f" → sums to: {weights[0, 0].sum().item():.4f} (should be 1.0)")
def demonstrate_scaling_matters() -> None:
"""Show the softmax saturation without sqrt(d_k) scaling."""
torch.manual_seed(0)
B, T = 1, 8
for D in [16, 64, 256, 1024]:
X = torch.randn(B, T, D)
Q = K = V = X
_, w_scaled = attention_scratch(Q, K, V, scale=True)
_, w_unscaled = attention_scratch(Q, K, V, scale=False)
max_scaled = w_scaled[0, 0].max().item()
max_unscaled = w_unscaled[0, 0].max().item()
print(f"d_k={D:4d} max_attn (scaled)={max_scaled:.3f} (unscaled)={max_unscaled:.3f}")
def benchmark() -> None:
torch.manual_seed(0)
B, T, D = 8, 512, 128
Q = torch.randn(B, T, D)
K = torch.randn(B, T, D)
V = torch.randn(B, T, D)
# Warm up
_ = attention_scratch(Q, K, V)
_ = attention_builtin(Q, K, V)
t0 = time.time()
for _ in range(20):
_ = attention_scratch(Q, K, V)
t_scratch = time.time() - t0
t0 = time.time()
for _ in range(20):
_ = attention_builtin(Q, K, V)
t_builtin = time.time() - t0
print(f"\nBenchmark (B={B}, T={T}, D={D}, 20 iters):")
print(f" scratch: {t_scratch*1000:.1f} ms")
print(f" builtin: {t_builtin*1000:.1f} ms ({t_scratch/t_builtin:.2f}× faster)")
def visualise_attention() -> None:
"""Print an attention matrix that reveals the structure."""
torch.manual_seed(42)
T, D = 6, 8
# Construct Q, K such that token i has high similarity to token i-1 (like a shift op).
X = torch.randn(T, D)
Q = X.clone()
K = torch.roll(X, shifts=1, dims=0) # K_i = X_{i-1}, so Q_i · K_i is high for i>0
V = X.clone()
_, weights = attention_scratch(Q.unsqueeze(0), K.unsqueeze(0), V.unsqueeze(0))
w = weights[0].numpy()
print("\nSynthetic 'shift by 1' attention pattern:")
print(" " + " ".join(f"k{i:d}" for i in range(T)))
for i in range(T):
row = " ".join(f"{v:.2f}" for v in w[i])
print(f"q{i:d} {row}")
print("(Notice heavy weight on the diagonal-shifted-by-1 position.)")
if __name__ == "__main__":
verify_equivalence()
print("\n--- Softmax saturation without scaling ---")
demonstrate_scaling_matters()
benchmark()
visualise_attention()Anatomy of the script
What the interesting lines do
# In attention_scratch, replace:
weights = F.softmax(scores, dim=-1)
# with:
weights = F.softmax(scores, dim=-2) # wrong dim!Rerun. Max diff explodes to ~1.0 or more. Lesson: softmax dim in attention is not optional — dim=-1 (across keys, per query) is the ONLY correct choice.
(d) Production reality · 15 min
Standard attention materialises the T×T attention matrix in GPU HBM (high-bandwidth memory). For T=8192, that's 8192² = 67M floats per head per layer — many gigabytes total for a batch. On A100 GPUs, this memory bandwidth was the bottleneck, not compute.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- Write the attention formula. Explain each symbol.
- Why sqrt(d_k)? Give the variance argument.
- How do gradients flow backward through attention, and what breaks if softmax saturates?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.