Skip to content

Optimizers: Momentum, Adam, and Learning Rate Schedules

Three optimizers, same start, same valley. SGD bounces off the steep walls, momentum glides, Adam adapts per-parameter and arrives first.

In the last post, we got gradient descent working. We computed gradients, applied the chain rule backwards through a network, and updated parameters with a simple rule: θθηL\theta \leftarrow \theta - \eta \nabla L. Vanilla stochastic gradient descent. The most straightforward optimizer you can write.

And it works, in theory. On toy problems, SGD finds the minimum just fine. The moment you try to train something real with it, you run into problems.

This post walks through those problems and the sequence of clever fixes that got us to the optimizer almost everyone uses today to train large language models: AdamW with warmup and cosine decay. The path from "SGD works in theory" to "here's what actually trains a 70B parameter model" is surprisingly short, and each step along the way is motivated by a concrete failure of the previous approach. (If you want a wider survey of every optimizer variant ever proposed, Ruder's overview is the standard reference.)


The problem with vanilla SGD

Imagine you're walking down a long, skinny valley. The walls are steep, but the floor slopes gently toward the minimum at the far end. SGD bounces back and forth between the steep walls while barely making progress along the floor. Each step overshoots in the steep direction and undershoots in the shallow one. You end up zigzagging when you should be gliding.

This is not contrived. Loss surfaces in deep learning are full of dimensions like this. Some parameters have enormous gradients (the loss is very sensitive to them), others have tiny gradients (the loss barely cares). SGD treats them all the same. One learning rate, one step size, take it or leave it.

If you set the learning rate high enough to make progress in the flat direction, you'll overshoot and oscillate in the steep direction. Set it low enough to be stable in the steep direction, and you'll crawl along the valley floor at geological speeds.

Here's what that looks like. The surface below is f(x,y)=x2+10y2f(x,y) = x^2 + 10y^2, an elongated bowl where the yy-direction is ten times steeper than the xx-direction. Watch SGD bounce:

startf(x,y) = x² + 10y²
SGDL = 31.50
MomentumL = 31.50
AdamL = 31.50

Same starting point, three optimizers. SGD oscillates, momentum smooths the path, Adam adapts per-parameter and converges fastest.

Watch all three run. SGD (red) oscillates wildly in the yy-direction. Momentum (blue) dampens those oscillations and builds up speed along the valley. Adam (green) does both and gets there first. Same starting point, same surface, dramatically different paths.

How do we fix this? Three ideas, layered on top of each other.


Fix 1: momentum

The first insight is almost physical. If SGD is like a ball rolling downhill, vanilla SGD is a ball with no mass. It instantly changes direction with every gradient. There's no inertia. The fix is to give the ball some weight.

Instead of using just the current gradient to update the parameters, we maintain a velocity vector vv that accumulates past gradients:

vt=βvt1+L(θt)v_t = \beta \cdot v_{t-1} + \nabla L(\theta_t) θt+1=θtηvt\theta_{t+1} = \theta_t - \eta \cdot v_t

The hyperparameter β\beta (typically 0.9) controls how much of the previous velocity we retain. When β=0\beta = 0, this is just vanilla SGD. When β=0.9\beta = 0.9, we're essentially averaging over the last ~10 gradients.

Think about what this does to our zigzagging problem. In the steep direction, the gradient flips sign every step (positive, negative, positive, negative). When you average those out, the oscillations cancel. In the flat direction, the gradient consistently points the same way, step after step. Those accumulate. The velocity builds up, and you start moving faster and faster along the valley floor.

Side by side, here are the two arrows that matter at every step. On the left, vanilla SGD: the descent direction is just L-\nabla L, which flips wildly across the valley walls. On the right, momentum keeps the same gradient (faint red, still flipping) but updates use v-v (blue), which averages out the oscillation and points roughly along the floor:

−∇LSGD: bounces off walls−∇LMomentum: oscillations cancel, glide accumulates
step 0 / 70

Both balls feel the same gradient at every step. The momentum ball ignores the wobble and follows the running average instead.

The gradient oscillations in noisy dimensions get dampened. The consistent signal in important dimensions gets amplified. Momentum was introduced by Polyak back in 1964, and nearly sixty years later it's still a core ingredient in every serious optimizer. (Goodfellow et al.'s Deep Learning Chapter 8 has a great derivation if you want the full mechanical picture.)


Fix 2: per-parameter adaptive rates (RMSProp)

Momentum helps with oscillation, but it doesn't solve the fundamental issue that different parameters live in different worlds. Some weights in a neural network change the loss enormously with a tiny perturbation. Others barely register. Using the same learning rate for all of them is like paying every employee the same salary regardless of their job.

The idea behind RMSProp (Hinton, 2012, introduced in a Coursera lecture with no paper, which is great) is to track how big each parameter's gradients have been historically, and then scale the learning rate inversely.

Maintain a running average of squared gradients:

st=βst1+(1β)(L)2s_t = \beta \cdot s_{t-1} + (1 - \beta) \cdot (\nabla L)^2

Then divide the update by st\sqrt{s_t}:

θt+1=θtηst+ϵL(θt)\theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{s_t} + \epsilon} \cdot \nabla L(\theta_t)

Parameters with consistently large gradients get their effective learning rate shrunk. Parameters with consistently small gradients get their effective learning rate boosted. The optimizer is adapting on a per-parameter basis. The ϵ\epsilon (usually 10810^{-8}) is just there to prevent division by zero.

The square root matters here. sts_t has units of squared gradient, so st\sqrt{s_t} puts the denominator back on the same scale as the gradient itself, and the ratio gt/stg_t / \sqrt{s_t} is dimensionally a unitless "this gradient relative to recent gradient size." Drop the square root and the units don't line up; the denominator overcompensates and the update gets vanishingly small for any parameter with even moderate gradient magnitude.

This matters a lot in practice. Instead of the human having to pick one learning rate that somehow works for every parameter in a model with billions of weights, the optimizer figures out the right scale for each one automatically.

Here's that adaptation made literal. Two parameters, side by side. Parameter A's gradient is consistently around 5; parameter B's is around 0.05, a hundred times smaller. Watch what Adam does to each. The raw gradients are wildly different, but after the running averages settle and we compute ηm^/(v^+ϵ)\eta \cdot \hat{m} / (\sqrt{\hat{v}} + \epsilon), the effective step sizes for A and B end up at the same order of magnitude:

Parameter Abig gradients (~5)g (gradient)5.20m̂ (1st moment)5.20√v̂ (2nd moment)5.20effective step η · m̂ / (√v̂ + ε)1.00e-2ΔθParameter Btiny gradients (~0.05)g (gradient)5.20e-2m̂ (1st moment)5.20e-2√v̂ (2nd moment)5.20e-2effective step η · m̂ / (√v̂ + ε)1.00e-2Δθstep 1 / 12 — same η, same β₁, β₂. Different parameters, similar Δθ.

Same η, same betas, same machinery. But because each parameter gets divided by its own √v̂, the effective step size lands in the same ballpark for both. That's per-parameter adaptation in one picture.


Fix 3: Adam (combining both)

Adam, introduced by Kingma and Ba in 2014, does the obvious thing: combine momentum and RMSProp.

It maintains two running averages:

  • First moment mtm_t: a running average of the gradients (like momentum's velocity)
  • Second moment vtv_t: a running average of the squared gradients (like RMSProp's denominator)
mt=β1mt1+(1β1)gtm_t = \beta_1 \cdot m_{t-1} + (1 - \beta_1) \cdot g_t vt=β2vt1+(1β2)gt2v_t = \beta_2 \cdot v_{t-1} + (1 - \beta_2) \cdot g_t^2

There's one wrinkle. Both mm and vv are initialized at zero. In the early steps, they're biased toward zero because they haven't accumulated enough history. Adam corrects for this with a bias correction step:

m^t=mt1β1t,v^t=vt1β2t\hat{m}_t = \frac{m_t}{1 - \beta_1^t}, \quad \hat{v}_t = \frac{v_t}{1 - \beta_2^t}

Then the update is:

θt+1=θtηm^tv^t+ϵ\theta_{t+1} = \theta_t - \eta \cdot \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon}

The default hyperparameters (β1=0.9\beta_1 = 0.9, β2=0.999\beta_2 = 0.999, ϵ=108\epsilon = 10^{-8}) are surprisingly robust. People often just use them as-is.

So Adam gives you momentum's oscillation dampening plus RMSProp's per-parameter adaptation, with a bias correction to keep things honest in early training. It's a clean design, and it's been the default optimizer for most of deep learning since about 2015.

A worked Adam step

The bias correction is one of those things that looks like algebraic noise until you watch it happen on real numbers. So let's walk through three consecutive steps on a single parameter. θ\theta starts at 1.0. The gradients arriving from the loss are 0.5, then 0.4, then 0.6. Standard Adam hyperparameters throughout:

One Adam step, by handstep t = 1 of 3η = 0.1, β₁ = 0.9, β₂ = 0.999, ε = 1e-8 — gradients hand-picked: 0.5, 0.4, 0.6g_1= 0.5000current gradientm_1= 0.00000.9 · m_{t-1} + 0.1 · g_1v_1= 0.0000000.999 · v_{t-1} + 0.001 · g_1²m̂_1= 0.0000m_1 / (1 − 0.9^1)v̂_1= 0.000000v_1 / (1 − 0.999^1)Δθ= 0.0000−0.1 · m̂_1 / (√v̂_1 + ε)θ= 1.0000current parameter valueNotice on step 1: m_1 = 0.05 but m̂_1 = 0.5. Bias correction undoes the zero-init drag.

Three Adam updates on one parameter. The yellow row is the quantity being computed in this phase. The footer note tracks how aggressively bias correction is rescuing the estimate from its zero-init.

The thing to notice on step 1: the raw m1=0.10.5=0.05m_1 = 0.1 \cdot 0.5 = 0.05, but m^1=0.05/(10.9)=0.5\hat{m}_1 = 0.05 / (1 - 0.9) = 0.5. The second moment is corrected the same way: v1=0.0010.25=0.00025v_1 = 0.001 \cdot 0.25 = 0.00025, but v^1=0.00025/(10.999)=0.25\hat{v}_1 = 0.00025 / (1 - 0.999) = 0.25. Both corrections work together; they're not redundant. Correcting only mm would make the first step too small relative to its target scale; correcting only vv would make it too large, because v10.0158\sqrt{v_1} \approx 0.0158 is much smaller than v^1=0.5\sqrt{\hat{v}_1} = 0.5 in the denominator. With both corrections in place, the first Adam update lands at the intended scale.

The two correction factors fade at different rates. The mm-correction 1/(1β1t)1/(1-\beta_1^t) with β1=0.9\beta_1 = 0.9 becomes negligible within a hundred steps or so. The vv-correction 1/(1β2t)1/(1-\beta_2^t) with β2=0.999\beta_2 = 0.999 takes much longer: at t=100t=100 it's still about 1/0.09510.51/0.095 \approx 10.5, and even at t=1000t=1000 it's about 1/0.6321.581/0.632 \approx 1.58. Bias correction matters for thousands of steps for the second moment, not just the first few.

But there's a subtle issue with how Adam handles regularization.


AdamW: weight decay done right

When training neural networks, you almost always want some form of regularization to prevent overfitting. The most common is weight decay, where you gently push all weights toward zero. In vanilla SGD, weight decay and L2 regularization are mathematically equivalent: adding λθ\lambda \theta to the gradient is the same as adding λ2θ2\frac{\lambda}{2} \|\theta\|^2 to the loss.

But in Adam, they're not equivalent. Because Adam rescales the gradient by the second moment, the weight decay term gets rescaled too. Large weights that happen to have large gradients get less decay than they should, and small weights with small gradients get more. The regularization is tangled up with the adaptive learning rate, and neither works as intended.

Loshchilov and Hutter (2017) pointed this out and proposed AdamW, which decouples the weight decay from the gradient-based update:

θt+1=θtη(m^tv^t+ϵ+λθt)\theta_{t+1} = \theta_t - \eta \left( \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon} + \lambda \theta_t \right)

The weight decay term λθt\lambda \theta_t is applied directly to the parameters, not passed through Adam's adaptive scaling machinery. It's a small change in the code, but it matters a lot empirically. In modern transformer pretraining, AdamW with decoupled weight decay is effectively the default, and "Adam" in casual speech often means AdamW in practice. Papers and code aren't always consistent about it, though, and other parts of the field (vision fine-tuning, Adafactor for memory-constrained T5-style training, newer optimizers like Lion or Shampoo) really do use something else. When it matters, check the implementation.


Learning rate schedules: warmup + cosine decay

Picking the right learning rate is important, but here's the thing: the right learning rate changes over the course of training. Early on, the model's weights are randomly initialized and the loss landscape is chaotic. Late in training, the model has found a good basin and needs to fine-tune. Using the same learning rate the whole time is suboptimal.

Warmup

At initialization, the model is completely random. The gradients are noisy and potentially huge. If you slam the optimizer with a large learning rate from step one, you can blow up the training entirely. The loss spikes, the gradients explode, and you're dead.

The fix is simple: start with a very small learning rate and linearly increase it to your target over the first NN warmup steps.

ηt=ηmaxtTwarmupfor tTwarmup\eta_t = \eta_{\text{max}} \cdot \frac{t}{T_{\text{warmup}}} \quad \text{for } t \leq T_{\text{warmup}}

For LLM pretraining, warmup is typically 1 to 2% of total training steps. LLaMA used 2,000 warmup steps for example, which is a fraction of a percent of its total training. It's not doing anything fancy. It's just giving the model time to stabilize before you hit the gas. Karpathy calls this "letting the network sort out its initial chaos," and that's basically the right way to think about it.

Cosine decay

After warmup, you want to gradually reduce the learning rate toward zero. The most popular schedule for LLM pretraining is cosine decay:

ηt=ηmin+12(ηmaxηmin)(1+cos(tTwarmupTtotalTwarmupπ))\eta_t = \eta_{\text{min}} + \frac{1}{2}(\eta_{\text{max}} - \eta_{\text{min}}) \left(1 + \cos\left(\frac{t - T_{\text{warmup}}}{T_{\text{total}} - T_{\text{warmup}}} \cdot \pi\right)\right)

The formula looks busy but the idea is simple. It traces a smooth half-cosine from ηmax\eta_{\text{max}} down to ηmin\eta_{\text{min}} (often set to something like 0.1×ηmax0.1 \times \eta_{\text{max}} or even zero). The key feature is that it decays slowly at first, accelerates in the middle, and slows down near the end. It gives the optimizer plenty of time at a reasonable learning rate during the bulk of training, then gradually cools things off.

Here is the whole schedule end to end. The dot rides through 30k steps: a fast linear ramp during the first 10% of training (warmup, amber), then a smooth cosine descent down toward ηmin\eta_\text{min} (decay, blue):

η_maxη_min0k8k15k23k30ktraining stepwarmupcosine decay
phase
warmup
step
0 / 30000
learning rate
3.00e-6

Warmup ramps from near-zero to η_max over the first ~10% of training, then cosine decay cools the rate down to η_min. Real LLM pretraining usually uses a much shorter warmup (often well under 1% of total steps); the demo exaggerates the warmup phase so the ramp is visually obvious.

Why cosine specifically? Honestly, the original motivation was about warm restarts, but in practice cosine decay just works well. It became a strong default for LLM pretraining largely because the Chinchilla and LLaMA papers used it. Schedule design isn't fully settled, though. Work on Warmup-Stable-Decay (WSD) and related cooldown schedules is an active area, partly because cosine requires committing to a fixed training horizon up front while WSD keeps a long stable phase and only decays near the end.

The full picture is to ramp up linearly during warmup, then cosine decay down. If you plot the learning rate over training, it looks like a sharp rise followed by a smooth descent.


What this costs at scale

Here's a practical detail that matters a lot at scale. Adam (and AdamW) maintains two extra tensors for every parameter in the model: the first moment mm and the second moment vv. So the optimizer state is 2x the size of the model itself.

For a 7B parameter model stored in fp32 (4 bytes per parameter), the model weights take 28 GB. The optimizer state takes another 56 GB. For a 70B model? 280 GB of weights plus 560 GB of optimizer state. This is why training large models requires so much more memory than inference, even before you account for gradients and activations.

In real mixed-precision training the exact multiplier shifts around. Most stacks keep parameters in bf16 but maintain fp32 master weights and fp32 Adam moments alongside, which still puts optimizer state at roughly 2× the model size in bytes. Distributed-training schemes like ZeRO shard the moments across devices so any single GPU only holds a fraction. The big picture is unchanged: Adam state is one of the dominant memory costs, and that's what makes techniques like 8-bit optimizers (Dettmers et al. 2021), which quantize the moments, so important for fitting training on limited hardware.


A harder landscape

The elongated bowl above is a clean illustration, but real loss surfaces are gnarlier. Here's the Rosenbrock function, a classic optimization benchmark with a narrow, curved valley where the minimum sits at (1,1)(1, 1):

startRosenbrock: (1 − x)² + 100(y − x²)²
SGDL = 104.00
AdamL = 104.00

The Rosenbrock function has a narrow curved valley. SGD barely moves. Adam navigates it efficiently.

SGD with a learning rate that doesn't diverge (you have to set it very low) barely makes any progress. Adam, with its per-parameter adaptation, follows the curve of the valley and gets somewhere. This is the kind of landscape where adaptive methods really shine.


The practical recipe

If you're training a transformer right now, here's what the optimizer setup looks like in PyTorch. Not much code:

import torch
from torch.optim import AdamW
from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR
 
# Model params
optimizer = AdamW(
    model.parameters(),
    lr=3e-4,              # peak learning rate
    weight_decay=0.1,     # standard for LLM pretraining
    betas=(0.9, 0.95),    # slightly lower beta2 is common now
)
 
# Warmup: linear ramp over first 2000 steps
warmup = LinearLR(optimizer, start_factor=1e-2, total_iters=2000)
 
# Cosine decay for the remaining steps
cosine = CosineAnnealingLR(optimizer, T_max=total_steps - 2000, eta_min=3e-5)
 
# Stitch them together
scheduler = SequentialLR(optimizer, [warmup, cosine], milestones=[2000])
 
# In the training loop:
for step in range(total_steps):
    loss = model(batch).loss
    loss.backward()
    torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    optimizer.step()
    scheduler.step()
    optimizer.zero_grad()

AdamW with β1=0.9\beta_1 = 0.9, β2=0.95\beta_2 = 0.95, weight decay of 0.1, peak learning rate around 3×1043 \times 10^{-4}, 2000 steps of warmup, then cosine decay. This is close to what LLaMA, Chinchilla, and most open-weight models use. The gradient clipping (clip_grad_norm_) is also standard, capping the gradient magnitude to prevent training instabilities.

Training a billion-parameter language model uses essentially the same optimizer as training a tiny classifier, just with a careful schedule and the right hyperparameters. AdamW scales.


Common misconceptions

"Adam always beats SGD." Not quite. SGD with momentum can actually generalize better than Adam in some settings, particularly in computer vision. The sharp minima Adam finds sometimes lead to worse test performance than the flatter minima SGD settles into. For transformers and LLMs specifically, Adam/AdamW is the clear winner. The adaptive rates are too important when you have parameters at wildly different scales (embeddings, attention weights, layer norm parameters).

"Learning rate is the only hyperparameter that matters." It is the most important one, sure. Warmup steps, weight decay, and the beta values all matter too. Using β2=0.95\beta_2 = 0.95 instead of the default 0.9990.999 has become common for LLM training because it makes the second moment estimate more responsive. Weight decay at 0.1 is fairly standard now, but getting it wrong can hurt. And the warmup duration needs to be long enough that the model stabilizes before the learning rate peaks.

"You should tune the optimizer for each task." For transformer-based models, AdamW with warmup and cosine decay is close to universal as a starting point. The things you usually tune are the peak learning rate, the warmup duration, weight decay, batch size, gradient clipping, and the betas. The recipe is a strong convention rather than a theorem. WSD-style schedules, memory-saving Adam variants, and newer optimizers (Lion, Shampoo, Muon-style methods) are active areas. But if you're starting fresh on a transformer, AdamW + warmup + cosine is the safe default.

What's next

We now have everything we need to run a training loop: a forward pass, a loss, gradients, backprop, and an optimizer that knows what to do with those gradients. The math is settled. What's not settled is the hardware. Every quantity in this post is a number, and how those numbers get stored on a GPU turns out to matter a lot more than I expected the first time I learned it.

The next post is GPUs, Floating Point, and Why Precision Matters. We'll look at what fp32 / bf16 / fp8 actually mean, where precision losses sneak in during training, and why the Adam state we just talked about ends up being the most precision-sensitive thing in the entire stack.


Additional reading (and watching)