Optimizers
TinyGPT ships five optimizers, selectable on tinygpt train, sft, and
dpo via --optimizer {adamw|lion|sophia|muon|adafactor}. Default is
adamw (preserves backward compatibility — pre-existing scripts work
unchanged).
The optimizer interface is the standard MLX-Swift Optimizer protocol;
each implementation lives in native-mac/Sources/TinyGPTModel/Optimizers.swift.
The drop-in surface — same flags, same trainer, same compiled step —
is what lets you A/B optimisers without touching the train loop.
Mechanism, in one paragraph each
AdamW (Loshchilov & Hutter, 2019) — the baseline
Two EMAs per parameter — first moment m and second moment v of the
gradient — plus decoupled weight decay (the AdamW-vs-Adam fix).
Update is lr · m / (√v + ε); weight decay is applied separately to
the parameter, not folded into the gradient. State footprint: 2× |θ|.
Robust default for transformer pre-training.
Lion (Chen et al., 2023) — sign-based, smaller state
Tracks only the first moment m. Update is lr · sign(b1·m + (1−b1)·g)
— the gradient is sign-ed away, so the per-step move has unit
magnitude in every coordinate. Sensitivity to LR is sharper (the paper
recommends LR 3-10× smaller than AdamW, weight decay 3-10× larger).
State footprint: 1× |θ|. Sometimes beats AdamW on transformer LM at
~½ optimizer memory.
Sophia (Liu et al., 2023) — second-order with clipping
Stores (m, h) where h is an EMA approximation of the Hessian
diagonal. Update is lr · sign(m) · clip(|m| / (ρ·h + ε), 1) — the
per-coordinate clip caps the per-step move at lr, which is what
gives Sophia robustness to bad-curvature directions. This
implementation uses the EMA-of-squared-gradient (“Sophia-light”)
proxy for h rather than the paper’s full Gauss-Newton Hessian
estimator — same memory pattern, similar dynamics on transformer LM,
no extra forward passes. State footprint: 2× |θ| (same as AdamW).
Muon (Jordan et al., 2024) — orthogonalised matrix update
For 2D weights (attention and MLP matrices), the momentum buffer
m_t = β·m_{t-1} + g is orthogonalised via a fixed 5-step Newton-
Schulz quintic polynomial — the result is approximately U·Vᵀ from
the SVD of m. The update is then lr · scale · NS(m) where
scale = max(1, √(d_out/d_in)) keeps update norm comparable to AdamW.
For 1D weights (LayerNorm γ/β, biases) and embedding tables, Muon
falls back to AdamW internally — orthogonalisation is meaningless for
vectors. State footprint: nominally 1× |θ| for 2D leaves (our impl
keeps the second slot present but zero for shape uniformity; a follow-
up could halve this — see “Open work” below).
Adafactor (Shazeer & Stern, 2018) — sublinear-memory Adam
For 2D weights, stores only the row-sum and column-sum of the second
moment instead of the full matrix — the rank-1 reconstruction
v ≈ outer(row, col) / mean(row) is what the per-step update divides
by. For 1D weights, falls back to a regular second-moment vector. By
default beta1 = nil → no first-moment tracking either (set
beta1 = 0.9 to add it back). Configured here with
relativeStep=false, scaleParameter=false so the LR scheduler
(--lr-schedule cosine) keeps working. State footprint: ~½ × |θ| —
the headline claim.
Smoke results
50-step run on /tmp/eval-holdout-tail.txt (5 MB raw text). LR per
optimizer follows paper recommendations. Build:
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild \
-scheme tinygpt -destination "platform=macOS" \
-derivedDataPath /tmp/tinygpt-smoke-opts -configuration Release build
Tiny preset (4L · d=128 · ctx=128 · batch=8 · 842k params)
| Optimizer | LR | step 1 loss | step 50 loss | step/s | peak MB | active MB |
|---|---|---|---|---|---|---|
| AdamW | 3e-4 | 6.345 | 3.046 | 46.2 | 160.6 | 10.1 |
| Lion | 3e-5 | 5.528 | 3.714 | 25.4 | 153.3 | 6.7 |
| Sophia | 3e-4 | 6.217 | 2.927 | 30.9 | 161.2 | 10.1 |
| Muon | 2e-3 | 5.925 | 3.450 | 18.2 | 163.5 | 10.1 |
| Adafactor | 3e-4 | 6.185 | 3.029 | 12.1 | 148.6 | 3.4 |
Small preset (6L · d=192 · ctx=256 · batch=4 · 3.2M params)
| Optimizer | step 1 loss | step 50 loss | step/s | peak MB | active MB |
|---|---|---|---|---|---|
| AdamW | 5.861 | 2.779 | 30.8 | 404.0 | 33.2 |
| Lion | 5.877 | 3.343 | 32.0 | 392.9 | 22.2 |
| Sophia | 5.757 | 2.785 | 30.6 | 404.0 | 33.2 |
| Muon | 6.102 | 3.139 | 18.8 | 404.0 | 33.2 |
| Adafactor | 6.448 | 2.894 | 13.1 | 382.0 | 11.2 |
Huge preset (12L · d=512 · ctx=512 · batch=2 · ~38M params)
| Optimizer | step 1 loss | step 50 loss | step/s | peak MB | active MB |
|---|---|---|---|---|---|
| AdamW | 5.905 | 2.820 | 16.3 | 523.6 | 115.3 |
| Lion | 6.332 | 3.024 | 16.3 | 485.2 | 76.9 |
| Sophia | 6.076 | 3.058 | 11.5 | 523.6 | 115.3 |
| Muon | 5.914 | 3.037 | 5.2 | 523.6 | 115.3 |
| Adafactor | 5.982 | 3.033 | 7.8 | 447.1 | 38.8 |
active MB is MLX’s “live tensor memory” snapshot at the end of the
50-step run — the most diagnostic number for optimizer state memory
(peak includes activations, which dominate at long context). At the
huge scale:
- AdamW ≡ Sophia ≡ Muon: 115.3 MB (2-state per param)
- Lion: 76.9 MB ≈ ⅔ AdamW
- Adafactor: 38.8 MB ≈ ⅓ AdamW
The Adafactor headline (“½ optimizer memory”) shows up more aggressively
than the paper’s prediction at this scale because we also drop the
first-moment buffer (Adafactor’s beta1 = nil default). With beta1 = 0.9, expect Adafactor to land near 75 MB — still well under Lion.
Tiny-preset 200-step trace
To verify all five continue converging, not just take a single drop:
adamw 5.748 → 2.832 → 2.729 → 2.552 → 2.624 (200 steps · 61.5 step/s · final 2.624)
lion 5.809 → 3.871 → 3.609 → 3.497 → 3.178 (200 steps · 77.4 step/s · final 3.178)
sophia 5.792 → 2.699 → 2.701 → 2.532 → 2.655 (200 steps · 72.7 step/s · final 2.655)
muon 6.159 → 3.397 → 2.775 → 2.896 → 2.631 (200 steps · 50.2 step/s · final 2.631)
adafactor 6.238 → 2.903 → 2.853 → 2.700 → 2.638 (200 steps · 74.1 step/s · final 2.638)
All five descend monotonically (with the usual noise band). At 200 steps on this small problem, AdamW / Sophia / Adafactor / Muon are within 0.05 nats of each other; Lion lags by 0.5 nats because the sign-based update needs more steps to find the right scale (the paper notes Lion typically catches up by 1k-2k steps on LM tasks).
When to pick which
Use AdamW when
- You have no specific reason to deviate. AdamW is the default for a reason — robust, well-understood, predictable hyperparameter behaviour across model scales.
Use Lion when
- You’re memory-bound by optimizer state at fp32, and want a quick win (~33% off optimizer memory) without changing model architecture.
- Your run is long (>5k steps) so Lion has time to catch up to AdamW on the loss curve. On <500-step smoke runs Lion often looks worse — this is expected.
- Pair with a 3-10× smaller LR than AdamW (Lion’s
sign-based update has unit magnitude, so the same LR translates to a 10×-100× bigger move).
Use Sophia when
- The loss surface has very different curvatures across coordinates (the per-coordinate clip in Sophia’s update is the part that matters here). On standard LM pre-training, expected wins are 10-20% fewer steps to the same loss vs AdamW.
- Compute budget allows ~10-15% slower steps (Sophia is more arithmetic per parameter than AdamW). On our tiny-preset trace Sophia is 25-30% slower because the small parameter count makes the overhead more visible; on larger models the gap shrinks toward 10%.
- Note: this is the EMA-of-squared-gradient (“Sophia-light”) variant, not the full Gauss-Newton Hessian estimator. The paper-recommended Sophia-G is a future enhancement (would need an extra forward pass every k steps).
Use Muon when
- Your model is dominated by 2D matrix weights (attention/MLP). The orthogonalisation pre-conditions the update step to look like an SVD-truncated update, which trains substantially better on those matrices in the Muon paper’s settings.
- Step time is acceptable — the Newton-Schulz iteration is 5 matmuls
per 2D param per step. On our
hugepreset Muon was 3× slower than AdamW (5.2 vs 16.3 step/s); on smaller models the relative slowdown is smaller (1.5-2× on tiny/small). - LR can be tuned generously larger (paper uses 2e-3, an order of magnitude above AdamW’s typical 1e-4 to 3e-4).
Use Adafactor when
- The model is large enough that fp32 optimizer state dominates per-step memory and you can’t afford bf16 (numerical stability) or ZeRO-style sharding (single-machine). At 38M params and fp32, Adafactor saves ~80 MB; at 7B fp32 it would save ~15 GB.
- bf16/fp16 training: Adafactor’s relative-rms scaling makes it
particularly bf16-friendly — the second-moment is already noisy, so
the factorised approximation introduces less additional error than
it would on fp32. Pair
--optimizer adafactorwith--dtype bfloat16for the most aggressive memory profile in this repo.
Implementation notes
LearningRateMutable
The Trainer schedule code does trainer.optimizer.learningRate = ...
each step to drive cosine warmup/decay. To preserve this across
optimizers (AdamW, Lion, Adafactor, Sophia, Muon), we added the
LearningRateMutable protocol with a single var learningRate: Float
requirement. Adafactor’s stored property is Float? (supports
relative-step mode), so it’s wrapped in AdafactorAdapter that
round-trips through the optional. We also lock Adafactor into
relativeStep: false, scaleParameter: false mode in the factory so
the optional is always populated.
Compile compatibility
All optimizers conform to MLX-Swift’s Optimizer: Updatable, Evaluatable protocol, so the compiled train step
compile(inputs: [m, optimizer], outputs: [m, optimizer]) { ... }
works unchanged. The training closure’s optimizer.update(model:, gradients:) call is dispatched dynamically through the protocol; MLX
traces through it the same way it traces AdamW.
Why we re-implement Sophia/Muon instead of inheriting OptimizerBase
MLXOptimizers.OptimizerBase’s synthesised initializer has internal
access, so cross-module subclasses fail to compile. Sophia and Muon
implement the Optimizer protocol directly, maintaining their own
NestedDictionary<String, PairState> state storage and a
mapValues-driven update(...) that mirrors OptimizerBase.apply().
Open work
-
Muon’s state: our
PairStatehas two MLXArrays per param even though the 2D-Muon path only uses one. Reclaiming that half (customUpdatableenum state) would bring Muon’s memory profile in line with Lion’s. Punted for clarity; the headline “Newton-Schulz update” behaviour is unchanged. -
Sophia-G: the Gauss-Newton Hessian estimator (the paper’s preferred variant) requires an extra forward + per-token logit sampling every
ksteps. Plumbable throughTrainer.stepas a callback; not done here to keep the drop-in story simple. -
Lion LR autotune: Lion’s “right” LR is typically 3-10× smaller than AdamW’s. A helper that scales the user’s
--max-lrwhen--optimizer lionis chosen would smooth the swap, at the cost of hiding paper-recommended behaviour.
Caveats from the 50-step smoke
-
50 steps is too few to rank optimisers on a loss curve. Lion in particular benefits from 2k+ steps. The smoke results here verify correctness (loss decreases, memory measurements are stable), not end-of-training quality.
-
The
huge-preset Muon step rate (5.2 step/s) was measured against GPU contention from another training process (the mega-v2 flagship run on the same machine). Solo numbers will be 1.5-2× higher. -
Adafactor’s step rate appears low at 50 steps because the first few steps trigger MLX trace recompilation more aggressively (the factorised second-moment has different shapes per leaf, and our config sets
relativeStep=falseso the per-step graph is more uniform after warmup). At 200 steps Adafactor reaches 77.8 step/s on tiny — the same neighbourhood as AdamW/Sophia/Lion.