S111 · Full Transformer Architecture — Encoder + Decoder
Everything you've learned, snapped together. Attention + FFN + residual + layernorm × N — the block that powers every LLM, and why encoder-only vs decoder-only vs encoder-decoder each earn their place.
🎯 Assemble a complete Transformer block from scratch, understand why every component (residual, layernorm, FFN) is non-negotiable, and know when to reach for encoder-only, decoder-only, or encoder-decoder.
Why this session exists
You've now learned every component: tokenization (S106), attention (S107-108), multi-head (S109), positional encoding (S110). This session snaps them together into a complete Transformer block — the atomic unit repeated N times to make BERT, GPT, T5, LLaMA, Claude, and every other modern LLM. You'll also learn the three architectural families (encoder-only, decoder-only, encoder-decoder) and why each survives — because they solve different problems.
- Draw a Transformer block from memory — attention, residual, layernorm, FFN, residual, layernorm.
- Explain why every one of those pieces (not just attention) is essential.
- Distinguish encoder-only (BERT), decoder-only (GPT), and encoder-decoder (T5) and pick the right one.
- Build a tiny working GPT-style model in PyTorch and generate text.
- Read any Transformer paper's architecture diagram and mentally implement it.
Prerequisites
- S106-110 — the entire NLP/Transformers arc. This is the payoff session.
- S102 · CNNs — for the residual connection intuition (ResNet).
- S100 · PyTorch Fundamentals — nn.Module, forward, backward.
(a) Intuition · 5 min
A 100-story skyscraper is remarkably simple: one floor plan repeated 100 times, plus a foundation and a roof. Each floor is identical structurally — same plumbing, electrical, walls — but each has different tenants, different furniture, different work happening.
A Transformer is the same. Twelve (or 96, or 480) identical blocks stacked on top of each other. Each block has the same architecture — attention + FFN with residual + layernorm — but during training each learns to specialise in different aspects of the input.
The block itself is TINY compared to the whole model: multi-head attention (~4·d² params) + a 2-layer feed-forward network (~8·d² params) + a couple of layer norms (~2·d params). Everything else about a Transformer — depth, width, vocab, context length — is just scaling those knobs.
Everything you've learned so far — tokenization, embeddings, attention, positional encoding, multi-head — is the ingredient list. This session is the recipe.
The five pieces of a Transformer block
- Multi-head self-attention — lets tokens communicate with each other.
- Residual connection around attention — gradient highway, preserves original signal.
- Layer normalisation — keeps activations at unit scale, essential for stable deep training.
- Feed-forward network (2-layer MLP) — per-token computation that transforms each vector individually.
- Second residual + layernorm around the FFN — same reasons as around attention.
A short history
- 2017Vaswani et al. · Attention Is All You NeedFull encoder-decoder Transformer for translation. Post-LN, sinusoidal PE.
- 2018GPT-1 · Radford et al.Drop the encoder. Decoder-only for language modelling. First large-scale generative pretraining.
- 2018BERT · Devlin et al.Drop the decoder. Encoder-only with masked LM. Bidirectional context, dominates NLU benchmarks.
- 2019T5 · GoogleFull encoder-decoder revival. 'Everything is text-to-text.' Uses relative position bias, prenorm.
- 2020GPT-3 · OpenAIDecoder-only, 175B params. In-context learning surprises everyone.
- 2023LLaMA · MetaDecoder-only, RoPE, SwiGLU, pre-RMSNorm. The reference open-source recipe.
(b) Visual walkthrough · 15 min
The Transformer block
This is the Pre-LN variant (layer norm BEFORE the sublayer). Modern Transformers all use this. The original 2017 paper used Post-LN (layer norm AFTER), which is harder to train stably at scale.
The full architecture — decoder-only (GPT)
Text → token IDs → nn.Embedding lookup → [B, T, d_model]. Add positional encoding (or apply RoPE inside attention).
Stack N (typically 12-96) identical blocks. Each processes [B, T, d] and outputs [B, T, d]. Each block adds one layer of abstraction.
One more norm before the output head. Standard in pre-LN Transformers.
nn.Linear(d_model, vocab_size). Often tied with the input embedding matrix (weight tying) to save params.
Or use raw logits and let cross-entropy handle it. Argmax for greedy, sample for creative.
Encoder-only vs decoder-only vs encoder-decoder
Bidirectional understanding
- Full attention — every token sees every token
- Pretrained with masked LM
- Great for classification, NER, retrieval
- BERT, RoBERTa, DeBERTa, ModernBERT
Causal generation
- Causal mask — token t sees only 1..t
- Pretrained with next-token prediction
- Generation, in-context learning, few-shot
- GPT-3/4, LLaMA, Claude, Mistral
Seq-to-seq translation
- Encoder processes input, decoder generates output
- Cross-attention: decoder queries encoder outputs
- Best for translation, summarisation
- T5, mT5, BART, Whisper, Flan-T5
Middle ground
- Bidirectional on prefix, causal on suffix
- One model, both understanding and generation
- Rare in practice
- UL2, some PaLM variants
Anatomy: what each component adds
Why each piece is non-negotiable
Why pre-LN beat post-LN
In post-LN, the residual has NO norm, so as depth grows the residual magnitude blows up — training becomes unstable. Pre-LN normalises the sublayer input, keeping magnitudes bounded regardless of depth. Nobody trains post-LN Transformers past 12-24 layers.
"LayerNorm goes after the sublayer — that's what the original Transformer paper does. Pre-LN vs post-LN is a cosmetic detail."
It decides whether the model trains at all beyond modest depth. Post-LN puts a normalisation on the residual path, so the identity shortcut is destroyed at every block and gradient magnitude compounds with depth — requiring a learning-rate warmup to avoid divergence. Pre-LN normalises the branch input and leaves the residual path clean, giving an unobstructed gradient highway from loss to embedding. Every large modern model is Pre-LN.
Because the canonical diagram in the paper — the one everyone has memorised — shows "Add & Norm" after each sublayer, and the original 6-layer models trained fine that way. At 6 layers the difference is small and hidden by warmup. At 48+ layers it is the difference between converging and diverging, and the fix was discovered only after people tried to scale.
Compare gradient norm reaching the first block in a deep stack under both orderings — same init, same input:
import torch, torch.nn as nn
class Blk(nn.Module):
def __init__(s, d, pre):
super().__init__(); s.pre=pre; s.n=nn.LayerNorm(d); s.f=nn.Linear(d,d)
def forward(s,x):
return x + s.f(s.n(x)) if s.pre else s.n(x + s.f(x))
for pre in (True, False):
net = nn.Sequential(*[Blk(128, pre) for _ in range(48)])
x = torch.randn(4, 128, requires_grad=True)
net(x).pow(2).mean().backward()
print('pre-LN' if pre else 'post-LN', x.grad.norm().item())Why is the FFN hidden dimension conventionally 4× the model dimension, and why is there an FFN at all when attention already mixes information?
- 1Attention's output at each position is a convex combination of value vectors, and each value is a linear map of a token. So attention alone is linear in its values.forced by · the only nonlinearity in attention is the softmax, and that acts on the mixing weights, not on the content being mixed
- 2A stack of linear mixings collapses: you would gain depth in routing but no ability to compute nonlinear functions of token content.forced by · composition of linear maps is a linear map
- 3So the block needs a position-wise nonlinearity. It must be position-wise (not across tokens) because attention already owns cross-token movement, and duplicating that would waste parameters and break the clean separation.forced by · separating "move information" from "process information" lets each be optimised and scaled independently
- 4The cheapest useful nonlinear map is up-project → nonlinearity → down-project. Its expressive power comes from the width of the hidden layer, which sets how many distinct features can be independently thresholded.forced by · with a ReLU-family activation, the hidden width is the number of half-space detectors available
- 5Wider is better up to the point where FFN parameters (2×d×dff) dominate attention parameters (4×d²). Setting dff=4d makes the FFN 8d² against attention's 4d² — a two-thirds/one-third split that empirically balances routing capacity against processing capacity.forced by · the ratio is a parameter-allocation decision between the two jobs, and 4× is where the tradeoff was found to sit
Therefore the transformer block is deliberately two specialists: attention moves information between positions, the FFN computes on it in place. 4× is the allocation that keeps the second from starving the first.
And note what this predicts: since the FFN holds roughly two-thirds of a block's parameters and runs independently per position, it is the natural place to add sparse conditional compute. That is exactly what Mixture-of-Experts does — it replaces the FFN, never the attention — and why MoE models report huge parameter counts with modest active FLOPs. The derivation tells you where the parameters were sitting.
Think of the residual stream as a wide bus running the full depth of the model, carrying the evolving representation of each token. Every block reads from the bus (through LayerNorm), computes something, and adds its result back. Nothing overwrites; everything accumulates.
Attention writes information copied from other positions. The FFN writes information computed from this position. The final layer just reads the bus and projects to vocabulary.
- Block = Pre-LN → attention → add · Pre-LN → FFN → add. Two sublayers, two residual adds, two norms.
- Residual connections exist so gradients reach layer 1 unattenuated; they are what makes depth trainable, not a regularisation trick.
- Encoder: bidirectional attention. Decoder: causal self-attention plus (if encoder-decoder) cross-attention where Q comes from the decoder and K,V from the encoder.
- Parameter split per block is roughly 1/3 attention, 2/3 FFN at dff=4d. Memory at inference, however, is dominated by the KV cache, not by weights.
Fire this model the moment you see: a deep model that will not train without warmup · a question about where to add adapters or LoRA (the bus tells you what each site affects) · MoE proposals · a residual connection accidentally omitted · an interpretability claim about "features in the residual stream".
Encoder-only, decoder-only, or encoder-decoder for a new system?
Decoder-only won the general case because a single next-token objective consumes any text, which turns architecture choice into a data-scale question — and scale beats inductive bias. But the encoder is not obsolete: at retrieval scale, embedding a billion documents with a bidirectional encoder is orders of magnitude cheaper than anything decoder-based, and it is what actually runs in production search.
The practical read: use a decoder-only LLM for generation and reasoning, an encoder for embeddings and reranking, and reach for encoder-decoder only when you have a genuine transduction task with a bounded source.
(c) Hands-on · 25 min
Build a complete tiny GPT (encoder-only version at the end for comparison) and generate text.
# tiny_gpt.py — a from-scratch decoder-only Transformer.
# Trains on a character-level corpus, then generates.
# Run: uv run tiny_gpt.py path/to/text.txt
import math
import sys
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, TensorDataset
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
BLOCK_SIZE = 128
BATCH = 32
D_MODEL = 128
N_HEADS = 4
N_LAYERS = 4
D_FF = 4 * D_MODEL
DROPOUT = 0.1
LR = 3e-4
EPOCHS = 5
class CausalSelfAttention(nn.Module):
def __init__(self, d_model: int, n_heads: int):
super().__init__()
assert d_model % n_heads == 0
self.n_heads = n_heads
self.d_head = d_model // n_heads
self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
self.proj = nn.Linear(d_model, d_model, bias=False)
self.dropout = nn.Dropout(DROPOUT)
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, T, D = x.shape
# One big projection, then split — faster than 3 separate matmuls.
qkv = self.qkv(x) # [B, T, 3D]
Q, K, V = qkv.split(D, dim=-1)
Q = Q.view(B, T, self.n_heads, self.d_head).transpose(1, 2)
K = K.view(B, T, self.n_heads, self.d_head).transpose(1, 2)
V = V.view(B, T, self.n_heads, self.d_head).transpose(1, 2)
# PyTorch's fused scaled_dot_product_attention with causal mask.
out = F.scaled_dot_product_attention(Q, K, V, is_causal=True) # [B, N, T, d_head]
out = out.transpose(1, 2).contiguous().view(B, T, D)
return self.dropout(self.proj(out))
class FeedForward(nn.Module):
def __init__(self, d_model: int, d_ff: int):
super().__init__()
self.fc1 = nn.Linear(d_model, d_ff)
self.fc2 = nn.Linear(d_ff, d_model)
self.dropout = nn.Dropout(DROPOUT)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.dropout(self.fc2(F.gelu(self.fc1(x))))
class Block(nn.Module):
"""One Transformer block — pre-LN variant."""
def __init__(self, d_model: int, n_heads: int, d_ff: int):
super().__init__()
self.ln1 = nn.LayerNorm(d_model)
self.attn = CausalSelfAttention(d_model, n_heads)
self.ln2 = nn.LayerNorm(d_model)
self.ffn = FeedForward(d_model, d_ff)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# Pre-LN: normalise BEFORE sublayer, add residual AFTER.
x = x + self.attn(self.ln1(x))
x = x + self.ffn(self.ln2(x))
return x
class TinyGPT(nn.Module):
def __init__(self, vocab_size: int, block_size: int, d_model: int,
n_heads: int, n_layers: int, d_ff: int):
super().__init__()
self.block_size = block_size
self.tok_embed = nn.Embedding(vocab_size, d_model)
self.pos_embed = nn.Embedding(block_size, d_model) # learned PE for simplicity
self.blocks = nn.ModuleList([
Block(d_model, n_heads, d_ff) for _ in range(n_layers)
])
self.ln_f = nn.LayerNorm(d_model)
self.head = nn.Linear(d_model, vocab_size, bias=False)
# Weight tying — share weights between token embedding and output head.
self.head.weight = self.tok_embed.weight
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, T = x.shape
pos = torch.arange(T, device=x.device)
h = self.tok_embed(x) + self.pos_embed(pos) # [B, T, d]
for block in self.blocks:
h = block(h)
h = self.ln_f(h)
return self.head(h) # [B, T, vocab]
@torch.no_grad()
def generate(self, idx: torch.Tensor, max_new: int, temperature: float = 1.0) -> torch.Tensor:
for _ in range(max_new):
idx_cond = idx[:, -self.block_size:] # crop to context window
logits = self(idx_cond)[:, -1, :] # last token's logits
probs = F.softmax(logits / temperature, dim=-1)
next_tok = torch.multinomial(probs, num_samples=1)
idx = torch.cat([idx, next_tok], dim=1)
return idx
def get_batches(data: torch.Tensor, block_size: int, batch_size: int) -> DataLoader:
n = (len(data) - 1) // block_size
x = data[: n * block_size].view(n, block_size)
y = data[1 : n * block_size + 1].view(n, block_size)
return DataLoader(TensorDataset(x, y), batch_size=batch_size, shuffle=True)
def main(path: str) -> None:
text = open(path, "r", encoding="utf-8").read()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
data = torch.tensor([stoi[c] for c in text], dtype=torch.long)
print(f"vocab={len(chars)} chars={len(text):,} device={DEVICE}")
loader = get_batches(data, BLOCK_SIZE, BATCH)
model = TinyGPT(len(chars), BLOCK_SIZE, D_MODEL, N_HEADS, N_LAYERS, D_FF).to(DEVICE)
params = sum(p.numel() for p in model.parameters())
print(f"params: {params:,} ({params/1e6:.2f}M)")
opt = torch.optim.AdamW(model.parameters(), lr=LR)
for epoch in range(1, EPOCHS + 1):
model.train()
total = 0.0
for step, (x, y) in enumerate(loader, 1):
x, y = x.to(DEVICE), y.to(DEVICE)
logits = model(x)
loss = F.cross_entropy(logits.reshape(-1, len(chars)), y.reshape(-1))
opt.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
total += loss.item()
# Generate a sample.
model.eval()
seed = torch.tensor([[stoi[text[0]]]], device=DEVICE)
out = model.generate(seed, max_new=200, temperature=0.8)
sample = "".join(itos[i.item()] for i in out[0])
print(f"epoch {epoch} loss={total/step:.3f}")
print(f" sample: {sample[:100]!r}...")
if __name__ == "__main__":
main(sys.argv[1])Anatomy of the script
What the interesting lines do
Two changes:
- In
CausalSelfAttention.forward, changeis_causal=Truetois_causal=False. - Change training loop: randomly replace 15% of input tokens with a
[MASK]token, then predict ONLY those positions.
Result: a mini-BERT. Won't generate text (that's not what encoder-only does) but will give great sentence embeddings for downstream classification.
(d) Production reality · 15 min
Early LLaMA-1 experiments used vanilla Transformer decoders (like GPT-2). Meta's team ran ablations on architectural choices at 7B scale and found several small tweaks compounded into significant quality + speed gains.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- Draw a Transformer block from memory and explain why each piece is needed.
- When would you pick encoder-only vs decoder-only vs encoder-decoder?
- Why did pre-LN replace post-LN, and why is the FFN 4× wider than d_model?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.