Skip to content
posttrainllm docs
Esc
navigateopen⌘Jpreview
On this page

Optimizers

Optimizers

posttrainllm ships five optimizers, selectable on posttrainllm 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 posttrainllm -destination "platform=macOS" \
  -derivedDataPath /tmp/posttrainllm-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 huge preset 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 adafactor with --dtype bfloat16 for 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 PairState has two MLXArrays per param even though the 2D-Muon path only uses one. Reclaiming that half (custom Updatable enum 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 k steps. Plumbable through Trainer.step as 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-lr when --optimizer lion is 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=false so 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.

Was this page helpful?