Tutorial 04: RMSNorm — From Math to Code¶
Normalization layers stabilize transformer training and inference. The modern choice is RMSNorm (Root Mean Square Normalization), which is simpler and faster than LayerNorm. This tutorial derives the math, builds the pure-Python implementation, and shows how a Metal kernel replaces it.
1. Why Normalize?¶
Neural network activations can drift in scale as they pass through layers. If one layer outputs values in the range [0, 1] and the next expects [-10, 10], the model cannot learn effectively. Normalization rescales activations to a stable range at every layer.
The original transformer used Layer Normalization:
LayerNorm subtracts the mean (centers the data) and divides by the standard
deviation (scales the data). gamma and beta are learnable parameters.
RMSNorm simplifies this by dropping the mean centering:
It only divides by the root mean square. Empirically, this works just as well as LayerNorm for transformers, but is ~10-15% faster because it avoids the mean subtraction.
2. The Math, Step by Step¶
Given an input vector x of dimension D:
Step 1: Square every element¶
Step 2: Compute the mean of the squares¶
This is the squared "energy" of the vector.
Step 3: Add epsilon for numerical stability¶
Epsilon prevents division by zero. If all inputs are zero, the RMS would be zero
without epsilon, causing NaN. Typical values: eps = 1e-6 or 1e-5.
Step 4: Take the square root¶
Step 5: Divide and scale¶
The division rescales the vector so its RMS is approximately 1. The learnable
scale gamma allows the model to adjust the scale if needed.
Concrete Example¶
x = [3.0, 4.0] # D = 2
x_squared = [9.0, 16.0]
mean_sq = (9 + 16) / 2 = 12.5
rms = sqrt(12.5) ≈ 3.536
normalized = [3.0/3.536, 4.0/3.536] ≈ [0.849, 1.131]
# Check: RMS of [0.849, 1.131] ≈ 1.0 ✓
3. The Pure-Python Implementation¶
import mlx.core as mx
class RMSNorm:
def __init__(self, dim: int, weight: mx.array, eps: float = 1e-5):
"""
Args:
dim: The normalization dimension (hidden_size of the model).
weight: The learnable scale parameter (gamma). Shape: (dim,)
eps: Small constant for numerical stability.
"""
self.dim = dim
self.eps = eps
self.weight = weight
def __call__(self, x: mx.array) -> mx.array:
"""
Apply RMSNorm to the input tensor.
Args:
x: Input tensor. Shape: (..., dim)
The "..." means any number of leading dimensions (batch, seq, etc.)
Only the last dimension (dim) is normalized over.
Returns:
Normalized tensor. Same shape as input.
"""
# Remember the original dtype — we compute in float32 for stability,
# then cast back to match the model's precision (float16 or bfloat16)
orig_dtype = x.dtype
# Cast to float32 to avoid underflow/overflow in intermediate computations
x = x.astype(mx.float32)
# Compute: x / rms(x)
# rms(x) = sqrt(mean(x^2) + eps)
#
# Breaking this down:
# 1. mx.square(x) — element-wise x^2
# 2. mx.mean(..., axis=-1, keepdims=True) — mean over the last dim
# 3. + self.eps — add epsilon
# 4. mx.rsqrt(...) — 1/sqrt (faster than 1/sqrt(x))
# 5. x * result — rescale
x = x * mx.rsqrt(mx.mean(mx.square(x), axis=-1, keepdims=True) + self.eps)
# Cast back to original precision and apply learnable scale
x = x.astype(orig_dtype)
return x * self.weight.astype(orig_dtype)
Key Details¶
keepdims=True: The mean must have shape (..., 1) so it broadcasts
correctly against x's shape (..., D). Without keepdims, the mean would
collapse the last dimension and the shapes would not align.
mx.rsqrt instead of 1/mx.sqrt: The reciprocal square root is a single
hardware instruction on most GPUs. Computing 1/sqrt(x) directly is faster than
1 / sqrt(x).
Float32 intermediate: Even if the model runs in float16 or bfloat16, the normalization statistics are computed in float32. This prevents precision loss when computing the mean of squared values (which can be very small).
4. Verification¶
# Verify RMSNorm produces outputs with RMS ≈ 1
x = mx.random.normal((2, 4, 128)) # batch=2, seq=4, hidden=128
gamma = mx.ones((128,)) # unit scale
norm = RMSNorm(dim=128, weight=gamma, eps=1e-6)
y = norm(x)
# Compute RMS of each vector — should be approximately 1.0
rms_values = mx.sqrt(mx.mean(mx.square(y), axis=-1))
print(rms_values)
# Should print values close to 1.0 for every (batch, seq) position
5. Where RMSNorm Appears in the Transformer¶
A transformer block applies RMSNorm twice:
In code:
class TransformerBlock:
def __call__(self, x, offset, cache, mask=None):
# First norm: normalize before attention
r = self.self_attn(self.input_layernorm(x), offset, cache, mask)
h = x + r # residual connection
# Second norm: normalize before feed-forward
r = self.mlp(self.post_attention_layernorm(h))
return h + r # residual connection
The pattern is always: norm → compute → residual add. This is called Pre-LN (pre-normalization) and is the standard for modern transformers.
6. RMSNorm vs LayerNorm: When It Matters¶
| Property | LayerNorm | RMSNorm |
|---|---|---|
| Centers data (subtracts mean) | Yes | No |
| Parameters | gamma + beta (2×D) | gamma only (D) |
| Compute cost | Higher | ~10-15% faster |
| Quality | Baseline | Equivalent for transformers |
For a model with hidden_size=4096, the extra beta parameter would add 4096 additional floats per layer (4B × 32 layers = 128MB). RMSNorm avoids this overhead with no quality loss.
7. From Pure Python to Metal¶
The pure-Python implementation works correctly but calls three separate MLX
operations: square, mean, and rsqrt. A custom Metal kernel can fuse these
into a single GPU pass:
# core/kernels.py — Metal-backed RMSNorm
class FastRMSNorm:
def __init__(self, dim: int, weight: mx.array, eps: float = 1e-5):
self.dim = dim
self.weight = weight
self.eps = eps
def __call__(self, x: mx.array) -> mx.array:
# One Metal kernel call instead of three MLX operations
return tiny_llm_ext_ref.rms_norm(
mx.contiguous(x),
mx.contiguous(self.weight.astype(x.dtype)),
self.eps,
)
The Metal kernel does the same math — square, mean, rsqrt, multiply — but in a single GPU dispatch. This eliminates two intermediate tensor allocations and two memory round-trips.
The engine chooses between them at initialization time:
# In model.py — selecting between pure Python and Metal paths
norm_cls = FastRMSNorm if features.kernels else RMSNorm
self.input_layernorm = norm_cls(
config.hidden_size,
weights.tensor(f"{prefix}.input_layernorm.weight"),
eps=config.rms_norm_eps,
)
Summary¶
- RMSNorm normalizes by the root mean square, avoiding mean centering.
- The formula:
x * rsqrt(mean(x^2) + eps) * gamma - Pure Python: three MLX ops (square → mean → rsqrt → multiply).
- Metal kernel: single fused GPU dispatch for the same math.
- Applied twice per transformer block: before attention and before feed-forward.