Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
The previous chapter scored stored embedding vectors with cosine and dot product. A retriever compares one query vector to many keys.
A transformer layer turns that same routing idea inward: each token supplies a query and a key, while a mask decides which positions it may read.
Take the line "The cache grew quickly because it was full." You know "it" is the cache, not the growth. If that reference reads from the wrong position, later layers receive the wrong context.
Before naming any matrices, predict the operation: each token should choose source positions, turn those scores into weights, and mix the chosen content. Scaled dot-product attention learns that routing with matrix multiplication.

The mask answers a separate question from relevance: which positions are legal to read? The cells are a causal mask. Compute the unmasked two-token slice by hand first, then put the mask back.
Every active query still needs at least one allowed key. An entirely blocked row has no probability distribution, because subtracting its maximum evaluates and produces NaN.
Three views of one token
Self-attention computes a weighted mix of token representations. Each query gets its weights from learned relevance scores against the keys.[1] To do that, the model turns each hidden state into three roles:
| Vector | Question it answers | Role in attention |
|---|---|---|
| Query (Q) | What context does this position need? | The current token's lookup vector |
| Key (K) | When should another position read me? | The addressable routing vector for a token |
| Value (V) | What information do I contribute if selected? | The content vector that gets mixed into the output |
Each token hidden state is projected three ways. produces what this position is searching for (query), and produces when others should read this position (key). produces the payload that gets mixed if selected (value):
Here and . is the residual width; and are the query/key and value widths.[1]
For the running example, token cache uses these toy vectors after projection:
| Role | Vector |
|---|---|
| Query | |
| Key | |
| Value |
Predict the split before reading the diagram: Q and K should meet to produce a routing score, while V should travel past that score and remain content. The self-score for cache will therefore use and , not .
![The token named cache splits through three learned matrices into query [1.0, 0.5], key [0.8, 0.2], and value [2.0, 1.0]; query and key meet to produce the 0.90 self-score, while value stays the content vector that later gets mixed.](/cdn/content-image/fundamentals/scaled-dot-product-attention/illustrations/_generated/qkv_projections_dark.png?v=b4ed5a977f30)
Q and K determine where to look. Their dot products produce routing scores, and softmax converts those scores into weights.
V determines what information to extract. Separating "routing" (Q, K) from "content" (V) is what lets attention route information flexibly.
In one sentence, which tensors choose where attention goes, and which tensor carries content?
Answer
Q and K choose where attention goes. Their dot products produce routing scores that softmax converts into weights. V carries the content that gets mixed by those weights.
The core formula
Read the formula as a lookup followed by a weighted read. Compatibility of queries with keys becomes weights; those weights mix values.[1] Softmax turns each query row into non-negative weights that sum to 1:
When some key positions aren't allowed, add a mask matrix after scaling. An allowed location has ; a blocked logit gets :
Step by step
| Step | Operation | What it means |
|---|---|---|
| Compute scores | Build an matrix where measures how much token should attend to token . | |
| Scale | Keep softmax logits in a range where gradients stay useful. | |
| Mask | Add or to each logit | Block future keys for causal attention or padded keys for batching. |
| Normalize | Turn each row into weights that sum to 1. | |
| Aggregate | Blend value vectors according to the attention weights. |
The order is the contract: scores choose locations, softmax makes each choice a distribution, and only then do those weights touch V. Predict the shapes before following the picture: two queries compared with two keys make a routing matrix, while the value width survives into the output.
The shape flow is compact enough to keep beside the formula. Queries and keys build one routing matrix; values join only after softmax:
![Diagram showing Q [B, h, Nq, d_k], QKᵀ / √d_k + mask [B, h, Nq, Nk], Kᵀ [B, h, d_k, Nk], and softmax over keys [B, h, Nq, Nk].](/cdn/content-image/fundamentals/scaled-dot-product-attention/diagrams/_generated/content_diagram_0_dark.png?v=3bf3c5762591)

A trace with real numbers
Walk through the first two tokens, cache and grew, before adding the third token or a mask. Each lives in a 2-dimensional toy space:
| Token | Query vector | Key vector | Value vector |
|---|---|---|---|
| cache | [1.0, 0.5] | [0.8, 0.2] | [2.0, 1.0] |
| grew | [0.5, 1.0] | [0.3, 0.9] | [1.0, 2.0] |
Step 1: raw scores
Compute the dot product of every query with every key:
Step 2: scale
With , divide by :
Step 3: softmax
Normalize each row so it sums to 1:
Step 4: weighted values
Multiply the weights by the value vectors:
- New "cache" vector =
- New "grew" vector =
Each original vector is replaced by a blend of the sequence, weighted by relevance. Cache pulls slightly more from its own value (0.53) than from grew (0.47), while grew mixes both nearly evenly. A real head does this across 64 or 128 dimensions, not two.
In this toy trace, skipping the scaling step barely changes softmax. Why does the same omission hurt when grows to 64 or 512?
Answer
At , raw scores stay small. Under the initialization assumptions used below, increasing increases the dot-product spread like . That makes saturated, low-gradient softmax rows more likely unless scores are scaled.
The same arithmetic is easy to verify without a tensor library:
1import math
2
3Q = [[1.0, 0.5], [0.5, 1.0]]
4K = [[0.8, 0.2], [0.3, 0.9]]
5V = [[2.0, 1.0], [1.0, 2.0]]
6
7def softmax(row: list[float]) -> list[float]:
8 shift = max(row)
9 exps = [math.exp(x - shift) for x in row]
10 total = sum(exps)
11 return [x / total for x in exps]
12
13scores = [[sum(q_i * k_i for q_i, k_i in zip(q, k)) for k in K] for q in Q]
14scaled = [[score / math.sqrt(2) for score in row] for row in scores]
15weights = [softmax(row) for row in scaled]
16outputs = [
17 [sum(weight * value[col] for weight, value in zip(row, V)) for col in range(2)]
18 for row in weights
19]
20
21print([[round(x, 2) for x in row] for row in weights])
22print([[round(x, 2) for x in row] for row in outputs])
23print([round(sum(row), 3) for row in weights])1[[0.53, 0.47], [0.42, 0.58]]
2[[1.53, 1.47], [1.42, 1.58]]
3[1.0, 1.0]Add the causal mask
The mask changes who may be read, not how the allowed weights are computed. With a lower-triangular mask, this function becomes the two-token slice of the opening figure: token cache can only read itself, while token grew can read cache and grew, so its weights match the unmasked walkthrough.
1import math
2
3Q = [[1.0, 0.5], [0.5, 1.0]]
4K = [[0.8, 0.2], [0.3, 0.9]]
5V = [[2.0, 1.0], [1.0, 2.0]]
6causal = [[True, False], [True, True]]
7
8def softmax(row: list[float]) -> list[float]:
9 shift = max(row)
10 exps = [math.exp(x - shift) for x in row]
11 total = sum(exps)
12 return [x / total for x in exps]
13
14def scaled_dot_product_attention(
15 Q: list[list[float]],
16 K: list[list[float]],
17 V: list[list[float]],
18 mask: list[list[bool]] | None = None,
19) -> tuple[list[list[float]], list[list[float]]]:
20 d_k = len(Q[0])
21 scores = [
22 [sum(q_i * k_i for q_i, k_i in zip(q, k)) / math.sqrt(d_k) for k in K]
23 for q in Q
24 ]
25 if mask is not None:
26 if not all(any(row) for row in mask):
27 raise ValueError("each query row must have at least one visible key")
28 scores = [
29 [logit if visible else float("-inf") for logit, visible in zip(row, visible_row)]
30 for row, visible_row in zip(scores, mask)
31 ]
32 weights = [softmax(row) for row in scores]
33 outputs = [
34 [sum(weight * value[col] for weight, value in zip(row, V)) for col in range(len(V[0]))]
35 for row in weights
36 ]
37 return outputs, weights
38
39output, weights = scaled_dot_product_attention(Q, K, V, causal)
40pretty_weights = [[round(value, 3) for value in row] for row in weights]
41print("causal weights:", pretty_weights)
42print("cache future weight:", pretty_weights[0][1])
43print("output rows:", len(output), "width:", len(output[0]))1causal weights: [[1.0, 0.0], [0.421, 0.579]]
2cache future weight: 0.0
3output rows: 2 width: 2Two masking details matter in real code. First, softmax must run along the key axis so each query row sums to 1; normalizing along the query axis silently changes the operation.
Second, a row with no permitted key has no valid attention distribution. -inf makes that mistake visible as NaN.
Replacing it with a finite negative value hides the bug: softmax assigns weight to blocked keys because every blocked logit ties. Ensure each active query has at least one allowed key (causal attention includes its own position), or explicitly suppress outputs for padded query rows.
Predict the failure before running this case: with a finite fill value, a fully blocked row gives every blocked key the same score, so softmax invents a uniform distribution. The suppression step must handle that row explicitly.
This short failure case makes the second rule concrete:
1import math
2
3scores = [[0.8, 0.1], [0.4, -0.2]]
4visible = [[True, False], [False, False]]
5
6def softmax(row: list[float]) -> list[float]:
7 shift = max(row)
8 exps = [math.exp(x - shift) for x in row]
9 total = sum(exps)
10 return [x / total for x in exps]
11
12filled = [
13 [logit if allowed else -1e4 for logit, allowed in zip(row, mask_row)]
14 for row, mask_row in zip(scores, visible)
15]
16finite_fill_weights = [softmax(row) for row in filled]
17served_weights = [
18 row if any(mask_row) else [0.0] * len(row)
19 for row, mask_row in zip(finite_fill_weights, visible)
20]
21
22print("finite fill, invalid row:", [round(x, 4) for x in finite_fill_weights[1]])
23print("after padded-query suppression:", served_weights[1])
24print("valid row ignores blocked key:", [round(x, 4) for x in served_weights[0]])1finite fill, invalid row: [0.5, 0.5]
2after padded-query suppression: [0.0, 0.0]
3valid row ignores blocked key: [1.0, 0.0]In PyTorch, F.scaled_dot_product_attention is the fused entry point for the same math. Current docs list three CUDA implementations (FlashAttention-2, memory-efficient attention, and a C++ math fallback) and an experimental enable_gqa path.
A fused kernel isn't guaranteed for every shape and dtype. Two API traps matter: True in a boolean attn_mask means the position participates, which is the inverse of nn.MultiheadAttention's boolean key_padding_mask; and dropout_p is applied whenever it's greater than zero, so pass 0.0 at eval.[2][3]
Common shape mistakes
When attention breaks, start with two questions: do the dimensions produce one score per query-key pair, and did the score scale stay stable? The two mistakes below produce distinct symptoms.
Forgetting to transpose K
If you multiply by instead of , the inner dimensions won't align. With shape and shape , you need . In PyTorch that's Q @ K.transpose(-2, -1), not Q @ K.
Forgetting to scale
If you skip attn_scores / math.sqrt(d_k), the code won't crash. Under the independent unit-variance setup below, a raw dot product has standard deviation 8 rather than 1. That larger spread can saturate softmax and reduce routing gradients.
Inspect score statistics and attention entropy when debugging, then restore the scale factor unless you're intentionally testing a different attention formulation.

Why scale by ? The variance proof
The two-token trace hides the problem because its head is only two numbers wide. Each attention score adds one feature product per dimension into one logit.
Without scaling, increasing makes raw dot products larger and larger until softmax saturates. Dividing by normalizes the score so the model can still distribute probability across several plausible tokens instead of locking onto one too early.
The derivation that motivated the transformer's scale factor starts with a simple assumption: entries of and are independent across vectors and dimensions, with mean 0 and variance 1. Learned activations won't satisfy those assumptions exactly.
The calculation still explains why unscaled logits begin with dimension-dependent spread.[1]
Assume
are independent components, each with mean and variance , and the product terms are independent across dimensions. The calculation doesn't require a Gaussian distribution; it uses these moments and independence to add the per-dimension variances. The dot product is:
Each term has (since and they're independent). The variance is:
By the sum of independent variances:
So the standard deviation of the raw dot product is . As grows under this model, logits spread more widely before softmax:
So at , predict a raw standard deviation of 8 and a scaled standard deviation of 1. The table checks that prediction across widths.
| 16 | 4.0 | 1.0 |
| 64 | 8.0 | 1.0 |
| 512 | 22.6 | 1.0 |
| 4096 | 64.0 | 1.0 |
After dividing by
under these assumptions. Scaling doesn't promise a particular learned attention pattern; it removes a predictable source of width-dependent logit growth.

See it in code. This experiment samples independent unit-variance query/key vectors and checks the standard-deviation calculation rather than choosing one dramatic softmax row:
1import math
2import random
3import statistics
4
5rng = random.Random(7)
6
7def dot_products(width: int, samples: int = 5000) -> list[float]:
8 return [
9 sum(rng.gauss(0, 1) * rng.gauss(0, 1) for _ in range(width))
10 for _ in range(samples)
11 ]
12
13for width in (16, 64, 512):
14 raw = dot_products(width)
15 raw_std = statistics.pstdev(raw)
16 scaled_std = statistics.pstdev([x / math.sqrt(width) for x in raw])
17 print(f"d_k={width:3d}: raw std={raw_std:5.2f}, scaled std={scaled_std:4.2f}")1d_k= 16: raw std= 4.07, scaled std=1.02
2d_k= 64: raw std= 8.09, scaled std=1.01
3d_k=512: raw std=22.39, scaled std=0.99The sampled values won't be exactly the theoretical values, but their trend should match: raw spread grows with width while scaled spread stays near one.
Why divide by instead of by ?
Answer
The raw dot product has variance , so its standard deviation is . Softmax sees the scale of logits through their standard deviation, so dividing by brings the variance back to 1. Dividing by would over-shrink the logits as dimensions grow.
Three types of attention
The formula doesn't decide who may read whom. The mask and the sources of , , and do.
Bidirectional attention lets every visible position read every other visible position. Causal attention processes left to right, so each new token can only use earlier tokens. Cross-attention lets a target sequence read from a separate source sequence, such as a decoder reading encoder states.
1. Bidirectional self-attention (encoder)
Every non-padding token may attend to every other non-padding token; there's no future-token mask. Encoder architectures such as BERT, and the Vision Transformer you'll meet next, use this pattern when the full input is already available:
1"The cache grew quickly"
2 Token "grew" attends to: [The, cache, grew, quickly] (full context)2. Causal self-attention (decoder)
Each token can only attend to itself and previous tokens. Future positions are masked with . Decoder architectures such as GPT-style and other autoregressive language models use this pattern for generation tasks where the model must predict the next token without seeing the future.
When processing a sequence step-by-step, the model progressively builds context but remains strictly blind to upcoming words:
1"The cache grew quickly"
2 Token "grew" attends to: [The, cache, grew] (only past + self)
3 Token "quickly" attends to: [The, cache, grew, quickly] (full history)The causal mask is a lower-triangular matrix that lets each position look only at itself and the positions before it. This minimal Python version takes the sequence length as input and outputs a boolean matrix where True indicates an allowed connection and False indicates a masked one.
1def create_causal_mask(seq_len: int) -> list[list[bool]]:
2 return [[key_pos <= query_pos for key_pos in range(seq_len)] for query_pos in range(seq_len)]
3
4mask = create_causal_mask(4)
5for row in mask:
6 print(row)
7print(mask[0] == [True, False, False, False])
8print(mask[1] == [True, True, False, False])
9print(mask[3] == [True, True, True, True])1[True, False, False, False]
2[True, True, False, False]
3[True, True, True, False]
4[True, True, True, True]
5True
6True
7TrueWhy must token 0 be unable to attend to token 1 in causal self-attention?
Answer
During next-token training, token 0 is only allowed to use itself and earlier context. If it can attend to token 1, the model can leak the future answer during training. Loss can look good, but generation fails because that future token isn't available at inference time.
3. Cross-attention (encoder-decoder)
Queries come from one sequence, Keys and Values from another. The original Transformer uses this pattern in its decoder: encoder outputs provide keys and values, while current decoder states provide queries.[1]
The resulting weights choose which source positions contribute to each target representation:
1Encoder output (source): "cache memory pressure" provides K, V
2Decoder state (target): "decode slows ___" provides Q
3
4Q from decoder times K from encoder gives attention weights
5Weights times V from encoder give decoder contextIn practice, cross-attention usually applies a source padding mask so decoder tokens don't attend to padded encoder positions. It doesn't use a causal mask over the source sequence, because the encoder has already seen the whole input.
Self-attention produces a square query-by-key matrix. Cross-attention doesn't have to: two target queries reading three source positions produce a 2 x 3 routing matrix.
1import math
2
3decoder_queries = [[1.0, 0.0], [0.0, 1.0]]
4encoder_keys = [[1.0, 0.0], [0.2, 0.8], [0.0, 1.0]]
5encoder_values = [[1.0, 0.0], [0.5, 0.5], [0.0, 1.0]]
6
7def softmax(row: list[float]) -> list[float]:
8 exps = [math.exp(x - max(row)) for x in row]
9 return [value / sum(exps) for value in exps]
10
11logits = [
12 [sum(q_i * k_i for q_i, k_i in zip(q, k)) / math.sqrt(2) for k in encoder_keys]
13 for q in decoder_queries
14]
15weights = [softmax(row) for row in logits]
16context = [
17 [sum(weight * value[d] for weight, value in zip(row, encoder_values)) for d in range(2)]
18 for row in weights
19]
20
21print(f"routing shape: {len(weights)} x {len(weights[0])}")
22print("row sums:", [round(sum(row), 3) for row in weights])
23print("context:", [[round(x, 3) for x in row] for row in context])1routing shape: 2 x 3
2row sums: [1.0, 1.0]
3context: [[0.623, 0.377], [0.393, 0.607]]| Attention Type | Q Source | K, V Source | Mask | Architecture |
|---|---|---|---|---|
| Bidirectional Self | Same sequence | Same sequence | Padding mask only (if needed) | BERT, Vision Transformer (ViT) |
| Causal Self | Same sequence | Same sequence | Lower-triangular, plus padding if needed | GPT, Llama |
| Cross | Target sequence | Source sequence | Source padding mask common | T5, original Transformer |

Multi-head attention
Multi-head attention gives the model several learned routing views of the same token sequence, then lets a final projection combine those views. Each head has its own query, key, and value projections. The formula describes parallel heads, each with :[1]
A trained head may acquire a recognizable routing pattern, but the architecture doesn't assign jobs such as "syntax head" or "copy head" in advance.
Reading the formula
Instead of running one big attention operation, we project into narrower heads that attend independently. After each head produces its output, we concatenate them and multiply by a final matrix so information from those routes rejoins the residual stream.
For self-attention with input , each head computes: . Cross-attention uses target states for the query projection and source states for the key/value projections.

What do attention heads learn?
These interpretability results are evidence about specific trained models, not a promise about every transformer. Voita et al. found positional and syntactic patterns among heads in neural machine translation encoders.[4]
Michel et al. found that many heads in the models they tested could be removed at inference with limited quality loss.[5] Olsson et al. studied induction heads, circuits that support copying patterns in autoregressive models under their experiments.[6]
| Result from a study | Useful inference | Unsafe inference |
|---|---|---|
| Some heads show consistent patterns | Inspect heads when debugging or researching a trained model | Every head has a named human-readable purpose |
| Some tested models tolerate head pruning | Redundancy can exist and can be measured | Arbitrarily deleting heads preserves a new model's quality |
| Induction-head circuits can emerge | Attention can implement copy-like sequence algorithms | An attention heatmap alone proves causal model behavior |
Same asymptotic attention FLOPs
Multi-head attention doesn't increase the asymptotic cost of the attention core when you keep fixed. It restructures the work.
Single-head attention on uses roughly the same leading-order FLOPs (Floating Point Operations) for score computation and value mixing as 8-head attention with each, because .
The dense Q/K/V and output projections still cost either way.
Predict before running the arithmetic: with fixed , one 512-wide head and eight 64-wide heads should do the same leading score-and-value work. Splitting a fixed width into more heads doesn't change the total number of score-and-value multiply-adds in the attention core:
1seq_len = 2048
2d_model = 512
3
4for heads in (1, 8, 16):
5 d_head = d_model // heads
6 score_and_value_work = 2 * heads * seq_len**2 * d_head
7 print(f"heads={heads:2d}, d_head={d_head:3d}, core units={score_and_value_work:,}")1heads= 1, d_head=512, core units=4,294,967,296
2heads= 8, d_head= 64, core units=4,294,967,296
3heads=16, d_head= 32, core units=4,294,967,296The usual layout is a reshape, not extra width. After a dense projection to , slice into heads and attend. Then concatenate back to before :
1B, N, D, n_heads = 1, 4, 8, 2
2d_k = D // n_heads
3assert D % n_heads == 0
4
5# (B, N, D) -> (B, h, N, d_k)
6heads = [
7 [[[0.0] * d_k for _ in range(N)] for _ in range(n_heads)]
8 for _ in range(B)
9]
10# (B, h, N, d_k) -> (B, N, D)
11concat = [
12 [
13 [coord for head in range(n_heads) for coord in heads[b][head][n]]
14 for n in range(N)
15 ]
16 for b in range(B)
17]
18print("input shape:", (B, N, D))
19print("head shape:", (len(heads), len(heads[0]), len(heads[0][0]), len(heads[0][0][0])))
20print("concat width:", len(concat[0][0]))
21print("width preserved:", len(concat[0][0]) == D)1input shape: (1, 4, 8)
2head shape: (1, 2, 4, 4)
3concat width: 8
4width preserved: TrueFrom-scratch checklist
Before relying on torch.nn.MultiheadAttention, make sure you can implement the pieces above by hand. High-level modules are useful later, but they hide the exact shape and masking mistakes that break production attention code.
For a decoder-only block, your implementation should do each step explicitly:
- Project
xinto Q, K, and V. - Reshape
(B, N, D)into(B, h, N, d_k). - Compute
Q @ K.transpose(-2, -1) / sqrt(d_k). - Apply the causal mask before softmax.
- Use numerically stable softmax along the key dimension.
- Multiply attention weights by V.
- Concatenate heads back to
(B, N, D). - Apply the output projection.
Two shape assertions catch many bugs:
1assert Q.shape == (B, n_heads, N, d_k)
2assert attn_weights.shape == (B, n_heads, N, N)One mask assertion catches leakage: assert not causal[0][1]. Token 0 must not see token 1.
If a model can read future tokens during training, the loss can look excellent while generation fails. That's why causal masking isn't a detail. It's the contract that makes next-token prediction honest.
Gradient flow through attention
Attention has three gradient paths:
| Path | What receives gradient | Why it matters |
|---|---|---|
| Output to V | value projection and upstream token representations | teaches what information each token should carry |
| Output to attention weights | softmax probabilities | teaches which source positions should matter |
| Weights back to Q and K | query/key projections | teaches the routing function itself |
The scale factor helps keep the Q/K path trainable. If scores become too large, softmax saturates, attention weights become nearly one-hot, and the gradient through the routing path becomes tiny. If the causal mask is wrong, gradients flow through illegal future positions and the model learns a shortcut it can't use at inference time.
When debugging attention, don't only print the final output. Inspect the score range, the mask, one row of attention weights, and the gradient norm on W_q and W_k. Those four checks tell you whether the model is learning routing or only moving values through a broken router.
Complexity analysis
There are two bottlenecks to keep separate: quadratic query-key pairs and width-heavy projections. Big-O notation describes how work or storage grows as sequence length and model width increase.
It hides constant factors, so use it for growth trends and use concrete byte/FLOP arithmetic for capacity planning.
| Metric | Complexity | Explanation |
|---|---|---|
| Time (attention core, single head) | Both and touch all query-key pairs | |
| Time (attention core, full multi-head) | Across heads, | |
| Time (Q/K/V + output projections) | Dense linear layers before and after the attention core | |
| Memory (naive weights) | per head | The score or weight matrix is |
| Parameters | are dense projections |

Concrete memory example
Batch of 8, 32 heads, , FP16 (16-bit floating point format):
How the numbers work
8 sequences in the batch x 32 attention heads x 8192² entries per attention map x 2 bytes per number (FP16) = about 34.4 GB of raw storage, or about 32 GiB, for one attention-score tensor before values needed for backpropagation, model weights, or optimizer state.
1batch = 8
2heads = 32
3seq_len = 8192
4bytes_per_fp16 = 2
5
6bytes_total = batch * heads * seq_len**2 * bytes_per_fp16
7gb = bytes_total / 1_000_000_000
8gib = bytes_total / 1024**3
9
10print(round(gb, 1), "GB")
11print(round(gib, 1), "GiB")
12print(round(gb, 1) == 34.4, round(gib, 1) == 32.0)134.4 GB
232.0 GiB
3True TrueIn the naive formulation, temporary memory becomes a bottleneck for long sequences. Doubling sequence length makes one materialized score tensor four times larger. Fused kernels can avoid storing that full tensor, but exact dense attention still computes interactions across all query-key pairs.
If sequence length grows from 512 to 2048, why does naive attention memory grow by 16x instead of 4x?
Answer
The attention score matrix is . Increasing by 4x makes the matrix , which has 16x as many entries. This is the core quadratic memory wall.
Training vs. inference bottlenecks
During training, or in a naive implementation, the temporary score matrix is the obvious memory problem.
During autoregressive decoding, optimized kernels often avoid materializing that matrix. Each new token has only one query row, but the model still has to repeatedly read the accumulated KV cache (past keys and values). That makes incremental inference heavily constrained by memory bandwidth, not FLOPs alone.[7]
Architectural variants attack that persistent cache cost directly. Multi-query attention (MQA) shares one key/value head across all query heads.[7]
Grouped-query attention (GQA) uses fewer key/value heads than query heads.[8]
Both shrink KV-cache bytes. The Llama 3 paper uses GQA with 8 key-value heads.[9] Whether that improves latency enough for a workload is a measurement question, because kernel choice, batch size, and quality requirements also matter.
For a simplified decoder cache, the storage count is proportional to layers x tokens x kv_heads x head_dim x 2 (the final factor stores both K and V). Keeping 32 query heads but reducing KV heads changes this count directly:
1layers = 32
2tokens = 8192
3head_dim = 128
4bytes_per_value = 2 # FP16
5
6def cache_gib(kv_heads: int) -> float:
7 bytes_total = layers * tokens * kv_heads * head_dim * 2 * bytes_per_value
8 return bytes_total / 1024**3
9
10mha = cache_gib(32)
11for label, kv_heads in [("MHA", 32), ("GQA", 8), ("MQA", 1)]:
12 size = cache_gib(kv_heads)
13 print(f"{label}: kv_heads={kv_heads:2d}, cache={size:.2f} GiB, reduction={mha / size:.0f}x")1MHA: kv_heads=32, cache=4.00 GiB, reduction=1x
2GQA: kv_heads= 8, cache=1.00 GiB, reduction=4x
3MQA: kv_heads= 1, cache=0.12 GiB, reduction=32xFlashAttention and MQA/GQA solve different problems. FlashAttention cuts temporary attention I/O, while MQA/GQA cut persistent KV-cache size.
Multi-head latent attention (MLA) compresses KV state further, as in DeepSeek-V2. That's a later serving layout, not a change to .[10]
Which optimization reduces temporary attention-matrix I/O, and which reduces persistent KV-cache bandwidth during decoding?
Answer
FlashAttention reduces temporary attention-matrix I/O by tiling attention and avoiding materializing the full matrix. MQA and GQA reduce persistent KV-cache bandwidth by sharing or grouping key/value heads during autoregressive decoding.
Softmax numerical stability
The two-token walkthrough already subtracted the row max before exp. That isn't a style choice.
Naive softmax overflows: grows so fast that a large positive logit becomes Inf, and Inf / Inf is NaN. Attention kernels need a stable form so large but valid scores don't corrupt routing weights.
The max-shift technique
Subtract the maximum value from the input vector before exponentiating:
Before running the example, predict what stays invariant: shifting by its maximum changes the numbers sent to exp, not the relative probabilities. The largest shifted logit should receive the largest weight, and the row should still sum to 1.
Subtracting shifts every logit by the same constant. That constant factors out of the exponentials and cancels between numerator and denominator, so the probabilities don't change.
The largest exponent is , which blocks positive overflow. Very negative shifted values may underflow toward zero, which is harmless.[11]
Online softmax (Milakov and Gimelshein, 2018) extends the same identity to a streaming pass. It tracks a running maximum and a running sum of exponentials, then rescales the sum when a larger maximum appears. That's the algebra FlashAttention uses to tile a query row without storing every score.[11]
1import math
2
3def stable_softmax(logits: list[float]) -> list[float]:
4 shift = max(logits)
5 exps = [math.exp(x - shift) for x in logits]
6 total = sum(exps)
7 return [x / total for x in exps]
8
9probs = stable_softmax([1000.0, 1001.0, 999.0])
10print([round(p, 4) for p in probs])
11print(round(sum(probs), 6))
12print(probs[1] == max(probs))1[0.2447, 0.6652, 0.09]
21.0
3TrueFlashAttention: tiled exact attention
The score matrix is the memory problem. If it's temporary, the useful question is whether we need to write the whole thing to HBM at all.
FlashAttention (Dao et al., 2022) keeps the definition exact and changes the execution order. Auxiliary attention state is linear in sequence length; the algorithm still does quadratic work.[12]
Instead of writing the full score matrix to GPU HBM, it computes attention in tiles that fit in on-chip SRAM and uses online softmax so each tile can update a running row max, sum, and output accumulator.[12][11]
| Property | Standard attention | FlashAttention |
|---|---|---|
| Memory for attention computation | extra beyond | |
| HBM traffic | Writes and rereads large score matrices | Keeps tiles on chip; no full or in HBM |
| Exact | Yes | Yes |
| Wall-clock speed | Baseline | Depends on hardware, shapes, dtype, and kernel availability |
FlashAttention-2 keeps that math and changes how work is partitioned across GPU thread blocks.[3] FlashAttention-3 adds asynchrony and low-precision paths on Hopper-class GPUs.[13]
Later kernels still implement ; they don't introduce a new attention formula.

You don't write a custom CUDA kernel to use this in PyTorch. F.scaled_dot_product_attention dispatches among FlashAttention-2, memory-efficient attention, and a C++ math fallback when the CUDA backend can. It may enable grouped-query attention through enable_gqa.
The public contract is the numeric result, not which kernel ran.[2][3]
Attention in modern architectures
Once Q/K/V roles and mask semantics are separate, architecture differences become a small lookup: where do Q, K, and V come from, and which score cells remain visible?
| Architecture | Self-attention | Cross-attention | Typical objective |
|---|---|---|---|
| BERT | Bidirectional | No | Masked language modeling |
| Decoder-only LM | Causal | No | Next-token prediction |
| T5 | Bidirectional encoder + causal decoder | Yes | Span corruption |
| Whisper | Bidirectional encoder + causal decoder | Yes | Speech-to-text seq2seq |
| Vision Transformer (ViT) | Bidirectional | No | Image classification / self-supervision |
BERT and ViT use bidirectional self-attention because the full input is already there. Decoder-only language models use causal self-attention because they generate left to right; looking at future tokens during training would leak the answer.
Encoder-decoder models such as T5 and Whisper mix both: a bidirectional encoder reads the source, a causal decoder writes the target, and cross-attention is the bridge.
ViT is the same attention operation after a different tokenizer. An image becomes patch tokens, then bidirectional self-attention mixes those patches with no causal mask. That's the next chapter.
Derivation checklist
| Tier | Defense target |
|---|---|
| Foundational | Derive and annotate the dimensions of each tensor. |
| Intermediate | Derive why dividing by normalizes score variance under independent unit-variance assumptions. |
| Advanced | Distinguish bidirectional self-attention, causal self-attention, and cross-attention by Q/K/V source and mask. |
| Advanced | Explain why multi-head attention runs several lower-dimensional attention heads without changing leading attention-core asymptotic FLOPs when is fixed. |
| Advanced | Separate attention-core time, projection time, and naive attention memory. |
| Advanced | Explain why FlashAttention cuts temporary attention I/O while MQA/GQA shrink persistent KV-cache traffic during decoding. |
| Advanced | Describe max-shift softmax, online softmax, and why numerical stability matters inside attention kernels. |
What happens if you remove the scaling factor for a large head dimension such as ?
Answer
Under the independent unit-variance calculation, the variance of grows with , so its standard deviation grows as . At , unscaled logits are much more likely to saturate softmax. That can weaken gradients through Q and K and make routing behave like a brittle hard lookup rather than a smooth weighted average.
Why use multiple attention heads instead of one large head?
Answer
Multiple heads give the model several separately projected routing spaces in parallel. Studies have measured recognizable patterns in some trained heads, but you must inspect a particular model before naming what its heads do. With fixed , each head is narrower, so the leading attention-core FLOPs stay in the same asymptotic class.
How can transformer systems handle very long contexts such as 100K tokens?
Answer
There isn't one trick. FlashAttention reduces temporary memory and HBM traffic for exact attention, but exact attention still has quadratic work. MQA and GQA shrink KV-cache bandwidth during decoding. Long-context systems may also use sliding-window attention, distributed cache placement, retrieval, or linear/state-space alternatives when they accept a different quality-efficiency tradeoff.
Mistakes that break attention
| Mistake | Symptom | Fix |
|---|---|---|
| Forgetting the K transpose | Matmul shape error before softmax | Compute Q @ K.transpose(-2, -1) so scores have shape (B, h, N, N). |
| Skipping scaling | Score spread rises with head width under the initialization model; attention may saturate | Divide scores by math.sqrt(d_k) before masking and softmax. |
| Mixing up attention memory and time | Bad long-context sizing estimates | Track attention-core FLOPs, projection FLOPs, temporary score memory, and KV-cache memory separately. |
| Treating FlashAttention and GQA as the same optimization | Wrong performance diagnosis | Use FlashAttention for temporary attention I/O; use MQA/GQA for persistent KV-cache traffic. |
| Forgetting padding masks in cross-attention | Decoder attends to fake source tokens | Mask padded encoder positions even though source positions don't need a causal mask. |
| Confusing Q/K/V roles | Hard-to-debug routing behavior | Remember: Q and K choose where information flows; V carries the content being mixed. |
| Using naive softmax | Inf, NaN, or unstable probabilities | Subtract the row max, or use a framework primitive that already applies stable softmax. |
| Softmax on the wrong axis | Rows don't sum to 1; routing is meaningless | Apply softmax over the key dimension (dim=-1), not the query dimension. |
| Allowing a fully masked query row | NaN with -inf, or silent blocked-key mixing with a finite fill | Guarantee one valid key per active query, or zero/skip outputs for padded queries. |
Going deeper
"Why not use additive attention instead of dot-product?"
Bahdanau et al. (2015)[14] used additive attention: . In the Transformer paper, Vaswani et al. note that additive and dot-product attention behave similarly at small dimensions, but dot-product maps much better to batched matrix multiplication and is much faster at the larger dimensions used in transformers.[1] The scaling is what keeps dot-product attention stable as grows.
"Can attention attend to nothing / everything equally?"
If all scores are equal, softmax produces a uniform distribution , and the output is the average of all value vectors. If one score dominates, softmax approximates an argmax, so the output is approximately one value vector. The sharpness of the logits controls where on this spectrum you land.
Practice drill
Build a tiny attention-debug notebook or trace for three tokens:
- Print Q, K, score, mask, softmax row sums, and output shapes after every operation.
- Add one padded key and one causal mask case, then verify blocked positions receive zero probability.
- Trigger one wrong-axis softmax failure and record the symptom you would see in row sums or output behavior.
- Write a checklist for reviewing production attention code: tensor shapes, mask semantics, stable softmax, and fully masked rows.
This makes routing errors observable before they become mysterious model-quality bugs.