Learning-rate schedules, warmup, and clipping

The learning rate is the one hyperparameter that matters at every step, and a schedule is the admission that no single value serves the whole run. This page derives why warmup exists, walks the shapes that survived (cosine, linear, step, one-cycle, inverse-sqrt, and the warmup-stable-decay shape recent LLMs use), and works out global-norm gradient clipping down to the rescale arithmetic. Then it builds a pure schedule library and a clipper in PyTorch and JAX and verifies them against torch.optim.lr_scheduler, optax, and torch.nn.utils.clip_grad_norm_.

What it is and when you reach for it

A learning-rate schedule is nothing more than a function η(t) evaluated once per optimizer step, and gradient clipping is a guard applied to the gradient just before the optimizer sees it. Together they form the stability layer that sits between the loss and the optimizer, whether that optimizer is SGD with momentum or Adam. You reach for a schedule the moment a run is longer than a few thousand steps, because the step size that makes early progress is too noisy to converge with, and the step size that converges is too timid to start with. You reach for warmup whenever the optimizer is adaptive or the batch is large, and for clipping whenever a single bad batch can produce a gradient large enough to undo hours of training, which in practice means always for language models and recurrent networks.

The math

Why the learning rate must move at all

SGD with a constant η does not converge to a minimum; it converges to a noise ball around it. On a quadratic with gradient noise, the steady-state error is proportional to η times the noise scale, so progress stalls at a plateau set by the learning rate. Halve η and the plateau drops, but so does the speed of travel between plateaus. A schedule resolves the tension by spending a large η while the signal-to-noise ratio of the gradient is high and paying it down as the iterate approaches regions where noise dominates. Classical theory wants ηt ~ 1/t for convergence guarantees; deep learning practice found that gentler decays to a small floor work better with momentum and finite budgets, which is why the shapes below all end low but rarely at zero.

Why warmup exists

Warmup, running the first hundreds or thousands of steps at a learning rate ramping up from near zero, earns its place twice over. The first reason is Adam's early variance. Adam divides each gradient coordinate by √v̂, where v̂ is an exponential average of squared gradients with decay β2 = 0.999. In the first steps that average has seen only a handful of samples, so it is a terrible estimate of the true second moment; bias correction fixes its mean but not its variance, and the RAdam analysis (Liu et al.) makes this precise: the variance of the adaptive step is enormous, in the limit unbounded, early in training. A too-large step taken through a badly estimated preconditioner can throw the parameters into a bad region the run never fully recovers from. Warmup keeps the step size small until v̂ has accumulated enough history, on the order of 1/(1 − β2) steps, to be trustworthy. The second reason is large-batch stability. Goyal et al.'s linear scaling rule multiplies η with the batch size, but the rule's justification assumes the gradient changes slowly along the trajectory, and that assumption is at its worst in the first epochs when weights move fastest; their fix was exactly a gradual warmup. Both arguments predict the same knob: ramp linearly from 0 (or a small fraction of peak) to ηpeak over Tw steps, η(t) = ηpeak·t/Tw.

The shapes that survived

After warmup, the decay shape takes over. The ones worth knowing, oldest heritage first. The inverse-sqrt schedule comes from the original transformer paper: η(t) = dmodel−0.5·min(t−0.5, t·Tw−1.5), which is a linear warmup to a peak at t = Tw followed by 1/√t decay. Its virtue is that it never needs to know the total budget; its vice is that it decays fast early and slow late, and it has largely been displaced. Step schedules hold η constant and divide by 10 at fixed milestones, the classic ImageNet recipe being epochs 30, 60, 90; simple, effective, and the visible loss-curve cliffs at each drop are a signature every practitioner has seen. Cosine decay, popularized by Loshchilov and Hutter's SGDR (whose warm restarts were mostly dropped while the half-cosine stayed), interpolates smoothly:

η(t) = ηmin + ½·(ηpeak − ηmin)·(1 + cos(π·t/T))

with t counted from the end of warmup and T the remaining budget. It spends most of its time near the ends, easing out of the peak and easing into the floor. Worked numbers, using the LLM-typical recipe of ηpeak = 3·10−4, floor at 10% so ηmin = 3·10−5, warmup 2,000 of 100,000 steps: at step 1,000 warmup gives 1.5·10−4; at step 51,000 the cosine argument is π·(49,000/98,000) = π/2, cos = 0, so η = 3·10−5 + ½·2.7·10−4 = 1.65·10−4; at step 100,000, cos(π) = −1 and η lands exactly on the 3·10−5 floor. Linear decay is the straight-line version of the same idea, standard for fine-tuning since BERT. One-cycle (Leslie Smith) is a single up-then-down cycle, roughly 30% of steps rising to a peak above the "safe" rate and 70% annealing far below it, with momentum cycled inversely; it produced the superconvergence results and remains a strong default for short vision runs. Finally the warmup-stable-decay (WSD) shape used by recent LLMs (MiniCPM described it, and trapezoidal variants have been analyzed since): warmup, then a long flat plateau at ηpeak, then a short sharp decay, 10 to 20% of the budget, to near zero. Its selling point is operational: because the plateau is horizon-free, you can branch a decay leg off any checkpoint and get a fully converged model, without having committed to a total step count the way cosine forces you to. That property is why continual-pretraining pipelines favor it.

Gradient clipping by global norm

Clipping by global norm treats the entire gradient, every tensor of every layer, as one long vector g and rescales it if it is too long:

g ← g · min(1, c / ‖g‖2), with ‖g‖2 = √(Σparams Σelements gi2)

The min with 1 means gradients already inside the ball pass untouched; clipping never scales up. Because every element is multiplied by the same scalar, the direction of the update is exactly preserved, only its length is capped, which is what distinguishes norm clipping from per-element value clamping (that one changes direction). A worked example: two parameter tensors with gradient norms 3 and 4 give a global norm of √(9 + 16) = 5; with the standard threshold c = 1 the coefficient is 1/5, every gradient element is multiplied by 0.2, and the clipped global norm is exactly 1. Had the global norm been 0.8, the coefficient 1.25 would be clamped to 1 and nothing would change. The practical effect is a hard bound on the step: for SGD the parameter displacement per step is at most η·c no matter what the batch produced, which is precisely the insurance you want against the rare pathological minibatch. PyTorch's implementation adds an ε = 10−6 to the denominator; optax's does not, a difference of one part in a million that only matters to tests.

Implementation, twice

Schedules deserve to be pure functions of the step number: trivial to plot, trivial to test, trivial to resume (the step count is the only state). The PyTorch tab writes them as multipliers and wires them in with LambdaLR, which multiplies the base lr by the function's value each step; the JAX tab writes the same shapes as jnp-traceable functions and shows the idiomatic optax equivalents, which optimizers accept directly in place of a constant learning rate.

import math
import torch

# Pure schedule(step) functions returning a multiplier in [0, 1].
# Peak lr lives in the optimizer; the schedule only shapes it.

def warmup_cosine(step, *, warmup, total, final_frac=0.10):
    """The LLM default: linear warmup, cosine to final_frac of peak."""
    if step < warmup:
        return (step + 1) / warmup
    t = (step - warmup) / max(1, total - warmup)      # 0 -> 1
    return final_frac + 0.5 * (1 - final_frac) * (1 + math.cos(math.pi * t))

def warmup_stable_decay(step, *, warmup, stable, decay, final_frac=0.0):
    """WSD: warmup, flat plateau, short linear cooldown."""
    if step < warmup:
        return (step + 1) / warmup
    if step < warmup + stable:
        return 1.0
    t = min(1.0, (step - warmup - stable) / max(1, decay))
    return final_frac + (1 - final_frac) * (1 - t)

def step_decay(step, *, milestones=(30_000, 60_000, 90_000), gamma=0.1):
    return gamma ** sum(step >= m for m in milestones)

def inverse_sqrt(step, *, warmup):
    s = step + 1
    return s / warmup if s < warmup else (warmup / s) ** 0.5

# Wiring: LambdaLR multiplies the optimizer's base lr by our multiplier.
model = torch.nn.Linear(768, 768)
opt = torch.optim.AdamW(model.parameters(), lr=3e-4)   # base lr = peak
sched = torch.optim.lr_scheduler.LambdaLR(
    opt, lambda s: warmup_cosine(s, warmup=2_000, total=100_000))

def train_step(batch):
    loss = model(batch).square().mean()
    loss.backward()
    torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    opt.step()
    opt.zero_grad()
    sched.step()      # once per optimizer step, AFTER opt.step()
    return loss
import jax.numpy as jnp
import optax

# Pure schedule(step) functions returning the lr itself.
# jnp.where instead of if: traceable under jit, step can be a tracer.

def warmup_cosine(step, *, peak, warmup, total, final_frac=0.10):
    warm = peak * (step + 1) / warmup
    t = jnp.clip((step - warmup) / jnp.maximum(1, total - warmup), 0.0, 1.0)
    floor = final_frac * peak
    cos = floor + 0.5 * (peak - floor) * (1 + jnp.cos(jnp.pi * t))
    return jnp.where(step < warmup, warm, cos)

def warmup_stable_decay(step, *, peak, warmup, stable, decay,
                        final_frac=0.0):
    warm = peak * (step + 1) / warmup
    t = jnp.clip((step - warmup - stable) / jnp.maximum(1, decay), 0.0, 1.0)
    tail = peak * (final_frac + (1 - final_frac) * (1 - t))
    return jnp.where(step < warmup, warm,
                     jnp.where(step < warmup + stable, peak, tail))

# The idiomatic versions ship with optax; decay_steps counts the
# WHOLE schedule length, warmup included.
cosine = optax.warmup_cosine_decay_schedule(
    init_value=0.0, peak_value=3e-4,
    warmup_steps=2_000, decay_steps=100_000, end_value=3e-5)

wsd = optax.join_schedules(
    schedules=[
        optax.linear_schedule(0.0, 3e-4, 2_000),     # warmup
        optax.constant_schedule(3e-4),               # stable plateau
        optax.linear_schedule(3e-4, 0.0, 10_000),    # cooldown
    ],
    boundaries=[2_000, 90_000])

# Wiring: optax optimizers accept the schedule wherever a constant
# lr would go, and clipping is just another link in the chain.
tx = optax.chain(
    optax.clip_by_global_norm(1.0),
    optax.adamw(learning_rate=cosine, weight_decay=0.1),
)

The shapes, drawn over one training run:

warmup + cosine (LLM default)          warmup-stable-decay (WSD)
peak |    .--..                        peak |   .-------------.
     |   /     `--.                         |  /               \
     |  /          `--.                     | /                 \
 10% | /               `---__           ~0% |/                   \_
     +------------------------              +-----------------------
      warmup    cosine to 10%                wu     stable     decay

step decay (ImageNet classic)          one-cycle (short vision runs)
peak |-------.                         peak |       .-``-.
 /10 |       `------.                       |     /       \
/100 |              `------.                |   /           \
     |                     `----        <<pk|__/              `--..__
     +------------------------              +-----------------------
       ep30     ep60    ep90                 ~30% up      ~70% down

Clipping is short enough to write and verify in one sitting, and worth it: the verification pins down both the returned norm and every clipped element against the reference implementations.

import torch

def clip_grads_(grads, max_norm, eps=1e-6):
    """In-place global-norm clip; returns the PRE-clip total norm.

    Mirrors torch.nn.utils.clip_grad_norm_: coefficient
    max_norm / (norm + eps), clamped at 1.0 so gradients already
    inside the ball are never scaled up.
    """
    total = torch.linalg.vector_norm(
        torch.stack([torch.linalg.vector_norm(g) for g in grads]))
    coef = (max_norm / (total + eps)).clamp(max=1.0)
    for g in grads:
        g.mul_(coef)
    return total

# Verify against the reference on a real backward pass.
torch.manual_seed(0)
model = torch.nn.Sequential(torch.nn.Linear(64, 64), torch.nn.ReLU(),
                            torch.nn.Linear(64, 10))
model(torch.randn(32, 64)).square().sum().backward()

copies = [p.grad.clone() for p in model.parameters()]
norm_ref = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
norm_ours = clip_grads_(copies, 1.0)

assert torch.allclose(norm_ours, norm_ref)
for ours, p in zip(copies, model.parameters()):
    assert torch.allclose(ours, p.grad)
print(f"pre-clip norm {norm_ref:.4f}; norms and clipped grads agree")
import jax
import jax.numpy as jnp
import optax

def global_norm(tree):
    return jnp.sqrt(sum(jnp.sum(jnp.square(g))
                        for g in jax.tree_util.tree_leaves(tree)))

def clip_by_global_norm(grads, max_norm):
    """Returns (clipped pytree, pre-clip norm). No epsilon: this
    matches optax exactly; torch adds 1e-6 in the denominator."""
    norm = global_norm(grads)
    scale = jnp.minimum(1.0, max_norm / norm)
    return jax.tree_util.tree_map(lambda g: g * scale, grads), norm

# Verify against optax on a pytree standing in for gradients.
k1, k2 = jax.random.split(jax.random.PRNGKey(0))
grads = {"w": 3.0 * jax.random.normal(k1, (64, 64)),
         "b": jax.random.normal(k2, (64,))}

ours, norm = clip_by_global_norm(grads, 1.0)
tx = optax.clip_by_global_norm(1.0)
theirs, _ = tx.update(grads, tx.init(grads))

for a, b in zip(jax.tree_util.tree_leaves(ours),
                jax.tree_util.tree_leaves(theirs)):
    assert jnp.allclose(a, b, atol=1e-7)
print(f"pre-clip norm {norm:.2f}; matches optax.clip_by_global_norm")

Using it on a real shape of problem

The pieces assemble into the loop below: AdamW at a 3·10−4 peak, 200 warmup steps of a 5,000-step budget, cosine to 10%, clip at global norm 1.0, on a transformer-width regression problem (batch 32, width 768). The skeleton is the same training loop as every page in this section, the neural network page builds it from scratch, with the stability layer threaded through it. Two things are worth watching. The learning-rate trace is deterministic and should reproduce the worked numbers from the math section scaled to this budget; printing it at steps 0, 100, 2,600, and 4,999 is a free correctness check. The loss curve is machine and seed dependent, but the characteristic signatures are not: gradient norms exceed the clip threshold most often in the first tens of steps (so clipping is active exactly when warmup is also protecting you), and if you re-run with warmup=1 you will often see the first-step loss jump that warmup exists to prevent, though on a problem this small Adam frequently survives it; at billion-scale it frequently does not.

import torch

torch.manual_seed(0)
X = torch.randn(4096, 768)
W_true = torch.randn(768, 1) / 768 ** 0.5
Y = X @ W_true + 0.01 * torch.randn(4096, 1)

model = torch.nn.Sequential(torch.nn.Linear(768, 768), torch.nn.GELU(),
                            torch.nn.Linear(768, 1))
opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1)
sched = torch.optim.lr_scheduler.LambdaLR(
    opt, lambda s: warmup_cosine(s, warmup=200, total=5_000))

for step in range(5_000):
    idx = torch.randint(0, 4096, (32,))
    loss = (model(X[idx]) - Y[idx]).square().mean()
    loss.backward()
    gnorm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    opt.step(); opt.zero_grad(); sched.step()
    if step in (0, 100, 2_600, 4_999):
        print(step, f"lr={opt.param_groups[0]['lr']:.2e}",
              f"loss={loss.item():.4f}", f"gnorm={gnorm:.2f}")
# lr trace is exact: warmup at 100, ~half-decayed at 2600, floor at 4999.
# loss should fall steadily; gnorm > 1 mostly in the earliest steps.
import jax
import jax.numpy as jnp
import optax

key = jax.random.PRNGKey(0)
kx, kw, km = jax.random.split(key, 3)
X = jax.random.normal(kx, (4096, 768))
Y = X @ (jax.random.normal(kw, (768, 1)) / 768 ** 0.5)

def net(params, x):
    h = jax.nn.gelu(x @ params["w1"] + params["b1"])
    return h @ params["w2"] + params["b2"]

k1, k2 = jax.random.split(km)
params = {"w1": jax.random.normal(k1, (768, 768)) / 768 ** 0.5,
          "b1": jnp.zeros(768),
          "w2": jax.random.normal(k2, (768, 1)) / 768 ** 0.5,
          "b2": jnp.zeros(1)}

schedule = optax.warmup_cosine_decay_schedule(
    init_value=0.0, peak_value=3e-4,
    warmup_steps=200, decay_steps=5_000, end_value=3e-5)
tx = optax.chain(optax.clip_by_global_norm(1.0),
                 optax.adamw(learning_rate=schedule, weight_decay=0.1))
state = tx.init(params)

@jax.jit
def train_step(params, state, xb, yb):
    def loss_fn(p):
        return jnp.mean((net(p, xb) - yb) ** 2)
    loss, grads = jax.value_and_grad(loss_fn)(params)
    updates, state = tx.update(grads, state, params)
    return optax.apply_updates(params, updates), state, loss

for step in range(5_000):
    key, sub = jax.random.split(key)
    idx = jax.random.randint(sub, (32,), 0, 4096)
    params, state, loss = train_step(params, state, X[idx], Y[idx])
    if step in (0, 100, 2_600, 4_999):
        print(step, f"lr={schedule(step):.2e}", f"loss={float(loss):.4f}")
# schedule(step) is a plain function: plotting or asserting its
# values needs no optimizer at all.

Applications

The standard LLM pretraining recipe is warmup plus cosine to 10% of peak, and it has been remarkably stable across generations. GPT-3 warmed up over the first 375 million tokens and cosine-decayed to 10% of peak over 260 billion; LLaMA used 2,000 warmup steps and cosine to 10%; Chinchilla's ablations kept the same family and mostly tuned how the horizon matches the token budget. The nanoGPT page walks a readable implementation of exactly this shape, a hand-rolled warmup-then-cosine function with a min lr of one tenth the peak, applied per step outside any scheduler class, which is a strong endorsement of the pure-function style. Global-norm clipping at 1.0 is part of the same recipe nearly universally. The WSD shape is the emerging alternative where the total budget is not known in advance: MiniCPM used it to reuse plateau checkpoints across scales, and continual-pretraining pipelines branch cooldowns off a long-running plateau. Fine-tuning flips most defaults: pretraining schedules are shaped around a fixed, enormous budget and a high peak; fine-tuning schedules are short, low, and end near zero. BERT-style fine-tuning established linear decay with roughly 10% warmup at peaks around 2·10−5, two orders of magnitude below pretraining peaks, and instruction-tuning recipes typically use cosine with a small warmup ratio around 3%. In vision, the step schedule trained the ResNet era, cosine is the modern default in timm recipes, and one-cycle remains popular for short runs where its aggressive peak pays off.

Against the real libraries

Three libraries own this space. torch.optim.lr_scheduler provides the class-based versions: LambdaLR (the idiomatic host for pure functions like the ones above), CosineAnnealingLR, StepLR and MultiStepLR, LinearLR, OneCycleLR (which also cycles momentum, the part of one-cycle everyone forgets), SequentialLR for stitching warmup onto a decay, and ReduceLROnPlateau, the one schedule that reads the loss instead of the clock. What the classes add over a bare function is state management: state_dict save and restore so a resumed run continues the schedule where it left off, per-parameter-group scaling so layer-wise lr multipliers keep working, and edge-case behavior (step-zero initialization, the call-order warning) that has been litigated in years of issues. optax takes the opposite stance and won the argument for JAX: schedules are pure functions, warmup_cosine_decay_schedule, cosine_decay_schedule, linear_schedule, piecewise_constant_schedule, and join_schedules to compose them (the WSD build above is three joins), and any optimizer accepts a schedule wherever it accepts a constant; optax.clip_by_global_norm composes into the same chain. HF Transformers wraps the common shapes behind get_scheduler(name, ...) with string names ("linear", "cosine", "inverse_sqrt", "warmup_stable_decay", and others) plus explicit constructors like get_cosine_schedule_with_warmup and get_wsd_schedule (warmup, stable, and decay step counts with a min_lr_ratio), which is what the Trainer drives; its value is that a config file fully specifies the schedule. The from-scratch version is genuinely enough surprisingly often, and the pure-function style is arguably the better engineering: it is what nanoGPT and most large training codebases do internally. Verification is mercifully deterministic: evaluate your schedule and the library's at a grid of steps (include 0, the warmup boundary, and the final step, where the off-by-one bugs live) and assert agreement to float64 precision; for clipping, the paired-gradient test in the implementation section pins both the returned norm and every element.

Traps and misconceptions

Stepping the scheduler at the wrong cadence. PyTorch schedulers were historically stepped per epoch, and OneCycleLR and every warmup-bearing schedule must be stepped per optimizer step; mixing the two conventions silently compresses your schedule 500-fold. Related: call opt.step() before sched.step(); PyTorch warns about the reversed order because it skips the first lr value. Under gradient accumulation, one "step" of the schedule is one optimizer step, not one microbatch.

LambdaLR takes a multiplier, not a learning rate. The lambda's return value is multiplied by the base lr stored in the optimizer. Return absolute lr values from it and your run trains at lr² scale. If you want the function to own the absolute value, set the optimizer's lr to 1.0 and document it loudly.

Warmup is not a license for a bad peak. Warmup prevents the specific early-training failure of adaptive optimizers and large batches; it does not make an intrinsically unstable peak lr stable. If loss spikes at step 20,000, long after warmup ended, the peak is too high (or clipping is missing), and lengthening warmup will only delay the evidence.

Clipping confusions. Global-norm clipping is not clip_grad_value_: value clamping changes the gradient's direction, norm clipping only its length. With mixed precision, clip after GradScaler.unscale_() or you are clipping scaled gradients against an unscaled threshold. With gradient accumulation, clip once on the accumulated gradient, not per microbatch. And a clip that fires every step is not stability, it is a schedule you did not choose: the effective update becomes η·c·g/‖g‖, a normalized-gradient method at a rate you never picked; monitor the clip fraction, and if it stays near 1.0, lower the peak lr instead.

"Cosine" does not name one function. HF's cosine decays to zero; nanoGPT's decays to 10% of peak; optax's takes an explicit end_value; CosineAnnealingLR takes eta_min defaulting to 0. A floor of 0 versus 10% changes how much learning happens in the final third of training, and a resumed or reproduced run that silently swaps floors will not match. Always state the floor as part of the recipe.

Key takeaway: a schedule is just a pure function of the step count, and the whole modern recipe is three commitments wide: ramp up while the optimizer's statistics are untrustworthy, decay toward a stated floor as gradient noise takes over, and cap the global gradient norm so no single batch can move the parameters further than η·c. Write the function, plot it, assert its values at the boundaries against the library, and the most temperamental part of training becomes the most deterministic.