Part 4: Build one decoder block¶
This chapter assembles the actual Transformer block that turns token states into context-aware hidden states. Up to now, we have seen the pieces separately: token lookup, position encoding, and attention. The decoder block is where they are combined in a trainable way.
Foundation connection: Review matrix and batched shapes, residual gradient flow, and the cost of a full score table. This chapter is where the geometry and the algebra become one working block.
1. The block is a small pipeline¶
A decoder block is usually:
- normalize the current token states
- run causal multi-head self-attention
- add a residual connection
- normalize again
- run a position-wise feed-forward network
- add another residual connection
A simplified diagram is:
flowchart LR
X[Input X] --> N1[LayerNorm]
N1 --> A[Causal multi-head attention]
A --> R1[Residual add]
X --> R1
R1 --> N2[LayerNorm]
N2 --> M[MLP]
M --> R2[Residual add]
R1 --> R2
R2 --> Y[Output Y]
This is the standard pattern for a modern decoder block. The attention sublayer moves information between positions; the MLP sublayer transforms each position separately.
2. What a single block is doing¶
Think of the sequence as a batch of token states:
where:
- \(B\) is batch size
- \(T\) is sequence length
- \(d\) is model width
Each attention head computes its own query, key, and value projections. If there are \(h\) heads, each head has width \(d_h = d/h\).
The key idea is that the same block is applied repeatedly. A single block does not “understand” the whole language. It refines the representation of each token by mixing in context and then projecting features through a learned nonlinear map.
The block does two kinds of work:
- cross-position mixing: attention
- per-position transformation: MLP
This is the basic design principle behind the Transformer.
3. Shape view of one decoder block¶
The common shape sequence is:
| Operation | Input shape | Output shape |
|---|---|---|
| token embeddings + position | \((B,T)\) IDs | \((B,T,d)\) |
| projections into Q, K, V | \((B,T,d)\) | \((B,h,T,d_h)\) |
| score matrix | \(Q,K\) | \((B,h,T,T)\) |
| attention output | scores + \(V\) | \((B,h,T,d_h)\) |
| concat heads + projection | \((B,h,T,d_h)\) | \((B,T,d)\) |
| residual add | \(X\), attention output | \((B,T,d)\) |
| MLP | \((B,T,d)\) | \((B,T,d)\) |
This is the practical shape story behind the math. The token sequence has width \(d\), and each head splits that width into smaller learned subspaces. The score matrix has one row per query position and one column per key position, so the model can compare every token to every earlier token in a batch of sequences.
The mask is applied to the scores before softmax. Future positions are set to a very negative value so the resulting attention weights are effectively zero. This is why the model cannot see future tokens during autoregressive generation.
4. A tiny sentence through one block¶
Imagine the prefix:
the cat sat
At the first layer, each token has a starting vector. The attention sublayer updates the representation of “sat” using evidence from “the” and “cat.” The MLP then transforms that updated vector within the same position.
This is a simple geometric story:
- “the” contributes lexical support
- “cat” contributes subject knowledge
- “sat” is the current token being contextualized
- attention decides how much each neighboring state matters
- the MLP reshapes the result in a non-linear way
flowchart LR
A[the] --> B[attention]
C[cat] --> B
D[sat] --> B
B --> E[contextualized sat state]
E --> F[MLP transform]
F --> G[updated hidden state]
The point is not that we can literally read off each learned weight. The point is that the block is doing two jobs: context routing and per-position feature transformation.
5. That residual path matters¶
Residual connections are not cosmetic. Without them, the model would be trying to learn a complicated transformation from scratch at every layer.
The standard pre-norm form is:
and then
The first equation says: keep the original state \(X\), then add the learned attention correction. The second says: keep the updated state \(H\), then add the learned feed-forward correction. In other words, each block is not replacing the representation; it is refining it.
This means the block mostly preserves the original representation and adds a learned correction. That makes optimization much easier, especially in deep stacks.
The practical effect is that the model can stack many layers without destroying the token signal too early. Each layer adds a small improvement rather than replacing the previous state entirely.
6. MLP: same position, different feature view¶
The attention sublayer mixes tokens across positions. The MLP does not mix positions directly. It acts independently at each position:
with a nonlinearity such as ReLU or GELU.
This is the feature transform step. It allows the network to apply a learned nonlinear operation to each token’s representation after the context has been assembled.
A useful intuition is:
- attention = “which context matters?”
- MLP = “how should this token state be transformed once it has that context?”
7. Why parameter counting matters¶
A rough parameter estimate helps explain why deep language models are expensive.
If the width is \(d\) and the feed-forward hidden size is \(d_{ff}\), then roughly:
- attention has about \(4d^2\) parameters for Q, K, V, and output projections
- the MLP has about \(2dd_{ff}\) parameters
- residual and normalization layers add some smaller overhead
With a common choice \(d_{ff} \approx 4d\), the block is dominated by large linear projections. This is one reason training and inference cost grow quickly as width increases.
8. Tiny NumPy sketch of one block¶
import numpy as np
# One tiny toy decoder block in a very simplified form.
# We do not implement the full Transformer; we only show the block idea.
x = np.array([[1.0, 0.5], [0.3, 0.9]])
W_q = np.array([[1.2, 0.1], [0.4, 0.7]])
W_k = np.array([[0.8, 0.2], [0.5, 0.9]])
W_v = np.array([[0.6, 0.4], [0.3, 1.0]])
W_o = np.array([[1.1, -0.2], [0.5, 0.8]])
Q = x @ W_q
K = x @ W_k
V = x @ W_v
scores = (Q @ K.T) / np.sqrt(K.shape[1])
mask = np.tril(np.ones_like(scores))
scores = np.where(mask == 1, scores, -1e9)
weights = np.exp(scores - scores.max(axis=-1, keepdims=True))
weights = weights / weights.sum(axis=-1, keepdims=True)
out = weights @ V
out = out @ W_o
print("Q shape:", Q.shape)
print("score matrix:\n", np.round(scores, 2))
print("attention output:\n", np.round(out, 2))
This is not a full production block, but it captures the exact structure:
- project to Q, K, V
- compute pairwise relevance
- apply causal mask
- softmax the row
- mix values
- project the result back
9. Why repeated blocks matter¶
One block is useful, but a model is usually a stack of many blocks. Each layer can refine the representation gradually.
The stages are:
- early layers capture local patterns and simple relations
- middle layers mix broader context and syntax
- deeper layers build more abstract, task-relevant features
The transformer is not a magic single-step solver. It is a stack of repeated transformations, each improving the state representation a little more.
10. The key takeaway¶
A decoder block is a small computational unit with a clear purpose:
- attention gathers relevant context
- residuals preserve useful information
- MLP transforms each position’s features
- repeated blocks build richer representations
This is the core of the transformer architecture. Everything else—training, inference, scaling, and generation—is built on top of this block.
11. Assemble Parts 1–3 without skipping a tensor¶
The earlier chapters supply the input to this block:
- Tokenization and embeddings turn integer IDs of shape \((B,T)\) into token vectors \(E[\text{ids}]\) of shape \((B,T,d)\).
- Position either adds a position vector to those token vectors or, with RoPE, later rotates query and key pairs inside attention.
- Attention projects the current states into \(Q\), \(K\), and \(V\), applies the causal mask, and returns one context update per token.
- This chapter adds normalization, residual paths, the output projection, and the MLP so the result can be stacked safely.
For a decoder-only model with additive position encoding, the path into the first block is
With RoPE, there is normally no \(P\) added here. Instead, each block creates \(Q\) and \(K\) and rotates their feature pairs before computing \(QK^\top\). That difference belongs in the attention implementation; the rest of the block can keep the same input and output shape.
flowchart LR
IDs[IDs B,T] --> E[Embedding lookup B,T,d]
E --> P[Add position, or defer RoPE]
P --> N1[Norm]
N1 --> QKV[Q K V projections]
QKV --> CA[Causal multi-head attention]
CA --> WO[Output projection]
P --> Add1[Residual add]
WO --> Add1
Add1 --> N2[Norm]
N2 --> FF[Expand, activate, contract]
Add1 --> Add2[Residual add]
FF --> Add2
Add2 --> Y[Next block B,T,d]
The residual stream always has width \(d\). Internal tensors may use \(h\) heads, head width \(d_h\), or MLP width \(d_{ff}\), but every sublayer must return to \(d\) before it can be added to the residual stream.
12. Multi-head attention inside the block, operation by operation¶
Begin with normalized states \(N\in\mathbb R^{B\times T\times d}\). The four attention matrices are
The combined projections have shape \((B,T,d)\):
Reshape each to \((B,T,h,d_h)\) and transpose to \((B,h,T,d_h)\), where \(d=h d_h\). No information is created by this reshape; it only groups features into heads. For every batch item and head:
\(M_{ij}=0\) when key \(j\) is visible to query \(i\) and \(M_{ij}=-\infty\) when it is forbidden. After concatenating the heads back to \((B,T,d)\), the output projection gives
\(W_O\) matters because concatenation alone leaves every head in a fixed slice of the feature vector. The projection lets the block learn combinations of features contributed by different heads and returns an update in the residual-stream coordinates.
Causality test, not just a mask picture¶
A causal implementation must satisfy this property: changing token position \(j\) cannot change any output at positions \(i<j\). A useful test is to run the same block twice, change only the last input token, and assert that all earlier outputs are identical. The repository test suite performs exactly this check for the NumPy block.
13. Layer normalization from the equation to code¶
For one token vector \(x\in\mathbb R^d\), LayerNorm computes statistics across its feature axis:
\(\gamma\) and \(\beta\) are learned vectors of length \(d\). The operation does not average across tokens, so normalization at one position cannot leak information from a future position. For a tensor \((B,T,d)\), code therefore uses axis=-1 and keeps that axis for broadcasting:
mean = x.mean(axis=-1, keepdims=True)
variance = ((x - mean) ** 2).mean(axis=-1, keepdims=True)
x_hat = (x - mean) / np.sqrt(variance + eps)
y = gamma * x_hat + beta
The \(\epsilon\) protects division when feature variance is extremely small. LayerNorm and attention softmax have different jobs: LayerNorm controls feature scale within a token; softmax normalizes routing scores across key positions.
Pre-norm and post-norm are different computation graphs¶
The original research figure is post-norm:
The block implemented in this repository is pre-norm:
Do not mix the two by moving only one normalization. Pre-norm leaves a particularly direct residual path through a deep stack. The final language model commonly adds one more normalization after the last block and before the vocabulary projection.
14. The MLP is a learned feature expansion¶
At every position, the same two matrices implement
with
If \(d_{ff}=4d\), the first projection expands every token into four times as many intermediate features. The nonlinearity is essential: two linear maps without an activation collapse into a single linear map, \(xW_1W_2\). GELU gates the expanded features smoothly, and \(W_2\) combines them into a width-\(d\) residual update.
Because the same MLP is independently applied at each token position, it can be implemented as matrix multiplication over the final axis of the whole \((B,T,d)\) tensor. There is no Python loop over tokens and no \(T\times T\) interaction in this sublayer.
Modern architectures may replace this with a gated MLP such as SwiGLU. That changes the feature transform and parameter count, but it does not change the division of labor: attention mixes positions; the MLP mixes features within each position.
15. A complete NumPy forward pass¶
The repository now contains a runnable implementation in llm_foundations/decoder_block.py with four deliberately separate pieces:
LayerNorm: feature-axis normalization with learned scale and shiftMultiHeadAttention: Q, K, V projections, head split/combine, causal attention, and \(W_O\)MLP: expansion, GELU, and contractionDecoderBlock: the two pre-norm residual updates
Use it locally from the repository root:
import numpy as np
from llm_foundations.decoder_block import DecoderBlock
rng = np.random.default_rng(7)
x = rng.normal(size=(2, 5, 8)) # B=2, T=5, d=8
block = DecoderBlock(
d_model=8,
num_heads=2,
d_ff=32,
seed=7,
)
y = block(x)
print("input: ", x.shape)
print("output:", y.shape)
assert y.shape == x.shape
The implementation omits dropout so repeated runs are easy to inspect. It is a forward-pass teaching implementation, not an optimized training kernel. NumPy materializes attention tensors and does not provide automatic differentiation here. The training chapter explains how a loss and backpropagation turn this forward graph into a trainable model.
16. From one block to vocabulary logits¶
One decoder block does not itself predict a token. A small language model wraps the stack:
If the last hidden tensor is \(H\in\mathbb R^{B\times T\times d}\) and the output matrix is \(W_U\in\mathbb R^{d\times V}\), then
During training, every position's row of \(V\) logits is compared with the next token target. During generation, the model uses the logits at the newest position to choose one token, appends it, and runs the next step. The block always transforms hidden states; the vocabulary projection is the bridge back to token IDs.
17. Worked parameter count¶
For \(d=512\), \(h=8\), and \(d_{ff}=2048\):
| Component | Weights | Biases | Count |
|---|---|---|---|
| Q, K, V, output projections | \(4(512^2)\) | \(4(512)\) | \(1,050,624\) |
| MLP expansion and contraction | \(2(512)(2048)\) | \(2048+512\) | \(2,099,712\) |
| Two LayerNorms | two scales | two shifts | \(2,048\) |
| Total block | \(3,152,384\) |
The head count does not multiply the \(4d^2\) projection count when Q, K, and V are stored as combined \(d\times d\) matrices. More heads split the same projected width into smaller pieces. Architectures with grouped-query attention, gated MLPs, projection biases removed, or different normalization have different totals, so count the code being used rather than memorizing one formula.
18. Implementation checklist and failure modes¶
Before stacking blocks, verify one block:
d_model % num_heads == 0.- Q, K, and V become \((B,h,T,d_h)\) before score multiplication.
- Softmax runs across the key axis, the final axis of \((B,h,T,T)\).
- The causal mask is applied before softmax and permits the diagonal.
- Concatenated heads return to \((B,T,d)\) before \(W_O\).
- Both residual additions combine tensors with exactly the same shape.
- LayerNorm uses the feature axis rather than batch or time.
- Changing a future token leaves earlier outputs unchanged.
- All attention rows are finite and sum to one before value mixing.
- The hand parameter count matches the arrays created by code.
Common bugs include transposing the wrong axes, normalizing score columns instead of rows, applying a mask after softmax, forgetting the output projection, and accidentally broadcasting a residual across the time axis. Printing every intermediate shape is more useful than staring at the final output after one of these mistakes.
19. Coding milestones¶
Build the block in this order:
- Implement and test stable softmax and a lower-triangular Boolean mask.
- Implement one causal attention head and verify its weight rows.
- Add split-head and combine-head functions; test that combining reverses splitting.
- Add \(W_O\) and confirm attention returns width \(d\).
- Implement LayerNorm and confirm each token has approximately zero mean and unit variance before learned scale and shift.
- Implement the MLP and confirm that it preserves \((B,T,d)\) externally.
- Add the two residual paths.
- Run the future-token causality test.
- Stack two blocks and add final normalization and vocabulary projection.
- Connect shifted targets and cross-entropy from Part 5, then overfit one tiny batch.
Completing those milestones means you can trace and code the full decoder-block forward path from the equations, understand every tensor shape, and identify where training must supply gradients.