AI Knowledge Tree

Modernize: RoPE and RMSNorm

Swap learned positional embeddings for rotary embeddings and LayerNorm for RMSNorm, the two changes that bring the toy model in line with current LLM architectures.

The model from the previous lesson uses a learned position embedding and LayerNorm, the 2017 choices. Current LLMs (LLaMA, Mistral, Qwen) use rotary position embeddings (RoPE) and RMSNorm. This lesson makes both swaps and explains why each is a strict simplification, not just a substitution.

1. RMSNorm

LayerNorm centers and scales: it subtracts the mean and divides by the standard deviation, then applies a learned gain and bias. RMSNorm drops the centering and the bias, normalizing only by the root mean square:

RMSNorm(x)=x1d∑i=1dxi2+ε⊙g,\text{RMSNorm}(x) = \frac{x}{\sqrt{\tfrac{1}{d}\sum_{i=1}^{d} x_i^2 + \varepsilon}} \odot g,

where g∈Rdg \in \mathbb{R}^d is a learned gain and ε\varepsilon guards against division by zero. It has fewer operations and parameters than LayerNorm and matches its quality in practice, which is why it became standard[1].

import torch
import torch.nn as nn

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

    def forward(self, x):
        rms = x.pow(2).mean(dim=-1, keepdim=True).add(self.eps).rsqrt()
        return x * rms * self.gain

Replace every nn.LayerNorm(n_embd) in the block and the final norm with RMSNorm(n_embd).

2. Rotary position embeddings

A learned position embedding stores one vector per absolute position and adds it once at the input. RoPE instead injects position inside attention by rotating each query and key by an angle proportional to its position. The dot product of a query at position mm and a key at position nn then depends only on the relative offset m−nm - n, which is what gives RoPE its length-generalization behavior[2], derived in positional encodings.

Group the head dimension into pairs and rotate each pair (x2i,x2i+1)(x_{2i}, x_{2i+1}) by angle m θim\,\theta_i with θi=10000−2i/dh\theta_i = 10000^{-2i/d_h}:

def build_rope_cache(seq_len, head_dim, base=10000.0, device="cpu"):
    theta = 1.0 / (base ** (torch.arange(0, head_dim, 2, device=device) / head_dim))
    pos = torch.arange(seq_len, device=device)
    freqs = torch.outer(pos, theta)            # (T, head_dim/2)
    return torch.cos(freqs), torch.sin(freqs)

def apply_rope(x, cos, sin):
    # x: (B, n_head, T, head_dim); rotate even/odd pairs
    x1, x2 = x[..., 0::2], x[..., 1::2]
    T = x.shape[-2]
    c, s = cos[:T].unsqueeze(0).unsqueeze(0), sin[:T].unsqueeze(0).unsqueeze(0)
    out1 = x1 * c - x2 * s
    out2 = x1 * s + x2 * c
    return torch.stack((out1, out2), dim=-1).flatten(-2)

In the attention head, apply apply_rope to the query and key after their projections and before the score, and delete the position_embedding table from the model. Position now enters through the rotation, not through an additive vector.

RoPE rotates each embedding pair by an angle proportional to position, across a spectrum of frequencies: low dimensions rotate fast (local order), high dimensions slow (long-range position).
RoPE rotates each embedding pair by an angle proportional to position, across a spectrum of frequencies: low dimensions rotate fast (local order), high dimensions slow (long-range position).

3. Why these are simplifications

Both changes remove parameters and operations. RMSNorm drops the mean subtraction and the bias; RoPE removes the learned position table and the T×dT \times d addition, replacing them with a fixed, parameter-free rotation. The model gets smaller and generalizes to longer contexts than it trained on, while the training loop is unchanged.

Exercises

Exercise 1. RMSNorm drops LayerNorm's mean subtraction. Show that RMSNorm is invariant to scaling its input by a positive constant: RMSNorm(cx)=RMSNorm(x)\text{RMSNorm}(cx) = \text{RMSNorm}(x) for any c>0c > 0 (ignoring the ε\varepsilon guard). Then explain why LayerNorm, which subtracts the mean, is not invariant to adding a constant to every coordinate in the same way, contrasting the two normalizers' symmetries.

Solution

Step 1. Write the RMS of cxcx. With RMS(x)=1d∑ixi2\text{RMS}(x) = \sqrt{\tfrac{1}{d}\sum_i x_i^2}, scaling pulls cc out: RMS(cx)=1d∑ic2xi2=∣c∣ RMS(x)=c RMS(x)\text{RMS}(cx) = \sqrt{\tfrac{1}{d}\sum_i c^2 x_i^2} = |c|\,\text{RMS}(x) = c\,\text{RMS}(x) since c>0c > 0.

Step 2. Then RMSNorm(cx)=cxc RMS(x)⊙g=xRMS(x)⊙g=RMSNorm(x)\text{RMSNorm}(cx) = \dfrac{cx}{c\,\text{RMS}(x)} \odot g = \dfrac{x}{\text{RMS}(x)} \odot g = \text{RMSNorm}(x). The cc cancels, so RMSNorm is scale-invariant.

Step 3. LayerNorm subtracts the mean first, then divides by the standard deviation. Subtracting the mean makes it invariant to shifts: adding the same constant bb to every coordinate leaves x−xˉx - \bar{x} unchanged. RMSNorm has no mean subtraction, so it is not shift-invariant; it is scale-invariant instead. The two normalizers therefore protect different symmetries: LayerNorm removes a shift degree of freedom, RMSNorm removes only a scale degree of freedom and keeps the cheaper operation.

Exercise 2 (code). Implement RMSNorm in numpy and verify both its definition and the scale-invariance from Exercise 1 on a random vector. Use a unit gain so the output is the pure normalizer.

Solution
python · numpy
import numpy as np

rng = np.random.default_rng(0)

def rmsnorm(x, gain, eps=1e-5):
    rms = np.sqrt((x ** 2).mean(axis=-1, keepdims=True) + eps)
    return x / rms * gain

d = 8
x = rng.standard_normal(d)
gain = np.ones(d)

y = rmsnorm(x, gain)

# Definition: each coordinate is x_i divided by the root-mean-square of x.
rms = np.sqrt((x ** 2).mean())
assert np.allclose(y, x / rms, atol=1e-4)

# Scale-invariance: scaling the input by c > 0 leaves the output fixed.
y_scaled = rmsnorm(3.0 * x, gain)
assert np.allclose(y, y_scaled, atol=1e-4)

print("rms of x:", round(float(rms), 4))
print("max |y - y_scaled|:", float(np.abs(y - y_scaled).max()))

The output matches x/RMS(x)x / \text{RMS}(x) and is unchanged when the input is tripled, confirming the normalizer and its scale-invariance.

Exercise 3 (code). The point of RoPE is that the dot product of a rotated query at position mm and a rotated key at position nn depends only on the relative offset m−nm - n. Verify this in numpy: rotate the same query and key vectors by two different absolute-position pairs that share an offset, and check the dot products agree. Use a single pair of head dimensions (one rotation angle) for clarity.

Solution
python · numpy
import numpy as np

rng = np.random.default_rng(1)

def rotate(v, angle):
    # rotate a 2D vector (one RoPE pair) by `angle` radians
    c, s = np.cos(angle), np.sin(angle)
    R = np.array([[c, -s], [s, c]])
    return R @ v

theta = 0.7                      # the per-pair frequency
q = rng.standard_normal(2)
k = rng.standard_normal(2)

def rope_score(q, k, m, n):
    return rotate(q, m * theta) @ rotate(k, n * theta)

# Two absolute-position pairs with the same offset m - n = 2.
s1 = rope_score(q, k, m=5, n=3)
s2 = rope_score(q, k, m=9, n=7)
assert np.isclose(s1, s2)

# A different offset gives a different score.
s3 = rope_score(q, k, m=5, n=4)   # offset 1
assert not np.isclose(s1, s3)

print("offset-2 scores:", round(float(s1), 4), round(float(s2), 4))
print("offset-1 score:", round(float(s3), 4))

The two offset-2 scores match while the offset-1 score differs, which is the relative-position property that gives RoPE its length-generalization behavior.

The architecture is now current. The next lesson takes this pretrained base and runs a minimal preference-tuning pass, the alignment step from E4 at toy scale.


References

  1. [1]Zhang, B., & Sennrich, R. (2019). "Root mean square layer normalization (RMSNorm)". NeurIPS. arXiv:1910.07467.↩
  2. [2]Su, J., Lu, Y., Pan, S., Wen, B., & Liu, Y. (2021). "RoFormer: enhanced transformer with rotary position embedding (RoPE)". arXiv:2104.09864.↩