Scaling Up

Dive into Deep Learning · §9.11

Making small-scale tuning survive scale
the drifting optimum · muP · the coordinate check · what the labs do

One run, no sweeps

Everything in this chapter was tuned by sweeping — fine when a run costs seconds.

  • A frontier model is trained once: thousands of accelerators, months. No grid search at that price.
  • Industry answer: tune something small, transfer up.
  • This section: when transfer fails, how muP repairs it, how to verify it, what labs actually do.

A family of widths

Fashion-MNIST MLP, 784 \to n \to n \to n \to 10, width n from 128 to 1,024. Only the two middle matrices are n \times n: that is where scale lives. Initialization pinned explicitly: variance 1/\text{fan-in}, biases zero — standard parametrization (SP).

class MLP(nn.Module):
    """A three-hidden-layer ReLU network under standard parametrization."""
    def __init__(self, width):
        super().__init__()
        self.fc_in = nn.Linear(784, width)
        self.fc_h1 = nn.Linear(width, width)
        self.fc_h2 = nn.Linear(width, width)
        self.fc_out = nn.Linear(width, 10)
        for lin in (self.fc_in, self.fc_h1, self.fc_h2, self.fc_out):
            nn.init.normal_(lin.weight, std=lin.in_features ** -0.5)
            nn.init.zeros_(lin.bias)

    def features(self, X):
        h = F.relu(self.fc_h1(F.relu(self.fc_in(X))))
        return F.relu(self.fc_h2(h))

    def forward(self, X):
        return self.fc_out(self.features(X))

    def configure_adam(self, lr):
        return torch.optim.Adam(self.parameters(), lr)

400 Adam steps at batch 512; score = final loss on the whole training set.

The sweep: the optimum moves

Eight learning rates × four widths, 32 runs, about a minute:

widths = [128, 256, 512, 1024]
lrs = [2 ** k for k in range(-12, -4)]
sp_loss = {w: [train_mlp(MLP, w, lr) for lr in lrs] for w in widths}
for w in widths:
    print(f'width {w:4d}: best lr {min(zip(sp_loss[w], lrs))[1]:.1e}')
d2l.plot(lrs, [sp_loss[w] for w in widths], 'learning rate',
         'training loss', xscale='log', ylim=[0.25, 0.8],
         legend=[f'width {w}' for w in widths])

width  128: best lr 7.8e-03
width  256: best lr 2.0e-03
width  512: best lr 2.0e-03
width 1024: best lr 9.8e-04
  • Every width: a U. The minima do not line up — the best learning rate falls about 8× as width grows 8×.
  • Wider is never worse at its own optimum; the narrow model’s optimum sits on the wide model’s unstable branch.
  • The best learning rate is a property of the model size, not the task.

Why: one step scales with fan-in

Single-example gradient of a hidden matrix = outer product \mathbf{g} = \boldsymbol{\delta}\mathbf{h}^\top; Adam’s first update is a sign step, and signs of outer products factorize:

(\Delta\mathbf{W}\mathbf{h})_i = -\eta \operatorname{sign}(\delta_i) \sum_{j=1}^n |h_j| \approx -\eta\, n\, \overline{|h|}.

All n terms add coherently: double the width, double the kick, halve the stable \eta.

Per layer: input weights (fan-in 784, fixed) and biases don’t scale. One global \eta = a compromise between layers that scale differently.

muP: the rules (Adam, width multiplier m)

Yang & Hu et al., 2022 — Tensor Programs V

parameters init Adam LR forward
input weights, biases unchanged \eta unchanged
hidden matrices \propto 1/\text{fan-in} (unchanged) \eta/m unchanged
output matrix unchanged \eta logits \times 1/m

Derived from the infinite-width limit where activations stay O(1) and every layer keeps learning — the maximal update. Embeddings count as input-like; attention needs 1/d; SGD has its own column.

Two rules, one subclass

class MuMLP(MLP):
    """The same network under muP, relative to a width-128 base."""
    def __init__(self, width, base_width=128):
        super().__init__(width)
        self.m = width / base_width

    def forward(self, X):
        # rule 1: scale the output matrix's logits; the bias is untouched
        return self.fc_out(self.features(X) / self.m)

    def configure_adam(self, lr):
        hidden = [self.fc_h1.weight, self.fc_h2.weight]
        rest = [p for p in self.parameters()
                if not any(p is q for q in hidden)]
        return torch.optim.Adam([              # rule 2: hidden LR / m
            {'params': rest, 'lr': lr},
            {'params': hidden, 'lr': lr / self.m}])

At the base width m=1: muP changes nothing about the model you tune.

The coordinate check

Mean |activation| per layer, one Adam step, widths 128 → 4,096. Correct scaling = flat curves. Under SP:

check_widths = [128, 256, 512, 1024, 2048, 4096]
sp_acts = coord_check(MLP, check_widths)
d2l.plot(check_widths, list(sp_acts), 'width', 'mean |activation|',
         xscale='log', yscale='log',
         legend=['layer 1', 'layer 2', 'layer 3', 'logits'])

Fixed fan-in layer flat; the layers behind square matrices grow, compounding with depth; logits blow up ~100× — the first step already writes the instability.

The coordinate check, under muP

mup_acts = coord_check(MuMLP, check_widths)
d2l.plot(check_widths, list(mup_acts), 'width', 'mean |activation|',
         xscale='log', yscale='log',
         legend=['layer 1', 'layer 2', 'layer 3', 'logits'])

  • All layer curves flat: no width-dependent update scale left.
  • Logits fall by design (the 1/m multiplier); they grow to O(1) by learning, not by size. Growth is the unambiguous failure — the check reads activation size, not feature learning.

Run this before trusting any muP integration — it catches missed multipliers, mislabeled layers, framework defaults sneaking back.

Learning-rate transfer

The payoff: the same sweep, under muP:

mup_loss = {w: [train_mlp(MuMLP, w, lr) for lr in lrs] for w in widths}
for w in widths:
    print(f'width {w:4d}: best lr {min(zip(mup_loss[w], lrs))[1]:.1e}')
d2l.plot(lrs, [mup_loss[w] for w in widths], 'learning rate',
         'training loss', xscale='log', ylim=[0.25, 0.8],
         legend=[f'width {w}' for w in widths])

width  128: best lr 7.8e-03
width  256: best lr 7.8e-03
width  512: best lr 7.8e-03
width 1024: best lr 7.8e-03
  • The optimum stops sliding: scatter within a grid step or two across 8× width (SP: slid three). Reusing the base optimum at width 1,024: within a couple % of that width’s best loss (SP: misses by 15–20%).
  • Tune small, ship big: TP5 tuned GPT-3 6.7B from a 40M proxy at ~7% of pretraining cost — and beat the original.
  • Caveat: at a width you can sweep, retuned SP matches; muP buys the sweep you cannot afford.

The spectral view

Healthy layer scale (:numref:sec_muon): \|\mathbf{W}\|_2 \asymp \|\Delta\mathbf{W}\|_2 \asymp \sqrt{n_{\text{out}}/n_{\text{in}}}.

  • muP \equiv this spectral condition (Yang–Simon–Bernstein, 2023): the per-layer LRs make Adam’s updates land at the right spectral scale.
  • muP = bookkeeping for an optimizer using the wrong norm; Muon’s shape-scaled updates build much of that control in (its RMS-match convention is not the \sqrt{n_{\text{out}}/n_{\text{in}}} scale).

What the big runs do

  • Cerebras: muP in production; family tuned from a ~40M proxy.
  • DeepSeek: don’t remove the drift — fit it. Power laws for best LR and batch vs compute, extrapolated to the target run.
  • Meta: “MetaP” per-layer LRs/init for Llama 4 (undisclosed).
  • Moonshot / Kimi K2: no parametrization; every Muon update scaled to an RMS matched empirically to AdamW’s. 15.5T tokens.
  • Live debate: weight decay, not muP, may drive transfer in long runs (Kosson et al., 2025). muP: one mechanism, not settled law.

Recap

  • Best LR drifts with width: one Adam step perturbs a layer \propto fan-in.
  • muP: init unchanged, hidden LR \eta/m, logits \times 1/m — optimum transfers from the base width.
  • Coordinate check: flat activation scales across width, in seconds. Run it.
  • Spectral view ties muP to Muon; labs mix parametrization, scaling-law fits, and matched update sizes. Shared goal: tune small, transfer big.