decoder · illustrated
The Illustrated Decoder
How a GPT-style model writes — one clear figure at a time, in the spirit of Jay Alammar's Illustrated Transformer. Every figure zooms: click it, scroll to magnify, drag to pan. And every figure ends at the real thing: the exact lines of nanoGPT that implement it, linked to annotated study notes that walk the code line by line.
prefer to hold it? ⚙ open the interactive 3D machine · prefer a ride? ▶ run the forward pass · prefer code? ⌨ the annotated nanoGPT
figure 1 · the map
The decoder, in one look
Everything that follows is this one picture. Tokens enter at the bottom, become vectors, pass through the repeated block — attention, then a feed-forward network, each adding its update onto the residual stream — and end as a probability distribution over the vocabulary. One token is sampled and loops back into the context.
click any figure to zoom · scroll to magnify · drag to pan
in nanoGPT
tok_emb = self.transformer.wte(idx) # ids -> vectors
x = self.transformer.drop(tok_emb + pos_emb)
for block in self.transformer.h: # the repeated block, ×12
x = block(x)
x = self.transformer.ln_f(x)
logits = self.lm_head(x) # vectors -> vocab scores
This whole page is one short method. The for-loop is the
transformer — everything else is get-in (embeddings) and get-out (final
norm + head). Every block takes and returns the same shape (B,T,C),
which is exactly why they stack.
figure 2 · autoregression
One token per pass
The model has no plan and writes no drafts. Each pass through the stack predicts exactly one next token, conditioned on everything before it — including its own previous output. Appending that token and running again is the whole writing loop.
in nanoGPT
for _ in range(max_new_tokens):
idx_cond = idx[:, -self.config.block_size:] # crop context to the window
logits, _ = self(idx_cond) # one full forward pass
... # pick one token — figure 9
idx = torch.cat((idx, idx_next), dim=1) # append, run again
The writing loop really is this small: forward, pick,
append, repeat. The crop on the first line is the model's hard memory
limit — only block_size (1024) position vectors exist, so once the
text outgrows the window, the oldest tokens simply fall off.
figure 3 · embeddings
Tokens become vectors
Each token id looks up a learned column of numbers, and a positional pattern is
added so word order survives — below, the real arithmetic: E[8402]
plus the sin/cos ladder PE(2), summed element by element into
x. From here on the model never sees words again —
only geometry, where nearby meanings sit at nearby points.
in nanoGPT
tok_emb = self.transformer.wte(idx) # token lookup table (50304 × 768)
pos_emb = self.transformer.wpe(pos) # position lookup table (1024 × 768)
x = self.transformer.drop(tok_emb + pos_emb)
Both are lookups, not matmuls —
nn.Embedding just selects a row, and training tunes what each row
means. One honest difference from the figure: the sin/cos ladder is the classic
2017 recipe, while nanoGPT (like GPT-2) learns its position
vectors — wpe starts random and converges by backprop. Same job
either way: inject order into an otherwise order-blind stack.
figure 4 · the atom
Matrix multiplication, by hand
Before anything else: the one operation underneath it all. Watch each output cell get computed — a row from A dotted with a column from B, products summed, cell filled. Every projection, every attention score, and every layer below is exactly this, just billions of times larger.
in nanoGPT
# .view() / .transpose() — relabel & re-file, no math
# @ — the one op that actually computes
att = q @ k.transpose(-2, -1) # (B,nh,T,hs) @ (B,nh,hs,T) -> (B,nh,T,T)
The way experts read PyTorch: track the shape after every
line, not the words. In the whole attention class, @ appears
exactly twice — the scores and the blend; every reshape around them moves no
numbers. And shape-legal ≠ correct: the notes walk through how matmul-ing the
wrong pair of axes runs without error and silently computes garbage.
figure 5 · q · k · v
Three lenses on every token
Three learned weight matrices project each vector into a query (what am I looking for?), a key (what do I contain?), and a value (what do I pass on?). These matrices are the weights — what training learns and fine-tuning reshapes. On the right: one output cell being computed live, term by term.
in nanoGPT
self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias)
q, k, v = self.c_attn(x).split(self.n_embd, dim=2) # 768 -> 2304, sliced back
All three lenses are one Linear (768 → 2304) —
one big matmul beats three small ones on a GPU — then .split slices
q, k, v apart, pure bookkeeping. And a division of labor the figure can't show:
q and k only ever decide how much; v alone decides what — once
the weights are computed, q and k never appear again.
figure 6 · masked attention
Score, mask, softmax, blend
Attention is four small steps: score every pair of tokens, mask the future so a decoder can only look back, softmax the scores into weights that sum to one, and blend the values with those weights. That's the entire mechanism this site is named after.
in nanoGPT
att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1)))
att = att.masked_fill(self.bias[:,:,:T,:T] == 0, float('-inf'))
att = F.softmax(att, dim=-1)
y = att @ v
Score, mask, softmax, blend — one line each.
exp(−inf) = 0, so the softmax is where causality actually
lands: future tokens get exactly zero weight. On PyTorch ≥ 2.0 nanoGPT replaces
all four lines with one flash-attention call (line 64) — same math, computed
with running totals so the T×T grid is never materialized.
figure 7 · multi-head
Eight readers, one sentence
That mechanism runs eight times in parallel, each head in its own low-dimensional subspace — one tracks syntax, another coreference, another position. Their readings are concatenated and mixed back together.
in nanoGPT
k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
# ...attention runs on every head at once, as a batch dimension...
y = y.transpose(1, 2).contiguous().view(B, T, C) # stitch heads back together
Heads are a reshape, not separate modules: the 768-wide tensor is relabeled into 12 slices of 64, the batched matmul treats each slice as its own little attention, and the outputs are stitched straight back. Across 12 layers × 12 heads, GPT-2 small runs 144 distinct readers, each with its own learned weights.
figure 8 · residual + ffn
The stream and the knowledge layer
Nothing overwrites anything. Each sublayer reads the residual
stream, computes a small update, and adds it back — watch it happen
with real numbers below: the stream [1.0, -0.5, 2.0] gets nudged,
element by element, never replaced. Then layernorm recenters and rescales so
values stay tame across dozens of layers. The FFN itself is just two matrix
multiplications around a gate: expand to 4d, gelu, project back — with d = 768
that's ≈4.7M weights per layer, which is where most of the "knowledge" lives.
in nanoGPT
def forward(self, x):
x = x + self.attn(self.ln_1(x)) # gather across tokens, ADD it back
x = x + self.mlp(self.ln_2(x)) # think within each token, ADD it back
return x
The entire transformer block is these two lines, stacked
12 times. Look where the norms sit (pre-LN): each wraps a sublayer's
input, and the raw un-normalized x is what's added — nothing ever
rescales the stream itself, and that clean highway is why 12–96-layer
stacks train at all. The mlp being called is just widen → gelu →
shrink — and those two fat matrices hold roughly two-thirds of the
model's parameters.
model.py:94–106 · Block + model.py:78–92 · MLP · block notes · mlp notes
figure 9 · sampling
From vector to word
After N layers, one projection scores every word in the vocabulary, softmax turns scores into probabilities, and one token is sampled — temperature decides how adventurous the dice are. The winner drops back into the context, and the loop runs again.
in nanoGPT
logits = logits[:, -1, :] / temperature # last position, scaled
probs = F.softmax(logits, dim=-1)
idx_next = torch.multinomial(probs, num_samples=1) # SAMPLE — not argmax
multinomial draws from the distribution instead of
taking the max — that's why one prompt writes different text each
run. And a quiet trick in the projection before it: nanoGPT ties
wte and lm_head to one shared matrix (line 138) — the
table that turned ids into vectors also scores vectors against every id. Same
similarity question, asked in both directions.
figure 10 · the source
The real thing, in ~300 lines
Everything above is implemented in karpathy's nanoGPT — a full GPT-2 small enough to read in one sitting. The snippets under each figure come from a study-notes branch that annotates the repo the way this page draws it: one note per module, each paired with a runnable numpy trace — edit the numbers, rerun, watch the shapes. The model is only the middle of the pipeline:
Then close the loop: a 20-question, JEE-style quiz over everything on this page (negative marking, answer key included), and a staged roadmap from this 2019-era GPT-2 to how frontier models are trained today.
keep going
⚙ the interactive 3D machine — the same eight ideas as one
orbitable, generating decoder, with a step-by-step tour.
▶ one forward pass — the scroll-driven story of the
transformer, and of attention.sh.
⌨ the
annotated nanoGPT — every figure above as real code, line-by-line notes, and
runnable traces.
attention.sh — a division of Jinacode Systems. Transformers,
fine-tuning, and sovereign LLMs.
we're hiring the curious — hello@attention.sh