← TinyGPT · docs · devlog · roadmap · speedup
source: docs/optimizers.md · view on GitHub ↗

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)

OptimizerLRstep 1 lossstep 50 lossstep/speak MBactive MB
AdamW3e-46.3453.04646.2160.610.1
Lion3e-55.5283.71425.4153.36.7
Sophia3e-46.2172.92730.9161.210.1
Muon2e-35.9253.45018.2163.510.1
Adafactor3e-46.1853.02912.1148.63.4

Small preset (6L · d=192 · ctx=256 · batch=4 · 3.2M params)

Optimizerstep 1 lossstep 50 lossstep/speak MBactive MB
AdamW5.8612.77930.8404.033.2
Lion5.8773.34332.0392.922.2
Sophia5.7572.78530.6404.033.2
Muon6.1023.13918.8404.033.2
Adafactor6.4482.89413.1382.011.2

Huge preset (12L · d=512 · ctx=512 · batch=2 · ~38M params)

Optimizerstep 1 lossstep 50 lossstep/speak MBactive MB
AdamW5.9052.82016.3523.6115.3
Lion6.3323.02416.3485.276.9
Sophia6.0763.05811.5523.6115.3
Muon5.9143.0375.2523.6115.3
Adafactor5.9823.0337.8447.138.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:

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

Use Lion when

Use Sophia when

Use Muon when

Use Adafactor when

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

Caveats from the 50-step smoke