Back to the map
Deep LearningAdvanced26h

Transformers & attention

Every token looking at every other token.

Earlier models read a sentence one word at a time, in order, carrying a running summary forward — which made them slow to train and forgetful across long passages. The transformer threw that out and used attention instead: when handling any one word, the model looks at every other word in the input at once and decides which of them matter here. The word "it" can look straight back at the noun it refers to, however far away that noun is.

Two things followed. Meaning at a distance stopped getting lost, because nothing has to survive a chain of intermediate summaries. And because every position is handled at the same time rather than in sequence, training could keep a whole GPU busy, which is what made models of today's size possible at all. The bill is that comparing every word with every other word grows with the square of the input length, which is why long inputs are expensive and why context windows have a ceiling.

It builds on neural networks: the pieces are ordinary layers, arranged so that attention sits between them. Everything in the language-model half of this atlas runs on it — pretraining is how a transformer acquires its capability, fine-tuning and alignment adjust one, quantization makes one cheaper to run. Read this before anything that says attention, heads or KV cache.

Every position can look at every other position in one go, which is what made both long-range understanding and large-scale training practical.

In more detailAttention lets each position weigh every other position when building its representation, which is why transformers capture long-range dependencies and why cost grows quadratically with sequence length. Queries, keys and values, multiple heads, and positional encoding are the moving parts. For applied work, the conceptual model and its cost implications are what you need.

Contested

How deep an applied AI engineer should go here is genuinely debated. Reported evidence says conceptual depth is asked and implementation is not.

Learn first

Section 1 of 4, 8h

Attention, conceptually

By the end: You can explain how a transformer works in five minutes with no equations.

  • The problem attention solves
  • Queries, keys and values as an idea
  • Why multiple heads
  • Why cost grows with the square of length

Every current language model is a transformer, and the part of a transformer that does the work is attention. This section explains it in plain language with no equations, because being able to say what attention does in five minutes is a genuinely separate skill from being able to implement it — the first is asked in interviews constantly and the second is asked rarely. The implementation, with the shapes and the arithmetic, is the from-scratch section later in this topic.

The problem#

To understand a word you often need another word far away. In the sentence about the trophy not fitting in the suitcase because it was too big, working out what "it" refers to means looking back at two candidate nouns and deciding which one the word "big" makes sensible. Change "big" to "small" and the answer flips to the other noun. Nothing local to the word "it" contains that information.

Older sequence models processed one step at a time and carried a summary forward, so distant information degraded. They also could not be parallelised across the sequence, because step ten could not begin until step nine finished. The sequence-models topic measures exactly how badly the first problem bites: in a plain recurrent network the gradient reaching fifty steps back is around 5e-19, which is not small but absent.

Every position looks at every position#

Attention lets each position look directly at all the others in the same sequence — self-attention — and decide how much each one matters for it. Distance costs nothing: the fiftieth word and the first are one step apart, exactly as the fiftieth and the forty-ninth are. And because every position can do this at the same time, the whole sequence is processed in parallel, which is what made training at scale possible.

Attention, one step at a time
1 / 6
Thetrophyfitinthesuitcasebecauseitwas

A sentence, split into tokens. We want to work out what “it” refers to.The answer is six positions away, and nothing nearby helps.

The output for each position is a blend: a weighted average of information drawn from every position, where the weights are how relevant each one was judged to be. Those weights are not fixed. They are computed fresh for every position on every input, which is why the same word contributes differently in different sentences, and why a model can resolve "it" one way in one sentence and the other way when a single adjective changes.

Queries, keys and values#

A useful analogy is a lookup. Each position produces a query describing what it is looking for, a key describing what it offers, and a value which is the information it contributes if selected. Every query is compared against every key, the comparisons become the weights, and the weights blend the values.

query   "I am a pronoun, what noun do I refer to?"
key     "I am a singular concrete noun"
value   the information contributed if selected

match query against every key   ->  a weight per position
blend the values by those weights  ->  this position's output
The three roles are three different learned views of the same position, which is the part that sounds arbitrary and is not: a word needs to advertise something different from what it is asking for.

That last point is the one worth holding on to. If a position had only one representation, what it offers to others and what it seeks from them would be forced to be the same thing, and they are not. The pronoun is seeking a noun and offering the fact that it is a pronoun. Separating query from key lets those be different, and separating value from both lets what gets passed on differ again from what made it match.

Multiple heads#

One set of weights captures one kind of relationship. Several heads run in parallel with different learned parameters, so one can track grammatical agreement while another tracks which noun a pronoun refers to and another tracks position in a list. Their outputs are combined, so the layer can express several relationships at once rather than being forced to choose.

It is tempting to think each head learns one clean, nameable job, and the honest position is that some do and most do not. Interpretability research has found heads with crisp roles — copying a token from earlier, tracking the previous token — and also found that heads share work, change role with context, and rarely divide up as neatly as the explanation suggests. The interpretability topic measures this directly: ablating heads one at a time leaves accuracy at 1.000 for six of the eight, and above 0.99 for the other two, while the logit margin moves by between 1.02 and 5.18, so the ones that look unused are doing something.

The cost#

Every position compares against every position, so doubling the length quadruples the comparisons. This is the single most important consequence of the design, and it is easier to believe as a table than as a sentence.

  tokens     comparisons    vs 1k    KV cache, 8B model
   1,000       1,000,000       1x               0.12 GB
   2,000       4,000,000       4x               0.24 GB
   8,000      64,000,000      64x               0.98 GB
  32,000   1,024,000,000    1024x               3.91 GB
 128,000  16,384,000,000   16384x              15.62 GB
1,000,000 1,000,000,000,000 1000000x           122.07 GB
Arithmetic, not a benchmark: comparisons are length squared, and the cache is what an 8B-shaped model must keep per token to avoid recomputing. At a million tokens the cache alone exceeds a single 80 GB card, which is why million-token context is an engineering achievement rather than a setting.

Two things follow that come up constantly in practice. Long context is expensive in a way that is not linear, so a prompt twice as long is not twice the cost; and the memory that grows is the cache of keys and values for everything already read, which is why generation slows as a conversation lengthens even though each new token is one token. Most of the work in modern architectures — the variants in the next section — is about making one or both of those grow more slowly.

How deep to go#

For applied work this level is sufficient: what attention does, why it replaced recurrence, what the three roles are for, and what it costs. If you can say those in five minutes without writing anything down, you can hold your end of any conversation an AI engineering interview will have about transformers.

Go further in two cases. If you are interviewing for research or for a role that trains models, implement causal multi-head attention from scratch — the from-scratch section in this topic does it in NumPy, and the exercise is an afternoon rather than a week. And if you are debugging inference cost or latency, read the modern-variants section instead, because what you need is not the mechanism but what recent architectures changed about it and why.

Where people go wrong

Learning the mathematics because it feels like the serious version. For applied roles the conceptual model and its cost implications are what gets asked; implementation is an ML engineer question.

Build this: Explain attention out loud in five minutes with no equations, to someone who knows programming but not machine learning. If you cannot, you do not understand it yet.

Open this section on its own page
Section 2 of 4, 6h

From attention to a full language model

By the end: You can describe every component between input text and output token.

  • Embeddings and positional information
  • The transformer block
  • Decoder-only models and causal masking
  • Producing the next token

You know what attention does. This section fills in everything around it: how text becomes vectors, what one block contains, what stops a model reading ahead when it is trained to predict the next word, and how the final vector becomes a token. Having the whole path in your head is worth the hours because most of the practical questions, such as why generation is slow, why streaming exists, or why long context costs what it does, are answered at some specific point along it.

token ids        [9906, 1917, 0]
embedding        one learned vector per id, width d_model
position         rotate or add, because attention cannot see order

repeat N times:
  x = x + attention(norm(x))        mix information across positions
  x = x + feed-forward(norm(x))     transform each position on its own

final norm
unembedding      d_model -> one score per vocabulary entry
sample           scores become probabilities, one is chosen
The whole path. Attention is one line of it, and the next section in this topic covers what modern models changed about each of the others.

Embeddings#

Tokenization turns text into integers, and each integer is a row index into a learned table. Look up the row and you have a vector of width d_model, which for a model of this size is 4,096 numbers. Nothing about that lookup is clever: the table is a plain matrix of vocabulary size by d_model, trained like everything else, and the row for a token is the only thing the model knows about it before any layer has run.

Two consequences follow and both come up in interviews. The embedding table is large because vocabulary is large, so on a small model it is a noticeable share of the parameters and on a large one it is not. And because every token becomes the same width, the rest of the network never has to know how many tokens there are; d_model is fixed and the sequence length is whatever it is.

Why position has to be supplied at all#

This is the part that sounds like a detail and is not. Attention compares every position with every other and blends the results, and none of those operations knows where anything sits. Shuffle the input tokens and you get the same outputs back in a different order. That is not an approximation or a weakness of the explanation; it is exactly true, and you can watch it happen in twelve lines.

import numpy as np

rng = np.random.default_rng(0)
d = 8
Wq, Wk, Wv = (rng.standard_normal((d, d)) / np.sqrt(d) for _ in range(3))

def attention(X):
    Q, K, V = X @ Wq, X @ Wk, X @ Wv
    s = Q @ K.T / np.sqrt(d)
    p = np.exp(s - s.max(axis=1, keepdims=True))
    return (p / p.sum(axis=1, keepdims=True)) @ V

X = rng.standard_normal((4, d))        # four token embeddings, no position anywhere
order = [3, 0, 2, 1]                   # shuffle the tokens

same = np.abs(attention(X)[order] - attention(X[order])).max()
print(f"no position:  shuffling the input just shuffles the output, gap {same:.1e}")

P = rng.standard_normal((4, d)) * 0.5  # one learned vector per slot in the sequence
moved = np.abs(attention(X + P)[order] - attention(X[order] + P)).max()
print(f"with position: the same shuffle changes every value, gap {moved:.3f}")

# no position:  shuffling the input just shuffles the output, gap 4.4e-16
# with position: the same shuffle changes every value, gap 1.378
4.4e-16 is floating-point noise: attention with no position information is exactly permutation-equivariant, which is to say it reads a bag of words. Adding one vector per slot breaks that.

So position is injected from outside. The original models added a learned vector per slot, which is what the second half of the code above does. Current models rotate the query and key vectors by an angle proportional to position instead, which makes the comparison between two positions depend on the gap between them rather than on where each one sits, and removes the fixed-size table that capped how far a model could read. That is rotary position embedding, and the modern-variants section in this topic covers it and the tricks used to stretch it.

Residual connections and normalisation#

Each block adds its output to its input rather than replacing it. Written out, x becomes x plus attention of x, then x plus feed-forward of x. The running vector that every block reads from and writes back into is usually called the residual stream, and it is the closest thing the architecture has to a working memory: a layer that has nothing to contribute at a position can contribute nearly nothing and the information already there passes through untouched.

def cos(a, b):
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))

def rms_norm(v):
    return v / np.sqrt(np.mean(v * v) + 1e-6)

D = 256
g = np.random.default_rng(1)
A = [g.standard_normal((D, D)) / np.sqrt(D) for _ in range(32)]   # 32 blocks
h0 = g.standard_normal(D)
plain = res = h0.copy()

print("layer    no residual          with residual")
for layer, Ai in enumerate(A, start=1):
    plain = np.tanh(plain @ Ai)
    res = res + np.tanh(rms_norm(res) @ Ai)
    if layer in (1, 2, 4, 8, 16, 32):
        print(f"{layer:5d}    cos {cos(plain, h0):+.3f}  |h| {np.linalg.norm(plain):6.3f}"
              f"    cos {cos(res, h0):+.3f}  |h| {np.linalg.norm(res):6.2f}")

# layer    no residual          with residual
#     1    cos -0.026  |h| 10.339    cos +0.843  |h|  19.04
#     2    cos -0.033  |h|  7.728    cos +0.760  |h|  22.35
#     4    cos -0.100  |h|  5.283    cos +0.653  |h|  25.06
#     8    cos +0.003  |h|  3.820    cos +0.479  |h|  33.14
#    16    cos +0.017  |h|  2.723    cos +0.409  |h|  39.62
#    32    cos -0.027  |h|  2.209    cos +0.256  |h|  59.01
Random matrices, not a trained model, so this shows the plumbing rather than a result. Without the skip the output is uncorrelated with its own input after a single layer and the norm decays steadily; with it the input is still visible thirty-two layers later.

That is the case for residuals in one table, and it is the same argument in the forward direction as the usual one about gradients flowing backwards. The cost is visible in the right-hand column: the norm of the residual stream grows as every layer adds to it, reaching 59 after thirty-two layers from a start of about 16. Normalisation is what keeps that under control. Each sub-layer normalises its input before using it, so a block sees a vector of predictable scale whatever the stream has accumulated, and a final normalisation runs before the unembedding for the same reason.

Layer normalisation subtracts the mean, divides by the standard deviation, then applies a learned scale and shift. RMSNorm, which almost everything current uses, drops the mean and the shift: divide by the root mean square and apply a scale. Two learned vectors of width d_model per block, so a few thousandths of one per cent of the parameters, which is worth knowing because it is a common guess that normalisation is expensive. It is not; it is a rounding error in the parameter count and a real cost only in memory traffic.

The feed-forward block, where the parameters actually are#

After attention has moved information between positions, the feed-forward block transforms each position independently: project up to a wider hidden dimension, apply a non-linearity, project back down. Older models used two matrices with a GELU between them. Current ones use a gated variant, SwiGLU, which does the up-projection twice and multiplies one copy through a swish by the other, so there are three matrices instead of two and the hidden width is cut to compensate.

People consistently expect attention to dominate the parameter count, because attention is the part everyone talks about. Work it out for a real configuration and it is not close.

# Shapes taken from a published 8B open-weights config.
V, d_model, layers = 128256, 4096, 32
heads, kv_heads, head_dim, ffn = 32, 8, 128, 14336

parts = [
    ("token embedding", V * d_model),
    ("attention Q", layers * d_model * heads * head_dim),
    ("attention K and V", layers * 2 * d_model * kv_heads * head_dim),
    ("attention output", layers * heads * head_dim * d_model),
    ("feed-forward (3 matrices)", layers * 3 * d_model * ffn),
    ("RMSNorm scales", layers * 2 * d_model + d_model),
    ("unembedding", V * d_model),
]
total = sum(n for _, n in parts)
print(f"{'component':26}{'parameters':>14}{'share':>8}")
for name, n in parts:
    print(f"{name:26}{n:14,}{n/total:8.1%}")
print(f"{'TOTAL':26}{total:14,}{1.0:8.1%}")

attn = sum(n for name, n in parts if name.startswith("attention"))
ffnp = dict(parts)["feed-forward (3 matrices)"]
print(f"\nfeed-forward is {ffnp/attn:.2f}x the whole attention mechanism")
print(f"per layer: attention {attn//layers:,}  feed-forward {ffnp//layers:,}")
for bits in (16, 8, 4):
    print(f"weights at {bits:2d}-bit: {total * bits / 8 / 1024**3:6.2f} GiB")

# component                     parameters   share
# token embedding              525,336,576    6.5%
# attention Q                  536,870,912    6.7%
# attention K and V            268,435,456    3.3%
# attention output             536,870,912    6.7%
# feed-forward (3 matrices)  5,637,144,576   70.2%
# RMSNorm scales                   266,240    0.0%
# unembedding                  525,336,576    6.5%
# TOTAL                      8,030,261,248  100.0%
#
# feed-forward is 4.20x the whole attention mechanism
# per layer: attention 41,943,040  feed-forward 176,160,768
# weights at 16-bit:  14.96 GiB
# weights at  8-bit:   7.48 GiB
# weights at  4-bit:   3.74 GiB
Arithmetic from a published config, not a measurement. 8,030,261,248 is where the marketing figure of 8B comes from, and 70 per cent of it is the part of the block that does not look at other tokens.

Seventy per cent in the feed-forward layers, seventeen across all of attention, thirteen in the two vocabulary matrices, and effectively nothing in the norms. The gap is wider still per layer: 176 million parameters of feed-forward against 42 million of attention, a ratio of 4.2 to 1. Attention gets the attention because it is the interesting mechanism and because its cost grows with the square of sequence length, but in sheer weight the model is mostly a stack of position-wise transformations, which is the usual reading of where a model keeps what it knows.

The same table answers a practical question. Weights at 16-bit are 14.96 GiB, which does not leave much of a 24 GiB consumer card for anything else, and at 4-bit they are 3.74 GiB, which does. The inference-cost section in the quantization topic takes that further, including what the cache on top of the weights costs.

Causal masking#

Language models predict the next token, so each position may only attend to earlier positions. A mask blocks attention to the future. This is what lets the whole sequence train in parallel while still learning left-to-right prediction. Drop the mask and you have an encoder-only model, such as BERT, where every position sees the whole input at once: useful for classification and embedding, and unable to generate text left to right.

The mask is also why the same architecture serves two different jobs. With it you have a decoder-only model that generates. Without it you have an encoder, where every position is conditioned on the whole input at once, which is what you want for classification and for producing a single embedding of a document and useless for writing the next word. Nothing else about the block changes.

Output: from the last vector to a token#

The final vector at the last position is projected to a score for every token in the vocabulary. Sampling from those scores produces the next token, which is appended to the input, and the process repeats. That autoregressive loop is generation.

The projection is the unembedding, a matrix of d_model by vocabulary size, and it is the largest single matrix in the model at 525 million parameters in the table above. Some models tie it to the embedding table, reusing the same weights transposed, which saves those parameters and costs a little quality; the 8B configuration above does not, which is why the two rows are counted separately. Its output is one raw number per vocabulary entry, called logits, and logits are not probabilities. Turning them into probabilities is a softmax, and everything a sampling parameter does happens between those two steps.

vocab = ["the", "a", "this", "my", "an", "our", "some", "no"]
logits = np.array([8.2, 6.9, 5.4, 4.8, 4.1, 3.7, 2.2, 1.0])

def softmax(z, temperature=1.0):
    z = z / temperature
    e = np.exp(z - z.max())
    return e / e.sum()

print(f"{'token':8}" + "".join(f"{f'T={t}':>9}" for t in (0.5, 1.0, 1.5)))
cols = [softmax(logits, t) for t in (0.5, 1.0, 1.5)]
for i, w in enumerate(vocab):
    print(f"{w:8}" + "".join(f"{c[i]:9.4f}" for c in cols))

p = softmax(logits)
order = np.argsort(-p)
cum = np.cumsum(p[order])
keep = order[: int(np.searchsorted(cum, 0.9)) + 1]
print(f"\ntop-p 0.9 at T=1 keeps {len(keep)} of {len(vocab)} tokens: "
      f"{[vocab[i] for i in keep]}")
print(f"the other {len(vocab) - len(keep)} share {1 - cum[len(keep)-1]:.4f} of the mass "
      f"and are dropped before sampling")
print(f"greedy would always pick {vocab[order[0]]!r} at p={p[order[0]]:.4f}")

# token       T=0.5    T=1.0    T=1.5
# the        0.9264   0.7155   0.5495
# a          0.0688   0.1950   0.2310
# this       0.0034   0.0435   0.0850
# my         0.0010   0.0239   0.0570
# an         0.0003   0.0119   0.0357
# our        0.0001   0.0079   0.0274
# some       0.0000   0.0018   0.0101
# no         0.0000   0.0005   0.0045
#
# top-p 0.9 at T=1 keeps 2 of 8 tokens: ['the', 'a']
# the other 6 share 0.0895 of the mass and are dropped before sampling
# greedy would always pick 'the' at p=0.7155
The logits are invented so the block runs with no model; the arithmetic on them is the real thing. Temperature divides the logits before the softmax, so 0.5 pushes the leader from 0.72 to 0.93 and 1.5 flattens it to 0.55.

Three things are worth taking from that. Temperature is a division applied before the softmax, not a scaling of probabilities afterwards, which is why its effect is not linear and why the difference between 0.7 and 1.0 is larger than it looks. Top-p truncates the sorted distribution at a cumulative mass and renormalises, so on a confident step it keeps two tokens and on an uncertain one it keeps dozens; that is the point of it, and the reason it is usually preferred to a fixed top-k. And temperature zero is not a temperature at all, since dividing by zero is undefined: implementations treat it as take the highest logit, which is why it is deterministic in a way no small temperature is.

Then the loop. The chosen token is appended and the whole thing runs again, which is the fact that explains most of what you notice about using a model: output is produced strictly one token at a time so latency scales with answer length, streaming is possible because each token is finished when it appears, and a model cannot revise a token it has already emitted because that token is now part of its own input.

Why generation is slow and the KV cache exists

Each new token requires attention over all previous tokens. Without caching, the keys and values for the whole prefix would be recomputed every step. The KV cache stores them, so each step only computes the newest position, which is why it is essential and why it consumes so much memory on long contexts.

Where people go wrong

Treating the model as a black box that outputs text. Knowing it produces one token at a time from a probability distribution explains sampling, streaming, cost and latency all at once.

Build this: Trace a single token from input text to output probability, naming every component in order, without notes.

Open this section on its own page
Section 3 of 4, 6h

What modern models actually do differently

By the end: You can read a current model's config and say what each named component changes about cost and quality.

  • Grouped-query and multi-query attention, and the cache arithmetic
  • Sliding-window and local attention
  • Rotary position embeddings and context extension
  • SwiGLU and RMSNorm
  • Mixture of experts: total against active parameters
  • FlashAttention, which is exact

Open the config of any open-weights model released recently and you will find a dozen names that no introduction to transformers mentions: grouped-query attention, RoPE scaling, SwiGLU, a router and an expert count. This section goes through them one at a time and says what each changes about memory, speed or quality, with the arithmetic where there is arithmetic. It is the difference between knowing how a transformer works and being able to read what someone is actually shipping.

The model in the paper is not the model you will serve#

The 2017 architecture is the one most explanations still teach: multi-head attention, learned absolute position embeddings, a GELU feed-forward layer, layer normalisation. Every one of those has been replaced in the models being served today. Interviewers ask for the replacements by name, because each is a decision with a cost attached rather than a detail.

Grouped-query attention#

Multi-head attention gives every query head its own key and value heads. Keys and values are what the KV cache stores, so the cache grows with the number of heads. Grouped-query attention keeps all the query heads and lets several of them share one key and value head. Multi-query attention is the same idea taken to a single shared pair for the whole layer.

32 query heads, 32 KV heads   multi-head        full cache
32 query heads, 8 KV heads    grouped-query     cache / 4
32 query heads, 1 KV head     multi-query       cache / 32

KV cache per token = 2 x layers x kv_heads x head_dim x bytes

32 layers, 8 KV heads, head_dim 128, 16-bit   ->  128 KiB per token
the same model with 32 KV heads               ->  512 KiB per token
The cache shrinks by exactly the ratio of query heads to key and value heads. The weights barely change size.

On a served model the cache, not the weights, is usually what caps how many requests fit on a card. Dividing it by four divides the memory cost of concurrency by four, or buys four times the context length for the same memory. This is the single change that made long context affordable, and it is why an 8B model can serve dozens of 8k conversations on one GPU.

The quality cost is small and real, and it grows as sharing gets more aggressive. Multi-query attention loses more and was reported to be less stable to train, which is why grouping, commonly eight key and value heads, is the compromise nearly everyone settled on.

Sliding-window and local attention#

Restrict each position to the last w tokens instead of the whole prefix. Cost per layer becomes linear in sequence length rather than quadratic, and the cache only has to hold w tokens per layer no matter how long the conversation gets.

What it costs is direct reach. Information still travels further than the window, because each layer moves it another w positions, so a stack of L layers reaches about L times w. What is lost is precise retrieval: a single token far outside the window cannot be attended to directly, which is exactly what a long-context lookup needs. Models that use it usually interleave, putting a few full-attention layers among many windowed ones so at least some layers can see everything.

Rotary position embeddings#

Attention treats its input as a set, so position has to be supplied from outside. Early models added a learned vector per position. That has two problems: the table has a fixed number of rows, so there is no vector at all for a position beyond the training length, and it encodes where a token sits rather than how far apart two tokens are.

RoPE rotates each query and key vector by an angle proportional to its position index. The dot product of a rotated query with a rotated key then depends only on the difference between the two positions, so relative distance falls out of the arithmetic instead of being learned. Nothing is added to the values and no table has to be sized in advance.

Extending context afterwards means changing the rotation frequencies so the angles a longer sequence produces stay inside the range the model saw in training. Position interpolation scales them down uniformly; the NTK-aware and YaRN variants scale them unevenly across frequency bands, leaving the high frequencies that carry local order alone. All of them work to a point, and all of them cost some quality, most visibly on precise retrieval inside the stretched range.

A context number in a config is not a measurement

If a model advertises a long context because someone changed the RoPE scaling and nothing else, the model can read that far and may not be able to use it. Test retrieval at the far end of the claimed window before believing the number.

SwiGLU and RMSNorm#

The feed-forward layer used to be an up-projection, an activation such as GELU, and a down-projection. SwiGLU does the up-projection twice, passes one copy through a swish activation and multiplies the two elementwise, so one path gates the other. That is three matrices where there were two, so the hidden width is cut to roughly two thirds to keep the parameter count level. The gate lets the layer suppress channels per token, and it measures slightly better for the same budget. There is no deeper story than that.

RMSNorm drops the mean subtraction from layer normalisation: divide the vector by its root mean square and apply a learned scale, with no shift. One less reduction over the vector, marginally less arithmetic, and no measurable quality cost, which is why it has become the default.

Mixture of experts#

The feed-forward layer is split into many separate experts, and a small router sends each token to one or two of them. The others do nothing for that token. The model gets far more parameters without the compute per token rising to match.

Dense model       8B total parameters      8B active per token
Sparse model    120B total parameters      5B active per token

compute per token    follows the active count
memory to serve      follows the total count

Mixture of experts saves compute, not memory

Every expert has to be resident, because the router may pick any of them for the next token. If you sized the GPU from the active parameter count, you have under-provisioned it by the ratio of total to active, and the model will not load at all.

The other costs are load balancing, which needs an auxiliary term in the loss to stop the router sending nearly everything to a few favoured experts, and communication: when experts are spread across devices, every token's routing decision becomes an all-to-all exchange.

FlashAttention#

Standard attention computes the full matrix of scores for every pair of positions, writes it to GPU memory, reads it back for the softmax, writes it again, and reads it once more to multiply by the values. On a long sequence that matrix is enormous, and moving it is what takes the time rather than the multiplying.

FlashAttention never writes it. It walks over blocks of queries and keys small enough to sit in on-chip memory, keeps a running maximum and sum so the softmax can be accumulated block by block, and builds the output as it goes. The backward pass recomputes the blocks it needs instead of storing them.

It is exact. The output matches standard attention to floating-point noise, it performs slightly more arithmetic rather than less, and the entire win is memory traffic. It also makes attention memory linear in sequence length rather than quadratic, which is what let context windows grow at all. If it appears in a list beside sparse and linear attention as another approximation, that is the thing to correct.

Where people go wrong

Revising the 2017 architecture and describing that in an interview. Nothing in production runs plain multi-head attention with learned absolute positions and a GELU feed-forward layer, and naming what replaced each part, and what it bought, is what is being tested.

Build this: Open the config of two recent open models. For each, work out the KV cache per token from the layer count, key and value head count and head dimension, then recompute it as if every query head had its own. Note which uses RoPE scaling, which uses windowed layers, and whether the parameter count is total or active.

Open this section on its own page
Section 4 of 4, 6h

Causal multi-head attention in NumPy

By the end: You can write causal multi-head attention from a blank editor in NumPy, state the shape of every intermediate, and check the result is causal without running the model.

  • Q, K and V as three projections of the same input
  • Scaled dot-product scores and why the scaling is there
  • The causal mask, and masking before the softmax rather than after
  • A numerically stable softmax
  • Splitting the feature axis into heads and putting it back
  • The two assertions that catch nearly every implementation bug

This is the single most commonly reported from-scratch task in ML engineer and research loops, and it is usually asked without a framework: a blank editor, NumPy, forty minutes. The mathematics is four lines. What actually goes wrong is shapes — a transpose on the wrong axis, a mask broadcast into the wrong dimension, a softmax over the wrong one — so this section states the shape of every intermediate and ends with two assertions that catch most of it.

One head, one sequence#

Start with a single sequence of T positions, each a vector of d_model features. Three learned matrices turn that one input into a query, a key and a value for every position. They are the same input three times, projected differently; there is no separate source for the keys in self-attention.

import numpy as np

rng = np.random.default_rng(0)

T, d_model, d_k = 5, 8, 8
x = rng.normal(size=(T, d_model))                 # (T, d_model)

W_q = rng.normal(size=(d_model, d_k)) * 0.1       # (d_model, d_k)
W_k = rng.normal(size=(d_model, d_k)) * 0.1
W_v = rng.normal(size=(d_model, d_k)) * 0.1

q = x @ W_q                                       # (T, d_k)
k = x @ W_k                                       # (T, d_k)
v = x @ W_v                                       # (T, d_k)
Three projections of one input. d_k need not equal d_model, and in a multi-head model it does not.

Each row of q asks a question; each row of k advertises what that position offers. Comparing every query with every key is one matrix multiplication, and it produces a square matrix of T by T: how much position i should attend to position j.

scores = q @ k.T / np.sqrt(d_k)                   # (T, d_k) @ (d_k, T) -> (T, T)

The division is not decoration. A dot product of two d_k-dimensional vectors with unit-variance entries has variance d_k, so without scaling the scores grow with the feature dimension, the softmax saturates into a near one-hot row, and the gradient through it goes to nothing. Dividing by the square root of d_k puts the variance back to roughly one.

The causal mask#

A decoder may not look ahead. Position i may attend to positions 0 through i and no further, which is exactly the lower triangle of the score matrix including the diagonal. The mask is applied to the scores, before the softmax, by setting the forbidden entries to negative infinity — so that after exponentiating they are exactly zero and the remaining weights still sum to one.

# True strictly above the diagonal: the future, which must not be attended to
mask = np.triu(np.ones((T, T), dtype=bool), k=1)
scores = np.where(mask, -np.inf, scores)          # (T, T), still

print(np.round(scores, 2))
# [[-0.03  -inf  -inf  -inf  -inf]
#  [ 0.11 -0.05  -inf  -inf  -inf]
#  [ 0.03  0.    0.    -inf  -inf]
#  [ 0.02  0.    0.02 -0.02  -inf]
#  [ 0.05 -0.1   0.02 -0.05  0.03]]

Zeroing after the softmax is the classic wrong version

If you softmax first and then multiply the future entries by zero, the rows no longer sum to one: each position keeps a share of its weight that has gone nowhere, and the earlier a position is, the more of its output is silently scaled down. Masking before the softmax is what keeps the weights a distribution over the visible positions only.

Softmax, stably#

Write it yourself rather than reaching for a library, because the stable form is part of what is being asked. Subtracting the row maximum before exponentiating changes nothing mathematically — the constant cancels between numerator and denominator — and it means the largest exponent is always exp(0), so nothing overflows.

def softmax(z, axis=-1):
    # subtracting the max is a no-op mathematically and stops exp overflowing
    z = z - z.max(axis=axis, keepdims=True)
    e = np.exp(z)
    return e / e.sum(axis=axis, keepdims=True)

w = softmax(scores, axis=-1)                      # (T, T), each row sums to 1
out = w @ v                                       # (T, T) @ (T, d_k) -> (T, d_k)

The masked entries survive this correctly: a row's maximum is always a real number, because the diagonal is never masked, so a masked entry becomes negative infinity minus a finite number, and its exponential is zero. No row is entirely masked, so there is no division by zero to worry about here.

The two assertions to run#

Nearly every bug in an implementation of this shows up in one of two checks. Run both before claiming it works, and say out loud that you are running them: an interviewer watching you write your own test is watching the thing they are actually grading.

# every row is a probability distribution over the positions it can see
assert np.allclose(w.sum(axis=-1), 1.0)

# nothing attends to the future: the strict upper triangle is exactly zero
assert np.all(np.triu(w, k=1) == 0.0)
The first catches a softmax over the wrong axis or a mask applied after it. The second catches a mask built with the wrong k or transposed.

Several heads at once#

Multiple heads are not multiple loops. The projections stay one matrix each of shape (d_model, d_model); the head split is a reshape of the feature axis into n_heads chunks of d_head, followed by a transpose that moves heads in front of positions so the score matrix is computed per head by ordinary batched matrix multiplication.

def causal_mha(x, W_q, W_k, W_v, W_o, n_heads):
    B, T, d_model = x.shape                       # (B, T, d_model)
    d_head = d_model // n_heads

    q = x @ W_q                                   # (B, T, d_model)
    k = x @ W_k
    v = x @ W_v

    def split(t):
        # (B, T, d_model) -> (B, T, n_heads, d_head) -> (B, n_heads, T, d_head)
        return t.reshape(B, T, n_heads, d_head).transpose(0, 2, 1, 3)

    q, k, v = split(q), split(k), split(v)

    # transpose the last two axes only: (B, n_heads, d_head, T)
    scores = q @ k.transpose(0, 1, 3, 2) / np.sqrt(d_head)   # (B, n_heads, T, T)

    mask = np.triu(np.ones((T, T), dtype=bool), k=1)         # broadcasts over B and heads
    scores = np.where(mask, -np.inf, scores)
    w = softmax(scores, axis=-1)                             # (B, n_heads, T, T)

    ctx = w @ v                                              # (B, n_heads, T, d_head)
    # heads back behind positions, then flatten them into the feature axis
    ctx = ctx.transpose(0, 2, 1, 3).reshape(B, T, d_model)   # (B, T, d_model)
    return ctx @ W_o, w                                      # (B, T, d_model)
Note the scaling is by the square root of d_head, not d_model: the dot products happen inside a head.

Two details in that function are where people lose marks. The transpose before the score matmul swaps the last two axes and leaves the batch and head axes alone; writing k.T would reverse all four. And the final reshape must follow the transpose back to (B, T, n_heads, d_head), because reshaping straight from (B, n_heads, T, d_head) interleaves positions and heads into nonsense that still has the right shape and will not raise an error.

B, T, d_model, n_heads = 2, 6, 16, 4
x = rng.normal(size=(B, T, d_model))
W_q, W_k, W_v, W_o = (rng.normal(size=(d_model, d_model)) * 0.1 for _ in range(4))

y, w = causal_mha(x, W_q, W_k, W_v, W_o, n_heads)
print(y.shape, w.shape)                           # (2, 6, 16) (2, 4, 6, 6)

assert np.allclose(w.sum(axis=-1), 1.0)
assert np.all(np.triu(w, k=1) == 0.0)

# a third check worth knowing: causality means a prefix is unaffected by what
# follows it, so truncating the input must not change the earlier outputs
y_short, _ = causal_mha(x[:, :4], W_q, W_k, W_v, W_o, n_heads)
assert np.allclose(y[:, :4], y_short)
That last assertion is the property a KV cache depends on. If it fails, the mask is wrong somewhere.

What is deliberately not here#

This is attention, not a transformer block. A block wraps it in a residual connection and a normalisation, then a feed-forward layer with its own residual, and a real model adds positional information — rotary embeddings, applied to q and k before the scores, in every current model. Modern attention also shares keys and values across heads rather than giving each its own. Those are covered elsewhere in this topic; if an interviewer asks for attention, give them this and then say what would surround it.

Where people go wrong

Reshaping the head axis back without transposing first. Both orders produce an array of shape (B, T, d_model), NumPy raises nothing, and the model trains to a mediocre loss instead of failing — the features of different heads end up interleaved across positions. Transpose to (B, T, n_heads, d_head) and only then reshape.

Build this: Write causal multi-head attention in NumPy from a blank file, with no reference open, in under forty minutes. Run all three checks: rows summing to one, an upper triangle of exactly zero, and prefix invariance under truncation. Then compare your output against torch.nn.functional.scaled_dot_product_attention with is_causal=True on the same weights and confirm it matches to floating point tolerance.

Open this section on its own page

Where you use this

A topic is easier to care about when you can see what it is for. These projects lean on it at a named stage.

Build your own GPT
  • Stage 3, One head of self-attention: The mechanism itself: what Q, K and V are and what the weights mean.
  • Stage 4, Many heads, and the block around them: How the pieces around attention fit together into a block you can stack.
  • Stage 5, The whole model: What sits either side of the stack of blocks, and why position has to be added at all.
  • Stage 7, Make it write: Why only the last position's logits matter when generating.

Now actually do it

Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.

1Explain attention in five minutes, out loud

4h

To someone who programs but does not know machine learning, with no equations. Record it and listen back.

Done when: They can explain it back to you afterwards.

2Implement attention from the equation

4h

Write scaled dot-product attention and then multi-head attention in NumPy, working from the maths rather than reference code. Check against a framework implementation.

Done when: Outputs match a reference implementation on the same input.

8 hours of practice

Words used here

19 terms this topic teaches, each with a one-line definition if one of them stopped you.

Transformer
The architecture behind almost every current language model: a stack of identical blocks, each pairing attention with a per-position feed-forward network, wrapped in normalisation and residual connections.
Attention
A layer in which every position pulls information from other positions, weighting each one by how relevant it is. The weights are computed from the input itself, so which positions matter changes with every sequence.
Self-attention
Attention where queries, keys and values all come from the same sequence, so each position looks at the other positions of that same input.
Multi-head attention
Several attention computations run in parallel over different learned projections of the same input, then concatenated and projected back. Each head can track a different kind of relationship.
Query, key and value
The three vectors each position produces in attention: what it is looking for, what it offers to others, and what it passes on when chosen. A query scored against a key decides how much of that key's value is read.
Causal mask
A constraint that stops each position attending to anything after it, applied by forcing those scores to negative infinity before the softmax. It is what lets a model generate: during training, position t only ever sees what preceded it.
Positional encoding
Information about where a token sits in the sequence, mixed into its representation. Attention treats its input as a set, so without it "dog bites man" and "man bites dog" are the same input.
RoPE
Position encoded by rotating query and key vectors through an angle proportional to their index, so an attention score depends only on the distance between the two positions. Standard in modern open models, and the reason their context can often be stretched after training.
RMSNorm
Layer normalisation with the mean subtraction dropped: divide the vector by its root mean square and apply a learned scale. Slightly cheaper, works just as well, and so has replaced layer norm in most recent models.
Autoregressive
Generating one element at a time and feeding each output back in as input for the next. It is why a reply's first token costs one pass over the prompt and every token after it costs another pass.
Decoder-only model
A transformer built from a single causally-masked stack that continues its input. Nearly every chat and completion model is one.
Encoder-only model
A transformer with no causal mask, so every position sees the whole input at once. Good for classification, embedding and reranking, and incapable of generating text left to right.
Grouped-query attention
Several query heads sharing one set of key and value heads, shrinking the KV cache by the sharing factor. Most large open models use it, because the cache rather than the weights is what caps batch size.
Multi-query attention
Every query head sharing a single key and value head — grouped-query attention taken to its limit. The smallest possible cache, at some quality cost, which is why grouping is the usual compromise.
Sliding-window attention
Restricting each position to attend only to the last w tokens instead of the whole prefix, making cost linear in sequence length rather than quadratic.
FlashAttention
An attention implementation that never materialises the full attention matrix in GPU memory, computing it in tiles inside fast on-chip memory instead. Several times quicker, and a large part of why long contexts are affordable.
Mixture of experts
A feed-forward layer split into many separate sub-networks, with a router sending each token to only one or two of them. Total parameters rise sharply while compute per token stays flat.
SwiGLU
The feed-forward activation used in most current models: a gated linear unit with a swish activation, needing three weight matrices where a plain layer needs two. Slightly better loss per parameter than ReLU or GELU, at the same cost once widths are adjusted.
Feed-forward network
The per-position half of a transformer block: expand to roughly four times the model dimension, apply a non-linearity, project back. Attention moves information between positions; this is where it gets processed.

Interview questions

17 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.

Attention, conceptually

Explain how attention works.

Each position produces a query, a key and a value. The query is compared against every key to produce weights, and the output is a weighted mixture of the values. It lets any position draw directly on any other regardless of distance, and unlike recurrence it parallelises across the sequence.

Then they push further

Why divide by the square root of the key dimension?

Dot products grow with dimension, so without scaling the scores get large, softmax becomes extremely peaked and gradients vanish. Dividing by the square root of the dimension keeps the scores in a workable range.

What changes in a decoder-only model?

A causal mask stops each position attending to later ones. The model can then learn to predict the next token at every position at once during training, and generate one token at a time afterwards.

What does the KV cache store, and why?

The key and value vectors of tokens already processed. For each new token only its own query, key and value are computed and the past ones are reused. Generation gets much faster, at the cost of memory that grows with sequence length and batch size.

What gets marked up. Strong answers explain queries, keys and values in plain terms, the scaling, and the mask, and connect them to the cost of long context. Reciting the formula without explaining it is marked down.

Why did transformers replace recurrent networks?

Recurrence processes sequentially, so distant information degrades through a carried summary and nothing parallelises across the sequence. Attention connects any two positions directly and computes all positions at once, which is what made training at scale practical.

Why is long context expensive?

Attention compares every position with every other, so cost grows with the square of sequence length. Doubling the input quadruples the work, which is why context limits exist and why efficient attention is an active research area.

What do multiple attention heads do?

Each head learns a different kind of relationship, such as grammatical agreement in one and topical relevance in another, and their outputs are combined. One set of weights can only represent one notion of relevance.

From attention to a full language model

What does causal masking do?

It prevents each position attending to later positions, so the model learns left-to-right prediction. It also lets the full sequence be trained in parallel, since every position predicts its next token at once.

What is the difference between attention and the feed-forward layer?

Attention moves information between positions. The feed-forward layer transforms each position independently. A block alternates the two, with residual connections around each.

Then they push further

Which holds more parameters?

The feed-forward layers, by a long way. On a typical 8B configuration they are about 70 per cent of the model against 17 per cent for all of attention, or 176 million per layer against 42 million. Attention gets discussed more because its cost grows with the square of sequence length, not because it is where the weights are.

Why does a transformer need position information at all?

Attention compares every position with every other and blends the results, and none of those operations knows where anything is. With no position signal the model is exactly permutation-equivariant: shuffle the input tokens and the outputs come back in the same shuffled order, unchanged. Position has to be injected from outside, either as a vector added per slot or, in current models, by rotating queries and keys by an angle proportional to position so the comparison depends on the gap between two tokens.

What are residual connections and layer norm for?

Two different jobs. The residual makes each block add to a running vector rather than replace it, so information survives dozens of layers and gradients have a short path back; without it the output of a deep stack is uncorrelated with its own input. Normalisation keeps the scale of that running vector predictable, since every block adds to it and the norm grows with depth. Current models use RMSNorm, which divides by the root mean square and applies a learned scale, and it is a rounding error in the parameter count.

What is the KV cache?

Stored keys and values for tokens already processed, so each generation step computes only the new position rather than recomputing the whole prefix. It makes generation practical and consumes significant memory on long contexts.

What modern models actually do differently

What is grouped-query attention and why does it matter?

Several query heads share one key and value head instead of each having its own. The KV cache shrinks by exactly that ratio, so a model with 32 query heads and 8 key and value heads has a quarter of the cache. Since the cache rather than the weights usually caps concurrency, that is four times the requests or four times the context for the same memory. Quality drops slightly, and more so for multi-query attention with a single shared pair, which is why eight is the usual compromise.

Then they push further

You need more concurrency still. What next?

Quantize the cache, cap context length, use an engine that allocates cache in pages so nothing is reserved and wasted, and consider a model with windowed attention layers, where the cache stops growing past the window.

Does it make the weights smaller too?

Only slightly. The key and value projection matrices shrink by the same ratio, but they are a small share of a layer's parameters. The cache is where the saving is.

What gets marked up. Strong answers give the ratio and connect it to concurrency rather than to speed. Saying it makes the model faster without mentioning the cache misses the point.

Why did rotary position embeddings replace learned absolute positions?

A learned table has a fixed number of rows, so there is nothing for a position beyond the training length, and it encodes absolute position when what attention needs is relative distance. RoPE rotates queries and keys by an angle proportional to position, so their dot product depends only on the gap between them. Extending context means rescaling those frequencies, which works to a point and costs some retrieval quality.

A model card says 120 billion total parameters and 5 billion active. What does that tell you about serving it?

It is a mixture of experts: a router picks a couple of experts per token, so compute per token is that of a 5 billion parameter model while all 120 billion must sit in memory. It will be cheap per token and expensive to host. Sizing the GPU from the active figure is the standard mistake.

Then they push further

What is harder about training it?

Keeping the router balanced, which needs an auxiliary loss term or the router collapses onto a few experts, and the all-to-all communication when experts live on different devices.

Is FlashAttention an approximation?

No. It computes exactly the same attention, tiling the work so blocks fit in on-chip memory and using a running softmax so the full scores matrix is never written to GPU memory. It does slightly more arithmetic and far less memory traffic, which is the whole speedup, and it makes attention memory linear in sequence length.

Causal multi-head attention in NumPy

Implement causal self-attention in NumPy.

Project the input three times to get q, k and v of shape (T, d_k). Compute scores as q @ k.T divided by the square root of d_k, giving (T, T). Set the strictly upper triangle to negative infinity, softmax each row, then multiply by v to get (T, d_k). For multiple heads, reshape the feature axis into (n_heads, d_head), transpose heads in front of positions, do the same computation batched, transpose back and reshape, then apply the output projection.

Then they push further

Why negative infinity rather than zeroing the weights afterwards?

Because the softmax has to normalise over the visible positions only. Zeroing after the softmax leaves each row summing to less than one, and the deficit is worst at early positions, which quietly shrinks their outputs. Negative infinity makes the exponential exactly zero before normalisation, so the surviving weights still sum to one.

Do you scale by the square root of d_model or of d_head?

d_head. The dot product happens inside a head, between vectors of length d_head, so that is the dimension whose variance needs correcting. Scaling by d_model would under-scale by the number of heads.

How would you convince me it is correct without a reference implementation?

Two assertions. Each row of the attention weights sums to one, which catches a softmax on the wrong axis or a mask applied too late. The strict upper triangle of the weights is exactly zero, which catches a mask built with the wrong offset or transposed. A third is that truncating the sequence leaves the earlier outputs unchanged, which is causality stated as a property rather than as a shape.

What gets marked up. Strong candidates state the shape of each intermediate as they write it and test their own code unprompted. Getting the mask right before the softmax, and scaling by d_head, separate people who have written this from people who have read it.

Walk through the shapes of multi-head attention for a batch of 2, length 6, d_model 16 and 4 heads.

Input (2, 6, 16). Each projection keeps (2, 6, 16). Reshape to (2, 6, 4, 4) and transpose to (2, 4, 6, 4), so heads sit in front of positions. Scores are (2, 4, 6, 6), one square matrix per batch item per head, and the mask (6, 6) broadcasts over the first two axes. Weights times values gives (2, 4, 6, 4); transpose back to (2, 6, 4, 4), reshape to (2, 6, 16), then the output projection leaves it there.

Why does the softmax need the maximum subtracted?

Purely for numerical stability. Exponentiating a large score overflows to infinity in floating point and the division then gives NaN. Subtracting the row maximum is algebraically a no-op because the constant factors out of numerator and denominator, and it guarantees the largest exponent is exp(0), so nothing overflows and the smallest underflows harmlessly to zero.

Your attention weights sum to 0.6 on the first row and 1.0 on the last. What is wrong?

The mask is being applied after the softmax rather than before. The first position can see one entry out of many, so once the remaining entries are zeroed most of its normalised weight has been thrown away; the last position can see everything, so nothing is lost. Move the mask to the scores and use negative infinity.

These questions on their own page, one link per question

Resources

Free to watch and read, picked for this topic.

Stop 26 of 50 on the AI Engineer route

0 of 50 topics done · 0h behind you, 603h to go

Next on this routeFine-tuningWhen prompting is not enough, and it usually is.18h

What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.