Deep dive
How it works
In: optimizer step number, peak, floor, warmup W, total T · Out: the learning rate for this step · State: none (derive from the global step) · Cost: O(1) per step.
- Before W, ramp linearly from near 0 to the peak.
- After W, follow the half cosine from peak to floor over T − W steps (or hold the peak and cool down for WSD).
With peak η_max, floor η_min, warmup W and total T optimizer steps:
step < W: η = η_max · (step + 1) / W
step ≥ W: p = (step − W) / (T − W) progress in [0, 1]
η = η_min + ½ (η_max − η_min) (1 + cos(π p))
η_max ┤ ╭──╮
│ ╱ ╲___ cosine
│ ╱ ╲___
│╱ ╲____
η_min ┤ ‾‾‾‾
└──W──────────────────── T
WSD instead holds η_max until step T − D, then decays to η_min over D steps (linear, or 1 − √p, which does best in Hägele et al.).
Why warmup works, mechanically:
- Adam's v after k steps averages only k squared gradients. The variance of 1/√v is huge for small k, and RAdam is a closed-form fix for exactly this.
- Post-LN transformers have large gradients near the output at initialization and diverge without warmup. Pre-LN needs much less (Xiong et al.).
- For large-batch SGD, the linear-scaling rule assumes weights change slowly, which does not hold at step 0. Goyal et al. warm up for 5 epochs.
Code
use std::f64::consts::PI;
pub enum Decay { Cosine, Wsd { decay_steps: u64 }, InvSqrt }
pub struct Schedule { pub peak: f64, pub min: f64, pub warmup: u64, pub total: u64, pub decay: Decay }
impl Schedule {
/// `step` counts optimizer steps (not micro-batches), starting at 0.
pub fn lr(&self, step: u64) -> f64 {
if step < self.warmup {
return self.peak * (step + 1) as f64 / self.warmup as f64; // never exactly 0
}
let s = step.min(self.total);
match self.decay {
Decay::Cosine => {
let p = (s - self.warmup) as f64 / (self.total - self.warmup).max(1) as f64;
self.min + 0.5 * (self.peak - self.min) * (1.0 + (PI * p).cos())
}
Decay::Wsd { decay_steps } => {
let start = self.total.saturating_sub(decay_steps).max(self.warmup);
if s < start { return self.peak; }
let p = (s - start) as f64 / decay_steps.max(1) as f64;
self.min + (self.peak - self.min) * (1.0 - p.sqrt()) // "1 − sqrt" cooldown
}
// Transformer (Vaswani et al.): peak at step = warmup, then ∝ 1/√step. Ignores total.
Decay::InvSqrt => self.peak * (self.warmup as f64 / (step + 1) as f64).sqrt(),
}
}
}
fn main() {
let cos = Schedule { peak: 3e-4, min: 3e-5, warmup: 2000, total: 100_000, decay: Decay::Cosine };
let wsd = Schedule { decay: Decay::Wsd { decay_steps: 20_000 }, ..cos };
assert!((cos.lr(1999) - 3e-4).abs() < 1e-12);
assert!((cos.lr(51_000) - 1.65e-4).abs() < 1e-9); // halfway: midpoint of peak and min
assert!((cos.lr(100_000) - 3e-5).abs() < 1e-12);
assert_eq!(wsd.lr(79_999), 3e-4);
for s in [0, 1000, 2000, 25_000, 50_000, 80_000, 90_000, 99_999] {
println!("{s:>6} cosine {:.2e} wsd {:.2e}", cos.lr(s), wsd.lr(s));
}
}
In PyTorch, wrap it as LambdaLR(opt, lambda s: sched.lr(s) / peak) and call sched.step() after opt.step().
Complexity and constants
- O(1) per step. The cost is all in choosing the numbers.
- Warmup length in practice: BERT 10k of 1M steps (1%); GPT-3 375M tokens (about 0.1% of 300B); LLaMA 2000 steps; ImageNet SGD 5 of 90 epochs.
- Final lr: 10% of peak (LLaMA, Chinchilla) or linear to 0 (BERT). In WSD runs, most of the schedule's loss gain shows up during the short final cooldown.
Efficiency
- Cost: one scalar per optimizer step; the schedule is free at run time. What it spends is training compute: a wrong length or floor wastes steps. Bound: none; it is a scalar function.
- Levers:
- cosine length equal to the real run length — overshooting by more than about 25% hurts (Chinchilla) — fixed-budget runs
- warmup of 0.1-1% of steps — avoids early divergence, so no restarted runs (LLaMA 2000 steps, BERT 1%) — Adam-style optimizers
- WSD: constant lr, short final cooldown — one run supports many lengths, scaling sweeps need fewer, reusable runs (Hägele et al.) — open-ended training
- save a checkpoint before each cooldown — branches cost only the cooldown — WSD runs
- floor at 10% of peak instead of 0 — simpler resume (LLaMA, Chinchilla) — continued training
- Naive → standard → state of the art:
- constant lr · diverges early with Adam, loss floor from gradient noise
- warmup + cosine to 10% · standard for LLMs; the length is fixed in advance
- WSD with reusable cooldowns, or schedule-free iterate averaging (no T needed) · extendable without a restart
Implementation notes
- Count optimizer steps. With gradient accumulation k,
total = micro_batches / k. - On resume, derive lr from the global step stored in the checkpoint, not from a fresh scheduler.
- Per-group lr (e.g. a lower lr for embeddings) composes as a multiplier on the global schedule.
- For WSD runs, save the checkpoint just before each cooldown. Any later cooldown can branch from it, which gives cheap scaling-law sweeps over training length.
- Log lr every step. Schedule bugs (wrong T, off-by-k accumulation) are the most common silent training bug.
Pitfalls
totalset too large (a multi-epoch estimate that never runs): lr never decays, and the final loss is worse than a correctly sized schedule.- Warmup to step W with
lr = peak · step / Wgives lr = 0 at step 0. Harmless for SGD; with Adam it wastes the step and still updates m and v. - Changing batch size mid-run without rescaling lr: linear for SGD, roughly √ for Adam.
- Continued pretraining from a cosine-to-zero checkpoint: re-warm, and expect some initial forgetting.
- Too-short warmup with Post-LN, or a high peak lr without QK-norm/logit soft-capping: divergence after 1-10k steps, not immediately.
Variants and alternatives
d^−0.5 · min(t^−0.5, t·W^−1.5) with W = 4000. Never anneals hard.In production
LLaMA 1-3 (2000-step warmup, cosine to 10%), Chinchilla, GPT-3 (cosine to 10% over 260B tokens), BERT (10k warmup + linear), MiniCPM (WSD), Hugging Face get_cosine_schedule_with_warmup, Optax warmup_cosine_decay_schedule; Swin Transformer (ImageNet, AdamW with cosine decay), Whisper (speech).
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| burn | Rust | tracel-ai/burn | cargo add burn | — | — | — | — | cosine, linear and Noam LR schedulers |
| PyTorch | Python | pytorch/pytorch | pip install torch | — | — | — | — | torch.optim.lr_scheduler (LambdaLR, CosineAnnealingLR, OneCycleLR) |
| Transformers | Python | huggingface/transformers | pip install transformers | — | — | — | — | get_cosine_schedule_with_warmup |
| Optax | Python | google-deepmind/optax | pip install optax | — | — | — | — | warmup_cosine_decay_schedule |
| schedule-free | Python | facebookresearch/schedule_free | pip install schedulefree | — | — | — | — | optimizers without a decay schedule |
Sources
- Loshchilov and Hutter, SGDR: Stochastic Gradient Descent with Warm Restarts, ICLR 2017. paper
- Goyal et al., Accurate, Large Minibatch SGD: Training ImageNet in 1 Hour, 2017. paper
- Hoffmann et al., Training Compute-Optimal Large Language Models (Chinchilla), 2022. paper
- Liu et al., On the Variance of the Adaptive Learning Rate and Beyond (RAdam), ICLR 2020. paper · code
- Xiong et al., On Layer Normalization in the Transformer Architecture, ICML 2020. paper
- Hu et al., MiniCPM: Unveiling the Potential of Small Language Models with Scalable Training Strategies, 2024. paper
- Hägele et al., Scaling Laws and Compute-Optimal Training Beyond Fixed Training Durations, NeurIPS 2024. paper · code
- Liu et al., Swin Transformer: Hierarchical Vision Transformer using Shifted Windows, ICCV 2021 (AdamW, 20 warmup epochs, cosine decay). paper
- Radford et al., Robust Speech Recognition via Large-Scale Weak Supervision (Whisper), 2022 (2048 warmup updates, linear decay). paper