Search Tech Journey

Find topics, journeys and posts

6-month learning plan108 / 130
back to blog
llmadvanced 55m read

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.

LLMsM13 · NLP & Transformers· Session 108 of 130 90 min

🎯 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.

You will be able to
  • 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

Interpolating Google search results
🌍 Real world

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.

💻 Code world

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

If you can answer these on a whiteboard, you understand attention
  • 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

1assumption
Assume Q and K entries are independent, zero mean, unit variance

Reasonable — that's what Xavier/Kaiming init aims for after each layer.

2step 1
Compute variance of one dot product q · k

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.

3step 2
So raw scores have std = sqrt(d_k)

For d_k=64, scores have std ≈ 8. That's HUGE — softmax input with std 8 is basically saturating to one-hot.

4step 3
Divide by sqrt(d_k) to get variance back to 1

scores / sqrt(d_k) → variance 1. Softmax now sees moderate values, produces smooth distributions, gradients flow.

5verify
Empirically confirmed by Vaswani et al.

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

Linear normalisation (divide by sum)

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
Softmax

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

∂L/∂output
Comes from the next layer. Shape [T, d_v]. Starting point for the backward pass.
start
∂L/∂V = weights^T @ ∂L/∂output
Gradient wrt V. Shape [T, d_v]. The weights spread the output gradient back to each value token.
V grad
∂L/∂weights = ∂L/∂output @ V^T
Gradient wrt the attention weights matrix. Shape [T, T].
w grad
∂L/∂scores (through softmax)
For softmax row w = softmax(s), the Jacobian is diag(w) - w·w^T. Then multiply by ∂L/∂weights. This is O(T²) but standard.
softmax back
∂L/∂Q = (∂L/∂scores) @ K / √d_k
Gradient wrt Q. Same shape as Q: [T, d_k].
Q grad
∂L/∂K = (∂L/∂scores)^T @ Q / √d_k
Gradient wrt K. Same shape as K: [T, d_k].
K grad
∂L/∂W_q = X^T @ ∂L/∂Q
Finally propagate through the linear projections back to the input X and the weight matrices W_q, W_k, W_v.
params

Common misconception
✗ What most people think

"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."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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))     # True
From first principles
Start with the question

Why is the causal mask implemented as adding −∞ before the softmax, rather than zeroing weights after it?

  1. 1
    A 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
  2. 2
    The output is Σj aijvj, so the requirement is exactly aij = 0 for j > i.
    forced by · attention can only mix values, so zero weight is zero influence
  3. 3
    Zeroing after softmax gives aij=0 but 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
  4. 4
    A 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
  5. 5
    Adding −∞ to the masked logits instead makes e−∞=0 inside 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

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.

Mental modelTwo circuits: where to look, and what to bring back

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.
🔔 Fires when you see

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.

The tradeoff

Additive (Bahdanau) attention scores with a small MLP; dot-product attention scores with a matrix multiply. Which, and why did the field converge?

Additive attention
+ you gain a learned nonlinearity in the scoring function, so it can express similarity criteria a bilinear form cannot; and it is naturally well-scaled, needing no √d correction
− you pay scoring every (i,j) pair requires an MLP evaluation per pair — many small ops instead of one big matmul, which is exactly the shape modern accelerators are worst at
pick when tiny sequence lengths where the T² term is negligible and expressiveness per parameter matters more than throughput
Scaled dot-product attention
+ you gain the entire score matrix is one QKT GEMM, saturating tensor cores; and the resulting structure (a bilinear form) is what makes fused kernels like FlashAttention possible at all
− you pay similarity is restricted to a bilinear function of the two projections, so expressiveness per head is lower — recovered by using many heads and many layers rather than a richer scorer
pick when essentially always, once sequence length or model size is large enough that hardware utilisation dominates
What a senior engineer actually does

The 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

scores = Q @ K.transpose(-2, -1)
Batched matmul. transpose(-2, -1) swaps the LAST two dims — turns [B, T, D] into [B, D, T]. Result [B, T, T].
matmul
scores / math.sqrt(d_k)
The magic constant. Try turning it off (scale=False) and observe softmax saturating as d_k grows.
scale
scores.masked_fill(mask, -inf)
Elementwise fill wherever mask is True. Post-softmax, those positions become exactly 0.
mask
F.softmax(scores, dim=-1)
dim=-1 is CRITICAL. You want to normalise ACROSS keys (per query), not across queries (per key). Off-by-one dim is the #1 attention bug.
softmax
F.scaled_dot_product_attention(Q, K, V)
The PyTorch primitive introduced in 2.0. On CUDA it automatically uses FlashAttention — much faster + memory-efficient. Prefer this in production.
primitive
demonstrate_scaling_matters()
Shows max attention weight for scaled vs unscaled as d_k grows. Unscaled at d_k=1024 → max weight approaches 1.0 (saturated).
demo
Try itProve attention with wrong softmax dim silently gives garbage
# 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.

💡 Hint · Change `F.softmax(scores, dim=-1)` to `F.softmax(scores, dim=-2)`. Rerun `verify_equivalence`. Now scratch and builtin will disagree by orders of magnitude — because softmax across queries makes no semantic sense.

(d) Production reality · 15 min

War story Tri Dao · FlashAttention· 2022all major LLMs
🔥 What broke

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.

🧯 The fix
Tri Dao's FlashAttention (2022) computes attention in tiles that fit in SRAM (fast on-chip memory), never materialising the full T×T matrix in HBM. Mathematically identical output, but 2-4× faster and O(T) memory instead of O(T²). PyTorch 2.0 shipped it as the default backend for `F.scaled_dot_product_attention`. Every modern LLM (LLaMA, GPT-4, Claude) uses FlashAttention or a variant.
🎓 Lesson to steal
The theoretical formula is not the same as the practical algorithm. FlashAttention proves that memory-hierarchy-aware algorithms can be dramatically faster with zero accuracy loss. Read Tri Dao's paper — it's a masterclass in systems thinking.
Post-mortem
War story Google · T5 numerical stability· 2019all T5 checkpoints
🔥 What broke
Google's T5 team trained large Transformers in bfloat16 precision. They discovered that raw dot-product scores could OVERFLOW in bfloat16 (max ~3.4e38, but any exp of a large positive number saturates). Softmax then produced NaNs, and training silently corrupted.
🧯 The fix
Added a small numerical trick: subtract the max score PER ROW before applying exp. Mathematically equivalent (softmax is shift-invariant) but keeps exp arguments in a safe range. This 'max-subtracted softmax' is now default in every attention implementation.
🎓 Lesson to steal
Mixed-precision training exposes numerical issues that fp32 hides. Attention with bfloat16 needs max-subtraction + occasionally fp32 accumulation for numerical stability. This is why F.scaled_dot_product_attention has an `enable_math` flag.
Post-mortem
War story Meta · LLaMA-2 grouped-query attention· 202370B params
🔥 What broke
LLaMA-2 34B and 70B were memory-bound during inference — the KV cache (stored K and V for all previous tokens) grew linearly with sequence length AND heads. For 70B with 64 heads at 4K context, KV cache alone was ~40 GB per request. Serving multiple users hit a wall.
🧯 The fix
Grouped-Query Attention (GQA): reduce the number of K and V heads while keeping Q heads at the full count. Multiple Q heads share the same K/V. Instead of 64 K/V heads, use 8. KV cache shrinks 8×. Accuracy hit is minimal (\\<1% on standard benchmarks). LLaMA-2 70B, Mistral, and most 2024 LLMs use GQA.
🎓 Lesson to steal
The Q, K, V structure has knobs beyond 'multi-head or single-head.' GQA and MQA (multi-query) trade tiny accuracy for huge memory savings — critical for serving.
Post-mortem

Where this shows up in the rest of the plan

Every attention variant is a modification of the Q/K/V formula
S109 · Multi-Head Attention
Run N of these attention heads in parallel, each with its own Q/K/V projections.
S110 · Positional Encoding
How to inject position info into Q and K so attention isn't permutation-invariant.
S111 · Full Transformer
Attention + FFN + residual + layernorm, stacked N times.
S115 · Long Context
Sparse attention, sliding-window, and FlashAttention — all modify the Q·K^T step.
S127 · CLIP Cross-Attention
Same math, but Q from images and K/V from text (or vice versa).
S068 · Model Serving
KV caching, GQA, and paged attention all optimise the same Q/K/V pipeline for inference.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three without notes, redo the session:

  1. Write the attention formula. Explain each symbol.
  2. Why sqrt(d_k)? Give the variance argument.
  3. 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.