Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°08

Single-Head Attention

Medium AI EngineeringTransformersLLM

The one operation at the heart of every Transformer: scaled dot-product attention. Given queries, keys, and values, let each query pull a weighted blend of the values — weighted by how much its query matches each key. No numpy, just the math.

The problem

Implement scaled dot-product attention. You are given three matrices as lists of rows: Q (queries), K (keys) — each row length d — and V (values). For each query row, compute a score against every key as their dot product divided by √d, softmax those scores into weights that sum to 1, then return the weighted sum of the value rows. Return a matrix with one row per query and one column per value dimension: softmax(Q·Kᵀ / √d) · V.

SOLVE IT YOURSELF

Your turn — write it

Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.

YOUR TASK

Implement attention(Q, K, V). For each query: score it against every key (dot product ÷ √d), softmax the scores, and return the weighted sum of the value rows.

CPython · WebAssembly
Examples and constraints 4 examples
EXAMPLE 1
Input Q=[[1]], K=[[1]], V=[[5, 6]]
Output [[5.0, 6.0]]
one key → weight 1 → the value itself
EXAMPLE 2
Input Q=[[1,5]], K=[[0,0],[0,0]], V=[[1,2],[3,4]]
Output [[2.0, 3.0]]
equal keys → uniform weights → the mean of V
EXAMPLE 3
Input Q=[[10,0]], K=[[1,0],[0,1]], V=[[1,1],[2,2]]
Output [[1.0008486049627112, 1.0008486049627112]]
query matches key 0 → about 99.9% of the weight on V[0]
EXAMPLE 4
Input Q=[[1000]], K=[[1],[0]], V=[[1],[2]]
Output [[1.0]]
edge case: a huge score would overflow exp without subtracting the row max first
CONSTRAINTS
  • Scale the scores by 1 / √d, where d = len(Q[0]) is the key/query dimension.
  • Use a numerically stable softmax (subtract the row max before exp).
  • Each output row is a convex combination of the value rows — the attention weights sum to 1.
  • Pyodide has no numpy — use the math stdlib and plain lists only.
Hints 4 ideas
HINTS — 4 IDEAS
  1. For query row q and key row k, the raw score is their dot product: sum(q[t] * k[t]).
  2. Divide every score by √d with d = len(Q[0]) — that is the "scaled" in scaled dot-product attention.
  3. Softmax each query row of scores into weights (subtract the max first for stability).
  4. Output row = Σ_j weight[j] * V[j] — a weighted blend of the value vectors, one entry per value column.
Approach, complexity & discussion — open after you solve

Start from brute force

The textbook matrix version materializes everything: build the full score matrix S = Q·Kᵀ / √d, run exp on every entry and divide each row by its sum, then multiply the weights by V. It is the formula written out, so on small, tame inputs it is right, at O(n · m · (d + d_v)) time for n queries and m keys and O(n · m) space for S. Two things break it. Memory: for self-attention over 100,000 tokens, S alone holds 10¹⁰ scores. And plain exp overflows: math.exp(1000) raises OverflowError in Python, while JavaScript returns Infinity and the weights come out NaN.

The approach

Work one query at a time and stabilize the softmax. The reference attention sets d = len(Q[0]) and scale = math.sqrt(d), then for each query row q computes scores, one dot product per key divided by scale. It subtracts the row maximum m before exponentiating: softmax is unchanged when every score shifts by the same amount, the largest term becomes exp(0) = 1 so nothing overflows, and the sum z is at least 1 so the division is safe. The weights w = [e / z for e in exps] are non-negative and sum to 1.

Each output entry is sum(w[j] * V[j][c] for j in range(len(V))), a convex blend of the value rows, so every output column lies between that column’s smallest and largest value. The output width is len(V[0]), which need not equal d. Dividing by √d matters because the dot product of two vectors with independent unit-variance entries has variance d; scaling brings it back to 1 and keeps the softmax out of saturation. The TypeScript version runs the same three steps per row.

Walk through an example

The third example is the one where the weights do real work: Q = [[10, 0]], K = [[1, 0], [0, 1]], V = [[1, 1], [2, 2]], and the query matches key 0 far better than key 1.

stepquantityvalue
1d, scale2, √2 = 1.4142135623730951
2scores[10 / √2, 0 / √2] = [7.071067811865475, 0.0]
3m, expsm = 7.071067811865475; exps = [1.0, 0.0008493257047191702]
4z, wz = 1.000849325704719; w = [0.9991513950372889, 0.0008486049627111874]
5output roweach column is w[0]·1 + w[1]·2 = 1.0008486049627112
6result[[1.0008486049627112, 1.0008486049627112]]: almost all of V[0]

Complexity

Time O(n · m · (d + d_v)) for n queries, m keys, key width d and value width d_v: each query scores m keys at O(d) each and blends m value rows into d_v columns. Working space is O(m) per query for scores, exps and w, plus O(n · d_v) for the output. The full-matrix version has the same time but O(n · m) space.

Edge cases to test

  • Q = [[1000]], K = [[1], [0]], V = [[1], [2]] → [[1.0]]. The max shift turns the exponents into 0 and -1000; without it, math.exp(1000) raises OverflowError.
  • Q = [[1, 5]], K = [[0, 0], [0, 0]], V = [[1, 2], [3, 4]] → [[2.0, 3.0]]. Identical keys give uniform weights and the mean of V, whatever the query.
  • Q = [[1, 0]], K = [[0, 1]], V = [[7, 8, 9]] → [[7.0, 8.0, 9.0]]. The output width follows V, not d, and a single key always gets weight 1.
  • Q = [[1, 1, 1, 1]], K = [[1, 1, 1, 1], [0, 0, 0, 0]], V = [[1], [0]] → [[0.8807970779778823]]. Without dividing by √4 the weight on the first key rises to 0.982: the scale softens the distribution.
  • Q = [[1, 0], [0, 1]] against three keys [[1, 0], [0, 1], [1, 1]] with V = [[1], [2], [3]] → [[2.0], [2.203336278039358]]. There is one output row per query; the number of keys only sets the length of each weight vector.

Common mistakes

  • Dropping the √d scaling — the dot products grow with dimension, saturate the softmax, and gradients vanish.
  • Softmax over the wrong axis — it must normalize over the keys, per query.
  • Forgetting the causal mask when the model is autoregressive (a token must not attend to the future).

Follow-ups an interviewer asks next

Add a causal mask for a decoder.
Query i may only attend to keys 0 through i. Set the scores of later keys to negative infinity before the softmax, so their weights are exactly 0, or in this loop simply score K[:i + 1]. The max shift still works, because every row keeps at least its own key, so m is finite and z is at least 1.
Turn it into multi-head attention.
Project the inputs with h separate learned matrices into h heads, each with key width d / h, run this function on every head independently, concatenate the h outputs, and apply one more learned output projection. The total work is about the same as one head of width d, but each head can learn a different pattern of what to attend to.
The sequence is 100,000 tokens long. What do production kernels do?
The full score matrix no longer fits in fast on-chip memory. FlashAttention tiles K and V into blocks and keeps a running maximum and running sum for every query, the online softmax, rescaling the partial output as each new block arrives. It never stores the whole matrix and still computes exact attention, not an approximation.
You generate text one token at a time.
Earlier tokens’ keys and values never change, so cache them. Each new token computes one query row against the cached K and V, costing O(n · d) per step instead of recomputing attention for the whole prefix. That is the KV cache, and because it grows with every token, its size is the main memory cost of serving long contexts.

Where this shows up

This is one head of the multi-head attention inside every Transformer, so implementing it from scratch is the canonical “do you actually understand attention” check. The O(n²) cost is also the direct reason long context is expensive and why sparse and linear-attention variants exist.

Reference solution

Reference solution — Python
import math

def attention(Q, K, V):
    d = len(Q[0])
    scale = math.sqrt(d)
    out = []
    for q in Q:
        # 1. scaled dot-product scores against every key
        scores = [sum(q[t] * k[t] for t in range(d)) / scale for k in K]
        # 2. numerically stable softmax → weights that sum to 1
        m = max(scores)
        exps = [math.exp(s - m) for s in scores]
        z = sum(exps)
        w = [e / z for e in exps]
        # 3. weighted sum of the value rows
        dv = len(V[0])
        row = [sum(w[j] * V[j][c] for j in range(len(V))) for c in range(dv)]
        out.append(row)
    return out


print(attention([[1]], [[1]], [[5, 6]]))                 # -> [[5.0, 6.0]]
print(attention([[1, 5]], [[0, 0], [0, 0]], [[1, 2], [3, 4]]))  # -> [[2.0, 3.0]]
Reference solution — TypeScript
function attention(Q: number[][], K: number[][], V: number[][]): number[][] {
  const d = Q[0].length;
  const scale = Math.sqrt(d);
  const out: number[][] = [];
  for (const q of Q) {
    // 1. scaled dot-product scores against every key
    const scores = K.map((k) => {
      let s = 0;
      for (let t = 0; t < d; t++) s += q[t] * k[t];
      return s / scale;
    });
    // 2. numerically stable softmax
    const m = Math.max(...scores);
    const exps = scores.map((s) => Math.exp(s - m));
    const z = exps.reduce((a, b) => a + b, 0);
    const w = exps.map((e) => e / z);
    // 3. weighted sum of the value rows
    const dv = V[0].length;
    const row: number[] = [];
    for (let c = 0; c < dv; c++) {
      let acc = 0;
      for (let j = 0; j < V.length; j++) acc += w[j] * V[j][c];
      row.push(acc);
    }
    out.push(row);
  }
  return out;
}

console.log(attention([[1]], [[1]], [[5, 6]]));                       // -> [[5, 6]]
console.log(attention([[1, 5]], [[0, 0], [0, 0]], [[1, 2], [3, 4]])); // -> [[2, 3]]

Questions about Single-Head Attention

Why is attention scaled by the square root of the dimension?

Because dot products grow with dimension. If the entries of a query and a key are independent with mean 0 and variance 1, their dot product over d dimensions has variance d, so typical scores grow like the square root of d. Large scores push the softmax toward a one-hot output, where the gradients for every other position are close to zero and learning slows. Dividing by the square root of d brings the variance back to about 1. The original Transformer paper gives exactly this argument for the scale factor.

What do the query, key and value matrices mean in attention?

Each token is projected three ways. Its query describes what it is looking for, its key describes what it offers to be matched against, and its value is the content it hands over when attended to. Scoring a query against every key with a dot product says how relevant each position is to this token, the softmax turns those scores into weights that sum to 1, and the output is the weighted average of the values. In self-attention all three come from one sequence; in cross-attention the queries come from one sequence and the keys and values from another.

Why subtract the maximum score before the softmax?

To avoid overflow without changing the answer. Softmax is unchanged when the same constant is subtracted from every score, because that factor cancels between the numerator and the denominator. Subtracting the row maximum makes the largest exponent exp(0), which is 1, so no term can overflow, and the sum is at least 1, so the division is safe. Without it, a score of 1000 makes Python raise OverflowError from math.exp, and in JavaScript Math.exp(1000) is Infinity, which turns the weights into NaN.

What is the time and space complexity of self-attention?

For a sequence of n tokens with head dimension d, self-attention costs O(n² · d) time, because every token scores every other token and then blends n value rows. Storing the full score matrix takes O(n²) memory, which is why long contexts are expensive. Computing one query row at a time, as this solution does, keeps the working memory at O(n) per row, and FlashAttention goes further by tiling the work so the full matrix is never stored, while still producing the exact result.

Finished this one? 0 / 76 Challenges done
NextThe Spotlight: Attention Next in “Build an LLM From Scratch” · step 9 of 20

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Challenges