Scaled dot-product attention
The attention formula, line by line.
Create a free account Try part of it here
Why it matters
Every transformer, the design behind today's large language models, runs the same short recipe over and over. It's called scaled dot-product attention. It lets each word look at the other words and pull in what it needs. You have already met every ingredient: queries, keys and values, the dot product and softmax. This lesson puts them together in one line. It explains the one new step, the "scaling". Then you write the function yourself, the same one a large language model calls dozens of times for every token it writes.
What you'll be able to do
- Read the attention formula softmax(QKᵀ/√d_k)V term by term
- Show why dividing by √d_k keeps the softmax from saturating
- Implement batched attention in PyTorch and check every shape
- Recognize the function libraries provide for this (and why it's optimized)
What it covers
- The recipe in four steps
- Worked example: one query, three tokens
- Why divide by √dk?
- Masks: switching some pairs off
- Batched attention in PyTorch
- In real code
The recipe in four steps
Think of a quiz night. Each team writes a question on a card. That's its query. Every team also wears a badge saying what it knows about. That's its key. And each team has a notebook of answers. That's its value. You met all three in the last lesson.
Attention runs the same four steps for every token at once:
- Score every pair. Take the dot product of one token's query with every token's key. A big score means "this key matches my question well".
- Shrink the scores. Divide them all by the same number. This is the new step, explained below.
- Turn scores into shares. Run softmax along each row. Each token now has a list of shares that add up to 1, one share per token it can look at.
- Blend the values. Each token takes that share of every token's value and adds them up.
Here are the same four steps as one formula. Every piece gets a name below.
- is the table of queries, one row per token.
- is the table of keys, also one row per token.
- is the table of values.
- is transposed: its rows turned into columns. That lets one matrix multiplication take every query's dot product with every key.
- is how many numbers each query and key holds. Say it "d k".
- is its square root. With , that's 8.
- is how many numbers each value holds. It's often the same as .
In plain words: score each query against every key. Divide the scores by the square root of the key length. Turn each row of scores into shares with softmax. Then use those shares to blend the values.
Show the answer
The values
Queries and keys decide how much to take from each token. The values are what actually gets taken.
Here is what each piece looks like for one sequence of tokens. "T × T" means a table with one row and one column per token.
| Piece | Shape (one sequence of T tokens) | Meaning |
|---|---|---|
| T × d_k | queries and keys | |
| T × T | every query's dot product with every key | |
| T × T | the same scores, made smaller | |
| softmax (each row) | T × T | attention weights; each row adds up to 1 |
| T × d_v | each token's blend of the values |
The attention weights are those shares from step 3.
Worked example: one query, three tokens
One token's query is [2, 0, 2, 0], so d_k = 4 and √d_k = 2. Three tokens have the keys [1, 0, 1, 0], [0, 1, 0, 1] and [1, 1, 0, 0]. Their values are [10, 0], [0, 10] and [5, 5]. Run the four steps by hand, then in Python.
import math q = [2, 0, 2, 0] # the query keys = [[1, 0, 1, 0], [0, 1, 0, 1], [1, 1, 0, 0]] # one key per token values = [[10, 0], [0, 10], [5, 5]] # one value per token d_k = len(q) # 4 numbers in each query and key scores = [sum(a * b for a, b in zip(q, k)) / math.sqrt(d_k) for k in keys] exps = [math.exp(s) for s in scores] weights = [e / sum(exps) for e in exps] out = [sum(w * v[i] for w, v in zip(weights, values)) for i in range(2)] print("scores: ", scores) print("weights:", [round(w, 3) for w in weights]) print("output: ", [round(x, 2) for x in out])
Step 1. The dot products are 2 + 0 + 2 + 0 = 4, then 0, then 2.
Step 2. Divide each by √4 = 2. The scores become 2, 0 and 1.
Step 3. Softmax raises e (about 2.718) to each score: 7.389, 1 and 2.718. Those add up to 11.107. Divide each by the total and the weights are 0.665, 0.09 and 0.245. They add up to 1.
Step 4. Blend the values. The first number is 0.665 × 10 + 0.09 × 0 + 0.245 × 5, about 7.88. The second is 0.665 × 0 + 0.09 × 10 + 0.245 × 5, about 2.12. The output leans toward the first value, because the first key matched the query best.
import math d_k = 16 print(12 / math.sqrt(d_k))
Show the answer
3.0
The square root of 16 is 4, and 12 ÷ 4 prints as 3.0. Dividing by d_k itself would give 0.75, which is a common mistake.
Why divide by √d_k?
Roll one die and you get a number from 1 to 6. Roll 64 dice and add them up, and the total can land much further from its average. Totals of many random numbers spread out more as you add more of them. They don't spread in step with the count, though. The spread grows with the square root of the count. Four times as many dice gives about twice the spread.
A dot product is that kind of total. It multiplies pairs of numbers and adds up the results. In a fresh model those numbers are roughly random, typically about ±1 each. Some are positive and some negative. So the typical size of a score grows like :
- with , scores are typically around ±2;
- with , around ±8;
- with , around ±23.
Big scores cause two problems.
One token takes everything. Softmax turns score gaps into share gaps, and it does this fiercely. Each extra point of score gives a token about 2.7 times as much weight. A gap of 20 points gives it hundreds of millions of times as much. So nearly all the weight lands on one token, and the blend in step 4 becomes a plain copy.
Learning stalls. When softmax is pushed that far, it is saturated. Small changes to the scores barely move the weights, so the slopes that training follows are almost zero. The model can't adjust where it looks.
Dividing every score by cancels the growth. The typical score goes back to about ±1, however long the keys are.
In plain words: longer keys make bigger scores, by a factor of about . Dividing by undoes exactly that, so softmax stays gentle and trainable.
The code below tests this. For three key lengths, it makes one random query and ten random keys. It prints how spread out the scores are, and the biggest weight softmax gives any one token. It does this with and without the scaling.
import numpy as np rng = np.random.default_rng(0) T = 10 for d_k in [4, 64, 512]: q = rng.normal(size=d_k) K = rng.normal(size=(T, d_k)) for label, scores in [("unscaled", K @ q), ("scaled", K @ q / np.sqrt(d_k))]: w = np.exp(scores - scores.max()) w /= w.sum() print(f"d_k {d_k:>3}, {label:<8}: score spread (std) {scores.std():6.2f} largest weight {w.max():.2f}")
"std" is the standard deviation, the typical distance from the average. Without scaling, it grows with the key length: 6.42 at and 22.56 at 512. That's close to and . One token then grabs most of the weight: 0.95 and 0.75 here. With scaling, the spread stays at 1 or below. The largest weight stays between 0.15 and 0.25, so the attention is shared out and can still learn. The exact numbers depend on the random draw. At , for example, this draw happens to give a small spread of 0.63.
Go deeper Where the √d_k comes from
Suppose each number in the query and key is random and independent of the others. Each has an average of 0 and a variance of 1. Variance is the square of the standard deviation.
Call the query's -th number . The key's -th number is . Each product then also has an average of 0 and a variance of 1.
For independent random numbers, variances add up. The dot product adds such products. The sign means "add up", here for from 1 to :
Read it as: the variance of the score is 1 + 1 + ... + 1, once for each of the numbers. So the standard deviation is . Dividing a random number by divides its standard deviation by . Dividing by gives a standard deviation of 1.
This is a simplification. Trained queries and keys aren't purely random. But the scaling keeps the scores at a sensible size when training starts, which is when it matters most.
With big scores one word takes everything. Dividing by √d_k keeps attention soft enough to learn.
Show the answer
To keep their spread near 1, so softmax doesn't saturate and learning doesn't stall
A dot product of a query and a key with d_k numbers each has a variance of about d_k, so its spread is about √d_k. Dividing by √d_k brings the spread back to about 1. Softmax is what makes the weights add up to 1.
Masks: switching some pairs off
Sometimes a token must not look at certain other tokens. The next lesson's causal mask is the main example: when a model writes text, a token can't look at words that come after it. A mask is a table of True and False, one entry per pair of tokens. True means "allowed". False means "switched off".
To switch a pair off, set its score to minus infinity, written , before the softmax. Softmax raises e to each score, and e to the power of minus infinity is exactly 0. So that pair gets a weight of exactly 0. The other weights still add up to 1.
Setting the score to 0 wouldn't work. A score of 0 becomes , which still earns a share of the weight.
import math scores = [2.0, 1.0, float("-inf")] exps = [math.exp(s) for s in scores] print([round(e / sum(exps), 3) for e in exps])
Show the answer
[0.731, 0.269, 0.0]
e to the minus infinity is exactly 0, so the masked token gets no weight. The other two split everything: 7.389 and 2.718 out of 10.107. A score of 0 instead would give [0.665, 0.245, 0.09].
Batched attention in PyTorch
Real models handle several sequences at once, called a batch. The tensors gain an extra dimension at the front. A batch of sequences, each tokens long, gives queries of shape (B, T, d_k).
That doesn't change the code. The @ operator multiplies the last two dimensions and repeats across any dimensions in front. k.transpose(-2, -1) swaps the last two dimensions, which is the in the formula. So the code reads almost word for word like the formula. The function also takes an optional mask, as described above.
import math import torch def scaled_dot_product_attention(q, k, v, mask=None): d_k = q.shape[-1] scores = q @ k.transpose(-2, -1) / math.sqrt(d_k) # (..., T, T) if mask is not None: scores = scores.masked_fill(~mask, float("-inf")) # forbidden pairs get -inf weights = torch.softmax(scores, dim=-1) # each row sums to 1 return weights @ v, weights torch.manual_seed(0) B, T, d_k = 2, 4, 8 q, k, v = torch.randn(B, T, d_k), torch.randn(B, T, d_k), torch.randn(B, T, d_k) out, w = scaled_dot_product_attention(q, k, v) print("output:", tuple(out.shape), " weights:", tuple(w.shape)) # check one entry against a plain Python loop b, i = 1, 2 scores = [float((q[b, i] * k[b, j]).sum()) / math.sqrt(d_k) for j in range(T)] exps = [math.exp(s - max(scores)) for s in scores] manual = sum(e / sum(exps) * v[b, j] for j, e in enumerate(exps)) print("matches a loop:", torch.allclose(out[b, i], manual, atol=1e-5)) mask = torch.tensor([[True, True, False, False]] * T) # every token may only see tokens 0 and 1 _, w_masked = scaled_dot_product_attention(q, k, v, mask) print("weights on masked tokens:", w_masked[0, :, 2:].abs().max().item())
Here's what happened:
- The batch has 2 sequences of 4 tokens, with 8 numbers per query, key and value. The output has shape (2, 4, 8): one blended value per token. The weights have shape (2, 4, 4): one 4 × 4 table per sequence.
- The second part redoes one token's output with a plain Python loop, the same way as the worked example. It matches.
- The mask lets every token see only tokens 0 and 1. The largest weight left on tokens 2 and 3 is 0.0.
masked_fill(~mask, ...) fills in the entries where the mask is False. The ~ flips True and False. (As before, torch is the course's in-browser PyTorch subset. Real PyTorch also accepts -inf in masked_fill exactly like this.)
Show the answer
(3, 6, 6)
Each sequence gets one table with a row and a column per token, 6 × 6. There are 3 sequences, so (3, 6, 6). The output is a different tensor: (3, 6, 16) if each value also holds 16 numbers.
In real code
Real PyTorch ships this whole recipe as one ready-made function:
import torch.nn.functional as F out = F.scaled_dot_product_attention(q, k, v, is_causal=True)
is_causal=True switches on the causal mask from the next lesson.
The function is fast because of how it runs on the graphics chips (GPUs) that train models. It uses programs called fused kernels, such as FlashAttention. "Fused" means several steps run as one, with no stops in between. FlashAttention works through the tokens in small tiles. It never stores the full T × T table of weights. For long texts, that table is huge, so skipping it saves a lot of memory and time.
The result is the same as your short function, up to tiny rounding differences. FlashAttention is not an approximation. It's the same arithmetic done in a smarter order.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
What shape is the attention weight matrix for a batch of 8 sequences of 100 tokens?