40. Transformer Decoder Block, End to End
Problem
Every autoregressive transformer, regardless of scale, is built from the same two-sublayer block repeated N times: causal self-attention and a position-wise MLP, each wrapped in a residual connection with pre-norm (GPT-style: normalize, run the sublayer, add the residual):
x = x + SelfAttention(LayerNorm1(x))x = x + MLP(LayerNorm2(x))
Unlike wiring together a pre-built nn.MultiheadAttention, this problem builds the attention sublayer itself from its component projections: a fused QKV linear layer, a manual head-split/attend/merge, and an output projection. Getting the plumbing right end to end — where the causal mask gets applied, that the residual branches off before normalization, that reshapes preserve the right axes — is what makes assembling any fancier architecture on top of this tractable.
Source: src/40_transformer_decoder_block.py
class TransformerDecoderBlock(nn.Module):
def __init__(self, d_model: int, num_heads: int, d_ff: int): ...
def forward(self, x: torch.Tensor) -> torch.Tensor: ... # (B, T, d_model) -> (B, T, d_model)
>>> torch.manual_seed(0)
>>> block = TransformerDecoderBlock(d_model=16, num_heads=2, d_ff=32)
>>> x = torch.randn(2, 5, 16)
>>> block(x).shape
torch.Size([2, 5, 16])
Step-by-Step Approach
- Normalize the input with
ln1, then project it throughqkv_projin one matmul and split the result intoq,k,vwith.chunk(3, dim=-1). - Reshape each of
q,k,vinto heads:(B, T, d_model) -> (B, H, T, d_head). - Compute causal attention scores, add a
(T, T)upper-triangular-infmask, softmax, then multiply byv. - Merge heads back to
(B, T, d_model)and applyattn_out_proj. - Add this attention output to the original, unnormalized
xas a residual connection — not toln1(x). - Repeat the pattern for the MLP sublayer:
x = x + self.mlp(self.ln2(x)). - Return the final
x, which has passed through both residual sublayers.
The key insight is pre-norm placement: normalization happens on the branch going into each sublayer, while the residual addition always uses the value before that normalization. Get this backwards (normalize after adding, or normalize the residual branch itself) and the block still runs and produces a right-shaped output, but it's not the architecture being asked for and gradient flow through the residual stream degrades.
Reference solution
import math
import torch
import torch.nn as nn
class TransformerDecoderBlock(nn.Module):
def __init__(self, d_model: int, num_heads: int, d_ff: int):
super().__init__()
assert d_model % num_heads == 0
self.num_heads = num_heads
self.d_head = d_model // num_heads
self.ln1 = nn.LayerNorm(d_model)
self.qkv_proj = nn.Linear(d_model, 3 * d_model)
self.attn_out_proj = nn.Linear(d_model, d_model)
self.ln2 = nn.LayerNorm(d_model)
self.mlp = nn.Sequential(
nn.Linear(d_model, d_ff),
nn.GELU(),
nn.Linear(d_ff, d_model),
)
def _self_attention(self, x: torch.Tensor) -> torch.Tensor:
B, T, d_model = x.shape
qkv = self.qkv_proj(x) # one matmul instead of three
q, k, v = qkv.chunk(3, dim=-1)
def split_heads(t):
return t.view(B, T, self.num_heads, self.d_head).transpose(1, 2)
q, k, v = split_heads(q), split_heads(k), split_heads(v)
scores = q @ k.transpose(-1, -2) / math.sqrt(self.d_head)
causal_mask = torch.triu(torch.full((T, T), float("-inf"), device=x.device), diagonal=1)
scores = scores + causal_mask
weights = torch.softmax(scores, dim=-1)
out = weights @ v
out = out.transpose(1, 2).contiguous().view(B, T, d_model)
return self.attn_out_proj(out)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# pre-norm residual: normalize -> sublayer -> add; the residual
# branches off the *unnormalized* x, not ln(x)
x = x + self._self_attention(self.ln1(x))
x = x + self.mlp(self.ln2(x))
return x
Key Functions & Tricks
nn.Linear(d_model, 3 * d_model)then.chunk(3, dim=-1)— one fused matmul for Q/K/V instead of three separate ones, a standard efficiency trick in real transformer implementations.torch.triu(..., diagonal=1)— builds the causal mask;diagonal=1keeps self-attention to the current position allowed.nn.LayerNorm(d_model)applied before each sublayer, not after — the pre-norm convention essentially every modern autoregressive transformer uses over the original post-norm design.- Residual add (
x = x + sublayer(ln(x))) — the pattern that keeps gradients flowing cleanly through arbitrarily many stacked blocks. - Causality unit test via input perturbation — changing only the last token and checking earlier outputs are unchanged is a cheap, direct way to verify a causal mask is actually wired in correctly, independent of any numeric oracle.
How to Recognize This Pattern
Recognize this whenever a problem asks you to "implement a transformer block" or "a decoder layer" rather than just attention in isolation — it's the assembly exercise that combines the attention primitive with a feedforward sublayer and residual/normalization scaffolding. The two things interviewers most often probe here: pre-norm vs. post-norm placement (does the residual add use the normalized or unnormalized branch?), and whether the causal mask is actually threaded through to the attention computation rather than silently dropped. A good sanity check independent of any numeric oracle is exactly the causality unit test used here: perturb only the last token and confirm no earlier position's output changes. Common pitfalls: swapping the order of LayerNorm and the sublayer (post-norm instead of pre-norm), forgetting the causal mask entirely (silently turns it into a bidirectional encoder block), and reusing a single LayerNorm module for both sublayers instead of two independent ones with their own learned parameters.