---
title: "Module 07: Training"
format:
html:
code-fold: false
toc: true
ipynb: default
jupyter: python3
---
{{< include ../_diagram-lib.qmd >}}
{{< include ../_components/step-control.qmd >}}
## Introduction
Training teaches a language model to predict the next token. The process iterates:
1. **Computing loss**: How wrong are our predictions?
2. **Computing gradients**: Which direction should we adjust weights?
3. **Updating weights**: Take a small step in that direction
4. **Repeat**: Until the model gets good at prediction
This module covers cross-entropy loss, the AdamW optimizer, learning rate scheduling, gradient accumulation, and checkpointing.
### What You'll Learn
After this module, you can:
- Understand cross-entropy loss and perplexity for language models
- Implement learning rate schedules (warmup + cosine decay)
- Use gradient accumulation for effective larger batch sizes
- Apply gradient clipping for training stability
- Read the floating-point number line from scratch — why fp16 overflows and
underflows where bf16 doesn't, and how loss scaling rescues small gradients
- Build both **fp8** formats (e4m3 / e5m2) — why e4m3 drops IEEE's infinity — and
the **per-tensor scaling** that makes 8-bit training work
- Save and load model checkpoints
- Plan a compute-optimal run with scaling laws (the `C≈6ND` rule, Chinchilla's ~20 tokens/param)
- **Fit a scaling law from scratch** — sweep IsoFLOP profiles, read each valley,
and recover the compute-optimal exponents ($N\propto C^{0.45}$, $D\propto C^{0.55}$)
by log-log least squares, so "20 tokens/param" arrives as a *measured* result —
then make the runs **noisy** and see why the valley must be **fit with a
parabola**, not read off the single lowest run
- Build **Muon** from scratch — orthogonalize the momentum matrix with a
from-scratch Newton–Schulz iteration, the first serious challenger to AdamW at
LLM scale
### Prerequisites
This module requires familiarity with:
- [Module 02: Autograd](../m02_autograd/lesson.qmd) — Gradient computation and backpropagation
- [Module 06: Transformer](../m06_transformer/lesson.qmd) — Transformer architecture to train
**Note**: This lesson demonstrates concepts interactively. The `training.py` file provides production-ready implementations of the same algorithms.
## The Training Objective
Language models learn through **next-token prediction**:
```
Input: [The, cat, sat, on, the]
Target: [cat, sat, on, the, mat]
For each position, predict the next token.
```
The loss function measures how well the model predicts: **Cross-entropy** between predicted probabilities and actual next tokens.
$$\text{loss} = -\sum \log(P(\text{correct\_token}))$$
Lower loss means the model assigns correct tokens higher probability.
## The Training Loop
Neural networks learn through the training loop:
```{ojs}
//| echo: false
// Training loop steps data
trainingSteps = [
{
id: 0,
name: "Zero Gradients",
code: "optimizer.zero_grad()",
description: "Clear accumulated gradients from the previous iteration to start fresh.",
detail: "Gradients accumulate by default in PyTorch. Without zeroing, they add up across iterations."
},
{
id: 1,
name: "Forward Pass",
code: "logits = model(input_ids)",
description: "Pass input tokens through the model to get predicted logits.",
detail: "The model computes attention, embeddings, and projections to produce next-token predictions."
},
{
id: 2,
name: "Compute Loss",
code: "loss = F.cross_entropy(logits, targets)",
description: "Measure how wrong the predictions are compared to actual next tokens.",
detail: "Cross-entropy loss: lower means higher probability assigned to correct tokens."
},
{
id: 3,
name: "Backward Pass",
code: "loss.backward()",
description: "Compute gradients for all parameters via backpropagation.",
detail: "Automatic differentiation traces computation graph backward, computing dLoss/dParam."
},
{
id: 4,
name: "Gradient Clipping",
code: "clip_grad_norm_(params, 1.0)",
description: "Scale gradients if their norm exceeds threshold to prevent instability.",
detail: "Prevents exploding gradients that can cause NaN loss or divergent training."
},
{
id: 5,
name: "Optimizer Step",
code: "optimizer.step()",
description: "Update model weights using the computed (and clipped) gradients.",
detail: "AdamW applies momentum, adaptive learning rates, and weight decay to the update."
},
{
id: 6,
name: "Update LR",
code: "scheduler.step()",
description: "Adjust learning rate according to schedule (warmup + cosine decay).",
detail: "High LR early for exploration, lower LR later for fine-tuning convergence."
}
]
```
```{ojs}
//| echo: false
// Step control for training loop
viewof trainingStep = stepControl({min: 0, max: 6, value: 0, label: "Training Step"})
```
```{ojs}
//| echo: false
// Current step info
currentTrainingStep = trainingSteps[trainingStep]
```
```{ojs}
//| echo: false
// Draw the cyclic training loop diagram
trainingLoopDiagram = {
const width = 650;
const height = 480;
const centerX = width / 2;
const centerY = height / 2 - 20;
const radius = 160;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`);
// Background
svg.append("rect")
.attr("width", width)
.attr("height", height)
.attr("fill", diagramTheme.bg)
.attr("rx", 8);
// Defs for arrows
const defs = svg.append("defs");
// Standard arrow
defs.append("marker")
.attr("id", "training-arrow")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 8)
.attr("refY", 0)
.attr("markerWidth", 5)
.attr("markerHeight", 5)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", diagramTheme.edgeStroke);
// Highlighted arrow
defs.append("marker")
.attr("id", "training-arrow-active")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 8)
.attr("refY", 0)
.attr("markerWidth", 5)
.attr("markerHeight", 5)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", diagramTheme.highlight);
// Calculate node positions in a circle
const nodeCount = 7;
const startAngle = -Math.PI / 2; // Start at top
const nodePositions = trainingSteps.map((step, i) => {
const angle = startAngle + (i * 2 * Math.PI / nodeCount);
return {
...step,
x: centerX + radius * Math.cos(angle),
y: centerY + radius * Math.sin(angle),
angle: angle
};
});
// Draw connecting arrows between nodes
const edgesGroup = svg.append("g").attr("class", "edges");
for (let i = 0; i < nodeCount; i++) {
const from = nodePositions[i];
const to = nodePositions[(i + 1) % nodeCount];
// Calculate edge start/end to not overlap nodes
const nodeRadius = 42;
const dx = to.x - from.x;
const dy = to.y - from.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const startX = from.x + (dx / dist) * (nodeRadius + 2);
const startY = from.y + (dy / dist) * (nodeRadius + 2);
const endX = to.x - (dx / dist) * (nodeRadius + 8);
const endY = to.y - (dy / dist) * (nodeRadius + 8);
// This edge is highlighted when we're on the "from" step
const isActive = trainingStep === i;
edgesGroup.append("path")
.attr("d", `M${startX},${startY} L${endX},${endY}`)
.attr("fill", "none")
.attr("stroke", isActive ? diagramTheme.highlight : diagramTheme.edgeStroke)
.attr("stroke-width", isActive ? 2.5 : 1.5)
.attr("marker-end", isActive ? "url(#training-arrow-active)" : "url(#training-arrow)")
.attr("opacity", isActive ? 1 : 0.6)
.style("filter", isActive ? `drop-shadow(0 0 4px ${diagramTheme.highlightGlow})` : "none");
}
// Draw nodes
const nodesGroup = svg.append("g").attr("class", "nodes");
nodePositions.forEach((node, i) => {
const isActive = trainingStep === i;
const nodeSize = 42;
const g = nodesGroup.append("g")
.attr("transform", `translate(${node.x}, ${node.y})`);
// Circle node
g.append("circle")
.attr("r", nodeSize)
.attr("fill", isActive ? diagramTheme.highlight : diagramTheme.nodeFill)
.attr("stroke", isActive ? diagramTheme.highlight : diagramTheme.nodeStroke)
.attr("stroke-width", isActive ? 2.5 : 1.5)
.style("filter", isActive ? `drop-shadow(0 0 8px ${diagramTheme.highlightGlow})` : "none");
// Step number
g.append("text")
.attr("y", -10)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("fill", isActive ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", "11px")
.attr("font-weight", "600")
.attr("opacity", 0.7)
.text(`Step ${i + 1}`);
// Node label (split long names)
const words = node.name.split(" ");
if (words.length > 1) {
g.append("text")
.attr("y", 5)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("fill", isActive ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", "11px")
.attr("font-weight", "500")
.text(words[0]);
g.append("text")
.attr("y", 18)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("fill", isActive ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", "11px")
.attr("font-weight", "500")
.text(words.slice(1).join(" "));
} else {
g.append("text")
.attr("y", 10)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("fill", isActive ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", "11px")
.attr("font-weight", "500")
.text(node.name);
}
});
// Center label
svg.append("text")
.attr("x", centerX)
.attr("y", centerY - 5)
.attr("text-anchor", "middle")
.attr("fill", diagramTheme.nodeText)
.attr("font-size", "14px")
.attr("font-weight", "600")
.attr("opacity", 0.8)
.text("Training");
svg.append("text")
.attr("x", centerX)
.attr("y", centerY + 12)
.attr("text-anchor", "middle")
.attr("fill", diagramTheme.nodeText)
.attr("font-size", "14px")
.attr("font-weight", "600")
.attr("opacity", 0.8)
.text("Loop");
// Info panel at bottom
const infoY = height - 100;
const infoGroup = svg.append("g")
.attr("transform", `translate(${width / 2}, ${infoY})`);
// Info box background
infoGroup.append("rect")
.attr("x", -280)
.attr("y", -10)
.attr("width", 560)
.attr("height", 85)
.attr("rx", 6)
.attr("fill", diagramTheme.bgSecondary)
.attr("stroke", diagramTheme.nodeStroke)
.attr("stroke-width", 1);
// Step name
infoGroup.append("text")
.attr("y", 8)
.attr("text-anchor", "middle")
.attr("fill", diagramTheme.highlight)
.attr("font-size", "13px")
.attr("font-weight", "600")
.text(`${currentTrainingStep.id + 1}. ${currentTrainingStep.name}`);
// Code
infoGroup.append("text")
.attr("y", 28)
.attr("text-anchor", "middle")
.attr("fill", diagramTheme.accent)
.attr("font-size", "12px")
.attr("font-family", "var(--pg-mono)")
.text(currentTrainingStep.code);
// Description (wrap if needed)
const desc = currentTrainingStep.description;
if (desc.length > 70) {
const mid = desc.lastIndexOf(" ", 70);
infoGroup.append("text")
.attr("y", 50)
.attr("text-anchor", "middle")
.attr("fill", diagramTheme.nodeText)
.attr("font-size", "11px")
.text(desc.substring(0, mid));
infoGroup.append("text")
.attr("y", 64)
.attr("text-anchor", "middle")
.attr("fill", diagramTheme.nodeText)
.attr("font-size", "11px")
.text(desc.substring(mid + 1));
} else {
infoGroup.append("text")
.attr("y", 55)
.attr("text-anchor", "middle")
.attr("fill", diagramTheme.nodeText)
.attr("font-size", "11px")
.text(desc);
}
return svg.node();
}
```
```{ojs}
//| echo: false
// Additional detail below the diagram
md`**Why this step matters:** ${currentTrainingStep.detail}`
```
Note: `zero_grad()` can be called either at the start or end of each iteration. Calling it at the start (shown above) is common because it ensures gradients are fresh before the backward pass.
## Setup
```{python}
import sys
import math
from pathlib import Path
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
# For reproducibility
torch.manual_seed(42)
# Display device info
device = "mps" if torch.backends.mps.is_available() else "cuda" if torch.cuda.is_available() else "cpu"
print(f"PyTorch version: {torch.__version__}")
print(f"Device: {device}")
```
## Cross-Entropy Loss
The loss function measures prediction error. Cross-entropy loss penalizes wrong predictions more heavily when the model is confident but incorrect.
**Why cross-entropy?**
1. **Probabilistic interpretation**: It measures the "surprise" when the true token appears
2. **Gradient properties**: Gradients are proportional to the error (predicted - actual)
3. **Information theory**: Minimizing cross-entropy = maximizing likelihood of data
**Mathematical formulation:**
$$\text{CrossEntropy}(p, q) = -\sum_{i} p_i \log(q_i)$$
For language modeling with one-hot targets (only one correct token), this simplifies to:
$$\text{Loss} = -\log(q_{\text{correct}})$$
where $q_{\text{correct}}$ is the probability the model assigns to the correct token.
```{python}
# Example: Model predicting next token
vocab_size = 10
# Model outputs logits (raw scores)
logits = torch.tensor([
[-1.0, 0.5, 2.0, -0.5, 1.0, 0.0, -1.5, 0.3, -0.8, 0.2] # scores for each token
])
# True next token is index 2
target = torch.tensor([2])
# Convert to probabilities
probs = F.softmax(logits, dim=-1)
print("Logits (raw model output):")
print(f" {logits[0].tolist()}")
print(f"\nProbabilities (after softmax):")
print(f" {[f'{p:.3f}' for p in probs[0].tolist()]}")
print(f"\nTarget token: {target.item()}")
print(f"Probability assigned to target: {probs[0, target.item()]:.4f}")
```
```{python}
# Cross-entropy loss
loss = F.cross_entropy(logits, target)
manual_loss = -torch.log(probs[0, target.item()])
print(f"Cross-entropy loss: {loss.item():.4f}")
print(f"Manual calculation: -log({probs[0, target.item()]:.4f}) = {manual_loss.item():.4f}")
# Perplexity
perplexity = math.exp(loss.item())
print(f"\nPerplexity: {perplexity:.2f}")
```
Let's visualize how loss changes with probability:
```{ojs}
//| echo: false
// Loss vs Probability interactive chart
lossProbChart = {
const width = 650;
const height = 340;
const margin = { top: 40, right: 30, bottom: 50, left: 60 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`);
// Background
svg.append("rect")
.attr("width", width)
.attr("height", height)
.attr("fill", theme.bg)
.attr("rx", 8);
const chart = svg.append("g")
.attr("transform", `translate(${margin.left}, ${margin.top})`);
// Generate data
const data = [];
for (let p = 0.01; p <= 0.99; p += 0.01) {
data.push({ prob: p, loss: -Math.log(p) });
}
// Scales
const xScale = d3.scaleLinear()
.domain([0, 1])
.range([0, innerWidth]);
const yScale = d3.scaleLinear()
.domain([0, 5])
.range([innerHeight, 0]);
// Grid lines
[0, 1, 2, 3, 4, 5].forEach(tick => {
chart.append("line")
.attr("x1", 0)
.attr("x2", innerWidth)
.attr("y1", yScale(tick))
.attr("y2", yScale(tick))
.attr("stroke", theme.nodeStroke)
.attr("stroke-opacity", 0.3)
.attr("stroke-dasharray", "3,3");
});
// Zero line
chart.append("line")
.attr("x1", 0)
.attr("x2", innerWidth)
.attr("y1", yScale(0))
.attr("y2", yScale(0))
.attr("stroke", theme.nodeText)
.attr("stroke-opacity", 0.4)
.attr("stroke-dasharray", "5,3");
// Line generator
const lineGen = d3.line()
.x(d => xScale(d.prob))
.y(d => yScale(d.loss))
.curve(d3.curveMonotoneX);
// Area under curve
const areaGen = d3.area()
.x(d => xScale(d.prob))
.y0(innerHeight)
.y1(d => yScale(d.loss))
.curve(d3.curveMonotoneX);
chart.append("path")
.datum(data)
.attr("d", areaGen)
.attr("fill", theme.accent)
.attr("opacity", 0.1);
// Main line
chart.append("path")
.datum(data)
.attr("d", lineGen)
.attr("fill", "none")
.attr("stroke", theme.accent)
.attr("stroke-width", 3);
// Annotated points
const points = [
{ prob: 0.1, label: "P=0.1", loss: -Math.log(0.1) },
{ prob: 0.5, label: "P=0.5", loss: -Math.log(0.5) },
{ prob: 0.9, label: "P=0.9", loss: -Math.log(0.9) }
];
points.forEach(pt => {
// Point circle
chart.append("circle")
.attr("cx", xScale(pt.prob))
.attr("cy", yScale(pt.loss))
.attr("r", 8)
.attr("fill", theme.highlight)
.attr("stroke", theme.bgOpaque)
.attr("stroke-width", 2);
// Label
const labelX = xScale(pt.prob) + 12;
const labelY = yScale(pt.loss) - 8;
chart.append("text")
.attr("x", labelX)
.attr("y", labelY)
.attr("fill", theme.highlight)
.attr("font-size", "11px")
.attr("font-weight", "600")
.text(pt.label);
chart.append("text")
.attr("x", labelX)
.attr("y", labelY + 14)
.attr("fill", theme.nodeText)
.attr("font-size", "10px")
.text(`Loss=${pt.loss.toFixed(2)}`);
});
// X-axis
chart.append("g")
.attr("transform", `translate(0, ${innerHeight})`)
.call(d3.axisBottom(xScale).ticks(10).tickFormat(d3.format(".1f")))
.call(g => g.select(".domain").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick line").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick text").attr("fill", theme.nodeText).attr("font-size", "11px"));
chart.append("text")
.attr("x", innerWidth / 2)
.attr("y", innerHeight + 40)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("fill", theme.nodeText)
.text("Probability assigned to correct token");
// Y-axis
chart.append("g")
.call(d3.axisLeft(yScale).ticks(5))
.call(g => g.select(".domain").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick line").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick text").attr("fill", theme.nodeText).attr("font-size", "11px"));
chart.append("text")
.attr("transform", "rotate(-90)")
.attr("x", -innerHeight / 2)
.attr("y", -45)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("fill", theme.nodeText)
.text("Cross-entropy loss");
// Title
svg.append("text")
.attr("x", width / 2)
.attr("y", 24)
.attr("text-anchor", "middle")
.attr("font-size", "14px")
.attr("font-weight", "600")
.attr("fill", theme.nodeText)
.text("Loss vs Probability");
return svg.node();
}
```
Higher probability means lower loss means better predictions.
### Cross-Entropy from Scratch
Before using `F.cross_entropy`, let's understand what it does internally.
**The Numerical Stability Problem**
Softmax involves `exp(x)`, which explodes for large x:
```{python}
# The problem: exp() overflows easily
logits_big = np.array([1000.0, 1001.0, 1002.0])
print(f"exp(logits) = {np.exp(logits_big)}") # [inf, inf, inf] - overflow!
```
**The Fix: Log-Sum-Exp Trick**
The key insight is that we can compute log-softmax stably by subtracting the maximum:
$$\log \text{softmax}(x_i) = x_i - \log\sum_j e^{x_j} = x_i - \underbrace{(m + \log\sum_j e^{x_j - m})}_{\text{logsumexp}}$$
where $m = \max(x)$. By subtracting the max, all exponents become $\leq 0$, avoiding overflow.
```{python}
def logsumexp(x: np.ndarray, axis: int = -1, keepdims: bool = True) -> np.ndarray:
"""
Stable log(sum(exp(x))).
Trick: log(sum(exp(x))) = m + log(sum(exp(x - m)))
where m = max(x). This keeps exp() arguments <= 0.
"""
m = x.max(axis=axis, keepdims=True)
return m + np.log(np.exp(x - m).sum(axis=axis, keepdims=keepdims))
# Now it works!
print(f"logsumexp(logits) = {logsumexp(logits_big, keepdims=False)}")
```
**Cross-Entropy Implementation**
```{python}
def cross_entropy_scratch(logits: np.ndarray, targets: np.ndarray) -> float:
"""
Cross-entropy loss from logits.
logits: (B, C) - raw scores for each class
targets: (B,) - integer class labels
Formula: loss = logsumexp(logits) - logits[correct_class]
This is equivalent to: -log(softmax(logits)[correct_class])
but numerically stable.
"""
B, C = logits.shape
# log(sum(exp(logits))) for normalization
lse = logsumexp(logits, axis=-1, keepdims=False).squeeze() # (B,)
# Gather correct class logits
correct_logits = logits[np.arange(B), targets] # (B,)
# Loss per sample, then mean
losses = lse - correct_logits
return float(losses.mean())
# Test
test_logits = np.array([[2.0, 1.0, 0.1], [0.5, 2.5, 0.3]])
test_targets = np.array([0, 1]) # First sample: class 0, second: class 1
print(f"Cross-entropy loss (scratch): {cross_entropy_scratch(test_logits, test_targets):.4f}")
```
**PyTorch Equivalent**
```{python}
# Compare with PyTorch
logits_pt = torch.tensor([[2.0, 1.0, 0.1], [0.5, 2.5, 0.3]])
targets_pt = torch.tensor([0, 1])
loss_pt = F.cross_entropy(logits_pt, targets_pt)
print(f"Cross-entropy loss (PyTorch): {loss_pt.item():.4f}")
```
Same result! PyTorch's `F.cross_entropy` does exactly this internally, plus handles gradients automatically.
::: {.callout-note}
## Key Insight
Cross-entropy is just `logsumexp(logits) - logits[correct_class]`. The logsumexp trick prevents numerical overflow by subtracting the max before exponentiating.
:::
## Perplexity
Perplexity is a more intuitive measure than raw loss:
$$\text{Perplexity} = e^{\text{cross\_entropy\_loss}}$$
**Interpretation**: "The model is as confused as if it were choosing uniformly among N options."
| Loss | Perplexity | Interpretation |
|------|------------|----------------|
| 0.0 | 1.0 | Perfect predictions |
| 2.3 | 10 | ~10 equally likely options |
| 4.6 | 100 | ~100 equally likely options |
| 6.9 | 1000 | Random guessing (vocab=1000) |
For reference:
- GPT-2 on WebText: ~20 perplexity
- Human baseline: ~10-20 perplexity (depends on domain)
## Learning Rate Schedule
We vary the learning rate over training, using warmup followed by cosine decay:
```{ojs}
//| echo: false
// Learning Rate Schedule Parameters with full interactivity
viewof lrMaxLRExp = Inputs.range([-5, -2], {
value: -3,
step: 0.5,
label: "Max LR (10^x)"
})
viewof lrMinLRExp = Inputs.range([-6, -3], {
value: -5,
step: 0.5,
label: "Min LR (10^x)"
})
viewof lrWarmupSteps = Inputs.range([0, 500], {
value: 100,
step: 10,
label: "Warmup Steps"
})
viewof lrTotalSteps = Inputs.range([100, 2000], {
value: 1000,
step: 50,
label: "Total Steps"
})
viewof lrCurrentStep = Inputs.range([0, lrTotalSteps], {
value: 0,
step: 1,
label: "Current Step"
})
```
```{ojs}
//| echo: false
// Convert log scale to actual values
lrMaxLR = Math.pow(10, lrMaxLRExp)
lrMinLR = Math.pow(10, lrMinLRExp)
// LR Schedule calculation function
lrScheduleData = {
const maxLR = lrMaxLR;
const minLR = lrMinLR;
const data = [];
for (let step = 0; step <= lrTotalSteps; step++) {
let lr;
let phase;
if (step < lrWarmupSteps) {
// Linear warmup
lr = maxLR * step / Math.max(1, lrWarmupSteps);
phase = "warmup";
} else if (step >= lrTotalSteps) {
lr = minLR;
phase = "decay";
} else {
// Cosine decay
const progress = (step - lrWarmupSteps) / Math.max(1, lrTotalSteps - lrWarmupSteps);
const cosine = 0.5 * (1 + Math.cos(Math.PI * progress));
lr = minLR + (maxLR - minLR) * cosine;
phase = "decay";
}
data.push({ step, lr, phase });
}
return data;
}
// Current LR value
currentLR = {
const maxLR = lrMaxLR;
const minLR = lrMinLR;
const step = lrCurrentStep;
if (step < lrWarmupSteps) {
return maxLR * step / Math.max(1, lrWarmupSteps);
} else if (step >= lrTotalSteps) {
return minLR;
} else {
const progress = (step - lrWarmupSteps) / Math.max(1, lrTotalSteps - lrWarmupSteps);
const cosine = 0.5 * (1 + Math.cos(Math.PI * progress));
return minLR + (maxLR - minLR) * cosine;
}
}
// Current phase
currentPhase = {
if (lrCurrentStep < lrWarmupSteps) return "warmup";
if (lrCurrentStep === lrWarmupSteps) return "peak";
return "decay";
}
```
```{ojs}
//| echo: false
// Learning Rate Schedule Visualization
lrScheduleChart = {
const width = 700;
const height = 380;
const margin = { top: 40, right: 30, bottom: 50, left: 60 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`)
.style("font-family", "var(--pg-mono)");
// Background with gradient
const defs = svg.append("defs");
const bgGradient = defs.append("linearGradient")
.attr("id", "lr-bg-gradient")
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "0%")
.attr("y2", "100%");
bgGradient.append("stop")
.attr("offset", "0%")
.attr("stop-color", theme.bg);
bgGradient.append("stop")
.attr("offset", "100%")
.attr("stop-color", theme.bgSecondary);
svg.append("rect")
.attr("width", width)
.attr("height", height)
.attr("fill", "url(#lr-bg-gradient)")
.attr("rx", 12);
// Chart area
const chart = svg.append("g")
.attr("transform", `translate(${margin.left}, ${margin.top})`);
// Scales - dynamic based on max/min LR
const xScale = d3.scaleLinear()
.domain([0, lrTotalSteps])
.range([0, innerWidth]);
const yScale = d3.scaleLinear()
.domain([0, lrMaxLR * 1.1])
.range([innerHeight, 0]);
// Phase background regions
// Warmup region
if (lrWarmupSteps > 0) {
chart.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", xScale(lrWarmupSteps))
.attr("height", innerHeight)
.attr("fill", theme.accent)
.attr("opacity", currentPhase === "warmup" ? 0.15 : 0.05);
}
// Decay region
chart.append("rect")
.attr("x", xScale(lrWarmupSteps))
.attr("y", 0)
.attr("width", innerWidth - xScale(lrWarmupSteps))
.attr("height", innerHeight)
.attr("fill", theme.highlight)
.attr("opacity", currentPhase === "decay" || currentPhase === "peak" ? 0.1 : 0.03);
// Grid lines - dynamic based on max LR
const yTicks = [0, lrMaxLR * 0.25, lrMaxLR * 0.5, lrMaxLR * 0.75, lrMaxLR];
yTicks.forEach(tick => {
chart.append("line")
.attr("x1", 0)
.attr("x2", innerWidth)
.attr("y1", yScale(tick))
.attr("y2", yScale(tick))
.attr("stroke", theme.nodeStroke)
.attr("stroke-opacity", 0.3)
.attr("stroke-dasharray", "3,3");
});
// Phase labels
if (lrWarmupSteps > 0) {
chart.append("text")
.attr("x", xScale(lrWarmupSteps / 2))
.attr("y", 15)
.attr("text-anchor", "middle")
.attr("font-size", "11px")
.attr("font-weight", currentPhase === "warmup" ? "600" : "400")
.attr("fill", currentPhase === "warmup" ? theme.accent : theme.nodeText)
.attr("opacity", currentPhase === "warmup" ? 1 : 0.5)
.text("WARMUP");
}
chart.append("text")
.attr("x", xScale(lrWarmupSteps + (lrTotalSteps - lrWarmupSteps) / 2))
.attr("y", 15)
.attr("text-anchor", "middle")
.attr("font-size", "11px")
.attr("font-weight", currentPhase === "decay" ? "600" : "400")
.attr("fill", currentPhase === "decay" || currentPhase === "peak" ? theme.highlight : theme.nodeText)
.attr("opacity", currentPhase === "decay" || currentPhase === "peak" ? 1 : 0.5)
.text("COSINE DECAY");
// Line generator
const lineGen = d3.line()
.x(d => xScale(d.step))
.y(d => yScale(d.lr))
.curve(d3.curveMonotoneX);
// Gradient for the line
const lineGradient = defs.append("linearGradient")
.attr("id", "lr-line-gradient")
.attr("gradientUnits", "userSpaceOnUse")
.attr("x1", 0)
.attr("x2", innerWidth)
.attr("y1", 0)
.attr("y2", 0);
lineGradient.append("stop")
.attr("offset", "0%")
.attr("stop-color", theme.accent);
const warmupPct = (lrWarmupSteps / lrTotalSteps * 100).toFixed(1);
lineGradient.append("stop")
.attr("offset", `${warmupPct}%`)
.attr("stop-color", theme.accent);
lineGradient.append("stop")
.attr("offset", `${warmupPct}%`)
.attr("stop-color", theme.highlight);
lineGradient.append("stop")
.attr("offset", "100%")
.attr("stop-color", theme.highlight);
// Area under curve
const areaGen = d3.area()
.x(d => xScale(d.step))
.y0(innerHeight)
.y1(d => yScale(d.lr))
.curve(d3.curveMonotoneX);
// Area gradient
const areaGradient = defs.append("linearGradient")
.attr("id", "lr-area-gradient")
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "0%")
.attr("y2", "100%");
areaGradient.append("stop")
.attr("offset", "0%")
.attr("stop-color", theme.highlight)
.attr("stop-opacity", 0.3);
areaGradient.append("stop")
.attr("offset", "100%")
.attr("stop-color", theme.highlight)
.attr("stop-opacity", 0.02);
chart.append("path")
.datum(lrScheduleData)
.attr("d", areaGen)
.attr("fill", "url(#lr-area-gradient)");
// Main line
chart.append("path")
.datum(lrScheduleData)
.attr("d", lineGen)
.attr("fill", "none")
.attr("stroke", "url(#lr-line-gradient)")
.attr("stroke-width", 3)
.attr("stroke-linecap", "round");
// Current step marker
const currentX = xScale(lrCurrentStep);
const currentY = yScale(currentLR);
// Vertical line at current step
chart.append("line")
.attr("x1", currentX)
.attr("x2", currentX)
.attr("y1", 0)
.attr("y2", innerHeight)
.attr("stroke", theme.nodeText)
.attr("stroke-opacity", 0.4)
.attr("stroke-dasharray", "4,4");
// Horizontal line to y-axis
chart.append("line")
.attr("x1", 0)
.attr("x2", currentX)
.attr("y1", currentY)
.attr("y2", currentY)
.attr("stroke", theme.nodeText)
.attr("stroke-opacity", 0.4)
.attr("stroke-dasharray", "4,4");
// Glow effect for marker
const glowFilter = defs.append("filter")
.attr("id", "lr-marker-glow")
.attr("x", "-50%")
.attr("y", "-50%")
.attr("width", "200%")
.attr("height", "200%");
glowFilter.append("feGaussianBlur")
.attr("stdDeviation", "4")
.attr("result", "blur");
glowFilter.append("feMerge")
.selectAll("feMergeNode")
.data(["blur", "SourceGraphic"])
.join("feMergeNode")
.attr("in", d => d);
// Current step dot with glow
chart.append("circle")
.attr("cx", currentX)
.attr("cy", currentY)
.attr("r", 12)
.attr("fill", currentPhase === "warmup" ? theme.accent : theme.highlight)
.attr("opacity", 0.3)
.attr("filter", "url(#lr-marker-glow)");
chart.append("circle")
.attr("cx", currentX)
.attr("cy", currentY)
.attr("r", 6)
.attr("fill", currentPhase === "warmup" ? theme.accent : theme.highlight)
.attr("stroke", theme.bgOpaque)
.attr("stroke-width", 2);
// X-axis
chart.append("g")
.attr("transform", `translate(0, ${innerHeight})`)
.call(d3.axisBottom(xScale).ticks(8).tickFormat(d => d))
.call(g => g.select(".domain").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick line").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick text")
.attr("fill", theme.nodeText)
.attr("font-size", "11px"));
// X-axis label
chart.append("text")
.attr("x", innerWidth / 2)
.attr("y", innerHeight + 40)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("fill", theme.nodeText)
.text("Training Steps");
// Y-axis with scientific notation for small LR values
chart.append("g")
.call(d3.axisLeft(yScale).ticks(5).tickFormat(d => {
if (d === 0) return "0";
if (d < 0.01) return d.toExponential(0);
return d.toFixed(4);
}))
.call(g => g.select(".domain").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick line").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick text")
.attr("fill", theme.nodeText)
.attr("font-size", "10px"));
// Y-axis label
chart.append("text")
.attr("transform", "rotate(-90)")
.attr("x", -innerHeight / 2)
.attr("y", -45)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("fill", theme.nodeText)
.text("Learning Rate");
// Info box
const infoBox = svg.append("g")
.attr("transform", `translate(${width - 170}, 50)`);
infoBox.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", 150)
.attr("height", 80)
.attr("rx", 8)
.attr("fill", theme.nodeFill)
.attr("stroke", theme.nodeStroke)
.attr("stroke-width", 1.5);
infoBox.append("text")
.attr("x", 75)
.attr("y", 22)
.attr("text-anchor", "middle")
.attr("font-size", "10px")
.attr("font-weight", "600")
.attr("fill", theme.nodeText)
.attr("opacity", 0.6)
.text("CURRENT");
// Format LR for display: use exponential for small values
const lrDisplay = currentLR < 0.0001 ? currentLR.toExponential(2) : currentLR.toFixed(6);
infoBox.append("text")
.attr("x", 75)
.attr("y", 45)
.attr("text-anchor", "middle")
.attr("font-size", "15px")
.attr("font-weight", "700")
.attr("fill", currentPhase === "warmup" ? theme.accent : theme.highlight)
.text(`LR: ${lrDisplay}`);
infoBox.append("text")
.attr("x", 75)
.attr("y", 65)
.attr("text-anchor", "middle")
.attr("font-size", "11px")
.attr("fill", theme.nodeText)
.attr("opacity", 0.7)
.text(`Step ${lrCurrentStep} / ${lrTotalSteps}`);
// Phase indicator badge
const phaseBadge = svg.append("g")
.attr("transform", `translate(${margin.left + 10}, 55)`);
const phaseColor = currentPhase === "warmup" ? theme.accent : theme.highlight;
const phaseLabel = currentPhase.toUpperCase();
phaseBadge.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", 75)
.attr("height", 24)
.attr("rx", 12)
.attr("fill", phaseColor)
.attr("opacity", 0.9);
phaseBadge.append("text")
.attr("x", 37.5)
.attr("y", 16)
.attr("text-anchor", "middle")
.attr("font-size", "10px")
.attr("font-weight", "700")
.attr("fill", theme.textOnHighlight)
.text(phaseLabel);
return svg.node();
}
```
**Why warmup?**
- Early training is unstable with large LR
- Gradients are noisy before weights settle
- Small LR lets model "get its bearings"
**Why decay?**
- Large LR is good for exploration early
- Small LR is good for fine-tuning later
- Cosine is smooth (no sudden changes)
```{python}
class CosineScheduler:
"""Learning rate scheduler with linear warmup and cosine decay."""
def __init__(self, optimizer, warmup_steps, total_steps, min_lr=0.0):
self.optimizer = optimizer
self.warmup_steps = warmup_steps
self.total_steps = total_steps
self.min_lr = min_lr
self.base_lr = optimizer.param_groups[0]['lr']
self.current_step = 0
def get_lr(self):
"""Calculate learning rate for current step."""
if self.current_step < self.warmup_steps:
# Linear warmup
return self.base_lr * self.current_step / max(1, self.warmup_steps)
elif self.current_step >= self.total_steps:
return self.min_lr
else:
# Cosine decay
progress = (self.current_step - self.warmup_steps) / max(
1, self.total_steps - self.warmup_steps
)
cosine = 0.5 * (1 + math.cos(math.pi * progress))
return self.min_lr + (self.base_lr - self.min_lr) * cosine
def step(self):
"""Update learning rate."""
lr = self.get_lr()
for param_group in self.optimizer.param_groups:
param_group['lr'] = lr
self.current_step += 1
return lr
# Create scheduler
model = nn.Linear(10, 10)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
scheduler = CosineScheduler(
optimizer,
warmup_steps=100,
total_steps=1000,
min_lr=1e-5
)
# Collect LRs over training
lrs = []
for _ in range(1000):
lrs.append(scheduler.get_lr())
scheduler.step()
print(f"Initial LR: {lrs[0]:.6f}")
print(f"After warmup (step 100): {lrs[100]:.6f}")
print(f"Final LR: {lrs[-1]:.6f}")
```
The interactive visualization above shows how learning rate changes over training. Try adjusting the warmup steps and total steps sliders to see how they affect the schedule.
## AdamW Optimizer
AdamW decouples weight decay from Adam (proper L2 regularization) and serves as the standard optimizer for language models.
**Why AdamW over SGD or Adam?**
- **SGD**: Requires careful learning rate tuning per layer, slow convergence
- **Adam**: Weight decay is applied to gradients (incorrect for L2 regularization)
- **AdamW**: Decouples weight decay from gradient updates (mathematically correct)
```{ojs}
//| echo: false
// AdamW step-through visualization
viewof adamwStep = stepControl({min: 0, max: 4, value: 0, label: "AdamW Step"})
```
```{ojs}
//| echo: false
// Step descriptions for AdamW
adamwStepInfo = {
const steps = [
{
title: "Input Gradient",
description: "Receive gradient g from backpropagation",
formula: "g = dL/dθ",
highlight: ["gradient"]
},
{
title: "Momentum Update",
description: "Update first moment (exponential moving average of gradients)",
formula: "m = β₁·m + (1-β₁)·g",
highlight: ["gradient", "momentum"]
},
{
title: "Adaptive Learning Rate",
description: "Update second moment (exponential moving average of squared gradients)",
formula: "v = β₂·v + (1-β₂)·g²",
highlight: ["gradient", "velocity"]
},
{
title: "Bias Correction",
description: "Correct for initialization bias in early timesteps",
formula: "m̂ = m/(1-β₁ᵗ), v̂ = v/(1-β₂ᵗ)",
highlight: ["momentum", "velocity", "bias"]
},
{
title: "Weight Update",
description: "Apply adaptive update with decoupled weight decay",
formula: "θ = θ - lr·(m̂/√v̂ + λ·θ)",
highlight: ["bias", "update"]
}
];
return steps[adamwStep];
}
// Numeric computation for AdamW example
adamwComputation = {
// Initial values and hyperparameters
const g = 0.5; // gradient
const beta1 = 0.9;
const beta2 = 0.999;
const lr = 0.001;
const lambda = 0.01; // weight decay
const t = 5; // timestep
const m_prev = 0.1; // previous momentum
const v_prev = 0.01; // previous velocity
const theta_prev = 0.75; // previous weight
// Step 0: Just the gradient
const step0 = { g };
// Step 1: Momentum update
const m = beta1 * m_prev + (1 - beta1) * g;
const step1 = { ...step0, m, m_prev };
// Step 2: Velocity update
const v = beta2 * v_prev + (1 - beta2) * (g * g);
const step2 = { ...step1, v, v_prev };
// Step 3: Bias correction
const m_hat = m / (1 - Math.pow(beta1, t));
const v_hat = v / (1 - Math.pow(beta2, t));
const step3 = { ...step2, m_hat, v_hat };
// Step 4: Weight update
const adam_update = m_hat / Math.sqrt(v_hat + 1e-8);
const weight_decay = lambda * theta_prev;
const theta = theta_prev - lr * (adam_update + weight_decay);
const step4 = { ...step3, adam_update, weight_decay, theta, theta_prev };
const steps = [step0, step1, step2, step3, step4];
return {
...steps[adamwStep],
beta1, beta2, lr, lambda, t,
step: adamwStep
};
}
```
```{ojs}
//| echo: false
// AdamW flowchart visualization
adamwDiagram = {
const width = 680;
const height = 420;
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`)
.style("font-family", "var(--pg-mono)");
// Background with subtle gradient
const bgGrad = svg.append("defs").append("linearGradient")
.attr("id", "adamw-bg-grad")
.attr("x1", "0%").attr("y1", "0%")
.attr("x2", "100%").attr("y2", "100%");
bgGrad.append("stop").attr("offset", "0%").attr("stop-color", theme.bg);
bgGrad.append("stop").attr("offset", "100%").attr("stop-color", theme.bgSecondary);
svg.append("rect")
.attr("width", width)
.attr("height", height)
.attr("fill", "url(#adamw-bg-grad)")
.attr("rx", 12);
// Glow filter for highlights
const defs = svg.select("defs");
const glowFilter = defs.append("filter")
.attr("id", "adamw-glow")
.attr("x", "-50%").attr("y", "-50%")
.attr("width", "200%").attr("height", "200%");
glowFilter.append("feGaussianBlur")
.attr("stdDeviation", "4")
.attr("result", "coloredBlur");
const feMerge = glowFilter.append("feMerge");
feMerge.append("feMergeNode").attr("in", "coloredBlur");
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
// Arrow marker
defs.append("marker")
.attr("id", "adamw-arrow")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 8)
.attr("refY", 0)
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", theme.edgeStroke);
defs.append("marker")
.attr("id", "adamw-arrow-highlight")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 8)
.attr("refY", 0)
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", theme.highlight);
// Node definitions
const nodes = [
{ id: "gradient", label: "Gradient", sublabel: "g = dL/dθ", x: 340, y: 60 },
{ id: "momentum", label: "Momentum", sublabel: "m = β₁m + (1-β₁)g", x: 180, y: 160 },
{ id: "velocity", label: "Adaptive LR", sublabel: "v = β₂v + (1-β₂)g²", x: 500, y: 160 },
{ id: "bias", label: "Bias Correction", sublabel: "m̂, v̂", x: 340, y: 260 },
{ id: "update", label: "Weight Update", sublabel: "θ = θ - lr·(...)", x: 340, y: 360 }
];
// Edge definitions
const edges = [
{ from: "gradient", to: "momentum" },
{ from: "gradient", to: "velocity" },
{ from: "momentum", to: "bias" },
{ from: "velocity", to: "bias" },
{ from: "bias", to: "update" }
];
// Determine which nodes/edges are active based on step
const activeNodes = adamwStepInfo.highlight;
const isNodeActive = (id) => activeNodes.includes(id);
const isEdgeActive = (from, to) => {
return activeNodes.includes(from) && activeNodes.includes(to);
};
// Draw edges
const edgesLayer = svg.append("g").attr("class", "edges");
edges.forEach(edge => {
const fromNode = nodes.find(n => n.id === edge.from);
const toNode = nodes.find(n => n.id === edge.to);
const active = isEdgeActive(edge.from, edge.to);
// Calculate shortened path
const dx = toNode.x - fromNode.x;
const dy = toNode.y - fromNode.y;
const len = Math.sqrt(dx*dx + dy*dy);
const startOffset = 30;
const endOffset = 35;
const x1 = fromNode.x + (dx/len) * startOffset;
const y1 = fromNode.y + (dy/len) * startOffset;
const x2 = toNode.x - (dx/len) * endOffset;
const y2 = toNode.y - (dy/len) * endOffset;
edgesLayer.append("path")
.attr("d", `M${x1},${y1} L${x2},${y2}`)
.attr("fill", "none")
.attr("stroke", active ? theme.highlight : theme.edgeStroke)
.attr("stroke-width", active ? 2.5 : 1.5)
.attr("marker-end", active ? "url(#adamw-arrow-highlight)" : "url(#adamw-arrow)")
.attr("opacity", active ? 1 : 0.5)
.style("filter", active ? "url(#adamw-glow)" : "none")
.style("transition", "all 0.3s ease");
});
// Draw nodes
const nodesLayer = svg.append("g").attr("class", "nodes");
nodes.forEach(node => {
const active = isNodeActive(node.id);
const nodeWidth = 140;
const nodeHeight = 54;
const g = nodesLayer.append("g")
.attr("transform", `translate(${node.x}, ${node.y})`);
// Node background
g.append("rect")
.attr("x", -nodeWidth/2)
.attr("y", -nodeHeight/2)
.attr("width", nodeWidth)
.attr("height", nodeHeight)
.attr("rx", 8)
.attr("ry", 8)
.attr("fill", active ? theme.highlight : theme.nodeFill)
.attr("stroke", active ? theme.highlight : theme.nodeStroke)
.attr("stroke-width", active ? 2 : 1.5)
.style("filter", active ? "url(#adamw-glow)" : "none")
.style("transition", "all 0.3s ease");
// Node label
g.append("text")
.attr("y", -8)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("fill", active ? theme.textOnHighlight : theme.nodeText)
.attr("font-size", "13px")
.attr("font-weight", "600")
.style("transition", "fill 0.3s ease")
.text(node.label);
// Node sublabel
g.append("text")
.attr("y", 12)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("fill", active ? theme.textOnHighlight : theme.nodeText)
.attr("font-size", "10px")
.attr("opacity", active ? 0.9 : 0.7)
.style("transition", "all 0.3s ease")
.text(node.sublabel);
});
return svg.node();
}
```
```{ojs}
//| echo: false
// Info panel showing current step details and numeric values
adamwInfoPanel = {
const theme = diagramTheme;
const comp = adamwComputation;
const info = adamwStepInfo;
const container = htl.html`<div style="
background: ${theme.bgSecondary};
border: 1px solid ${theme.nodeStroke};
border-radius: 8px;
padding: 16px 20px;
margin-top: 12px;
font-family: var(--pg-mono);
">
<div style="
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
">
<span style="
font-size: 14px;
font-weight: 600;
color: ${theme.highlight};
">Step ${adamwStep}: ${info.title}</span>
<span style="
font-size: 12px;
color: ${theme.nodeText};
opacity: 0.7;
">t = ${comp.t}</span>
</div>
<p style="
font-size: 12px;
color: ${theme.nodeText};
margin: 0 0 12px 0;
line-height: 1.5;
">${info.description}</p>
<div style="
background: ${theme.nodeFill};
border-radius: 6px;
padding: 12px 16px;
font-family: var(--pg-mono);
">
<div style="
font-size: 15px;
color: ${theme.accent};
font-weight: 500;
margin-bottom: 10px;
">${info.formula}</div>
${adamwStep === 0 ? htl.html`
<div style="font-size: 11px; color: ${theme.nodeText}; line-height: 1.8;">
<div><span style="opacity: 0.6;">gradient:</span> g = <span style="color: ${theme.highlight};">${comp.g.toFixed(3)}</span></div>
<div><span style="opacity: 0.6;">hyperparams:</span> β₁=${comp.beta1}, β₂=${comp.beta2}, lr=${comp.lr}, λ=${comp.lambda}</div>
</div>
` : ''}
${adamwStep === 1 ? htl.html`
<div style="font-size: 11px; color: ${theme.nodeText}; line-height: 1.8;">
<div>m = ${comp.beta1} × ${comp.m_prev.toFixed(3)} + ${(1-comp.beta1).toFixed(1)} × ${comp.g.toFixed(3)}</div>
<div>m = <span style="color: ${theme.highlight};">${comp.m.toFixed(4)}</span></div>
</div>
` : ''}
${adamwStep === 2 ? htl.html`
<div style="font-size: 11px; color: ${theme.nodeText}; line-height: 1.8;">
<div>v = ${comp.beta2} × ${comp.v_prev.toFixed(4)} + ${(1-comp.beta2).toFixed(3)} × ${comp.g.toFixed(3)}²</div>
<div>v = <span style="color: ${theme.highlight};">${comp.v.toFixed(6)}</span></div>
</div>
` : ''}
${adamwStep === 3 ? htl.html`
<div style="font-size: 11px; color: ${theme.nodeText}; line-height: 1.8;">
<div>m̂ = ${comp.m.toFixed(4)} / (1 - ${comp.beta1}^${comp.t}) = <span style="color: ${theme.highlight};">${comp.m_hat.toFixed(4)}</span></div>
<div>v̂ = ${comp.v.toFixed(6)} / (1 - ${comp.beta2}^${comp.t}) = <span style="color: ${theme.highlight};">${comp.v_hat.toFixed(6)}</span></div>
</div>
` : ''}
${adamwStep === 4 ? htl.html`
<div style="font-size: 11px; color: ${theme.nodeText}; line-height: 1.8;">
<div>adam = m̂/√v̂ = ${comp.m_hat.toFixed(4)} / √${comp.v_hat.toFixed(6)} = ${comp.adam_update.toFixed(4)}</div>
<div>decay = λ·θ = ${comp.lambda} × ${comp.theta_prev.toFixed(2)} = ${comp.weight_decay.toFixed(5)}</div>
<div>θ = ${comp.theta_prev.toFixed(4)} - ${comp.lr} × (${comp.adam_update.toFixed(4)} + ${comp.weight_decay.toFixed(5)})</div>
<div>θ = <span style="color: ${theme.highlight}; font-weight: 600;">${comp.theta.toFixed(6)}</span></div>
</div>
` : ''}
</div>
</div>`;
return container;
}
```
**Hyperparameters explained:**
| Parameter | Default | Purpose |
|-----------|---------|---------|
| beta1 | 0.9 | Momentum coefficient - smooths gradient direction |
| beta2 | 0.999 | Adaptive LR coefficient - smooths gradient magnitude |
| epsilon | 1e-8 | Numerical stability (prevents division by zero) |
| weight_decay | 0.01 | L2 regularization strength |
**Practical tip:** The LLM community has converged on beta1=0.9, beta2=0.95 for large models (used by LLaMA, GPT-3). The lower beta2 adapts faster to changing gradient magnitudes.
```{python}
# Creating an AdamW optimizer
model = nn.Linear(100, 10)
optimizer = torch.optim.AdamW(
model.parameters(),
lr=3e-4, # Learning rate
betas=(0.9, 0.999), # Momentum and adaptive LR
weight_decay=0.01 # Regularization
)
print("AdamW optimizer created")
print(f" Learning rate: {optimizer.param_groups[0]['lr']}")
print(f" Weight decay: {optimizer.param_groups[0]['weight_decay']}")
```
### Optimizers from Scratch
Let's build optimizers from first principles to understand what PyTorch does internally.
#### Plain SGD
The simplest optimizer: move parameters in the opposite direction of the gradient.
```{python}
class SGD_Scratch:
"""
Stochastic Gradient Descent.
Update rule: theta = theta - lr * gradient
"""
def __init__(self, params, lr=0.01):
self.params = list(params)
self.lr = lr
def step(self):
with torch.no_grad():
for p in self.params:
if p.grad is not None:
p -= self.lr * p.grad
def zero_grad(self):
for p in self.params:
if p.grad is not None:
p.grad = None
# Test: compare with PyTorch SGD
torch.manual_seed(42)
model_scratch = nn.Linear(10, 2)
model_pytorch = nn.Linear(10, 2)
model_pytorch.load_state_dict(model_scratch.state_dict())
opt_scratch = SGD_Scratch(model_scratch.parameters(), lr=0.1)
opt_pytorch = torch.optim.SGD(model_pytorch.parameters(), lr=0.1)
# Forward + backward
x = torch.randn(4, 10)
loss_scratch = model_scratch(x).sum()
loss_pytorch = model_pytorch(x).sum()
loss_scratch.backward()
loss_pytorch.backward()
# Update
opt_scratch.step()
opt_pytorch.step()
# Compare weights
print("After one SGD step:")
print(f" Scratch weight[0,0]: {model_scratch.weight[0,0].item():.6f}")
print(f" PyTorch weight[0,0]: {model_pytorch.weight[0,0].item():.6f}")
print(f" Match: {torch.allclose(model_scratch.weight, model_pytorch.weight)}")
```
#### SGD with Momentum
Momentum adds "velocity" to gradient descent. Instead of using the gradient directly, we accumulate a moving average of gradients:
$$v_t = \mu \cdot v_{t-1} + g_t$$
$$\theta_t = \theta_{t-1} - \alpha \cdot v_t$$
This helps:
- Smooth out noisy gradients
- Accelerate through flat regions
- Dampen oscillations in steep valleys
```{python}
class SGD_Momentum_Scratch:
"""
SGD with momentum.
Update rule:
v = momentum * v + gradient
theta = theta - lr * v
"""
def __init__(self, params, lr=0.01, momentum=0.9):
self.params = list(params)
self.lr = lr
self.momentum = momentum
# Velocity buffer for each parameter
self.v = [torch.zeros_like(p) for p in self.params]
def step(self):
with torch.no_grad():
for i, p in enumerate(self.params):
if p.grad is None:
continue
# Update velocity: v = momentum * v + grad
self.v[i] = self.momentum * self.v[i] + p.grad
# Update parameter
p -= self.lr * self.v[i]
def zero_grad(self):
for p in self.params:
if p.grad is not None:
p.grad = None
# Test: compare with PyTorch SGD momentum
torch.manual_seed(42)
model_scratch = nn.Linear(10, 2)
model_pytorch = nn.Linear(10, 2)
model_pytorch.load_state_dict(model_scratch.state_dict())
opt_scratch = SGD_Momentum_Scratch(model_scratch.parameters(), lr=0.1, momentum=0.9)
opt_pytorch = torch.optim.SGD(model_pytorch.parameters(), lr=0.1, momentum=0.9)
# Multiple steps to see momentum accumulate
for step in range(3):
x = torch.randn(4, 10)
loss_scratch = model_scratch(x).sum()
loss_pytorch = model_pytorch(x).sum()
opt_scratch.zero_grad()
opt_pytorch.zero_grad()
loss_scratch.backward()
loss_pytorch.backward()
opt_scratch.step()
opt_pytorch.step()
print("After 3 momentum SGD steps:")
print(f" Scratch weight[0,0]: {model_scratch.weight[0,0].item():.6f}")
print(f" PyTorch weight[0,0]: {model_pytorch.weight[0,0].item():.6f}")
print(f" Match: {torch.allclose(model_scratch.weight, model_pytorch.weight)}")
```
::: {.callout-note}
## Key Insight: Momentum
Momentum is like pushing a ball down a hill - it builds up speed in consistent directions and resists sudden direction changes. This makes optimization faster and more stable.
:::
#### Adam from Scratch
Adam combines momentum with adaptive learning rates. It tracks two quantities:
1. **First moment** $m$ (mean of gradients) - like momentum
2. **Second moment** $v$ (mean of squared gradients) - adapts learning rate per-parameter
$$m_t = \beta_1 \cdot m_{t-1} + (1 - \beta_1) \cdot g_t$$
$$v_t = \beta_2 \cdot v_{t-1} + (1 - \beta_2) \cdot g_t^2$$
We also need **bias correction** because $m$ and $v$ are initialized to zero:
$$\hat{m}_t = \frac{m_t}{1 - \beta_1^t}, \quad \hat{v}_t = \frac{v_t}{1 - \beta_2^t}$$
Finally, the update:
$$\theta_t = \theta_{t-1} - \alpha \cdot \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon}$$
```{python}
class Adam_Scratch:
"""
Adam optimizer with optional weight decay (AdamW style).
Tracks first moment (mean) and second moment (variance) of gradients.
Uses bias correction to fix initialization bias.
"""
def __init__(self, params, lr=1e-3, betas=(0.9, 0.999), eps=1e-8, weight_decay=0.0):
self.params = list(params)
self.lr = lr
self.b1, self.b2 = betas
self.eps = eps
self.weight_decay = weight_decay
# First moment (mean of gradients)
self.m = [torch.zeros_like(p) for p in self.params]
# Second moment (mean of squared gradients)
self.v = [torch.zeros_like(p) for p in self.params]
# Timestep
self.t = 0
def step(self):
self.t += 1
with torch.no_grad():
for i, p in enumerate(self.params):
if p.grad is None:
continue
g = p.grad
# AdamW: Weight decay applied directly to weights (decoupled)
if self.weight_decay != 0.0:
p -= self.lr * self.weight_decay * p
# Update first moment: m = beta1 * m + (1 - beta1) * g
self.m[i] = self.b1 * self.m[i] + (1 - self.b1) * g
# Update second moment: v = beta2 * v + (1 - beta2) * g^2
self.v[i] = self.b2 * self.v[i] + (1 - self.b2) * (g * g)
# Bias correction (crucial early in training!)
mhat = self.m[i] / (1 - self.b1 ** self.t)
vhat = self.v[i] / (1 - self.b2 ** self.t)
# Update parameters
p -= self.lr * mhat / (torch.sqrt(vhat) + self.eps)
def zero_grad(self):
for p in self.params:
if p.grad is not None:
p.grad = None
# Test: compare with PyTorch AdamW
torch.manual_seed(42)
model_scratch = nn.Linear(10, 2)
model_pytorch = nn.Linear(10, 2)
model_pytorch.load_state_dict(model_scratch.state_dict())
opt_scratch = Adam_Scratch(model_scratch.parameters(), lr=1e-3, weight_decay=0.01)
opt_pytorch = torch.optim.AdamW(model_pytorch.parameters(), lr=1e-3, weight_decay=0.01)
# Multiple steps
for step in range(5):
x = torch.randn(4, 10)
loss_scratch = model_scratch(x).sum()
loss_pytorch = model_pytorch(x).sum()
opt_scratch.zero_grad()
opt_pytorch.zero_grad()
loss_scratch.backward()
loss_pytorch.backward()
opt_scratch.step()
opt_pytorch.step()
print("After 5 AdamW steps:")
print(f" Scratch weight[0,0]: {model_scratch.weight[0,0].item():.6f}")
print(f" PyTorch weight[0,0]: {model_pytorch.weight[0,0].item():.6f}")
print(f" Close match: {torch.allclose(model_scratch.weight, model_pytorch.weight, atol=1e-6)}")
```
::: {.callout-note}
## Key Insight: Adam
Adam is "momentum + per-parameter learning rates." The second moment $v$ tracks how much each parameter's gradient varies. Parameters with consistently large gradients get smaller effective learning rates (stabilizing training), while those with small gradients get larger rates (speeding up learning).
:::
**Why bias correction matters:**
Without bias correction, the first few steps are biased toward zero because $m$ and $v$ are initialized to zero. Let's see this:
```{python}
# Demonstrate bias correction importance
m, v = 0.0, 0.0
b1, b2 = 0.9, 0.999
true_grad = 1.0 # Pretend gradient is always 1
print("Step | m (biased) | m_hat (corrected)")
print("-" * 45)
for t in range(1, 6):
m = b1 * m + (1 - b1) * true_grad
m_hat = m / (1 - b1 ** t)
print(f" {t} | {m:.4f} | {m_hat:.4f}")
print(f"\nWithout correction, m starts near 0.1 instead of 1.0!")
print(f"Bias correction fixes this, making m_hat ≈ 1.0 from the start.")
```
#### From Inline Sketch to a Tested Module
The classes above are teaching sketches. The production versions — `SGD`, `Adam`,
and `AdamW`, each with a shared `Optimizer` base and full type hints — live in
`optimizers.py`. They are checked **bit-for-bit** against `torch.optim`: the test
suite trains a twin `nn.Linear` under each hand-written optimizer and the matching
PyTorch one and asserts the weights agree to floating-point rounding. When we say
"this *is* what PyTorch does," it's a tested claim, not a slogan.
```{python}
from optimizers import SGD, Adam, AdamW
# Our AdamW vs torch.optim.AdamW, five steps, same init.
torch.manual_seed(0)
a = nn.Linear(10, 3)
b = nn.Linear(10, 3)
b.load_state_dict(a.state_dict())
ours = AdamW(a.parameters(), lr=1e-2, weight_decay=0.1)
theirs = torch.optim.AdamW(b.parameters(), lr=1e-2, weight_decay=0.1)
for _ in range(5):
x = torch.randn(5, 10)
ours.zero_grad(); theirs.zero_grad()
a(x).pow(2).sum().backward()
b(x).pow(2).sum().backward()
ours.step(); theirs.step()
print(f"max |ours - torch|: {(a.weight - b.weight).abs().max().item():.2e}")
print(f"Match: {torch.allclose(a.weight, b.weight, atol=1e-6)}")
```
#### Adam vs AdamW: Why Decoupling Matters
Earlier we said Adam applies weight decay "to the gradient" and AdamW "to the
weights." That one-word difference in *placement* is the whole reason AdamW exists.
Both start from the same coefficient $\lambda$:
$$
\underbrace{g_t \leftarrow g_t + \lambda\,\theta_{t-1}}_{\textbf{Adam (coupled L2)}}
\qquad\text{vs.}\qquad
\underbrace{\theta_{t-1} \leftarrow \theta_{t-1} - \alpha\lambda\,\theta_{t-1}}_{\textbf{AdamW (decoupled)}}
$$
In **AdamW** the decay is a plain shrink: every weight loses the same fraction
$\alpha\lambda$ of its magnitude each step, no matter what its gradient is doing.
In **Adam** the decay is folded *into the gradient*, so it rides through the rest of
the update — including the adaptive denominator $\frac{1}{\sqrt{\hat v_t}+\epsilon}$.
A weight whose gradients are large (big $\hat v$) gets its decay **divided down**;
a weight with tiny gradients gets its decay through at nearly full strength. The
regularization you *asked for* silently becomes a different, per-parameter amount.
Run the two with identical `lr` and `weight_decay` and they simply disagree:
```{python}
from optimizers import Adam, AdamW
torch.manual_seed(1)
a = nn.Linear(8, 4)
b = nn.Linear(8, 4)
b.load_state_dict(a.state_dict())
coupled = Adam(a.parameters(), lr=1e-2, weight_decay=0.3) # classic Adam L2
decoupled = AdamW(b.parameters(), lr=1e-2, weight_decay=0.3) # AdamW
for _ in range(25):
x = torch.randn(6, 8)
coupled.zero_grad(); decoupled.zero_grad()
a(x).pow(2).sum().backward()
b(x).pow(2).sum().backward()
coupled.step(); decoupled.step()
print(f"Same lr, same weight_decay, 25 steps:")
print(f" max |Adam(L2) - AdamW| weight gap: {(a.weight - b.weight).abs().max().item():.4f}")
print(f" identical? {torch.allclose(a.weight, b.weight, atol=1e-4)}")
```
::: {.callout-note}
## Key Insight: Where the Decay Lands
Set `weight_decay=0` and Adam and AdamW are *bit-identical* — there is nothing to
decouple. Turn decay on and they part ways, because coupled L2 passes through the
$1/\sqrt{\hat v}$ denominator and decoupled decay does not. The plot below makes the
damping literal: AdamW's effective decay is flat across parameters; Adam's falls off
as gradients grow.
:::
The next cell bridges `effective_decay` from `optimizers.py` — the steady-state
decay each scheme actually applies to a weight whose gradient has a given magnitude.
```{python}
#| echo: false
#| output: false
from optimizers import effective_decay
# Sweep gradient magnitude across three decades; report effective decay per unit weight.
import math as _math
_grad_var = [ (10 ** (e / 4.0)) ** 2 for e in range(-4, 13) ] # std from 0.1 to ~1000
_base = effective_decay(_grad_var, lr=1e-3, weight_decay=0.1)
decayGrid = [
{"std": s, "coupled": c, "decoupled": d}
for s, c, d in zip(_base["grad_std"], _base["coupled"], _base["decoupled"])
]
ojs_define(decayGrid = decayGrid, decayBaseLambda = 0.1, decayLr = 1e-3)
```
```{ojs}
//| echo: false
viewof optDecayLambda = Inputs.range([0.0, 0.5], {value: 0.1, step: 0.01, label: "Weight decay λ"})
```
```{ojs}
//| echo: false
optDecayChart = {
const width = 720, height = 380, m = {top: 30, right: 130, bottom: 54, left: 70};
const theme = diagramTheme;
const scale = optDecayLambda / decayBaseLambda; // effective decay is linear in λ
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const data = decayGrid.map(d => ({
std: d.std, coupled: d.coupled * scale, decoupled: d.decoupled * scale
}));
const x = d3.scaleLog().domain(d3.extent(data, d => d.std)).range([m.left, width - m.right]);
const yMax = d3.max(data, d => Math.max(d.coupled, d.decoupled)) * 1.05 || 1;
const y = d3.scaleLinear().domain([0, yMax]).range([height - m.bottom, m.top]);
const xAxis = d3.axisBottom(x).ticks(5, "~g");
const yAxis = d3.axisLeft(y).ticks(5, ".0e");
svg.append("g").attr("transform", `translate(0,${height - m.bottom})`).call(xAxis)
.call(g => g.selectAll("text").attr("fill", theme.nodeText))
.call(g => g.selectAll("line,path").attr("stroke", theme.edgeStroke));
svg.append("g").attr("transform", `translate(${m.left},0)`).call(yAxis)
.call(g => g.selectAll("text").attr("fill", theme.nodeText))
.call(g => g.selectAll("line,path").attr("stroke", theme.edgeStroke));
svg.append("text").attr("x", (m.left + width - m.right) / 2).attr("y", height - 14)
.attr("text-anchor", "middle").attr("fill", theme.nodeText).attr("font-size", 13)
.text("gradient magnitude √v̂ (log scale)");
svg.append("text").attr("transform", "rotate(-90)").attr("x", -(height / 2)).attr("y", 18)
.attr("text-anchor", "middle").attr("fill", theme.nodeText).attr("font-size", 13)
.text("effective decay per step");
const line = key => d3.line().x(d => x(d.std)).y(d => y(d[key]));
// AdamW: flat (decoupled)
svg.append("path").datum(data).attr("fill", "none")
.attr("stroke", theme.success).attr("stroke-width", 3).attr("d", line("decoupled"));
// Adam: falls off as 1/std (coupled)
svg.append("path").datum(data).attr("fill", "none")
.attr("stroke", theme.highlight).attr("stroke-width", 3).attr("d", line("coupled"));
const legend = [
{label: "AdamW (decoupled)", color: theme.success},
{label: "Adam (coupled L2)", color: theme.highlight}
];
legend.forEach((L, i) => {
const gy = m.top + 8 + i * 24;
svg.append("line").attr("x1", width - m.right + 8).attr("x2", width - m.right + 30)
.attr("y1", gy).attr("y2", gy).attr("stroke", L.color).attr("stroke-width", 3);
svg.append("text").attr("x", width - m.right + 36).attr("y", gy + 4)
.attr("fill", theme.nodeText).attr("font-size", 11).text(L.label);
});
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Push λ up:** both lines rise, but only the orange (coupled) line stays *bent* —
parameters with large gradients keep getting under-decayed relative to what you set.
2. **λ → 0:** the two lines collapse onto the axis together. No decay, nothing to
decouple — exactly why our `Adam` and `AdamW` are bit-identical at `weight_decay=0`.
:::
#### The Optimizer Zoo, Side by Side
Weight decay aside, why reach for Adam at all? The clearest picture is a *path* on a
hard surface. `demonstrate_optimizers` in `optimizers.py` runs SGD, SGD+momentum, and
Adam on an **ill-conditioned bowl** $f(x,y)=\tfrac12(25x^2+y^2)$ — 25× steeper along
$x$ than $y$ — and records where each one goes.
```{python}
#| echo: false
#| output: false
from optimizers import demonstrate_optimizers
optTrajectories = demonstrate_optimizers(init=(-6.0, -9.0), steps=50, curvature=25.0)
ojs_define(optTrajectories = optTrajectories, optCurvature = 25.0)
```
```{ojs}
//| echo: false
viewof optZooStep = stepControl({min: 0, max: 50, value: 50, label: "Optimization step"})
```
```{ojs}
//| echo: false
optZooChart = {
const width = 720, height = 460, m = {top: 24, right: 130, bottom: 48, left: 56};
const theme = diagramTheme;
const c = optCurvature;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const runs = [
{name: "SGD", color: theme.info},
{name: "Momentum", color: theme.success},
{name: "Adam", color: theme.highlight}
];
// Data ranges from all paths.
const allPts = runs.flatMap(r => optTrajectories[r.name].path);
const xExt = d3.extent(allPts, p => p[0]);
const yExt = d3.extent(allPts, p => p[1]);
const pad = 0.6;
const x = d3.scaleLinear().domain([xExt[0] - pad, xExt[1] + pad]).range([m.left, width - m.right]);
const y = d3.scaleLinear().domain([yExt[0] - pad, yExt[1] + pad]).range([height - m.bottom, m.top]);
// Contour ellipses of the bowl: 0.5*(c*x^2 + y^2) = L.
const levels = [2, 10, 40, 120, 300];
levels.forEach(L => {
const ax = Math.sqrt(2 * L / c); // x half-width
const ay = Math.sqrt(2 * L); // y half-width
svg.append("ellipse")
.attr("cx", x(0)).attr("cy", y(0))
.attr("rx", Math.abs(x(ax) - x(0)))
.attr("ry", Math.abs(y(0) - y(ay)))
.attr("fill", "none").attr("stroke", theme.edgeStroke)
.attr("stroke-width", 1).attr("opacity", 0.5);
});
// Axes.
svg.append("g").attr("transform", `translate(0,${y(0)})`)
.call(d3.axisBottom(x).ticks(6))
.call(g => g.selectAll("text").attr("fill", theme.nodeText).attr("font-size", 10))
.call(g => g.selectAll("line,path").attr("stroke", theme.edgeStroke));
svg.append("g").attr("transform", `translate(${x(0)},0)`)
.call(d3.axisLeft(y).ticks(6))
.call(g => g.selectAll("text").attr("fill", theme.nodeText).attr("font-size", 10))
.call(g => g.selectAll("line,path").attr("stroke", theme.edgeStroke));
// The minimum.
svg.append("circle").attr("cx", x(0)).attr("cy", y(0)).attr("r", 4)
.attr("fill", theme.nodeText).attr("opacity", 0.7);
const k = optZooStep;
const line = d3.line().x(d => x(d[0])).y(d => y(d[1]));
runs.forEach((r, i) => {
const full = optTrajectories[r.name].path;
const seg = full.slice(0, k + 1);
svg.append("path").datum(seg).attr("fill", "none")
.attr("stroke", r.color).attr("stroke-width", 2.5).attr("opacity", 0.9)
.attr("d", line);
const head = seg[seg.length - 1];
svg.append("circle").attr("cx", x(head[0])).attr("cy", y(head[1])).attr("r", 5)
.attr("fill", r.color).attr("stroke", theme.bgOpaque).attr("stroke-width", 1.5);
const gy = m.top + 8 + i * 26;
svg.append("line").attr("x1", width - m.right + 8).attr("x2", width - m.right + 30)
.attr("y1", gy).attr("y2", gy).attr("stroke", r.color).attr("stroke-width", 3);
const loss = optTrajectories[r.name].loss[k];
svg.append("text").attr("x", width - m.right + 36).attr("y", gy + 4)
.attr("fill", theme.nodeText).attr("font-size", 11)
.text(`${r.name} (${loss.toFixed(2)})`);
});
svg.append("text").attr("x", width - m.right + 8).attr("y", height - m.bottom + 4)
.attr("fill", theme.nodeText).attr("font-size", 10).attr("opacity", 0.7)
.text("( ) = loss at step");
return svg.node();
}
```
Scrub the step and watch the shapes, not a winner:
- **SGD** must use a step small enough for the steep $x$ axis (here $\alpha=1/25$), so
it snaps to the valley floor almost immediately, then *crawls* along the shallow $y$
axis — the classic ill-conditioning crawl.
- **Momentum** builds velocity down the valley, overshoots the bottom, and curls back.
- **Adam** normalizes each axis by its own gradient history and marches at a roughly
constant per-coordinate pace — and, honestly, tends to *hover* near the minimum on a
clean quadratic rather than settle into it.
::: {.callout-note}
## Key Insight: Why AdamW Is the LLM Default
On this toy quadratic, momentum actually reaches the lowest loss — Adam is not magic.
Adam earns its place in *deep networks*, where different parameters see gradients that
differ by orders of magnitude (embeddings vs. layer norms vs. deep weights). Its
per-parameter scaling makes one global learning rate work across all of them with
little tuning, and AdamW adds the decoupled decay that keeps that scaling from
corrupting your regularization. Robustness at scale, not toy-problem speed, is the win.
:::
## Beyond AdamW: Orthogonalized Momentum (Muon)
We just crowned AdamW the LLM default — and it has held that crown for a decade.
The first optimizer to seriously challenge it doesn't add a cleverer per-weight
learning rate. It changes what a "weight" *is*.
Every optimizer so far treats a weight matrix as a **bag of independent scalars**.
Adam's `1/√v̂` rescales each scalar on its own. But a hidden weight $W$ is a
*matrix*, and matrices have structure that per-scalar rules are blind to:
directions (its singular vectors) along which it stretches a lot or a little. When
one direction dominates the gradient, per-scalar updates pour most of their energy
there and starve the rest — the update is **ill-conditioned**.
**Muon** (Keller Jordan, 2024; scaled to LLM pretraining by Moonshot's *Muon is
Scalable*, 2025) fixes this at the level of the whole matrix. Take the momentum
matrix $M$ and factor it with the SVD, $M = U\,\Sigma\,V^\top$. Muon throws away
the singular values $\Sigma$ — the very thing that makes the update lopsided — and
steps along
$$
O = U V^\top \qquad(\text{all singular values set to } 1).
$$
$O$ is the **nearest semi-orthogonal matrix** to $M$: same directions, but every
one advanced at the same rate. The catch is that an SVD every step, on every
weight, would be far too slow. The whole trick is computing $UV^\top$ *without* one.
### The Newton–Schulz iteration
Muon approximates $UV^\top$ with a fixed **matrix polynomial**, iterated about five
times. First normalize $M$ by its Frobenius norm (so its largest singular value is
$\le 1$), then repeat
$$
X \leftarrow a\,X + b\,(XX^\top)X + c\,(XX^\top)^2 X,
\qquad (a,b,c) = (3.4445,\ -4.7750,\ 2.0315).
$$
Because this iteration is odd in $X$ and never touches the singular *vectors*, it
acts on each singular value on its own through the scalar quintic
$p(\sigma) = a\sigma + b\sigma^3 + c\sigma^5$. Here is the subtle part, and it is
easy to get wrong: **this is not a convergent iteration.** Run it forever and the
singular values *oscillate*. The coefficients are tuned for a **fixed budget of
~5 steps** — the steep slope at zero ($a = 3.44 > 1$) yanks even tiny singular
values upward fast, and the higher-order terms bend the curve so it overshoots
just past $1$. After five steps every $\sigma \in (0,1]$ lands in a **loose band
around 1** (roughly $[0.7, 1.3]$). That is *approximate* orthogonalization — Muon
happily trades an exact $\sigma = 1$ for lifting the small directions quickly.
Drive the iteration below. Each line is one singular value of a real momentum
matrix; watch them march out of the decaying spectrum toward the shaded band at 1.
```{python}
#| echo: false
#| output: false
from muon import newton_schulz_trace, spectrum_demo, demonstrate_muon, quintic_map
import torch as _t
# (1) singular-value trajectories of a conditioned momentum matrix over NS steps.
_t.manual_seed(0)
_k = 8
_u, _ = _t.linalg.qr(_t.randn(_k, _k))
_v, _ = _t.linalg.qr(_t.randn(_k, _k))
_sig = _t.logspace(0.7, 0.0, _k) # condition ~5, a graded spectrum
_M = (_u * _sig) @ _v.T
_trace = newton_schulz_trace(_M, steps=5)
muonTrace = [{"step": i, "svals": row} for i, row in enumerate(_trace)]
# The scalar quintic map p(sigma), for reference.
muonMapCurve = [
{"x": j / 100.0, "p": float(quintic_map(_t.tensor(j / 100.0)))} for j in range(0, 141)
]
# (2) raw (Frobenius-normalized) vs orthogonalized spectrum of an ill-conditioned matrix.
_spec = spectrum_demo(shape=(32, 32), condition=40.0)
_raw = _t.tensor(_spec["raw"])
_rawnorm = (_raw / _raw.norm()).tolist()
muonSpectrum = [
{"i": i, "raw": r, "ortho": o}
for i, (r, o) in enumerate(zip(_rawnorm, _spec["orthogonalized"]))
]
# (4) honest loss curves: Muon vs AdamW vs plain momentum.
_demo = demonstrate_muon()
muonLoss = [
{
"step": s,
"Muon": _demo["Muon"]["loss"][s],
"AdamW": _demo["AdamW"]["loss"][s],
"Momentum": _demo["Momentum"]["loss"][s],
}
for s in range(len(_demo["Muon"]["loss"]))
]
ojs_define(
muonTrace=muonTrace,
muonMapCurve=muonMapCurve,
muonSpectrum=muonSpectrum,
muonLoss=muonLoss,
)
```
```{ojs}
//| echo: false
viewof muonNSStep = stepControl({min: 0, max: 5, value: 5, label: "Newton–Schulz step"})
```
```{ojs}
//| echo: false
muonNSChart = {
const theme = diagramTheme;
const width = 720, height = 380, m = {top: 28, right: 150, bottom: 48, left: 54};
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`).attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`).style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height).attr("fill", theme.bg).attr("rx", 12);
const x = d3.scaleLinear().domain([0, 5]).range([m.left, width - m.right]);
const y = d3.scaleLinear().domain([0, 1.4]).range([height - m.bottom, m.top]);
svg.append("g").attr("transform", `translate(0,${height - m.bottom})`).call(d3.axisBottom(x).ticks(5))
.call(g => g.selectAll("text").attr("fill", theme.nodeText))
.call(g => g.selectAll("line,path").attr("stroke", theme.edgeStroke));
svg.append("g").attr("transform", `translate(${m.left},0)`).call(d3.axisLeft(y).ticks(6))
.call(g => g.selectAll("text").attr("fill", theme.nodeText))
.call(g => g.selectAll("line,path").attr("stroke", theme.edgeStroke));
// The target band around 1.
svg.append("rect").attr("x", m.left).attr("width", width - m.right - m.left)
.attr("y", y(1.15)).attr("height", y(0.85) - y(1.15)).attr("fill", theme.success).attr("opacity", 0.12);
svg.append("line").attr("x1", m.left).attr("x2", width - m.right).attr("y1", y(1)).attr("y2", y(1))
.attr("stroke", theme.success).attr("stroke-dasharray", "4,4").attr("stroke-width", 1.5);
const K = muonTrace[0].svals.length;
const line = d3.line().x(d => x(d.step)).y(d => y(d.v));
for (let k = 0; k < K; k++) {
const series = muonTrace.map(t => ({step: t.step, v: t.svals[k]})).filter(d => d.step <= muonNSStep);
svg.append("path").datum(series).attr("fill", "none")
.attr("stroke", theme.accent).attr("stroke-width", 1.6).attr("opacity", 0.5).attr("d", line);
const last = series[series.length - 1];
svg.append("circle").attr("cx", x(last.step)).attr("cy", y(last.v)).attr("r", 3.6).attr("fill", theme.highlight);
}
svg.append("text").attr("x", (m.left + width - m.right) / 2).attr("y", height - 10)
.attr("text-anchor", "middle").attr("fill", theme.nodeText).attr("font-size", 13).text("Newton–Schulz step");
svg.append("text").attr("transform", "rotate(-90)").attr("x", -(height / 2)).attr("y", 16)
.attr("text-anchor", "middle").attr("fill", theme.nodeText).attr("font-size", 13).text("singular value");
const cur = muonTrace[muonNSStep].svals;
const rows = [["step", muonNSStep], ["min σ", Math.min(...cur).toFixed(3)],
["max σ", Math.max(...cur).toFixed(3)], ["spread", (Math.max(...cur) - Math.min(...cur)).toFixed(3)]];
rows.forEach((r, i) => {
svg.append("text").attr("x", width - m.right + 14).attr("y", m.top + 12 + i * 20)
.attr("fill", theme.nodeText).attr("font-size", 12).text(`${r[0]}: ${r[1]}`);
});
return svg.node();
}
```
::: {.callout-tip}
## Try This
Drag the step to **0**: the singular values fan out — a decaying spectrum, the
ill-conditioned update. Now step to **5**: they collapse into the green band at 1.
Notice the *smallest* value climbs the most and the largest barely moves — the
iteration lifts starved directions without touching dominant ones. That is the
entire point: **equalize every direction of the update.**
:::
### Orthogonalization from scratch
`muon.py` builds two versions: `newton_schulz` (the iteration above, matmuls only)
and `orthogonalize` (the exact $UV^\top$ via `torch.linalg.svd`) that we keep only
as ground truth. Muon uses the first *because* it avoids the second.
```{python}
from muon import newton_schulz, orthogonalize, singular_values
import torch
torch.manual_seed(0)
# An ill-conditioned momentum matrix: singular values span 40 : 1.
u, _ = torch.linalg.qr(torch.randn(6, 6))
v, _ = torch.linalg.qr(torch.randn(6, 6))
M = (u * torch.logspace(torch.log10(torch.tensor(40.0)), 0.0, 6)) @ v.T
raw = singular_values(M)
ns = singular_values(newton_schulz(M))
exact = singular_values(orthogonalize(M))
print("raw σ: ", [f"{s:5.2f}" for s in raw])
print("Newton–Schulz σ:", [f"{s:5.2f}" for s in ns], " <- loose band around 1")
print("exact UVᵀ σ:", [f"{s:5.2f}" for s in exact], " <- all exactly 1")
# Same direction as the exact polar factor, computed with matmuls alone:
cos = torch.sum(newton_schulz(M) * orthogonalize(M)) / (newton_schulz(M).norm() * orthogonalize(M).norm())
print(f"\ncos(Newton–Schulz, exact UVᵀ) = {float(cos):.4f}")
# Scale-invariant: orthogonalizing 100·M gives the same matrix.
print("scale-invariant:", torch.allclose(newton_schulz(M), newton_schulz(100 * M), atol=1e-5))
```
The next chart makes the whitening visible on a 32×32 matrix whose singular values
span 40 : 1. Orange bars are the singular values of the (normalized) momentum
matrix — a steep decay. Blue bars are the same matrix after Newton–Schulz: the
whole tail is lifted up to ~1.
```{ojs}
//| echo: false
muonSpectrumChart = {
const theme = diagramTheme;
const width = 720, height = 360, m = {top: 30, right: 130, bottom: 50, left: 56};
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`).attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`).style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height).attr("fill", theme.bg).attr("rx", 12);
const x = d3.scaleBand().domain(muonSpectrum.map(d => d.i)).range([m.left, width - m.right]).padding(0.2);
const xin = d3.scaleBand().domain(["raw", "ortho"]).range([0, x.bandwidth()]).padding(0.08);
const y = d3.scaleLinear().domain([0, 1.25]).range([height - m.bottom, m.top]);
svg.append("g").attr("transform", `translate(0,${height - m.bottom})`)
.call(d3.axisBottom(x).tickValues(x.domain().filter(i => i % 4 === 0)))
.call(g => g.selectAll("text").attr("fill", theme.nodeText))
.call(g => g.selectAll("line,path").attr("stroke", theme.edgeStroke));
svg.append("g").attr("transform", `translate(${m.left},0)`).call(d3.axisLeft(y).ticks(6))
.call(g => g.selectAll("text").attr("fill", theme.nodeText))
.call(g => g.selectAll("line,path").attr("stroke", theme.edgeStroke));
svg.append("line").attr("x1", m.left).attr("x2", width - m.right).attr("y1", y(1)).attr("y2", y(1))
.attr("stroke", theme.success).attr("stroke-dasharray", "4,4").attr("stroke-width", 1.5);
muonSpectrum.forEach(d => {
svg.append("rect").attr("x", x(d.i) + xin("raw")).attr("width", xin.bandwidth())
.attr("y", y(d.raw)).attr("height", y(0) - y(d.raw)).attr("fill", theme.highlight).attr("opacity", 0.85);
svg.append("rect").attr("x", x(d.i) + xin("ortho")).attr("width", xin.bandwidth())
.attr("y", y(d.ortho)).attr("height", y(0) - y(d.ortho)).attr("fill", theme.accent).attr("opacity", 0.85);
});
svg.append("text").attr("x", (m.left + width - m.right) / 2).attr("y", height - 12)
.attr("text-anchor", "middle").attr("fill", theme.nodeText).attr("font-size", 13).text("singular value index");
const legend = [{label: "raw momentum (normalized)", color: theme.highlight}, {label: "orthogonalized", color: theme.accent}];
legend.forEach((L, i) => {
const gy = m.top + 6 + i * 22;
svg.append("rect").attr("x", width - m.right + 8).attr("y", gy - 9).attr("width", 14).attr("height", 12).attr("fill", L.color).attr("opacity", 0.85);
svg.append("text").attr("x", width - m.right + 28).attr("y", gy + 1).attr("fill", theme.nodeText).attr("font-size", 11).text(L.label);
});
return svg.node();
}
```
### The full Muon step
The 2024 original stopped at orthogonalized momentum. Making Muon actually beat
AdamW at scale (Moonshot, 2025) took two small, crucial additions — a **scale** so
its updates are the same size as AdamW's, and **decoupled weight decay** (the exact
trick we built earlier). The whole step, for a weight of shape $A \times B$:
$$
\begin{aligned}
M_t &= \mu\, M_{t-1} + G_t & &\text{(heavy-ball momentum, } \mu = 0.95)\\
O_t &= \text{NewtonSchulz}_5\!\left(G_t + \mu M_t\right) & &\text{(Nesterov look-ahead, then orthogonalize)}\\
W_t &= W_{t-1} - \eta_t\left(\;0.2\,\sqrt{\max(A,B)}\; O_t \;+\; \lambda\, W_{t-1}\right) & &\text{(RMS-matched step + decoupled decay)}
\end{aligned}
$$
Why the $0.2\sqrt{\max(A,B)}$ factor? An orthogonal $O$ has per-entry RMS
$\approx 1/\sqrt{\max(A,B)}$; multiplying by it brings the update's RMS to
$\approx 0.2$, the typical size of an AdamW update — so the same learning rate you
tuned for AdamW transfers straight over. Trace the pipeline:
```{ojs}
//| echo: false
viewof muonStepStage = stepControl({min: 0, max: 4, value: 0, label: "Muon step stage"})
```
```{ojs}
//| echo: false
muonPipeline = {
const theme = diagramTheme;
const width = 760, height = 220, m = {left: 16, top: 60};
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`).attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`).style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height).attr("fill", theme.bg).attr("rx", 12);
const stages = [
{t: "Gₜ", s: "gradient"},
{t: "Mₜ = μMₜ₋₁ + Gₜ", s: "momentum (μ=0.95)"},
{t: "Oₜ = NS₅(·)", s: "orthogonalize"},
{t: "0.2√max(A,B)·Oₜ", s: "RMS-match scale"},
{t: "Wₜ = Wₜ₋₁ − η(· + λWₜ₋₁)", s: "step + decay"},
];
const boxW = 138, boxH = 66, gap = (width - m.left * 2 - boxW * stages.length) / (stages.length - 1);
stages.forEach((st, i) => {
const bx = m.left + i * (boxW + gap);
const active = i === muonStepStage, done = i < muonStepStage;
svg.append("rect").attr("x", bx).attr("y", m.top).attr("width", boxW).attr("height", boxH).attr("rx", 9)
.attr("fill", active ? theme.highlight : (done ? theme.accent : theme.nodeFill))
.attr("opacity", active ? 1 : (done ? 0.5 : 1))
.attr("stroke", active ? theme.highlight : theme.nodeStroke).attr("stroke-width", active ? 2.5 : 1);
svg.append("text").attr("x", bx + boxW / 2).attr("y", m.top + 28).attr("text-anchor", "middle")
.attr("fill", active ? theme.textOnHighlight : theme.nodeText).attr("font-size", 12).attr("font-weight", 600).text(st.t);
svg.append("text").attr("x", bx + boxW / 2).attr("y", m.top + 48).attr("text-anchor", "middle")
.attr("fill", active ? theme.textOnHighlight : theme.nodeText).attr("font-size", 10).attr("opacity", 0.85).text(st.s);
if (i < stages.length - 1) {
const ax = bx + boxW, ay = m.top + boxH / 2;
svg.append("line").attr("x1", ax + 2).attr("x2", ax + gap - 2).attr("y1", ay).attr("y2", ay)
.attr("stroke", theme.edgeStroke).attr("stroke-width", 1.5).attr("marker-end", "url(#muonArrow)");
}
});
const defs = svg.append("defs");
defs.append("marker").attr("id", "muonArrow").attr("viewBox", "0 0 10 10").attr("refX", 8).attr("refY", 5)
.attr("markerWidth", 6).attr("markerHeight", 6).attr("orient", "auto-start-reverse")
.append("path").attr("d", "M 0 0 L 10 5 L 0 10 z").attr("fill", theme.edgeStroke);
svg.append("text").attr("x", width / 2).attr("y", 30).attr("text-anchor", "middle")
.attr("fill", theme.nodeText).attr("font-size", 13).text(stages[muonStepStage].s.toUpperCase());
return svg.node();
}
```
In code, Muon updates only 2D weight matrices; embeddings, the LM head, biases,
and norm scales keep AdamW. `route_parameters` does the split, and the optimizer
itself is a few lines on top of `newton_schulz`:
```{python}
from muon import Muon, route_parameters
import torch
# Split a model's parameters the way both Muon papers do.
params = [
("blocks.0.attn.qkv.weight", torch.zeros(96, 32)), # 2D hidden weight -> Muon
("blocks.0.mlp.fc.weight", torch.zeros(128, 32)), # 2D hidden weight -> Muon
("wte.weight", torch.zeros(1000, 32)), # embedding -> AdamW
("blocks.0.norm.weight", torch.zeros(32)), # norm scale (1D) -> AdamW
]
groups = route_parameters(params)
print("Muon (matrices):", len(groups["muon"]), " AdamW (rest):", len(groups["adamw"]))
# One Muon step, decoupled decay only (zero gradient) shrinks W by exactly lr·λ:
w = torch.ones(4, 4, requires_grad=True)
opt = Muon([w], lr=0.1, weight_decay=0.5)
w.grad = torch.zeros_like(w)
opt.step()
print("after decay-only step:", float(w[0, 0].detach()), "== 1·(1 − 0.1·0.5) = 0.95")
```
### Does it actually help?
`demonstrate_muon` runs Muon, AdamW, and plain momentum on the *same*
mildly-ill-conditioned regression, each at a sensible learning rate for itself.
```{ojs}
//| echo: false
muonLossChart = {
const theme = diagramTheme;
const width = 720, height = 380, m = {top: 24, right: 120, bottom: 48, left: 62};
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`).attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`).style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height).attr("fill", theme.bg).attr("rx", 12);
const series = ["Muon", "AdamW", "Momentum"];
const colors = {Muon: theme.highlight, AdamW: theme.accent, Momentum: theme.edgeStroke};
const x = d3.scaleLinear().domain([0, d3.max(muonLoss, d => d.step)]).range([m.left, width - m.right]);
const lo = d3.min(muonLoss, d => Math.min(d.Muon, d.AdamW, d.Momentum));
const y = d3.scaleLog().domain([Math.max(lo, 1e-4), 1.05]).range([height - m.bottom, m.top]).clamp(true);
svg.append("g").attr("transform", `translate(0,${height - m.bottom})`).call(d3.axisBottom(x).ticks(6))
.call(g => g.selectAll("text").attr("fill", theme.nodeText))
.call(g => g.selectAll("line,path").attr("stroke", theme.edgeStroke));
svg.append("g").attr("transform", `translate(${m.left},0)`).call(d3.axisLeft(y).ticks(5, "~g"))
.call(g => g.selectAll("text").attr("fill", theme.nodeText))
.call(g => g.selectAll("line,path").attr("stroke", theme.edgeStroke));
series.forEach(name => {
const line = d3.line().x(d => x(d.step)).y(d => y(Math.max(d[name], 1e-4)));
svg.append("path").datum(muonLoss).attr("fill", "none").attr("stroke", colors[name])
.attr("stroke-width", 2.6).attr("d", line);
const last = muonLoss[muonLoss.length - 1];
svg.append("text").attr("x", width - m.right + 8).attr("y", y(Math.max(last[name], 1e-4)) + 4)
.attr("fill", colors[name]).attr("font-size", 12).attr("font-weight", 600).text(name);
});
svg.append("text").attr("x", (m.left + width - m.right) / 2).attr("y", height - 10)
.attr("text-anchor", "middle").attr("fill", theme.nodeText).attr("font-size", 13).text("optimization step");
svg.append("text").attr("transform", "rotate(-90)").attr("x", -(height / 2)).attr("y", 16)
.attr("text-anchor", "middle").attr("fill", theme.nodeText).attr("font-size", 13).text("normalized loss (log)");
return svg.node();
}
```
Muon's single orthogonalized update leaves plain momentum far behind (~0.03 vs
~0.31 final loss) and lands right in AdamW's ballpark — with **no second-moment
state at all**. It does not *beat* well-tuned AdamW here, and that is the honest
result: on a small convex problem, per-coordinate adaptation is very hard to
top, exactly as we said for Adam itself. Muon's real prize shows up where the loss
surface is genuinely deep and non-convex — full LLM pretraining — where Moonlight
reached AdamW-level quality with **~2× less compute**.
::: {.callout-note}
## Key Insight
AdamW conditions the update **per scalar**; Muon conditions it **per matrix**. By
replacing the momentum matrix with its nearest orthogonal matrix — every singular
value 1 — Muon advances all directions of a weight equally, without ever storing a
second moment. The Newton–Schulz iteration makes that orthogonalization a handful
of matmuls instead of an SVD, and the $0.2\sqrt{\max(A,B)}$ scale lets it borrow
AdamW's learning rate wholesale.
:::
::: {.callout-warning}
## Common Pitfalls with Muon
- **It's not a convergent iteration.** Newton–Schulz is tuned for ~5 steps and
*approximately* orthogonalizes (singular values ~1, not exactly 1). Iterating it
to "convergence" makes things worse, not better.
- **Matrices only.** Applying Muon to embeddings, the LM head, biases, or norm
scales hurts — those go to AdamW. Muon is for 2D hidden weights.
- **The scale and decay are not optional.** The bare 2024 update (no
$0.2\sqrt{\max(A,B)}$, no weight decay) does *not* challenge AdamW at scale; both
additions are what made Muon competitive in the 2025 results.
- **fp32 here, bf16 in production.** We orthogonalize in float32 for clarity; real
Muon runs Newton–Schulz in bfloat16, which is where its speed comes from.
:::
**Going deeper on Muon:** Keller Jordan, [*Muon: An optimizer for hidden layers of
neural networks*](https://kellerjordan.github.io/posts/muon/) (2024) — the
original, with the Newton–Schulz coefficients and the derivation of why
orthogonalized updates help. Liu et al. (Moonshot AI), [*Muon is Scalable for LLM
Training*](https://arxiv.org/abs/2502.16982) (2025) — the RMS-match scale,
decoupled weight decay, and the ~2× compute-efficiency result at 3B/16B scale.
## Muon at Scale: QK-Clip and MuonClip
We just said Muon's real prize is full LLM pretraining. There is a catch that
kept "Muon at scale" a *promise* rather than a recipe until 2025: Muon's larger,
orthogonalized updates **inflate attention logits faster than AdamW does**, and at
frontier scale that inflation runs away into divergence. The fix that made
trillion-parameter Muon real is **QK-Clip**, and Muon + QK-Clip together are the
**MuonClip** optimizer behind Moonshot AI's *Kimi K2* — a 1-trillion-parameter
Mixture-of-Experts model (32B active) pretrained on 15.5T tokens with **zero loss
spikes**.
### Intuition: the logit Muon inflates
You have already met this failure mode. In [Module 5's QK-Norm
section](../m05_attention/lesson.qmd#qk-norm-bounding-the-attention-logits) the
pre-softmax logit $q \cdot k / \sqrt{d_k}$ grew as the query/key activation norms
drifted upward during training, the softmax sharpened toward one-hot, the gradient
through it vanished, and the loss diverged — *attention entropy collapse*. There it
was framed as an architectural problem and cured by **normalizing the
activations**. Here it is the *same disease with an optimizer-shaped cause*: Muon's
whitened update is bigger than AdamW's per-coordinate step, so it pushes $\lVert q
\rVert$ and $\lVert k \rVert$ up faster, and the logits blow up sooner.
QK-Clip takes the opposite tack to QK-Norm. It changes **nothing** in the forward
pass. Instead it watches the logits and, whenever a head's maximum logit crosses a
cap $\tau$, reaches into that head's **weights** after the optimizer step and
shrinks them just enough to pull the logit back under $\tau$. It is a *controller*,
not a layer: once training settles and the logits stop crossing $\tau$, it stops
acting entirely.
::: {.callout-note}
## Two cures, one disease
**QK-Norm** (m05) normalizes $q, k$ *every forward pass* so the logit is a bounded
cosine $\in [-g, g]$ — always on, and it changes the model. **QK-Clip** (here)
leaves the model alone and clips the *weights* after each step — a post-hoc, per-head,
self-deactivating fix. Same explosion; one works in activation space, the other in
weight space.
:::
### The Math: watch, then clip
After each optimizer step, for each head $h$ measure the largest pre-softmax logit
the softmax will see over the batch $\mathcal{B}$ — defined, like every logit in
this book, *after* the $1/\sqrt{d_k}$ scaling:
$$
S_{\max}^{h} \;=\; \frac{1}{\sqrt{d_k}}\;\max_{X \in \mathcal{B}}\;\max_{i,\,j}\;
q_i^{h}\cdot k_j^{h}.
$$
Form a per-head clip factor against a hard cap $\tau$, then rescale that head's
query and key projections by its square root:
$$
\gamma_h \;=\; \min\!\left(1,\ \frac{\tau}{S_{\max}^{h}}\right),
\qquad
W_q^{h}\leftarrow \sqrt{\gamma_h}\,W_q^{h},
\quad
W_k^{h}\leftarrow \sqrt{\gamma_h}\,W_k^{h}.
$$
The square root is the whole trick. The logit is **bilinear** in $(q, k)$: scaling
$W_q^{h}$ by $\sqrt{\gamma_h}$ scales $q^h$ by $\sqrt{\gamma_h}$, scaling $W_k^{h}$
by $\sqrt{\gamma_h}$ scales $k^h$ by $\sqrt{\gamma_h}$, so the logit $q^h\cdot k^h$
scales by exactly $\gamma_h$. A hot head's max logit lands at precisely $\tau$; a
cool head ($S_{\max}^h \le \tau$) gets $\gamma_h = 1$ and is left untouched. Kimi K2
used $\tau = 100$.
Walk the four moves one at a time — measure, compare, clip, verify:
```{ojs}
//| echo: false
viewof qkClipStep = stepControl({min: 0, max: 4, value: 0, label: "Step"})
```
```{ojs}
//| echo: false
qkClipSteps = [
{title: "Measure", caption: "After the Muon step, take each head's max pre-softmax logit over the batch."},
{title: "Compare to τ", caption: "Heads under the cap τ are fine; heads above it are inflating toward divergence."},
{title: "Clip factor", caption: "γₕ = min(1, τ / Sₘₐₓ) — exactly 1 for a cool head, below 1 for a hot one."},
{title: "√γ on the weights", caption: "Scale Wq and Wk for that head by √γₕ. Only the hot heads move."},
{title: "Capped logit", caption: "The logit is bilinear, so it scales by γₕ — the hot head lands exactly on τ."},
]
```
```{ojs}
//| echo: false
qkClipDiagram = {
const theme = diagramTheme;
const width = 720, height = 400, m = {top: 40, right: 28, bottom: 54, left: 58};
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`).attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`).style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height).attr("fill", theme.bg).attr("rx", 12);
const tau = 100;
const heads = [{name: "h0", raw: 140}, {name: "h1", raw: 60}, {name: "h2", raw: 210}];
const step = qkClipStep;
heads.forEach(d => {
d.gamma = Math.min(1, tau / d.raw);
// value shown per step: raw until step 4, then the capped value
d.shown = step >= 4 ? d.raw * d.gamma : d.raw;
d.hot = d.raw > tau;
});
const x = d3.scaleBand().domain(heads.map(d => d.name)).range([m.left, width - m.right]).padding(0.4);
const y = d3.scaleLinear().domain([0, 230]).range([height - m.bottom, m.top]);
// y axis + gridlines
svg.append("g").attr("transform", `translate(${m.left},0)`).call(d3.axisLeft(y).ticks(5))
.call(g => g.selectAll("text").attr("fill", theme.nodeText))
.call(g => g.selectAll("line,path").attr("stroke", theme.edgeStroke));
svg.append("text").attr("transform", "rotate(-90)").attr("x", -(height / 2)).attr("y", 16)
.attr("text-anchor", "middle").attr("fill", theme.nodeText).attr("font-size", 12).text("max attention logit");
// the τ cap line — emphasized from step 1 on
const tauActive = step >= 1;
svg.append("line").attr("x1", m.left).attr("x2", width - m.right).attr("y1", y(tau)).attr("y2", y(tau))
.attr("stroke", tauActive ? theme.highlight : theme.edgeStroke)
.attr("stroke-width", tauActive ? 2.4 : 1.4).attr("stroke-dasharray", "6 4");
svg.append("text").attr("x", width - m.right).attr("y", y(tau) - 6).attr("text-anchor", "end")
.attr("fill", tauActive ? theme.highlight : theme.nodeText).attr("font-size", 12).attr("font-weight", 600)
.text(`τ = ${tau}`);
heads.forEach(d => {
const hot = d.hot;
// colour: hot heads flagged from step 1; from step 4 the clipped ones read "success"
let fill = theme.nodeFill;
if (step >= 1 && hot) fill = step >= 4 ? theme.success : theme.error;
svg.append("rect").attr("x", x(d.name)).attr("width", x.bandwidth())
.attr("y", y(d.shown)).attr("height", y(0) - y(d.shown))
.attr("fill", fill).attr("rx", 4)
.attr("stroke", hot && step >= 1 ? theme.highlight : theme.nodeStroke)
.attr("stroke-width", hot && step >= 1 ? 2 : 1);
// value label
svg.append("text").attr("x", x(d.name) + x.bandwidth() / 2).attr("y", y(d.shown) - 8)
.attr("text-anchor", "middle").attr("fill", theme.nodeText).attr("font-size", 12)
.text(Math.round(d.shown));
// head name
svg.append("text").attr("x", x(d.name) + x.bandwidth() / 2).attr("y", height - m.bottom + 18)
.attr("text-anchor", "middle").attr("fill", theme.nodeText).attr("font-size", 12).text(d.name);
// γ label at step 2/3
if (step === 2 || step === 3) {
svg.append("text").attr("x", x(d.name) + x.bandwidth() / 2).attr("y", height - m.bottom + 36)
.attr("text-anchor", "middle").attr("fill", hot ? theme.highlight : theme.nodeText)
.attr("font-size", 11).text(`γ=${d.gamma.toFixed(2)}`);
}
});
// step caption
svg.append("text").attr("x", m.left).attr("y", 22).attr("fill", theme.highlight)
.attr("font-size", 13).attr("font-weight", 700).text(qkClipSteps[step].title);
svg.append("text").attr("x", m.left).attr("y", height - 8).attr("fill", theme.nodeText)
.attr("font-size", 11.5).text(qkClipSteps[step].caption);
return svg.node();
}
```
### Code: QK-Clip from scratch
The from-scratch implementation lives in `qk_clip.py`. It is deliberately small —
four functions and a wrapper — because QK-Clip *is* small. Start with the two
measurements: project inputs to per-head queries and keys, then take the per-head
max logit.
```{python}
import torch
from qk_clip import (
project_qk, head_max_logit, qk_clip_factors,
apply_qk_clip, clip_attention_layer, MuonClip, qk_clip_logit_growth,
)
torch.manual_seed(0)
d_model, num_heads, d = 32, 4, 8 # head-major weights: (H*d, d_model)
x = torch.randn(4, 6, d_model) # a probe batch (B, L, d_model)
w_q = torch.randn(num_heads * d, d_model) * 0.9
w_k = torch.randn(num_heads * d, d_model) * 0.9
q, k = project_qk(x, w_q, w_k, num_heads)
print("per-head max logits:", [round(v, 2) for v in head_max_logit(q, k).tolist()])
```
Now the clip itself. `qk_clip_factors` turns those max logits into per-head factors
against a cap $\tau$, and `apply_qk_clip` rescales each head's Q/K rows by
$\sqrt{\gamma_h}$ in place:
```{python}
tau = 90.0
before = head_max_logit(*project_qk(x, w_q, w_k, num_heads))
factors = qk_clip_factors(before, tau)
print("γ per head:", [round(v, 3) for v in factors.tolist()])
apply_qk_clip(w_q, w_k, factors, num_heads)
after = head_max_logit(*project_qk(x, w_q, w_k, num_heads))
print("before:", [round(v, 2) for v in before.tolist()])
print("after: ", [round(v, 2) for v in after.tolist()])
print(f"max logit now ≤ τ={tau}? ", bool((after <= tau + 1e-4).all()))
```
Every head that was over $\tau$ now sits at exactly $\tau$; every head that was
already under it is unchanged. `clip_attention_layer` bundles the three moves
(measure → factor → apply) into one call, and `MuonClip` wraps a `Muon` optimizer
so each `.step()` runs the optimizer and *then* clips the registered attention
layers:
```{python}
torch.manual_seed(1)
w_q = torch.randn(num_heads * d, d_model, requires_grad=True)
w_k = torch.randn(num_heads * d, d_model, requires_grad=True)
from muon import Muon
opt = MuonClip(Muon([w_q, w_k], lr=0.0), tau=3.0) # lr=0 isolates the clip phase
opt.register_attention(w_q, w_k, num_heads=num_heads, probe=x)
with torch.no_grad(): # force the layer hot
w_q *= 2.5; w_k *= 2.5
w_q.grad = torch.zeros_like(w_q); w_k.grad = torch.zeros_like(w_k)
hot = head_max_logit(*project_qk(x, w_q, w_k, num_heads)).max()
opt.step()
cool = head_max_logit(*project_qk(x, w_q, w_k, num_heads)).max()
print(f"max logit {hot:.1f} → {cool:.1f} after one MuonClip step (τ=3.0)")
```
::: {.callout-note}
## Key Insight
QK-Clip is a feedback controller on the attention logits. It never enters the
forward pass — it reads $S_{\max}^h$, and when a head crosses $\tau$ it multiplies
that head's Q and K weights by $\sqrt{\gamma_h}$ so the *bilinear* logit scales by
$\gamma_h$ and lands on $\tau$. Cool heads see $\gamma_h = 1$, so as training
stabilizes and the logits stop crossing $\tau$, QK-Clip quietly turns itself off.
:::
The real payoff is what happens across many inflating steps. `qk_clip_logit_growth`
inflates a Q/K pair each step (a stand-in for repeated Muon updates) and traces the
max logit with and without the clip:
```{python}
#| output: false
rows = qk_clip_logit_growth(steps=14, tau=8.0, inflation=1.30, seed=0)
ojs_define(
qkGrowthSteps = [r["step"] for r in rows],
qkGrowthUncapped = [r["uncapped"] for r in rows],
qkGrowthClipped = [r["clipped"] for r in rows],
qkGrowthTau = 8.0,
)
```
```{ojs}
//| echo: false
qkGrowthChart = {
const theme = diagramTheme;
const width = 720, height = 360, m = {top: 24, right: 120, bottom: 48, left: 62};
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`).attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`).style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height).attr("fill", theme.bg).attr("rx", 12);
const data = qkGrowthSteps.map((s, i) => ({step: s, uncapped: qkGrowthUncapped[i], clipped: qkGrowthClipped[i]}));
const x = d3.scaleLinear().domain(d3.extent(data, d => d.step)).range([m.left, width - m.right]);
const y = d3.scaleLog().domain([Math.max(1, d3.min(data, d => d.clipped) * 0.8), d3.max(data, d => d.uncapped) * 1.1])
.range([height - m.bottom, m.top]).clamp(true);
svg.append("g").attr("transform", `translate(0,${height - m.bottom})`).call(d3.axisBottom(x).ticks(7))
.call(g => g.selectAll("text").attr("fill", theme.nodeText))
.call(g => g.selectAll("line,path").attr("stroke", theme.edgeStroke));
svg.append("g").attr("transform", `translate(${m.left},0)`).call(d3.axisLeft(y).ticks(5, "~g"))
.call(g => g.selectAll("text").attr("fill", theme.nodeText))
.call(g => g.selectAll("line,path").attr("stroke", theme.edgeStroke));
// τ line
svg.append("line").attr("x1", m.left).attr("x2", width - m.right).attr("y1", y(qkGrowthTau)).attr("y2", y(qkGrowthTau))
.attr("stroke", theme.edgeStroke).attr("stroke-width", 1.4).attr("stroke-dasharray", "6 4");
svg.append("text").attr("x", m.left + 6).attr("y", y(qkGrowthTau) - 6)
.attr("fill", theme.nodeText).attr("font-size", 11).text(`τ = ${qkGrowthTau}`);
const series = [
{name: "uncapped", key: "uncapped", color: theme.error},
{name: "QK-Clip", key: "clipped", color: theme.success},
];
series.forEach(s => {
const line = d3.line().x(d => x(d.step)).y(d => y(Math.max(d[s.key], 1)));
svg.append("path").datum(data).attr("fill", "none").attr("stroke", s.color).attr("stroke-width", 2.6).attr("d", line);
const last = data[data.length - 1];
svg.append("text").attr("x", width - m.right + 8).attr("y", y(Math.max(last[s.key], 1)) + 4)
.attr("fill", s.color).attr("font-size", 12).attr("font-weight", 600).text(s.name);
});
svg.append("text").attr("x", (m.left + width - m.right) / 2).attr("y", height - 10)
.attr("text-anchor", "middle").attr("fill", theme.nodeText).attr("font-size", 13).text("inflating step");
svg.append("text").attr("transform", "rotate(-90)").attr("x", -(height / 2)).attr("y", 16)
.attr("text-anchor", "middle").attr("fill", theme.nodeText).attr("font-size", 13).text("max logit (log)");
return svg.node();
}
```
The uncapped logit climbs a straight line on the log axis — geometric blow-up,
exactly the runaway that ends a large Muon run. The QK-Clipped copy rises until it
touches $\tau$, then rides it: every step still inflates the weights, and every step
QK-Clip pulls them back.
### Interactive Exploration
Drive the cap $\tau$ and the per-step inflation yourself. Watch where the clipped
curve pins, and how a *tighter* cap or a *hotter* optimizer changes when — and how
often — QK-Clip has to fire.
```{ojs}
//| echo: false
viewof qkClipTau = Inputs.range([2, 60], {value: 12, step: 1, label: "cap τ"})
```
```{ojs}
//| echo: false
viewof qkClipInflation = Inputs.range([1.05, 1.6], {value: 1.28, step: 0.01, label: "per-step inflation"})
```
```{ojs}
//| echo: false
qkClipLiveChart = {
const theme = diagramTheme;
const width = 720, height = 360, m = {top: 24, right: 128, bottom: 48, left: 62};
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`).attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`).style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height).attr("fill", theme.bg).attr("rx", 12);
// Faithful reduced model of qk_clip_logit_growth: the logit is bilinear, so one
// inflating step multiplies it by inflation². The clipped copy is capped to τ.
const steps = 18, base = 3.0, tau = qkClipTau, g = qkClipInflation * qkClipInflation;
const data = [];
let uncapped = base, clipped = base;
for (let t = 0; t <= steps; t++) {
const fired = clipped * g > tau && t > 0;
if (t > 0) { uncapped *= g; clipped = Math.min(clipped * g, tau); }
data.push({step: t, uncapped, clipped, fired});
}
const x = d3.scaleLinear().domain([0, steps]).range([m.left, width - m.right]);
const y = d3.scaleLog().domain([base * 0.8, d3.max(data, d => d.uncapped) * 1.1]).range([height - m.bottom, m.top]).clamp(true);
svg.append("g").attr("transform", `translate(0,${height - m.bottom})`).call(d3.axisBottom(x).ticks(9))
.call(g => g.selectAll("text").attr("fill", theme.nodeText))
.call(g => g.selectAll("line,path").attr("stroke", theme.edgeStroke));
svg.append("g").attr("transform", `translate(${m.left},0)`).call(d3.axisLeft(y).ticks(5, "~g"))
.call(g => g.selectAll("text").attr("fill", theme.nodeText))
.call(g => g.selectAll("line,path").attr("stroke", theme.edgeStroke));
svg.append("line").attr("x1", m.left).attr("x2", width - m.right).attr("y1", y(tau)).attr("y2", y(tau))
.attr("stroke", theme.highlight).attr("stroke-width", 2).attr("stroke-dasharray", "6 4");
svg.append("text").attr("x", m.left + 6).attr("y", y(tau) - 6)
.attr("fill", theme.highlight).attr("font-size", 11).attr("font-weight", 600).text(`τ = ${tau}`);
[{key: "uncapped", color: theme.error, name: "uncapped"},
{key: "clipped", color: theme.success, name: "QK-Clip"}].forEach(s => {
const line = d3.line().x(d => x(d.step)).y(d => y(Math.max(d[s.key], base * 0.8)));
svg.append("path").datum(data).attr("fill", "none").attr("stroke", s.color).attr("stroke-width", 2.6).attr("d", line);
const last = data[data.length - 1];
svg.append("text").attr("x", width - m.right + 8).attr("y", y(Math.max(last[s.key], base * 0.8)) + 4)
.attr("fill", s.color).attr("font-size", 12).attr("font-weight", 600).text(s.name);
});
// clip-event ticks along the τ line
data.filter(d => d.fired).forEach(d => {
svg.append("circle").attr("cx", x(d.step)).attr("cy", y(tau)).attr("r", 3.2)
.attr("fill", theme.highlight);
});
const nFired = data.filter(d => d.fired).length;
svg.append("text").attr("x", (m.left + width - m.right) / 2).attr("y", height - 10)
.attr("text-anchor", "middle").attr("fill", theme.nodeText).attr("font-size", 12)
.text(`inflating step · QK-Clip fired on ${nFired} of ${steps} steps`);
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Lower the cap.** Drag $\tau$ down. The clipped curve pins lower and the clip
fires on more steps — a tighter cap is a busier controller.
2. **Heat up the optimizer.** Raise the inflation. The uncapped curve steepens and
diverges sooner; QK-Clip still pins the clipped curve at $\tau$.
3. **Find the quiet regime.** Lower inflation until the clip stops firing entirely
(no dots). That is the self-deactivated state real runs settle into — the logits
never cross $\tau$, so QK-Clip is a no-op.
:::
::: {.callout-note}
## In Kimi K2: MLA, decoupled RoPE, and MoE
Kimi K2 does not use vanilla multi-head attention — it uses **MLA** (Module 9) with
**decoupled RoPE** (Module 10). The clean $\sqrt{\gamma}$ split we built is the
standard-MHA rule; Kimi K2 splits it by component: the non-RoPE content projections
$q^{C}, k^{C}$ are scaled by $\sqrt{\gamma_h}$, the *per-head* rotary query $q^{R}$
takes the **full** $\gamma_h$, and the **shared** rotary key $k^{R}$ is left
untouched — clipping a shared key would perturb every other head. QK-Clip's role in
"MuonClip" is exactly this: it is what let Muon's ~2× token efficiency scale from
speedruns and a 16B MoE all the way to a 1T-parameter MoE with no loss spikes.
:::
::: {.callout-warning}
## Common Pitfalls with QK-Clip
- **Clip *after* the optimizer step, not before.** QK-Clip corrects what the step
just did; running it first measures stale logits and clips nothing useful.
- **Only the hot heads move.** $\gamma_h = \min(1, \tau/S_{\max}^h)$ is 1 for a cool
head — clipping every head uniformly would needlessly shrink healthy attention.
- **$\tau$ too low throttles capacity.** The cap is a safety rail, not a target. Set
it well above the logits a healthy run produces (Kimi K2 used $\tau = 100$); too
tight and you cap attention the model actually needs.
- **It's a controller, not a loss term.** QK-Clip has no gradient and adds nothing to
the objective — it edits weights between steps. Don't try to backprop through it.
- **The MHA rule is not the MLA rule.** The $\sqrt{\gamma}$-on-both-projections split
is for standard attention; MLA with decoupled RoPE splits it by component (see the
aside above).
:::
**Going deeper on MuonClip:** Kimi Team (Moonshot AI), [*Kimi K2: Open Agentic
Intelligence*](https://arxiv.org/abs/2507.20534) (2025) — the MuonClip optimizer,
the QK-Clip rule and threshold, and the zero-loss-spike 1T-parameter pretraining
run.
## Gradient Accumulation
Gradient accumulation increases effective batch size without adding memory.
**Problem**: Want batch_size=32 but only 8 fits in memory
**Solution**: Accumulate gradients over 4 mini-batches
```{ojs}
//| echo: false
// Step slider for gradient accumulation (0 = initial, 1-4 = mini-batches, 5 = optimizer step)
viewof accumStep = Inputs.range([0, 5], {
value: 0,
step: 1,
label: "Accumulation Step"
})
```
```{ojs}
//| echo: false
// Gradient accumulation diagram data
accumStepInfo = {
const steps = [
{ name: "Ready", description: "Gradients zeroed, ready to accumulate", gradientLevel: 0 },
{ name: "Mini-batch 1", description: "loss.backward() - gradients start accumulating", gradientLevel: 0.25 },
{ name: "Mini-batch 2", description: "loss.backward() - gradients continue accumulating", gradientLevel: 0.5 },
{ name: "Mini-batch 3", description: "loss.backward() - gradients continue accumulating", gradientLevel: 0.75 },
{ name: "Mini-batch 4", description: "loss.backward() - gradients fully accumulated", gradientLevel: 1.0 },
{ name: "Optimizer Step", description: "optimizer.step() - one weight update with effective batch_size=32", gradientLevel: 0 }
];
return steps[accumStep];
}
```
```{ojs}
//| echo: false
// Interactive gradient accumulation visualization
{
const width = 700;
const height = 340;
const batchSize = 8;
const accumSteps = 4;
const effectiveBatch = batchSize * accumSteps;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`);
// Background
svg.append("rect")
.attr("width", width)
.attr("height", height)
.attr("fill", diagramTheme.bg)
.attr("rx", 8);
// Defs for arrows and gradients
const defs = svg.append("defs");
// Arrow markers
defs.append("marker")
.attr("id", "accum-arrow")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 8)
.attr("refY", 0)
.attr("markerWidth", 5)
.attr("markerHeight", 5)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", diagramTheme.edgeStroke);
defs.append("marker")
.attr("id", "accum-arrow-active")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 8)
.attr("refY", 0)
.attr("markerWidth", 5)
.attr("markerHeight", 5)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", diagramTheme.highlight);
// Gradient fill for the accumulator bar
const gradientFill = defs.append("linearGradient")
.attr("id", "gradient-fill")
.attr("x1", "0%")
.attr("y1", "100%")
.attr("x2", "0%")
.attr("y2", "0%");
gradientFill.append("stop")
.attr("offset", "0%")
.attr("stop-color", diagramTheme.accent);
gradientFill.append("stop")
.attr("offset", "100%")
.attr("stop-color", diagramTheme.highlight);
// Layout constants
const batchBoxWidth = 100;
const batchBoxHeight = 55;
const batchStartX = 60;
const batchSpacing = 20;
const batchY = 80;
const accumX = 480;
const accumY = 80;
const accumWidth = 70;
const accumHeight = 140;
const optimizerX = 620;
const optimizerY = 150;
// Draw mini-batch boxes
const batches = [1, 2, 3, 4];
batches.forEach((batch, i) => {
const x = batchStartX + i * (batchBoxWidth + batchSpacing);
const isActive = accumStep === batch;
const isProcessed = accumStep > batch;
const g = svg.append("g")
.attr("transform", `translate(${x}, ${batchY})`);
// Box
g.append("rect")
.attr("width", batchBoxWidth)
.attr("height", batchBoxHeight)
.attr("rx", 6)
.attr("fill", isActive ? diagramTheme.highlight : (isProcessed ? diagramTheme.accent : diagramTheme.nodeFill))
.attr("stroke", isActive ? diagramTheme.highlight : (isProcessed ? diagramTheme.accent : diagramTheme.nodeStroke))
.attr("stroke-width", isActive ? 2.5 : 1.5)
.attr("opacity", isProcessed && !isActive ? 0.7 : 1)
.style("filter", isActive ? `drop-shadow(0 0 8px ${diagramTheme.highlightGlow})` : "none");
// Batch label
g.append("text")
.attr("x", batchBoxWidth / 2)
.attr("y", 18)
.attr("text-anchor", "middle")
.attr("fill", isActive || isProcessed ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", "11px")
.attr("font-weight", "600")
.text(`Mini-batch ${batch}`);
// Size info
g.append("text")
.attr("x", batchBoxWidth / 2)
.attr("y", 34)
.attr("text-anchor", "middle")
.attr("fill", isActive || isProcessed ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", "10px")
.attr("opacity", 0.8)
.text(`size=${batchSize}`);
// backward() call
g.append("text")
.attr("x", batchBoxWidth / 2)
.attr("y", 48)
.attr("text-anchor", "middle")
.attr("fill", isActive || isProcessed ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", "9px")
.attr("font-family", "var(--pg-mono)")
.attr("opacity", 0.7)
.text("loss.backward()");
// Arrow from batch to accumulator
if (accumStep >= batch && accumStep <= 4) {
const arrowActive = isActive;
const startX = x + batchBoxWidth;
const startY = batchY + batchBoxHeight / 2;
const endX = accumX - 5;
const endY = accumY + 30 + i * 25;
// Curved path
const midX = (startX + endX) / 2 + 20;
svg.append("path")
.attr("d", `M${startX + 5},${startY} Q${midX},${startY} ${endX},${endY}`)
.attr("fill", "none")
.attr("stroke", arrowActive ? diagramTheme.highlight : diagramTheme.edgeStroke)
.attr("stroke-width", arrowActive ? 2 : 1.5)
.attr("marker-end", arrowActive ? "url(#accum-arrow-active)" : "url(#accum-arrow)")
.attr("opacity", isProcessed && !arrowActive ? 0.5 : (arrowActive ? 1 : 0.7))
.style("filter", arrowActive ? `drop-shadow(0 0 3px ${diagramTheme.highlightGlow})` : "none");
}
});
// Draw accumulator container
const accumG = svg.append("g")
.attr("transform", `translate(${accumX}, ${accumY})`);
// Accumulator background
accumG.append("rect")
.attr("width", accumWidth)
.attr("height", accumHeight)
.attr("rx", 8)
.attr("fill", diagramTheme.bgSecondary)
.attr("stroke", accumStep >= 1 && accumStep <= 4 ? diagramTheme.accent : diagramTheme.nodeStroke)
.attr("stroke-width", 2);
// Gradient level bar (fills from bottom)
const gradientLevel = accumStepInfo.gradientLevel;
const barPadding = 8;
const barWidth = accumWidth - barPadding * 2;
const barMaxHeight = accumHeight - barPadding * 2 - 20;
const barHeight = barMaxHeight * gradientLevel;
if (barHeight > 0) {
accumG.append("rect")
.attr("x", barPadding)
.attr("y", accumHeight - barPadding - barHeight)
.attr("width", barWidth)
.attr("height", barHeight)
.attr("rx", 4)
.attr("fill", "url(#gradient-fill)")
.attr("opacity", 0.9);
}
// Accumulator label
accumG.append("text")
.attr("x", accumWidth / 2)
.attr("y", 14)
.attr("text-anchor", "middle")
.attr("fill", diagramTheme.nodeText)
.attr("font-size", "10px")
.attr("font-weight", "600")
.text("Gradients");
// Percentage label
accumG.append("text")
.attr("x", accumWidth / 2)
.attr("y", accumHeight / 2 + 5)
.attr("text-anchor", "middle")
.attr("fill", gradientLevel > 0.3 ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", "14px")
.attr("font-weight", "700")
.text(`${Math.round(gradientLevel * 100)}%`);
// Arrow from accumulator to optimizer
const optimizerActive = accumStep === 5;
svg.append("path")
.attr("d", `M${accumX + accumWidth + 5},${accumY + accumHeight / 2} L${optimizerX - 50},${optimizerY}`)
.attr("fill", "none")
.attr("stroke", optimizerActive ? diagramTheme.highlight : diagramTheme.edgeStroke)
.attr("stroke-width", optimizerActive ? 2.5 : 1.5)
.attr("marker-end", optimizerActive ? "url(#accum-arrow-active)" : "url(#accum-arrow)")
.attr("opacity", accumStep < 5 ? 0.4 : 1)
.attr("stroke-dasharray", accumStep < 5 ? "5,3" : "none")
.style("filter", optimizerActive ? `drop-shadow(0 0 4px ${diagramTheme.highlightGlow})` : "none");
// Optimizer box
const optG = svg.append("g")
.attr("transform", `translate(${optimizerX - 45}, ${optimizerY - 30})`);
optG.append("rect")
.attr("width", 90)
.attr("height", 60)
.attr("rx", 6)
.attr("fill", optimizerActive ? diagramTheme.highlight : diagramTheme.nodeFill)
.attr("stroke", optimizerActive ? diagramTheme.highlight : diagramTheme.nodeStroke)
.attr("stroke-width", optimizerActive ? 2.5 : 1.5)
.style("filter", optimizerActive ? `drop-shadow(0 0 8px ${diagramTheme.highlightGlow})` : "none");
optG.append("text")
.attr("x", 45)
.attr("y", 22)
.attr("text-anchor", "middle")
.attr("fill", optimizerActive ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", "11px")
.attr("font-weight", "600")
.text("Optimizer");
optG.append("text")
.attr("x", 45)
.attr("y", 38)
.attr("text-anchor", "middle")
.attr("fill", optimizerActive ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", "9px")
.attr("font-family", "var(--pg-mono)")
.attr("opacity", 0.8)
.text("step()");
optG.append("text")
.attr("x", 45)
.attr("y", 52)
.attr("text-anchor", "middle")
.attr("fill", optimizerActive ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", "8px")
.attr("opacity", 0.7)
.text("1 update");
// Status info panel at bottom
const infoY = 250;
svg.append("rect")
.attr("x", 30)
.attr("y", infoY)
.attr("width", width - 60)
.attr("height", 70)
.attr("rx", 6)
.attr("fill", diagramTheme.bgSecondary)
.attr("stroke", diagramTheme.nodeStroke)
.attr("stroke-width", 1);
// Step name
svg.append("text")
.attr("x", 50)
.attr("y", infoY + 22)
.attr("fill", diagramTheme.highlight)
.attr("font-size", "13px")
.attr("font-weight", "700")
.text(`Step ${accumStep}: ${accumStepInfo.name}`);
// Description
svg.append("text")
.attr("x", 50)
.attr("y", infoY + 42)
.attr("fill", diagramTheme.nodeText)
.attr("font-size", "11px")
.text(accumStepInfo.description);
// Effective batch size calculation
svg.append("text")
.attr("x", 50)
.attr("y", infoY + 58)
.attr("fill", diagramTheme.accent)
.attr("font-size", "10px")
.attr("font-family", "var(--pg-mono)")
.text(`Effective batch size: ${batchSize} x ${accumSteps} = ${effectiveBatch}`);
return svg.node();
}
```
```{python}
# Demonstrate gradient accumulation
model = nn.Linear(10, 1)
accumulation_steps = 4
# Simulate accumulated gradients
total_loss = 0
for i in range(accumulation_steps):
x = torch.randn(8, 10) # Mini-batch
y = model(x)
loss = y.mean() / accumulation_steps # Scale loss!
loss.backward() # Gradients accumulate
total_loss += loss.item()
print(f"Accumulated loss (4 mini-batches): {total_loss:.4f}")
print(f"Gradient norm before step: {model.weight.grad.norm().item():.4f}")
# Now do one optimizer step
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
optimizer.step()
optimizer.zero_grad()
print("After optimizer.step() and zero_grad()")
```
## Gradient Clipping
Gradient clipping scales down gradients whose norm exceeds a threshold, preventing gradient explosion.
```{python}
# Demonstrate gradient clipping
model = nn.Linear(10, 10)
# Create artificial large gradients
for p in model.parameters():
p.grad = torch.randn_like(p) * 100 # Very large!
# Compute gradient norm before clipping
total_norm_before = 0
for p in model.parameters():
total_norm_before += p.grad.norm().item() ** 2
total_norm_before = total_norm_before ** 0.5
print(f"Gradient norm before clipping: {total_norm_before:.2f}")
# Clip gradients
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# Compute gradient norm after
total_norm_after = 0
for p in model.parameters():
total_norm_after += p.grad.norm().item() ** 2
total_norm_after = total_norm_after ** 0.5
print(f"Gradient norm after clipping: {total_norm_after:.2f}")
print(f"\nGradients scaled down by {total_norm_before / total_norm_after:.1f}x")
```
### Gradient Clipping from Scratch
Let's implement gradient clipping ourselves to understand the algorithm:
```{python}
def clip_grad_norm_scratch(params, max_norm: float) -> float:
"""
Clip gradients by global norm.
Algorithm:
1. Compute total norm: sqrt(sum of all grad^2)
2. If total_norm > max_norm, scale all grads by (max_norm / total_norm)
Returns the original norm (before clipping).
"""
params = list(params)
# Step 1: Compute total gradient norm
total_sq = 0.0
for p in params:
if p.grad is not None:
total_sq += (p.grad ** 2).sum().item()
total_norm = total_sq ** 0.5
# Step 2: Clip if needed
if total_norm > max_norm:
scale = max_norm / (total_norm + 1e-12) # Small epsilon for numerical stability
for p in params:
if p.grad is not None:
p.grad *= scale
return total_norm
# Test: compare with PyTorch
model_scratch = nn.Linear(10, 10)
model_pytorch = nn.Linear(10, 10)
# Set same large gradients
torch.manual_seed(42)
for p in model_scratch.parameters():
p.grad = torch.randn_like(p) * 100
for ps, pp in zip(model_scratch.parameters(), model_pytorch.parameters()):
pp.grad = ps.grad.clone()
# Clip with both
norm_scratch = clip_grad_norm_scratch(model_scratch.parameters(), max_norm=1.0)
norm_pytorch = torch.nn.utils.clip_grad_norm_(model_pytorch.parameters(), max_norm=1.0)
print(f"Original norm (scratch): {norm_scratch:.4f}")
print(f"Original norm (PyTorch): {norm_pytorch.item():.4f}")
# Check gradients match after clipping
grads_match = all(
torch.allclose(ps.grad, pp.grad)
for ps, pp in zip(model_scratch.parameters(), model_pytorch.parameters())
)
print(f"Gradients match after clipping: {grads_match}")
```
::: {.callout-note}
## Key Insight: Gradient Clipping
Gradient clipping scales ALL gradients by the same factor to preserve their relative magnitudes. This is different from clipping each gradient independently - we want to maintain the direction of the overall update while limiting its magnitude.
:::
**When to use gradient clipping:**
- Always for transformer training (standard practice)
- max_norm=1.0 is a good default
- Monitor gradient norms during training - consistently high norms suggest instability
## Batch Size Considerations
Batch size affects both training dynamics and memory usage:
**Tradeoffs:**
| Aspect | Small Batch | Large Batch |
|--------|-------------|-------------|
| Memory | Less | More |
| Gradient noise | More (regularization effect) | Less (stable gradients) |
| Convergence | May generalize better | Faster convergence |
| LR needed | Lower | Higher (linear scaling rule) |
**The Linear Scaling Rule:** When you double the batch size, you can double the learning rate. This maintains similar training dynamics.
**Effective batch size** = batch_size x gradient_accumulation_steps
```{python}
# Batch size vs memory example (conceptual)
print("Memory usage scales linearly with batch size:")
print()
for batch_size in [8, 16, 32, 64]:
# Simulated memory calculation
tokens_per_batch = batch_size * 512 # sequence length
memory_mb = batch_size * 50 # ~50MB per sample for a small model
print(f" Batch size {batch_size:2d}: ~{tokens_per_batch:,} tokens/batch, ~{memory_mb}MB")
```
## Mixed Precision & Numerics
Everything so far assumed a comfortable 32-bit float (`fp32`). But modern models
train in **16-bit** — and increasingly **8-bit** — because the arithmetic is
2–8× faster and every tensor is half the size (or less). The catch is that a
narrower float represents *fewer numbers*, and two failure modes follow:
- **Overflow** — a value too large for the format becomes `inf`, and any math
touching it turns to `NaN`. One `NaN` poisons the whole update.
- **Underflow** — a value too *small* rounds all the way to `0`, so a real
gradient silently vanishes and that weight never learns.
To see *exactly* where those walls are, we build the floating-point number line
from scratch in `precision.py` — a small IEEE-754-style **encoder** that rounds
any Python float into a chosen `(exponent, mantissa)` layout, the same rounding a
GPU does when it stores a number in 16 bits. It reproduces fp32, fp16, and bf16
exactly (the tests check it against `struct` and PyTorch).
### Anatomy of a float
A binary float spends its bits on two jobs: **exponent** bits buy *range* (how
big and how small), **mantissa** bits buy *precision* (how many significant
digits). A 16-bit budget forces a choice, and the two 16-bit formats split it
oppositely:
```{python}
from precision import FORMATS, format_spec
for name in ("fp32", "fp16", "bf16"):
s = format_spec(FORMATS[name])
print(f"{s['name']:5} exp={s['exp_bits']:>2} mantissa={s['mantissa_bits']:>2} "
f"max={s['max_normal']:.3e} min_normal={s['min_normal']:.3e} "
f"eps={s['eps']:.2e} (~{s['decimal_digits']:.1f} decimal digits)")
```
Read the two 16-bit rows against each other: **fp16** spends 10 bits on the
mantissa (fine precision) but only 5 on the exponent (a tiny range — it caps at
65504). **bf16** keeps fp32's full 8 exponent bits (so it barely ever overflows
or underflows) at the cost of just 7 mantissa bits (coarse precision). That one
trade is the whole story of mixed-precision training.
Step through some values and watch how each format stores them — where the bits
go, what value comes back, and when a value hits a wall:
```{python}
#| output: false
from precision import FORMATS, encode, round_to_format, rounding_error, overflows, underflows
def _bit_row(x, fmt):
import math as _m
bits = encode(x, fmt)
s = format(bits, f"0{fmt.total_bits}b")
r = round_to_format(x, fmt)
return {
"sign": s[0],
"exp": s[1:1 + fmt.exp_bits],
"mant": s[1 + fmt.exp_bits:],
"stored": r if _m.isfinite(r) else None, # null in JS; overflow flag drives display
"error": min(rounding_error(x, fmt), 9.99),
"overflow": overflows(x, fmt),
"underflow": underflows(x, fmt),
}
_demo = [0.1, 1.0, 3.1415927, 0.15625, 65504.0, 70000.0, 6.1e-5, 1e-8]
precisionData = [
{"value": x,
"fp16": _bit_row(x, FORMATS["fp16"]),
"bf16": _bit_row(x, FORMATS["bf16"])}
for x in _demo
]
ojs_define(precisionData = precisionData)
```
```{ojs}
//| echo: false
viewof precStep = stepControl({min: 0, max: 7, value: 5, label: "Value"})
```
```{ojs}
//| echo: false
precBitViz = {
const theme = diagramTheme;
const width = 720, height = 300;
const row = precisionData[precStep];
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%")
.attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
svg.append("text").attr("x", width / 2).attr("y", 34)
.attr("text-anchor", "middle").attr("fill", theme.nodeText)
.attr("font-size", 20).attr("font-weight", 700)
.text(`storing ${row.value}`);
const drawFormat = (label, fmt, y) => {
const boxW = 22, boxH = 30, gap = 3;
const bits = [
{b: fmt.sign, kind: "sign"},
...fmt.exp.split("").map(b => ({b, kind: "exp"})),
...fmt.mant.split("").map(b => ({b, kind: "mant"}))
];
const totalW = bits.length * (boxW + gap);
const x0 = (width - totalW) / 2;
const color = {sign: theme.edgeStroke, exp: theme.accent, mant: theme.highlight};
// Ink for a SET bit, picked per fill family (DESIGN_LANGUAGE §6): the sky
// accent & orange highlight stay mid-tone in both themes so they take dark
// text; the stone edge hue flips, so it takes the flipping textOnFill.
const onColor = {sign: theme.textOnFill, exp: theme.textOnAccent, mant: theme.textOnHighlight};
svg.append("text").attr("x", x0 - 12).attr("y", y + boxH / 2 + 5)
.attr("text-anchor", "end").attr("fill", theme.nodeText)
.attr("font-size", 14).attr("font-weight", 600).text(label);
bits.forEach((bit, i) => {
const x = x0 + i * (boxW + gap);
svg.append("rect").attr("x", x).attr("y", y).attr("width", boxW).attr("height", boxH)
.attr("rx", 3).attr("fill", bit.b === "1" ? color[bit.kind] : theme.bgSecondary)
.attr("stroke", theme.nodeStroke).attr("stroke-width", 1);
svg.append("text").attr("x", x + boxW / 2).attr("y", y + boxH / 2 + 5)
.attr("text-anchor", "middle")
.attr("fill", bit.b === "1" ? onColor[bit.kind] : theme.edgeStroke)
.attr("font-size", 13).text(bit.b);
});
let note, noteColor;
if (fmt.overflow) { note = "→ OVERFLOW (inf)"; noteColor = theme.error; }
else if (fmt.underflow) { note = "→ UNDERFLOW (0)"; noteColor = theme.error; }
else {
const err = fmt.error === 0 ? "exact" : `err ${(fmt.error * 100).toFixed(3)}%`;
note = `→ ${fmt.stored} (${err})`;
noteColor = fmt.error === 0 ? theme.success : theme.nodeText;
}
svg.append("text").attr("x", x0 + totalW + 14).attr("y", y + boxH / 2 + 5)
.attr("fill", noteColor).attr("font-size", 12).text(note);
};
drawFormat("fp16", row.fp16, 90);
drawFormat("bf16", row.bf16, 170);
// legend
const leg = [["sign", theme.edgeStroke], ["exponent (range)", theme.accent], ["mantissa (precision)", theme.highlight]];
let lx = (width - 360) / 2;
leg.forEach(([t, c]) => {
svg.append("rect").attr("x", lx).attr("y", 244).attr("width", 14).attr("height", 14).attr("rx", 2).attr("fill", c);
svg.append("text").attr("x", lx + 20).attr("y", 255).attr("fill", theme.nodeText).attr("font-size", 12).text(t);
lx += t.length * 7.4 + 44;
});
return svg.node();
}
```
::: {.callout-note}
## Key Insight
Same 16 bits, opposite bets. **fp16** gives the mantissa 10 bits and the exponent
5 — precise, but it overflows at 65504 and underflows around $6\times10^{-5}$.
**bf16** gives the exponent 8 bits (fp32's whole range) and the mantissa only 7 —
it almost never over/underflows, but every stored number is coarser. Step to
**70000** and watch fp16 flip to `inf` while bf16 holds it; step to **1e-8** and
watch fp16 collapse to `0`.
:::
### Failure mode 1: overflow
`70000` is a perfectly ordinary number, but it is past fp16's largest value
(65504). Storing it there gives `inf` — and `inf - inf = NaN` a few operations
later. bf16, with fp32's exponent range, does not blink:
```{python}
from precision import round_to_format, FORMATS
for name in ("fp16", "bf16"):
print(f"70000 in {name}: {round_to_format(70000.0, FORMATS[name])}")
```
### Failure mode 2: underflow (and the loss-scaling fix)
The subtler killer is underflow. Late in training, gradients get small — and a
gradient like `1e-8` is *below fp16's smallest representable value*, so it rounds
to `0` and that weight simply stops updating. The classic fix is **loss scaling**:
multiply the loss by a constant $S$ before backprop, which multiplies *every*
gradient by $S$, lifting it out of the underflow hole. The optimizer then divides
the update back out by $S$, so the math is unchanged — only the *representation*
was rescued.
```{python}
from precision import loss_scale_gradient, FORMATS
out = loss_scale_gradient(grad=1e-8, scale=1024.0, fmt=FORMATS["fp16"])
print(f"true gradient: {out['grad']:g}")
print(f"stored directly in fp16: {out['naive_stored']} underflowed={out['naive_underflowed']}")
print(f"x{out['scale']:g}, store, unscale: {out['recovered']:g} rescued={out['rescued']}")
```
Multiplying by $S = 1024$ turned an unrepresentable `1e-8` into `~1e-5` (safely
inside fp16), and unscaling recovered the gradient almost exactly. This is what
PyTorch's `GradScaler` automates — and it is why **bf16, which underflows far
less, usually needs no loss scaling at all**.
### The whole number line at a glance
The range ladder below shows why the choice comes out the way it does: each bar
runs from a format's smallest subnormal to its largest value (log scale). fp16 is
a narrow window; bf16 spans essentially all of fp32. The two markers are the
problem values above — `70000` sits past fp16's right wall, `1e-8` past its left.
```{python}
#| output: false
from precision import FORMATS, format_spec
_ranges = []
for name in ("fp32", "fp16", "bf16"):
s = format_spec(FORMATS[name])
_ranges.append({"format": name, "lo": s["min_subnormal"], "hi": s["max_normal"]})
ojs_define(precisionRanges = _ranges)
```
```{ojs}
//| echo: false
precRangeViz = Plot.plot({
width: 680,
height: 190,
marginLeft: 70,
x: {type: "log", label: "representable magnitude (log scale)", grid: true},
y: {label: null, domain: precisionRanges.map(r => r.format)},
color: {domain: ["fp32", "fp16", "bf16"], range: [diagramTheme.edgeStroke, diagramTheme.highlight, diagramTheme.accent]},
marks: [
Plot.barX(precisionRanges, {y: "format", x1: "lo", x2: "hi", fill: "format", rx: 4, fillOpacity: 0.85}),
Plot.ruleX([70000], {stroke: diagramTheme.error, strokeDasharray: "4 3"}),
Plot.ruleX([1e-8], {stroke: diagramTheme.error, strokeDasharray: "4 3"}),
Plot.text([{x: 70000, t: "70000"}], {x: "x", y: () => "fp32", text: "t", dy: -34, fill: diagramTheme.error, fontSize: 11}),
Plot.text([{x: 1e-8, t: "1e-8"}], {x: "x", y: () => "fp32", text: "t", dy: -34, fill: diagramTheme.error, fontSize: 11})
]
})
```
::: {.callout-tip}
## Try This
In the bit stepper, compare **0.1** across the two formats: neither is exact
(0.1 is not a finite binary fraction), but fp16's error is ~10× smaller than
bf16's — that is the extra 3 mantissa bits. Then jump to **70000** and **1e-8**:
fp16 hits a wall at both ends, bf16 at neither. That asymmetry — bf16 trades
precision you rarely miss for range you can't afford to lose — is why bf16 became
the default 16-bit training format on A100/H100/TPU hardware.
:::
### Putting it together: the mixed-precision recipe
"Mixed" precision means using *both*: fast low-precision math for the heavy
forward/backward pass, full-precision fp32 for the parts that must be accurate.
1. Keep a **master copy of the weights in fp32**.
2. Cast to fp16/bf16 for the forward and backward pass (the expensive part, now 2× faster).
3. If using fp16, apply **loss scaling** so small gradients survive.
4. Apply the update to the **fp32 master weights** (accurate accumulation), then re-cast.
In PyTorch this is a few lines of `autocast` + `GradScaler`:
```python
from torch.cuda.amp import autocast, GradScaler
scaler = GradScaler() # manages loss scaling automatically
for batch in dataloader:
optimizer.zero_grad()
with autocast(dtype=torch.bfloat16): # fp16/bf16 forward+backward
loss = F.cross_entropy(model(input_ids), targets)
scaler.scale(loss).backward() # scale up -> gradients survive fp16
scaler.step(optimizer) # unscale -> fp32 master-weight update
scaler.update() # adapt the scale factor
```
**Practical advice:**
- Prefer **bf16** where the hardware supports it (A100/H100/TPU) — its fp32-sized
range means no loss scaling and far fewer `NaN` surprises.
- Use **fp16 + loss scaling** on older GPUs (e.g. V100) that lack bf16.
- **fp8** (e4m3 / e5m2) is the frontier — H100-class hardware trains parts of the
network in 8 bits, with per-tensor scaling doing the loss-scaling job the number
line above makes necessary. The next section builds both fp8 formats and that
scaling from scratch.
### Going to 8 bits: fp8 and per-tensor scaling
The same encoder that gave us fp16 and bf16 also gives us **fp8** — the frontier
of low-precision training, run natively on H100/H200 tensor cores and used to
train **DeepSeek-V3** (671B parameters, 2024) end to end. Eight bits is so few
that the range-vs-precision trade can no longer be settled with a single format,
so fp8 ships as *two*:
- **e4m3** — 4 exponent, 3 mantissa bits. More precision, less range (caps at
**448**). Used for the **forward pass**: weights and activations, which sit in a
fairly narrow band once normalized.
- **e5m2** — 5 exponent, 2 mantissa bits. fp16's exponent, so fp16's range (caps
at **57344**) but coarser. Used for **gradients**, which span many orders of
magnitude — the same reason bf16 exists, taken to 8 bits.
Both are already in `precision.py` as `FP8_FORMATS`:
```{python}
from precision import FP8_FORMATS, format_spec
for name in ("e4m3", "e5m2"):
s = format_spec(FP8_FORMATS[name])
print(f"{s['name']:5} exp={s['exp_bits']} mantissa={s['mantissa_bits']} "
f"max={s['max_normal']:>7.0f} min_sub={s['min_subnormal']:.2e} "
f"eps={s['eps']:.3f} inf={s['has_inf']}")
```
#### e4m3 breaks the IEEE rules
Notice `inf=False` for e4m3. This is the one genuinely new idea in fp8, and it is
worth pausing on. Every format so far — fp32, fp16, bf16, e5m2 — is IEEE-754: the
*all-ones* exponent is reserved to mean `inf` (mantissa 0) or `NaN` (mantissa
nonzero), and no normal number is allowed to use it. That costs a whole binade of
range at the top.
With only 4 exponent bits, e4m3 cannot afford that. So it **reclaims** the
all-ones exponent for normal numbers and keeps exactly **one** bit pattern for
`NaN` — `S.1111.111` — and **no infinity at all** (this is what PyTorch calls
`float8_e4m3fn`, the "fn" = *finite*). Reclaiming that exponent is why e4m3
reaches 448 instead of the 240 an IEEE-style e4m3 would cap at. The trade: a value
past the top no longer rounds to a harmless `inf` — it becomes `NaN`.
Our encoder handles this with a single `has_inf` flag; step through some values
and watch e4m3 saturate to 448 and then go `NaN` while e5m2 rounds to `inf` like
fp16 does:
```{python}
#| output: false
from precision import FP8_FORMATS, encode, round_to_format, rounding_error, overflows, underflows
def _fp8_row(x, fmt):
import math as _m
bits = encode(x, fmt)
s = format(bits, f"0{fmt.total_bits}b")
r = round_to_format(x, fmt)
return {
"sign": s[0],
"exp": s[1:1 + fmt.exp_bits],
"mant": s[1 + fmt.exp_bits:],
"stored": r if _m.isfinite(r) else None,
"isnan": _m.isnan(r),
"isinf": _m.isinf(r),
"error": min(rounding_error(x, fmt), 9.99),
"overflow": overflows(x, fmt),
"underflow": underflows(x, fmt),
}
_fp8_demo = [1.0, 0.1, 0.0018, 6.1e-4, 448.0, 500.0, 57344.0, 1e5]
fp8Data = [
{"value": x,
"e4m3": _fp8_row(x, FP8_FORMATS["e4m3"]),
"e5m2": _fp8_row(x, FP8_FORMATS["e5m2"])}
for x in _fp8_demo
]
ojs_define(fp8Data = fp8Data)
```
```{ojs}
//| echo: false
viewof fp8Step = stepControl({min: 0, max: 7, value: 5, label: "Value"})
```
```{ojs}
//| echo: false
fp8BitViz = {
const theme = diagramTheme;
const width = 720, height = 300;
const row = fp8Data[fp8Step];
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%")
.attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
svg.append("text").attr("x", width / 2).attr("y", 34)
.attr("text-anchor", "middle").attr("fill", theme.nodeText)
.attr("font-size", 20).attr("font-weight", 700)
.text(`storing ${row.value} in 8 bits`);
const drawFormat = (label, fmt, y) => {
const boxW = 40, boxH = 34, gap = 5;
const bits = [
{b: fmt.sign, kind: "sign"},
...fmt.exp.split("").map(b => ({b, kind: "exp"})),
...fmt.mant.split("").map(b => ({b, kind: "mant"}))
];
const totalW = bits.length * (boxW + gap);
const x0 = (width - totalW) / 2 - 40;
const color = {sign: theme.edgeStroke, exp: theme.accent, mant: theme.highlight};
// Ink for a SET bit, picked per fill family (DESIGN_LANGUAGE §6): the sky
// accent & orange highlight stay mid-tone in both themes so they take dark
// text; the stone edge hue flips, so it takes the flipping textOnFill.
const onColor = {sign: theme.textOnFill, exp: theme.textOnAccent, mant: theme.textOnHighlight};
svg.append("text").attr("x", x0 - 12).attr("y", y + boxH / 2 + 5)
.attr("text-anchor", "end").attr("fill", theme.nodeText)
.attr("font-size", 15).attr("font-weight", 600).text(label);
bits.forEach((bit, i) => {
const x = x0 + i * (boxW + gap);
svg.append("rect").attr("x", x).attr("y", y).attr("width", boxW).attr("height", boxH)
.attr("rx", 4).attr("fill", bit.b === "1" ? color[bit.kind] : theme.bgSecondary)
.attr("stroke", theme.nodeStroke).attr("stroke-width", 1);
svg.append("text").attr("x", x + boxW / 2).attr("y", y + boxH / 2 + 6)
.attr("text-anchor", "middle")
.attr("fill", bit.b === "1" ? onColor[bit.kind] : theme.edgeStroke)
.attr("font-size", 16).text(bit.b);
});
let note, noteColor;
if (fmt.isnan) { note = "→ NaN"; noteColor = theme.error; }
else if (fmt.isinf) { note = "→ inf"; noteColor = theme.error; }
else {
const err = fmt.error === 0 ? "exact" : `err ${(fmt.error * 100).toFixed(2)}%`;
note = `→ ${fmt.stored} (${err})`;
noteColor = fmt.error === 0 ? theme.success : theme.nodeText;
}
svg.append("text").attr("x", x0 + totalW + 16).attr("y", y + boxH / 2 + 5)
.attr("fill", noteColor).attr("font-size", 13).text(note);
};
drawFormat("e4m3", row.e4m3, 92);
drawFormat("e5m2", row.e5m2, 172);
const leg = [["sign", theme.edgeStroke], ["exponent (range)", theme.accent], ["mantissa (precision)", theme.highlight]];
let lx = (width - 360) / 2;
leg.forEach(([t, c]) => {
svg.append("rect").attr("x", lx).attr("y", 246).attr("width", 14).attr("height", 14).attr("rx", 2).attr("fill", c);
svg.append("text").attr("x", lx + 20).attr("y", 257).attr("fill", theme.nodeText).attr("font-size", 12).text(t);
lx += t.length * 7.4 + 44;
});
return svg.node();
}
```
::: {.callout-note}
## Key Insight
e4m3 has just **4 exponent bits**, so it drops IEEE's reserved-infinity rule to
buy range: no `inf`, one `NaN`, and a top exponent that holds *normal* numbers up
to **448**. Step to **500** and watch e4m3 flip to `NaN` (there is no `inf` to
catch it) while e5m2 — with fp16's 5 exponent bits — still holds 512. Step to
**57344** and only e5m2 survives. This is why e4m3 does the *forward* pass (narrow,
precise) and e5m2 does the *gradients* (wide, coarse).
:::
#### Per-tensor scaling: the fp8 loss-scaling
There is a second problem 8 bits creates. e4m3's smallest normal value is about
`0.016`, and its smallest subnormal about `0.002` — but a trained network's
weights are routinely `~0.001`–`0.05`. Quantize such a tensor to e4m3 directly and
much of it **underflows to zero**, exactly the failure loss scaling fixed for
fp16 gradients. The fix is the same idea, applied per tensor: compute one
**scale** that maps the tensor's largest magnitude (`amax`) onto `max_normal`,
multiply the whole tensor by it so it fills the 8-bit range, quantize, and divide
the scale back out in the matmul.
```{python}
from precision import quantize_per_tensor, per_tensor_scale, FP8_FORMATS
weights = [0.031, -0.012, 0.004, 0.0018, -0.0009, 0.021, -0.006, 0.0003]
e4m3 = FP8_FORMATS["e4m3"]
scale = per_tensor_scale(weights, e4m3) # 448 / amax(weights)
out = quantize_per_tensor(weights, e4m3)
print(f"per-tensor scale: {scale:,.0f} (maps amax {max(map(abs, weights))} -> 448)")
print(f"naive fp8: {out['naive_zeroed']}/{len(weights)} entries underflow to 0, "
f"mean rel error {out['naive_rel_error']:.1%}")
print(f"scaled fp8: {out['scaled_zeroed']}/{len(weights)} entries underflow to 0, "
f"mean rel error {out['scaled_rel_error']:.1%}")
```
Without scaling, the two smallest weights vanish and the average entry is off by a
quarter of its value. Scale the tensor first and *nothing* underflows — the whole
vector survives at true 8-bit precision. The bars below show each weight before
(target), after naive quantization (some collapse to 0), and after scaled
quantization (all recovered):
```{python}
#| output: false
from precision import quantize_per_tensor, per_tensor_scale, FP8_FORMATS
_w = [0.031, -0.012, 0.004, 0.0018, -0.0009, 0.021, -0.006, 0.0003]
_e4m3 = FP8_FORMATS["e4m3"]
_out = quantize_per_tensor(_w, _e4m3)
fp8Scaling = [
{"i": i, "target": _w[i], "naive": _out["naive"][i], "scaled": _out["recovered"][i]}
for i in range(len(_w))
]
ojs_define(fp8Scaling = fp8Scaling)
ojs_define(fp8Scale = per_tensor_scale(_w, _e4m3))
```
```{ojs}
//| echo: false
fp8ScalingViz = {
const theme = diagramTheme;
const rows = fp8Scaling.flatMap(d => [
{i: `w${d.i}`, kind: "target", v: d.target},
{i: `w${d.i}`, kind: "naive", v: d.naive},
{i: `w${d.i}`, kind: "scaled", v: d.scaled}
]);
return Plot.plot({
width: 720,
height: 320,
marginBottom: 40,
caption: `per-tensor scale ≈ ${fp8Scale.toLocaleString("en-US", {maximumFractionDigits: 0})} — red = naive fp8 (some collapse to 0), green = scaled fp8`,
fx: {label: "weight (scaled up by amax→448)"},
x: {axis: null, domain: ["target", "naive", "scaled"]},
y: {label: "value", grid: true},
color: {
domain: ["target", "naive", "scaled"],
range: [theme.edgeStroke, theme.error, theme.success],
legend: true
},
marks: [
Plot.ruleY([0], {stroke: theme.nodeStroke}),
Plot.barY(rows, {fx: "i", x: "kind", y: "v", fill: "kind"})
]
});
}
```
::: {.callout-tip}
## Try This
In the bit stepper, step to **0.0018** and **6.1e-4**: e4m3 stores the first as a
tiny subnormal but rounds the second to `0` — that is the underflow the scaling
above rescues. Real fp8 training goes further still: DeepSeek-V3 uses *fine-grained*
scaling (a separate scale per 1×128 tile of activations and per 128×128 block of
weights) so one giant outlier can't force the whole tensor's scale down and drown
everything else. Same idea, finer grain.
:::
**Going deeper on fp8:** [*FP8 Formats for Deep
Learning*](https://arxiv.org/abs/2209.05433) (Micikevicius et al., 2022) defines
e4m3/e5m2 and the forward-vs-gradient split; the [OCP Microscaling (MX)
spec](https://arxiv.org/abs/2310.10537) (2023) standardizes them; and the
[DeepSeek-V3 technical report](https://arxiv.org/abs/2412.19437) (2024) shows fp8
mixed-precision training of a 671B model with fine-grained per-tile/per-block
scaling.
## Distributed Training Basics
Large models require multiple GPUs. A brief overview:
**Data Parallel (DP/DDP):**
- Same model copied to all GPUs
- Each GPU processes different data
- Gradients are averaged across GPUs
- Memory per GPU = full model size
```{ojs}
//| echo: false
// Data Parallel step descriptions
dpSteps = [
{
id: 0,
name: "Input Data",
description: "Large training batch ready to be distributed across GPUs"
},
{
id: 1,
name: "Split Data",
description: "Batch is divided evenly among available GPUs"
},
{
id: 2,
name: "Forward Pass",
description: "Each GPU computes forward pass on its data shard with full model copy"
},
{
id: 3,
name: "Compute Gradients",
description: "Each GPU computes gradients via backpropagation"
},
{
id: 4,
name: "AllReduce",
description: "Gradients are averaged across all GPUs via collective communication"
}
]
```
```{ojs}
//| echo: false
// Step slider for Data Parallel diagram
viewof dpStep = Inputs.range([0, 4], {
value: 0,
step: 1,
label: "Step"
})
```
```{ojs}
//| echo: false
// Current Data Parallel step info
currentDpStep = dpSteps[dpStep]
```
```{ojs}
//| echo: false
// Data Parallel interactive diagram
{
const width = 700;
const height = 380;
const numGpus = 3;
const batchPerGpu = 8;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`);
// Background
svg.append("rect")
.attr("width", width)
.attr("height", height)
.attr("fill", diagramTheme.bg)
.attr("rx", 8);
// Defs for arrows and gradients
const defs = svg.append("defs");
// Arrow markers
defs.append("marker")
.attr("id", "dp-arrow")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 8)
.attr("refY", 0)
.attr("markerWidth", 5)
.attr("markerHeight", 5)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", diagramTheme.edgeStroke);
defs.append("marker")
.attr("id", "dp-arrow-active")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 8)
.attr("refY", 0)
.attr("markerWidth", 5)
.attr("markerHeight", 5)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", diagramTheme.highlight);
// Data flow gradient for animation effect
const flowGradient = defs.append("linearGradient")
.attr("id", "dp-flow-gradient")
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "100%")
.attr("y2", "0%");
flowGradient.append("stop")
.attr("offset", "0%")
.attr("stop-color", diagramTheme.highlight)
.attr("stop-opacity", 0.2);
flowGradient.append("stop")
.attr("offset", "50%")
.attr("stop-color", diagramTheme.highlight)
.attr("stop-opacity", 1);
flowGradient.append("stop")
.attr("offset", "100%")
.attr("stop-color", diagramTheme.highlight)
.attr("stop-opacity", 0.2);
// Layout constants
const batchX = 70;
const splitX = 200;
const gpuX = 400;
const reduceX = 580;
const centerY = height / 2;
const gpuSpacing = 90;
// GPU Y positions
const gpuYs = [centerY - gpuSpacing, centerY, centerY + gpuSpacing];
// Helper: draw data block
const drawDataBlock = (g, x, y, w, h, label, isActive, isSmall = false) => {
const block = g.append("g").attr("transform", `translate(${x}, ${y})`);
block.append("rect")
.attr("x", -w/2)
.attr("y", -h/2)
.attr("width", w)
.attr("height", h)
.attr("rx", 4)
.attr("fill", isActive ? diagramTheme.highlight : diagramTheme.nodeFill)
.attr("stroke", isActive ? diagramTheme.highlight : diagramTheme.nodeStroke)
.attr("stroke-width", isActive ? 2 : 1.5)
.style("filter", isActive ? `drop-shadow(0 0 6px ${diagramTheme.highlightGlow})` : "none");
if (label) {
block.append("text")
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("fill", isActive ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", isSmall ? "10px" : "11px")
.attr("font-weight", "500")
.text(label);
}
return block;
};
// Helper: draw GPU box
const drawGpu = (g, x, y, gpuNum, isActive, showGradients = false) => {
const gpu = g.append("g").attr("transform", `translate(${x}, ${y})`);
const boxW = 100;
const boxH = 60;
// GPU container
gpu.append("rect")
.attr("x", -boxW/2)
.attr("y", -boxH/2)
.attr("width", boxW)
.attr("height", boxH)
.attr("rx", 6)
.attr("fill", isActive ? diagramTheme.highlight : diagramTheme.nodeFill)
.attr("stroke", isActive ? diagramTheme.highlight : diagramTheme.nodeStroke)
.attr("stroke-width", isActive ? 2.5 : 1.5)
.style("filter", isActive ? `drop-shadow(0 0 8px ${diagramTheme.highlightGlow})` : "none");
// GPU label
gpu.append("text")
.attr("y", -12)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("fill", isActive ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", "11px")
.attr("font-weight", "600")
.text(`GPU ${gpuNum}`);
// Full model indicator
gpu.append("text")
.attr("y", 6)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("fill", isActive ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", "9px")
.attr("opacity", isActive ? 0.9 : 0.7)
.text("Full Model");
// Gradient indicator (when computing gradients)
if (showGradients) {
gpu.append("text")
.attr("y", 20)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("fill", isActive ? diagramTheme.textOnHighlight : diagramTheme.accent)
.attr("font-size", "9px")
.attr("font-weight", "500")
.text("∇ gradients");
}
return gpu;
};
// Draw based on current step
const mainGroup = svg.append("g");
// Step 0: Show full batch
if (dpStep >= 0) {
const isActive = dpStep === 0;
drawDataBlock(mainGroup, batchX, centerY, 60, 100, null, isActive);
// Data visualization inside batch
const batchGroup = mainGroup.append("g").attr("transform", `translate(${batchX}, ${centerY})`);
for (let i = 0; i < 6; i++) {
const row = Math.floor(i / 2);
const col = i % 2;
batchGroup.append("rect")
.attr("x", -20 + col * 22)
.attr("y", -35 + row * 25)
.attr("width", 18)
.attr("height", 20)
.attr("rx", 2)
.attr("fill", isActive ? diagramTheme.textOnHighlight : diagramTheme.accent)
.attr("opacity", isActive ? 0.9 : 0.6);
}
// Batch label
mainGroup.append("text")
.attr("x", batchX)
.attr("y", centerY + 65)
.attr("text-anchor", "middle")
.attr("fill", diagramTheme.nodeText)
.attr("font-size", "11px")
.attr("font-weight", "500")
.text("Data Batch");
mainGroup.append("text")
.attr("x", batchX)
.attr("y", centerY + 80)
.attr("text-anchor", "middle")
.attr("fill", diagramTheme.nodeText)
.attr("font-size", "10px")
.attr("opacity", 0.7)
.text(`(${batchPerGpu * numGpus} samples)`);
}
// Step 1+: Show split batches
if (dpStep >= 1) {
const isActive = dpStep === 1;
// Draw split indicator arrows
for (let i = 0; i < numGpus; i++) {
const startX = batchX + 35;
const startY = centerY;
const endX = splitX - 25;
const endY = gpuYs[i];
mainGroup.append("path")
.attr("d", `M${startX},${startY} C${startX + 40},${startY} ${endX - 40},${endY} ${endX},${endY}`)
.attr("fill", "none")
.attr("stroke", isActive ? diagramTheme.highlight : diagramTheme.edgeStroke)
.attr("stroke-width", isActive ? 2 : 1.5)
.attr("marker-end", isActive ? "url(#dp-arrow-active)" : "url(#dp-arrow)")
.attr("opacity", isActive ? 1 : 0.6)
.style("filter", isActive ? `drop-shadow(0 0 4px ${diagramTheme.highlightGlow})` : "none");
}
// Draw split batches
for (let i = 0; i < numGpus; i++) {
drawDataBlock(mainGroup, splitX, gpuYs[i], 45, 40, null, isActive, true);
// Mini data visualization
const splitGroup = mainGroup.append("g").attr("transform", `translate(${splitX}, ${gpuYs[i]})`);
for (let j = 0; j < 2; j++) {
splitGroup.append("rect")
.attr("x", -15 + j * 16)
.attr("y", -8)
.attr("width", 12)
.attr("height", 16)
.attr("rx", 2)
.attr("fill", isActive ? diagramTheme.textOnHighlight : diagramTheme.accent)
.attr("opacity", isActive ? 0.9 : 0.6);
}
// Batch shard label
mainGroup.append("text")
.attr("x", splitX)
.attr("y", gpuYs[i] + 30)
.attr("text-anchor", "middle")
.attr("fill", diagramTheme.nodeText)
.attr("font-size", "9px")
.attr("opacity", 0.7)
.text(`Batch ${i}`);
}
}
// Step 2+: Show GPUs with forward pass
if (dpStep >= 2) {
const isForward = dpStep === 2;
const isGradient = dpStep === 3;
const isActive = isForward || isGradient;
// Arrows from split to GPU
for (let i = 0; i < numGpus; i++) {
const startX = splitX + 28;
const endX = gpuX - 55;
const y = gpuYs[i];
mainGroup.append("path")
.attr("d", `M${startX},${y} L${endX},${y}`)
.attr("fill", "none")
.attr("stroke", isActive ? diagramTheme.highlight : diagramTheme.edgeStroke)
.attr("stroke-width", isActive ? 2 : 1.5)
.attr("marker-end", isActive ? "url(#dp-arrow-active)" : "url(#dp-arrow)")
.attr("opacity", isActive ? 1 : 0.6)
.style("filter", isActive ? `drop-shadow(0 0 4px ${diagramTheme.highlightGlow})` : "none");
}
// Draw GPUs
for (let i = 0; i < numGpus; i++) {
drawGpu(mainGroup, gpuX, gpuYs[i], i, isActive, isGradient);
}
}
// Step 4: AllReduce
if (dpStep >= 4) {
const isActive = dpStep === 4;
// Arrows from GPU to AllReduce
for (let i = 0; i < numGpus; i++) {
const startX = gpuX + 55;
const startY = gpuYs[i];
const endX = reduceX - 45;
const endY = centerY;
mainGroup.append("path")
.attr("d", `M${startX},${startY} C${startX + 30},${startY} ${endX - 30},${endY} ${endX},${endY}`)
.attr("fill", "none")
.attr("stroke", isActive ? diagramTheme.highlight : diagramTheme.edgeStroke)
.attr("stroke-width", isActive ? 2 : 1.5)
.attr("marker-end", isActive ? "url(#dp-arrow-active)" : "url(#dp-arrow)")
.attr("opacity", isActive ? 1 : 0.6)
.style("filter", isActive ? `drop-shadow(0 0 4px ${diagramTheme.highlightGlow})` : "none");
}
// AllReduce node
const reduceGroup = mainGroup.append("g").attr("transform", `translate(${reduceX}, ${centerY})`);
reduceGroup.append("rect")
.attr("x", -42)
.attr("y", -35)
.attr("width", 84)
.attr("height", 70)
.attr("rx", 6)
.attr("fill", isActive ? diagramTheme.highlight : diagramTheme.nodeFill)
.attr("stroke", isActive ? diagramTheme.highlight : diagramTheme.nodeStroke)
.attr("stroke-width", isActive ? 2.5 : 1.5)
.style("filter", isActive ? `drop-shadow(0 0 8px ${diagramTheme.highlightGlow})` : "none");
reduceGroup.append("text")
.attr("y", -10)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("fill", isActive ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", "11px")
.attr("font-weight", "600")
.text("AllReduce");
reduceGroup.append("text")
.attr("y", 8)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("fill", isActive ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", "9px")
.attr("opacity", 0.8)
.text("Average");
reduceGroup.append("text")
.attr("y", 22)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("fill", isActive ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", "9px")
.attr("opacity", 0.8)
.text("Gradients");
}
// Step indicator bar at top
const stepBarY = 25;
const stepBarWidth = 500;
const stepBarX = (width - stepBarWidth) / 2;
const stepWidth = stepBarWidth / 5;
const stepLabels = ["Input", "Split", "Forward", "Gradients", "AllReduce"];
for (let i = 0; i < 5; i++) {
const isCurrentStep = dpStep === i;
const isPastStep = dpStep > i;
const stepX = stepBarX + i * stepWidth + stepWidth / 2;
// Step circle
mainGroup.append("circle")
.attr("cx", stepX)
.attr("cy", stepBarY)
.attr("r", 12)
.attr("fill", isCurrentStep ? diagramTheme.highlight : (isPastStep ? diagramTheme.accent : diagramTheme.nodeFill))
.attr("stroke", isCurrentStep ? diagramTheme.highlight : (isPastStep ? diagramTheme.accent : diagramTheme.nodeStroke))
.attr("stroke-width", isCurrentStep ? 2 : 1.5)
.style("filter", isCurrentStep ? `drop-shadow(0 0 6px ${diagramTheme.highlightGlow})` : "none");
// Step number
mainGroup.append("text")
.attr("x", stepX)
.attr("y", stepBarY)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("fill", isCurrentStep || isPastStep ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr("font-size", "10px")
.attr("font-weight", "600")
.text(i);
// Step label
mainGroup.append("text")
.attr("x", stepX)
.attr("y", stepBarY + 22)
.attr("text-anchor", "middle")
.attr("fill", isCurrentStep ? diagramTheme.highlight : diagramTheme.nodeText)
.attr("font-size", "9px")
.attr("font-weight", isCurrentStep ? "600" : "400")
.attr("opacity", isCurrentStep ? 1 : 0.7)
.text(stepLabels[i]);
// Connecting line (except last)
if (i < 4) {
const lineStartX = stepX + 15;
const lineEndX = stepBarX + (i + 1) * stepWidth + stepWidth / 2 - 15;
mainGroup.append("line")
.attr("x1", lineStartX)
.attr("y1", stepBarY)
.attr("x2", lineEndX)
.attr("y2", stepBarY)
.attr("stroke", isPastStep ? diagramTheme.accent : diagramTheme.edgeStroke)
.attr("stroke-width", 1.5)
.attr("opacity", 0.5);
}
}
// Effective batch size display
const statsY = height - 30;
mainGroup.append("text")
.attr("x", width / 2)
.attr("y", statsY)
.attr("text-anchor", "middle")
.attr("fill", diagramTheme.nodeText)
.attr("font-size", "11px")
.attr("opacity", 0.8)
.text(`Effective batch size: ${batchPerGpu} samples/GPU × ${numGpus} GPUs = ${batchPerGpu * numGpus} samples`);
return svg.node();
}
```
```{ojs}
//| echo: false
// Step description panel for Data Parallel
html`<div style="
background: ${diagramTheme.bgSecondary};
border-radius: 6px;
padding: 12px 16px;
margin-top: 8px;
border-left: 3px solid ${diagramTheme.highlight};
">
<div style="font-weight: 600; color: ${diagramTheme.nodeText}; margin-bottom: 4px;">
Step ${currentDpStep.id}: ${currentDpStep.name}
</div>
<div style="color: ${diagramTheme.nodeText}; opacity: 0.8; font-size: 13px;">
${currentDpStep.description}
</div>
</div>`
```
**Fully Sharded Data Parallel (FSDP):**
- Model is sharded across GPUs
- Each GPU holds a fraction of parameters
- Memory per GPU = model_size / num_gpus
- Enables training models larger than single GPU memory
```{python}
# Distributed training concepts
print("Distributed Training Strategies:")
print()
print("1. Data Parallel (DDP):")
print(" - Best for: Models that fit in one GPU")
print(" - Scales: Batch size (effective_batch = batch * num_gpus)")
print()
print("2. Fully Sharded Data Parallel (FSDP):")
print(" - Best for: Large models (>10B parameters)")
print(" - Scales: Model size and batch size")
print()
print("3. Pipeline Parallel:")
print(" - Best for: Very deep models")
print(" - Splits model layers across GPUs")
print()
print("4. Tensor Parallel:")
print(" - Best for: Models with large layers")
print(" - Splits individual layers across GPUs")
```
## Training Stability and Failure Modes
Understanding common failure modes helps you debug training issues:
**Loss = NaN or Inf**
Causes:
- Learning rate too high
- Gradient explosion
- Numerical overflow in fp16
Solutions:
- Reduce learning rate (try 10x smaller)
- Add gradient clipping
- Use bf16 instead of fp16 or add gradient scaling
**Loss stuck at high value**
Causes:
- Learning rate too low
- Poor weight initialization
- Data loading bug (same batch every time)
Solutions:
- Increase learning rate
- Check data loader with small sample
- Verify model architecture
**Loss oscillates or increases**
Causes:
- Learning rate too high
- Batch size too small
- Bug in loss computation
Solutions:
- Add warmup period
- Reduce learning rate
- Use gradient accumulation
```{ojs}
//| echo: false
// Training pathologies visualization
trainingPathologiesChart = {
const width = 750;
const height = 280;
const margin = { top: 35, right: 20, bottom: 45, left: 50 };
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`);
// Background
svg.append("rect")
.attr("width", width)
.attr("height", height)
.attr("fill", theme.bg)
.attr("rx", 8);
// Three panels: Good, Unstable (LR high), Slow (LR low)
const panelWidth = (width - 60) / 3;
const innerWidth = panelWidth - margin.left - margin.right + 30;
const innerHeight = height - margin.top - margin.bottom;
const configs = [
{
title: "Good Training",
color: theme.accent,
getData: () => {
const data = [];
for (let i = 0; i < 100; i++) {
// Exponential decay with some noise
const noise = (Math.sin(i * 0.7) * 0.1 + Math.cos(i * 1.3) * 0.08);
data.push({ step: i, loss: 5.0 * Math.exp(-0.03 * i) + 0.5 + noise });
}
return data;
},
yDomain: [0, 6]
},
{
title: "LR Too High (Unstable)",
color: diagramTheme.error, // red - danger/unstable
getData: () => {
const data = [];
for (let i = 0; i < 100; i++) {
data.push({ step: i, loss: 4.0 + 0.5 * Math.sin(i * 0.3) + 0.02 * i });
}
return data;
},
yDomain: [0, 8]
},
{
title: "LR Too Low (Slow)",
color: theme.highlight,
getData: () => {
const data = [];
for (let i = 0; i < 100; i++) {
data.push({ step: i, loss: 5.0 * Math.exp(-0.005 * i) + 0.5 });
}
return data;
},
yDomain: [0, 6]
}
];
configs.forEach((config, panelIdx) => {
const offsetX = 10 + panelIdx * panelWidth;
const panel = svg.append("g")
.attr("transform", `translate(${offsetX + margin.left}, ${margin.top})`);
const data = config.getData();
// Scales
const xScale = d3.scaleLinear()
.domain([0, 100])
.range([0, innerWidth]);
const yScale = d3.scaleLinear()
.domain(config.yDomain)
.range([innerHeight, 0]);
// Grid lines
[2, 4, 6].forEach(tick => {
if (tick <= config.yDomain[1]) {
panel.append("line")
.attr("x1", 0)
.attr("x2", innerWidth)
.attr("y1", yScale(tick))
.attr("y2", yScale(tick))
.attr("stroke", theme.nodeStroke)
.attr("stroke-opacity", 0.3)
.attr("stroke-dasharray", "2,2");
}
});
// Line generator
const lineGen = d3.line()
.x(d => xScale(d.step))
.y(d => yScale(d.loss))
.curve(d3.curveMonotoneX);
// Line
panel.append("path")
.datum(data)
.attr("d", lineGen)
.attr("fill", "none")
.attr("stroke", config.color)
.attr("stroke-width", 2.5);
// X-axis
panel.append("g")
.attr("transform", `translate(0, ${innerHeight})`)
.call(d3.axisBottom(xScale).ticks(5))
.call(g => g.select(".domain").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick line").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick text").attr("fill", theme.nodeText).attr("font-size", "10px"));
panel.append("text")
.attr("x", innerWidth / 2)
.attr("y", innerHeight + 35)
.attr("text-anchor", "middle")
.attr("font-size", "10px")
.attr("fill", theme.nodeText)
.text("Step");
// Y-axis
panel.append("g")
.call(d3.axisLeft(yScale).ticks(4))
.call(g => g.select(".domain").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick line").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick text").attr("fill", theme.nodeText).attr("font-size", "10px"));
if (panelIdx === 0) {
panel.append("text")
.attr("transform", "rotate(-90)")
.attr("x", -innerHeight / 2)
.attr("y", -35)
.attr("text-anchor", "middle")
.attr("font-size", "10px")
.attr("fill", theme.nodeText)
.text("Loss");
}
// Title
panel.append("text")
.attr("x", innerWidth / 2)
.attr("y", -12)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("font-weight", "600")
.attr("fill", config.color)
.text(config.title);
});
return svg.node();
}
```
**Debugging checklist:**
1. Check initial loss - should be ~log(vocab_size) for untrained model
2. Verify data is being loaded correctly (print a few samples)
3. Monitor gradient norms - should be stable, not growing
4. Check learning rate schedule is working (print LR each step)
5. Test with a tiny dataset first to verify overfitting capability
## Text Dataset
Let's create a simple dataset for language modeling:
```{python}
from torch.utils.data import Dataset, DataLoader
class TextDataset(Dataset):
"""Simple text dataset for language modeling."""
def __init__(self, tokens, seq_len):
self.tokens = tokens
self.seq_len = seq_len
def __len__(self):
return max(0, len(self.tokens) - self.seq_len)
def __getitem__(self, idx):
input_ids = self.tokens[idx:idx + self.seq_len]
targets = self.tokens[idx + 1:idx + self.seq_len + 1]
return input_ids, targets
# Create a simple dataset
tokens = torch.arange(100) # Token IDs 0-99
seq_len = 8
dataset = TextDataset(tokens, seq_len=seq_len)
print(f"Token IDs: {tokens[:20].tolist()}...")
print(f"Sequence length: {seq_len}")
print(f"Number of samples: {len(dataset)}")
```
```{python}
# Look at a sample
input_ids, targets = dataset[0]
print("Sample 0:")
print(f" Input: {input_ids.tolist()}")
print(f" Target: {targets.tolist()}")
print(f"\n Target is input shifted by 1 position!")
# Another sample
input_ids, targets = dataset[50]
print(f"\nSample 50:")
print(f" Input: {input_ids.tolist()}")
print(f" Target: {targets.tolist()}")
```
## Training a Model
Now let's put it all together and train a tiny model:
```{python}
import sys
sys.path.insert(0, '..')
from m06_transformer.transformer import create_gpt_tiny
# Create model and data
torch.manual_seed(42)
vocab_size = 100
model = create_gpt_tiny(vocab_size=vocab_size)
# Random "training data"
tokens = torch.randint(0, vocab_size, (5000,))
print(f"Model: {model.num_params:,} parameters")
print(f"Training data: {len(tokens):,} tokens")
```
```{python}
# Check initial loss (should be ~log(vocab_size) for random predictions)
dataset = TextDataset(tokens, seq_len=32)
input_ids, targets = dataset[0]
input_ids = input_ids.unsqueeze(0) # Add batch dimension
targets = targets.unsqueeze(0)
model.eval()
with torch.no_grad():
logits = model(input_ids)
# Reshape for loss computation
B, T, V = logits.shape
initial_loss = F.cross_entropy(logits.view(B*T, V), targets.view(B*T))
print(f"Initial loss: {initial_loss.item():.4f}")
print(f"Initial perplexity: {math.exp(initial_loss.item()):.2f}")
print(f"\nExpected for random guessing: loss ~ {np.log(vocab_size):.2f}, ppl ~ {vocab_size}")
```
```{python}
def train_model(model, tokens, num_steps=100, batch_size=16, seq_len=32, learning_rate=3e-4):
"""Simple training loop."""
dataset = TextDataset(tokens, seq_len)
dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True, drop_last=True)
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate, weight_decay=0.01)
scheduler = CosineScheduler(optimizer, warmup_steps=10, total_steps=num_steps, min_lr=1e-5)
model.train()
losses = []
step = 0
while step < num_steps:
for input_ids, targets in dataloader:
if step >= num_steps:
break
# Forward pass
logits = model(input_ids)
B, T, V = logits.shape
loss = F.cross_entropy(logits.view(B*T, V), targets.view(B*T))
# Backward pass
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
# Update
optimizer.step()
scheduler.step()
optimizer.zero_grad()
losses.append(loss.item())
if step % 10 == 0:
lr = optimizer.param_groups[0]['lr']
ppl = math.exp(loss.item())
print(f"Step {step:3d} | Loss: {loss.item():.4f} | PPL: {ppl:.2f} | LR: {lr:.2e}")
step += 1
return losses
# Train!
print("Starting training...\n")
losses = train_model(model, tokens, num_steps=100)
print(f"\nFinal loss: {losses[-1]:.4f}")
print(f"Final perplexity: {math.exp(losses[-1]):.2f}")
```
```{python}
#| output: false
# Pass losses to OJS for visualization
import json
ojs_define(training_losses = losses, vocab_size_val = vocab_size)
```
```{ojs}
//| echo: false
// Training curve visualization
trainingCurveChart = {
const width = 750;
const height = 300;
const margin = { top: 35, right: 30, bottom: 45, left: 55 };
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`);
// Background
svg.append("rect")
.attr("width", width)
.attr("height", height)
.attr("fill", theme.bg)
.attr("rx", 8);
// Two panels: Loss and Perplexity
const panelWidth = (width - 30) / 2;
const innerWidth = panelWidth - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const losses = training_losses;
const vocabSize = vocab_size_val;
const randomBaseline = Math.log(vocabSize);
// Panel 1: Loss
const panel1 = svg.append("g")
.attr("transform", `translate(${margin.left}, ${margin.top})`);
const xScale1 = d3.scaleLinear()
.domain([0, losses.length - 1])
.range([0, innerWidth]);
const yScale1 = d3.scaleLinear()
.domain([0, Math.max(...losses) * 1.1])
.range([innerHeight, 0]);
// Grid
[1, 2, 3, 4].forEach(tick => {
if (tick <= Math.max(...losses) * 1.1) {
panel1.append("line")
.attr("x1", 0)
.attr("x2", innerWidth)
.attr("y1", yScale1(tick))
.attr("y2", yScale1(tick))
.attr("stroke", theme.nodeStroke)
.attr("stroke-opacity", 0.3)
.attr("stroke-dasharray", "2,2");
}
});
// Random baseline line
panel1.append("line")
.attr("x1", 0)
.attr("x2", innerWidth)
.attr("y1", yScale1(randomBaseline))
.attr("y2", yScale1(randomBaseline))
.attr("stroke", theme.error)
.attr("stroke-width", 1.5)
.attr("stroke-dasharray", "5,3");
// Loss line
const lineGen1 = d3.line()
.x((d, i) => xScale1(i))
.y(d => yScale1(d))
.curve(d3.curveMonotoneX);
panel1.append("path")
.datum(losses)
.attr("d", lineGen1)
.attr("fill", "none")
.attr("stroke", theme.accent)
.attr("stroke-width", 2.5);
// X-axis
panel1.append("g")
.attr("transform", `translate(0, ${innerHeight})`)
.call(d3.axisBottom(xScale1).ticks(5))
.call(g => g.select(".domain").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick line").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick text").attr("fill", theme.nodeText).attr("font-size", "10px"));
panel1.append("text")
.attr("x", innerWidth / 2)
.attr("y", innerHeight + 35)
.attr("text-anchor", "middle")
.attr("font-size", "11px")
.attr("fill", theme.nodeText)
.text("Step");
// Y-axis
panel1.append("g")
.call(d3.axisLeft(yScale1).ticks(5))
.call(g => g.select(".domain").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick line").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick text").attr("fill", theme.nodeText).attr("font-size", "10px"));
panel1.append("text")
.attr("transform", "rotate(-90)")
.attr("x", -innerHeight / 2)
.attr("y", -40)
.attr("text-anchor", "middle")
.attr("font-size", "11px")
.attr("fill", theme.nodeText)
.text("Loss");
// Title
panel1.append("text")
.attr("x", innerWidth / 2)
.attr("y", -12)
.attr("text-anchor", "middle")
.attr("font-size", "13px")
.attr("font-weight", "600")
.attr("fill", theme.nodeText)
.text("Training Loss");
// Legend
panel1.append("line")
.attr("x1", innerWidth - 100)
.attr("x2", innerWidth - 80)
.attr("y1", 15)
.attr("y2", 15)
.attr("stroke", theme.error)
.attr("stroke-width", 1.5)
.attr("stroke-dasharray", "5,3");
panel1.append("text")
.attr("x", innerWidth - 75)
.attr("y", 19)
.attr("font-size", "9px")
.attr("fill", theme.nodeText)
.text("Random baseline");
// Panel 2: Perplexity
const panel2 = svg.append("g")
.attr("transform", `translate(${panelWidth + margin.left + 15}, ${margin.top})`);
const perplexities = losses.map(l => Math.exp(l));
const xScale2 = d3.scaleLinear()
.domain([0, losses.length - 1])
.range([0, innerWidth]);
const yScale2 = d3.scaleLinear()
.domain([0, Math.max(...perplexities) * 1.1])
.range([innerHeight, 0]);
// Grid
[25, 50, 75, 100].forEach(tick => {
if (tick <= Math.max(...perplexities) * 1.1) {
panel2.append("line")
.attr("x1", 0)
.attr("x2", innerWidth)
.attr("y1", yScale2(tick))
.attr("y2", yScale2(tick))
.attr("stroke", theme.nodeStroke)
.attr("stroke-opacity", 0.3)
.attr("stroke-dasharray", "2,2");
}
});
// Random baseline line
panel2.append("line")
.attr("x1", 0)
.attr("x2", innerWidth)
.attr("y1", yScale2(vocabSize))
.attr("y2", yScale2(vocabSize))
.attr("stroke", theme.error)
.attr("stroke-width", 1.5)
.attr("stroke-dasharray", "5,3");
// Perplexity line
const lineGen2 = d3.line()
.x((d, i) => xScale2(i))
.y(d => yScale2(d))
.curve(d3.curveMonotoneX);
panel2.append("path")
.datum(perplexities)
.attr("d", lineGen2)
.attr("fill", "none")
.attr("stroke", theme.highlight)
.attr("stroke-width", 2.5);
// X-axis
panel2.append("g")
.attr("transform", `translate(0, ${innerHeight})`)
.call(d3.axisBottom(xScale2).ticks(5))
.call(g => g.select(".domain").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick line").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick text").attr("fill", theme.nodeText).attr("font-size", "10px"));
panel2.append("text")
.attr("x", innerWidth / 2)
.attr("y", innerHeight + 35)
.attr("text-anchor", "middle")
.attr("font-size", "11px")
.attr("fill", theme.nodeText)
.text("Step");
// Y-axis
panel2.append("g")
.call(d3.axisLeft(yScale2).ticks(5))
.call(g => g.select(".domain").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick line").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick text").attr("fill", theme.nodeText).attr("font-size", "10px"));
panel2.append("text")
.attr("transform", "rotate(-90)")
.attr("x", -innerHeight / 2)
.attr("y", -40)
.attr("text-anchor", "middle")
.attr("font-size", "11px")
.attr("fill", theme.nodeText)
.text("Perplexity");
// Title
panel2.append("text")
.attr("x", innerWidth / 2)
.attr("y", -12)
.attr("text-anchor", "middle")
.attr("font-size", "13px")
.attr("font-weight", "600")
.attr("fill", theme.nodeText)
.text("Training Perplexity");
// Legend
panel2.append("line")
.attr("x1", innerWidth - 100)
.attr("x2", innerWidth - 80)
.attr("y1", 15)
.attr("y2", 15)
.attr("stroke", theme.error)
.attr("stroke-width", 1.5)
.attr("stroke-dasharray", "5,3");
panel2.append("text")
.attr("x", innerWidth - 75)
.attr("y", 19)
.attr("font-size", "9px")
.attr("fill", theme.nodeText)
.text("Random baseline");
return svg.node();
}
```
## Effect of Learning Rate
Learning rate is crucial - too high causes instability, too low is slow:
```{python}
# Train with different learning rates
learning_rates = [1e-5, 1e-4, 3e-4, 1e-3, 3e-3]
all_losses = {}
for lr in learning_rates:
torch.manual_seed(42)
model = create_gpt_tiny(vocab_size=100)
tokens = torch.randint(0, 100, (3000,))
# Train silently
dataset = TextDataset(tokens, seq_len=32)
dataloader = DataLoader(dataset, batch_size=8, shuffle=True, drop_last=True)
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
model.train()
losses = []
step = 0
while step < 50:
for input_ids, targets in dataloader:
if step >= 50:
break
logits = model(input_ids)
B, T, V = logits.shape
loss = F.cross_entropy(logits.view(B*T, V), targets.view(B*T))
loss.backward()
optimizer.step()
optimizer.zero_grad()
losses.append(loss.item())
step += 1
all_losses[lr] = losses
print(f"LR={lr:.0e}: final_loss={losses[-1]:.3f}, final_ppl={math.exp(losses[-1]):.1f}")
```
```{python}
#| output: false
# Pass learning rate comparison data to OJS
# Convert dict with float keys to list of dicts for JSON serialization
lr_comparison_data = [{"lr": lr, "losses": losses} for lr, losses in all_losses.items()]
ojs_define(lr_comparison = lr_comparison_data)
```
```{ojs}
//| echo: false
// Learning rate comparison chart
lrComparisonChart = {
const width = 700;
const height = 380;
const margin = { top: 40, right: 100, bottom: 50, left: 60 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`);
// Background
svg.append("rect")
.attr("width", width)
.attr("height", height)
.attr("fill", theme.bg)
.attr("rx", 8);
const chart = svg.append("g")
.attr("transform", `translate(${margin.left}, ${margin.top})`);
// Color scale for different learning rates - uses theme colors
const colors = [
theme.edgeStroke, // gray - too low
theme.accent, // blue - low
theme.success, // green - optimal
theme.highlight, // orange - high
theme.error // red - too high
];
// Find max values
const allLosses = lr_comparison.flatMap(d => d.losses);
const maxLoss = Math.max(...allLosses);
const maxSteps = Math.max(...lr_comparison.map(d => d.losses.length));
// Scales
const xScale = d3.scaleLinear()
.domain([0, maxSteps - 1])
.range([0, innerWidth]);
const yScale = d3.scaleLinear()
.domain([0, Math.min(maxLoss * 1.1, 10)])
.range([innerHeight, 0]);
// Grid
[2, 4, 6, 8].forEach(tick => {
chart.append("line")
.attr("x1", 0)
.attr("x2", innerWidth)
.attr("y1", yScale(tick))
.attr("y2", yScale(tick))
.attr("stroke", theme.nodeStroke)
.attr("stroke-opacity", 0.3)
.attr("stroke-dasharray", "2,2");
});
// Line generator
const lineGen = d3.line()
.x((d, i) => xScale(i))
.y(d => yScale(Math.min(d, 10)))
.curve(d3.curveMonotoneX);
// Draw lines for each learning rate
lr_comparison.forEach((lrData, idx) => {
const color = colors[idx % colors.length];
chart.append("path")
.datum(lrData.losses)
.attr("d", lineGen)
.attr("fill", "none")
.attr("stroke", color)
.attr("stroke-width", 2.5)
.attr("opacity", 0.9);
});
// X-axis
chart.append("g")
.attr("transform", `translate(0, ${innerHeight})`)
.call(d3.axisBottom(xScale).ticks(6))
.call(g => g.select(".domain").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick line").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick text").attr("fill", theme.nodeText).attr("font-size", "11px"));
chart.append("text")
.attr("x", innerWidth / 2)
.attr("y", innerHeight + 40)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("fill", theme.nodeText)
.text("Step");
// Y-axis
chart.append("g")
.call(d3.axisLeft(yScale).ticks(5))
.call(g => g.select(".domain").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick line").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick text").attr("fill", theme.nodeText).attr("font-size", "11px"));
chart.append("text")
.attr("transform", "rotate(-90)")
.attr("x", -innerHeight / 2)
.attr("y", -45)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("fill", theme.nodeText)
.text("Loss");
// Title
svg.append("text")
.attr("x", width / 2)
.attr("y", 24)
.attr("text-anchor", "middle")
.attr("font-size", "14px")
.attr("font-weight", "600")
.attr("fill", theme.nodeText)
.text("Training Loss for Different Learning Rates");
// Legend
const legend = svg.append("g")
.attr("transform", `translate(${width - margin.right + 15}, ${margin.top + 20})`);
lr_comparison.forEach((lrData, idx) => {
const y = idx * 22;
const color = colors[idx % colors.length];
const lrStr = lrData.lr.toExponential(0);
legend.append("line")
.attr("x1", 0)
.attr("x2", 20)
.attr("y1", y)
.attr("y2", y)
.attr("stroke", color)
.attr("stroke-width", 2.5);
legend.append("text")
.attr("x", 25)
.attr("y", y + 4)
.attr("font-size", "10px")
.attr("fill", theme.nodeText)
.text(`LR=${lrStr}`);
});
return svg.node();
}
```
**Observations:**
- Too low (1e-5): Training is very slow
- Just right (3e-4): Smooth, fast convergence
- Too high (3e-3): Unstable, loss may spike or diverge
## Checkpointing
Save regularly! Training can crash. Here's what to save:
```{python}
# Demonstrate checkpointing
import json
from pathlib import Path
def save_checkpoint(model, optimizer, step, loss, path):
"""Save a training checkpoint."""
checkpoint = {
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'step': step,
'loss': loss,
}
torch.save(checkpoint, path)
print(f"Checkpoint saved to {path}")
def load_checkpoint(model, optimizer, path):
"""Load a training checkpoint."""
checkpoint = torch.load(path, weights_only=False)
model.load_state_dict(checkpoint['model_state_dict'])
optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
print(f"Checkpoint loaded from {path}")
print(f" Step: {checkpoint['step']}, Loss: {checkpoint['loss']:.4f}")
return checkpoint['step'], checkpoint['loss']
# Save example
model = create_gpt_tiny(vocab_size=100)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
save_checkpoint(model, optimizer, step=50, loss=2.5, path="demo_checkpoint.pt")
# Load example
model2 = create_gpt_tiny(vocab_size=100)
optimizer2 = torch.optim.AdamW(model2.parameters(), lr=3e-4)
step, loss = load_checkpoint(model2, optimizer2, "demo_checkpoint.pt")
# Clean up
Path("demo_checkpoint.pt").unlink()
```
## Validation and Early Stopping
Monitor validation loss to detect overfitting:
```{ojs}
//| echo: false
// Early Stopping Controls
viewof esCurrentEpoch = Inputs.range([1, 50], {
value: 1,
step: 1,
label: "Current Epoch"
})
```
```{ojs}
//| echo: false
// Generate training and validation loss curves
earlyStoppingData = {
const epochs = 50;
const data = [];
// Training loss: exponential decay with noise
// Validation loss: decreases then increases (U-shape)
const bestEpoch = 25; // Where validation loss is lowest
for (let epoch = 1; epoch <= epochs; epoch++) {
// Training loss: smooth exponential decay
const trainLoss = 2.5 * Math.exp(-0.08 * epoch) + 0.3 + 0.05 * Math.sin(epoch * 0.5);
// Validation loss: U-shaped curve
// Decreases initially, then increases (overfitting)
const valBase = 2.5 * Math.exp(-0.06 * epoch) + 0.4;
const overfitComponent = epoch > bestEpoch ? 0.02 * Math.pow(epoch - bestEpoch, 1.3) : 0;
const valLoss = valBase + overfitComponent + 0.03 * Math.sin(epoch * 0.7 + 1);
data.push({
epoch,
trainLoss,
valLoss,
gap: valLoss - trainLoss
});
}
return data;
}
// Find the best epoch (minimum validation loss)
bestModelEpoch = {
let minVal = Infinity;
let bestEpoch = 1;
for (const d of earlyStoppingData) {
if (d.valLoss < minVal) {
minVal = d.valLoss;
bestEpoch = d.epoch;
}
}
return bestEpoch;
}
// Current epoch data
currentEpochData = {
const current = earlyStoppingData.find(d => d.epoch === esCurrentEpoch);
return current || earlyStoppingData[0];
}
// Training phase detection
trainingPhase = {
if (esCurrentEpoch < bestModelEpoch - 5) return "learning";
if (esCurrentEpoch <= bestModelEpoch + 2) return "optimal";
return "overfitting";
}
```
```{ojs}
//| echo: false
// Early Stopping Visualization
{
const theme = diagramTheme;
const width = 700;
const height = 400;
const margin = { top: 40, right: 150, bottom: 60, left: 70 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%")
.attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
const defs = svg.append("defs");
// Background gradient
const bgGradient = defs.append("linearGradient")
.attr("id", "es-bg-gradient")
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "0%")
.attr("y2", "100%");
bgGradient.append("stop")
.attr("offset", "0%")
.attr("stop-color", theme.bg);
bgGradient.append("stop")
.attr("offset", "100%")
.attr("stop-color", theme.bgSecondary);
svg.append("rect")
.attr("width", width)
.attr("height", height)
.attr("fill", "url(#es-bg-gradient)")
.attr("rx", 12);
const chart = svg.append("g")
.attr("transform", `translate(${margin.left}, ${margin.top})`);
// Scales
const xScale = d3.scaleLinear()
.domain([1, 50])
.range([0, innerWidth]);
const yScale = d3.scaleLinear()
.domain([0, 3])
.range([innerHeight, 0]);
// Overfitting region highlight
chart.append("rect")
.attr("x", xScale(bestModelEpoch))
.attr("y", 0)
.attr("width", innerWidth - xScale(bestModelEpoch))
.attr("height", innerHeight)
.attr("fill", theme.error)
.attr("opacity", trainingPhase === "overfitting" ? 0.15 : 0.05);
// Optimal zone highlight
chart.append("rect")
.attr("x", xScale(Math.max(1, bestModelEpoch - 5)))
.attr("y", 0)
.attr("width", xScale(bestModelEpoch + 2) - xScale(Math.max(1, bestModelEpoch - 5)))
.attr("height", innerHeight)
.attr("fill", theme.success)
.attr("opacity", trainingPhase === "optimal" ? 0.15 : 0.05);
// Region labels at top
chart.append("text")
.attr("x", xScale(10))
.attr("y", 15)
.attr("text-anchor", "middle")
.attr("font-size", "10px")
.attr("font-weight", trainingPhase === "learning" ? "600" : "400")
.attr("fill", theme.accent)
.attr("opacity", trainingPhase === "learning" ? 1 : 0.5)
.text("LEARNING");
chart.append("text")
.attr("x", xScale(bestModelEpoch))
.attr("y", 15)
.attr("text-anchor", "middle")
.attr("font-size", "10px")
.attr("font-weight", trainingPhase === "optimal" ? "600" : "400")
.attr("fill", theme.success)
.attr("opacity", trainingPhase === "optimal" ? 1 : 0.5)
.text("OPTIMAL");
chart.append("text")
.attr("x", xScale(40))
.attr("y", 15)
.attr("text-anchor", "middle")
.attr("font-size", "10px")
.attr("font-weight", trainingPhase === "overfitting" ? "600" : "400")
.attr("fill", theme.error)
.attr("opacity", trainingPhase === "overfitting" ? 1 : 0.5)
.text("OVERFITTING");
// Grid lines
const yTicks = [0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0];
yTicks.forEach(tick => {
chart.append("line")
.attr("x1", 0)
.attr("x2", innerWidth)
.attr("y1", yScale(tick))
.attr("y2", yScale(tick))
.attr("stroke", theme.nodeStroke)
.attr("stroke-opacity", 0.2)
.attr("stroke-dasharray", "3,3");
});
// X-axis
chart.append("g")
.attr("transform", `translate(0, ${innerHeight})`)
.call(d3.axisBottom(xScale).ticks(10))
.call(g => g.select(".domain").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick line").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick text").attr("fill", theme.nodeText).attr("font-size", "11px"));
// Y-axis
chart.append("g")
.call(d3.axisLeft(yScale).ticks(6))
.call(g => g.select(".domain").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick line").attr("stroke", theme.nodeStroke))
.call(g => g.selectAll(".tick text").attr("fill", theme.nodeText).attr("font-size", "11px"));
// Axis labels
chart.append("text")
.attr("x", innerWidth / 2)
.attr("y", innerHeight + 45)
.attr("text-anchor", "middle")
.attr("fill", theme.nodeText)
.attr("font-size", "12px")
.attr("font-weight", "500")
.text("Epoch");
chart.append("text")
.attr("x", -innerHeight / 2)
.attr("y", -50)
.attr("transform", "rotate(-90)")
.attr("text-anchor", "middle")
.attr("fill", theme.nodeText)
.attr("font-size", "12px")
.attr("font-weight", "500")
.text("Loss");
// Filter data up to current epoch
const visibleData = earlyStoppingData.filter(d => d.epoch <= esCurrentEpoch);
// Gap fill between curves (overfitting visualization)
const gapArea = d3.area()
.x(d => xScale(d.epoch))
.y0(d => yScale(d.trainLoss))
.y1(d => yScale(d.valLoss))
.curve(d3.curveMonotoneX);
chart.append("path")
.datum(visibleData)
.attr("d", gapArea)
.attr("fill", theme.error)
.attr("opacity", 0.1);
// Training loss line
const trainLine = d3.line()
.x(d => xScale(d.epoch))
.y(d => yScale(d.trainLoss))
.curve(d3.curveMonotoneX);
chart.append("path")
.datum(visibleData)
.attr("d", trainLine)
.attr("fill", "none")
.attr("stroke", theme.accent)
.attr("stroke-width", 3)
.attr("stroke-linecap", "round");
// Validation loss line
const valLine = d3.line()
.x(d => xScale(d.epoch))
.y(d => yScale(d.valLoss))
.curve(d3.curveMonotoneX);
chart.append("path")
.datum(visibleData)
.attr("d", valLine)
.attr("fill", "none")
.attr("stroke", theme.highlight)
.attr("stroke-width", 3)
.attr("stroke-linecap", "round");
// Best model marker (vertical line at best epoch)
if (esCurrentEpoch >= bestModelEpoch) {
const bestData = earlyStoppingData.find(d => d.epoch === bestModelEpoch);
chart.append("line")
.attr("x1", xScale(bestModelEpoch))
.attr("x2", xScale(bestModelEpoch))
.attr("y1", 0)
.attr("y2", innerHeight)
.attr("stroke", theme.success)
.attr("stroke-width", 2)
.attr("stroke-dasharray", "6,4");
// Best model point marker
chart.append("circle")
.attr("cx", xScale(bestModelEpoch))
.attr("cy", yScale(bestData.valLoss))
.attr("r", 8)
.attr("fill", theme.success)
.attr("stroke", theme.bgOpaque)
.attr("stroke-width", 2);
// Star/checkpoint icon
chart.append("text")
.attr("x", xScale(bestModelEpoch))
.attr("y", yScale(bestData.valLoss) + 1)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("fill", theme.textOnFill)
.attr("font-size", "10px")
.attr("font-weight", "bold")
.text("★");
// Label for best model
chart.append("text")
.attr("x", xScale(bestModelEpoch))
.attr("y", yScale(bestData.valLoss) - 18)
.attr("text-anchor", "middle")
.attr("fill", theme.success)
.attr("font-size", "11px")
.attr("font-weight", "600")
.text("SAVE CHECKPOINT");
}
// Current epoch marker
const currentX = xScale(esCurrentEpoch);
const currentTrainY = yScale(currentEpochData.trainLoss);
const currentValY = yScale(currentEpochData.valLoss);
// Vertical line at current epoch
chart.append("line")
.attr("x1", currentX)
.attr("x2", currentX)
.attr("y1", 0)
.attr("y2", innerHeight)
.attr("stroke", theme.nodeText)
.attr("stroke-width", 1)
.attr("stroke-opacity", 0.4)
.attr("stroke-dasharray", "4,4");
// Gap indicator arrow
if (currentEpochData.gap > 0.1) {
const midY = (currentTrainY + currentValY) / 2;
// Gap line
chart.append("line")
.attr("x1", currentX + 8)
.attr("x2", currentX + 8)
.attr("y1", currentTrainY)
.attr("y2", currentValY)
.attr("stroke", theme.error)
.attr("stroke-width", 2);
// Gap label
chart.append("text")
.attr("x", currentX + 18)
.attr("y", midY)
.attr("dominant-baseline", "central")
.attr("fill", theme.error)
.attr("font-size", "10px")
.attr("font-weight", "500")
.text(`Gap: ${currentEpochData.gap.toFixed(2)}`);
}
// Current points
chart.append("circle")
.attr("cx", currentX)
.attr("cy", currentTrainY)
.attr("r", 6)
.attr("fill", theme.accent)
.attr("stroke", theme.bgOpaque)
.attr("stroke-width", 2);
chart.append("circle")
.attr("cx", currentX)
.attr("cy", currentValY)
.attr("r", 6)
.attr("fill", theme.highlight)
.attr("stroke", theme.bgOpaque)
.attr("stroke-width", 2);
// Legend
const legendX = innerWidth + 20;
const legendY = 40;
// Training loss legend
chart.append("line")
.attr("x1", legendX)
.attr("x2", legendX + 25)
.attr("y1", legendY)
.attr("y2", legendY)
.attr("stroke", theme.accent)
.attr("stroke-width", 3);
chart.append("text")
.attr("x", legendX + 32)
.attr("y", legendY)
.attr("dominant-baseline", "central")
.attr("fill", theme.nodeText)
.attr("font-size", "11px")
.text("Train Loss");
// Validation loss legend
chart.append("line")
.attr("x1", legendX)
.attr("x2", legendX + 25)
.attr("y1", legendY + 25)
.attr("y2", legendY + 25)
.attr("stroke", theme.highlight)
.attr("stroke-width", 3);
chart.append("text")
.attr("x", legendX + 32)
.attr("y", legendY + 25)
.attr("dominant-baseline", "central")
.attr("fill", theme.nodeText)
.attr("font-size", "11px")
.text("Val Loss");
// Best model legend
chart.append("circle")
.attr("cx", legendX + 12)
.attr("cy", legendY + 55)
.attr("r", 6)
.attr("fill", theme.success);
chart.append("text")
.attr("x", legendX + 32)
.attr("y", legendY + 55)
.attr("dominant-baseline", "central")
.attr("fill", theme.nodeText)
.attr("font-size", "11px")
.text("Best Model");
// Status panel
const statusY = legendY + 90;
chart.append("rect")
.attr("x", legendX - 5)
.attr("y", statusY - 5)
.attr("width", 115)
.attr("height", 75)
.attr("rx", 6)
.attr("fill", theme.bgSecondary)
.attr("stroke", theme.nodeStroke)
.attr("stroke-width", 1);
chart.append("text")
.attr("x", legendX + 5)
.attr("y", statusY + 12)
.attr("fill", theme.nodeText)
.attr("font-size", "10px")
.attr("opacity", 0.7)
.text(`Epoch: ${esCurrentEpoch}`);
chart.append("text")
.attr("x", legendX + 5)
.attr("y", statusY + 28)
.attr("fill", theme.accent)
.attr("font-size", "10px")
.text(`Train: ${currentEpochData.trainLoss.toFixed(3)}`);
chart.append("text")
.attr("x", legendX + 5)
.attr("y", statusY + 44)
.attr("fill", theme.highlight)
.attr("font-size", "10px")
.text(`Val: ${currentEpochData.valLoss.toFixed(3)}`);
const phaseColor = trainingPhase === "learning" ? theme.accent :
trainingPhase === "optimal" ? (theme.success) :
(theme.error);
chart.append("text")
.attr("x", legendX + 5)
.attr("y", statusY + 60)
.attr("fill", phaseColor)
.attr("font-size", "10px")
.attr("font-weight", "600")
.text(trainingPhase.toUpperCase());
return svg.node();
}
```
Tips:
- Monitor validation loss, not just training loss
- Save the model with the best validation loss
- Consider early stopping if validation loss increases consistently
## Scaling Laws: Compute-Optimal Training
Everything so far answers *how* to train. This section answers the question that
comes **before** the first step: given a fixed compute budget, how big should
the model be, and how many tokens should it see? The surprising answer is that
this is predictable — model loss follows smooth **power laws** in size, data,
and compute, so you can plan the run instead of guessing.
### The Compute Rule: `C ≈ 6ND`
The compute (in FLOPs) of one training run is captured by a famously simple
approximation:
$$
C \approx 6 \cdot N \cdot D
$$
where $N$ is the number of parameters and $D$ is the number of training tokens.
The 6 counts roughly **2 FLOPs per parameter per token** in the forward pass and
**4 in the backward pass** (the backward pass computes gradients w.r.t. both
activations and weights). This one relation is the budget line every planning
decision moves along: for a fixed $C$, buying more parameters means buying fewer
tokens, and vice versa.
```{python}
from scaling import compute_flops, chinchilla_loss
# A 1B-parameter model trained on 20B tokens.
flops = compute_flops(n_params=1e9, n_tokens=20e9)
print(f"Compute: {flops:.2e} FLOPs")
print(f"Predicted loss: {chinchilla_loss(1e9, 20e9):.3f} nats/token")
```
### From Kaplan to Chinchilla
Two landmark papers shaped how the field spends compute:
- **Kaplan et al. (2020)** measured that test loss falls as a power law in $N$,
$D$, and $C$, and concluded that most extra compute should go into **bigger
models** — data could lag behind.
- **Hoffmann et al. (2022), "Chinchilla"** re-fit the curves more carefully and
found Kaplan's recipe left models badly **under-trained**. Compute-optimal
training grows parameters and tokens *together* — a rule of thumb of about
**20 tokens per parameter**. Their 70B-parameter Chinchilla, trained on 1.4T
tokens, beat the 280B-parameter Gopher trained on only 300B tokens — using the
*same* compute.
Chinchilla summarized the loss surface with a parametric fit:
$$
L(N, D) = E + \frac{A}{N^{\alpha}} + \frac{B}{D^{\beta}}
$$
$E$ is the **irreducible loss** (the entropy of natural text you can never beat);
the two power-law terms shrink as you add parameters or data. `scaling.py`
implements this fit and finds the loss-minimizing split of any compute budget by
a ternary search along the $C = 6ND$ constraint.
```{python}
from scaling import compute_optimal_allocation, chinchilla_rule_allocation
C = compute_flops(7e10, 1.4e12) # Chinchilla's own compute budget
rule = chinchilla_rule_allocation(C, tokens_per_param=20)
print(f"20x rule of thumb -> {rule['n_params']/1e9:.0f}B params, "
f"{rule['n_tokens']/1e12:.1f}T tokens") # recovers Chinchilla exactly
opt = compute_optimal_allocation(C)
print(f"Parametric optimum -> {opt['n_params']/1e9:.0f}B params, "
f"{opt['n_tokens']/1e12:.1f}T tokens, "
f"{opt['tokens_per_param']:.0f} tokens/param")
```
Notice the parametric optimum's ratio is **above** 20 — the fit implies the
compute-optimal tokens-per-parameter ratio *drifts upward* with scale. That is
one reason modern models are trained far past 20× (Llama-3's 8B model saw ~15T
tokens — about **1875** tokens per parameter): once training compute is spent,
a smaller-but-longer-trained model is cheaper to *serve*.
### The Undertraining Story, Quantified
The clearest way to see scaling laws at work is to place real models against the
**compute-optimal frontier** — the lowest loss achievable at each compute
budget. A model with too many parameters for its token budget sits *above* the
frontier: it wasted FLOPs it could have spent on data.
```{python}
from scaling import demonstrate_scaling
_ = demonstrate_scaling()
```
The `excess` column is how much loss each model gives up versus a compute-optimal
model using identical FLOPs. Gopher and GPT-3 — parameter-heavy, ~1 token per
parameter — give up the most; Chinchilla sits almost on the frontier.
### How the Frontier Is Found: IsoFLOP Profiles
Everything above *used* the coefficients $(E, A, B, \alpha, \beta)$ — but where
do they come from? `CHINCHILLA_COEFFS` is a fitted result, not a law of nature.
This book earns its keep by building things, so let's build the measurement
behind the most-quoted number in the field. Chinchilla found the frontier three
ways; the cleanest to reproduce is **Approach 2 — IsoFLOP profiles**:
1. Pick a compute budget $C$.
2. Train models of many sizes $N$ at that budget — the tokens
$D = C / 6N$ are forced by the budget line, so bigger models see fewer tokens.
3. Plot final loss against $N$. The curve is a **valley**: too-small models
waste compute on surplus data, too-large models starve for it. The bottom is
the compute-optimal model *for that budget*.
4. Read $N_{\text{opt}}(C)$ off each valley, then **fit** how it grows with $C$.
The valley has a closed form we can check against. Substitute $D = C/6N$ into the
loss and differentiate along the budget line:
$$
L(N) = E + \frac{A}{N^{\alpha}} + B\left(\frac{6N}{C}\right)^{\beta}, \qquad
\frac{dL}{dN} = 0 \;\Longrightarrow\; N_{\text{opt}} \propto C^{\,\beta/(\alpha+\beta)}.
$$
So the compute-optimal **exponent is a slope you can measure**:
$a = \beta/(\alpha+\beta)$. With Chinchilla's $\alpha=0.34,\ \beta=0.28$ that is
$a \approx 0.45$, and because $N \cdot D$ is pinned by $C$, tokens scale as
$D_{\text{opt}} \propto C^{\,1-a} \approx C^{0.55}$. Two exponents near $\tfrac12$,
summing to one — *that* is "grow parameters and tokens together."
We recover both from data without touching the closed form. `isoflop_optimum`
reads a valley off a simulated profile by grid `argmin`, and `fit_power_law` fits
a straight line in log–log space (a power law is a line once you take logs):
```{python}
from scaling import fit_power_law, isoflop_optimum, compute_flops
# A power law is a line in log-log space; recover y = 3·x^2 from clean points.
fit = fit_power_law([1, 2, 4, 8], [3, 12, 48, 192])
print(f"fit_power_law -> y = {fit['coefficient']:.1f}·x^{fit['exponent']:.2f}")
# The measured valley matches the analytic optimum — no closed form used.
print(f"IsoFLOP valley at 1e21 FLOPs: "
f"{isoflop_optimum(1e21)['n_params']/1e6:.0f}M params")
```
Now run the full Approach-2 pipeline — profile nine budgets, read each valley,
fit the two exponents — and compare to Chinchilla's own reported numbers:
```{python}
from scaling import demonstrate_isoflop
_ = demonstrate_isoflop()
```
Fitting profiles generated by the Approach-3 loss law recovers **that
approach's** exponents — $a \approx 0.45$, $b \approx 0.55$ — measured, not
quoted, with $a + b = 1$ falling out to three decimals because the $C = 6ND$
budget line forces $D \propto C/N$.
#### Interactive: The IsoFLOP Valley
Drag the compute budget. The left panel recomputes the loss-vs-$N$ **profile**
live and drops a marker at its valley; the right panel places that valley on the
fitted scaling line. Watch the bottom slide rightward along a straight line whose
**slope is the exponent** $a \approx 0.45$ — the scaling law, drawn as you move.
```{python}
#| echo: false
#| output: false
# Bridge the fitted line + the loss-law coefficients so the widget can recompute
# profiles live in the browser (the same L(N,D) the Python code fits).
import math as _math2
from scaling import CHINCHILLA_COEFFS as _CC, fit_isoflop_scaling as _fis
_isofit = _fis()
ojs_define(isoflopCoeffs = {k: float(v) for k, v in _CC.items()})
ojs_define(isoflopFit = {
"a": _isofit["a"], "b": _isofit["b"], "n_coeff": _isofit["n_coeff"],
})
ojs_define(isoflopMinima = [
{"log10_c": _math2.log10(p["flops"]), "log10_n": _math2.log10(p["n_opt"])}
for p in _isofit["points"]
])
```
```{ojs}
//| echo: false
viewof isoflopBudgetExp = Inputs.range([19, 27], {
value: 23.8, step: 0.1, label: "log₁₀ compute budget (FLOPs)"
})
```
```{ojs}
//| echo: false
// Recompute one IsoFLOP profile in the browser from the fitted loss law.
isoflopLive = {
const E = isoflopCoeffs.E, A = isoflopCoeffs.A, B = isoflopCoeffs.B;
const al = isoflopCoeffs.alpha, be = isoflopCoeffs.beta;
const C = 10 ** isoflopBudgetExp;
const loss = (N) => {
const D = C / (6 * N);
return E + A / N ** al + B / D ** be;
};
// Find the valley on a fine sweep of log10(N).
let best = { logN: 7, loss: Infinity };
for (let lg = 5; lg <= 13; lg += 0.002) {
const L = loss(10 ** lg);
if (L < best.loss) best = { logN: lg, loss: L };
}
// Display curve: ±1.5 decades around the valley.
const curve = [];
for (let lg = best.logN - 1.5; lg <= best.logN + 1.5; lg += 0.05) {
curve.push({ logN: lg, loss: loss(10 ** lg) });
}
const N = 10 ** best.logN, D = C / (6 * N);
return { curve, valley: best, N, D, tokPerParam: D / N };
}
```
```{ojs}
//| echo: false
isoflopReadout = {
const fmt = x => x >= 1e12 ? (x / 1e12).toFixed(2) + "T"
: x >= 1e9 ? (x / 1e9).toFixed(2) + "B"
: x >= 1e6 ? (x / 1e6).toFixed(0) + "M"
: x.toFixed(0);
return html`<div style="font-family: var(--pg-mono); font-size: 14px; margin: 8px 0 4px; color: ${diagramTheme.nodeText};">
Valley at <strong>10<sup>${isoflopBudgetExp.toFixed(1)}</sup></strong> FLOPs:
<span style="color: ${diagramTheme.highlight}; font-weight: 600;">${fmt(isoflopLive.N)} params · ${fmt(isoflopLive.D)} tokens · ${isoflopLive.tokPerParam.toFixed(0)} tokens/param</span>`;
}
```
```{ojs}
//| echo: false
isoflopValleyChart = Plot.plot({
width: 380,
height: 340,
marginLeft: 52,
marginBottom: 44,
style: { background: "transparent", color: diagramTheme.nodeText, fontSize: "12px" },
x: { label: "log₁₀ model size N →", grid: true },
y: { label: "↑ loss (nats/token)", grid: true },
marks: [
Plot.line(isoflopLive.curve, {
x: "logN", y: "loss", stroke: diagramTheme.accent, strokeWidth: 2.5, curve: "natural"
}),
Plot.ruleX([isoflopLive.valley.logN], { stroke: diagramTheme.highlight, strokeDasharray: "4,4" }),
Plot.dot([isoflopLive.valley], { x: "logN", y: "loss", fill: diagramTheme.highlight, r: 6 }),
Plot.text([{ logN: isoflopLive.valley.logN, loss: isoflopLive.valley.loss }], {
text: ["valley = compute-optimal"], dy: -14, fontSize: 11, fill: diagramTheme.highlight
})
]
})
```
```{ojs}
//| echo: false
isoflopLineChart = {
// The current budget's valley as a point on the (log C, log N) plane.
const here = { log10_c: isoflopBudgetExp, log10_n: Math.log10(isoflopLive.N) };
// The fitted line: log N = log(n_coeff) + a · log C (log10 form).
const a = isoflopFit.a;
const kLog10 = Math.log10(isoflopFit.n_coeff);
const line = [18, 28].map(lc => ({ log10_c: lc, log10_n: kLog10 + a * lc }));
return Plot.plot({
width: 380,
height: 340,
marginLeft: 52,
marginBottom: 44,
style: { background: "transparent", color: diagramTheme.nodeText, fontSize: "12px" },
x: { label: "log₁₀ compute C →", grid: true, domain: [18.5, 27.5] },
y: { label: "↑ log₁₀ optimal N", grid: true },
marks: [
Plot.line(line, { x: "log10_c", y: "log10_n", stroke: diagramTheme.edgeStroke, strokeWidth: 1.5, strokeDasharray: "5,4" }),
Plot.dot(isoflopMinima, { x: "log10_c", y: "log10_n", r: 5, fill: diagramTheme.nodeFill, stroke: diagramTheme.accent, strokeWidth: 1.5 }),
Plot.dot([here], { x: "log10_c", y: "log10_n", r: 7, fill: diagramTheme.highlight }),
Plot.text([{ log10_c: 22.5, log10_n: kLog10 + a * 24.4 }], {
text: [`slope a = ${a.toFixed(2)}`], fontSize: 12, fill: diagramTheme.accent, dy: -6
})
]
});
}
```
```{ojs}
//| echo: false
html`<div style="display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-start;">
<div>${isoflopValleyChart}</div>
<div>${isoflopLineChart}</div>
</div>`
```
::: {.callout-note}
## Key Insight
**The compute-optimal exponent is a slope you can measure.** "20 tokens per
parameter" is not an axiom handed down from a paper — it is a *consequence* of
$a \approx b \approx \tfrac12$, which itself is read off the valleys of IsoFLOP
profiles. Fitting the minima of curves generated by the loss law recovers its own
exponents ($a\approx0.45$, $b\approx0.55$), and $a + b = 1$ is just the
$C = 6ND$ budget line reasserting itself. Discover the law and the rule of thumb
falls out for free.
:::
::: {.callout-warning}
## Simulated Runs Are a Teaching Stand-In
Real IsoFLOP profiles come from *actually training* hundreds of models, each
with its own noisy final loss; the valley is estimated through that scatter, and
the fit is only as trustworthy as the spread of sizes around each minimum. Here
the fitted loss law plays the role of those runs so the whole method runs in
milliseconds — the *procedure* (sweep, find the valley, fit the slope) is
identical, but a from-scratch sweep of tiny real models would wobble where this
one is exact.
:::
### Interactive: Compute-Optimal Explorer
Slide the compute budget and read off the compute-optimal model (via the 20×
rule). The chart plots the loss frontier against compute on a log-compute axis,
with four real models placed where their own compute and loss land — the ones
floating **above** the line are the under-trained ones.
```{python}
#| echo: false
#| output: false
# Bridge the frontier envelope and the landmark models to the plot below.
import math as _math
from scaling import (
compute_optimal_allocation as _opt,
model_vs_frontier as _mvf,
LANDMARK_MODELS as _LM,
)
_frontier = []
_e = 0.0
while _e <= 8.0001: # log10(C) from 18 to 26
_c = 10 ** (18.0 + _e)
_o = _opt(_c)
_frontier.append({"logC": _math.log10(_c), "loss": _o["loss"]})
_e += 0.25
_models = []
for _name, (_n, _d) in _LM.items():
_r = _mvf(_name, _n, _d)
_models.append({
"name": _name,
"logC": _math.log10(_r["flops"]),
"loss": _r["loss"],
"tokens_per_param": _r["tokens_per_param"],
})
ojs_define(scalingFrontier = _frontier)
ojs_define(scalingModels = _models)
```
```{ojs}
//| echo: false
viewof scalingBudgetExp = Inputs.range([18, 26], {
value: 23.77, step: 0.1, label: "log₁₀ compute budget (FLOPs)"
})
```
```{ojs}
//| echo: false
scalingReadout = {
// The 20x rule of thumb: N = sqrt(C / (6 * 20)), D = 20 * N.
const C = 10 ** scalingBudgetExp;
const N = Math.sqrt(C / (6 * 20));
const D = 20 * N;
const fmt = x => x >= 1e12 ? (x / 1e12).toFixed(2) + "T"
: x >= 1e9 ? (x / 1e9).toFixed(2) + "B"
: (x / 1e6).toFixed(0) + "M";
const label = `${fmt(N)} params · ${fmt(D)} tokens · 20 tokens/param`;
return html`<div style="font-family: var(--pg-mono); font-size: 14px; margin: 8px 0 4px; color: ${diagramTheme.nodeText};">
Compute-optimal model at <strong>10<sup>${scalingBudgetExp.toFixed(1)}</sup></strong> FLOPs:
<span style="color: ${diagramTheme.highlight}; font-weight: 600;">${label}</span>
</div>`;
}
```
```{ojs}
//| echo: false
scalingChart = {
// Interpolate the frontier loss at the selected budget for the marker.
const budgetLoss = (() => {
const xs = scalingFrontier;
const x = scalingBudgetExp;
for (let i = 1; i < xs.length; i++) {
if (xs[i].logC >= x) {
const a = xs[i - 1], b = xs[i];
const t = (x - a.logC) / (b.logC - a.logC);
return a.loss + t * (b.loss - a.loss);
}
}
return xs[xs.length - 1].loss;
})();
return Plot.plot({
width: 720,
height: 440,
marginLeft: 56,
marginBottom: 46,
style: { background: "transparent", color: diagramTheme.nodeText, fontSize: "13px" },
x: { label: "log₁₀ training compute (FLOPs) →", grid: true },
y: { label: "↑ loss (nats/token)", grid: true },
marks: [
Plot.line(scalingFrontier, {
x: "logC", y: "loss",
stroke: diagramTheme.accent, strokeWidth: 2.5
}),
Plot.ruleX([scalingBudgetExp], { stroke: diagramTheme.highlight, strokeDasharray: "4,4" }),
Plot.dot([{ logC: scalingBudgetExp, loss: budgetLoss }], {
fill: diagramTheme.highlight, r: 6
}),
Plot.dot(scalingModels, {
x: "logC", y: "loss", r: 6,
fill: diagramTheme.nodeFill, stroke: diagramTheme.edgeStroke, strokeWidth: 1.5
}),
Plot.text(scalingModels, {
x: "logC", y: "loss", text: "name",
dy: -12, fontSize: 11, fill: diagramTheme.nodeText
}),
Plot.text([{ logC: scalingFrontier[2].logC, loss: scalingFrontier[2].loss }], {
text: ["compute-optimal frontier"], dy: -10, dx: 40,
fontSize: 11, fill: diagramTheme.accent
})
]
});
}
```
::: {.callout-note}
## Key Insight
**Under-training is spending compute on parameters instead of data.** A model
that is too large for its token budget sits above the compute-optimal frontier —
it would have reached a lower loss, at the *same* FLOPs, as a smaller model
trained on more tokens. Chinchilla's headline result was that most large models
of its era (Gopher, GPT-3, MT-NLG) were on the wrong side of that line.
:::
::: {.callout-warning}
## "20 Tokens per Parameter" Is a Rule of Thumb, Not a Law
The 20× ratio comes from Chinchilla's compute-optimal fit *at their scale*. The
parametric loss law implies the optimal ratio **grows** with compute, and — just
as important — compute-optimal training only minimizes *training* cost. When a
model will be served to many users, it is often worth **over-training** a smaller
model far past 20× (Llama-3 8B: ~1875 tokens/param) to cut inference cost. Use
scaling laws to plan, but pick the ratio for *your* deployment, not a magic
number.
:::
### Fitting Through Noise: Fit the Curve, Not the Lowest Dot
The valley-reader above (`isoflop_optimum`) took the single lowest point of a
profile — a plain `argmin`. That is honest here *only because our losses are
exact*: they come from a formula, so the lowest dot really is the minimum. Real
IsoFLOP profiles are **measured** — each dot is one model you actually trained,
with its own noisy final loss (the seed, the data order, exactly where you
stopped). The "simulated runs are a teaching stand-in" warning above flagged
this; now we pay it off.
Here is the trap. Near the bottom the valley is nearly **flat** — that is what
"optimal" means, the loss barely changes as you nudge the model size. So a
whisker of measurement noise is enough to hand "lowest" to a *neighbour*. Run the
same experiment again with a new seed and the `argmin` jumps to a different model
size. The estimate is chasing the noise, not the valley.
This is exactly why Hoffmann et al. did **not** take the argmin. In their own
words: *"We fit a parabola to each IsoFLOPs curve to directly estimate at what
model size the minimum loss is achieved."* A parabola uses **every** point in a
window around the bottom and reads off the vertex — it pools the measurements and
leans on the *curvature*, which noise cannot easily fake.
#### The Math: Why a Flat Valley Punishes the Argmin
Near its minimum any smooth loss is locally a parabola in $x = \log_{10} N$:
$$
L(x) \;\approx\; a\,(x - x^\star)^2 + c ,
$$
where $x^\star$ is the true compute-optimal size and $a > 0$ is the valley's
**curvature** — small $a$ means a flat, forgiving valley. Add independent
measurement noise of scale $\sigma$ to each observed loss. The `argmin` is fooled
whenever noise overcomes the real loss gap between two sizes; on a flat valley
that gap grows only like $a\,\Delta x^2$, so the argmin's spread blows up roughly
like $\sqrt{\sigma / a}$ — the flatter the valley, the worse it gets.
The parabola fit instead solves an ordinary least squares for
$y = a x^2 + b x + c$ over a window of points and returns the vertex
$x^\star = -b / 2a$. Because it averages many points and estimates the *curvature*
$a$ directly, its spread shrinks like $\sigma / \sqrt{K}$ in the number of points
$K$ — it barely moves where the argmin lurches. Same runs, same noise; the fit
just uses more of the information in them.
```{python}
#| echo: false
# Fit a parabola from scratch (a 3×3 normal-equations solve) and read its vertex;
# then estimate a noisy IsoFLOP valley both ways. See scaling.py for the code.
from scaling import fit_parabola, isoflop_profile, estimate_valley, compute_optimal_allocation
# The vertex of y = (x − 1)² is recovered exactly from five clean points.
_p = fit_parabola([-1, 0, 1, 2, 3], [(x - 1) ** 2 for x in [-1, 0, 1, 2, 3]])
print(f"parabola vertex: x* = {_p['x_min']:.3f}, y* = {_p['y_min']:.3f} (want 1.000, 0.000)")
# One noisy profile at C = 1e21; the two estimators disagree.
_prof = isoflop_profile(1e21, samples=41, noise=0.02, seed=0)
_true = compute_optimal_allocation(1e21)["n_params"]
_arg = estimate_valley(_prof, method="argmin")["n_params"]
_par = estimate_valley(_prof, method="parabola")["n_params"]
print(f"true optimum : {_true/1e6:8.1f}M params")
print(f"argmin guess: {_arg/1e6:8.1f}M params ({100*abs(_arg-_true)/_true:5.1f}% off)")
print(f"parabola fit : {_par/1e6:8.1f}M params ({100*abs(_par-_true)/_true:5.1f}% off)")
```
Now the honest headline — not one lucky seed but the **spread over many**. The
`demonstrate_noisy_isoflop` driver draws 200 independent noisy profiles at the
same budget, estimates the valley both ways on each, and reports how far each
estimator wanders from the analytic truth:
```{python}
#| echo: false
from scaling import demonstrate_noisy_isoflop
_ = demonstrate_noisy_isoflop()
```
At a realistic $\sigma = 0.02$ nats of per-run noise the bare `argmin` scatters
almost **three times wider** than the parabola fit — and, crucially, both are
roughly *unbiased*, so the argmin isn't wrong on average, it is just **noisy**.
That extra variance is compute you paid for and threw away by reading one dot
instead of the curve.
#### Interactive: One Noisy Profile
Turn up the **measurement noise** and **reseed** the runs. The grey curve is the
true (noise-free) valley; the dots are what you actually measured. Watch the
`argmin` marker (which dot is lowest) **teleport** between sizes as you reseed,
while the fitted parabola's vertex stays pinned near the true optimum. At
$\sigma = 0$ they agree; crank $\sigma$ and the argmin comes apart first.
```{python}
#| echo: false
#| output: false
# Bridge the loss-law coefficients and the verified spread numbers so the widget
# can recompute noisy profiles live in the browser (same L(N,D) the code uses).
import math as _mn
from scaling import (
CHINCHILLA_COEFFS as _CCn,
compute_optimal_allocation as _coan,
demonstrate_noisy_isoflop as _dnin,
)
_nb = _dnin(verbose=False)
ojs_define(noiseCoeffs = {k: float(v) for k, v in _CCn.items()})
ojs_define(noiseBudgetExp = 21.0)
ojs_define(noiseTruthLog10N = float(_mn.log10(_coan(1e21)["n_params"])))
ojs_define(noiseSpread = {
"argmin_std": float(_nb["robustness"]["argmin_std"]),
"parabola_std": float(_nb["robustness"]["parabola_std"]),
"std_ratio": float(_nb["robustness"]["std_ratio"]),
})
```
```{ojs}
//| echo: false
viewof noiseSigma = Inputs.range([0, 0.06], {
value: 0.02, step: 0.002, label: "measurement noise σ (nats)"
})
```
```{ojs}
//| echo: false
viewof noiseSeed = Inputs.range([0, 40], {
value: 0, step: 1, label: "reseed the runs"
})
```
```{ojs}
//| echo: false
// A tiny seedable PRNG (mulberry32) + Box–Muller, so the noise is reproducible
// and the reader can step through seeds deterministically.
noiseRng = {
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rng = mulberry32(1000 + noiseSeed);
return () => {
const u = 1 - rng(), v = rng();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
};
}
```
```{ojs}
//| echo: false
// Fit y = a·x² + b·x + c by least squares and return its vertex (or null if the
// window fits a non-convex parabola — the same graceful-failure guard as the code).
fitParabolaJS = function (xs, ys) {
const n = xs.length;
let s1 = 0, s2 = 0, s3 = 0, s4 = 0, t0 = 0, t1 = 0, t2 = 0;
for (let i = 0; i < n; i++) {
const x = xs[i], y = ys[i], x2 = x * x;
s1 += x; s2 += x2; s3 += x2 * x; s4 += x2 * x2;
t0 += y; t1 += x * y; t2 += x2 * y;
}
const M = [[s4, s3, s2], [s3, s2, s1], [s2, s1, n]];
const T = [t2, t1, t0];
const det3 = (m) =>
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) -
m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) +
m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
const d = det3(M);
if (d === 0) return null;
const col = (j) => {
const mj = M.map((r) => r.slice());
for (let i = 0; i < 3; i++) mj[i][j] = T[i];
return det3(mj) / d;
};
const a = col(0), b = col(1);
if (a <= 0) return null;
const xmin = -b / (2 * a);
return { a, xmin };
}
```
```{ojs}
//| echo: false
// Build one noisy profile at C = 1e21 and estimate its valley both ways.
noiseProfile = {
const E = noiseCoeffs.E, A = noiseCoeffs.A, B = noiseCoeffs.B;
const al = noiseCoeffs.alpha, be = noiseCoeffs.beta;
const C = 10 ** noiseBudgetExp;
const clean = (x) => { // x = log10(N)
const N = 10 ** x, D = C / (6 * N);
return E + A / N ** al + B / D ** be;
};
const center = noiseTruthLog10N;
const rng = noiseRng; // depends on noiseSeed
const pts = [];
for (let i = 0; i < 41; i++) {
const x = center - 1.5 + (3.0 * i) / 40;
const c = clean(x);
pts.push({ x, clean: c, obs: c + noiseSigma * rng() });
}
// argmin: the lowest observed dot.
let lo = pts[0];
for (const p of pts) if (p.obs < lo.obs) lo = p;
// parabola: fit a ±0.6-decade window around that coarse minimum.
const win = pts.filter((p) => Math.abs(p.x - lo.x) <= 0.6);
const fit = fitParabolaJS(win.map((p) => p.x), win.map((p) => p.obs));
let parX = fit ? fit.xmin : lo.x;
if (!(pts[0].x <= parX && parX <= pts[pts.length - 1].x)) parX = lo.x;
return { pts, argminX: lo.x, parabolaX: parX, truthX: center };
}
```
```{ojs}
//| echo: false
noiseProfileReadout = {
const fmt = (x) => `10^${x.toFixed(3)}`;
const dArg = Math.abs(noiseProfile.argminX - noiseProfile.truthX);
const dPar = Math.abs(noiseProfile.parabolaX - noiseProfile.truthX);
return html`<div style="font-size: 13px; line-height: 1.6; margin: 6px 0;">
true optimum log₁₀N = <strong>${noiseProfile.truthX.toFixed(3)}</strong> ·
<span style="color:${diagramTheme.edgeStroke};">argmin</span> off by
<strong style="color:${diagramTheme.edgeStroke};">${dArg.toFixed(3)}</strong> dec ·
<span style="color:${diagramTheme.highlight};">parabola</span> off by
<strong style="color:${diagramTheme.highlight};">${dPar.toFixed(3)}</strong> dec
</div>`;
}
```
```{ojs}
//| echo: false
noiseProfileChart = Plot.plot({
width: 640,
height: 360,
marginLeft: 54,
marginBottom: 46,
style: { background: "transparent", color: diagramTheme.nodeText, fontSize: "12px" },
x: { label: "log₁₀ model size N →", grid: true },
y: { label: "↑ measured loss (nats)", grid: true },
marks: [
Plot.line(noiseProfile.pts, { x: "x", y: "clean", stroke: diagramTheme.edgeStroke, strokeWidth: 1.5, opacity: 0.5 }),
Plot.dot(noiseProfile.pts, { x: "x", y: "obs", r: 3, fill: diagramTheme.nodeFill, stroke: diagramTheme.nodeText, strokeOpacity: 0.4 }),
Plot.ruleX([noiseProfile.truthX], { stroke: diagramTheme.nodeText, strokeDasharray: "2,3", opacity: 0.6 }),
Plot.ruleX([noiseProfile.argminX], { stroke: diagramTheme.edgeStroke, strokeWidth: 2 }),
Plot.ruleX([noiseProfile.parabolaX], { stroke: diagramTheme.highlight, strokeWidth: 2 }),
Plot.text([{ x: noiseProfile.truthX, y: 0 }], { text: ["true"], frameAnchor: "top", dy: 2, fontSize: 11, fill: diagramTheme.nodeText }),
Plot.text([{ x: noiseProfile.argminX }], { text: ["argmin"], frameAnchor: "bottom", dy: -2, fontSize: 11, fill: diagramTheme.edgeStroke }),
Plot.text([{ x: noiseProfile.parabolaX }], { text: ["parabola"], frameAnchor: "top", dy: 16, fontSize: 11, fill: diagramTheme.highlight })
]
})
```
```{ojs}
//| echo: false
html`<div>${noiseProfileReadout}${noiseProfileChart}</div>`
```
::: {.callout-tip}
## Try This
Drag $\sigma$ to $0$: the argmin and the parabola vertex land on the same size —
with no noise, the lowest dot *is* the minimum. Now push $\sigma$ up and hold
down the reseed slider. The **argmin marker jumps** from size to size on almost
every seed, while the **parabola line barely twitches**. Keep going: find the
$\sigma$ where even the fit starts to wobble — that is the point where your runs
are too noisy for the number of models you trained, and the cure is more sizes
around the valley, not a lower dot.
:::
#### Interactive: The Estimator Race
One profile is an anecdote. Run the whole experiment **120 times** at the current
noise level and histogram where each estimator lands. The true optimum is the
dashed line; a good estimator makes a tight pile on it. The `argmin` cloud is
wide and grainy (it can only ever land *on* a grid size); the parabola cloud is
tight and continuous.
```{ojs}
//| echo: false
noiseRace = {
const E = noiseCoeffs.E, A = noiseCoeffs.A, B = noiseCoeffs.B;
const al = noiseCoeffs.alpha, be = noiseCoeffs.beta;
const C = 10 ** noiseBudgetExp;
const center = noiseTruthLog10N;
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const grid = [];
for (let i = 0; i < 41; i++) grid.push(center - 1.5 + (3.0 * i) / 40);
const clean = grid.map((x) => {
const N = 10 ** x, D = C / (6 * N);
return E + A / N ** al + B / D ** be;
});
const argEst = [], parEst = [];
for (let s = 0; s < 120; s++) {
const rng = mulberry32(5000 + s);
const obs = clean.map((c) => c + noiseSigma * Math.sqrt(-2 * Math.log(1 - rng())) * Math.cos(2 * Math.PI * rng()));
let li = 0;
for (let i = 1; i < obs.length; i++) if (obs[i] < obs[li]) li = i;
argEst.push(grid[li]);
const win = [];
for (let i = 0; i < grid.length; i++) if (Math.abs(grid[i] - grid[li]) <= 0.6) win.push([grid[i], obs[i]]);
const fit = fitParabolaJS(win.map((p) => p[0]), win.map((p) => p[1]));
let px = fit ? fit.xmin : grid[li];
if (!(grid[0] <= px && px <= grid[grid.length - 1])) px = grid[li];
parEst.push(px);
}
const std = (a) => { const m = d3.mean(a); return Math.sqrt(d3.mean(a.map((v) => (v - m) ** 2))); };
return {
rows: argEst.map((v) => ({ estimator: "argmin", x: v }))
.concat(parEst.map((v) => ({ estimator: "parabola", x: v }))),
argStd: std(argEst), parStd: std(parEst), truthX: center
};
}
```
```{ojs}
//| echo: false
noiseRaceChart = Plot.plot({
width: 640,
height: 300,
marginLeft: 78,
marginBottom: 46,
style: { background: "transparent", color: diagramTheme.nodeText, fontSize: "12px" },
color: { domain: ["argmin", "parabola"], range: [diagramTheme.edgeStroke, diagramTheme.highlight], legend: true },
x: { label: "recovered log₁₀ N_opt (120 seeds) →", grid: true },
y: { label: null, domain: ["argmin", "parabola"] },
marks: [
Plot.ruleX([noiseRace.truthX], { stroke: diagramTheme.nodeText, strokeDasharray: "3,3", opacity: 0.7 }),
Plot.dot(noiseRace.rows, {
x: "x", y: "estimator", fill: "estimator", r: 4, fillOpacity: 0.35,
dy: () => (Math.random() - 0.5) * 10
}),
Plot.tickX(noiseRace.rows, { x: "x", y: "estimator", stroke: "estimator", strokeOpacity: 0.5 })
]
})
```
```{ojs}
//| echo: false
noiseRaceReadout = html`<div style="font-size: 13px; line-height: 1.6; margin: 6px 0;">
spread of recovered log₁₀N over 120 seeds ·
<span style="color:${diagramTheme.edgeStroke};">argmin σ = <strong>${noiseRace.argStd.toFixed(3)}</strong></span>
vs
<span style="color:${diagramTheme.highlight};">parabola σ = <strong>${noiseRace.parStd.toFixed(3)}</strong></span>
→ the fit is <strong>${(noiseRace.argStd / Math.max(noiseRace.parStd, 1e-9)).toFixed(1)}×</strong> tighter
</div>`
```
```{ojs}
//| echo: false
html`<div>${noiseRaceReadout}${noiseRaceChart}</div>`
```
::: {.callout-note}
## Key Insight
**A flat minimum is where you must fit, not pick.** The very property that makes a
model *compute-optimal* — the loss is insensitive to size right there — is what
makes the single lowest run a terrible pointer to it. The estimator's whole job is
to recover the valley's **location from its curvature**, and a parabola fit does
exactly that while an argmin throws the curvature away and reads one noisy dot.
This is not a scaling-laws quirk; it is the general rule for reading a minimum off
noisy measurements, from learning-rate sweeps to hyperparameter search.
:::
::: {.callout-warning}
## Pitfall: Never Argmin a Noisy Sweep
Taking the single best run of a sweep — the lowest loss, the top eval score, the
fastest step — is `argmin`, and on a flat landscape it reports the noise, not the
signal. Fit a smooth curve through the points and read *its* optimum. The opposite
mistake also bites: a **too-wide** parabola window drags in the profile's tails,
which are not quadratic, and *biases* the vertex. Fit a window snug around the
minimum — wide enough to average the noise, narrow enough to stay in the bowl.
:::
### Going Deeper
- Hoffmann, Borgeaud, Mensch, et al., [*Training Compute-Optimal Large Language
Models*](https://arxiv.org/abs/2203.15556) (Chinchilla, 2022) — the three
estimation approaches; §3.2 is the IsoFLOP method we reproduced, and Table 2
reports the exponents ($N\propto C^a$, $D\propto C^b$): Approach 1 (0.50/0.50),
Approach 2 (0.49/0.51), Approach 3 (0.46/0.54, the parametric fit whose
coefficients `scaling.py` carries).
- Kaplan, McCandlish, Henighan, et al., [*Scaling Laws for Neural Language
Models*](https://arxiv.org/abs/2001.08361) (2020) — the original power-law
framing Chinchilla corrected.
- Besiroglu et al., [*Chinchilla Scaling: A Replication
Attempt*](https://arxiv.org/abs/2404.10102) (2024) — how sensitive the fitted
exponents are to the fitting procedure, a good sanity check on the numbers above.
## Zero-Shot HP Transfer: Maximal Update Parametrization (μP)
Scaling laws told us *how big* to make the model. They said nothing about its
**hyperparameters** — the learning rate, the initialization scale, the output
multiplier. And here is the trap: under the parametrization everyone uses by
default (call it **standard parametrization, SP**), the *optimal learning rate
shifts every time you make the model wider.* The value you carefully tuned on a
100M-parameter model is simply wrong for a 10B one — but you cannot afford to
sweep learning rates on the 10B model to find out.
**Maximal Update Parametrization (μP)** removes the shift. Under μP the optimal
hyperparameters are (nearly) **invariant to width**, so you can tune once on a
tiny, cheap proxy and copy the numbers straight to the giant target with no
further tuning. This is **μTransfer** (Yang et al., 2022). Cerebras-GPT tuned its
hyperparameters on a ~40M-parameter proxy and reused the same learning rate all
the way up to 2.7B — for about 7% of the target's compute.
### Intuition: keep every layer's update Θ(1)
Why does the optimal learning rate drift under SP? Watch what one Adam step does
to a hidden preactivation. A hidden unit is a sum over the width:
$$
h_i = \sum_{j=1}^{n} W_{ij}\, x_j .
$$
Adam normalizes each gradient to roughly unit scale, so after a step every weight
entry moves by about $\eta$ (the learning rate). The preactivation therefore moves
by
$$
\Delta h_i = \sum_{j=1}^{n} \Delta W_{ij}\, x_j \;\approx\; \eta \cdot \Theta(n),
$$
because after the first gradient step the update aligns with the input and the
$n$ terms **add coherently**. Read that again: the size of the update *grows with
the width* $n$. SP holds $\eta$ fixed as you scale, so a wider model takes a
*bigger* step in feature space — it overshoots, and you have to lower the learning
rate to compensate. That is the drift.
μP's fix is embarrassingly simple: scale the hidden learning rate **down** in
proportion to width, $\eta \propto 1/n$, so that $\Delta h_i = \Theta(1)$ at
*every* width. Each layer then updates *maximally* — a full $\Theta(1)$ change,
never vanishing — yet *stably* — never exploding. That is the "maximal update" the
name refers to, and it is exactly what pins the loss landscape (and its optimal
learning rate) in place as the model grows.
::: {.callout-note}
## Key Insight
μP is not a new optimizer or architecture. It is a **rescaling of three things —
initialization, forward multipliers, and per-layer learning rates — as a function
of width**, chosen so that the training dynamics have a well-defined
infinite-width limit. Because the dynamics stop depending on width, so do the best
hyperparameters. Tune small, transfer big.
:::
### The rules (SP vs μP, for Adam)
μP is an **abc-parametrization**: for each weight tensor it specifies how the
init variance (b), a forward multiplier (a), and the learning rate (c) scale with
that tensor's `fan_in`. Weights fall into three categories — **input** (embeddings;
`fan_in` is fixed), **hidden** (both dimensions scale with width), and **output**
(the readout/unembedding). Here is the table we implement (the "Table 8" form used
by the `mup` package), expressed relative to a **base width** $n_0$ with width
multiplier $m = n/n_0$:
| Weight | Init variance | Forward multiplier | Adam LR |
|--------|---------------|--------------------|---------|
| **Input** (embeddings) | $1/\text{fan\_in}$ | $1$ | $\eta$ (constant) |
| **Hidden** ($n\times n$) | $1/\text{fan\_in}$ | $1$ | $\eta \cdot n_0/n$ |
| **Output** (readout) | $1/\text{fan\_in}$ | $n_0/n$ | $\eta$ (constant) |
Every weight is still initialized $\mathcal{N}(0,\,1/\text{fan\_in})$ — the usual
Kaiming init. **The only two things μP changes versus SP are the two shaded cells:
the hidden learning rate ($\eta \cdot n_0/n$ instead of $\eta$) and the output
multiplier ($n_0/n$ instead of $1$).** At the base width $m=1$, both revert to
$1$ — so **μP is *identical* to SP at the base width**, which is precisely the
anchor you tune at.
::: {.callout-warning}
## Transformers need one more change: 1/d attention
For an MLP the table above is complete. A Transformer maps the width down to a
*finite* number of attention heads, and every such "infinite → finite" map picks
up a $1/\text{fan\_in}$ multiplier. Concretely, μP scales attention logits by
$1/d_k$ (the head dimension) instead of the familiar $1/\sqrt{d_k}$:
$\text{logit} = q^\top k / d_k$. The `mup` package uses $8/d_k$ in practice purely so
it coincides with $1/\sqrt{d_k}$ at the common $d_k=64$.
:::
### Code: an MLP under both parametrizations
The implementation lives in `mup.py`. The scaling rules are three one-line pure
functions:
```{python}
from mup import (
width_multiplier, mup_hidden_lr, mup_output_multiplier,
MuMLP, make_task, coordinate_check, coord_blowup_ratio,
lr_transfer_sweep, optimal_log_lr,
)
# At the base width everything is 1x; a 4x-wider model quarters the hidden LR.
print("width mult (1024 vs base 256):", width_multiplier(1024, 256))
print("hidden LR (base 0.01): ", mup_hidden_lr(0.01, 1024, 256))
print("output multiplier: ", mup_output_multiplier(1024, 256))
```
`MuMLP` builds the network under either parametrization. Watch the anchor
property — at the base width, μP and SP are the *same model*:
```{python}
task = make_task()
sp = MuMLP(task.d_in, 256, task.d_out, parametrization="sp", base_width=256, seed=7)
mp = MuMLP(task.d_in, 256, task.d_out, parametrization="mup", base_width=256, seed=7)
import torch
print("identical outputs at base width:", torch.allclose(sp(task.X), mp(task.X)))
print("μP output multiplier at 256: ", mp.output_multiplier) # 1.0
# Make it 4x wider and the two μP knobs engage:
mp_wide = MuMLP(task.d_in, 1024, task.d_out, parametrization="mup", base_width=256, seed=7)
print("μP output multiplier at 1024: ", mp_wide.output_multiplier) # 0.25
print("μP hidden LR at 1024 (base 1e-2):", mp_wide.param_groups(1e-2)[1]["lr"])
```
### The coordinate check
The **coordinate check** is *the* diagnostic for a correct μP implementation, and
it makes the whole idea visible in one picture. The recipe: build the network at a
range of widths, take a few Adam steps, and plot the **average absolute activation
coordinate** of a hidden layer against width — one line per training step.
- **Correct μP:** the lines are **flat across width** — activations are $\Theta(1)$
no matter how wide the model is.
- **Broken SP:** after the very first gradient step, the activations **explode with
width** — exactly the overshoot our intuition predicted.
```{python}
widths = [64, 128, 256, 512, 1024]
sp_cc = coordinate_check(widths, parametrization="sp") # base_lr=0.03, 2 steps
mp_cc = coordinate_check(widths, parametrization="mup")
print("hidden coord size after 1 Adam step (t=1):")
print(f"{'width':>6} | {'SP':>9} | {'μP':>7}")
for w in widths:
s = next(r["coord"] for r in sp_cc if r["width"] == w and r["step"] == 1)
m = next(r["coord"] for r in mp_cc if r["width"] == w and r["step"] == 1)
print(f"{w:>6} | {s:>9.3f} | {m:>7.3f}")
print(f"\nSP blow-up 64→1024: {coord_blowup_ratio(sp_cc, 1):.0f}x")
print(f"μP blow-up 64→1024: {coord_blowup_ratio(mp_cc, 1):.1f}x")
```
Drive the slider below through the training steps. At **t = 0** (before any
update) both panels are flat — the difference is invisible at initialization. Push
to **t = 1** and the left (SP) panel fans out across two orders of magnitude while
the right (μP) panel stays pinned flat. That single frame *is* μP.
```{python}
#| echo: false
#| output: false
# Bridge the coordinate check to the plot below.
from mup import coordinate_check as _cc
_labels = {"sp": "Standard parametrization (SP)", "mup": "μP (maximal update)"}
_ccw = [64, 128, 256, 512, 1024]
_coord = []
for _p in ("sp", "mup"):
for _r in _cc(_ccw, parametrization=_p, base_lr=0.03, n_steps=2):
_coord.append({
"param": _labels[_p], "width": _r["width"],
"step": _r["step"], "coord": _r["coord"],
})
ojs_define(coordData = _coord)
```
```{ojs}
//| echo: false
viewof ccStep = Inputs.range([0, 2], { value: 1, step: 1, label: "training step t" })
```
```{ojs}
//| echo: false
coordChart = {
const stepColor = { 0: diagramTheme.edgeStroke, 1: diagramTheme.highlight, 2: diagramTheme.accent };
return Plot.plot({
width: 780,
height: 420,
marginLeft: 60,
marginRight: 16,
marginBottom: 44,
style: { background: "transparent", color: diagramTheme.nodeText, fontSize: "13px" },
fx: { label: null },
x: { type: "log", label: "width (log) →", ticks: [64, 128, 256, 512, 1024], tickFormat: String, grid: true },
y: { type: "log", label: "↑ mean |hidden activation coordinate| (log)", grid: true },
color: { domain: ["t = 0", "t = 1", "t = 2"], range: [stepColor[0], stepColor[1], stepColor[2]], legend: true },
marks: [
Plot.line(coordData, {
fx: "param", x: "width", y: "coord", z: "step",
stroke: d => `t = ${d.step}`,
strokeWidth: d => (d.step === ccStep ? 3.5 : 1),
opacity: d => (d.step === ccStep ? 1 : 0.3),
}),
Plot.dot(coordData.filter(d => d.step === ccStep), {
fx: "param", x: "width", y: "coord", fill: d => `t = ${d.step}`, r: 4,
}),
Plot.frame({ stroke: diagramTheme.edgeStroke, strokeOpacity: 0.3 }),
],
});
}
```
::: {.callout-tip}
## Try This
Hold the slider at **t = 1** and read the left panel: each doubling of width
roughly triples the SP activation size — the coherent $\Theta(n)$ growth from the
intuition, made literal. Now look at t = 0: SP and μP are indistinguishable. The
pathology is *invisible at init* and only appears once gradients flow. That is why
you cannot catch it by inspecting a freshly initialized model — you have to take a
step.
:::
### μTransfer in action
The payoff: if the dynamics are width-invariant, so is the **optimal learning
rate**. Here we sweep the learning rate at four widths and plot the final loss.
Under SP the U-curves slide leftward as the model grows — the optimum keeps
moving. Under μP the minima line up in a **vertical column**: the best learning
rate found on the smallest model is (nearly) the best on all of them.
```{python}
grid = [round(-3.0 + 0.25 * i, 2) for i in range(13)] # log10(lr): -3.0 … 0.0
for name in ("sp", "mup"):
sweep = lr_transfer_sweep([64, 128, 256, 512], grid,
parametrization=name, base_width=64, n_steps=25)
best = {w: optimal_log_lr(sweep, w) for w in [64, 128, 256, 512]}
spread = max(best.values()) - min(best.values())
row = " ".join(f"n={w}: {best[w]:+.2f}" for w in best)
print(f"{name:>3} best log₁₀(lr) → {row} (spread {spread:.2f} dex)")
```
```{python}
#| echo: false
#| output: false
from mup import lr_transfer_sweep as _lts
_lrw = [64, 128, 256, 512]
_grid = [round(-3.0 + 0.25 * i, 2) for i in range(13)]
_lr = []
for _p in ("sp", "mup"):
for _r in _lts(_lrw, _grid, parametrization=_p, base_width=64, n_steps=25):
_lr.append({
"param": _p, "width": str(_r["width"]),
"logLr": _r["log_lr"], "loss": min(_r["loss"], 1.5),
})
ojs_define(lrData = _lr)
```
```{ojs}
//| echo: false
viewof lrParam = Inputs.radio(
[{ label: "μP (aligned)", value: "mup" }, { label: "Standard (drifts)", value: "sp" }].map(d => d.value),
{ value: "mup", label: "parametrization", format: v => v === "mup" ? "μP (aligned)" : "Standard (drifts)" }
)
```
```{ojs}
//| echo: false
lrChart = {
const data = lrData.filter(d => d.param === lrParam);
const widths = [...new Set(data.map(d => d.width))];
const minima = widths.map(w => {
const pts = data.filter(d => d.width === w);
return pts.reduce((a, b) => (b.loss < a.loss ? b : a));
});
return Plot.plot({
width: 780,
height: 420,
marginLeft: 56,
marginBottom: 44,
style: { background: "transparent", color: diagramTheme.nodeText, fontSize: "13px" },
x: { label: "log₁₀ learning rate →", grid: true },
y: { label: "↑ final loss", grid: true, domain: [0, 1.5] },
color: { label: "width", legend: true, scheme: "viridis" },
marks: [
Plot.ruleX(minima, { x: "logLr", stroke: d => d.param, strokeOpacity: 0.18, strokeWidth: 6 }),
Plot.line(data, { x: "logLr", y: "loss", z: "width", stroke: "width", strokeWidth: 2, curve: "catmull-rom" }),
Plot.dot(minima, { x: "logLr", y: "loss", fill: "width", stroke: diagramTheme.nodeText, strokeWidth: 1.5, r: 6 }),
],
});
}
```
::: {.callout-tip}
## Try This
Flip the toggle between **μP** and **Standard**. Under Standard, the coloured
minima march to the left as width grows — a full order of magnitude of drift from
the smallest to the largest model. Under μP they collapse into a narrow band: the
learning rate *transfers*. (A small residual wobble remains because μTransfer is
exact only in the infinite-width limit — at widths of 64–512 there are honest
finite-size corrections. μP shrinks the drift from ~10× to under ~3×.)
:::
### Common Pitfalls with μP
- **Forgetting the base shape.** μP scalings are *relative* to a base width. You
must record the proxy's per-tensor `fan_in` ("base shapes") and scale everything
against it — a μP model with no base shape is just SP.
- **Zero-init the readout (and query).** The paper recommends initializing the
output layer (and the attention query projection) to **zero**, so their
activations are exactly $0$ at $t=0$ regardless of width. This removes a
proxy-vs-target mismatch at initialization; they become nonzero after the first
gradient step.
- **Weight decay and dropout do *not* transfer.** μTransfer covers the
optimization hyperparameters (LR, init, multipliers, Adam betas). Regularization
strength is width-dependent and must be set at the target scale.
- **1/d, not 1/√d, attention.** The single easiest thing to get wrong when moving
from an MLP to a Transformer — see the warning above.
### What μP buys you
::: {.callout-note}
## Key Insight
The economics are the whole point. Sweeping hyperparameters on a frontier model is
often as expensive as training it. μTransfer moves that sweep onto a proxy that is
*orders of magnitude* cheaper, and the result carries over. Combined with scaling
laws — which pick the compute-optimal size — μP is what makes training a model you
have *never trained before* a predictable engineering task rather than a gamble.
:::
### Going Deeper
- Yang, Hu, Babuschkin, et al., [*Tensor Programs V: Tuning Large Neural Networks
via Zero-Shot Hyperparameter Transfer*](https://arxiv.org/abs/2203.03466) (2022) —
the μTransfer paper; Tables 3 & 8 give the abc rules, §D.1 the coordinate check.
- Yang & Hu, [*Tensor Programs IV: Feature Learning in Infinite-Width Neural
Networks*](https://arxiv.org/abs/2011.14522) (ICML 2021) — where μP originates
(the SGD case).
- Microsoft [`mup`](https://github.com/microsoft/mup) — the reference PyTorch
implementation, and EleutherAI's [*Practitioner's Guide to
μP*](https://blog.eleuther.ai/mutransfer/) for the tables and coord-check figures.
- Dey et al., [*Cerebras-GPT*](https://arxiv.org/abs/2304.03208) (2023) — μP tuned
on a ~40M proxy and reused from 111M to 2.7B.
## Multi-Token Prediction: Training n Futures at Once
Every objective so far has the model predict **one** token — the next one — from
the trunk representation $z_t$ at position $t$. But $z_t$ carries far more than
"what comes next": to predict the next token *well* it must already anticipate
the words after it. **Multi-token prediction** (MTP) makes that latent planning
an explicit target. Keep the same shared trunk, but attach $n$ output heads: head
$k$ predicts the token $k$ positions ahead. Head 1 is the ordinary next-token
head; heads $2..n$ look further out.
Two things fall out for free:
- **A denser training signal.** Forcing $z_t$ to also predict $x_{t+2},\dots,x_{t+n}$
pushes the trunk to plan ahead. The gains grow with scale — *worse* than the
baseline for small models, but a 13B model trained this way solves **12% more**
HumanEval and **17% more** MBPP problems than a next-token baseline, at **no**
extra training time.
- **A built-in speculative drafter.** The extra heads *are* a draft model: one
forward pass proposes $n$ tokens, so you decode in bigger jumps — up to **3.0×**
faster on code, **2.7×** on natural language, **6.4×** for a byte-level model.
This is the same draft/verify machinery you built in
[Module 16](../m16_speculative_decoding/lesson.qmd), except the drafter costs
nothing extra to train.
The from-scratch implementation lives in `mtp.py`.
### Intuition: One Trunk, Many Futures
Picture the sequence laid out left to right. A standard model, standing at
position $t$, draws a single arrow to $t+1$. MTP fans $n$ arrows out of the same
spot — one per head — reaching $t+1, t+2, \dots, t+n$. The heads share everything
below them; only the final linear read-out differs. Step through the fan-out:
```{ojs}
//| echo: false
viewof mtpStep = stepControl({min: 0, max: 4, value: 0, label: "Head"})
```
```{ojs}
//| echo: false
mtpSteps = [
{title: "Trunk", caption: "The shared representation z_t is computed once at position t."},
{title: "Head 1", caption: "Head 1 predicts x_{t+1} — the ordinary next token."},
{title: "Head 2", caption: "Head 2 predicts x_{t+2}, two steps ahead, from the same z_t."},
{title: "Head 3", caption: "Head 3 predicts x_{t+3}. The trunk must plan further out."},
{title: "Head 4", caption: "Head 4 predicts x_{t+4}. Four futures, one forward pass."}
]
```
```{ojs}
//| echo: false
mtpFanout = {
const theme = diagramTheme;
const width = 720, height = 340, n = 4;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`).attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`).style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height).attr("fill", theme.bg).attr("rx", 12);
// Token strip along the bottom.
const toks = ["…", "x_{t-1}", "x_t", "x_{t+1}", "x_{t+2}", "x_{t+3}", "x_{t+4}"];
const bw = 84, gap = 8, startX = (width - (toks.length * (bw + gap) - gap)) / 2, ty = height - 74;
const cx = i => startX + i * (bw + gap) + bw / 2;
toks.forEach((t, i) => {
const isTrunk = i === 2;
const isFuture = i >= 3 && i <= 2 + Math.max(mtpStep, 0) && mtpStep > 0;
svg.append("rect").attr("x", startX + i * (bw + gap)).attr("y", ty).attr("width", bw).attr("height", 40).attr("rx", 8)
.attr("fill", isTrunk ? theme.accent : (isFuture ? theme.highlight : theme.nodeFill))
.attr("opacity", isTrunk ? 0.9 : (isFuture ? 0.85 : 1))
.attr("stroke", isTrunk ? theme.accent : theme.nodeStroke).attr("stroke-width", 1.5);
svg.append("text").attr("x", cx(i)).attr("y", ty + 25).attr("text-anchor", "middle")
.attr("fill", isTrunk ? theme.textOnAccent : (isFuture ? theme.textOnHighlight : theme.nodeText)).attr("font-size", 12).text(t);
});
// Trunk node up top.
const zx = cx(2), zy = 66;
svg.append("circle").attr("cx", zx).attr("cy", zy).attr("r", 26).attr("fill", theme.accent).attr("opacity", 0.9)
.attr("stroke", theme.accent).attr("stroke-width", 2);
svg.append("text").attr("x", zx).attr("y", zy + 5).attr("text-anchor", "middle").attr("fill", theme.textOnAccent).attr("font-size", 14).text("z_t");
// Fan of arrows to the n future tokens; highlight the active head.
for (let k = 1; k <= n; k++) {
const active = k === mtpStep;
const seen = mtpStep > 0 && k <= mtpStep;
const tx = cx(2 + k), tyk = ty;
svg.append("path")
.attr("d", `M ${zx} ${zy + 26} C ${zx} ${(zy + tyk) / 2}, ${tx} ${(zy + tyk) / 2}, ${tx} ${tyk}`)
.attr("fill", "none")
.attr("stroke", active ? theme.highlight : (seen ? theme.accent : theme.edgeStroke))
.attr("stroke-width", active ? 3 : (seen ? 2 : 1))
.attr("opacity", active ? 1 : (seen ? 0.7 : 0.3))
.attr("stroke-dasharray", seen || active ? "none" : "4,4");
const lx = (zx + tx) / 2, ly = (zy + tyk) / 2 - 6;
svg.append("text").attr("x", lx).attr("y", ly).attr("text-anchor", "middle")
.attr("fill", active ? theme.highlight : theme.nodeText).attr("font-size", 11)
.attr("opacity", active ? 1 : (seen ? 0.8 : 0.35)).text(`head ${k}`);
}
svg.append("text").attr("x", width / 2).attr("y", 24).attr("text-anchor", "middle")
.attr("fill", theme.nodeText).attr("font-size", 13).text(mtpSteps[mtpStep].title + " — " + mtpSteps[mtpStep].caption.slice(0, 60));
return svg.node();
}
```
### The Math: Shifted Targets and a Summed Loss
The paper writes the objective as one factorized cross-entropy over the next $n$
tokens (their Eq. 2), factorized across the heads on the shared trunk output
$z_t$ (Eq. 3):
$$
\mathcal{L}_n \;=\; -\sum_t \log P_\theta\!\left(x_{t+n:t+1}\mid x_{t:1}\right)
\;=\; -\sum_t \sum_{k=1}^{n} \log P_\theta\!\left(x_{t+k}\mid z_t\right).
$$
So expanded it is a plain **sum over positions and heads of per-token
cross-entropy**. The only new mechanic is the *target*: head $k$ is scored
against $x_{t+k}$, not $x_{t+1}$. Building those targets is a shift — and the last
$k$ positions of a length-$T$ sequence have no $k$-ahead token, so they are masked
out (`ignore_index = -100`, skipped by cross-entropy). The grid below shows, for a
toy sequence, which token each head is trained to predict at each position:
```{python}
#| echo: false
#| output: false
from mtp import head_target_grid
# A short readable sequence; each head's row is its shifted target (× = off-the-end).
mtp_grid = head_target_grid([11, 12, 13, 14, 15, 16, 17, 18], n_future=4)
ojs_define(mtpGrid = mtp_grid)
```
```{ojs}
//| echo: false
mtpGridChart = {
const theme = diagramTheme;
const tokens = mtpGrid.tokens, heads = mtpGrid.heads;
const T = tokens.length, n = heads.length;
const cell = 62, labelW = 92, top = 44, width = labelW + T * cell + 16, height = top + (n + 1) * cell + 20;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`).attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`).style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height).attr("fill", theme.bg).attr("rx", 12);
const cx = j => labelW + j * cell + cell / 2;
// Header row: the input tokens at each position.
svg.append("text").attr("x", labelW - 8).attr("y", top + cell / 2 + 4).attr("text-anchor", "end")
.attr("fill", theme.nodeText).attr("font-size", 12).text("input x_t");
tokens.forEach((tok, j) => {
svg.append("rect").attr("x", labelW + j * cell + 3).attr("y", top + 3).attr("width", cell - 6).attr("height", cell - 6)
.attr("rx", 6).attr("fill", theme.accent).attr("opacity", 0.85);
svg.append("text").attr("x", cx(j)).attr("y", top + cell / 2 + 4).attr("text-anchor", "middle")
.attr("fill", theme.textOnAccent).attr("font-size", 13).text(tok);
svg.append("text").attr("x", cx(j)).attr("y", top - 8).attr("text-anchor", "middle")
.attr("fill", theme.nodeText).attr("font-size", 10).attr("opacity", 0.7).text("t=" + j);
});
// One row per head.
heads.forEach((row, k) => {
const ry = top + (k + 1) * cell;
svg.append("text").attr("x", labelW - 8).attr("y", ry + cell / 2 + 4).attr("text-anchor", "end")
.attr("fill", theme.nodeText).attr("font-size", 12).text(`head ${k + 1} → x_{t+${k + 1}}`);
row.forEach((tgt, j) => {
const off = tgt === -100;
svg.append("rect").attr("x", labelW + j * cell + 3).attr("y", ry + 3).attr("width", cell - 6).attr("height", cell - 6)
.attr("rx", 6).attr("fill", off ? theme.nodeFill : theme.highlight).attr("opacity", off ? 1 : 0.8)
.attr("stroke", theme.nodeStroke).attr("stroke-width", 1).attr("stroke-dasharray", off ? "3,3" : "none");
svg.append("text").attr("x", cx(j)).attr("y", ry + cell / 2 + 4).attr("text-anchor", "middle")
.attr("fill", off ? theme.edgeStroke : theme.textOnHighlight).attr("font-size", 13).text(off ? "×" : tgt);
});
});
svg.append("text").attr("x", width / 2).attr("y", 20).attr("text-anchor", "middle")
.attr("fill", theme.nodeText).attr("font-size", 13).text("Each head's target is the input, shifted k left (× = no k-ahead token)");
return svg.node();
}
```
::: {.callout-note}
## Key Insight
Read the first column: at position $t=0$ the four heads are trained toward
$x_1, x_2, x_3, x_4$ — the next four tokens — all from the *single* representation
$z_0$. Standard training only ever asks for $x_1$. Set $n=1$ and the grid
collapses to that one row: **multi-token prediction with one head is exactly
next-token prediction.**
:::
### Code: The Heads and the Loss
`MultiTokenHeads` is just $n$ bias-free linear read-outs sharing the trunk;
`mtp_loss` sums their per-head cross-entropies. The load-bearing check is the
$n=1$ identity — the new objective must reduce to the old one:
```{python}
import torch
from mtp import MultiTokenHeads, mtp_loss, next_token_loss
torch.manual_seed(0)
ids = torch.randint(0, 50, (4, 16)) # a batch of token sequences
trunk = torch.randn(4, 16, 32) # a stand-in trunk output z
# n = 1: MTP must equal ordinary next-token cross-entropy, bit for bit.
one_head = MultiTokenHeads(d_model=32, vocab_size=50, n_future=1)
logits1 = one_head(trunk) # (B, T, 1, V)
total1, _ = mtp_loss(logits1, ids)
baseline = next_token_loss(logits1[:, :, 0, :], ids)
print(f"n=1 MTP loss == next-token loss: {torch.allclose(total1, baseline)}")
# n = 4: four heads, one summed loss. Per-head losses decompose the total.
four_heads = MultiTokenHeads(d_model=32, vocab_size=50, n_future=4)
logits4 = four_heads(trunk) # (B, T, 4, V)
total4, per_head = mtp_loss(logits4, ids)
print(f"per-head losses: {[round(x, 3) for x in per_head.tolist()]}")
print(f"total == sum(per-head): {torch.allclose(total4, per_head.sum())}")
```
To match DeepSeek-V3's recipe — which keeps the next-token head at full weight
but down-weights the extra heads (its $\lambda$) — pass `head_weights`:
```{python}
# DeepSeek-V3 uses lambda = 0.3 (then 0.1) on the extra-token loss.
weighted, _ = mtp_loss(logits4, ids, head_weights=[1.0, 0.3, 0.3, 0.3])
print(f"weighted total: {weighted.item():.3f}")
```
### The Memory Trick: n Heads, One Head's Memory
There is an obvious objection: an output head produces a logit for *every* token
in the vocabulary, and $V$ is enormous (often 100k+). Materializing all $n$ heads'
logits at once costs $O(nV)$ floats per position — the vocabulary axis, $n$ times
over. That would make MTP a memory hog.
The paper's fix (their Fig. 2) is to compute the forward **and** backward pass of
each head *sequentially*, accumulate its gradient into the $d$-dimensional trunk
gradient, and free the head's logits before moving to the next head. Peak memory
drops from $O(nV + d)$ to $O(V + d)$ — independent of $n$. Because the loss
$\sum_k w_k \mathcal{L}_k$ is separable across heads, this streaming computation
yields the **identical** loss *and* the identical trunk gradient:
```{python}
from mtp import mtp_loss_streaming
heads = MultiTokenHeads(d_model=32, vocab_size=50, n_future=4)
# Batched path: all four heads' logits live at once.
trunk_a = torch.randn(4, 16, 32, requires_grad=True)
loss_batched, _ = mtp_loss(heads(trunk_a), ids)
loss_batched.backward()
# Streaming path: one head's logits in memory at a time (the paper's Fig. 2).
trunk_b = trunk_a.detach().clone().requires_grad_(True)
loss_stream, _ = mtp_loss_streaming(trunk_b, heads, ids)
loss_stream.backward()
print(f"same loss: {torch.allclose(loss_batched, loss_stream)}")
print(f"same trunk gradient: {torch.allclose(trunk_a.grad, trunk_b.grad, atol=1e-6)}")
```
That gradient equality *is* the memory argument: you never pay for $n$ vocab-sized
logit tensors, yet you train exactly as if you had.
### Free Speedup: Self-Speculative Decoding
At inference the heads become a drafter. From the last position, head $k$ proposes
the token $k$ steps ahead — so one forward pass yields an $n$-token draft. Feed the
draft back through the model and **verify** it with head 1 (the trusted next-token
head): accept the longest prefix head 1 agrees with, plus its correction at the
first disagreement. Because that correction is conditioned only on the *accepted*
prefix, the output is bit-for-bit the greedy head-1 string — exactly the
draft/verify guarantee from [Module 16](../m16_speculative_decoding/lesson.qmd),
now powered by MTP's own heads:
```{python}
from mtp import MTPModel, plain_generate, self_speculative_generate
torch.manual_seed(0)
model = MTPModel(vocab_size=32, d_model=64, n_future=4, max_len=32).eval()
prompt = torch.randint(0, 32, (1, 5))
plain = plain_generate(model, prompt, max_new_tokens=24) # 1 token / forward
spec, accepts = self_speculative_generate(model, prompt, max_new_tokens=24)
same = torch.equal(plain[:, :spec.shape[1]], spec[:, :plain.shape[1]])
print(f"self-speculative == greedy: {same}")
print(f"tokens committed per round: {accepts} (each round is one draft+verify)")
```
The output is identical to plain greedy decoding; the win is that several rounds
commit more than one token, so you reach the same string in **fewer** forward
passes. On real text the far heads are progressively harder, so acceptance is
partial (the paper reports ~2.5 of 3 drafted code tokens accepted); on a perfectly
predictable stream, whole drafts commit.
### Interactive Exploration
Train the tiny MTP model on a fully predictable stream for several values of $n$,
then read off two things: how accurately each head predicts its $k$-ahead token,
and how many tokens self-speculative decoding commits per forward pass (its speedup
over one-token-at-a-time decoding). Increase $n$ and watch the drafter reach
further:
```{python}
#| echo: false
#| output: false
from mtp import demonstrate_mtp
mtp_scan = []
for _n in [1, 2, 4, 8]:
_r = demonstrate_mtp(n_future=_n, steps=200, seed=0, verbose=False)
mtp_scan.append({
"n": _n,
"acc": _r["per_head_accuracy"],
"mean_accept": _r["mean_accepted_per_round"],
"max_accept": _r["max_accepted_per_round"],
})
ojs_define(mtpScan = mtp_scan)
```
```{ojs}
//| echo: false
viewof mtpN = Inputs.radio([1, 2, 4, 8], {value: 4, label: "heads n"})
```
```{ojs}
//| echo: false
mtpAccChart = {
const theme = diagramTheme;
const row = mtpScan.find(d => d.n === mtpN);
const acc = row.acc, n = acc.length;
const width = 720, height = 340, m = {top: 34, right: 24, bottom: 52, left: 56};
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`).attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`).style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height).attr("fill", theme.bg).attr("rx", 12);
const x = d3.scaleBand().domain(d3.range(n)).range([m.left, width - m.right]).padding(0.28);
const y = d3.scaleLinear().domain([0, 1]).range([height - m.bottom, m.top]);
svg.append("g").attr("transform", `translate(0,${height - m.bottom})`)
.call(d3.axisBottom(x).tickFormat(i => `head ${+i + 1}`))
.call(g => g.selectAll("text").attr("fill", theme.nodeText).attr("font-size", 11))
.call(g => g.selectAll("line,path").attr("stroke", theme.edgeStroke));
svg.append("g").attr("transform", `translate(${m.left},0)`).call(d3.axisLeft(y).ticks(5).tickFormat(d3.format(".0%")))
.call(g => g.selectAll("text").attr("fill", theme.nodeText))
.call(g => g.selectAll("line,path").attr("stroke", theme.edgeStroke));
acc.forEach((a, k) => {
svg.append("rect").attr("x", x(k)).attr("width", x.bandwidth()).attr("y", y(a)).attr("height", y(0) - y(a))
.attr("rx", 5).attr("fill", theme.highlight).attr("opacity", 0.88);
svg.append("text").attr("x", x(k) + x.bandwidth() / 2).attr("y", y(a) - 6).attr("text-anchor", "middle")
.attr("fill", theme.nodeText).attr("font-size", 11).text((a * 100).toFixed(0) + "%");
});
svg.append("text").attr("transform", "rotate(-90)").attr("x", -(height / 2)).attr("y", 16)
.attr("text-anchor", "middle").attr("fill", theme.nodeText).attr("font-size", 12).text("per-head accuracy");
svg.append("text").attr("x", width / 2).attr("y", 20).attr("text-anchor", "middle")
.attr("fill", theme.nodeText).attr("font-size", 13)
.text(`n=${mtpN}: ${row.mean_accept.toFixed(2)} tokens committed / forward → ~${row.mean_accept.toFixed(1)}× fewer passes`);
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Set $n=1$**: one head, exactly one token per forward pass — the ordinary
decoder, no speedup. This is the baseline every larger $n$ improves on.
2. **Climb to $n=8$**: on this fully-learnable stream every head hits ~100% and
the drafter commits all eight tokens per round. Real language won't be this
kind, but the *shape* — a free drafter that costs nothing to train — is real.
3. **Weight the heads**: in `mtp_loss`, try `head_weights=[1.0, 0.3, 0.3, ...]`
to mirror DeepSeek-V3's $\lambda$, protecting next-token quality while still
training the drafter.
:::
::: {.callout-warning}
## MTP Helps at Scale, Not on Toys
The headline gains are a **large-model** phenomenon. Gloeckle et al. are explicit
that multi-token prediction is *worse* than a next-token baseline for small
models and only overtakes it as models grow (their wins are clearest at 3B–13B,
and on code). The tiny model here exists to make the *mechanics* — shifted
targets, the summed loss, exact self-speculative decoding — visible and provable;
do not read its toy-stream accuracy as evidence MTP will lift a small model on
real data.
:::
## Training Tips
### Quick Reference Table
| Symptom | Likely Cause | Solution |
|---------|--------------|----------|
| Loss = NaN | LR too high | Reduce LR by 10x |
| Loss stuck | LR too low | Increase LR by 2-5x |
| Loss oscillates | Batch too small | Use gradient accumulation |
| Overfitting | Not enough data | More data, more dropout |
| Underfitting | Model too small | More layers/heads/dims |
| Slow training | No GPU/MPS | Use hardware acceleration |
| OOM errors | Batch too large | Reduce batch size, use accumulation |
| Training crash | No checkpoints | Save every N steps |
### Hyperparameter Recommendations
Based on published research and common practices:
| Hyperparameter | Small Models (<1B) | Large Models (>1B) |
|----------------|-------------------|-------------------|
| Learning rate | 1e-4 to 6e-4 | 1e-4 to 3e-4 |
| Warmup | 1-2% of steps | 0.1-1% of steps |
| Weight decay | 0.01 - 0.1 | 0.01 - 0.1 |
| Beta1 | 0.9 | 0.9 |
| Beta2 | 0.999 | 0.95 |
| Batch size | 256 - 1024 tokens | 1M - 4M tokens |
| Gradient clip | 1.0 | 1.0 |
### Memory Optimization Strategies
1. **Gradient accumulation**: Simulate larger batches
2. **Mixed precision (fp16/bf16)**: ~50% memory reduction
3. **Gradient checkpointing**: Trade compute for memory
4. **FSDP/DeepSpeed**: Shard model across GPUs
## Interactive Exploration
Experiment with learning rate schedules in real-time. Adjust the hyperparameters to see how warmup and cosine decay shape the learning rate curve.
```{ojs}
//| echo: false
// Cosine schedule with linear warmup
function computeSchedule(maxLr, minLr, warmupSteps, totalSteps) {
const lrs = [];
const numPoints = Math.min(totalSteps, 500); // Limit points for performance
const stepSize = totalSteps / numPoints;
for (let i = 0; i <= numPoints; i++) {
const step = Math.floor(i * stepSize);
let lr;
if (step < warmupSteps) {
// Linear warmup
lr = maxLr * step / Math.max(1, warmupSteps);
} else if (step >= totalSteps) {
lr = minLr;
} else {
// Cosine decay
const progress = (step - warmupSteps) / Math.max(1, totalSteps - warmupSteps);
const cosine = 0.5 * (1 + Math.cos(Math.PI * progress));
lr = minLr + (maxLr - minLr) * cosine;
}
lrs.push({ step, lr, phase: step < warmupSteps ? "warmup" : "decay" });
}
return lrs;
}
// Get LR at a specific step
function getLrAtStep(step, maxLr, minLr, warmupSteps, totalSteps) {
if (step < warmupSteps) {
return maxLr * step / Math.max(1, warmupSteps);
} else if (step >= totalSteps) {
return minLr;
} else {
const progress = (step - warmupSteps) / Math.max(1, totalSteps - warmupSteps);
const cosine = 0.5 * (1 + Math.cos(Math.PI * progress));
return minLr + (maxLr - minLr) * cosine;
}
}
```
```{ojs}
//| echo: false
// Input controls
viewof maxLr = Inputs.range([1e-5, 1e-2], {
value: 1e-3,
step: 1e-5,
label: "Max Learning Rate",
format: x => x.toExponential(1)
})
viewof minLr = Inputs.range([0, 1e-4], {
value: 1e-5,
step: 1e-6,
label: "Min Learning Rate",
format: x => x.toExponential(1)
})
viewof warmupSteps = Inputs.range([0, 500], {
value: 100,
step: 10,
label: "Warmup Steps"
})
viewof totalSteps = Inputs.range([100, 2000], {
value: 1000,
step: 50,
label: "Total Steps"
})
viewof currentStep = Inputs.range([0, totalSteps], {
value: Math.floor(totalSteps / 2),
step: 1,
label: "Current Step"
})
```
```{ojs}
//| echo: false
// Widget theme - uses diagramTheme from _diagram-lib.qmd which already handles dark mode
theme = {
const t = diagramTheme;
return {
warmupBg: t.isDark ? 'rgba(251, 146, 60, 0.15)' : 'rgba(249, 115, 22, 0.1)',
curveStroke: t.accent,
warmupMarker: t.highlight,
currentMarker: t.error,
annotationText: t.highlight
};
}
```
```{ojs}
//| echo: false
// Compute schedule data
scheduleData = computeSchedule(maxLr, minLr, warmupSteps, totalSteps)
// Current LR
currentLr = getLrAtStep(currentStep, maxLr, minLr, warmupSteps, totalSteps)
// Warmup percentage
warmupPct = ((warmupSteps / totalSteps) * 100).toFixed(1)
```
```{ojs}
//| echo: false
Plot = import("../../assets/vendor/plot/plot.esm.js")
Plot.plot({
title: "Learning Rate Schedule: Warmup + Cosine Decay",
subtitle: `Warmup: ${warmupSteps} steps (${warmupPct}%) | Peak LR: ${maxLr.toExponential(1)} | Min LR: ${minLr.toExponential(1)}`,
width: 700,
height: 350,
marginLeft: 70,
marginBottom: 50,
x: {
label: "Training Step →",
domain: [0, totalSteps]
},
y: {
label: "↑ Learning Rate",
domain: [0, maxLr * 1.1],
tickFormat: ".1e"
},
marks: [
// Warmup region background
Plot.rectY([{x1: 0, x2: warmupSteps, y: maxLr * 1.1}], {
x1: "x1",
x2: "x2",
y2: "y",
y1: 0,
fill: theme.warmupBg,
fillOpacity: 0.5
}),
// Main LR curve
Plot.line(scheduleData, {
x: "step",
y: "lr",
stroke: theme.curveStroke,
strokeWidth: 2.5
}),
// Warmup end marker
Plot.ruleX([warmupSteps], {
stroke: theme.warmupMarker,
strokeWidth: 2,
strokeDasharray: "5,5"
}),
// Current step indicator
Plot.ruleX([currentStep], {
stroke: theme.currentMarker,
strokeWidth: 2
}),
// Current LR point
Plot.dot([{step: currentStep, lr: currentLr}], {
x: "step",
y: "lr",
fill: theme.currentMarker,
r: 6
}),
// Annotations
Plot.text([{step: warmupSteps, lr: maxLr * 1.05}], {
x: "step",
y: "lr",
text: ["← Warmup ends"],
fill: theme.annotationText,
fontSize: 11,
textAnchor: "start"
}),
Plot.ruleY([0])
]
})
```
```{ojs}
//| echo: false
// Display current step info
md`**Step ${currentStep}:** LR = **${currentLr.toExponential(3)}** ${currentStep < warmupSteps ? "(warming up)" : currentStep >= totalSteps ? "(finished)" : "(decaying)"}`
```
```{ojs}
//| echo: false
// Legend
md`<span style="background: ${theme.warmupBg}; padding: 2px 8px; color: ${theme.nodeText}">Warmup phase</span> <span style="color: ${theme.warmupMarker}">┆</span> Warmup ends <span style="color: ${theme.currentMarker}">│</span> Current step`
```
::: {.callout-tip}
## Try This
1. **Effect of warmup**: Set warmup to 0, then gradually increase to 200. Notice how the curve changes from immediate peak to gradual ramp-up.
2. **Long vs short training**: Compare total_steps=500 vs total_steps=2000 with the same warmup. See how the decay rate changes.
3. **Min LR matters**: Set min_lr to 0, then to 1e-5. The floor prevents the model from completely stopping learning.
4. **Warmup ratio**: Try warmup_steps = 1-2% of total_steps (common in practice). For 1000 steps, that's 10-20 warmup steps.
5. **Drag the current step slider** to see the exact LR at any point in training.
:::
## Exercises
### Exercise 1: Learning Rate Finder
Implement a learning rate finder that trains for a few iterations at exponentially increasing learning rates and plots loss vs learning rate.
```{python}
# Your implementation here
def lr_finder(model, tokens, start_lr=1e-7, end_lr=1e-1, num_steps=100):
"""Find optimal learning rate by training with exponentially increasing LR."""
# TODO: Implement this
pass
```
### Exercise 2: Custom Scheduler
Implement a linear warmup + linear decay scheduler (instead of cosine decay).
```{python}
# Your implementation here
class LinearScheduler:
def __init__(self, optimizer, warmup_steps, total_steps, min_lr=0.0):
# TODO: Implement this
pass
def step(self):
pass
```
### Exercise 3: Training with Validation
Modify the training loop to:
1. Compute validation loss every N steps
2. Save the best model (lowest validation loss)
3. Implement early stopping if validation loss doesn't improve for M steps
### Exercise 4: Prove the Decoupling to Yourself
Using the from-scratch `Adam` and `AdamW` from `optimizers.py`, show that coupled and
decoupled weight decay are *not* the same thing. Train two identically-initialized
layers with the same `lr` and `weight_decay`, then confirm (a) they diverge with decay
on, and (b) they are bit-identical with `weight_decay=0`.
```{python}
# Your implementation here
from optimizers import Adam, AdamW
def decoupling_gap(weight_decay, steps=25, seed=0):
"""Return the max weight difference between Adam(L2) and AdamW after training."""
# TODO: build twin nn.Linear layers, run Adam vs AdamW, return the gap
pass
# Expect: decoupling_gap(0.3) > 0 and decoupling_gap(0.0) == 0
```
### Exercise 5: Why fp8 Needs Per-Tensor Scaling
Take a small weight vector and quantize it to e4m3 *without* scaling, then *with*
per-tensor scaling, and measure how much the scaling recovers. Confirm that the
scale is exactly `max_normal / amax`, and that shrinking every weight by 10×
(so more of them underflow) makes the naive path worse while the scaled path is
unchanged — because scaling is scale-invariant by construction.
```{python}
# Your implementation here
from precision import FP8_FORMATS, quantize_per_tensor, per_tensor_scale
def scaling_benefit(weights, shrink=1.0):
"""Return (naive_rel_error, scaled_rel_error) for e4m3 after shrinking weights."""
w = [x * shrink for x in weights]
# TODO: quantize_per_tensor(w, e4m3); return the two mean relative errors
pass
# Expect: naive error grows as shrink -> 0.1, scaled error stays ~constant.
```
### Exercise 6: Orthogonalization Whitens the Update
Using `newton_schulz` and `orthogonalize` from `muon.py`, confirm that Muon's
update is (approximately) the polar factor of the momentum matrix — and that this
*whitens* the spectrum. Build a deliberately ill-conditioned matrix, orthogonalize
it both ways, and compare.
```{python}
# Your implementation here
from muon import newton_schulz, orthogonalize, singular_values
def whitening_check(condition=50.0, seed=0):
"""Return (raw_condition_number, orthogonalized_condition_number)."""
import torch
torch.manual_seed(seed)
u, _ = torch.linalg.qr(torch.randn(32, 32))
v, _ = torch.linalg.qr(torch.randn(32, 32))
m = (u * torch.logspace(0, torch.log10(torch.tensor(condition)), 32)) @ v.T
# TODO: singular_values(m) vs singular_values(newton_schulz(m));
# return max/min for each (the raw one is ~condition, the ortho one ~1)
pass
# Expect: raw condition number ~= `condition`, orthogonalized ~= 1.
# Bonus: check newton_schulz(m) and orthogonalize(m) point the same way
# (cosine > 0.97), but only orthogonalize() sets every singular value to exactly 1.
```
### Exercise 7: Break μP and Watch the Coordinate Check Fail
μP is a package deal — its two knobs work *together*. Break just one and the
coordinate check should light up. Using `MuMLP` and `coordinate_check` from
`mup.py`, build a "half-μP" model that scales the hidden learning rate but
*forgets* the output multiplier, and confirm its coordinate check is no longer
flat.
```{python}
# Your implementation here
from mup import MuMLP, coordinate_check, coord_blowup_ratio
def half_mup_check():
"""Compare full-μP vs SP blow-up ratios at t=1 over an 8x width sweep."""
widths = [64, 128, 256, 512]
mp = coordinate_check(widths, parametrization="mup")
sp = coordinate_check(widths, parametrization="sp")
# TODO: return (coord_blowup_ratio(mp, 1), coord_blowup_ratio(sp, 1))
# Expect μP ~1.x (flat), SP >10x (exploding).
pass
# Bonus: monkey-patch a MuMLP so output_multiplier stays 1.0 while the hidden LR
# still scales, then run the check — the readout logits should now drift with
# width even though the hidden activations stay put.
```
### Exercise 8: Find the Cap That Just Barely Never Fires
QK-Clip is a *self-deactivating* controller: for a given optimizer "heat", there
is a cap $\tau$ high enough that the logits never cross it, so the clip is a
perpetual no-op. Using `qk_clip_logit_growth` from `qk_clip.py`, find the smallest
integer $\tau$ (for a fixed `inflation`) at which no step fires the clip.
```{python}
# Your implementation here
from qk_clip import qk_clip_logit_growth
def smallest_quiet_tau(inflation=1.15, steps=20):
"""Return the smallest integer tau at which no clip event fires."""
# A run never fires iff the *uncapped* curve never exceeds tau. Trace once
# with a huge tau to read the uncapped max, then round up.
# TODO: rows = qk_clip_logit_growth(steps=steps, tau=1e9, inflation=inflation)
# return math.ceil(max(r["uncapped"] for r in rows))
pass
# Bonus: lower `inflation` toward 1.0 and watch the quiet tau shrink — a gentler
# optimizer needs a looser rail. This is why healthy runs settle below tau=100.
```
### Exercise 9: Fit the Data Exponent Yourself
You saw `fit_isoflop_scaling` recover $a\approx0.45$ for parameters. The tokens
exponent $b$ should come out near $0.55$ so that $a+b=1$. Without calling
`fit_isoflop_scaling`, reproduce $b$ from scratch: read the valley of each
IsoFLOP profile, collect $D_{\text{opt}}$, and fit $D_{\text{opt}}\propto C^b$.
```{python}
# Your implementation here
from scaling import isoflop_optimum, fit_power_law
def fit_data_exponent(flops_grid=None):
"""Return the fitted exponent b in D_opt ∝ C^b, measured off IsoFLOP valleys."""
# TODO: budgets = flops_grid or [10**e for e in range(19, 28)]
# d_opt = [isoflop_optimum(c)["n_tokens"] for c in budgets]
# return fit_power_law(budgets, d_opt)["exponent"]
pass
# Bonus: change CHINCHILLA_COEFFS' alpha/beta and confirm a = beta/(alpha+beta)
# still predicts what the fit recovers — the exponent is not magic, it's the ratio.
```
### Exercise 10: The Argmin's Variance, and the Window's Bias
The noise section claimed the parabola fit beats the bare argmin *and* that too
wide a fit window re-biases the vertex. Measure both yourself with
`noise_robustness` and `estimate_valley`. First confirm the headline: at
$\sigma=0.02$ the argmin's spread is ~3× the parabola's. Then sweep the fit
`window` from very narrow (say $0.2$ decades) to very wide (the whole profile) and
watch the parabola estimate's **bias** (mean − truth) grow as the window swallows
the non-quadratic tails — the classic bias–variance trade, live.
```{python}
# Your implementation here
from scaling import noise_robustness, isoflop_profile, estimate_valley, compute_optimal_allocation
import math
# Part 1: reproduce the ~3× spread ratio.
r = noise_robustness(flops=1e21, noise=0.02, trials=200, seed=0)
print(f"argmin std {r['argmin']['std']:.3f} vs parabola std {r['parabola']['std']:.3f} "
f"({r['std_ratio']:.1f}×)")
# Part 2: TODO — for window in (0.2, 0.4, 0.6, 1.0, 1.5):
# over many seeds, estimate_valley(prof, method="parabola", window=window),
# and print mean(estimate) − truth (the bias) alongside its std (the variance).
# You should see the bias stay ~0 for snug windows, then grow as it widens.
truth = math.log10(compute_optimal_allocation(1e21)["n_params"])
```
## Summary
This module covered:
1. **Cross-entropy loss** measures prediction quality (lower = better), with mathematical foundations in information theory
2. **Perplexity** provides an intuitive metric: exp(loss) - "choosing among N equally likely options"
3. **Learning rate scheduling** with warmup + cosine decay prevents early instability and enables fine-tuning
4. **Optimizers from scratch** — built `SGD`, `Adam`, and `AdamW` in `optimizers.py`, each matching `torch.optim` bit-for-bit. The **Adam-vs-AdamW** difference is one term's placement: coupled L2 rides the gradient through the `1/√v̂` denominator (per-parameter, unintended), while AdamW's decoupled decay shrinks every weight uniformly — identical when `weight_decay=0`, divergent otherwise
5. **Gradient accumulation** increases effective batch size without adding memory
6. **Gradient clipping** (max_norm=1.0) prevents exploding gradients, essential for transformers
7. **Batch size tradeoffs** affect memory, training dynamics, and generalization
8. **Mixed precision & numerics** — built the float number line from scratch: fp16 spends bits on precision (overflows at 65504, underflows near 6e-5), bf16 keeps fp32's range at coarser precision, and **loss scaling** lifts tiny gradients out of fp16's underflow hole (2× speed, 50% memory). At the frontier, **fp8** uses two formats — e4m3 (precise, no infinity, max 448) for the forward pass and e5m2 (fp16's range) for gradients — with **per-tensor scaling** (map `amax`→`max_normal`) rescuing tensors that would otherwise underflow in 8 bits
9. **Distributed training** (DDP, FSDP) scales training to multiple GPUs
10. **Common failure modes** (NaN loss, stuck training, oscillation) and their solutions
11. **Checkpointing strategies** ensure you never lose training progress
12. **Scaling laws** predict loss from compute: the `C≈6ND` rule plus Chinchilla's ~20 tokens/param let you size a model and its data budget instead of guessing — and reveal that parameter-heavy models like GPT-3 and Gopher were under-trained. And the law isn't handed down — you *fit* it: **IsoFLOP profiles** (built in `scaling.py` as `isoflop_profile`/`isoflop_optimum`/`fit_power_law`/`fit_isoflop_scaling`) sweep model size at each compute budget, read the loss valley, and recover the compute-optimal exponents ($N\propto C^{0.45}$, $D\propto C^{0.55}$, summing to one because $C=6ND$) by log-log least squares — so "20 tokens/param" is a *measured consequence* of $a\approx b\approx\tfrac12$, not an axiom. And because real runs are **noisy**, the valley is *fit*, not argmin-ed: near a flat minimum the single lowest run is a high-variance pointer, so — as Chinchilla actually did — you fit a **parabola** to each profile and read its vertex (`fit_parabola`/`estimate_valley`/`noise_robustness`), which scatters ~3× less than a bare argmin at the same measurement noise
13. **Muon** treats each weight as a *matrix*, not a bag of scalars: it orthogonalizes the momentum matrix (all singular values → 1) with a from-scratch **Newton–Schulz** iteration — no SVD, no second-moment state — scales the result by `0.2·√max(A,B)` to match AdamW's update RMS, and adds decoupled weight decay. Built in `muon.py`, it clears plain momentum on a toy and, at scale (Moonlight), reaches AdamW quality with ~2× less compute — the first real challenger to a decade of Adam
14. **Maximal Update Parametrization (μP)** makes hyperparameters **transfer across width**: because SP takes a $\Theta(\text{width})$-sized feature step per Adam update, its optimal learning rate drifts as you scale — μP rescales the hidden LR ($\propto 1/\text{width}$) and the readout multiplier ($\propto 1/\text{width}$) so every layer's update stays $\Theta(1)$. Built in `mup.py`, the **coordinate check** shows SP activations exploding ~140× across an 8× width sweep while μP stays flat, and the **μTransfer** sweep shows the optimal LR pinned in place. Tune on a tiny proxy, copy the numbers to the giant model (Yang et al., 2022; Cerebras-GPT tuned a 40M proxy for a 2.7B target)
15. **QK-Clip & MuonClip** are what let Muon scale to the frontier. Muon's larger updates inflate the attention logits faster than AdamW — the same explosion m05's QK-Norm cures in activation space — so **QK-Clip** cures it in *weight* space: after each step, any head whose max logit crosses a cap $\tau$ has its Q/K projections rescaled by $\sqrt{\min(1, \tau/S_{\max}^h)}$, and because the logit is bilinear it lands back at exactly $\tau$. Built in `qk_clip.py` with the `MuonClip` wrapper (step, then clip); it is a self-deactivating controller — once the logits settle it stops firing — and it is the recipe behind Kimi K2's zero-loss-spike 1T-parameter run ($\tau = 100$)
16. **Multi-token prediction** changes the *objective*: keep the shared trunk but add $n$ output heads, head $k$ predicting the token $k$ steps ahead, and sum their cross-entropies. Built in `mtp.py`, it reduces to plain next-token training at $n=1$ (proven bit-for-bit); the paper's **memory trick** (compute each head sequentially, free its logits) keeps peak memory at $O(V+d)$ regardless of $n$ — the same loss *and* trunk gradient. It buys a denser signal (a 13B model solves 12%/17% more HumanEval/MBPP) and a **built-in speculative drafter** whose greedy self-speculative decoding is exact (up to 3× faster on code). Helps at scale, hurts small models
### Key Takeaways
- Always use warmup (at least 1% of steps) to stabilize early training
- Monitor gradient norms alongside loss - they tell you about training stability
- Start with standard hyperparameters (lr=3e-4, wd=0.01, clip=1.0), then adjust
- Test your training loop on a tiny dataset first - verify it can overfit
- Tuning a model far larger than any you've trained? Parametrize it in **μP** and
transfer the learning rate from a small proxy instead of sweeping at full scale
- Reading a minimum off noisy measurements — an IsoFLOP valley, an LR sweep, a
hyperparameter search? **Fit a curve and take its optimum; never argmin the raw
points.** On a flat landscape the single best run is chasing the noise
## What's Next
[Module 08: Generation](../m08_generation/) uses the trained model to generate text with various decoding strategies: greedy, sampling, and top-k/top-p.