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
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:
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:
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:
Taking expectations and assuming \(\mathbb{E}[g_i^2]\) is approximately constant \(\approx \mathbb{E}[g_t^2]\),
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:
The final update normalizes the first moment by the root second moment, with a small \(\epsilon\) (e.g. \(10^{-8}\)) for numerical safety:
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.
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:
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
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.
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:
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:
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:
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:
and preconditions on both sides with their inverse fourth roots:
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
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.
+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\):
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)
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:
- 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.
- 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).
- 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.
- 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
- Kingma & Ba, Adam: A Method for Stochastic Optimization (2015) — original Adam derivation with bias-corrected moment estimates; the paper every LLM optimizer builds on.
- Loshchilov & Hutter, Decoupled Weight Decay Regularization (2019) — shows L2 regularization ≠ weight decay under Adam and introduces AdamW, now the universal LLM training standard.
- Shazeer & Stern, Adafactor: Adaptive Learning Rates with Sublinear Memory Cost (2018) — rank-1 factored second moments cut optimizer memory from O(nm) to O(n+m); first practical large-scale memory-frugal adaptive optimizer.
Recent advances (2023–2026)
- Chen et al., Symbolic Discovery of Optimization Algorithms (2023) — program-search discovers Lion (Evolved Sign Momentum): one buffer, sign update, half Adam’s optimizer-state memory.
- Liu et al., Muon is Scalable for LLM Training (2025) — proves Muon scales to large models with weight decay + per-parameter update scaling, achieving ~2× compute efficiency vs. AdamW on compute-optimal runs.
- Vyas et al., SOAP: Improving and Stabilizing Shampoo using Adam (2024) — runs Adam in Shampoo’s eigenbasis, reducing iterations by 40% and wall-clock time by 35% on language model training.
- Anil et al., Scalable Second Order Optimization for Deep Learning (2020) — Distributed Shampoo: Kronecker-factored preconditioners made tractable via CPU-distributed inverse-root computation.
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
- Keller Jordan, Muon: An optimizer for hidden layers in neural networks (2024) — the original Muon blog post explaining Newton-Schulz orthogonalization, nanoGPT results, and the connection to Shampoo.
- Dettmers et al., 8-bit Optimizers via Block-wise Quantization (2022) — ICLR 2022 spotlight; the paper behind bitsandbytes’ 8-bit Adam, with block-wise dynamic quantization preserving 32-bit fidelity.
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.
Show working
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:
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
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)\):
The update is therefore
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
Therefore
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\).