ml-models / Neural network fundamentals / Optimizers: SGD to AdamW core

Optimizers: SGD to AdamW

Backpropagation gives the gradient; the optimizer decides what to do with it. This page builds up from plain stochastic gradient descent through momentum, Nesterov and RMSProp to Adam and AdamW, races them on the same narrow valley, works one Adam step through by hand, shows why decoupled weight decay is not the same as L2 regularization, adds Muon's orthogonalized update for weight matrices and Kimi K2's QK-Clip, and reads the update rules out of torch.optim at v2.15.0.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. SGD and minibatches
  5. Momentum and Nesterov
  6. RMSProp
  7. Adam: moment estimates and bias correction
  8. AdamW: decoupled weight decay
  9. Muon: orthogonalized momentum for weight matrices
  10. Optimizer state memory
  11. In PyTorch
  12. Worked example
  13. The race in numbers
  14. Implementation
  15. Tradeoffs
  16. Check yourself
  17. Connections
  18. Read first
  19. Read next
  20. References

Problem

Gradient descent says: move every parameter a small step against its gradient, θ ← θ − η∇L(θ). Two things make that harder than it sounds. First, the true gradient is an average over the whole training set, and computing it for every step would be far too slow, so every step uses a noisy estimate from a small batch. Second, the loss surface is badly shaped. In some directions it is steep, in others nearly flat, and one learning rate η has to serve all of them: small enough not to blow up in the steep directions, which makes it crawl in the flat ones.

An optimizer is a rule for turning the stream of noisy gradients into steps that make fast, stable progress anyway. The rules on this page differ in what they remember between steps, and that memory (the optimizer's state) costs real memory: for Adam, two extra numbers per parameter.

Intuition

Picture a long, narrow valley: gentle along its floor, steep across it. Plain gradient descent points mostly across the valley, because that's where the slope is, so it bounces from wall to wall and creeps along the floor. Raise the learning rate to creep faster and the bouncing grows until it diverges.

  • Momentum keeps a running sum of past gradients, like a heavy ball. The across-the-valley components alternate in sign and cancel; the along-the-floor components agree and add up, so the ball builds speed where it should.
  • RMSProp divides each parameter's gradient by a running average of its own size. The steep direction, with big gradients, gets a small step; the flat direction gets a big one. Every parameter gets its own effective learning rate.
  • Adam does both: a running average of the gradient (momentum) divided by a running average of its square (RMSProp), plus a correction for the averages starting at zero. Its steps are about η in size whatever the gradient's scale.
  • AdamW is Adam with weight decay applied directly to the weights rather than folded into the gradient, where Adam's division would distort it.
Three optimizers on a narrow quadratic valley Contours of the loss L = ½(1x² + 50y²), a valley 50 times steeper across (y) than along (x), and the first 30 steps of three optimizers from (-4, 1), where the loss is 33. SGD, lr 0.036 ends at (-1.332, 0.001238) with loss 0.8866. momentum 0.7, lr 0.03 ends at (-0.04024, 0.005769) with loss 0.001642. Adam, lr 0.3 ends at (0.6668, -0.1429) with loss 0.733. Plain SGD zigzags across the valley and is still far from the minimum; momentum zigzags less and arrives; Adam heads diagonally at a near-constant speed and then circles the minimum. minimum SGD, lr 0.036: loss 0.89 momentum 0.7, lr 0.03: loss 0.0016 Adam, lr 0.3: loss 0.73 start, loss 33

Each dot is one step. The y direction is 50× steeper, so SGD's learning rate is capped by it and the x direction barely moves. Momentum and Adam both fix that, in different ways. Adam's steps are all about η = 0.3 long, which is too big for the last stretch, so it overshoots and circles the minimum instead of settling.

Mechanics

SGD and minibatches

The training loss is an average over N examples, L(θ) = (1/N) Σ ℓi(θ). Stochastic gradient descent replaces its gradient with the average over a random minibatch B of B examples:

g = (1/B) Σi∈B ∇ℓi(θ),   θ ← θ − η g

g is an unbiased estimate of the full gradient: right on average, with noise whose standard deviation falls like 1/√B. Quadrupling the batch halves the noise but costs four times the compute per step, which is why moderate batches win on total compute. The noise isn't purely harmful either; it helps escape saddle points and is thought to favour flatter minima that generalize better. Batch size and learning rate interact: bigger batches allow bigger η, roughly in proportion for SGD, up to a limit.

On a quadratic, the step along a direction with curvature a multiplies the error by (1 − ηa). That is stable only while |1 − ηa| < 1, that is η < 2/a, so the steepest direction sets the largest usable learning rate, and the flattest direction then sets how slowly everything converges. In the valley above, a = 50 across it caps η at 0.04; at η = 0.036 the x error shrinks by only 0.964 per step. The ratio of the largest curvature to the smallest, the condition number, is the core difficulty every rule below attacks.

Momentum and Nesterov

Momentum keeps a velocity buffer b and steps along it:

b ← μb + g,   θ ← θ − η b

With μ = 0.9, a gradient that keeps the same sign is added up to an effective 1/(1 − μ) = 10 times; one that alternates mostly cancels. That is exactly the valley's problem: the floor direction is consistent, the wall-to-wall direction alternates. PyTorch's SGD stores b as momentum_buffer, initializes it to the first gradient, and (with the default dampening=0) uses exactly this form; some texts write b ← μb + (1 − μ)g instead, which only rescales η.

Nesterov momentum steps along g + μb instead of b: it applies the momentum "look-ahead" to the current gradient as well, which reacts sooner when the velocity starts carrying the parameters uphill. It is a small change with a better worst-case theory for convex problems. Note what it does to the step on the fresh gradient: η(1 + μ) instead of η, so it needs a smaller learning rate. In the race, Nesterov at η = 0.03 diverges, while at η = 0.02 it reaches 0.0943.

RMSProp

RMSProp keeps a running average of each parameter's squared gradient and divides by its root:

s ← αs + (1 − α)g²,   θ ← θ − η g / (√s + ε)

Everything is element-wise, so each parameter gets its own scale. If a parameter's gradients are consistently around size 50, √s ≈ 50 and the step is about η; if they're around 1, the step is also about η. That flattens the valley's condition number for any problem whose steep directions line up with the parameter axes. PyTorch's RMSprop defaults to α = 0.99, ε = 10−8 and stores s as square_avg. Its ancestor AdaGrad summed g² over all time instead of averaging, so its steps shrank forever; the moving average is what lets RMSProp keep learning.

Adam: moment estimates and bias correction

Adam keeps both averages, called the first and second moment estimates:

m ← β1m + (1 − β1)g,   v ← β2v + (1 − β2)g²

m̂ = m / (1 − β1t),   v̂ = v / (1 − β2t),   θ ← θ − η m̂ / (√v̂ + ε)

with defaults η = 10−3, β1 = 0.9, β2 = 0.999, ε = 10−8. m is momentum and v is RMSProp's average, so the step m̂/√v̂ is roughly "the gradient's sign, smoothed", and its size is at most about η per parameter.

The hats are the bias correction. Both averages start at 0, so early on they are too small: after one step m = (1 − β1)g = 0.1g and v = 0.001g². Dividing by 1 − βt rescales them to unbiased estimates of the gradient's mean and mean square. Without the correction, the first step would be η · 0.1g / √(0.001g²) = 3.162η, and because v's average is much slower than m's, the overshoot gets worse before it gets better, peaking near t = 12.

How much larger Adam's step would be without bias correction The ratio of the uncorrected Adam step to the corrected one, (1 − β1^t) / √(1 − β2^t), with β1 = 0.9 and β2 = 0.999, for steps t from 1 to 3000 on a logarithmic axis. It starts at 3.162 at t = 1, rises to 6.569 at t = 12, then falls: 3.241 at t = 100, 1.258 at t = 1000 and 1.026 at t = 3000, approaching 1, the corrected step. 1× 2× 3× 4× 5× 6× 7× 1 10 100 1000 3.16× 6.57× 3.24× 1.26× with bias correction: 1× from the first step step t (log scale); vertical: uncorrected step ÷ corrected step, constant gradient

For a constant gradient, the corrected step is exactly η from step 1. The uncorrected step starts 3.16× too large, reaches 6.57× at t = 12, and is still 1.26× at t = 1000. Early steps are exactly when the parameters are worst, which is why learning-rate warmup is often used on top.

Because the step is normalized, Adam is far less sensitive to the gradient's scale than SGD, and one learning rate works across layers whose gradients differ by orders of magnitude. That, plus robustness to noisy and sparse gradients, is why it became the default for transformers. It is not a free win: on some problems, especially image classification with well-tuned schedules, SGD with momentum still generalizes slightly better.

AdamW: decoupled weight decay

Weight decay shrinks every weight toward zero each step. For SGD, adding an L2 penalty (λ/2)‖θ‖² to the loss does exactly that: the gradient gains a term λθ, and the step −η(g + λθ) is the usual step plus θ ← (1 − ηλ)θ. The two ideas coincide, and SGD(weight_decay=λ) implements decay by adding λθ to the gradient.

Adam breaks the equivalence. Adam(weight_decay=λ) also adds λθ to the gradient, and then divides by √v̂. A weight whose gradients are large gets its decay divided down to almost nothing; a weight with tiny gradients gets its decay amplified. Loshchilov and Hutter's AdamW decouples the two: it shrinks the weight directly, θ ← (1 − ηλ)θ, and then takes an ordinary Adam step on the unmodified gradient. In PyTorch that is the single line param.mul_(1 - lr * weight_decay), and AdamW is Adam with decoupled_weight_decay=True and a default λ of 10−2 instead of 0.

The same weight decay under Adam with L2 and under AdamW One weight starting at 1, trained for 1000 steps with learning rate 0.01 and weight decay 0.1, on gradients that alternate between plus and minus a scale, so the gradient itself pulls the weight nowhere and only decay moves it. Adam with L2, gradient scale 1: 0.8048 after 200 steps and 0.3598 after 1000. Adam with L2, gradient scale 0.01: 0.01763 after 200 steps and 0.0001442 after 1000. AdamW, gradient scale 1: 0.8042 after 200 steps and 0.3614 after 1000. AdamW, gradient scale 0.01: 0.8042 after 200 steps and 0.3614 after 1000. AdamW decays the weight at the same rate whatever the gradient scale; Adam with L2 decays it about as fast when the gradient is large, and far faster when the gradient is small. 0 0.25 0.5 0.75 1 0 250 500 750 1000 Adam + L2, gradients ±0.01 AdamW, gradients ±1 or ±0.01 Adam + L2, gradients ±1 step; vertical: the weight's value

With L2 inside Adam, the decay term λθ is divided by the gradient's running size. When the gradients are ±0.01, that division makes the decay act about 100 times harder and the weight is gone (0.2295 after 100 steps). AdamW's two curves lie on top of each other: 0.3614 after 1000 steps either way, matching (1 − ηλ)1000 = 0.3677.

Muon: orthogonalized momentum for weight matrices

Every optimizer so far treats a weight matrix as a bag of independent numbers. Muon treats it as a matrix. Its gradient G (or the momentum built from it) can have a few large singular values and many tiny ones, and then a plain step moves the layer a lot along a handful of directions and barely at all along the rest. Muon replaces the update with its nearest semi-orthogonal matrix: write the momentum as M = USVT and step along UVT, which has the same singular directions but every singular value set to 1. Every direction the gradient points in gets an equal share of the step.

An exact SVD per step would be too slow, so Muon runs a few Newton–Schulz iterations instead. Divide M by its Frobenius norm, which puts every singular value in [0, 1], then repeat X ← aX + (bXXT + c(XXT)2)X. Each iteration only multiplies matrices, and it acts on each singular value s independently as the odd polynomial φ(s) = as + bs3 + cs5. PyTorch's coefficients, (a, b, c) = (3.4445, -4.775, 2.0315), are chosen to make the slope at zero, a, as large as possible, so small singular values grow quickly, at the cost of exactness: φ(1) = 0.701, so the iteration never settles at exactly 1. The pinned docstring says the result is US′VT with S′ roughly uniform on (0.5, 1.5), and that this does not hurt model performance relative to UVT. The matrix runs in bfloat16, and a tall matrix is transposed first so XXT is the smaller Gram matrix.

Singular values through five Newton–Schulz steps The 3 by 4 example gradient has singular values [3.695, 1.695, 0.1577]. After dividing by its Frobenius norm they are [0.9082, 0.4166, 0.03876]. After step 1: [1.115, 0.8065, 0.1332]. After step 2: [0.9663, 0.7229, 0.4477]. After step 3: [1.15, 1.087, 0.7316]. After step 4: [1.076, 0.7853, 0.6944]. After step 5: [1.121, 0.9991, 0.6878]. All three end between 0.5 and 1.5; the smallest grows from 0.03876 to 0.6878. 0 0.5 1 1.5 0 1 2 3 4 5 σ₁ σ₂ σ₃ Newton–Schulz step; vertical: singular value

The example gradient [3, 1, 0, 0.5], [1, 2, 1, 0], [0, 0.5, 0.2, 0.1] has a condition number of 23.43. Five steps with PyTorch's coefficients bring its singular values to [1.121, 0.9991, 0.6878]: not exactly 1, which is why the result still differs from the exact UVT by 0.3348 in Frobenius norm, but no direction is left out. Computed in float64 by muon_newton_schulz, a stdlib copy of _zeropower_via_newtonschulz.

The rest of torch.optim.Muon is short. Momentum is one buffer per matrix, updated in place as buf.lerp_(grad, 1 - momentum): an exponential average, even though the docstring's formula writes Bt = μBt−1 + gt. With nesterov=True (the default) the orthogonalized matrix is grad.lerp(buf, momentum). Weight decay is decoupled as in AdamW, with the unadjusted rate, and then the step is scaled by a shape-dependent factor, because an orthogonal matrix's entries shrink as the matrix grows. For an A×B weight:

adjust_lr_fnLearning-rate multiplier1024×40964096×1024
None or "original"√max(1, A/B)12
"match_rms_adamw"0.2·√max(A, B)12.812.8
"spectral_unclamped"√(A/B)0.52

"match_rms_adamw" is the rule from Moonshot's Muon is Scalable for LLM Training: it scales the update so its entries have about the same RMS (0.2) as a typical AdamW step, which lets Muon reuse AdamW's learning rate and weight decay. Muon only accepts 2-D parameters and raises on anything else; the docstring says biases, embeddings and other non-2-D parameters should go to a standard optimizer such as AdamW, and its example builds the two side by side. Its state is one momentum buffer, half of Adam's.

At scale, Muon has one known failure. Kimi K2's report says that training a 53B-parameter MoE with plain Muon let the largest attention logit, the maximum of q·k/√d over a batch, climb past 1000 in some heads, which risks loss spikes. Their fix, QK-Clip, watches each head's maximum logit Smax and, whenever it passes a threshold τ, rescales that head's query and key weights after the optimizer step by √γ each, with γ = min(1, τ/Smax), so the product q·k shrinks by exactly γ. Heads under τ are untouched, and nothing in the forward pass changes. With τ = 100:

Head's max logitγ = min(1, τ/Smax)Scale on Wq and WkMax logit after
421142
1800.55560.7454100
951195
4000.250.5100

Muon with QK-Clip, plus weight decay and the RMS-matching scale, is what the report calls MuonClip; K2 used τ = 100 and pre-trained a 1T-parameter MoE on 15.5T tokens with no loss spikes. For multi-head latent attention the report scales only the per-head components and leaves the shared rotary key alone. PyTorch's Muon does not include QK-Clip at v2.15.0, since the clip needs the attention logits, which the optimizer never sees.

Optimizer state memory

Every bit of memory an optimizer keeps is per parameter. For a model with 7 billion parameters in float32:

OptimizerState tensors per parameterPyTorch state keysExtra bytes per parameterExtra memory at 7B
SGD0none00 GB
SGD with momentum1momentum_buffer428 GB
RMSProp1square_avg428 GB
Adam / AdamW2exp_avg, exp_avg_sq856 GB

The weights themselves are another 28 GB and the gradients another 28 GB, so Adam training needs 16 bytes per parameter, 112 GB at 7B, before a single activation is stored. Mixed-precision training usually keeps the same total: bfloat16 weights and gradients (4 bytes) plus a float32 master copy and Adam's two moments (12 bytes). This is why optimizer state is the first thing sharded across GPUs in large-model training (ZeRO and FSDP), and why memory-lean variants exist: Adafactor factorizes v into row and column averages, and 8-bit Adam quantizes both moments. PyTorch also keeps a tiny step counter per parameter.

In PyTorch

Every optimizer subclasses torch.optim.Optimizer. It is built from an iterable of parameters (or of parameter groups, dicts that each carry their own lr, weight_decay and so on), keeps self.param_groups and a self.state dict keyed by parameter, and exposes three calls a training loop uses:

  • zero_grad() clears every parameter's .grad, by default by setting it to None, because backward accumulates;
  • step() reads each .grad, updates the state and changes the parameters in place, under torch.no_grad();
  • state_dict() / load_state_dict() save and restore the state, so a resumed run continues with the same moments rather than restarting Adam's warm-up.

Adam.step gathers the tensors of each group and calls a functional adam, which dispatches to one of three implementations: _single_tensor_adam, a plain loop over parameters that is easiest to read; a foreach version that batches the same operations over lists of tensors; and a fused kernel that does the whole update in one GPU launch. The default on GPU is foreach. They compute the same update.

TermShape/ParamsMeaningSource
torch.optim.SGD lr=1e-3, momentum=0, dampening=0, weight_decay=0, nesterov=False SGD, with optional momentum, Nesterov and L2-style decay. sgd.py
_single_tensor_sgd per parameter: momentum_buffer Adds λθ to the gradient, updates the buffer, applies Nesterov, then steps. sgd.py
torch.optim.RMSprop lr=1e-2, alpha=0.99, eps=1e-8, centered=False Divides by the root of a running mean square, square_avg. rmsprop.py
torch.optim.Adam lr=1e-3, betas=(0.9, 0.999), eps=1e-8, weight_decay=0 Adam; state exp_avg (m), exp_avg_sq (v) and step. adam.py
_single_tensor_adam per parameter: m, v, t Decay, the two moment updates, bias correction and addcdiv_. adam.py
torch.optim.AdamW weight_decay=1e-2 Adam with decoupled_weight_decay=True. adamw.py
torch.optim.Muon lr=1e-3, weight_decay=0.1, momentum=0.95, nesterov=True, ns_steps=5 Muon for 2-D parameters only; state momentum_buffer. _muon.py
_zeropower_via_newtonschulz (A, B) matrix, ns_coefficients, ns_steps, eps=1e-7 Normalizes by the Frobenius norm, then runs the quintic Newton–Schulz iteration in bfloat16. _muon.py
_adjust_lr adjust_lr_fn, param.shape[:2] The shape-dependent learning-rate multiplier. _muon.py
_single_tensor_muon per parameter: B EMA momentum, Nesterov blend, orthogonalize, decoupled decay, step. _muon.py
Optimizer.zero_grad set_to_none=True Clears .grad on every parameter in every group. optimizer.py
Optimizer.step closure=None One update; each subclass overrides it. optimizer.py
Optimizer.state_dict {"state", "param_groups"} The optimizer's state for checkpointing, with parameters replaced by indices. optimizer.py
Optimizer.add_param_group a dict with "params" Adds parameters with their own settings, e.g. unfrozen layers mid-training. optimizer.py

Worked example

Three Adam steps, every number. Two parameters start at θ = [0.5, -0.3]. η = 0.001, β1 = 0.9, β2 = 0.999, ε = 1.0e-08. The gradients are deliberately mismatched: the second parameter's are about 200 times the first's.

tgmvm̂v̂step −η m̂/(√v̂ + ε)θ after
1[0.02, -4][0.002, -0.4][4e-07, 0.016][0.02, -4][0.0004, 16][-0.001, 0.001][0.499, -0.299]
2[0.01, -3][0.0028, -0.66][4.996e-07, 0.02498][0.01474, -3.474][0.0002499, 12.5][-0.0009322, 0.0009826][0.4981, -0.298]
3[0.03, -5][0.00552, -1.094][1.399e-06, 0.04996][0.02037, -4.037][0.0004668, 16.67][-0.0009427, 0.0009887][0.4971, -0.297]

Step 1: m = 0.1g = [0.002, -0.4] and v = 0.001g² = [4e-07, 0.016]. Bias correction divides by 1 − 0.9 = 0.1 and 1 − 0.999 = 0.001, so m̂ = g and v̂ = g² exactly, and the step is −η g/|g| = −η·sign(g) = [-0.001, 0.001]. Both parameters move by η, though one gradient is 200 times the other.

Step 2: m = 0.9 · -0.4 + 0.1 · -3 = -0.66 for the second parameter, and the corrections are 1 − 0.9² = 0.19 and 1 − 0.999² = 0.001999, giving m̂ = -3.474 and v̂ = 12.5. The step is −0.001 × -3.474 / √12.5 = 0.0009826: a little under η, because the gradients disagree a little about their size. After three steps θ = [0.4971, -0.297]; plain SGD with the same η would have moved the second parameter by 0.012 and the first by only 6e-05.

The race in numbers

All six runs on the valley above, for 30 steps from loss 33:

OptimizerLoss after 30 stepsNote
SGD, lr 0.036 0.8866 the largest η that converges smoothly; x crawls
SGD, lr 0.042 7613, diverged just past η = 2/50 = 0.04: the steep direction explodes
momentum 0.7, lr 0.03 0.001642 consistent x gradients add up; the y oscillation cancels
Nesterov 0.7, lr 0.02 0.0943 needs a smaller η: η = 0.03 diverges
RMSProp, lr 0.05 0.1806 per-axis scaling, no momentum; steps of about η each
Adam, lr 0.3 0.733 near-constant step size; circles the minimum at the end

Implementation

import torch

theta = torch.tensor([0.5, -0.3], requires_grad=True)
opt = torch.optim.Adam([theta], lr=1e-3, betas=(0.9, 0.999), eps=1e-8)
for g in ([0.02, -4.0], [0.01, -3.0], [0.03, -5.0]):
    opt.zero_grad()
    theta.grad = torch.tensor(g)        # stand-in for loss.backward()
    opt.step()
theta                                   # tensor([ 0.4971, -0.2970])
opt.state[theta].keys()                 # step, exp_avg, exp_avg_sq

# the usual setup: AdamW, with no decay on biases and normalization weights
decay = [p for n, p in model.named_parameters() if p.ndim >= 2]
no_decay = [p for n, p in model.named_parameters() if p.ndim < 2]
opt = torch.optim.AdamW([
    {"params": decay, "weight_decay": 0.1},
    {"params": no_decay, "weight_decay": 0.0},
], lr=3e-4, betas=(0.9, 0.95))

for x, y in loader:
    opt.zero_grad()
    loss = loss_fn(model(x), y)
    loss.backward()
    opt.step()

torch.save(opt.state_dict(), "opt.pt")  # m and v are part of the training state

Every rule on this page, in plain Python:

import math

def step(kind, params, grads, state, lr, mu=0.9, alpha=0.99,
         betas=(0.9, 0.999), eps=1e-8, wd=0.0):
    t = state["t"] = state.get("t", 0) + 1
    if kind == "adamw":
        params = [p * (1 - lr * wd) for p in params]
    else:
        grads = [g + wd * p for g, p in zip(grads, params)]
    if kind == "sgd":
        return [p - lr * g for p, g in zip(params, grads)]
    if kind in ("momentum", "nesterov"):
        buf = state["buf"] = grads if t == 1 else [mu * b + g for b, g in zip(state["buf"], grads)]
        d = buf if kind == "momentum" else [g + mu * b for g, b in zip(grads, buf)]
        return [p - lr * x for p, x in zip(params, d)]
    if kind == "rmsprop":
        s = state["s"] = [alpha * s + (1 - alpha) * g * g
                          for s, g in zip(state.get("s", [0.0] * len(grads)), grads)]
        return [p - lr * g / (math.sqrt(v) + eps) for p, g, v in zip(params, grads, s)]
    b1, b2 = betas
    m = state["m"] = [b1 * m + (1 - b1) * g for m, g in zip(state.get("m", [0.0] * len(grads)), grads)]
    v = state["v"] = [b2 * v + (1 - b2) * g * g for v, g in zip(state.get("v", [0.0] * len(grads)), grads)]
    return [p - lr * (mi / (1 - b1**t)) / (math.sqrt(vi / (1 - b2**t)) + eps)
            for p, mi, vi in zip(params, m, v)]

theta, state = [0.5, -0.3], {}
for g in ([0.02, -4.0], [0.01, -3.0], [0.03, -5.0]):
    theta = step("adam", theta, g, state, lr=1e-3)
theta                                   # [0.497125..., -0.297029...]

Tradeoffs

  • Adaptive versus plain. Adam and AdamW need little tuning and handle badly scaled and sparse gradients; they're the default for transformers. SGD with momentum uses half the state memory and, with a carefully tuned schedule, often generalizes a little better on vision tasks.
  • Memory. Adam doubles the parameter memory again, 8 extra bytes per float32 parameter; at billions of parameters that drives sharding, 8-bit states or factored variants like Adafactor.
  • Hyperparameters still matter. The learning rate dominates everything else. For large models β2 = 0.95 is common, because 0.999 averages over about a thousand steps and reacts too slowly when gradients spike; ε matters in low precision, where 10−8 underflows in float16.
  • L2 or decoupled decay. With SGD they're the same. With Adam use AdamW, whose decay strength doesn't depend on the gradient scale, and usually exclude biases and normalization weights from decay.
  • Implementation. foreach and fused versions compute the same update with far fewer kernel launches; the single-tensor loop exists for clarity and for unusual tensor types.

Check yourself

On a quadratic with curvatures 1 and 50, what's the largest learning rate plain gradient descent can use?

Just under 2/50 = 0.04. The steep direction diverges above it, and at that rate the flat direction shrinks its error by only 4% per step.

Adam's first step for a parameter with gradient g = −4, and one with g = 0.02, both at η = 10−3?

+0.001 and −0.001. With bias correction m̂ = g and v̂ = g², so the step is −η·sign(g): the size of the gradient doesn't matter at all on step 1.

Without bias correction, would Adam's first steps be too large or too small, and by roughly how much?

Too large: m is 0.1g but √v is only √0.001·|g| ≈ 0.032|g|, so the step is about 3.2η at t = 1, and the gap grows to about 6.6η around t = 12 before shrinking.

Why is Adam(weight_decay=0.01) not the same as AdamW(weight_decay=0.01)?

Adam adds λθ to the gradient and then divides by √v̂, so the decay is scaled down for weights with large gradients and up for ones with small gradients. AdamW multiplies the weight by (1 − ηλ) directly, the same for every weight.

You resume training from a checkpoint that saved the model but not the optimizer. What changes?

Adam's m, v and step count restart at zero, so the bias correction starts over and the first steps behave like the start of training, often causing a loss spike. Save opt.state_dict() too.

Connections

References

  • Robbins and Monro. A Stochastic Approximation Method. Annals of Mathematical Statistics, 1951.
  • Polyak. Some Methods of Speeding Up the Convergence of Iteration Methods. USSR Computational Mathematics and Mathematical Physics, 1964 (momentum).
  • Nesterov. A Method for Solving the Convex Programming Problem with Convergence Rate O(1/k²). Soviet Mathematics Doklady, 1983.
  • Duchi, Hazan and Singer. Adaptive Subgradient Methods for Online Learning and Stochastic Optimization. JMLR, 2011 (AdaGrad).
  • Tieleman and Hinton. Lecture 6.5, RMSProp. Coursera: Neural Networks for Machine Learning, 2012.
  • Sutskever, Martens, Dahl and Hinton. On the Importance of Initialization and Momentum in Deep Learning. ICML, 2013.
  • Kingma and Ba. Adam: A Method for Stochastic Optimization. ICLR, 2015 (arXiv 1412.6980).
  • Loshchilov and Hutter. Decoupled Weight Decay Regularization. ICLR, 2019 (arXiv 1711.05101).
  • Reddi, Kale and Kumar. On the Convergence of Adam and Beyond. ICLR, 2018 (arXiv 1904.09237; AMSGrad).
  • Rajbhandari, Rasley, Ruwase and He. ZeRO: Memory Optimizations Toward Training Trillion Parameter Models. SC, 2020 (arXiv 1910.02054).
  • Keller Jordan. Muon: An Optimizer for Hidden Layers in Neural Networks. Blog post, 2024 (kellerjordan.github.io/posts/muon).
  • Moonshot AI. Muon is Scalable for LLM Training. 2025 (arXiv 2502.16982; the match_rms_adamw scale).
  • Jeremy Bernstein. Deriving Muon. 2025 (jeremybernste.in/writing/deriving-muon).
  • Kimi Team. Kimi K2: Open Agentic Intelligence. 2025 (arXiv 2507.20534; QK-Clip and MuonClip).
  • PyTorch v2.15.0: torch/optim/sgd.py, torch/optim/adam.py, torch/optim/adamw.py, torch/optim/_muon.py and torch/optim/optimizer.py.