Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Single-Head Attention
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.
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.
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.
Examples and constraints 4 examples
Q=[[1]], K=[[1]], V=[[5, 6]][[5.0, 6.0]]Q=[[1,5]], K=[[0,0],[0,0]], V=[[1,2],[3,4]][[2.0, 3.0]]Q=[[10,0]], K=[[1,0],[0,1]], V=[[1,1],[2,2]][[1.0008486049627112, 1.0008486049627112]]Q=[[1000]], K=[[1],[0]], V=[[1],[2]][[1.0]]- Scale the scores by
1 / √d, whered = 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
mathstdlib and plain lists only.
Hints 4 ideas
- For query row q and key row k, the raw score is their dot product: sum(q[t] * k[t]).
- Divide every score by √d with d = len(Q[0]) — that is the "scaled" in scaled dot-product attention.
- Softmax each query row of scores into weights (subtract the max first for stability).
- 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.
| step | quantity | value |
|---|---|---|
| 1 | d, scale | 2, √2 = 1.4142135623730951 |
| 2 | scores | [10 / √2, 0 / √2] = [7.071067811865475, 0.0] |
| 3 | m, exps | m = 7.071067811865475; exps = [1.0, 0.0008493257047191702] |
| 4 | z, w | z = 1.000849325704719; w = [0.9991513950372889, 0.0008486049627111874] |
| 5 | output row | each column is w[0]·1 + w[1]·2 = 1.0008486049627112 |
| 6 | result | [[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)raisesOverflowError.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 ofV, whatever the query.Q = [[1, 0]], K = [[0, 1]], V = [[7, 8, 9]]→[[7.0, 8.0, 9.0]]. The output width followsV, notd, 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]]withV = [[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
√dscaling — 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
imay only attend to keys0throughi. Set the scores of later keys to negative infinity before the softmax, so their weights are exactly 0, or in this loop simply scoreK[:i + 1]. The max shift still works, because every row keeps at least its own key, somis finite andzis 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.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- SoftmaxThe function at the end of every classifier and language model: turn raw scores (logits) into a probability distribution. Implement the numerically stable version so big logits do not overflow. Solve it in Python or TypeScript.Read →
- Layer NormalizationThe stabilizer wrapped around every Transformer sub-layer: re-center and re-scale a vector to mean 0 and variance 1, then let learned gamma and beta stretch and shift it back — y = gamma·(x−mean)/√(var+eps) + beta. Keeps deep nets trainable. Solve it in Python or TypeScript.Read →
- Cross-Entropy LossThe loss that trains almost every classifier and language model. It measures how surprised the model was by the right answer — high probability on the true class → near 0, confidently wrong → explodes. Return -log(p[target]), eps-guarded. Solve it in Python or TypeScript.Read →
- TF-IDFThe scoring that ran search for decades — and still seeds hybrid retrieval today. Reward a word frequent in one document but rare across the corpus, shrug off words that appear everywhere: tf·log(N/df). Solve it in Python or TypeScript.Read →
- BPE Merge StepOne step of how every tokenizer vocabulary is built: count adjacent symbol pairs across the corpus, pick the most frequent (ties break lexicographically), and merge it everywhere, left to right, without overlaps. Run it a few thousand times and you have byte-pair encoding. Solve it in Python or TypeScript.Read →
- Beam Search DecoderGreedy decoding takes the best next token and never looks back — straight into garden paths. Keep the k best partial sequences alive at every step, with a product score and a clean tie-break, and watch k=2 escape a trap k=1 falls into. Solve it in Python or TypeScript.Read →