The LLM StackFrom Silicon to Agents
Part II — The Transformer Architecture
38 min read·Updated ·▶ Run the code (Colab)

2.10 Modern Architecture Improvements & Design Choices

The Transformer described by Vaswani et al. in 2017 was a landmark, but the architecture used in today’s production LLMs — Llama 4, Qwen3, DeepSeek-V3, Gemma 3 — bears only a family resemblance to that original design. A practitioner opening a modern model config file for the first time will encounter terms like rms_norm, silu, rope_theta, num_key_value_heads, qk_norm, and no_bias. Each of these represents a deliberate, empirically validated architectural decision made in pursuit of better training stability, improved compute efficiency, or stronger final model quality.

This chapter is your annotated map of those decisions. We will examine each improvement from first principles — why it was introduced, what it fixes, how it works mathematically, how it appears in code, and finally what its name is in a real config.json and which library ships the fused kernel for it. We conclude with a “modern transformer recipe” summarizing which choices are nearly universal versus still debated. For the transformer block basics, see The Transformer Block: Norms, Residuals, MLPs & Activations; for positional encodings in depth, see Positional Encodings: Sinusoidal, Learned, RoPE & ALiBi; and for GQA/MQA mechanics, see Multi-Head Attention, MQA, GQA & MLA.

Every choice surveyed here is committed to in The Stack-100M Architecture, where the capstone freezes one config — RMSNorm pre-norm, SwiGLU, GQA with 8 query / 2 KV heads, RoPE with interleaved position-free layers, QK-norm, tied embeddings, no biases — and reproduces its parameter count to the last norm vector. Read this chapter for the menu; read that one for the order.


RMSNorm: Cheaper, Equally Stable Normalization

The problem with LayerNorm

The original Transformer used LayerNorm (Ba et al., “Layer Normalization”, 2016):

\[ \text{LayerNorm}(x) = \frac{x - \mu}{\sqrt{\sigma^2 + \epsilon}} \cdot \gamma + \beta \]

where \(\mu = \frac{1}{d}\sum_i x_i\) and \(\sigma^2 = \frac{1}{d}\sum_i (x_i - \mu)^2\). This requires two passes over the feature vector: one to compute the mean, one to compute the variance. More importantly, it requires two learned parameter vectors: \(\gamma\) (scale) and \(\beta\) (shift). The \(\beta\) term encodes an explicit re-centering.

RMSNorm derivation

Zhang and Sennrich (“Root Mean Square Layer Normalization”, NeurIPS 2019) asked: is the re-centering operation actually necessary? They ablated LayerNorm into its components and found that the re-scaling (via \(\gamma\)) drives almost all of LayerNorm’s benefit, while mean subtraction contributes little to final performance but accounts for roughly a third of LayerNorm’s compute.

Root Mean Square Normalization (RMSNorm) drops mean subtraction entirely:

\[ \text{RMSNorm}(x) = \frac{x}{\text{RMS}(x)} \cdot \gamma, \quad \text{where } \text{RMS}(x) = \sqrt{\frac{1}{d}\sum_{i=1}^{d} x_i^2 + \epsilon} \]

There is no \(\beta\) parameter. One division by the RMS, then a learned element-wise scale. This is approximately 10–30% faster than LayerNorm on modern hardware because there is no mean subtraction kernel and no second reduction pass.

import torch
import torch.nn as nn

class RMSNorm(nn.Module):
    """
    Root Mean Square Layer Normalization.
    Used in Llama, Mistral, Qwen, DeepSeek, Gemma, and most modern LLMs.

    Parameters
    ----------
    dim : int
        The feature dimension (hidden size d_model).
    eps : float
        Small constant for numerical stability. 1e-5 is common; some
        models (Gemma) use 1e-6.
    """

    def __init__(self, dim: int, eps: float = 1e-5):
        super().__init__()
        self.eps = eps
        # gamma: learnable scale, initialized to ones so RMSNorm is
        # initially the identity (modulo the normalization step).
        self.weight = nn.Parameter(torch.ones(dim))

    def _norm(self, x: torch.Tensor) -> torch.Tensor:
        # x: (..., dim). Compute RMS over last dimension.
        # rsqrt = 1/sqrt for numerical efficiency.
        return x * torch.rsqrt(x.pow(2).mean(dim=-1, keepdim=True) + self.eps)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # Cast to float32 for the norm computation for precision,
        # then cast back. This pattern is used in Llama reference code.
        output = self._norm(x.float()).type_as(x)
        return output * self.weight


# --- Quick sanity check ---
torch.manual_seed(42)
x = torch.randn(2, 8, 512)   # batch=2, seq=8, d_model=512
norm = RMSNorm(dim=512)
y = norm(x)
# Verify unit-RMS along the feature dimension (before scaling by weight)
raw_normed = x.float() * torch.rsqrt(x.float().pow(2).mean(-1, keepdim=True) + 1e-5)
print(f"RMS of raw_normed row 0: {raw_normed[0,0].pow(2).mean().sqrt():.4f}")  # ~1.000

Common pitfall: the (1 + w) convention

Two conventions for the learned scale coexist in the wild. Llama/Qwen/Mistral store \(\gamma\) directly and multiply by self.weight (initialized to ones), exactly as above. Gemma stores an offset and multiplies by (1.0 + self.weight) with the parameter initialized to zeros, so that weight decay pulls the scale toward 1 rather than toward 0. Loading a Gemma checkpoint into a Llama-style RMSNorm therefore multiplies every hidden state by roughly zero and produces fluent-looking garbage with no error message. When you write a checkpoint converter, diff the norm formula before you diff anything else.

Pre-norm vs post-norm

The original Transformer (and BERT) used post-norm: normalization applied after the residual addition. GPT-2 already moved the norm to the input of each sub-block — pre-norm — and added a single final norm before the LM head; nearly all modern models kept that placement:

Post-norm (Vaswani 2017, BERT):  x → Sublayer(x) + x → Norm → output
Pre-norm  (GPT-2 onward):        x → Norm(x) → Sublayer → + x → output

Pre-norm yields more stable gradients at large scale — the residual path is always clean, so gradients flow through the skip connection without being divided by a normalization operation. The trade-off is that pre-norm models can exhibit “representation collapse” at depth if not carefully initialized, which motivates other tricks like scaled initialization.

Two further placements are now common at the frontier, both of which keep the clean residual highway but re-bound what each block adds to it. Sandwich norm (Gemma ⅔) keeps the pre-norm and adds a second RMSNorm on the sublayer output before the residual add: x + Norm_post(Sublayer(Norm_pre(x))). Reordered / output norm (OLMo 2) drops the pre-norm and normalizes only the branch output: x + Norm(Sublayer(x)). Both cost one extra cheap reduction per sublayer and both were adopted specifically because they suppress loss spikes in long runs (see Training Stability, Loss Spikes & Debugging Large Runs). A different axis entirely — running the attention and MLP sublayers in parallel off a single norm, as PaLM and GPT-NeoX do — is derived in The Transformer Block: Norms, Residuals, MLPs & Activations.


SwiGLU: A Better MLP Activation

From ReLU to GELU to SwiGLU

The original Transformer used a plain two-layer MLP with ReLU:

\[ \text{FFN}(x) = \max(0, xW_1 + b_1)W_2 + b_2 \]

BERT switched to GELU (Gaussian Error Linear Unit), which is smoother and empirically outperforms ReLU. Modern LLMs take this further with Gated Linear Units (GLU) and specifically their SiLU-gated variant — SwiGLU — introduced by Noam Shazeer (“GLU Variants Improve Transformer”, 2020).

SwiGLU mechanics

A Gated Linear Unit multiplies two linear projections together, one of which passes through a nonlinearity acting as a soft gate:

\[ \text{SwiGLU}(x, W, V, W_2) = (\text{SiLU}(xW) \otimes xV) \cdot W_2 \]

where \(\text{SiLU}(z) = z \cdot \sigma(z)\) (also called Swish), and \(\otimes\) denotes element-wise multiplication. The SiLU function is smooth, non-monotone, and has a non-zero gradient for negative inputs, all of which help gradient flow.

The gating mechanism is important: \(xV\) produces a “content” projection and \(\text{SiLU}(xW)\) produces a soft gate. The gate dynamically suppresses or amplifies each dimension of the content, giving the MLP a multiplicative interaction it lacked before.

SwiGLU: a soft gate modulating a content projection Two parallel projections meet at an element-wise multiply -- the gate dials each dimension of the content up or down. plain MLP x W1 GELU W2 one path, no interaction SwiGLU x xW gate = xW SiLU gate xV up = xV content the gate dials each dimension: gate content result gate~1 -> amplify gate~0 -> suppress gate (x) up W2 down = W2 output (d_model) three matrices (W, V, W2) -> intermediate width shrinks to 8d/3 so param count stays iso with a plain 4x two-matrix MLP (3 x d x 8d/3 = 8d^2 = 2 x d x 4d) gate path: xW -> SiLU (this figure's accent) content path: xV, and the down-projection W2 Two matrices (W1, W2) give one path with no interaction; SwiGLU's third matrix (V) lets the gate multiplicatively suppress or amplify each dimension of the content before it is projected back down.
SwiGLU replaces a single linear path with two parallel projections that meet at an element-wise multiply. One projection (xW) passes through SiLU to become a soft, per-dimension gate in roughly [0, 1]-ish range; the other (xV) is the raw content -- the gate then dials each dimension of the content up or down before the result is projected back to model width by W2, which is the multiplicative interaction a plain GELU MLP lacks. Because SwiGLU needs three weight matrices instead of two, the intermediate width is shrunk to 8d/3 so total parameters match a standard 4x MLP.

Because SwiGLU has three weight matrices (\(W\), \(V\), \(W_2\)) instead of two, to keep parameter count equal to a standard 4x MLP, the hidden dimension is reduced to \(\frac{2}{3} \cdot 4d = \frac{8d}{3}\). In practice most models round to a multiple of 256 for hardware alignment; Llama 2 70B uses an intermediate size of 28,672 for a model dimension of 8,192.

import torch
import torch.nn as nn
import torch.nn.functional as F

class SwiGLUMLP(nn.Module):
    """
    SwiGLU feed-forward network used in Llama, Qwen, DeepSeek, etc.

    For a model with hidden_dim = d, the intermediate_dim is typically
    floor(8*d/3) rounded up to a multiple of 256.
    """

    def __init__(self, hidden_dim: int, intermediate_dim: int):
        super().__init__()
        # Three linear projections; no bias (see section on no-bias later)
        self.gate_proj = nn.Linear(hidden_dim, intermediate_dim, bias=False)
        self.up_proj   = nn.Linear(hidden_dim, intermediate_dim, bias=False)
        self.down_proj = nn.Linear(intermediate_dim, hidden_dim, bias=False)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # gate: apply SiLU to create a soft gate
        gate = F.silu(self.gate_proj(x))
        # up: linear content projection
        up   = self.up_proj(x)
        # Element-wise product (gated content), then project back down
        return self.down_proj(gate * up)


# Demonstration: compare SwiGLU vs vanilla FFN parameter counts
d = 4096  # typical hidden size

# Vanilla FFN with 4x expansion: 2 * d * 4d = 8d^2
vanilla_params = 2 * d * (4 * d)

# SwiGLU FFN with 8/3 expansion: 3 * d * (8d/3) = 8d^2 — same!
intermediate = int(8 * d / 3)
swiglu_params = 3 * d * intermediate
print(f"Vanilla 4x FFN params: {vanilla_params:,}")
print(f"SwiGLU  8/3 FFN params: {swiglu_params:,}")
# Both are approximately 8*d^2 — SwiGLU is iso-parameter with better quality.

RoPE: Rotary Position Embeddings

Rotary Position Embedding (RoPE), introduced by Su et al. (“RoFormer: Enhanced Transformer with Rotary Position Embedding”, 2021), is now the dominant positional encoding for autoregressive LLMs. See Positional Encodings: Sinusoidal, Learned, RoPE & ALiBi for the full derivation; here we focus on why it was adopted in the modern recipe and its practical configuration.

Why RoPE won

RoPE encodes position by rotating query and key vectors in 2D subspaces. The critical insight is that the dot product \(q_m^\top k_n\) naturally becomes a function of the relative offset \((m - n)\), not of absolute positions — this is the relative position property that matters for generalization. Unlike sinusoidal encodings, RoPE requires no separate embedding table and requires no modification to the value vectors. Unlike learned absolute position embeddings, it generalizes beyond training context length (with appropriate scaling, see below).

The rotation for position \(m\) applied to a 2D subspace of the query vector:

\[ R_m = \begin{bmatrix} \cos(m\theta) & -\sin(m\theta) \\ \sin(m\theta) & \cos(m\theta) \end{bmatrix} \]

For a \(d_k\)-dimensional head, this is repeated for \(d_k/2\) rotation pairs, each with a different base frequency:

\[ \theta_i = \text{base}^{-2i/d_k}, \quad i = 0, 1, \ldots, d_k/2 - 1 \]

The rope_theta hyperparameter (the base) controls the frequency range. GPT-2 had no RoPE. Llama 1 used rope_theta=10000. Llama 3 extended this to rope_theta=500000 to improve long-context behavior by spreading frequencies more broadly, making it easier to extrapolate to new positions at inference time. RoPE’s dominance is now being probed at the long-context frontier: Llama 4 (2025) uses iRoPE, interleaving a minority of position-free “NoPE” attention layers among the RoPE layers to support its 10M-token context window.

import torch
import torch.nn as nn

def precompute_freqs_cis(dim: int, max_seq_len: int, theta: float = 10000.0):
    """
    Precompute complex exponentials for RoPE.
    Returns a tensor of shape (max_seq_len, dim//2) with dtype=complex64.
    The 'cis' notation: cis(x) = e^{ix} = cos(x) + i*sin(x).
    """
    # Frequencies: theta^{-2i/dim} for i in [0, dim/2)
    freqs = 1.0 / (theta ** (torch.arange(0, dim, 2).float() / dim))
    # Positions: [0, 1, 2, ..., max_seq_len-1]
    t = torch.arange(max_seq_len)
    # Outer product: shape (max_seq_len, dim//2)
    freqs = torch.outer(t, freqs)
    # Convert to complex: e^{i * freqs}
    freqs_cis = torch.polar(torch.ones_like(freqs), freqs)
    return freqs_cis


def apply_rotary_emb(xq: torch.Tensor, xk: torch.Tensor,
                     freqs_cis: torch.Tensor):
    """
    Apply RoPE to query and key tensors.
    xq, xk: (batch, seq_len, n_heads, head_dim)
    freqs_cis: (seq_len, head_dim//2) complex tensor
    """
    # Reshape to complex view: treat pairs of reals as complex numbers
    xq_ = torch.view_as_complex(xq.float().reshape(*xq.shape[:-1], -1, 2))
    xk_ = torch.view_as_complex(xk.float().reshape(*xk.shape[:-1], -1, 2))

    # Broadcast freqs_cis over batch and head dimensions
    # freqs_cis shape: (seq_len, head_dim//2) -> (1, seq_len, 1, head_dim//2)
    freqs_cis = freqs_cis.unsqueeze(0).unsqueeze(2)

    # Multiply in complex space = rotation in 2D pairs
    xq_out = torch.view_as_real(xq_ * freqs_cis).flatten(3)
    xk_out = torch.view_as_real(xk_ * freqs_cis).flatten(3)

    return xq_out.type_as(xq), xk_out.type_as(xk)


# Demonstration
torch.manual_seed(0)
batch, seq_len, n_heads, head_dim = 2, 16, 8, 64
xq = torch.randn(batch, seq_len, n_heads, head_dim)
xk = torch.randn(batch, seq_len, n_heads, head_dim)

freqs = precompute_freqs_cis(head_dim, seq_len, theta=500000.0)  # Llama 3 theta
xq_rot, xk_rot = apply_rotary_emb(xq, xk, freqs)
print(f"RoPE applied. Output shape: {xq_rot.shape}")
# Verify relative position property: dot product depends only on relative offset
q0 = xq_rot[0, 3, 0]  # position 3, head 0
k5 = xk_rot[0, 8, 0]  # position 8, head 0
# The dot product q0.k5 encodes offset=5, not absolute positions 3 and 8.

Grouped Query Attention (GQA)

Standard Multi-Head Attention (MHA) maintains separate \(K\) and \(V\) projection matrices for each of \(H\) heads. During autoregressive decoding, the key-value (KV) cache grows as \(O(L \cdot H \cdot d_k)\) per layer — for a 70B model with 64 heads and a 128K context, this is on the order of tens of gigabytes. See Multi-Head Attention, MQA, GQA & MLA for the full treatment; here we focus on the design decision and its practical configuration.

Grouped Query Attention (GQA), introduced in Ainslie et al. (“GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints”, 2023), is a generalization that interpolates between Multi-Head Attention and Multi-Query Attention (MQA, which has a single KV head for all query heads):

MHA:  H_q = H_kv                  (all heads have own KV)
GQA:  H_q = G * H_kv  for G > 1   (G query heads share one KV head)
MQA:  H_kv = 1                     (all query heads share one KV)

The near-universal choice is 8 KV heads, almost regardless of query-head count: Llama 3 8B uses 32 query / 8 KV heads (\(G=4\)), Llama 2 70B and Llama 3 70B use 64 query / 8 KV heads (\(G=8\)), and the Qwen 2.5 / Qwen3 families follow the same pattern. This reduces KV cache memory by a factor of \(G\) while introducing only a small quality degradation versus MHA. Eight is not a coincidence: under tensor parallelism the KV heads are split across ranks, so \(H_{kv} \ge \text{TP degree}\) lets each GPU of a standard 8-GPU node own exactly one KV head with no replication and no extra all-gather (see Distributed Training II: Tensor, Pipeline, Sequence & Expert Parallelism). Picking \(H_{kv} < \text{TP}\) forces the KV cache to be duplicated on every rank and silently gives back the memory you were trying to save.

Worked example: KV cache memory budget

Consider a model with the following configuration (similar to Llama 3 8B): - Layers: 32 - Hidden dim: 4096 - Query heads: 32 (head_dim = 128) - KV heads (GQA): 8

KV cache per token per layer:

\[ \text{bytes} = 2 \text{ (K and V)} \times H_{kv} \times d_k \times \text{bytes\_per\_element} \]

With bf16 (2 bytes), this is:

\[ 2 \times 8 \times 128 \times 2 = 4096 \text{ bytes} = 4 \text{ KB per token per layer} \]

Total KV cache for 32 layers, 8K context:

\[ 4 \text{ KB} \times 32 \times 8192 = 1073741824 \text{ bytes} \approx 1 \text{ GB} \]

With full MHA (32 KV heads), this would be 4 GB — four times larger. For a 128K context, GQA brings it from ~64 GB to ~16 GB, making long-context inference feasible on reasonable hardware.

import torch
import torch.nn as nn
import math

class GroupedQueryAttention(nn.Module):
    """
    Grouped Query Attention (GQA) as used in Llama 2/3, Qwen, Mistral.

    n_heads: number of query heads (H_q)
    n_kv_heads: number of key/value heads (H_kv), must divide n_heads evenly
    """

    def __init__(self, d_model: int, n_heads: int, n_kv_heads: int):
        super().__init__()
        assert n_heads % n_kv_heads == 0, "n_heads must be divisible by n_kv_heads"
        self.n_heads    = n_heads
        self.n_kv_heads = n_kv_heads
        self.n_rep      = n_heads // n_kv_heads   # how many Q heads per KV head
        self.head_dim   = d_model // n_heads

        # Query projects to n_heads * head_dim
        self.q_proj = nn.Linear(d_model, n_heads    * self.head_dim, bias=False)
        # Key/Value project to n_kv_heads * head_dim (smaller!)
        self.k_proj = nn.Linear(d_model, n_kv_heads * self.head_dim, bias=False)
        self.v_proj = nn.Linear(d_model, n_kv_heads * self.head_dim, bias=False)
        self.o_proj = nn.Linear(d_model, d_model, bias=False)

    def _repeat_kv(self, x: torch.Tensor) -> torch.Tensor:
        """Expand KV from n_kv_heads to n_heads by repeating."""
        # x: (batch, seq, n_kv_heads, head_dim)
        if self.n_rep == 1:
            return x
        # Repeat along the head dimension n_rep times
        return x.unsqueeze(3).expand(
            *x.shape[:2], self.n_kv_heads, self.n_rep, self.head_dim
        ).reshape(*x.shape[:2], self.n_heads, self.head_dim)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        B, T, C = x.shape
        head_dim = self.head_dim

        # Project to Q, K, V
        q = self.q_proj(x).view(B, T, self.n_heads,    head_dim)
        k = self.k_proj(x).view(B, T, self.n_kv_heads, head_dim)
        v = self.v_proj(x).view(B, T, self.n_kv_heads, head_dim)

        # Expand K and V to match n_heads
        k = self._repeat_kv(k)  # (B, T, n_heads, head_dim)
        v = self._repeat_kv(v)

        # Transpose to (B, n_heads, T, head_dim) for attention
        q = q.transpose(1, 2)
        k = k.transpose(1, 2)
        v = v.transpose(1, 2)

        # Scaled dot-product attention
        scale = math.sqrt(head_dim)
        attn = (q @ k.transpose(-2, -1)) / scale         # (B, n_heads, T, T)
        attn = torch.softmax(attn, dim=-1)
        out  = attn @ v                                   # (B, n_heads, T, head_dim)

        # Merge heads and project
        out = out.transpose(1, 2).contiguous().view(B, T, C)
        return self.o_proj(out)

QK-Norm: Stabilizing Attention Logits at Scale

The problem: logit explosion

In deep, wide models trained for many tokens, the dot products \(q_i \cdot k_j\) can grow to very large values. Once the logits are large in magnitude, the softmax saturates: one token gets weight ~1 and all others get weight ~0. This “attention collapse” degrades the model’s ability to attend to multiple positions, and the large pre-softmax logits create numerical instability, especially in bf16 where the dynamic range is narrow. See also Numerical Computing, Floating Point & Precision for why bf16 overflow is a real concern.

Keeping the softmax from saturating Two independent guards against attention collapse: bound the inputs to the dot product, or squash the extreme outputs. 1. the problem logits grow with scale / long training q k q . k = LARGE softmax ~1 attention collapse: one-hot distribution (one token gets weight ~1, all others ~0) unbounded logits saturate softmax 2. QK-norm RMSNorm(q), RMSNorm(k) before the dot product q RMSNorm k RMSNorm q k unit-length q, k q.k now bounded to O(d_k) healthy, spread-out distribution attention spreads across several tokens bounds the INPUT to the dot product 3. soft-cap z -> c * tanh(z / c) z out y = z (identity) +c -c linear near 0 asymptote at +c asymptote at -c c: illustrative cap value (e.g. tick above) squashes the OUTPUT extreme QK-norm bounds the input; soft-cap squashes the extreme -- both prevent one-hot collapse.
Unbounded attention logits saturate the softmax into a near one-hot distribution, and two independent fixes guard against it. QK-norm normalizes queries and keys to unit length before the dot product so the logit itself stays bounded to O(d_k), while logit soft-capping instead lets the raw logit grow but squashes it afterward with c*tanh(z/c), a curve that tracks the identity near zero and flattens to a fixed asymptote for extreme values -- one constrains the input, the other constrains the output, and Gemma 2 uses both together.

QK-norm: normalize Q and K before attention

The fix — introduced for ViT-22B (Dehghani et al., “Scaling Vision Transformers to 22 Billion Parameters”, 2023), studied systematically as an instability remedy by Wortsman et al. (“Small-scale proxies for large-scale Transformer training instabilities”, 2023), and now standard in Gemma 3, OLMo 2, Chameleon and Qwen3 — is to apply RMSNorm to the query and key vectors before computing the attention scores:

\[ \text{Attention}(Q, K, V) = \text{softmax}\!\left(\frac{\text{RMSNorm}(Q)\,\text{RMSNorm}(K)^\top}{\sqrt{d_k}}\right)V \]

After normalization each head’s query and key have RMS \(\approx \gamma\), so \(\|q\| \approx \|k\| \approx \sqrt{d_k}\,\gamma\) and by Cauchy–Schwarz \(|q \cdot k| \le d_k \gamma_q \gamma_k\); after the \(1/\sqrt{d_k}\) scale the logits are \(O(\sqrt{d_k}\,\gamma_q\gamma_k)\) — bounded by the learned scales rather than by whatever magnitude the projections happened to drift to over a trillion tokens. Because \(\gamma\) is learned per channel, expressivity is largely retained; only the unbounded growth is removed.

Two implementation details matter. First, order versus RoPE: the convention (and what HuggingFace’s Qwen3Attention and Gemma3Attention do) is project → normalize → rotate. Either order gives the same bound, since rotation preserves vector norms, but the two are not the same function once \(\gamma\) is per-channel — rotation mixes channel pairs — so a converter that swaps them will silently produce a different model. Second, the norm is over head_dim, not d_model: one tiny \(\gamma \in \mathbb{R}^{d_k}\) shared across heads (Qwen3, Gemma 3) or one per head (some variants). The shared version is what the code below implements, and it adds only \(2 d_k\) parameters per layer.

class QKNormAttention(nn.Module):
    """
    Attention with per-head QK normalization, as in Gemma 3, OLMo 2 and Qwen3.
    (Gemma 2 used logit soft-capping instead; Gemma 3 replaced it with this.)
    In HuggingFace these modules are literally named `q_norm` / `k_norm`.
    Prevents logit explosion during long training runs.
    """

    def __init__(self, d_model: int, n_heads: int):
        super().__init__()
        self.n_heads  = n_heads
        self.head_dim = d_model // n_heads
        self.q_proj   = nn.Linear(d_model, d_model, bias=False)
        self.k_proj   = nn.Linear(d_model, d_model, bias=False)
        self.v_proj   = nn.Linear(d_model, d_model, bias=False)
        self.o_proj   = nn.Linear(d_model, d_model, bias=False)

        # One RMSNorm per head for Q and K; per-head head_dim
        self.q_norm = RMSNorm(self.head_dim)
        self.k_norm = RMSNorm(self.head_dim)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        B, T, C = x.shape
        q = self.q_proj(x).view(B, T, self.n_heads, self.head_dim)
        k = self.k_proj(x).view(B, T, self.n_heads, self.head_dim)
        v = self.v_proj(x).view(B, T, self.n_heads, self.head_dim)

        # Apply QK-norm per head (RMSNorm operates on head_dim)
        q = self.q_norm(q)   # normalizes the last dimension
        k = self.k_norm(k)

        # Standard scaled dot-product attention from here
        q = q.transpose(1, 2)
        k = k.transpose(1, 2)
        v = v.transpose(1, 2)
        attn = torch.softmax((q @ k.transpose(-2,-1)) / math.sqrt(self.head_dim), dim=-1)
        out  = (attn @ v).transpose(1,2).contiguous().view(B, T, C)
        return self.o_proj(out)

No Biases, Tied/Untied Embeddings, and Initialization

Dropping biases

GPT-2 had bias terms in every linear projection and LayerNorm. Modern models like Llama, Qwen, and Mistral remove biases from all linear layers. The motivation is empirical: at large scale biases do not meaningfully improve loss (they represent a negligible fraction of parameters), but they complicate optimizer state memory (Adam maintains a first and second moment for every parameter, so biases add to optimizer memory with little benefit). For a 7B model, removing biases saves a few hundred MB of optimizer state — not huge, but free.

There is also a theoretical argument: when using pre-RMSNorm (which has no bias itself), the preceding biases in linear projections are redundant, as RMSNorm can represent any affine output of a linear layer with a bias by adjusting its scale. A stability argument closed the case: biases are the one part of the model that receives gradient regardless of the input, so they drift monotonically over long runs and are a recurring source of activation outliers that later break INT8/FP8 quantization (see Quantization I: Post-Training Quantization). Qwen2 kept a bias on the Q/K/V projections specifically to help length extrapolation; Qwen3 removed it and added QK-norm instead — the clearest single data point that the field now prefers normalizing over biasing.

Tied vs untied embeddings

The embedding table maps token IDs to vectors of dimension \(d\) and has shape \((V, d)\) where \(V\) is the vocabulary size. The output “unembedding” (also called the LM head or logit projection) maps from \(d\) back to \(V\), also shape \((V, d)\).

Tied embeddings (Press & Wolf, 2017) share the same weight matrix for both. This saves \(V \times d\) parameters — for a vocabulary of 32,000 and \(d=4096\), that is ~131M. Untied embeddings use separate matrices. The choice:

  • Tied: fewer parameters, simpler to implement, forces the embedding space to be simultaneously useful for input representation and output scoring.
  • Untied: more expressive; the input and output embedding spaces can specialize, and the LM head can develop the large-norm “unembedding” directions that tying would push back into the input representation.

The decision is essentially a budget fraction question, and the field splits cleanly by scale rather than by year. Tie when \(V d\) is a large slice of the model — GPT-2, all Gemma models, Llama 3.2 1B/3B, and Qwen3 0.6B–4B tie; untie when it is a rounding error — the Llama ½/3 models at 7B and above, and Qwen3 8B+, untie. A workable rule of thumb: if \(V d\) exceeds roughly 10% of your parameter budget, tie. At \(V=128{,}256\) and \(d=3072\) the table is 394M parameters, over 12% of a 3B model — tie. At \(d=8192\) in a 70B model it is 1.05B, under 2% — untie and buy the expressivity.

Practitioner tip: tying changes the gradient scale on the embedding table

With tied weights the embedding matrix receives gradient from two places every step: a sparse gradient from the input lookup (only the rows for tokens actually in the batch) and a dense gradient from the LM head (every row, every step). The dense term dominates, so the table trains far faster than an untied input embedding would. Gemma compensates by scaling the looked-up embeddings by \(\sqrt{d_{\text{model}}}\) before the first block — a fixed multiplier that decouples the scale the input path wants from the scale the output logits want. If you tie embeddings in a from-scratch model and see the embedding rows blowing up, this multiplier (or a lower LR group for the table) is the standard fix.

Initialization matters more than you think

Modern models use carefully scaled initialization. The standard method (from GPT-2 and widely adopted) scales down the residual projections (the output projection of attention and the down-projection of MLP) by \(1/\sqrt{2L}\) where \(L\) is the number of layers. This controls the variance of the residual stream at initialization, preventing the residual sum from growing as \(O(\sqrt{L})\). Without this, deep models (>60 layers) exhibit loss spikes at the start of training.


Logit Soft-Capping

The problem: logit skew in final layer

During pretraining, the final linear projection from hidden state to vocabulary logits can develop large-magnitude outputs for a small set of tokens. When the model becomes very confident, the cross-entropy gradient for non-predicted tokens effectively vanishes, slowing learning. At inference time, extreme logits after temperature scaling create numerical issues.

Soft-capping

Gemma 2 introduces logit soft-capping: a differentiable function that squashes logits toward a cap value \(c\) while preserving sign and relative ordering for logits well within the cap:

\[ \hat{z}_i = c \cdot \tanh\!\left(\frac{z_i}{c}\right) \]

For small \(|z_i| \ll c\), \(\tanh(z_i/c) \approx z_i/c\), so \(\hat{z}_i \approx z_i\) — linear behavior near zero. For large \(|z_i|\), \(\hat{z}_i \to \pm c\) asymptotically. With \(c=30\) (Gemma 2’s setting), logits are prevented from exceeding 30 in magnitude.

Gemma 2 also applies soft-capping to per-layer attention logits (pre-softmax), with \(c=50\):

\[ \hat{a}_{ij} = 50 \cdot \tanh\!\left(\frac{q_i \cdot k_j / \sqrt{d_k}}{50}\right) \]

Currency note: soft-capping lost. Gemma 3 explicitly replaced Gemma 2’s soft-capping with QK-norm, citing Dehghani et al. (2023), Wortsman et al. (2023) and the Chameleon team. The reason is a systems reason rather than a quality one: a \(\tanh\) applied to the full \(T \times T\) logit matrix cannot be expressed inside a FlashAttention kernel, which by construction never materializes that matrix (see FlashAttention I: IO-Awareness & The Online Softmax). Enabling attention soft-capping in HuggingFace therefore forces the eager attention path, which is exactly the throughput you were trying to buy with long context. QK-norm, by contrast, is two cheap elementwise ops on \(Q\) and \(K\) before the kernel, so it composes with FlashAttention for free.

The other standard guard on the final logits is the z-loss, an auxiliary penalty \(\beta_z \cdot \operatorname{mean}\big(\log\sum_v e^{z_v}\big)^2\) with \(\beta_z \approx 10^{-4}\) (PaLM, ST-MoE, OLMo 2). It applies pressure on the log-partition function during training instead of clamping at inference, so it changes no forward-pass math and costs nothing at serving time — which is why most 2025–2026 models use z-loss rather than logit capping. It is derived, fused with cross-entropy, and chunked for memory in Training Stability, Loss Spikes & Debugging Large Runs. Learn soft-capping because you will meet it in Gemma 2 checkpoints and in interviews; reach for QK-norm plus z-loss when you build.

import torch

def soft_cap(logits: torch.Tensor, cap: float) -> torch.Tensor:
    """
    Differentiable logit soft-capping (Gemma 2).

    Maps logits smoothly toward [-cap, +cap]. For |z| << cap, behavior
    is approximately linear; for |z| >> cap, asymptotes to ±cap.

    Args
    ----
    logits : (..., vocab_size) raw pre-softmax logits
    cap    : scalar, e.g. 30.0 for final logits or 50.0 for attention logits

    Returns
    -------
    Capped logits of same shape as input.
    """
    return cap * torch.tanh(logits / cap)


# Demonstration
z = torch.tensor([-100.0, -30.0, -10.0, 0.0, 10.0, 30.0, 100.0])
capped = soft_cap(z, cap=30.0)
print("Raw    :", z.tolist())
print("Capped :", [f"{v:.2f}" for v in capped.tolist()])
# Output:  Capped: [-29.92, -22.85, -9.65, 0.00, 9.65, 22.85, 29.92]
# Note: 100 -> 29.92, 30 -> 22.85 (compressed, not clipped)

Attention Sinks and Sink Tokens

The “attention sink” phenomenon

Xiao et al. (“Efficient Streaming LLMs with Attention Sinks”, 2023) made an empirical observation: attention maps in trained autoregressive models show that some tokens — almost always the first few tokens in the context (especially BOS, Beginning-Of-Sequence) — receive anomalously high attention weights regardless of their content relevance. These are called attention sinks.

Why does this happen? Attention weights must sum to 1 via softmax. When no other token is relevant, the model needs somewhere to “dump” the probability mass so it can attend to nothing useful without the softmax distribution becoming uniform (which would average together all values and potentially corrupt the output). The initial tokens, having been seen in every context, become the designated garbage-collector for probability mass.

Why softmax must "dump" probability somewhere Attention weights always sum to 1 -- when nothing is relevant, the leftover mass has to land on some token. A. relevant context present query BOS t1 t2 t3 t4 t5 t6 t7 t8 t9 relevant tokens sum=1 B. nothing relevant query BOS t1 t2 t3 t4 t5 t6 t7 t8 t9 all shrink toward ~0 attention sink -- probability dumped here sum=1 mass conserved: BOS only grew because the others shrank -- the total is still 1 C. StreamingLLM: keep sinks, slide the window BOS t1 t2 t3 t4 t5 t6 t7 t8 t9 sliding attention window (recent tokens) pinned evicted evicted evicted evicted Keep the sinks; evict the middle -- dropping BOS collapses attention.
Softmax weights always sum to one, so when no token is relevant the leftover probability mass has nowhere to go but onto the first token(s) -- usually BOS. Panel B makes the conservation literal: the relevant-token bars shrink toward zero and that same mass visibly reappears on BOS, which is why StreamingLLM (Panel C) can safely evict old KV cache entries from the middle of a long stream but must permanently pin the sink tokens, since dropping them collapses attention and causes catastrophic loss spikes.

Implications for model design and inference

The attention sink phenomenon has two practical consequences:

  1. Long-context window extension via StreamingLLM: if you want to process infinite-length streams, you can evict old KV cache entries safely — as long as you keep the first few tokens’ KV cache (the sinks). Dropping the sink tokens causes catastrophic loss spikes.

  2. Deliberate sink token design: some models prepend a learnable or fixed “register” token that is never attended to in the output but acts as a sink for attention. This frees the model from overloading the BOS token.

  3. Learned sink logits, no token at all: the cleanest 2025 formulation drops the sink token and instead gives each head a learned scalar \(s_h\) that participates in the softmax denominator but has no corresponding value vector — i.e. \(\text{softmax}\) over \([\,q\!\cdot\!k_1, \ldots, q\!\cdot\!k_t, s_h\,]\), with the last slot’s weight simply discarded. The head can now attend to nothing (all real weights near zero) without distorting any value, which is the “off-by-one softmax” idea made trainable. OpenAI’s gpt-oss models (2025) ship this as a per-head sinks parameter, and it is what GptOssAttention implements in HuggingFace transformers. Concretely, the softmax denominator becomes \(e^{s_h} + \sum_j e^{q\cdot k_j}\), costing one extra scalar per head and nothing at inference.

Interview Corner

Q: You are designing a 70B parameter language model for production deployment. Walk through the key architectural choices you would make compared to the original Transformer, and explain why for each one.

A: A strong answer covers the following decisions with their motivations:

  1. RMSNorm over LayerNorm: cheaper (no mean subtraction), equally stable; use pre-norm placement for gradient flow.
  2. SwiGLU FFN: empirically stronger than ReLU/GELU at the same parameter count; multiplicative gating gives more expressive activations.
  3. RoPE positional encoding: relative position property generalizes beyond training length; no embedding table overhead; set rope_theta high (500k) for long-context capability.
  4. GQA with ~8 KV heads: dramatically reduces KV cache memory (often 4–8x smaller) with minimal quality loss; critical for deployment economics.
  5. No biases in linear layers: negligible quality impact, reduces optimizer memory, simplifies distributed checkpointing.
  6. Untied input/output embeddings: improved model quality at scale; the cost is \(V \times d\) extra parameters (~130M for a 32K vocab at d=4096), which is acceptable.
  7. QK-norm (RMSNorm on Q and K per head): prevents attention logit explosion during extended training runs, and unlike soft-capping it composes with FlashAttention because it acts before the kernel.
  8. Z-loss (\(\beta_z \approx 10^{-4}\) on \((\log\sum_v e^{z_v})^2\)) rather than Gemma 2-style logit soft-capping: same guard against output logit blow-up, but training-time only, so inference math and kernel choice are unaffected.
  9. Scaled residual initialization: scale down output projections by \(1/\sqrt{2L}\) to keep residual variance stable at initialization in deep models.

Depth vs Width Trade-offs

Why we go deep

Scaling laws (Kaplan et al., “Scaling Laws for Neural Language Models”, 2020; Hoffmann et al., “Training Compute-Optimal Large Language Models” / Chinchilla, 2022) largely treat architecture shape as secondary to total parameter count and compute budget. See Scaling Laws: Kaplan, Chinchilla & Beyond for the full story. But within a fixed parameter budget, how to allocate parameters between depth (number of layers) and width (hidden dimension, number of heads, MLP expansion) matters.

The dominant empirical finding is that depth is worth more than width up to a point. Deeper models can represent functions of exponentially higher complexity (via function composition) than wide-but-shallow ones. However, deeper models are harder to parallelize in pipeline parallel training (more micro-batches needed to fill the pipeline bubble) and have slower sequential KV-cache generation at inference.

Practical aspect ratios

The ratio \(L / d_{\text{model}}\) tends to be consistent across generations:

Model Layers (\(L\)) Hidden dim (\(d\)) \(L/d\)
GPT-2 1.5B 48 1600 0.030
Llama 2 7B 32 4096 0.0078
Llama 3 70B 80 8192 0.0098
Qwen 2.5 72B 80 8192 0.0098
DeepSeek-V3 (dense equiv.) 61 7168 0.0085

Modern 7B-class models favor roughly 32 layers with \(d=4096\), yielding an attention head dimension of 128 (with 32 heads). Larger models scale \(d\) and \(L\) roughly in proportion, holding \(d_k = 128\) fixed and adding heads: \(d_k\) below 64 wastes tensor-core tiles (which want \(\ge 64\) along the contraction dimension) and starves each head of capacity, while \(d_k\) above 256 is unsupported by most FlashAttention builds. So the practical knobs are \(L\), \(H_q\) and \(H_{kv}\), with \(d = H_q \cdot d_k\) falling out.

Below ~1B parameters the trade-off tilts noticeably toward depth: MobileLLM (Liu et al., 2024) ablated shape at fixed parameter count for sub-billion models and found deeper-and-thinner consistently wins, which is why the capstone’s Stack-100M chooses \(d=512\) with 30 layers (\(L/d \approx 0.059\), six times “deeper” by this metric than a 7B model). See The Stack-100M Architecture for that derivation in full.

Worked example: parameter count breakdown for Llama 2 7B

Configuration (the actual Llama 2 7B): \(L=32\), \(d=4096\), \(H_q=32\), \(H_{kv}=32\) (full MHA – Llama 2 predates GQA at the 7B size), \(d_k=128\), intermediate \(=11008\) (SwiGLU), vocab \(=32000\), untied embeddings.

Attention (\(Q, K, V, O\) projections), per layer. With full MHA, \(H_{kv}=H_q=32\), so K and V are full width:

\[P_{\text{attn}} = \underbrace{d\,H_q d_k}_{Q} + \underbrace{2\,d\,H_{kv} d_k}_{K,V} + \underbrace{H_q d_k\, d}_{O} = 4 \times 4096^2 \approx 67.1\text{M}\]
  • \(Q\): \(4096 \times 4096 = 16.8\)M
  • \(K\): \(4096 \times 4096 = 16.8\)M
  • \(V\): \(4096 \times 4096 = 16.8\)M
  • \(O\): \(4096 \times 4096 = 16.8\)M
  • Attention total: \(\approx 67.1\)M

MLP (SwiGLU, intermediate \(=11008\)), per layer:

  • gate_proj + up_proj: \(2 \times 4096 \times 11008 = 90.2\)M
  • down_proj: \(11008 \times 4096 = 45.1\)M
  • MLP total: \(\approx 135.3\)M

RMSNorm (2 per layer): \(2 \times 4096 \approx 8\)K (negligible).

Per-layer total: \(67.1 + 135.3 \approx 202.4\)M.

32 layers: \(32 \times 202.4\text{M} \approx 6.48\)B.

Embeddings (untied): input \(32000 \times 4096 \approx 131\)M; output (LM head) \(\approx 131\)M; total \(\approx 262\)M.

Grand total: \(6.48\text{B} + 0.26\text{B} \approx 6.74\)B – exactly what “Llama 2 7B” denotes. The round “7B” is marketing rounding of 6.74B, not a vocabulary artifact.

Where does the often-quoted “~5.9B” figure come from? From plugging GQA (\(H_{kv}=8\), Llama 3 8B style) into this same count: K and V shrink to \(4096 \times 1024 = 4.2\)M each, attention drops to \(Q+K+V+O = 16.8+4.2+4.2+16.8 \approx 42\)M/layer, and the model falls to \(32 \times (42+135.3)\text{M} + 262\text{M} \approx 5.94\)B. So the 5.9-vs-6.7B gap is entirely the MHA-vs-GQA choice in the attention block – the vocabulary (\(32000\)) is identical either way. (Llama 3 8B ends up larger than 7B despite GQA because it pairs a 128K-token vocab with a wider \(14336\) intermediate.)

Worked exercise: size a Llama-style model to a 3B budget

This unit’s deliverable is turning a parameter budget into a full config. Build a 3B-parameter dense decoder step by step, then verify the count. (The result is essentially Llama 3.2 3B.)

Step 1 – head dimension. Fix \(d_k = 128\) (the modern convention: good tensor-core shapes, enough per-head capacity).

Step 2 – width and depth via the aspect ratio. From the \(L/d\) table above, target \(L/d \approx 0.009\). Pick \(d = 3072\) (a multiple of both 128 and 256); then \(L \approx 0.009 \times 3072 \approx 28\) layers.

Step 3 – query heads. \(H_q = d / d_k = 3072 / 128 = 24\).

Step 4 – KV heads (GQA). Choose \(H_{kv} = 8\) (grouping \(G = H_q/H_{kv} = 3\)), the usual quality/memory sweet spot; \(24\) is divisible by \(8\). checks out.

Step 5 – FFN width. SwiGLU iso-parameter rule: \(d_{ff} = \tfrac{8}{3} d = \tfrac{8}{3}\times 3072 = 8192\), already a multiple of 256 – no rounding needed.

Step 6 – vocabulary and embedding tying. Use the Llama 3 tokenizer, \(V = 128256\). At \(d=3072\) the embedding table is \(128256 \times 3072 \approx 394\)M – over 12% of a 3B budget – so tie input and output embeddings (share one matrix) as Llama 3.2 1B/3B do; untying would add another 394M and blow the budget.

Step 7 – verify. Per layer: attention \(Q(3072^2)+K(3072\cdot1024)+V(3072\cdot1024)+O(3072^2) \approx 25.2\)M; MLP \(2(3072\cdot8192)+8192\cdot3072 \approx 75.5\)M; total \(\approx 100.7\)M. Times \(L=28\) gives \(2.82\)B, plus the (tied) \(0.39\)B embedding \(\Rightarrow \approx 3.21\)B. On budget.

def llama_param_count(d, L, n_heads, n_kv, d_ff, vocab, head_dim=128, tied=True):
    # Q + O are full width; K + V are GQA-width
    attn  = 2 * d * (n_heads * head_dim) + 2 * d * (n_kv * head_dim)
    mlp   = 3 * d * d_ff          # gate, up, down (SwiGLU)
    norms = 2 * d                 # 2 RMSNorm per layer (gamma only)
    per_layer = attn + mlp + norms
    emb = vocab * d if tied else 2 * vocab * d
    return per_layer * L + emb + d   # + final RMSNorm

n = llama_param_count(d=3072, L=28, n_heads=24, n_kv=8,
                      d_ff=8192, vocab=128256, tied=True)
print(f"{n:,}")   # 3,212,749,824  -> ~3.21B, matches Llama 3.2 3B

Checklist to apply every time: (1) \(d = H_q \cdot d_k\) exactly; (2) \(H_q \bmod H_{kv} = 0\); (3) \(d_{ff}\) a multiple of 256; (4) for models under ~4B, tie embeddings or they dominate the budget; (5) recompute the total and confirm it lands within ~10% of target before committing to a training run.


The Modern Transformer Recipe

We now have enough pieces to assemble a complete reference. The table below summarizes the consensus choices circa 2024–2026, distinguishing “near-universal” (adopted by nearly all recent models) from “model-specific” (used by some, absent in others).

GPT-2 (2019) Modern (2024-26) why the change stability efficiency quality LM Head tied: shares Token Embed LM Head untied: separate matrix logits logits Embeddings tied -> untied MLP - GELU (2 matrices) Up GELU Down + LayerNorm RMSNorm SwiGLU (3 matrices) SiLU(Gate) Up x Down + MLP activation GELU MLP -> SwiGLU Linear layers Wx + b Linear layers Wx Bias in Linear bias -> no bias dashed = residual (skip) path Attention - MHA + LayerNorm RMSNorm Attention - GQA (RoPE on Q,K) + Norm placement & type post-LN -> pre-RMSNorm Attention variant MHA -> GQA tokens Token Embed + learned pos-embed tokens Token Embed no pos-embed added here — RoPE rotates Q,K in Attention Position encoding learned add -> RoPE
Every GPT-2 architectural choice has a modern replacement, and each replacement earns its place for a specific reason. Reading bottom to top like the residual stream itself: RoPE replaces the learned position embedding, pre-RMSNorm replaces post-LayerNorm (note how the modern column's skip path stays unbroken and clean while GPT-2's must still pass through its norm), GQA replaces full multi-head attention, biases are dropped from every linear layer, SwiGLU replaces the plain GELU MLP, and the output embedding is untied from the input. None of these changes are free lunches on their own — GQA trades a little quality for much smaller KV cache, untying trades parameters for expressivity — but together they define the 2024-26 consensus recipe.
Component GPT-2 (2019) Modern Consensus HF config field Notes
Normalization LayerNorm (pre) RMSNorm (pre; +post in Gemma/OLMo 2) rms_norm_eps Zhang & Sennrich 2019
MLP activation GeLU SwiGLU / GeGLU hidden_act: "silu" Shazeer 2020
MLP width \(4d\) \(\approx \tfrac{8}{3}d\), multiple of 256 intermediate_size iso-parameter with 3 matrices
Position encoding Learned absolute RoPE (+ NoPE layers at long ctx) rope_theta, rope_scaling Su et al. 2021
Multi-head variant MHA GQA, \(H_{kv}=8\) num_key_value_heads Ainslie et al. 2023
Bias in Linear Yes No attention_bias: false Qwen3 dropped the last holdout
Tied embeddings Yes Tied under ~4B, untied above tie_word_embeddings budget-fraction decision
QK-norm No Yes (increasingly standard) q_norm/k_norm modules Gemma 3, OLMo 2, Qwen3
Logit soft-cap No Rare — superseded attn_logit_softcapping Gemma 2 only; Gemma 3 dropped it
Logit z-loss No Common (training-time only) (loss-side, not in config) PaLM, ST-MoE, OLMo 2
Attention sink Ignored Keep BOS in KV cache; learned sink logits sinks (gpt-oss) StreamingLLM 2023; gpt-oss 2025
"""
Putting it all together: a from-scratch ModernTransformerBlock.
This implements the full recipe: pre-RMSNorm, SwiGLU, GQA with RoPE,
no biases, QK-norm optional. Runnable as a standalone unit test.
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
import math


class RMSNorm(nn.Module):
    def __init__(self, dim: int, eps: float = 1e-5):
        super().__init__()
        self.eps = eps
        self.weight = nn.Parameter(torch.ones(dim))

    def forward(self, x):
        return x * torch.rsqrt(x.float().pow(2).mean(-1, keepdim=True) + self.eps
                               ).type_as(x) * self.weight


class SwiGLUFFN(nn.Module):
    def __init__(self, d_model: int, intermediate: int):
        super().__init__()
        self.gate = nn.Linear(d_model, intermediate, bias=False)
        self.up   = nn.Linear(d_model, intermediate, bias=False)
        self.down = nn.Linear(intermediate, d_model,  bias=False)

    def forward(self, x):
        return self.down(F.silu(self.gate(x)) * self.up(x))


class ModernTransformerBlock(nn.Module):
    """
    A single Transformer block implementing the modern (2024-era) recipe:
      - Pre-RMSNorm on both attention and MLP sublayers
      - GQA-style attention (n_kv_heads <= n_heads)
      - SwiGLU FFN
      - No biases anywhere
      - Optional QK-norm

    Does NOT include RoPE application here for brevity; in a full model
    RoPE would be applied to Q and K after projection.
    """

    def __init__(
        self,
        d_model: int,
        n_heads: int,
        n_kv_heads: int,
        intermediate: int,
        qk_norm: bool = False,
        eps: float = 1e-5,
    ):
        super().__init__()
        assert d_model % n_heads == 0
        assert n_heads % n_kv_heads == 0

        self.n_heads    = n_heads
        self.n_kv_heads = n_kv_heads
        self.n_rep      = n_heads // n_kv_heads
        self.head_dim   = d_model // n_heads

        # Pre-norm before attention
        self.attn_norm = RMSNorm(d_model, eps)
        # Attention projections — no bias
        self.q_proj = nn.Linear(d_model, n_heads    * self.head_dim, bias=False)
        self.k_proj = nn.Linear(d_model, n_kv_heads * self.head_dim, bias=False)
        self.v_proj = nn.Linear(d_model, n_kv_heads * self.head_dim, bias=False)
        self.o_proj = nn.Linear(d_model, d_model,                    bias=False)

        # Optional QK-norm
        self.qk_norm = qk_norm
        if qk_norm:
            self.q_norm = RMSNorm(self.head_dim, eps)
            self.k_norm = RMSNorm(self.head_dim, eps)

        # Pre-norm before MLP
        self.ffn_norm = RMSNorm(d_model, eps)
        self.ffn      = SwiGLUFFN(d_model, intermediate)

        # Scaled residual initialization for stability in deep models
        # Scale output projections by 1/sqrt(2*n_layers); caller should
        # set this post-construction, e.g.:
        #   for block in blocks:
        #       block.o_proj.weight.data.mul_(1.0 / math.sqrt(2 * n_layers))
        #       block.ffn.down.weight.data.mul_(1.0 / math.sqrt(2 * n_layers))

    def _repeat_kv(self, x: torch.Tensor) -> torch.Tensor:
        if self.n_rep == 1:
            return x
        return x.unsqueeze(3).expand(
            *x.shape[:2], self.n_kv_heads, self.n_rep, self.head_dim
        ).reshape(*x.shape[:2], self.n_heads, self.head_dim)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        B, T, C = x.shape
        # --- Attention sublayer (pre-norm) ---
        h = self.attn_norm(x)
        q = self.q_proj(h).view(B, T, self.n_heads,    self.head_dim)
        k = self.k_proj(h).view(B, T, self.n_kv_heads, self.head_dim)
        v = self.v_proj(h).view(B, T, self.n_kv_heads, self.head_dim)

        if self.qk_norm:
            q = self.q_norm(q)
            k = self.k_norm(k)

        k = self._repeat_kv(k)
        v = self._repeat_kv(v)

        # (B, H, T, d_k)
        q = q.transpose(1, 2)
        k = k.transpose(1, 2)
        v = v.transpose(1, 2)

        # Causal mask for autoregressive decoding
        mask = torch.tril(torch.ones(T, T, device=x.device)).bool()
        attn_logits = (q @ k.transpose(-2, -1)) / math.sqrt(self.head_dim)
        attn_logits = attn_logits.masked_fill(~mask, float('-inf'))
        attn_w = torch.softmax(attn_logits, dim=-1)

        out = (attn_w @ v).transpose(1, 2).contiguous().view(B, T, C)
        x = x + self.o_proj(out)          # residual connection

        # --- MLP sublayer (pre-norm) ---
        x = x + self.ffn(self.ffn_norm(x))  # residual connection

        return x


# --- Integration test ---
torch.manual_seed(0)
block = ModernTransformerBlock(
    d_model=512, n_heads=8, n_kv_heads=2, intermediate=1365,  # 8/3 * 512 ≈ 1365
    qk_norm=True
)
x = torch.randn(2, 16, 512)  # batch=2, seq=16, d_model=512
y = block(x)
print(f"Block output shape: {y.shape}")          # (2, 16, 512)
print(f"Output norm (should be finite): {y.norm().item():.3f}")
param_count = sum(p.numel() for p in block.parameters())
print(f"Block parameter count: {param_count:,}")  # 2,753,152 -> ~2.75M
# Check by hand: attention = 512*512 (Q) + 2*512*128 (K,V, GQA) + 512*512 (O)
#              = 655,360;  SwiGLU = 3*512*1365 = 2,096,640;
#              norms = 2*512 + 2*64 (qk_norm) = 1,152.  Total 2,753,152.

The Recipe in Practice: Config Files, Fused Kernels & Reference Repos

We opened by promising that a modern config.json would stop being alphabet soup. Here is the actual configuration of Llama 3.2 3B — the model we sized by hand earlier — with every field now decodable:

{
  "architectures": ["LlamaForCausalLM"],
  "hidden_size": 3072,
  "intermediate_size": 8192,
  "num_hidden_layers": 28,
  "num_attention_heads": 24,
  "num_key_value_heads": 8,
  "hidden_act": "silu",
  "rms_norm_eps": 1e-05,
  "rope_theta": 500000.0,
  "rope_scaling": {
    "rope_type": "llama3",
    "factor": 32.0,
    "low_freq_factor": 1.0,
    "high_freq_factor": 4.0,
    "original_max_position_embeddings": 8192
  },
  "max_position_embeddings": 131072,
  "attention_bias": false,
  "tie_word_embeddings": true,
  "vocab_size": 128256,
  "torch_dtype": "bfloat16"
}

Read it against this chapter: rms_norm_eps is the \(\epsilon\) of our RMSNorm (pre-norm placement is implied by the architecture class, not by a flag); hidden_act: "silu" with intermediate_size \(\ne 4d\) tells you it is SwiGLU, since a plain FFN would have no third matrix and would sit at \(4 \times 3072 = 12288\); num_key_value_heads: 8 against 24 query heads is GQA with \(G=3\); attention_bias: false is the no-bias decision; tie_word_embeddings: true is the small-model budget call (\(128256 \times 3072 = 394\)M would otherwise be paid twice); rope_theta: 500000 plus rope_scaling is long-context RoPE, trained at 8192 and extended 32× by frequency-dependent interpolation (see Long-Context Pretraining & Context Extension). There is no q_norm field because QK-norm in transformers is expressed as submodules in the attention class (Qwen3, Gemma 3, OLMo 2) rather than a boolean.

Instantiating that recipe — no download required — is three lines:

from transformers import LlamaConfig, LlamaForCausalLM, AutoModelForCausalLM

# Route 1: build the architecture from the recipe, randomly initialized.
cfg = LlamaConfig(
    hidden_size=3072, intermediate_size=8192, num_hidden_layers=28,
    num_attention_heads=24, num_key_value_heads=8,      # GQA, G=3
    hidden_act="silu", rms_norm_eps=1e-5,               # SwiGLU + RMSNorm
    rope_theta=500000.0, attention_bias=False,          # RoPE, no biases
    tie_word_embeddings=True, vocab_size=128256,
)
model = LlamaForCausalLM(cfg)
print(f"{sum(p.numel() for p in model.parameters()):,}")  # ~3.21B, matching our hand count

# Route 2: load real weights and read the same knobs back off the config.
# model = AutoModelForCausalLM.from_pretrained(
#     "meta-llama/Llama-3.2-3B",
#     attn_implementation="flash_attention_2",  # or "sdpa" (default) / "eager"
#     dtype="bfloat16",
# )

That attn_implementation argument is where this chapter meets Part IV. "eager" is the hand-rolled softmax we wrote above; "sdpa" dispatches to torch.nn.functional.scaled_dot_product_attention; "flash_attention_2" calls the flash-attn package’s kernel directly. Only the first can express logit soft-capping — which is precisely why Gemma 3 dropped it.

Where the fused kernels live

The from-scratch modules in this chapter are for understanding. In a real training loop every one of them has a fused counterpart, and using them is not optional at scale:

Layer From scratch (this chapter) What you actually call Package
GQA attention manual _repeat_kv + softmax F.scaled_dot_product_attention(..., enable_gqa=True) PyTorch (2.5+)
Attention (train) flash_attn_func, flash_attn_varlen_func flash-attn
RMSNorm x * rsqrt(mean(x²)) LigerRMSNorm, apex.normalization.FusedRMSNorm Liger-Kernel, apex
SwiGLU MLP three nn.Linear + F.silu LigerSwiGLUMLP Liger-Kernel
RoPE complex-valued rotation LigerRopeFunction, apply_rotary_emb Liger-Kernel, flash-attn
LM head + CE (+ z-loss) lm_head then cross_entropy LigerFusedLinearCrossEntropy, cut-cross-entropy Liger-Kernel

The last row matters more than it looks: it never materializes the \((B, T, V)\) logit tensor, which at \(BT=16384\) and \(V=128256\) would be 8.4 GB in fp32 — often the single largest tensor in a small-model training step. enable_gqa=True similarly avoids materializing the repeated K and V, which our teaching code does materialize. Liger-Kernel is written in Triton (see Writing GPU Kernels with Triton); torch.compile will fuse the simpler ones (RMSNorm, SwiGLU) for you without any extra dependency.

import torch
import torch.nn.functional as F

# The production form of the GQA forward pass: no _repeat_kv materialization,
# causal masking inside the kernel, and an IO-aware fused backend under the hood.
B, T, Hq, Hkv, dk = 2, 128, 24, 8, 128
q = torch.randn(B, Hq,  T, dk)      # (B, H_q,  T, d_k)
k = torch.randn(B, Hkv, T, dk)      # (B, H_kv, T, d_k)  <-- 3x smaller
v = torch.randn(B, Hkv, T, dk)

out = F.scaled_dot_product_attention(q, k, v, is_causal=True, enable_gqa=True)
print(out.shape)                    # (2, 24, 128, 128) -- broadcast over the group

Reference implementations worth reading

When you need a correct, complete, trainable version of this recipe rather than a teaching one, these are the canonical open-source starting points, in rough order of increasing machinery: nanoGPT (Karpathy — the minimal GPT-2 training loop, the ancestor of Building a GPT From Scratch); litgpt (Lightning AI — clean single-file implementations of ~20 modern architectures); torchtitan (PyTorch’s own reference for pretraining Llama-family models with FSDP2 + tensor/pipeline parallelism); torchtune (PyTorch’s fine-tuning library, with the architectures expressed as composable builder functions); nanotron (HuggingFace’s minimal 3D-parallel pretrainer); and Megatron-LM / DeepSpeed for the industrial path, covered in Megatron-LM, DeepSpeed & Parallelism in Practice. All six implement exactly the components of this chapter; the differences between them are parallelism and ergonomics, not architecture.


Key Takeaways

  • RMSNorm (pre-norm placement) replaces LayerNorm in virtually all modern LLMs. It is ~10–30% faster, equally stable, and requires no bias parameters.
  • SwiGLU provides multiplicative gating that consistently outperforms ReLU/GELU FFNs at equal parameter cost; the three-weight design requires scaling the intermediate dimension to \(\frac{8}{3}d\) to stay iso-parameter.
  • RoPE enables relative positional encoding without a separate embedding table, generalizes beyond training context length, and is the universal choice for decoder-only models. The rope_theta base should be set high (100k–500k) for long-context models.
  • GQA (Grouped Query Attention) reduces KV cache memory by a factor equal to the grouping ratio (commonly 4–8x) with minimal quality degradation; this is the key architectural enabler for long-context inference.
  • QK-norm (normalizing Q and K per head before computing attention scores) prevents attention logit explosion in large or long-training models; originating in ViT-22B and now used in Gemma 3, OLMo 2 and Qwen3, it is increasingly a default rather than a large-scale-only trick.
  • No biases in linear layers is almost universal at the frontier; biases add optimizer memory overhead and negligible quality benefit, especially with pre-RMSNorm, and they drive activation outliers that later hurt quantization. Qwen3 dropped the last common holdout (QKV bias) in favor of QK-norm.
  • Logit soft-capping (\(z \to c \cdot \tanh(z/c)\)) is a differentiable alternative to hard clipping, but it cannot live inside a FlashAttention kernel; Gemma 3 replaced it with QK-norm, and the training-time z-loss is the standard guard on final logits.
  • Attention sinks (typically the BOS token) must be preserved in the KV cache for streaming/long-context inference; evicting them causes catastrophic attention pattern collapse. The 2025 refinement is a learned per-head sink logit with no token and no value vector (gpt-oss).
  • Know the config field for every knob. num_key_value_heads, rope_theta, hidden_act, attention_bias, tie_word_embeddings, rms_norm_eps are the interface; flash-attn, PyTorch SDPA (enable_gqa=True), and Liger-Kernel are the implementations you actually run.
  • Scaled residual initialization (\(\times 1/\sqrt{2L}\) on output projections) is essential for stable training of very deep models; without it, the residual stream variance grows with depth.

State of the Art & Resources (2026)

The modern transformer recipe — RMSNorm, SwiGLU, RoPE, GQA, and QK-norm — has become near-universal across frontier open-source models (Llama 4, Qwen3, DeepSeek-V3, Gemma 3) since 2023, with z-loss and attention-sink awareness rounding out the toolkit for stable long-context training (logit soft-capping was a Gemma 2-only detour that Gemma 3 itself abandoned, because a \(\tanh\) on the score matrix is incompatible with FlashAttention). The 2025 generation pushed on two fronts: sparse Mixture-of-Experts backbones (DeepSeek-V3, Llama 4, Qwen3) and long-context attention variants (Gemma 3’s 5:1 local-to-global sliding-window interleave; Llama 4’s position-free “NoPE” layers).

Foundational work

Recent advances (2023–2026)

Open-source & tools

  • huggingface/transformers — canonical implementations of every major modern architecture (Llama, Qwen, Gemma, DeepSeek, gpt-oss) with config files exposing every design knob discussed in this chapter; attn_implementation selects between eager / SDPA / FlashAttention-2 backends.
  • pytorch/torchtitan and pytorch/torchtune — PyTorch’s own reference pretraining and fine-tuning stacks; the cleanest reading of this recipe wired to FSDP2 and tensor/pipeline parallelism.
  • linkedin/Liger-Kernel — Triton kernels fusing exactly the modules in this chapter (RMSNorm, SwiGLU, RoPE, and a fused linear + cross-entropy that never materializes the \((B,T,V)\) logit tensor).
  • Dao-AILab/flash-attention — the attention kernel every serious training run uses, plus fused RoPE and layer-norm utilities.
  • Lightning-AI/litgpt and karpathy/nanoGPT — readable single-file implementations for checking your own from-scratch model against a known-good reference.

Go deeper

Further Reading

  • Zhang and Sennrich, “Root Mean Square Layer Normalization,” NeurIPS 2019 — the RMSNorm paper.
  • Noam Shazeer, “GLU Variants Improve Transformer,” arXiv 2020 — SwiGLU and GeGLU.
  • Su et al., “RoFormer: Enhanced Transformer with Rotary Position Embedding,” arXiv 2021 — original RoPE paper.
  • Ainslie et al., “GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints,” EMNLP 2023 — GQA, including a method for uptraining existing MHA checkpoints.
  • Touvron et al., “Llama 2: Open Foundation and Fine-Tuned Chat Models,” arXiv 2023 — a comprehensive blueprint of the modern recipe in an openly released model.
  • Team Gemma, “Gemma 2: Improving Open Language Models at a Practical Size,” Google DeepMind 2024 — logit soft-capping, sandwich normalization, and interleaved sliding-window attention; and “Gemma 3 Technical Report,” 2025, which swaps soft-capping for QK-norm.
  • Wortsman et al., “Small-scale proxies for large-scale Transformer training instabilities,” 2023 — QK-norm, z-loss and logit-growth diagnostics studied at a scale you can actually reproduce.
  • Press and Wolf, “Using the Output Embedding to Improve Language Models,” EACL 2017 — the original weight-tying argument.
  • Liu et al., “MobileLLM: Optimizing Sub-billion Parameter Language Models for On-Device Use Cases,” ICML 2024 — the deep-and-thin aspect-ratio result for small models.
  • Xiao et al., “Efficient Streaming LLMs with Attention Sinks,” ICLR 2024 — attention sink phenomenon and StreamingLLM.
  • Kaplan et al., “Scaling Laws for Neural Language Models,” arXiv 2020 — depth vs width trade-offs in the context of scaling.
  • Meta AI, Llama 3 model card and technical report, 2024 — updated RoPE theta and architectural decisions at 70B/405B scale.

Exercises

1. (Conceptual) LayerNorm applies \(\frac{x-\mu}{\sqrt{\sigma^2+\epsilon}}\cdot\gamma + \beta\), whereas RMSNorm applies \(\frac{x}{\text{RMS}(x)}\cdot\gamma\) with no \(\beta\). (a) Which two operations present in LayerNorm does RMSNorm discard, and which one does it keep? (b) Zhang & Sennrich justified the discard empirically — what did their ablation find? © In the chapter’s RMSNorm.forward, why is the input cast to float32 before computing the norm and cast back afterward?

Solution

(a) RMSNorm discards mean subtraction (the \(-\mu\) re-centering) and the learned shift \(\beta\). It keeps the re-scaling: division by a per-vector magnitude statistic followed by the learned element-wise scale \(\gamma\). The magnitude statistic changes from the standard deviation \(\sigma\) (computed around the mean) to the root-mean-square \(\text{RMS}(x)=\sqrt{\frac{1}{d}\sum_i x_i^2 + \epsilon}\) (computed around zero), so no mean is ever needed.

(b) Their ablation of LayerNorm into its components found that the re-scaling via \(\gamma\) drives almost all of LayerNorm’s benefit, while mean subtraction contributes little to final performance yet costs roughly a third of LayerNorm’s compute (a second reduction pass plus a subtraction kernel). Dropping it is therefore near-free in quality but ~10-30% faster.

© The normalization involves squaring every element, summing, and taking a reciprocal square root. In low-precision formats like bf16 the squared sum can lose precision or overflow the narrow dynamic range, and the reciprocal-square-root is sensitive to rounding. Computing x.float().pow(2).mean(...) in float32 keeps the reduction accurate; the result is then cast back with .type_as(x) so the rest of the network still runs in the model’s working precision. This is exactly the pattern used in the Llama reference code.

2. (Quantitative) You are configuring a SwiGLU FFN for a model with hidden dimension \(d = 4096\). A vanilla two-matrix FFN would use a 4x expansion (intermediate \(= 4d\)). (a) Compute the parameter count of that vanilla FFN. (b) Using the iso-parameter rule for SwiGLU’s three matrices, compute the target intermediate dimension, then round it to the nearest multiple of 256. © Compute the SwiGLU FFN’s parameter count at that rounded dimension and confirm it is close to the vanilla count.

Solution

(a) Vanilla FFN has two matrices, \(W_1\in\mathbb{R}^{d\times 4d}\) and \(W_2\in\mathbb{R}^{4d\times d}\): $\(P_{\text{vanilla}} = 2 \cdot d \cdot 4d = 8d^2 = 8 \times 4096^2 = 134{,}217{,}728 \approx 134.2\text{M}.\)$

(b) SwiGLU has three matrices (\(W\), \(V\), \(W_2\)), so its cost is \(3\,d\,d_{ff}\). Setting this equal to \(8d^2\) gives the iso-parameter rule: $\(d_{ff} = \frac{8d^2}{3d} = \frac{8d}{3} = \frac{8 \times 4096}{3} = 10922.67.\)$ Rounding to the nearest multiple of 256: \(10922.67 / 256 = 42.67 \to 43\), and \(43 \times 256 = 11008\). (This is exactly the intermediate size Llama 2 7B uses.)

© At \(d_{ff}=11008\): $\(P_{\text{SwiGLU}} = 3 \cdot 4096 \cdot 11008 = 135{,}266{,}304 \approx 135.3\text{M}.\)$ This is within about 0.8% of the vanilla \(134.2\)M — the small excess comes from rounding \(10922.67\) up to \(11008\). SwiGLU is thus iso-parameter with the vanilla 4x FFN while adding multiplicative gating.

3. (Quantitative) A decoder-only model has \(L = 48\) layers, \(H_q = 40\) query heads, head dimension \(d_k = 128\), and uses GQA with \(H_{kv} = 8\) KV heads. It runs in bf16 (2 bytes/element). (a) Compute the KV-cache size per token per layer. (b) Compute the total KV cache for a context of 16,384 tokens across all layers, in GiB. © What would the same total be with full MHA (\(H_{kv} = H_q = 40\)), and what is the reduction factor?

Solution

(a) The cache stores both K and V for each KV head: $\(\text{bytes/token/layer} = 2\,(\text{K and V}) \times H_{kv} \times d_k \times 2\,(\text{bytes}) = 2 \times 8 \times 128 \times 2 = 4096\ \text{bytes} = 4\ \text{KiB}.\)$

(b) Total over all layers and all context positions: $\(4096 \times 48 \times 16384 = 3{,}221{,}225{,}472\ \text{bytes} = 3 \times 2^{30}\ \text{bytes} = 3\ \text{GiB}.\)$

© With full MHA, \(H_{kv}=40\) so bytes/token/layer \(= 2 \times 40 \times 128 \times 2 = 20480\) bytes \(= 20\) KiB. Total: $\(20480 \times 48 \times 16384 = 16{,}106{,}127{,}360\ \text{bytes} = 15\ \text{GiB}.\)$ The reduction factor is \(15 / 3 = 5\), exactly \(H_q / H_{kv} = 40/8 = 5\) — GQA shrinks the KV cache by the grouping ratio \(G\), here \(G=5\).

4. (Conceptual) Llama 1 used rope_theta = 10000; Llama 3 raised it to rope_theta = 500000 specifically to improve long-context behavior. (a) In the frequency formula \(\theta_i = \text{base}^{-2i/d_k}\), what happens to the set of rotation frequencies as the base increases from 10,000 to 500,000? (b) Why does that change help a model extrapolate to positions beyond its training length? © RoPE is described as giving a “relative position” property — state precisely what quantity the attention dot product \(q_m^\top k_n\) ends up depending on, and why that property is desirable.

Solution

(a) Each frequency is \(\theta_i = \text{base}^{-2i/d_k}\), a value in \((0,1]\) that shrinks as \(i\) grows. Raising the base makes the exponential decay steeper, so the frequencies (especially at larger \(i\)) become smaller — the rotations turn more slowly and the corresponding wavelengths become longer. The overall frequency spectrum is stretched/spread out: the fastest pair still rotates at \(\theta_0 = 1\), but the slow pairs rotate far more slowly than with base 10,000.

(b) With longer wavelengths, a given absolute position \(m\) produces a smaller total rotation angle \(m\theta_i\) on the low-frequency pairs. This means positions farther apart than anything seen in training still fall within the first, unambiguous part of each sinusoid rather than “wrapping around” and aliasing onto angles the model saw for nearby positions. Spreading the frequencies more broadly therefore keeps distant positions distinguishable and makes extrapolation to longer contexts at inference time behave more gracefully.

© Because position \(m\) rotates the query and position \(n\) rotates the key by angles proportional to \(m\) and \(n\) in each 2D subspace, the inner product \(q_m^\top k_n\) depends only on the relative offset \((m-n)\), not on the absolute positions \(m\) and \(n\) individually. This is desirable because linguistic relationships are typically relative (“the previous word”, “three tokens back”): a model whose attention scores are a function of distance generalizes across the sequence and to unseen absolute positions, and needs no separate learned position-embedding table.

5. (Implementation) Gemma 2 applies logit soft-capping in two places: on the attention logits (pre-softmax, \(c=50\)) and on the final vocabulary logits (\(c=30\)), using \(\hat z = c\cdot\tanh(z/c)\). Starting from the chapter’s ModernTransformerBlock, modify its forward so that attention logits are soft-capped at \(c=50\). Then (a) explain why the cap must be applied before the causal -inf mask, and (b) verify by hand what an uncapped logit of \(75\) becomes after soft-capping at \(c=50\).

Solution

Add a soft_cap helper and insert one line into the attention path, capping the scaled logits before the causal mask and softmax:

import torch
import math

def soft_cap(x: torch.Tensor, cap: float) -> torch.Tensor:
    # Smoothly squashes x toward [-cap, +cap]; ~linear for |x| << cap.
    return cap * torch.tanh(x / cap)

def forward(self, x: torch.Tensor, attn_logit_cap: float = 50.0) -> torch.Tensor:
    B, T, C = x.shape
    # --- Attention sublayer (pre-norm) ---
    h = self.attn_norm(x)
    q = self.q_proj(h).view(B, T, self.n_heads,    self.head_dim)
    k = self.k_proj(h).view(B, T, self.n_kv_heads, self.head_dim)
    v = self.v_proj(h).view(B, T, self.n_kv_heads, self.head_dim)

    if self.qk_norm:
        q = self.q_norm(q)
        k = self.k_norm(k)

    k = self._repeat_kv(k)
    v = self._repeat_kv(v)

    q = q.transpose(1, 2)
    k = k.transpose(1, 2)
    v = v.transpose(1, 2)

    attn_logits = (q @ k.transpose(-2, -1)) / math.sqrt(self.head_dim)

    # >>> soft-cap the attention logits (Gemma 2, c=50) BEFORE masking <<<
    attn_logits = soft_cap(attn_logits, attn_logit_cap)

    mask = torch.tril(torch.ones(T, T, device=x.device)).bool()
    attn_logits = attn_logits.masked_fill(~mask, float('-inf'))
    attn_w = torch.softmax(attn_logits, dim=-1)

    out = (attn_w @ v).transpose(1, 2).contiguous().view(B, T, C)
    x = x + self.o_proj(out)

    # --- MLP sublayer (pre-norm) ---
    x = x + self.ffn(self.ffn_norm(x))
    return x

(a) The mask fills the disallowed (future) positions with \(-\infty\) so that softmax assigns them exactly zero weight. If we soft-capped after masking, every masked entry \(-\infty\) would be sent to \(50\cdot\tanh(-\infty/50) = -50\), a finite value — those future tokens would then receive non-zero attention weight and the causal property would be broken. Capping must therefore act only on the real, finite logits, i.e. before the -inf mask is applied. (This mirrors QK-norm and soft-capping being complementary front-end guards on the logits, with masking as the final step.)

(b) With \(z = 75\) and \(c = 50\): \(z/c = 1.5\), and \(\tanh(1.5) \approx 0.9051\). So $\(\hat z = 50 \times 0.9051 \approx 45.26.\)$ The logit is compressed from \(75\) toward the cap of \(50\) (not hard-clipped): it stays below \(50\), preserves sign and ordering, and remains differentiable, which is precisely the point of using \(\tanh\) rather than a hard clamp.