The LLM StackFrom Silicon to Agents
Part III — Pretraining at Scale

3.9 Optimizers: SGD, Adam, Adafactor, Lion, Muon & Shampoo

The optimizer is the engine that turns gradients into weight updates. Choose it well and a 70-billion-parameter model converges smoothly on a fixed token budget; choose it poorly and you either waste GPU-months or watch the loss spike into NaNs. For most of the deep-learning era one optimizer — Adam, and its weight-decay-fixed cousin AdamW — has been the default for training transformers, and for good reason. But Adam carries a hidden tax: it stores two extra full-precision tensors per parameter, and at scale that memory cost rivals the model weights themselves. That tension — fast, robust convergence versus memory and compute overhead — is the throughline of this chapter, and it is exactly what newer optimizers like Adafactor, Lion, Shampoo, and Muon attack from different angles.

We will build up from the gradient-descent first principles you saw in Calculus, Optimization & Convexity, derive Adam and its bias correction carefully, implement AdamW from scratch, account for optimizer-state memory the way a systems engineer must, and then tour the modern menagerie: factored second moments (Adafactor), sign-based updates (Lion), full preconditioners (Shampoo), and the newest newsmaker, Muon, which orthogonalizes the update of every weight matrix. By the end you should be able to pick an optimizer for a given memory and stability budget, and defend that choice in an interview.

From Gradient Descent to Momentum

The starting point is plain stochastic gradient descent (SGD). Given a loss \(L(\theta)\) and a minibatch estimate \(g_t = \nabla_\theta L_{\mathcal{B}_t}(\theta_{t-1})\) of its gradient, SGD takes the step

\[ \theta_t = \theta_{t-1} - \eta\, g_t, \]

where \(\eta\) is the learning rate. This is the cheapest possible optimizer: zero extra state, one tensor of gradients, one fused multiply-subtract. Its weakness is that the raw gradient is a noisy, badly-scaled descent direction. In a ravine — a loss surface that is steep across one axis and nearly flat along another, which is the typical geometry of deep nets — SGD zig-zags across the steep walls while crawling along the flat valley floor. The condition number of the Hessian, the ratio of its largest to smallest eigenvalue, controls how bad this is; for transformers it can be enormous.

Momentum fixes the zig-zag by accumulating an exponentially-weighted average of past gradients, a “velocity” \(v_t\), and stepping along that instead:

\[ v_t = \mu\, v_{t-1} + g_t, \qquad \theta_t = \theta_{t-1} - \eta\, v_t. \]

Here \(\mu \in [0,1)\) (typically 0.9) is the momentum coefficient. The intuition is physical: the velocity behaves like a heavy ball rolling downhill. Oscillating components of the gradient cancel across steps, while the consistent down-valley component accumulates. The geometric-series identity tells us that a steady gradient \(g\) produces a terminal velocity of \(g/(1-\mu)\), so momentum with \(\mu=0.9\) effectively multiplies the step length along consistent directions by \(10\times\). That is also why you usually drop the learning rate when you increase momentum.

A subtle but important variant is Nesterov momentum, which evaluates the gradient at the look-ahead point \(\theta_{t-1} - \eta\mu v_{t-1}\) rather than at \(\theta_{t-1}\). The correction term gives a slightly more responsive update and a better convergence rate on convex problems. SGD with Nesterov momentum and a well-tuned learning-rate schedule remains the gold standard for training convolutional vision models, and it generalizes beautifully. So why do we not train transformers with it?

import torch

def sgd_momentum_step(params, grads, velocities, lr=0.1, mu=0.9, nesterov=False):
    """One step of (Nesterov) momentum SGD, in-place. Pure PyTorch tensors."""
    for p, g, v in zip(params, grads, velocities):
        v.mul_(mu).add_(g)              # v <- mu*v + g
        if nesterov:
            update = g.add(v, alpha=mu)  # g + mu*v  (look-ahead)
        else:
            update = v
        p.add_(update, alpha=-lr)        # theta <- theta - lr * update

The answer is scale heterogeneity. In a transformer the gradient magnitudes differ wildly across parameters — embedding rows that fire rarely versus LayerNorm gains versus attention projections — and a single global learning rate cannot serve all of them. We need a per-parameter adaptive learning rate. That is what Adam provides.

Aside: SGD’s generalization edge

A persistent empirical finding is that SGD-trained models often generalize slightly better than adaptive ones, plausibly because the implicit regularization of SGD’s noise biases solutions toward flatter minima. For LLMs this edge is outweighed by Adam’s vastly faster and more robust convergence on the ill-conditioned, sparse-gradient landscape of language. The frontier optimizers later in this chapter (Muon, Shampoo) are partly attempts to recover both: adaptive speed and SGD-like generalization.

Adam and AdamW: Derivation, Bias Correction, Decoupled Decay

Adam (Kingma & Ba, Adam: A Method for Stochastic Optimization, 2015) combines two ideas: momentum on the gradient (the first moment), and a per-coordinate rescaling by the running magnitude of the gradient (the second moment, an idea inherited from RMSProp and AdaGrad). It maintains two exponential moving averages:

\[ m_t = \beta_1 m_{t-1} + (1-\beta_1) g_t, \qquad v_t = \beta_2 v_{t-1} + (1-\beta_2) g_t^2, \]

where \(g_t^2\) is elementwise. Typical defaults are \(\beta_1 = 0.9\), \(\beta_2 = 0.999\) (for LLMs \(\beta_2 = 0.95\) is common — more on that below). \(m_t\) estimates \(\mathbb{E}[g]\) and \(v_t\) estimates \(\mathbb{E}[g^2]\).

Why bias correction is necessary

Both averages are initialized at zero, which biases them toward zero in early steps. Unroll \(v_t\) assuming a stationary gradient distribution:

\[ v_t = (1-\beta_2)\sum_{i=1}^{t} \beta_2^{\,t-i}\, g_i^2. \]

Taking expectations and assuming \(\mathbb{E}[g_i^2]\) is approximately constant \(\approx \mathbb{E}[g_t^2]\),

\[ \mathbb{E}[v_t] = \mathbb{E}[g_t^2]\,(1-\beta_2)\sum_{i=1}^{t}\beta_2^{\,t-i} = \mathbb{E}[g_t^2]\,(1 - \beta_2^{\,t}). \]

The factor \((1-\beta_2^{\,t})\) is the bias: at \(t=1\) with \(\beta_2=0.999\), \(v_1\) is only \(0.1\%\) of the true second moment, so \(\sqrt{v_1}\) is \(\approx\sqrt{1000}\approx 31\times\) too small. The first moment is biased too (\(m_1 = (1-\beta_1)g = 0.1\,g\)), and the two biases partially cancel — an update with neither correction is inflated by \((1-\beta_1)/\sqrt{1-\beta_2} = 0.1/0.0316 \approx 3.2\times\) at \(t=1\), still enough to wreck a freshly initialized model. Crucially the two biases decay at different rates (\(\beta_1^t\) vs \(\beta_2^t\)), so the mismatch persists for hundreds of steps and must be removed from each moment separately:

\[ \hat{m}_t = \frac{m_t}{1-\beta_1^{\,t}}, \qquad \hat{v}_t = \frac{v_t}{1-\beta_2^{\,t}}. \]

The final update normalizes the first moment by the root second moment, with a small \(\epsilon\) (e.g. \(10^{-8}\)) for numerical safety:

\[ \theta_t = \theta_{t-1} - \eta\, \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon}. \]

The deep insight is in the ratio \(\hat{m}_t / \sqrt{\hat{v}_t}\). For a coordinate with a consistent gradient, \(\hat{m}_t \approx \sqrt{\hat{v}_t}\), so the update magnitude is \(\approx \eta\) — Adam takes a step of roughly unit size in each coordinate, regardless of the gradient’s absolute scale. This is the scale-invariance that makes Adam so robust: you can change the loss scaling (or use mixed precision, see Mixed Precision, bf16 & FP8 Training) and Adam’s effective step is unchanged. It is also why Adam tolerates the wildly heterogeneous gradient scales of a transformer that defeat plain SGD.

Adam turns wildly different gradient scales into uniform steps Illustrative magnitudes only. Four coordinates, three orders of magnitude apart, all end up ~unit-size after Adam's per-coordinate rescaling. Raw gradient g (per-coordinate) one global learning rate, eta ~1e-4 ~1e-2 ~1e-1 ~1e0 embed row (rare tok) LayerNorm gain attention proj MLP proj eta x g: a tiny step here... ...and a huge step here (right bar) one LR cannot serve all: SGD zig-zags divide each coordinate by its OWN sqrt(v_hat) (running grad magnitude) Adam step m_hat / sqrt(v_hat) approx eta (unit step) ~1e-4 ~1e-2 ~1e-1 ~1e0 embed row LayerNorm attn proj MLP proj each divided by its own sqrt(v_hat) all four coordinates land at ~equal height Big takeaway For a consistent-gradient coordinate, m_hat approx sqrt(v_hat), so the update magnitude is approx eta -- a step of roughly unit size in every coordinate, independent of the gradient's raw scale. That is Adam's scale-invariance: change the loss scaling and the effective step is unchanged.
Adam divides each coordinate by its own running gradient magnitude, so a single learning rate produces a roughly uniform step everywhere. Four illustrative coordinates start three orders of magnitude apart under SGD (a rare embedding row barely moves while an MLP projection lurches); after Adam's m_hat / sqrt(v_hat) rescaling, all four land near the same ~eta height, which is exactly the scale-invariance that lets one global learning rate work across a transformer's wildly heterogeneous parameters.

Decoupled weight decay: the W in AdamW

L2 regularization adds \(\tfrac{\lambda}{2}\lVert\theta\rVert^2\) to the loss, which contributes \(\lambda\theta\) to the gradient. In plain SGD this is identical to weight decay (shrinking \(\theta\) toward zero each step). But in Adam they are not equivalent: the \(\lambda\theta\) term flows through the \(m_t\) and \(v_t\) machinery and gets divided by \(\sqrt{\hat v_t}\), so parameters with large gradient magnitude (large \(v_t\)) get less decay — exactly backwards from what you want. Loshchilov & Hutter (Decoupled Weight Decay Regularization, 2019) showed this hurts, and proposed AdamW, which applies the decay directly to the weights, decoupled from the adaptive term:

\[ \theta_t = \theta_{t-1} - \eta\left( \frac{\hat{m}_t}{\sqrt{\hat{v}_t}+\epsilon} + \lambda\, \theta_{t-1}\right). \]

Equivalently \(\theta_t = (1-\eta\lambda)\,\theta_{t-1} - \eta\,\hat m_t/(\sqrt{\hat v_t}+\epsilon)\): a clean multiplicative shrink toward zero plus the adaptive step. AdamW is the de-facto standard for pretraining every modern LLM. A crucial practical detail: do not decay 1-D parameters — biases, LayerNorm/RMSNorm gains, and usually embeddings — only the 2-D weight matrices. Decaying norm gains pulls them toward zero and destabilizes training.

Implementing AdamW from scratch

Here is a complete, correct, heavily-commented AdamW that you could drop into a real training loop. It mirrors the math above and matches torch.optim.AdamW numerically.

import torch
from torch.optim import Optimizer

class AdamW(Optimizer):
    """From-scratch AdamW (Loshchilov & Hutter, 2019).

    Stores two state tensors per parameter: exp_avg (m) and exp_avg_sq (v).
    This is the memory tax we account for in the next section.
    """
    def __init__(self, params, lr=1e-3, betas=(0.9, 0.95),
                 eps=1e-8, weight_decay=0.1):
        # betas: (beta1, beta2). For LLMs beta2=0.95 is the common choice.
        defaults = dict(lr=lr, betas=betas, eps=eps, weight_decay=weight_decay)
        super().__init__(params, defaults)

    @torch.no_grad()
    def step(self, closure=None):
        loss = closure() if closure is not None else None
        for group in self.param_groups:
            lr, (b1, b2) = group["lr"], group["betas"]
            eps, wd = group["eps"], group["weight_decay"]
            for p in group["params"]:
                if p.grad is None:
                    continue
                g = p.grad
                if g.is_sparse:
                    raise RuntimeError("AdamW does not support sparse grads")

                state = self.state[p]
                if len(state) == 0:                    # lazy init
                    state["step"] = 0
                    state["exp_avg"] = torch.zeros_like(p)      # m
                    state["exp_avg_sq"] = torch.zeros_like(p)   # v
                m, v = state["exp_avg"], state["exp_avg_sq"]
                state["step"] += 1
                t = state["step"]

                # --- Decoupled weight decay: multiplicative shrink toward 0 ---
                # Applied to the *weights*, NOT folded into the gradient.
                if wd != 0:
                    p.mul_(1.0 - lr * wd)

                # --- Update biased first and second moment estimates ---
                m.mul_(b1).add_(g, alpha=1.0 - b1)          # m = b1*m + (1-b1)*g
                v.mul_(b2).addcmul_(g, g, value=1.0 - b2)   # v = b2*v + (1-b2)*g^2

                # --- Bias correction ---
                bias_c1 = 1.0 - b1 ** t
                bias_c2 = 1.0 - b2 ** t
                # Fold bias_c2 into the denominator; step_size folds bias_c1.
                denom = (v.sqrt() / (bias_c2 ** 0.5)).add_(eps)
                step_size = lr / bias_c1

                # theta <- theta - step_size * m / denom
                p.addcdiv_(m, denom, value=-step_size)
        return loss

A few implementation notes that separate a toy from a production optimizer. The @torch.no_grad() decorator is mandatory — the update itself must not build an autograd graph. Operations are in-place (mul_, add_, addcmul_, addcdiv_) to avoid allocating new tensors every step; for a 7B model a single full-size temporary is 14 GB in bf16. Real implementations go further with fused or foreach kernels (torch.optim.AdamW(..., fused=True)) that batch the elementwise math across all parameters into one CUDA launch, which is a large throughput win when you have thousands of parameter tensors.

Common pitfall: \(\beta_2\) and loss spikes

The default \(\beta_2 = 0.999\) averages the second moment over \(\sim 1/(1-\beta_2) = 1000\) steps. If a single batch produces a large gradient (a “bad” document, a tokenization artifact), \(v_t\) reacts slowly, so \(\sqrt{\hat v_t}\) stays small and the update explodes — a classic loss spike. Lowering to \(\beta_2 = 0.95\) (a \(\sim 20\)-step window) makes the denominator respond faster and is standard for large LLM pretraining. See Training Stability, Loss Spikes & Debugging Large Runs.

Parameter groups and the libraries you actually call

In a real run you never hand model.parameters() to the optimizer as one blob — the decay/no-decay split from above has to be expressed as parameter groups, and the optimizer itself comes from a library. This is the whole “optimizer layer” of a training script:

import torch

model = torch.nn.Sequential(              # stand-in for your transformer
    torch.nn.Embedding(1000, 64), torch.nn.LayerNorm(64), torch.nn.Linear(64, 64)
)

def param_groups(model, weight_decay=0.1):
    """Two groups: 2-D matmul weights decay, 1-D params (norms, biases) do not."""
    decay, no_decay = [], []
    for name, p in model.named_parameters():
        if not p.requires_grad:
            continue
        # nanoGPT/GPT-3 convention: everything with ndim >= 2 decays, including
        # the embedding and LM head. Some recipes exclude those by name — be
        # explicit either way, and never let norm gains or biases into `decay`.
        (decay if p.ndim >= 2 else no_decay).append(p)
    return [{"params": decay, "weight_decay": weight_decay},
            {"params": no_decay, "weight_decay": 0.0}]

# The 99% case: PyTorch's fused AdamW. `fused=True` runs the whole elementwise
# update as one multi-tensor CUDA kernel (params must be CUDA + floating point);
# the default `foreach=True` path is the CPU/other-backend fallback.
opt = torch.optim.AdamW(param_groups(model), lr=3e-4, betas=(0.9, 0.95),
                        eps=1e-8, fused=torch.cuda.is_available())

# Memory-bound instead? bitsandbytes is a drop-in replacement, same signature:
#   import bitsandbytes as bnb
#   opt = bnb.optim.AdamW8bit(param_groups(model), lr=3e-4, betas=(0.9, 0.95))
# (bitsandbytes' GlobalOptimManager lets you force the embedding table back to
#  32-bit optimizer state, which is the usual stability precaution.)

The same layer looks different inside the big training frameworks, and it is worth knowing which knob is which: DeepSpeed swaps in deepspeed.ops.adam.FusedAdam automatically, and DeepSpeedCPUAdam when you enable ZeRO-Offload so the optimizer step runs on CPU; Megatron-LM shards optimizer state ZeRO-1-style behind --use-distributed-optimizer; PyTorch FSDP shards it as a consequence of sharding the parameters themselves (see Megatron-LM, DeepSpeed & Parallelism in Practice). In every case the math is the AdamW you just wrote — only the sharding and the kernel change.

Large-batch training: LARS, LAMB and the trust ratio

When you scale the global batch to tens of thousands of sequences to keep more GPUs busy, a single global learning rate becomes the bottleneck again — this time across layers. The ratio \(\lVert\Delta\theta\rVert / \lVert\theta\rVert\) varies by orders of magnitude between layers, and the layer with the largest ratio diverges first. LARS (You et al., 2017) and its Adam-based successor LAMB (You et al., Large Batch Optimization for Deep Learning: Training BERT in 76 Minutes, 2020) fix this with a per-layer trust ratio. Writing \(u_t\) for the layer’s raw update — for LAMB, the AdamW update \(\hat m_t/(\sqrt{\hat v_t}+\epsilon) + \lambda\theta_{t-1}\) — the step becomes

\[ \theta_t = \theta_{t-1} - \eta\,\frac{\lVert\theta_{t-1}\rVert}{\lVert u_t\rVert}\, u_t, \]

with the ratio clamped to 1 when either norm is zero. Every layer now moves by a fixed fraction of its own weight norm per step, which is what let BERT pretrain at batch size 32k. For LLM pretraining today, tuned AdamW with warmup and muP-style width scaling (see Learning Rate Schedules, Warmup, Batch Size & Hyperparameters) has largely displaced LAMB — but the diagnostic is permanent: log \(\lVert\Delta\theta\rVert/\lVert\theta\rVert\) per layer during training. A common rule of thumb is that healthy training sits on the order of \(10^{-3}\); a layer sitting orders of magnitude above the rest is where your next loss spike will come from.

The Memory Cost of Optimizer States

Here is the systems reality that motivates everything in the rest of the chapter. Consider a model with \(P\) parameters. With AdamW you store, at minimum:

Tensor Count Typical precision Bytes/param
Parameters (weights) \(P\) bf16 2
Gradients \(P\) bf16 2
Adam \(m\) (first moment) \(P\) fp32 4
Adam \(v\) (second moment) \(P\) fp32 4
Master weights (fp32 copy) \(P\) fp32 4

That last row appears because mixed-precision training keeps a high-precision master copy of the weights so that tiny updates are not lost to bf16 rounding (see Mixed Precision, bf16 & FP8 Training). The headline number from the ZeRO/DeepSpeed analysis is 16 bytes per parameter for the model+optimizer state (2+2+4+4+4 = 16), of which the optimizer alone (m, v, master weights) is 12 bytes — three times the 4 bytes of bf16 weights and gradients together.

Worked example: optimizer memory for a 7B model

Take \(P = 7\times 10^9\) parameters.

  • bf16 weights: \(7\text{e}9 \times 2 = 14\) GB
  • bf16 gradients: \(14\) GB
  • Adam \(m\) (fp32): \(7\text{e}9 \times 4 = 28\) GB
  • Adam \(v\) (fp32): \(28\) GB
  • fp32 master weights: \(28\) GB

Total \(= 14 + 14 + 28 + 28 + 28 = 112\) GB, i.e. \(16\) bytes/param. The optimizer states alone are 84 GB — they do not fit on a single 80 GB H100, and they dwarf the 14 GB of actual weights. This is the single biggest reason large-model training needs ZeRO/FSDP sharding (see Distributed Training I: Data Parallelism, DDP, ZeRO & FSDP), and the single biggest motivation for memory-frugal optimizers. Halving optimizer memory can be the difference between needing 16 versus 8 GPUs.

The 16-byte-per-parameter memory tax of AdamW Every trained parameter costs 16 bytes total; 12 of those bytes are optimizer state, three times the 4-byte model (bf16 weights + grads). Per-parameter memory, 16 bytes total (segment width proportional to bytes) bf16 weights 2 B bf16 grads 2 B Adam m (fp32) 4 B Adam v (fp32) 4 B fp32 master weights 4 B actual model = 4 B OPTIMIZER STATE = 12 B/param = 3x the weights 7B model, in GB bf16 weights 14 bf16 grads 14 Adam m 28 Adam v 28 fp32 master 28 total 112 GB optimizer state alone 84 GB 80 GB H100 84 GB does not fit one 80 GB H100
Optimizer state, not the weights, dominates AdamW's memory footprint. Of the 16 bytes/param needed to train in mixed precision, the bf16 weights and gradients are only 4 bytes; the remaining 12 bytes — Adam's first and second moments plus the fp32 master-weight copy — are three times that 4-byte model (weights + gradients). For a 7B model that is 112 GB total, of which 84 GB is optimizer state alone, more than an 80 GB H100 can hold on its own.

Two orthogonal strategies attack this. The first is systems-level: ZeRO/FSDP shards the 12 bytes of optimizer state across \(N\) data-parallel ranks, so each rank holds only \(12P/N\) bytes — no change to the math, pure distribution. The second is algorithmic: redesign the optimizer to store less state per parameter. Adafactor, Lion, and the sign-based family live here, and that is where we turn next. The two strategies compose: you can shard a Lion optimizer too.

Adafactor: Factoring the Second Moment

Adafactor (Shazeer & Stern, Adafactor: Adaptive Learning Rates with Sublinear Memory Cost, 2018) was born from exactly the memory pressure above, originally for training large T5 models on TPUs. Its key observation: the costliest state is the second moment \(v\), a full \(P\)-element tensor. For a weight matrix \(W \in \mathbb{R}^{n\times m}\), instead of storing the full \(n\times m\) matrix of second moments, Adafactor stores only a row vector \(R\in\mathbb{R}^{n}\) and a column vector \(C\in\mathbb{R}^{m}\) and reconstructs a rank-1 approximation:

\[ \hat V_{ij} \approx \frac{R_i\, C_j}{\sum_k R_k}. \]

This is the best rank-1 (in a generalized-KL sense) factorization of the true second-moment matrix. The memory for the second moment drops from \(O(nm)\) to \(O(n+m)\) — sublinear in the parameter count. For a \(4096\times 4096\) matrix that is \(16.7\)M numbers versus \(8192\), a \(2000\times\) reduction for that tensor’s second-moment state.

Adafactor pairs this with two more memory moves: it can drop the first moment entirely (\(\beta_1 = 0\), no momentum), and it uses relative step sizes scaled by the RMS of the parameters so it needs no external learning-rate tuning. It also adds update clipping by RMS norm for stability. The factored update for a matrix, in pseudocode:

import torch

def adafactor_matrix_step(W, G, R, C, t, lr, beta2=0.999, eps1=1e-30, eps2=1e-3):
    """One Adafactor step for a 2D weight W with grad G.
    R: row accumulator (n,), C: col accumulator (m,). No first moment here.
    """
    n, m = W.shape
    g2 = G * G + eps1                       # squared grad, floored

    # Decayed running averages of row sums and column sums of g^2
    beta2_t = 1.0 - t ** (-0.8)             # Adafactor's time-dependent decay
    R.mul_(beta2_t).add_(g2.mean(dim=1), alpha=1 - beta2_t)   # (n,)
    C.mul_(beta2_t).add_(g2.mean(dim=0), alpha=1 - beta2_t)   # (m,)

    # Rank-1 reconstruction of the second-moment estimate V_hat (n,m)
    R_factor = (R / R.mean()).rsqrt().unsqueeze(1)   # (n,1)
    C_factor = C.rsqrt().unsqueeze(0)                # (1,m)
    update = G * R_factor * C_factor                 # G / sqrt(V_hat)

    # RMS-clip the update (Adafactor's stability trick)
    rms = update.pow(2).mean().sqrt()
    update = update / max(1.0, (rms / 1.0).item())

    # Relative step size scaled by parameter RMS
    param_rms = W.pow(2).mean().sqrt().clamp_min(eps2)
    W.add_(update, alpha=-lr * param_rms.item())

The trade-off is real: Adafactor’s factored second moment and missing momentum make it slightly noisier and sometimes less stable than AdamW, and it can need more babysitting (warmup, the eps2 floor). But it cut optimizer memory roughly in half-to-two-thirds and made T5-scale training feasible. It remains popular for fine-tuning large models on memory-constrained hardware, and its rank-1 factorization idea echoes in later work. A related modern option, 8-bit Adam (Dettmers et al.), takes the orthogonal route of keeping full \(m\) and \(v\) but quantizing them to 8 bits with block-wise scaling, cutting their footprint \(4\times\) with almost no quality loss — and composes with everything else.

Lion: Learning the Sign of the Update

Lion (Chen et al., Symbolic Discovery of Optimization Algorithms, 2023) was discovered by a program-search procedure over optimizer programs, and the winner is startlingly simple. “Lion” stands for Evolved Sign Momentum. It keeps a single momentum buffer (so only 4 extra bytes/param versus Adam’s 8) and the update direction is the sign of an interpolated momentum:

\[ c_t = \beta_1 m_{t-1} + (1-\beta_1) g_t, \qquad \theta_t = \theta_{t-1} - \eta\big(\operatorname{sign}(c_t) + \lambda\theta_{t-1}\big), \]
\[ m_t = \beta_2 m_{t-1} + (1-\beta_2) g_t. \]

Note the two different interpolations: the update uses \(\beta_1\) (e.g. 0.9) while the momentum buffer is updated with \(\beta_2\) (e.g. 0.99). The \(\operatorname{sign}\) is the heart of it: every parameter moves by exactly \(\pm\eta\) (plus decay), independent of gradient magnitude. This is an extreme form of the scale-invariance Adam approximates — Lion makes it exact and uniform.

import torch

def lion_step(p, g, m, lr=1e-4, beta1=0.9, beta2=0.99, wd=0.0):
    """One Lion update, in-place. Only ONE state tensor m per parameter."""
    # Update direction uses an interpolation with beta1...
    c = m.mul(beta1).add(g, alpha=1.0 - beta1)   # beta1*m + (1-beta1)*g (temp)
    update = c.sign()                            # +/-1 per coordinate
    if wd != 0:
        p.mul_(1.0 - lr * wd)                    # decoupled weight decay
    p.add_(update, alpha=-lr)                    # theta <- theta - lr*sign(c)
    # ...but the stored momentum uses beta2 (note: g, not c)
    m.mul_(beta2).add_(g, alpha=1.0 - beta2)

Because the update magnitude is uniformly \(\eta\), Lion’s effective step is larger and more uniform than AdamW’s, so the recommended learning rate is roughly \(3\)\(10\times\) smaller than Adam’s, and the weight decay correspondingly \(3\)\(10\times\) larger (to keep \(\eta\lambda\) in a sane range). When tuned, Lion matches or beats AdamW on many vision and language pretraining tasks while using half the optimizer memory and slightly less compute (no square root, no second buffer). Its weaknesses: the sign update injects more gradient noise, so it tends to need larger batch sizes to behave, and it can be touchier near the end of training. Still, Lion is the cleanest demonstration that you do not need a per-coordinate magnitude estimate at all — a good sign plus momentum is often enough.

Practitioner tip: re-tune LR and decay when switching optimizers

You cannot drop a new optimizer into an existing recipe and keep the hyperparameters. Lion needs a much smaller LR and larger decay than AdamW; Muon needs its own LR for matrices and a separate AdamW for embeddings and the LM head. Always re-sweep learning rate (and warmup) when changing optimizer family. See Learning Rate Schedules, Warmup, Batch Size & Hyperparameters.

Second-Order Methods: Shampoo and Preconditioning

Everything so far uses only diagonal curvature information — Adam’s \(\sqrt{v}\) is a diagonal preconditioner, one scalar per coordinate. The ideal update, from a second-order Taylor expansion, is Newton’s method:

\[ \theta_t = \theta_{t-1} - \eta\, H^{-1} g_t, \]

where \(H\) is the Hessian (or, in practice, the Fisher / empirical second-moment matrix). \(H^{-1}\) rotates and rescales the gradient to undo the loss surface’s anisotropy, fixing the ill-conditioning that diagonal methods can only partially address. The catch is fatal at scale: for \(P\) parameters \(H\) is \(P\times P\), which is \(\sim 5\times 10^{19}\) entries for a 7B model. Storing, let alone inverting, the full Hessian is impossible.

Shampoo (Gupta, Koren & Singer, Shampoo: Preconditioned Stochastic Tensor Optimization, 2018) makes second-order methods tractable for matrix-shaped parameters by using a Kronecker-factored preconditioner. For a weight matrix \(W\in\mathbb{R}^{n\times m}\) with gradient \(G\), it maintains two much smaller matrices:

\[ L_t = L_{t-1} + G G^\top \in \mathbb{R}^{n\times n}, \qquad R_t = R_{t-1} + G^\top G \in \mathbb{R}^{m\times m}, \]

and preconditions on both sides with their inverse fourth roots:

\[ W_t = W_{t-1} - \eta\, L_t^{-1/4}\, G\, R_t^{-1/4}. \]

This approximates the full \(nm\times nm\) preconditioner by the Kronecker product \(L^{1/2}\otimes R^{1/2}\), capturing curvature between rows and between columns separately. The memory is \(O(n^2 + m^2)\) instead of \(O(n^2 m^2)\), and Shampoo provably converges faster per step on many problems. Its costs are the periodic computation of matrix inverse-roots (eigendecompositions of \(n\times n\) and \(m\times m\) matrices, done every \(K\) steps to amortize) and the \(L,R\) accumulators themselves. A distributed implementation (Distributed Shampoo, Anil et al.) won the 2024 AlgoPerf optimization benchmark, demonstrating that well-engineered second-order methods can beat AdamW on wall-clock time, not just step count. The practical barriers — kernel complexity, inverse-root numerics, and integrating with parameter sharding — have kept it out of most mainstream LLM recipes, but it directly inspired the optimizer we cover last.

Muon: Orthogonalizing the Update

Muon (Jordan et al., 2024) is the most prominent of the recent entrants and has driven much of the optimizer excitement since 2024, graduating from record-setting nanoGPT speedruns to frontier-scale production training (Moonshot’s Kimi K2 and Zhipu’s GLM-4.5 both train on Muon variants). The name stands for MomentUm Orthogonalized by Newton-Schulz. Its premise is geometric: for a 2-D weight matrix, the momentum update \(M_t\) is typically dominated by a few large singular directions — it is effectively low-rank, so most of the update’s “energy” pushes along a handful of directions and starves the rest. Muon fixes this by replacing the momentum update with its orthogonalization: the nearest semi-orthogonal matrix, which has all singular values equal to 1.

Formally, if \(M_t = U\Sigma V^\top\) is the singular value decomposition (SVD) of the momentum, Muon’s update is

\[ O_t = U V^\top, \qquad W_t = W_{t-1} - \eta\, O_t. \]

Setting every singular value to 1 makes the update spectrally uniform — it pushes equally along every singular direction of the momentum, rather than letting the top few dominate. This is the matrix-valued analogue of Lion’s per-coordinate sign: Lion normalizes each scalar entry to \(\pm 1\); Muon normalizes each singular value to 1. Both are aggressive ways of discarding magnitude and keeping direction, but Muon respects the matrix structure of the weight.

Adam, Lion, Muon: discard magnitude, keep direction Same design axis at three granularities: soft per-coordinate normalization, hard per-coordinate sign, and per-singular-value orthogonalization. Adam (soft, per-coordinate) Lion (sign, per-coordinate) Muon (orthogonalize, per singular value) input: raw coordinates c divide each coord by sqrt(v_hat) approx unit divide each coord by sqrt(v_hat) -> approximately unit input: raw coordinates c sign(c): snap to +1 or -1, exactly exactly +eta exactly -eta (-1, points down) sign(c): every coord = +/- eta -> exactly uniform momentum M = U Sigma V^T (singular values, sorted) s1 s2 s3 s4 s5 a few directions carry the energy all singular values = 1 U V^T: every singular value = 1 -> the matrix analogue of Lion's sign scalar matrix Adam (soft precursor) Lion Muon
Adam, Lion, and Muon all discard magnitude and keep direction — they just differ in what unit they operate on. Adam softly rescales each coordinate toward roughly unit size; Lion hardens that into an exact per-coordinate sign, +eta or -eta; Muon performs the same "spiky to uniform" move on a weight matrix's singular values instead of its scalar entries, making Muon the matrix-valued analogue of Lion's sign.

The genius is how it computes \(UV^\top\) without an SVD (which is slow and hard to do well in bf16 on GPUs). It uses a Newton-Schulz iteration: a fixed sequence of matrix-multiply-only steps that drives the singular values toward 1. Starting from a spectrally-normalized \(X_0 = M/\lVert M\rVert_F\), it repeats a cubic polynomial in \(X\):

\[ X_{k+1} = a X_k + b\,(X_k X_k^\top) X_k + c\,(X_k X_k^\top)^2 X_k, \]

with carefully chosen coefficients \((a,b,c)\approx(3.4445, -4.7750, 2.0315)\) tuned so that roughly 5 iterations push all singular values close to 1. Crucially these are just matmuls — they run at full GPU throughput in bf16, so the orthogonalization adds only modest overhead.

import torch

@torch.no_grad()
def newton_schulz5(G, steps=5, eps=1e-7):
    """Compute an approximate UV^T (orthogonalization) of G via Newton-Schulz.
    Matmul-only; runs in bf16. Coefficients tuned to converge singular values->1.
    """
    assert G.ndim == 2
    a, b, c = 3.4445, -4.7750, 2.0315
    X = G.bfloat16()
    transposed = False
    if X.size(0) > X.size(1):           # iterate on the smaller dimension
        X = X.T
        transposed = True
    X = X / (X.norm() + eps)            # spectral pre-normalization
    for _ in range(steps):
        A = X @ X.T
        B = b * A + c * (A @ A)
        X = a * X + B @ X
    if transposed:
        X = X.T
    return X

@torch.no_grad()
def muon_step(W, G, momentum_buf, lr=0.02, mu=0.95, ns_steps=5):
    """One Muon update for a 2D weight W. Only ONE state buffer (momentum)."""
    momentum_buf.mul_(mu).add_(G)               # standard heavy-ball momentum
    # Reference implementations default to Nesterov here, i.e. orthogonalize
    # (G + mu * momentum_buf) instead of the buffer itself.
    update = newton_schulz5(momentum_buf, steps=ns_steps)  # orthogonalize
    # Scale by sqrt(max(rows,cols)) so RMS of update ~ 1, matching AdamW's scale
    scale = (max(W.shape) ** 0.5)
    W.add_(update, alpha=-lr * scale)
Newton-Schulz: matmul-only iteration flattens the spectrum to 1 Each panel is the sorted singular-value spectrum of the momentum matrix; the dashed line is the target every singular value converges to. dashed line every panel shares: target, all singular values = 1 Frame 0: X0 = M / ||M||_F s1 s8 momentum is low-rank: a few directions carry the energy -> k = 1 iteration: matmul-only (X X^T then polynomial) -> k = 2 runs at full GPU throughput in bf16 -> k = 3 small values rise, any overshoot settles -> k = 5 (final) singular values -> 1: spectrally uniform (dashed = before, frame 0) Directions U, V^T are unchanged across iterations -- only the magnitudes (singular values) move toward 1. Coefficients (a,b,c) approx (3.4445, -4.7750, 2.0315), tuned so ~5 iterations suffice. Big takeaway No SVD is ever computed. A fixed cubic polynomial in X, applied ~5 times, pushes every singular value from a spiky, low-rank spectrum to exactly 1 -- and every step is just matmuls, so it runs at full bf16 throughput.
Newton-Schulz replaces an SVD with ~5 matmul-only steps that push every singular value of the momentum matrix to 1. Starting from a spiky, effectively low-rank spectrum (a few directions dominate), the cubic map X <- aX + b(XX^T)X + c(XX^T)^2X is applied repeatedly until, by iteration 5, every singular value sits on the dashed target line; the directions (U, V^T) never change, only their magnitudes do, and because every step is a matrix multiply it runs at full GPU throughput in bf16.

Muon’s properties make it a compelling AdamW replacement for the bulk of an LLM’s parameters:

  • Memory. Like momentum SGD and Lion, it stores one buffer per parameter (momentum), not two — half of Adam’s optimizer-state memory. There is no second-moment tensor at all.
  • Only for 2-D weights. Orthogonalization is defined for matrices. The standard recipe is hybrid: use Muon for the 2-D hidden weight matrices (attention and MLP projections) and a small AdamW for the 1-D parameters and the input embedding / output head, which are not matrix-multiplied in the same sense and behave better under Adam.
  • Scale matching. The \(\sqrt{\max(n,m)}\) factor makes Muon’s update RMS comparable to AdamW’s, so learning-rate intuition transfers and you can reuse much of an AdamW schedule.
  • Reported gains. On small-scale benchmarks (nanoGPT speedruns) and, more recently, at MoE scale (Moonshot’s 16B/3B-active Moonlight on 5.7T tokens), Muon reaches a target loss in meaningfully fewer steps/tokens than tuned AdamW — roughly \(2\times\) compute efficiency at the compute-optimal frontier — while using less memory.

The mechanism connects cleanly to Shampoo: orthogonalizing \(M = U\Sigma V^\top\) to \(UV^\top\) is exactly applying the preconditioner \((MM^\top)^{-1/2}M\), a “whitening” of the update — the same spectral idea as Shampoo’s inverse-root preconditioning, but computed cheaply with matmuls and applied to the momentum rather than accumulated second moments. Muon can be read as a streamlined, GPU-friendly descendant of the Shampoo line.

Aside: why the embeddings get AdamW

Embedding and unembedding (LM head) parameters have rows indexed by token, with extremely sparse and uneven gradients — a rare token’s row is updated only when that token appears. Orthogonalizing across the vocabulary dimension mixes unrelated tokens and behaves poorly, and these layers benefit from Adam’s per-coordinate magnitude adaptation. Hence the hybrid Muon+AdamW recipe rather than Muon everywhere. The same logic explains why 1-D norm/bias parameters stay on AdamW.

Wiring the hybrid recipe

“Hybrid” means two optimizer objects over two disjoint parameter sets, both stepped every iteration. That is all there is to it:

import torch

model = torch.nn.Sequential(              # stand-in for your transformer
    torch.nn.Embedding(1000, 64), torch.nn.LayerNorm(64), torch.nn.Linear(64, 64)
)

# Muon for the 2-D hidden weights; AdamW for embeddings, LM head, norms, biases.
hidden, others = [], []
for name, p in model.named_parameters():
    is_hidden_matrix = p.ndim == 2 and not any(k in name
                                               for k in ("embed", "lm_head"))
    (hidden if is_hidden_matrix else others).append(p)

adamw = torch.optim.AdamW(others, lr=3e-4, betas=(0.9, 0.95), weight_decay=0.0)
bufs = {p: torch.zeros_like(p) for p in hidden}   # one momentum buffer per matrix

def hybrid_step(lr_muon=0.02):
    for p in hidden:                              # muon_step defined above
        muon_step(p.data, p.grad, bufs[p], lr=lr_muon)
    adamw.step()
    adamw.zero_grad(set_to_none=True)
    for p in hidden:
        p.grad = None

One systems caveat that bites as soon as you leave a single GPU: Newton-Schulz needs the whole 2-D matrix on one device, but ZeRO/FSDP shard parameters and gradients flat across ranks. A distributed Muon therefore has to gather each matrix’s gradient (or replicate the orthogonalization) before it can iterate — that engineering, on top of ZeRO-1-style optimizer sharding, is exactly what Moonshot’s open-source Moonlight implementation contributes. Reference implementations live in KellerJordan/modded-nanogpt (single-node) and MoonshotAI/Moonlight (distributed). We use this hybrid — Muon on hidden matrices, AdamW on the rest, with a QK-clip for attention stability — to train the capstone model in Optimizer & Schedule: Muon + MuonClip and Warmup-Stable-Decay, where every constant is pinned to a real 100M-parameter run.

Choosing an Optimizer: A Practical Comparison

The table below summarizes the state per parameter (the memory tax) and the character of each method. “Buffers/param” counts the optimizer-specific state tensors beyond weights and gradients.

Optimizer Buffers/param Extra bytes/param (fp32) Update character Where it shines
SGD 0 0 raw gradient cheapest; rarely used for LLMs
SGD+momentum 1 (\(v\)) 4 velocity vision models, great generalization
AdamW 2 (\(m,v\)) 8 per-coord adaptive LLM default; robust, well-understood
8-bit AdamW 2 (quantized) ~2 same as AdamW AdamW quality, \(4\times\) less state
LAMB 2 (\(m,v\)) 8 AdamW × layer trust ratio very large batches (32k+)
Adafactor factored ~\(O(n{+}m)\) factored 2nd moment memory-bound fine-tuning, T5-scale
Lion 1 (\(m\)) 4 sign of momentum half AdamW memory; needs big batches
Shampoo 2 factors \(O(n^2{+}m^2)\) Kronecker 2nd-order fewest steps; heavy compute/eng
Muon (2-D) 1 (\(m\)) 4 orthogonalized momentum speed + half memory; hybrid recipe

A pragmatic decision procedure for a new pretraining run:

  1. Default to AdamW with \(\beta=(0.9, 0.95)\), weight decay \(0.1\) on 2-D weights only, gradient clipping at norm 1.0. It is the most documented and forgiving choice and the safe baseline for an interview answer.
  2. If optimizer memory is the binding constraint and you cannot shard further, reach for 8-bit AdamW (smallest behavioral change) or Adafactor (most aggressive, more babysitting).
  3. If you want speed and have appetite for tuning, try Muon for the matrices + AdamW for embeddings/head; it is the most exciting current option and halves optimizer-state memory on the bulk of parameters.
  4. Always re-sweep learning rate, warmup, and weight decay when you change family — the hyperparameters do not transfer.

Interview Corner

Q: Why is Adam (or AdamW) the default for training transformers instead of SGD with momentum, and what is the cost of that choice?

A: Three reasons. (1) Per-parameter adaptivity / scale invariance. Transformer gradients span many orders of magnitude across parameters — sparse embedding rows, norm gains, dense projections — and Adam’s division by \(\sqrt{\hat v}\) rescales every coordinate to a near-unit step, so a single global learning rate works. SGD has one rate for all and zig-zags on the resulting ill-conditioned, anisotropic loss surface. (2) Robustness to sparse and noisy gradients, which dominate at the embedding layer. (3) Fast, reliable early convergence, helped by bias correction that prevents huge steps in the first iterations. The cost is memory: AdamW stores two extra fp32 tensors per parameter (first and second moment), so optimizer states are ~12 bytes/param including the fp32 master copy — three times the bf16 weights. For a 7B model that’s ~84 GB of optimizer state alone, which is why we need ZeRO/FSDP sharding and why memory-frugal optimizers (Lion, Adafactor, Muon, 8-bit Adam) exist. A secondary cost: SGD often generalizes slightly better, but for LLMs Adam’s convergence speed and robustness win decisively.

Follow-up Q: What’s the difference between L2 regularization and weight decay in Adam, and why does AdamW matter?

A: In plain SGD they’re identical. In Adam they’re not: L2 adds \(\lambda\theta\) to the gradient, which then passes through the adaptive denominator \(\sqrt{\hat v}\) — so high-gradient parameters get less effective decay, the opposite of the intent. AdamW decouples decay, applying \(\theta \leftarrow (1-\eta\lambda)\theta\) directly to the weights independent of the adaptive term. This consistently improves generalization and is why every modern LLM uses AdamW, not Adam-with-L2. And you apply decay only to 2-D weight matrices, never to norm gains or biases.

Key Takeaways

  • SGD+momentum is cheapest (0–1 buffers) and generalizes well, but a single global learning rate cannot handle the wildly heterogeneous gradient scales of a transformer — hence adaptive methods.
  • Adam/AdamW rescale each coordinate by \(\hat m/\sqrt{\hat v}\), giving near-unit, scale-invariant steps; bias correction (\(1-\beta^t\) factors) prevents huge early steps. For LLMs use \(\beta_2 = 0.95\) to react faster to gradient spikes.
  • AdamW decouples weight decay from the adaptive term (\(\theta\leftarrow(1-\eta\lambda)\theta\)); L2-in-the-gradient under-decays high-gradient params. Decay only 2-D weights, never norms or biases.
  • Optimizer state is the memory hog: AdamW costs ~12 bytes/param (\(m\), \(v\), fp32 master) — 3× the bf16 weights, ~84 GB for a 7B model. This forces ZeRO/FSDP sharding and motivates frugal optimizers.
  • Adafactor factors the second moment into row×column vectors (\(O(n{+}m)\) memory) and can drop momentum — sublinear optimizer memory, at some stability cost. 8-bit Adam quantizes \(m,v\) for a \(4\times\) cut with little quality loss.
  • Lion stores one buffer and steps by the sign of momentum (uniform \(\pm\eta\)); half AdamW memory, needs a smaller LR, larger decay, and bigger batches.
  • LARS/LAMB rescale each layer’s update by a trust ratio \(\lVert\theta\rVert/\lVert u\rVert\) so every layer moves a fixed fraction of its own norm — the key to 32k-batch training, and the origin of the per-layer update-to-weight ratio (\(\sim 10^{-3}\)) you should be logging.
  • Shampoo is a tractable second-order method via Kronecker-factored preconditioners (\(L^{-1/4}GR^{-1/4}\)); fewest steps, but heavy compute and engineering.
  • Muon orthogonalizes the momentum of 2-D weights via a matmul-only Newton-Schulz iteration (singular values → 1), the matrix analogue of Lion’s sign. One buffer per param, hybrid with AdamW for embeddings/head; a fast, memory-light, newsmaking AdamW alternative.

State of the Art & Resources (2026)

As of 2026, AdamW remains the default for LLM pretraining, but orthogonalization-based optimizers—Muon in particular—have moved from nanoGPT speedruns to frontier-scale production use (Kimi K2, GLM-4.5), offering roughly 2× compute efficiency gains over AdamW. The field is converging on hybrid recipes: Muon for 2-D weight matrices, AdamW for embeddings and 1-D parameters.

Foundational work

Recent advances (2023–2026)

Open-source & tools

  • bitsandbytes-foundation/bitsandbytes — drop-in 8-bit AdamW (and other optimizers) via block-wise quantization; cuts optimizer-state memory 4× with negligible quality loss.
  • MoonshotAI/Moonlight — open-source distributed Muon implementation used to train the 16B Moonlight MoE on 5.7T tokens; includes pretrained checkpoints.
  • KellerJordan/modded-nanogpt — the Muon reference implementation inside the nanoGPT speedrun that made it famous; the place to read the Newton-Schulz kernel and the hybrid Muon+AdamW param split.
  • lucidrains/lion-pytorch — clean PyTorch implementation of Lion with optional Triton fused kernels.

Go deeper

Further reading

  • Kingma & Ba, Adam: A Method for Stochastic Optimization (2015) — the original Adam derivation and bias correction.
  • Loshchilov & Hutter, Decoupled Weight Decay Regularization (2019) — AdamW and why decoupling matters.
  • Shazeer & Stern, Adafactor: Adaptive Learning Rates with Sublinear Memory Cost (2018) — factored second moments.
  • Chen et al., Symbolic Discovery of Optimization Algorithms (2023) — the Lion optimizer.
  • You et al., Large Batch Optimization for Deep Learning: Training BERT in 76 Minutes (2020) — LARS/LAMB and the layer-wise trust ratio.
  • Gupta, Koren & Singer, Shampoo: Preconditioned Stochastic Tensor Optimization (2018); Anil et al., Scalable Second Order Optimization for Deep Learning (Distributed Shampoo).
  • Jordan et al., Muon (2024) — orthogonalized momentum via Newton-Schulz; see also the nanoGPT speedrun writeups.
  • Rajbhandari et al., ZeRO: Memory Optimizations Toward Training Trillion Parameter Models (2020) — the optimizer-state memory analysis and sharding.
  • Dettmers et al., 8-bit Optimizers via Block-wise Quantization (2022) — quantized Adam states.
Self-check: Adam optimizer-state memory
Mixed-precision Adam keeps an fp32 master copy + two fp32 moments (m, v) = 12 bytes/parameter of optimizer state. For a 1.0-billion-parameter model, how much? (answer in GB, 1 GB = 109 bytes)
GB
Show working
12 bytes × 109 params = 1.2×1010 bytes = 12 GB — on top of the weights and gradients, which is why optimizer state dominates training memory and motivates 8-bit optimizers / sharding.
Self-check: type a number and press Check (or Enter). Answers use a small tolerance, so round sensibly.

Exercises

1. (Weight decay.) A colleague configures AdamW with a single parameter group and applies weight decay \(\lambda = 0.1\) to every parameter in a transformer, including RMSNorm gains and biases. (a) Explain why L2 regularization (adding \(\tfrac{\lambda}{2}\lVert\theta\rVert^2\) to the loss) and decoupled weight decay are not equivalent under Adam, even though they are identical under plain SGD. (b) Why is decaying the 1-D RMSNorm gains specifically harmful? What should the correct setup look like?

Solution

(a) L2 regularization adds \(\lambda\theta\) to the gradient \(g_t\). That term then flows through Adam’s machinery: it is accumulated into \(m_t\) and \(v_t\) and finally divided by \(\sqrt{\hat v_t}+\epsilon\). So the effective decay a coordinate receives is \(\lambda\theta / \sqrt{\hat v_t}\) — parameters with a large running gradient magnitude (large \(v_t\)) get less decay, which is exactly backwards from the intent of shrinking all weights uniformly. AdamW instead applies the decay directly to the weights, decoupled from the adaptive denominator:

\[ \theta_t = (1-\eta\lambda)\,\theta_{t-1} - \eta\,\frac{\hat m_t}{\sqrt{\hat v_t}+\epsilon}, \]

a clean multiplicative shrink by the same factor \((1-\eta\lambda)\) for every weight, independent of its gradient. In plain SGD there is no \(\sqrt{\hat v_t}\) denominator, so \(\lambda\theta\)-in-the-gradient and multiplicative shrink coincide; the divergence is created entirely by Adam’s per-coordinate rescaling.

(b) An RMSNorm gain multiplies its activation channel; pulling it toward zero directly shrinks the signal passing through the normalization layer and destabilizes training (the layer’s output scale collapses). More generally, 1-D parameters (norm gains, biases, and usually embeddings) are not the high-dimensional weight matrices that benefit from L2-style capacity control, so decaying them only hurts. The correct setup uses two parameter groups: one for the 2-D weight matrices with weight_decay=0.1, and one for all 1-D parameters (and typically embeddings) with weight_decay=0.0.

2. (Momentum, by hand.) Consider heavy-ball momentum \(v_t = \mu v_{t-1} + g_t\), \(\theta_t = \theta_{t-1} - \eta v_t\), driven by a constant gradient \(g\) starting from \(v_0 = 0\). (a) Derive the terminal (steady-state) velocity and evaluate it for \(\mu = 0.9\) and \(\mu = 0.98\). (b) If you raise \(\mu\) from \(0.9\) to \(0.98\) and want to keep the same steady-state step length \(\eta v_\infty\), by what factor must you change \(\eta\)? © This is why momentum and learning rate are coupled. State the coupling in one sentence.

Solution

(a) Unrolling the recursion for constant \(g\) gives \(v_t = g\sum_{i=0}^{t-1}\mu^{i} = g\,\frac{1-\mu^t}{1-\mu}\). As \(t\to\infty\), \(\mu^t\to 0\), so the terminal velocity is

\[ v_\infty = \frac{g}{1-\mu}. \]

For \(\mu = 0.9\): \(v_\infty = g/0.1 = 10g\). For \(\mu = 0.98\): \(v_\infty = g/0.02 = 50g\).

(b) The steady-state step length is \(\eta v_\infty = \eta g/(1-\mu)\). Going from \(\mu=0.9\) to \(\mu=0.98\) multiplies \(v_\infty\) by \(50/10 = 5\). To hold \(\eta v_\infty\) fixed you must divide \(\eta\) by \(5\) (i.e. \(\eta \to \eta/5\)).

© Increasing momentum lengthens the effective step along consistent directions by \(1/(1-\mu)\), so you must lower the learning rate proportionally to avoid overshooting.

3. (Bias correction, by hand.) The chapter notes that with \(\beta_2 = 0.999\), the uncorrected second-moment estimate at \(t=1\) is only \(0.1\%\) of the true value, so the \(v\)-side of the raw step is about \(\sqrt{1000}\approx 31\times\) too large (partly offset by the first moment’s own bias). (a) Redo this for the LLM-standard \(\beta_2 = 0.95\): compute the bias factor \((1-\beta_2^{\,t})\) at \(t=1\) and \(t=3\), and the resulting “too-large” factor \(1/\sqrt{1-\beta_2^{\,t}}\) on the step. Comment on why \(\beta_2=0.95\) needs far less protection than \(\beta_2=0.999\). (b) Now show that with full bias correction, a constant gradient \(g\) produces a bias-corrected Adam step of exactly \(\eta\,\mathrm{sign}(g)\) at every \(t\) (take \(\epsilon\to 0\)), independent of \(\beta_1,\beta_2,t\). This is the scale-invariance property.

Solution

(a) The bias factor is \((1-\beta_2^{\,t})\) and the step inflation from an uncorrected \(\sqrt{v_t}\) is \(1/\sqrt{1-\beta_2^{\,t}}\).

  • \(t=1\): \(1-0.95 = 0.05\), so the inflation is \(1/\sqrt{0.05} = \sqrt{20} \approx 4.47\times\).
  • \(t=3\): \(1-0.95^3 = 1 - 0.857375 = 0.142625\), so the inflation is \(1/\sqrt{0.142625} \approx 2.65\times\).

With \(\beta_2=0.95\) the second moment averages over only \(\sim 1/(1-\beta_2)=20\) steps, so \(v_t\) fills in quickly and the early bias is mild (a \(\sim 4.5\times\) effect at \(t=1\), gone within a handful of steps). With \(\beta_2=0.999\) the window is \(\sim 1000\) steps, so \(v_1\) captures almost none of the true second moment and the \(v\)-side inflation is \(\sim 31\times\) — a far larger correction, over many more steps.

Worth noting: the first moment is biased in the opposite direction, so the two partly cancel. With no correction at all the net step inflation at \(t=1\) is \((1-\beta_1)/\sqrt{1-\beta_2}\): with \(\beta_2=0.999\) that is \(0.1/0.0316\approx 3.2\times\) (too large), but with \(\beta_2=0.95\) it is \(0.1/\sqrt{0.05}\approx 0.45\times\) (too small). The sign of the mismatch flips with \(\beta_2\), and it decays at a different rate for each moment — which is exactly why Adam corrects \(m\) and \(v\) separately rather than applying one lumped factor.

(b) For a constant gradient \(g\) with \(m_0 = v_0 = 0\), the EMAs of a constant are \(m_t = (1-\beta_1^{\,t})\,g\) and \(v_t = (1-\beta_2^{\,t})\,g^2\) (same unrolling as part 2a with the \((1-\beta)\) weighting). Bias correction divides each by its own \((1-\beta^t)\):

\[ \hat m_t = \frac{(1-\beta_1^{\,t})g}{1-\beta_1^{\,t}} = g, \qquad \hat v_t = \frac{(1-\beta_2^{\,t})g^2}{1-\beta_2^{\,t}} = g^2. \]

The update is therefore

\[ \theta_t - \theta_{t-1} = -\eta\,\frac{\hat m_t}{\sqrt{\hat v_t}} = -\eta\,\frac{g}{\sqrt{g^2}} = -\eta\,\mathrm{sign}(g), \]

a step of magnitude exactly \(\eta\) regardless of \(|g|\), \(\beta_1\), \(\beta_2\), or \(t\). This is the near-unit, scale-invariant step that lets a single global \(\eta\) serve parameters whose gradients span many orders of magnitude.

4. (Optimizer-memory accounting.) Consider a \(30\times 10^9\)-parameter dense model trained in bf16 mixed precision with AdamW (\(m\) and \(v\) in fp32, plus an fp32 master copy of the weights). (a) Give the memory for weights, gradients, and each of the three optimizer tensors, and the total bytes/param. (b) How much memory is the optimizer state alone, and how many 80 GB H100s would that state occupy (unsharded)? © You switch the 2-D matrices to an optimizer that keeps a single momentum buffer instead of \(m\) and \(v\) (Lion or Muon). Assuming essentially all \(30\)B params are 2-D, how much optimizer-state memory do you save, and what is the new H100 count for the optimizer state?

Solution

(a) With \(P = 30\text{e}9\):

  • bf16 weights: \(30\text{e}9 \times 2 = 60\) GB
  • bf16 gradients: \(60\) GB
  • fp32 Adam \(m\): \(30\text{e}9 \times 4 = 120\) GB
  • fp32 Adam \(v\): \(120\) GB
  • fp32 master weights: \(120\) GB

Total \(= 60+60+120+120+120 = 480\) GB, i.e. \(16\) bytes/param.

(b) Optimizer state \(=\) $m + v + $ master \(= 120+120+120 = 360\) GB (the \(12\) bytes/param figure). At \(80\) GB/GPU that is \(360/80 = 4.5\), so it occupies \(5\) H100s (you cannot use a fraction of a GPU).

© A single-buffer optimizer drops the \(v\) tensor entirely, saving one fp32 copy \(= 120\) GB. New optimizer state \(=\) momentum \((120)\) + master \((120) = 240\) GB, a \(120\) GB / one-third reduction. That is \(240/80 = 3\) H100s — two fewer GPUs just for optimizer state. (This is on top of any ZeRO/FSDP sharding, which the two strategies compose with.)

5. (Implementation.) The chapter gives Lion as a functional lion_step. Turn it into a proper torch.optim.Optimizer subclass, mirroring the from-scratch AdamW in the chapter: lazy per-parameter state initialization, one momentum buffer per parameter, decoupled weight decay, the \(\beta_1\)-interpolated sign update, and the \(\beta_2\)-updated momentum buffer. Keep it in-place and under @torch.no_grad().

Solution

The key structural point is that Lion stores exactly one state tensor per parameter (exp_avg), applies decoupled decay as a multiplicative shrink, steps by sign(beta1*m + (1-beta1)*g), and only then updates the stored buffer with beta2 (using the raw gradient g, not the interpolated c).

import torch
from torch.optim import Optimizer

class Lion(Optimizer):
    """From-scratch Lion (Chen et al., 2023). One state tensor per param."""
    def __init__(self, params, lr=1e-4, betas=(0.9, 0.99), weight_decay=0.0):
        # Lion's LR is typically ~3-10x smaller than AdamW's, decay larger.
        defaults = dict(lr=lr, betas=betas, weight_decay=weight_decay)
        super().__init__(params, defaults)

    @torch.no_grad()
    def step(self, closure=None):
        loss = closure() if closure is not None else None
        for group in self.param_groups:
            lr, (b1, b2) = group["lr"], group["betas"]
            wd = group["weight_decay"]
            for p in group["params"]:
                if p.grad is None:
                    continue
                g = p.grad
                state = self.state[p]
                if len(state) == 0:                       # lazy init
                    state["exp_avg"] = torch.zeros_like(p)  # m (only buffer)
                m = state["exp_avg"]

                # --- Decoupled weight decay (multiplicative shrink) ---
                if wd != 0:
                    p.mul_(1.0 - lr * wd)

                # --- Update direction: sign of the beta1 interpolation ---
                c = m.mul(b1).add(g, alpha=1.0 - b1)      # temp, not stored
                p.add_(c.sign(), alpha=-lr)               # theta -= lr*sign(c)

                # --- Momentum buffer uses beta2 and the raw grad g ---
                m.mul_(b2).add_(g, alpha=1.0 - b2)
        return loss

Notes matching the chapter: c is a temporary (built with the non-in-place .mul/.add) so the stored buffer m is not prematurely overwritten before the sign step; the buffer update uses g, not c; and every weight moves by exactly \(\pm lr\) (plus decay), which is why the recommended lr is smaller and weight_decay larger than for AdamW.

6. (Shampoo memory and the Muon connection.) Shampoo maintains \(L\in\mathbb{R}^{n\times n}\) and \(R\in\mathbb{R}^{m\times m}\) for a weight matrix \(W\in\mathbb{R}^{n\times m}\); AdamW maintains \(m,v\in\mathbb{R}^{n\times m}\). (a) Count the optimizer-state numbers for each on a square \(4096\times4096\) matrix and on a rectangular \(4096\times1024\) matrix. Is Shampoo always cheaper than Adam? What does this tell you about why people use Shampoo? (b) The chapter says orthogonalizing the momentum \(M = U\Sigma V^\top\) to \(UV^\top\) equals applying the preconditioner \((MM^\top)^{-1/2}M\). Verify this identity and explain in one line how it connects Muon to Shampoo’s spectral idea.

Solution

(a) AdamW stores \(m\) and \(v\), i.e. \(2nm\) numbers. Shampoo stores \(L\) and \(R\), i.e. \(n^2 + m^2\) numbers.

  • Square \(4096\times4096\): Adam \(= 2(4096)(4096) = 33.6\)M. Shampoo \(= 4096^2 + 4096^2 = 33.6\)M. Equal.
  • Rectangular \(4096\times1024\): Adam \(= 2(4096)(1024) = 8.39\)M. Shampoo \(= 4096^2 + 1024^2 = 16.78 + 1.05 = 17.8\)M. Shampoo is larger (\(\approx 2.1\times\)).

So Shampoo is not a memory-saving optimizer — for square matrices its accumulators tie Adam’s, and for tall/wide matrices they can cost more (the \(n^2\) term blows up when one dimension is large). People use Shampoo for its convergence (fewer steps via genuine second-order/Kronecker-factored curvature), accepting the compute of periodic inverse-fourth-roots and no memory win — the opposite trade-off from Lion/Muon, which chase memory and speed.

(b) Let \(M = U\Sigma V^\top\) be the SVD (with \(\Sigma\) square and invertible on the relevant subspace, \(U^\top U = V^\top V = I\)). Then

\[ MM^\top = U\Sigma V^\top V \Sigma U^\top = U\Sigma^2 U^\top \;\Rightarrow\; (MM^\top)^{-1/2} = U\Sigma^{-1}U^\top. \]

Therefore

\[ (MM^\top)^{-1/2}M = U\Sigma^{-1}U^\top \, U\Sigma V^\top = U\Sigma^{-1}\Sigma V^\top = U V^\top, \]

which is exactly Muon’s orthogonalized update (all singular values set to \(1\)). The connection: this is a “whitening” of the update by an inverse-square-root of a second-moment-like matrix \(MM^\top\) — the same spectral inverse-root preconditioning Shampoo applies via \(L^{-1/4}GR^{-1/4}\), except Muon computes it cheaply with a matmul-only Newton-Schulz iteration on the momentum instead of accumulating and eigendecomposing \(L,R\).