DL S039 · The Full Transformer Block — Encoder, Decoder, and What Order Everything Goes In
Attention + FFN + LayerNorm + residual. Wire them in the right order and you get a transformer block. Stack N of them and you get a transformer. Walk both the encoder-decoder original and the modern decoder-only variant end to end.
🎯 Assemble a complete transformer block from attention + FFN + LayerNorm + residual, understand pre-LN vs post-LN, and walk the full encoder-decoder model end to end.
Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 39 / 80 · Module M07 · ~2 hours
The story
We have all the ingredients. Multi-head self-attention (S037). Positional encodings (S038). The FFN (a two-layer MLP, S013). LayerNorm (S016). Residual connections (S017). What's left is the recipe: which order do they go in?
The recipe matters more than beginners expect. "Attention Is All You Need" (2017) used post-LN: x + Sublayer(x) first, then LayerNorm. It works, but training is fragile — you need warmup, careful init, and it explodes at deeper depths (past ~12 layers) without special tricks. Every modern transformer (GPT-2, Llama, everything) uses pre-LN: x + Sublayer(LayerNorm(x)). Pre-LN trains stably at 100+ layers, needs less warmup, and is what you'll implement in S040.
This session we build the block twice — once as the paper wrote it (encoder + decoder), once as the modern decoder-only variant used by GPT/Llama. You'll see exactly what changed, why cross-attention exists only in the encoder-decoder version, and why decoder-only won for language modelling. By the end, you can draw the full "Attention Is All You Need" figure on a whiteboard and defend every line.
- Draw a transformer encoder block and decoder block with every arrow labelled.
- Explain the difference between pre-LN and post-LN and why modern models use pre-LN.
- Justify the FFN's 4×d_model hidden dimension in one sentence.
- Explain the causal mask and produce it in a single line of PyTorch.
- Walk cross-attention in the decoder: where Q comes from, where K/V come from, and what the shapes are.
- Implement a full decoder-only transformer block (~50 lines) that matches nanoGPT's block.
Prerequisites
- S036–S038 — attention, multi-head, positional.
- S013 — MLP forward.
- S016 — LayerNorm.
- S017 — residuals.
1 · The four ingredients
Every transformer block is built from these four:
- Multi-head self-attention (MHA) — S037. Mixes information across positions.
- Feed-forward network (FFN) — a two-layer MLP applied position-wise (same weights every position).
- LayerNorm (LN) — normalises each token's features to unit mean/variance.
- Residual connection (skip) — adds the input of a sublayer to its output.
The FFN is the compute workhorse. For d_model = 512, it's typically:
FFN(x) = Linear(d_ff, d_model)(GELU(Linear(d_model, d_ff)(x)))with d_ff = 4 · d_model = 2048. That 4× expansion is universal — even Llama and GPT-4-ish models keep the 4× ratio (SwiGLU variants use ~2.67× · 2 = 5.33× in raw dims to hit the same effective compute, but conceptually still 4×).
Why 4×? The FFN carries ~2/3 of the parameters of a transformer block (attention is only ~1/3). Making it wider gives you more capacity per layer. Empirically, 4× is the sweet spot: bigger doesn't help much, smaller hurts.
2 · Post-LN — the original recipe (and why it's fragile)
The 2017 paper's block:
attn_out = MultiHeadAttention(x)
x = LayerNorm(x + attn_out) # post-LN: add first, then norm
ffn_out = FFN(x)
x = LayerNorm(x + ffn_out)In code:
class PostLNBlock(nn.Module):
def __init__(self, d_model, h, d_ff):
super().__init__()
self.attn = MultiHeadAttention(d_model, h)
self.ffn = nn.Sequential(
nn.Linear(d_model, d_ff), nn.GELU(),
nn.Linear(d_ff, d_model))
self.ln1 = nn.LayerNorm(d_model)
self.ln2 = nn.LayerNorm(d_model)
def forward(self, x, mask=None):
x = self.ln1(x + self.attn(x, mask))
x = self.ln2(x + self.ffn(x))
return xWorks fine for the paper's 6-layer encoder + 6-layer decoder. But scale to 24 layers and you'll find gradients at the input layer are wildly larger than at the output layer, training loss oscillates in early epochs, and the model needs a linear-warmup learning rate schedule to survive the first few thousand steps.
3 · Pre-LN — the modern recipe
Swap the order: normalise inside the residual, add outside:
x = x + MultiHeadAttention(LayerNorm(x))
x = x + FFN(LayerNorm(x))class PreLNBlock(nn.Module):
def __init__(self, d_model, h, d_ff):
super().__init__()
self.attn = MultiHeadAttention(d_model, h)
self.ffn = nn.Sequential(
nn.Linear(d_model, d_ff), nn.GELU(),
nn.Linear(d_ff, d_model))
self.ln1 = nn.LayerNorm(d_model)
self.ln2 = nn.LayerNorm(d_model)
def forward(self, x, mask=None):
x = x + self.attn(self.ln1(x), mask)
x = x + self.ffn(self.ln2(x))
return xTwo lines swapped. Consequences: gradients through the residual go straight through (identity path), the sublayer's LN keeps sublayer inputs well-scaled, and depth scales to 100+ layers without divergence.
Instantiate the PreLNBlock above and run:
block = PreLNBlock(d_model=64, h=4, d_ff=256)
x = torch.randn(2, 10, 64) # (B, T, d_model)
y = block(x)
assert y.shape == x.shape
# Kill the FFN and attention entirely
with torch.no_grad():
for p in block.attn.parameters(): p.zero_()
for p in block.ffn.parameters(): p.zero_()
y_zero = block(x)
print((y_zero - x).abs().max().item()) # expect ~0 (float noise only)The second block confirms the residual actually works: when sublayers contribute nothing, the block is the identity. Now do the opposite — keep FFN and attention random but re-init both LayerNorm.weight = 0 and see what happens. This is the litmus test every transformer implementation should pass before you trust it on real data.
4 · The causal mask (decoder-only or decoder side of enc-dec)
Language modelling is next-token prediction. When training on "the cat sat on the mat", the model must NOT be allowed to peek at "cat" when predicting the next token after "the". Every position i can only attend to positions 0…i.
Enforce this by adding -∞ to attention scores at "forbidden" positions before soft-max. After soft-max, those positions get weight zero.
# T = sequence length
mask = torch.tril(torch.ones(T, T)) # lower-triangular of 1s
# then inside attention:
scores = scores.masked_fill(mask == 0, float('-inf'))The tril matrix looks like:
[[1, 0, 0, 0],
[1, 1, 0, 0],
[1, 1, 1, 0],
[1, 1, 1, 1]]Row i has 1s in columns 0…i and 0s beyond. masked_fill(mask == 0, -inf) blocks the future. After soft-max, row 0 attends only to position 0, row 1 splits between 0 and 1, etc.
5 · The encoder-decoder — original transformer
The 2017 paper is an encoder-decoder for machine translation. Left tower processes the source sentence; right tower generates the target token by token, attending to both its own previous tokens (self-attention) and the encoder's outputs (cross-attention).
Encoder block: [LN → MHA(self) → +] → [LN → FFN → +]. Bidirectional attention over source.
Decoder block: three sublayers:
[LN → MHA(self, causal)]— attend to previous target tokens.[LN → MHA(cross, Q=decoder, K=V=encoder_out)]— pull from source.[LN → FFN]— position-wise.
Cross-attention is the same math as self-attention but with Q from the decoder and K, V from the encoder. Shapes:
Q: (B, T_dec, d_model) from LN(x_dec)
K, V: (B, T_enc, d_model) from encoder_output (already normed)
scores: (B, h, T_dec, T_enc)The cross-attention mask blocks padding tokens in the source but has no causal structure.
6 · Decoder-only — the modern LM
For pure language modelling (predicting the next token given the past), you don't need an encoder or cross-attention. Just stack N pre-LN decoder blocks with causal self-attention, plus a token+position embedding at the input and a linear head at the output.
class DecoderOnlyBlock(nn.Module):
def __init__(self, d_model, h, d_ff, dropout=0.0):
super().__init__()
self.ln1 = nn.LayerNorm(d_model)
self.attn = MultiHeadAttention(d_model, h, dropout)
self.ln2 = nn.LayerNorm(d_model)
self.ffn = nn.Sequential(
nn.Linear(d_model, d_ff), nn.GELU(),
nn.Linear(d_ff, d_model),
nn.Dropout(dropout))
def forward(self, x, mask):
x = x + self.attn(self.ln1(x), mask)
x = x + self.ffn(self.ln2(x))
return xAnd the full model:
class GPTLike(nn.Module):
def __init__(self, vocab_size, max_len, d_model, h, d_ff, n_layers):
super().__init__()
self.tok_emb = nn.Embedding(vocab_size, d_model)
self.pos_emb = nn.Embedding(max_len, d_model) # learned PE
self.blocks = nn.ModuleList([
DecoderOnlyBlock(d_model, h, d_ff) for _ in range(n_layers)])
self.ln_f = nn.LayerNorm(d_model) # final norm
self.head = nn.Linear(d_model, vocab_size, bias=False)
# tie weights (optional, saves params and improves quality)
self.head.weight = self.tok_emb.weight
def forward(self, idx):
B, T = idx.shape
pos = torch.arange(T, device=idx.device)
x = self.tok_emb(idx) + self.pos_emb(pos)
mask = torch.tril(torch.ones(T, T, device=idx.device))
for blk in self.blocks:
x = blk(x, mask)
x = self.ln_f(x)
return self.head(x) # (B, T, vocab)Roughly 50 lines. That is a GPT. We build the full training loop for this in S040.
7 · Why decoder-only won
- Simpler: half as many block types, no cross-attention, no separate encoder pipeline.
- Unified pretraining objective: next-token prediction works for ANY text (paper, code, chat, docs). Encoder-decoder needs paired source-target for its original task.
- In-context learning: prepending 'examples' as prefix tokens is free (they just become part of the context). Encoder-decoder splits input and output into separate paths, less flexible.
- Scaling behaves cleanly: same architecture from 100M to 1T params. Encoder-decoder needs delicate rebalancing of encoder vs decoder depths.
For dedicated seq2seq tasks (machine translation, summarisation with strict input-output separation), encoder-decoder still wins on FLOPs-per-BLEU. But for general-purpose LMs and chatbots, decoder-only rules.
8 · Common bugs
torch.tril(torch.ones(T, T)) lives on CPU. If your input is on CUDA and the mask isn't, you get a device-mismatch error. Always create the mask on idx.device (as in the code above) or pre-register it as a buffer with self.register_buffer('mask', torch.tril(torch.ones(max_len, max_len))) and slice [:T, :T] at forward time.
Very easy to accidentally write x = self.ln1(x + self.attn(x)) — that's post-LN. Pre-LN is x = x + self.attn(self.ln1(x)). The difference is one nesting level; the impact on stability at depth is huge.
self.ln_f after the last block, before the output projection. Without it the head's inputs have wandering scale and the output logits are noisy. Every reference implementation has this final LN; every "why is my transformer not training" post is missing it.
9 · Parameter budget worksheet
For d_model = 512, h = 8, d_ff = 2048, n_layers = 12, vocab = 50257, max_len = 1024:
- Token embed:
50257 · 512 = 25.7M - Position embed:
1024 · 512 = 0.5M - Per block:
4 · d_model² (attn) + 2 · d_model · d_ff (FFN)=4·262144 + 2·512·2048=1.05M + 2.10M=3.15M. Times 12 =37.8M. - Head (tied with token embed) = 0.
- Final LN etc. = negligible.
- Total ≈ 64M.
That's about the size of GPT-2 small (117M — differs because GPT-2 uses d_ff = 4·d_model = 3072, n_layers = 12, d_model = 768; let's redo: 12 · (4·768² + 2·768·3072) = 12 · (2.36M + 4.72M) = 85M, plus 50257·768 = 38.6M embed = ~124M, matches "117M" once you account for weight tying reducing the effective count).
Do this arithmetic once. You'll never be intimidated by "how big is this model" again.
10 · Modern-2025 twist — the block that actually ships
The pre-LN block above is the GPT-2 (2019) shape. What every 2024–2026 frontier model actually ships is a small set of drop-in upgrades. Here they are, roughly in order of impact:
1. RMSNorm instead of LayerNorm (Zhang & Sennrich, 2019, arXiv:1910.07467). Drops the mean-subtraction and the bias term; just rescales by root-mean-square. Same-ish quality, ~7–15% faster, one less numerical failure mode. Used in Llama 1/2/3/4, Mistral, Qwen, DeepSeek, Gemma.
class RMSNorm(nn.Module):
def __init__(self, d, eps=1e-6):
super().__init__(); self.g = nn.Parameter(torch.ones(d)); self.eps = eps
def forward(self, x):
return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) * self.g2. SwiGLU instead of GELU-FFN (Shazeer, 2020, arXiv:2002.05202). Replace the 2-layer MLP with a gated variant. Consistently ~1–2% lower loss at fixed compute. Universal in modern LMs.
class SwiGLU(nn.Module):
def __init__(self, d, d_ff):
super().__init__()
self.w1 = nn.Linear(d, d_ff, bias=False) # gate
self.w2 = nn.Linear(d, d_ff, bias=False) # value
self.w3 = nn.Linear(d_ff, d, bias=False) # out
def forward(self, x):
return self.w3(F.silu(self.w1(x)) * self.w2(x))Because SwiGLU has 3 matrices instead of 2, Llama shrinks d_ff from 4d to ~2.67d so the parameter count matches a 4d GELU FFN.
3. No biases anywhere (Llama). Empirically neutral quality but simpler, slightly faster. All nn.Linear(..., bias=False).
4. RoPE instead of learned/sinusoidal positions — covered in S038 and S043. Baked into attention, not added to embeddings.
5. GQA / MQA / MLA — shrinks KV heads. Covered in S037/S042.
The "modern block" — all five upgrades — is essentially the Llama 3 block. Eight-line class:
class ModernBlock(nn.Module):
def __init__(self, d, n_head, n_kv_head, d_ff):
super().__init__()
self.n1 = RMSNorm(d); self.attn = GQAAttention(d, n_head, n_kv_head)
self.n2 = RMSNorm(d); self.ffn = SwiGLU(d, d_ff)
def forward(self, x, cos, sin, kv_cache=None):
h, kv = self.attn(self.n1(x), cos, sin, kv_cache)
x = x + h; x = x + self.ffn(self.n2(x)); return x, kvThat's the block behind every open weights release from mid-2023 on. Read Llama 3's model.py; this is essentially what you'll see.
2025 experimental additions worth knowing about:
- DeepNorm (post-LN variant, Wang et al., 2022, arXiv:2203.00555) — scale residual by
αto stabilise 1000-layer training. Used in some Chinese-lab models. - Value residual (modded-nanoGPT, 2024) — add a residual on V inside attention.
- QK-norm (Chameleon, Grok) — RMSNorm on Q and K separately before the dot product. Kills attention-logit explosions at scale.
- Tanh logit soft-capping (Gemma 2, Gemma 2 tech report, 2024) —
logits = cap * tanh(logits / cap). Prevents softmax saturation. - U-net-shaped residual streams (modded-nanoGPT speedrun 2025) — experimental, decoupled residual streams reduce loss further.
Further reading:
- Llama 2 paper (Touvron et al., 2023) §2.1 — the modern-block recipe stated cleanly.
- SwiGLU original (Shazeer, 2020) — the two-page paper that changed every FFN.
- modded-nanoGPT PR history — real-time architecture research.
"Pre-LN versus post-LN is a minor implementation detail. Layer norm normalises the activations either way — moving it inside the residual branch is a small refactor that might help stability a little."
The placement decides whether an identity path exists from the loss to the input at all. In post-LN, every residual add is immediately followed by a normalisation, so the "skip" is not a skip: the gradient passes through L normalisation Jacobians on its way back, and their product shrinks with depth. In pre-LN, the normalisation sits inside each branch, so the residual stream itself is an unbroken sum from input to output and the gradient has a path with no multiplicative factors on it whatsoever. That is the difference between a network that needs a carefully-tuned warmup schedule to train past a certain depth and one that trains without warmup at all. It is not a refactor; it changes the architecture's gradient topology.
Because the belief is essentially correct at shallow depth, where the failure it predicts is small enough to be invisible. At six layers a post-LN transformer trains fine, and the original architecture was post-LN precisely because it worked. Both forms also produce the same forward-pass shapes, the same parameter count, and outputs in the same range, so nothing about inspecting the model reveals a difference. It only bites when you stack deep or remove warmup — and when it bites, the symptom is a loss that diverges in the first few hundred steps, which looks like a learning-rate problem and gets treated as one. The fix that "works" is lowering the learning rate, which confirms the wrong diagnosis and hides the structural cause for another few months.
Measure the gradient norm reaching the first block, as a function of depth, under both placements:
import torch
def grad_at_input(depth, pre_ln):
x = torch.randn(2, 16, 64, requires_grad=True)
h = x
for _ in range(depth):
if pre_ln:
h = h + branch(norm(h)) # residual stream untouched
else:
h = norm(h + branch(h)) # every add passes through norm
h.sum().backward()
return x.grad.norm().item()
for depth in (2, 6, 12, 24, 48):
print(depth, grad_at_input(depth, True), grad_at_input(depth, False))
# pre-LN column: roughly stable as depth grows
# post-LN column: falls away with depth -- and that decay is
# exactly what the warmup schedule was compensating forWhy is the feed-forward hidden dimension four times d_model? The factor 4 shows up in nearly every transformer ever published — it looks like a number someone picked once and everyone copied.
- 1Attention is a mixing operation: each output is a weighted average of value vectors. Between the projections it performs no per-position nonlinear computation at all.forced by · the softmax acts on scores, not on the values being combined, so the value path through attention is entirely linear
- 2A stack of purely linear mixing collapses: a composition of linear maps is a linear map. Something in the block must supply per-position nonlinearity or depth buys nothing beyond a single layer's expressivity.forced by · without a nonlinearity between them, N layers are representationally equivalent to one
- 3The feed-forward network is therefore where all the per-token computation lives. Its job is to take a mixed representation and apply a learned nonlinear transformation — in effect a key-value memory, where the first matrix matches against learned patterns and the second writes out associated content.forced by · it is the only sublayer that operates on each position independently with a nonlinearity in the middle
- 4The number of distinct patterns such a layer can match is bounded by its hidden width. Making the hidden layer narrower than
d_modelwould force a bottleneck, discarding information before the nonlinearity ever sees it; making it equal tod_modelgives no room to expand into a sparse, more separable representation.forced by · a nonlinearity applied in a compressed space cannot recover distinctions the compression already destroyed - 5So the hidden width must exceed
d_model, and the multiplier is set by a parameter budget: the feed-forward block holds2 * m * d_model^2parameters against attention's4 * d_model^2. Atm = 4the ratio is 2:1, which is where the transformer places two-thirds of its non-embedding parameters.forced by · every extra unit of m costs parameters quadratically in d_model, so the multiplier is a budget allocation between mixing and computing
Therefore 4 is not sacred — it is the point where the field settled the split between mixing capacity and per-token compute capacity, and it survives because that split turned out to be a broad optimum rather than a sharp one.
The derivation makes a sharp prediction you can check against real architectures: since the constraint is on the parameter count of the feed-forward block and not on the literal number 4, any change that adds a third matrix should reduce the multiplier to compensate. Gated variants such as SwiGLU use three matrices instead of two, so holding parameters fixed requires the multiplier to drop by roughly a third — and that is exactly why you find models using a factor near 8/3 rather than 4. Go read the config of any recent open-weight model and check the intermediate size against d_model; the number will not be 4, and now you know why.
Do not picture the block as transforming its input into an output. Picture a wide bus running vertically through the entire network, carrying d_model lanes from input embedding to final logits. Each sublayer reads the bus, computes something, and adds its result back. Nobody overwrites; everyone contributes.
Attention reads across positions and writes back what it gathered. The feed-forward network reads one position and writes back what it computed. Layer norm in the pre-LN arrangement is a lens on the read side only — it standardises what a sublayer sees without ever disturbing the bus itself. That is why the bus is an uninterrupted additive path, and why gradient flows down it unattenuated.
- Block = attention sublayer + feed-forward sublayer, each wrapped as
x = x + sublayer(norm(x)). Attention mixes across positions; the feed-forward mixes across features. Nothing else happens. - Pre-LN keeps the residual stream clean and trains without warmup, at the cost of a growing stream variance that needs a final norm before the output head. Post-LN normalises the stream and needs warmup.
- The causal mask is applied to the scores before the softmax, using a large negative value rather than zero, because zeroing a probability after the softmax breaks normalisation.
- Roughly two-thirds of non-embedding parameters sit in the feed-forward blocks. When you are counting memory or thinking about where knowledge is stored, that is where to look first — not in attention.
Fire this model the moment you see: a loss that diverges in the first few hundred steps and is "fixed" by warmup · a debate about where to put layer norm · a -inf or large negative constant in an attention mask · someone asking where a model stores facts · an intermediate size that is not four times the hidden size · residual connections being described as "helping gradient flow" without saying why.
You are choosing the normalisation for a new model. LayerNorm or RMSNorm, and where does it go?
Pre-norm with RMSNorm is the current default and it is a defensible starting point for anything new. But hold the reasoning, not the recipe: the reason pre-norm won is the unbroken residual path, and the reason RMSNorm won is that normalisation is bandwidth-bound so removing a pass over the features is nearly free performance.
The one thing to keep from the post-LN era is the diagnostic. If a deep model diverges early in training and warmup rescues it, do not conclude the learning rate was too high — check whether something in your block broke the identity path. A normalisation, a scaling, or a nonlinearity accidentally placed on the residual stream rather than inside the branch reproduces the post-LN failure exactly, and the warmup will keep hiding it from you.
Retention scaffold
Recall questions
1. Pre-LN vs post-LN — write both.
Post-LN: x = LN(x + Sublayer(x)). Pre-LN: x = x + Sublayer(LN(x)). Pre-LN is strictly better for deep models.
2. What is the FFN's hidden dimension typically?
d_ff = 4 · d_model. Two linear layers with a GELU (or SwiGLU in modern models) in between. Applied position-wise (same weights every position).
3. Write the one-line causal mask.
mask = torch.tril(torch.ones(T, T)) — lower triangular of 1s. Fill non-mask positions in the scores with -inf before soft-max.
4. What are the three sublayers of a decoder block in the encoder-decoder transformer?
(1) causal self-attention, (2) cross-attention (Q from decoder, K/V from encoder), (3) FFN. Each wrapped in LN + residual.
5. Why does the decoder-only architecture "win" for LMs?
Simpler (one block type), scales cleanly to any parameter count, and the next-token-prediction objective works on any text corpus with no paired-data requirement. Also enables in-context learning naturally.
Stretch prompt
Implement the encoder-decoder transformer (six blocks each side) and train it on a small English→German dataset (like Multi30k). Compare BLEU to a decoder-only trained on the same data as a concatenated EN <sep> DE sequence. Decoder-only will be within a couple BLEU points despite being architecturally simpler — that's the modern LM story in miniature.
In your own words
"Explain to a friend, in 4–5 sentences, what makes a transformer 'a transformer' as opposed to just 'a stack of attention layers'."
Spaced review
- S016 — LayerNorm, the piece that makes deep transformers train.
- S017 — residuals, without which nothing above 4 layers would learn.
- S037 — MHA, the workhorse.
Next-session teaser
S040: Karpathy's nanoGPT, line by line. We take the ~300 lines of model.py and understand every symbol. If you followed today's session, tomorrow you'll be typing it from memory.
Bring back tomorrow
- Pre-LN order (LN inside the residual).
- FFN = 4× hidden, GELU or SwiGLU, position-wise.
- Causal mask via
tril. - Decoder-only is the default modern LM architecture.