RockDesk

What Attention Actually Does

This page answers two questions: what attention concretely does, and why the FFN widens first and then shrinks back.

The reference object is the classic network (MLP) trained to a perfect score in part one: the same 9×9 times table, done again with a transformer, taken apart side by side. You can read this without part one — it only uses three of its conclusions, listed below.


Three conclusions from part one (the full article goes from plain arithmetic all the way to backpropagation):

That MLP's input is x = onehot(a) ++ onehot(b) — of 20 slots, only slot a and slot 10+b are 1 (this is one-hot). Matrix multiplication therefore collapses into picking two columns and adding them: column a of W1 — those 24 numbers — is the model's entire representation of the digit a.

So its pathway is static: a travels down column a because of which slot it was placed in, regardless of what digit it is — the wiring is welded at the factory.

Its 2,554 parameters were trained by hand to 100/100. This page retrained it with the same code at load time (check the tally in “both models right now” at the bottom).

03The turn: where welded wiring gets stuck

Look back at the input line:

x = onehot(a) ++ onehot(b)

This line presupposes that a and b have already been extracted and placed into fixed slots.

But real input is a sequence:

7   *   8   =

Which one is a, which is b, at which position? Order varies, length varies, other tokens can sit in between (a token is the smallest unit the model processes; here it's a single character, vocabulary of 13: 09, *, =, ;) — say, multiply 8 by 7. The line can no longer be written.

Part one could write that line because we picked a and b out for the model. Stop doing that, and the convention “slot a holds a” cannot hold — which position to read depends on the content of this particular problem.

“Which position to read” turns from a hard-wired convention into a quantity computed fresh on every forward pass.

That is the entire reason attention exists.

token → vector: one-hot collapses again

Each token first becomes a vector of its own — four parallel tracks, initially blind to each other. This step is a lookup in the embedding table (one learned row per token), and it's an old acquaintance:

Embedding lookup = one-hot times a matrix this time it collapses to “take one row”

Train it first — you already know this whole step

Not a line of structure has been explained; why train first? Because the theory of training was finished in part one, and switching models changes not one word of it: the right-hand side below is the same loop — draw a batch, forward, backward, average the slopes, one Adam step; only the inside of fwd/bwd goes from 4 tables to 14 tables, 9,440 numbers in all.

Train it to a perfect score first — everything below dissects this live specimen; every figure reads its numbers straight off it.

The transformer also starts from random init 1 layer · 4 heads · 32 dims · 9,440 params

Slower than the MLP — a thousand-plus steps: it also has to learn “where to read”. Click it and keep reading while it trains.

The forward pass — single-step those 39 lines of attention

The model stands at the = position, about to output the first character of the answer. All it holds is ='s own 32 dimensions — it must fetch 7 and 8 first. Fetch them from where?

The classic form first — the matrix shape used by the paper and every textbook, eight steps: project Q, K, V; score with Q·Kᵀ; divide by √d_head; set the “future” to −∞ with the causal mask; softmax each row; multiply the weights back onto V. Each line's comment is that step's logic; this code is not an illustration — it is what this page runs, reconciled bit-for-bit against the production implementation at load time:

Attention, classic form — Q·Kᵀ/√d_head → mask → softmax → ·V real code; reconciled bit-for-bit at load against the 39-line production version

The single-stepper below walks the production implementation of the same thing — the matrices written out over one block of memory, cell by cell. Classic form for shapes and logic; production form to see where every number comes from:

Transformer forward — walked line by line, same as before bottom right switches “1st char / 2nd char”

Stop at the table-building segment: s += q[t * d + h * dh + i] * k[u * d + h * dh + i]softmaxInto(att, base, t + 1)

The MLP's forward pass has no such segment — it never asks “where to read”; a statically occupies slot a.

And this row of weights is computed from the content of the current input — a different problem gives a completely different row, while Wq and Wk haven't changed by a single number.

What softmax is (used in part one to build the loss; restated here): take a row of scores, raise e to each (everything becomes positive, gaps get stretched), then divide each by the total — out comes a row of numbers that are non-negative and sum to exactly 1.

Every row of every weight table above is made this way — pick any row, add up its cells: exactly 1. That property, “each row sums to 1”, becomes the main character when we get to multi-head.

What entitles the dot product q·k to act as the “where to read” score? — At the factory, nothing. Wq and Wk start as random numbers, and this row of scores is a heap of meaningless values (click “forget everything, start over” on the training panel above, then look at the tables — verifiable on the spot).

Training makes it so: the loss only comes down when the right positions get read, so the slopes push “the q·k pairs that should be read” ever larger and the rest smaller. “Relevance” is not the dot product's innate meaning; it is a division of labor forced out of these 9,440 parameters by the loss — the same inverted causality as part one's “L was composed for the training method from the start.”

“Standing at the last position, deciding where to read” — this sentence is not a metaphor; every phrase maps to one step of computation:

“standing at the last position” ⇒ the next character is read only from the last position's vector — real source of generate():
   const t = tok.length - 1; … A.prob[t * V + c]        # emitting looks only at row t; no other row takes part

“where to read” ⇒ that position's vector is assembled like this (the move-V source above):
   for (let u = 0; u <= t; u++) s += att[…t*T + u] * v[u*d + …]
   # row t of att = in what proportion each position's v gets mixed into the vector about to emit

“deciding, each time” ⇒ this row of proportions is recomputed on every forward pass (q·k → softmax, just derived) — not looked up

So “where to read” = the name given to “the row of weights in a weighted sum”: wherever row t carries big weights, those positions' v flow into the emitting vector in big proportion — there is no other mechanism. Whenever the text below says “the table asks where to read”, it means these three steps.

The three names q, k, v are likewise derived — the role is not in the numbers, it's in the subscripts. Watch the subscripts take their posts in those two source segments:

s += q[t*d + …] * k[u*d + …]     # q always carries t — only the emitting row uses its own q; k always carries u — every position's k is queried again and again by all later rows
o += att[t,u] * v[u*d + …]        # v takes no part in scoring; only after the scores are set is it carried off, multiplied by the weight

One vector times three different tables gives three projections, and nothing in the numbers labels any of them with a role. The role comes from where the subscript sits: the one carrying t, used only by its own row, is conventionally the query (what you ask with); the one carrying u, looked up repeatedly by other rows, is the key (what you hang out to be searched); the one that never scores and is only carried off by the weights is the value (what actually gets delivered). — Like “where to read”, the names label positions in the formula, not three inherently different things.

The routing is computed live — three on-the-spot proofs

Parameters are static; routing is dynamic — Wq and Wk never move after training, yet “who reads whom” differs on every problem. Three proofs, click each:

Proof ① Feed one character, the table grows one row and one column

Same problem, fed one character at a time from the first; after each, take the last row once — as derived above, that row is the proportion in which each position's v mixes into the emitting vector; the table is stamped fresh at every step, not looked up:

From 1 token to 5 — rows are heads, columns are positions top: each step's “last position reads whom”; bottom: the full 5×5 with the causal mask

Proof ② Swap the multiplicands, the wiring flips wholesale

Same model, same parameters, two problems that differ only by swapping the multiplicands — the pair is chosen by the page itself (all 45 pairs tried; the one whose wiring changes most):

Same parameters, wiring flipped wholesale thicker line = larger weight

Proof ③ It reads a token it generated itself

The answer to 7*8= has two digits: after emitting 5 it must emit 6. Switch the stepper above to “2nd char” and check the computed weight row for a line connecting to position 4 — the 5 sitting there is a token the model itself generated one step ago:

Does it read its own freshly generated token? tallied across all 100 problems

While we're here, derive the four “why is that there” — the designers' view

The source above walked past four unexplained parts. Each has one forced line of reasoning:

Position vectors (the + Pe inside emb) — because in the attention formula, u is just a summation index:
  scoring sees only the content of k[u], moving sees only the content of v[u]; swap two tokens and the scores travel with them
  ⇒ without position vectors, “7*8” and “8*7” are the same problem in its eyes — “which position” simply doesn't exist as information; it can only be added in

Causal mask (the u ≤ t bound inside att) — because the training target is the next character, and the next character is lying right there in the input:
  bwd source: const tgt = t + 1 < T ? tok[t + 1] : -1   # the answer row t must predict is slot t+1 of the input sequence
  ⇒ allowing a look ahead = allowing copying the answer: loss collapses to 0 and nothing is learned; at generation time the future doesn't exist yet anyway

Residual (the two add(x, …)) — what got carried in is an increment, and must not overwrite the self:
  x still holds the token + position information the FFN and the emitter need later;
  backprop also gets a free through-lane: bwd source dx1[i] += dx2[i]   # slopes pass through the plus sign unchanged — trainable even when deep

LayerNorm (the three ln) — because this page's two most sensitive parts both fear scale:
  the dot product sums dozens of terms, and softmax then raises e to them — drift a little and it saturates (the same account as √d_head)
  ⇒ each layer first pulls every position back to a fixed scale, so scoring stays in the usable range

Multi-head — one head is one reading quota

First separate two words that get blurred together (q, k, v were taken apart at stepper step 3: what I ask with / what I am / what I hand over when chosen):

Two unrelated adjectives. This model is self-attention + 4 heads.

How exactly “cut into segments” cuts is worth a picture — the crux of attention is this one cut:

How multi-head actually cuts — the crux is this one cut four colors = four heads; cells drawn live from the current problem

First, to be clear: a single head also has attention. Attention is the whole kit — “score with q·k → softmax → move v by the weights”; a single head runs that kit on the full 32 dimensions and produces one T×T table. Multi-head is not a precondition of attention; it is a refinement of it.

Refining what? — exactly the softmax quota below.

The key is softmax: it forces a single head's weights to sum to exactly 1. So one head is one fixed reading quota — give a nine tenths and only one tenth is left for b. And this task needs both operands read clearly. Multi-head = several mutually independent quotas, able to hold several spots at once.

The cost must be stated too: total width is fixed at 32 dimensions — more heads, narrower heads; at 8 heads each gets 32÷8 = 4 dims. It is a trade: more quotas for each seeing more coarsely. Which matters more on this task — the experiment below trains it in front of you.

1 / 2 / 4 heads: same problem, the tables they each learn trains 3 models live; the orange-framed row = standing on “=”, about to emit the answer

04FFN: why widen and then shrink back

The operands have been fetched. What happens next was finished entirely in part one: add two vectors, pass a nonlinearity, look up the answer — it is that MLP. The only difference: here the “two vectors” were fetched by the model itself.

So one transformer layer is just two things:
① compute where to read, fetch what's needed into hand (attention)
② run an MLP on the spot (this MLP is called the FFN)

One question remains: why is this MLP 32 → 64 → 32?

The answer sits in one sentence from part one

GELU acts dimension by dimension — how dimension 3 is treated depends only on dimension 3's own value.

It cannot express “activate when dimensions 3 and 7 are large at the same time”; it can only judge each dimension alone. Hence a hard constraint:

To give a feature its own gate, that feature must first be placed on a dimension of its own.

But the residual stream (the main vector running the whole model, everything adding onto it) is only 32 dims, and must also carry position, token, and intermediate conclusions. Features needing private gates far outnumber 32.

So widen to 64 first — one feature, one dimension, each with its own gate; after the gates, compress back to 32 and pool.

Widening builds coordinates; shrinking collects conclusions — they are not inverse operations: W2 is not the inverse of W1, it is an independently learned table.

Negative check: remove the nonlinearity

If this explanation holds, then removing GELU should make those 64 middle dimensions instantly meaningless — provable without training; it is the sentence above.

Remove GELU and the two matrices merge into one computed with the real weights you just trained

And in actual training? Same structure, same steps, only no nonlinearity:

Actually delete GELU and retrain one trains live, ~15 seconds

What lives in those 64 dimensions

64 middle units' activations over the 100 problems click one for a close-up

If “one feature, one dimension” holds, it should work in reverse: given a concept, search the 64 dimensions for the one encoding it.

Name a concept, search for its unit if none is found, it says so

Widening does not buy capacity

Intuition says widening buys “more room to store things”. That claim can be measured directly: stack the 100 problems' activations into a 100×64 matrix and run principal component analysis (splitting the data into mutually independent directions, ranked by how much variation each explains), and count how many directions it takes to hold 90% of the variation.

How many truly independent directions live in the 64 PCA, computed live

05Summary: what actually changed between the generations

Two pipelines, the single difference
MLP (part one)
—— this step doesn't exist ——
1 a occupies slot a, b occupies slot b (static)
2 take two columns, add → nonlinearity → lookup
3 output 56
transformer (this page)
standing on =, first compute “which positions to read” — from current content, fresh on every problem
1 fetch those positions by the computed weights
2 add → nonlinearity → lookup (the MLP on the left)
3 output 5, feed it back in, run again for 6

The lower halves are identical. The entire difference is the bold step.

What does attention concretely do?
It turns “which dimension to read” from a static positional convention into a quantity computed fresh on every forward pass. Parameters stay static; routing becomes dynamic.

Why does the FFN widen then shrink?
The nonlinearity acts per dimension; a feature needs a private coordinate before it can have a private gate. Widening builds coordinates, shrinking collects conclusions — not inverse operations.

Both purchases are the same commodity: exclusivity. A single head's quota is locked to 1 by softmax — exclusivity there takes more heads; one dimension holds one gate — exclusivity there takes more dimensions.

Both models right now

Reading the two parts together: the model in part one isn't bad — its wiring was welded in advance by a person, and the moment the input's shape changes, that convention collapses. The one step this page adds hands “where to read” back to the model, computed from content. Handling sequences of any length and any order starts from this step.

Nothing here compared accuracy — the 9×9 table has only 100 problems and both models ace it. Its value is being small enough to draw every single wire.

Both models total one file, lab.en.js, 1,014 lines, zero dependencies, forward and backward fully handwritten (no autograd) — the lines in the code panes above are the lines actually running.

Playable from the console: inside.mlp / inside.tf are the two models, inside.tf.fwd(Lab.SEQS[78]) returns every intermediate quantity of 7*8=, inside.redraw() refreshes all figures.

Bigger, trainable on real text: the online playground. The transformer's overall structure: Learn home.