AI Knowledge Tree

The transformer block

Assemble multi-head attention, position-wise FFN, residual connections, and layer norm into one transformer block, and trace the tensor shapes through a full forward pass.

⏱ 30 minPrerequisites:Multi-head attention

Multi-head attention mixes information across positions. But attention is a weighted average of values -- it is linear in the values, and a model made entirely of attention heads is just a large linear transformation. Two additions turn this into a deep nonlinear model: a position-wise feed-forward network and residual connections with layer normalization[1].

Position-wise FFN

After attention, each token's representation is updated by a small MLP applied independently to each position. For input x∈Rdmodelx \in \mathbb{R}^{d_{\text{model}}}:

FFN(x)=W2 ReLU(W1x+b1)+b2,\text{FFN}(x) = W_2\, \text{ReLU}(W_1 x + b_1) + b_2,

where W1∈Rdff×dmodelW_1 \in \mathbb{R}^{d_{\text{ff}} \times d_{\text{model}}} and W2∈Rdmodel×dffW_2 \in \mathbb{R}^{d_{\text{model}} \times d_{\text{ff}}}, with dff=4dmodeld_{\text{ff}} = 4 d_{\text{model}} typically.

The FFN is "position-wise" because the same weights are shared across all positions, but applied independently -- no cross-position mixing happens here. Modern variants replace ReLU with SwiGLU or GELU: FFN(x)=(W1x⊙σ(W3x))W2\text{FFN}(x) = (W_1 x \odot \sigma(W_3 x)) W_2, which empirically trains better.

Residual connections

Residual connections (He et al., 2016) add the block's input directly to its output:

x←x+SubLayer(x).x \leftarrow x + \text{SubLayer}(x).

This creates a direct gradient path from the loss to early layers, mitigating the vanishing-gradient problem in deep networks. It also gives the model an easy way to learn "do nothing" (output zero from the sublayer).

Layer normalization

Layer norm (Ba et al., 2016) normalizes across the feature dimension (not the batch dimension like BatchNorm):

LN(x)=γ⊙x−μσ2+ϵ+β,\text{LN}(x) = \gamma \odot \frac{x - \mu}{\sqrt{\sigma^2 + \epsilon}} + \beta,

where μ\mu and σ2\sigma^2 are the mean and variance computed over the dmodeld_{\text{model}} features of a single token, and γ,β∈Rdmodel\gamma, \beta \in \mathbb{R}^{d_{\text{model}}} are learned affine parameters.

Layer norm stabilizes training across batch sizes and sequence lengths. It is computed per-token, making it independent of other tokens in the batch.

Pre-norm vs post-norm: the original Transformer uses post-norm (LN(x+Sub(x))\text{LN}(x + \text{Sub}(x))). Modern LLMs use pre-norm (x+Sub(LN(x))x + \text{Sub}(\text{LN}(x))), which stabilizes training at scale without learning-rate warmup.

The full transformer block

With pre-norm:

Block(x)=x+FFN(LN(x+MHA(LN(x)))).\text{Block}(x) = x + \text{FFN}(\text{LN}(x + \text{MHA}(\text{LN}(x)))).

Tracing tensor shapes

For dmodel=512d_{\text{model}} = 512, h=8h = 8, dff=2048d_{\text{ff}} = 2048, sequence length TT:

TensorShape
Input xx(T,512)(T, 512)
Q,K,VQ, K, V (per head)(T,64)(T, 64) each
Attention output per head(T,64)(T, 64)
Concatenated heads(T,512)(T, 512)
After WOW^O(T,512)(T, 512)
After residual + LN(T,512)(T, 512)
FFN intermediate(T,2048)(T, 2048)
FFN output(T,512)(T, 512)
Block output(T,512)(T, 512)

The sequence length dimension is never changed by any operation -- the block is shape-preserving.

Implementation

python · numpy
import numpy as np

def layer_norm(x, gamma, beta, eps=1e-5):
    mu    = x.mean(axis=-1, keepdims=True)
    var   = x.var(axis=-1, keepdims=True)
    return gamma * (x - mu) / np.sqrt(var + eps) + beta

def gelu(x):
    return 0.5 * x * (1 + np.tanh(np.sqrt(2 / np.pi) * (x + 0.044715 * x**3)))

class TransformerBlock:
    def __init__(self, d_model, n_heads, d_ff, seed=0):
        rng = np.random.default_rng(seed)
        s = 0.02
        self.mha    = None   # placeholder -- use the class from the previous lesson
        self.W1     = rng.normal(0, s, (d_ff, d_model))
        self.b1     = np.zeros(d_ff)
        self.W2     = rng.normal(0, s, (d_model, d_ff))
        self.b2     = np.zeros(d_model)
        self.gamma1 = np.ones(d_model);  self.beta1 = np.zeros(d_model)
        self.gamma2 = np.ones(d_model);  self.beta2 = np.zeros(d_model)

    def ffn(self, x):
        return gelu(x @ self.W1.T + self.b1) @ self.W2.T + self.b2

    def forward(self, x, mha_out):
        """Pre-norm transformer block. mha_out already computed externally."""
        # Attention sublayer
        x_normed = layer_norm(x, self.gamma1, self.beta1)
        x = x + mha_out   # residual (pre-norm: mha_out = MHA(x_normed))

        # FFN sublayer
        x_normed = layer_norm(x, self.gamma2, self.beta2)
        x = x + self.ffn(x_normed)
        return x

# Verify shapes
d_model, n_heads, d_ff, T = 32, 4, 128, 6
rng = np.random.default_rng(0)
x   = rng.normal(0, 1, (T, d_model))
# Fake MHA output for demonstration
mha_out = rng.normal(0, 0.1, (T, d_model))
block = TransformerBlock(d_model, n_heads, d_ff)
y     = block.forward(x, mha_out)
print("Input shape: ", x.shape)
print("Output shape:", y.shape)
print("Max absolute change:", np.abs(y - x).max().round(4))

Parameter count of one block

ComponentParameters
MHA (WQ,WK,WV,WOW^Q, W^K, W^V, W^O)4d24 d^2
LN1 (γ,β\gamma, \beta)2d2d
FFN (W1,b1,W2,b2W_1, b_1, W_2, b_2)2d⋅dff+dff+d2 d \cdot d_{\text{ff}} + d_{\text{ff}} + d
LN2 (γ,β\gamma, \beta)2d2d

For GPT-2 (d=768d=768, dff=3072d_{\text{ff}}=3072, 12 blocks): ∼85\sim 85M parameters. For GPT-3 (d=12288d=12288, dff=49152d_{\text{ff}}=49152, 96 blocks): ∼175\sim 175B parameters.

Exercises

Exercise 1. For dmodel=512d_{\text{model}} = 512 and dff=2048d_{\text{ff}} = 2048, count the FFN parameters (W1,b1,W2,b2W_1, b_1, W_2, b_2). Then argue from the pre-norm formula Block(x)=x+FFN(LN(x+MHA(LN(x))))\text{Block}(x) = x + \text{FFN}(\text{LN}(x + \text{MHA}(\text{LN}(x)))) why the block is shape-preserving.

Solution

Step 1. W1W_1 is dff×dmodeld_{\text{ff}} \times d_{\text{model}} and W2W_2 is dmodel×dffd_{\text{model}} \times d_{\text{ff}}, so the two weight matrices hold 2dmodeldff2 d_{\text{model}} d_{\text{ff}} parameters. The biases add dff+dmodeld_{\text{ff}} + d_{\text{model}}:

2⋅512⋅2048+2048+512=2,097,152+2,560=2,099,712.2 \cdot 512 \cdot 2048 + 2048 + 512 = 2{,}097{,}152 + 2{,}560 = 2{,}099{,}712.

Step 2. Trace the shapes. LN(x)\text{LN}(x) keeps (T,dmodel)(T, d_{\text{model}}); MHA maps (T,dmodel)(T, d_{\text{model}}) to (T,dmodel)(T, d_{\text{model}}) through WOW^O; the residual adds two (T,dmodel)(T, d_{\text{model}}) tensors. The FFN sends dmodeld_{\text{model}} up to dffd_{\text{ff}} and W2W_2 brings it back to dmodeld_{\text{model}}, and its residual adds same-shaped tensors. Every sublayer outputs (T,dmodel)(T, d_{\text{model}}), so the block does too. No operation touches the sequence axis TT, so the block is shape-preserving.

Exercise 2. Compute layer norm of x=(1,2,3,4)x = (1, 2, 3, 4) with γ=1\gamma = 1, β=0\beta = 0, ϵ=0\epsilon = 0 by hand, and confirm the result has zero mean and unit variance.

Solution

Step 1. The mean over the four features is μ=(1+2+3+4)/4=2.5\mu = (1+2+3+4)/4 = 2.5 and the variance is

σ2=14[(1.5)2+(0.5)2+(0.5)2+(1.5)2]=14(2.25+0.25+0.25+2.25)=1.25,\sigma^2 = \tfrac{1}{4}\big[(1.5)^2 + (0.5)^2 + (0.5)^2 + (1.5)^2\big] = \tfrac{1}{4}(2.25 + 0.25 + 0.25 + 2.25) = 1.25,

so σ=1.25=1.1180\sigma = \sqrt{1.25} = 1.1180.

Step 2. Normalize each feature as (xi−μ)/σ(x_i - \mu)/\sigma:

(−1.51.1180, −0.51.1180, 0.51.1180, 1.51.1180)=(−1.3416, −0.4472, 0.4472, 1.3416).\left( \tfrac{-1.5}{1.1180},\ \tfrac{-0.5}{1.1180},\ \tfrac{0.5}{1.1180},\ \tfrac{1.5}{1.1180} \right) = (-1.3416,\ -0.4472,\ 0.4472,\ 1.3416).

Step 3. The four outputs sum to 00, so the mean is 00. Their squares are 1.8+0.2+0.2+1.8=41.8 + 0.2 + 0.2 + 1.8 = 4, and dividing by 44 gives variance 11. With γ=1\gamma = 1 and β=0\beta = 0 the affine step leaves this unchanged.

Exercise 3 (code). Implement layer norm and confirm it produces zero mean and unit variance per token, and that it is invariant to a constant shift of the input (adding a constant to every feature of a token leaves the output unchanged).

Solution
python · numpy
import numpy as np

def layer_norm(x, gamma, beta, eps=1e-5):
    mu = x.mean(axis=-1, keepdims=True)
    var = x.var(axis=-1, keepdims=True)
    return gamma * (x - mu) / np.sqrt(var + eps) + beta

rng = np.random.default_rng(0)
T, d = 4, 16
x = rng.normal(3.0, 2.0, (T, d))          # nonzero mean, nonunit variance
g, b = np.ones(d), np.zeros(d)
y = layer_norm(x, g, b, eps=0.0)

assert np.allclose(y.mean(axis=-1), 0.0, atol=1e-6)   # zero mean per token
assert np.allclose(y.var(axis=-1), 1.0, atol=1e-6)    # unit variance per token

y_shift = layer_norm(x + 7.0, g, b, eps=0.0)          # shift every feature
assert np.allclose(y, y_shift, atol=1e-6)             # subtracting mu cancels it
print("per-token mean 0, variance 1, shift-invariant")

Subtracting the per-token mean removes any constant offset before scaling, so layer norm sees only the shape of each token's feature vector, not its absolute level.

The next lesson adds positional encodings, which tell the model where in the sequence each token sits -- since attention itself is permutation-equivariant.


References

  1. [1]Vaswani, A., et al. (2017). "Attention is all you need". NeurIPS. arXiv:1706.03762↩