Skip to content

feat(mlx): Make adamw_8bit actually use 8-bit optimizer state - #962

Open
BardiaKoopah wants to merge 2 commits into
unslothai:mainfrom
BardiaKoopah:feat/mlx-quantized-optimizer-state
Open

feat(mlx): Make adamw_8bit actually use 8-bit optimizer state#962
BardiaKoopah wants to merge 2 commits into
unslothai:mainfrom
BardiaKoopah:feat/mlx-quantized-optimizer-state

Conversation

@BardiaKoopah

Copy link
Copy Markdown
Contributor

The silent ignore

trainer.py:745-758 accepts adamw_8bit and rewrites it to plain adamw with no message, so a user asking for an 8-bit optimizer on MLX silently gets full fp32 optimizer state. adam_8bit was not accepted at all.

Delivered

Both names now route to QuantizedMomentAdamW / QuantizedMomentAdam in a new unsloth_zoo/mlx/optimizers_quantized.py, holding Adam's first moment as 8-bit packed arrays.

Optimizer state 1,052,684 → 675,852 B — 35.8% measured on the test model, 35.9% on a wider one. Final loss 0.08318 against an fp32 baseline of 0.08317 on the same seed.

Both classes wrap the stock MLX update, so bias correction and AdamW's decoupled decay are unchanged. Routing prints once, naming what you get: first moment 8-bit (group_size=64), second moment float32, and only 2-D parameters whose last dim is a multiple of the group size.

Documented limitation: the second moment is not quantized

v is a running mean of squared gradients, so it spans orders of magnitude and sits near zero early. MLX's affine mx.quantize reconstructs poorly there, sqrt(v) lands in the update denominator, and the step explodes. Measured, both for int8 m+v and for fp32 m + int8 v:

1.0590 -> 0.8627 -> 19.3755 -> 10.2466 -> nan

Fixing this needs a dynamic/exponential mapping with stochastic rounding — what bitsandbytes implements and MLX does not expose. A guard test asserts v stays a single float32 array so this cannot be silently switched on.

bits is constrained to 8 with a ValueError rather than shipped untested. group_size accepts 32 / 64 / 128, all three exercised (finals 0.75205 / 0.75203 / 0.75202, all monotonic).

Deliberate exclusions

paged_adamw_8bit and adamw_bnb_8bit keep collapsing to adamw. "paged" promises CPU offload and "bnb" names a library MLX does not use, so routing them here would swap one wrong answer for another.

Two test files, on purpose

The numeric tests need real mx.quantize, so a single file would skip entirely on Linux CI and be a gate no-op — the #669/#739 pattern the behavioral-gates step exists to prevent.

  • tests/test_mlx_quantized_optimizer_routing.py runs under the existing torch shim and executes in the gate.
  • tests/test_mlx_quantized_optimizer_state.py holds the behaviour tests and runs on Apple Silicon.

Tested

32 tests. Loss decreases over 8 steps; final loss within 1e-4 of fp32 on the same seed; asserted byte reduction; save → reload → resume reproduces the next step exactly; compiled and uncompiled bit-identical under mx.compile(inputs=state, outputs=state); a model with no quantization-eligible parameter still trains; bits != 8 and invalid group_size rejected.

Three of the routing tests fail against unpatched trainer.pyadamw_8bit resolving to adamw, adam_8bit raising Unsupported MLX optimizer, and adamw_8bit absent from the advertised list.

Full zoo suite run twice per side: totals 2979 → 3011, delta 32 = the tests added. No environment writes. No Dataset.map path is involved, so the forced-fork matrix does not apply here.

One caveat on the suite comparison: tests/test_unsloth_zoo_lora_merge.py and tests/test_moe_fused_narrow_expert_merge.py are nondeterministic independent of this change — the same file run twice on unmodified main gave 14 then 12 failures. Four of their tests appeared to regress on a two-run-per-side comparison and cleared on two additional baseline passes; neither file references MLX or the new module. Two runs per side is not always enough to separate signal from that file's noise.

Overlap

Textual overlap with #819 on trainer.py:56 and the _build_optimizer chain — same author, semantically disjoint (the optimizers #819 adds hold no first moment). Whichever merges second rebases.

`trainer.py:745-758` accepts `adamw_8bit` and rewrites it to plain `adamw` with
no message, so a user asking for an 8-bit optimizer on MLX silently gets full
fp32 optimizer state. `adam_8bit` was not accepted at all.

Delivered. Both names now route to `QuantizedMomentAdamW` / `QuantizedMomentAdam`
in a new `unsloth_zoo/mlx/optimizers_quantized.py`, holding Adam's FIRST moment
as 8-bit packed arrays. Optimizer state 1,052,684 -> 675,852 B: 35.8% measured on
the test model, 35.9% on a wider one. Final loss 0.08318 against an fp32 baseline
of 0.08317 on the same seed. Both classes wrap the stock MLX update, so bias
correction and AdamW's decoupled decay are unchanged. Routing prints once, naming
what you get: first moment 8-bit (group_size=64), second moment float32, and only
2-D parameters whose last dim is a multiple of the group size.

Documented limitation: the second moment is NOT quantized. `v` is a running mean
of squared gradients, so it spans orders of magnitude and sits near zero early.
MLX's affine `mx.quantize` reconstructs poorly there, `sqrt(v)` lands in the
update denominator, and the step explodes -- measured, both for int8 m+v and
fp32 m + int8 v:

    1.0590 -> 0.8627 -> 19.3755 -> 10.2466 -> nan

That needs a dynamic/exponential mapping with stochastic rounding, which
bitsandbytes implements and MLX does not expose. A guard test asserts `v` stays a
single float32 array. `bits` is constrained to 8 with a ValueError rather than
shipped untested; `group_size` accepts 32/64/128, all three exercised.

`paged_adamw_8bit` and `adamw_bnb_8bit` keep collapsing to `adamw` deliberately:
"paged" promises CPU offload and "bnb" names a library MLX does not use, so
routing them here would swap one wrong answer for another.

Two test files on purpose: the numeric tests need real `mx.quantize`, so one file
would skip entirely on Linux CI and be a gate no-op (unslothai#669/unslothai#739). `..._routing.py`
runs under the existing torch shim and executes in the gate; `..._state.py` holds
the behaviour tests and runs on Apple Silicon.

Tested, 32 tests: loss decreases over 8 steps; within 1e-4 of fp32; asserted byte
reduction; save/reload/resume reproduces the next step exactly; compiled and
uncompiled bit-identical under `mx.compile(inputs=state, outputs=state)`; a model
with no eligible parameter still trains. Full suite twice per side, totals
2979 -> 3011.

Textual overlap with unslothai#819 on `trainer.py:56` and the `_build_optimizer` chain --
same author, whichever merges second rebases.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dc36474205

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

# regress silently). test_mlx_quantized_optimizer_routing pins which optim
# names reach the 8-bit path; it runs under the torch shim so it EXECUTES
# here rather than skipping (the numeric half needs real MLX and runs on
# the Apple Silicon job). Executing them here keeps fixture/source drift visible.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run the real-MLX quantized state tests

For PRs/scheduled MLX CI, this new comment says the numeric half runs on Apple Silicon, but .github/workflows/mlx-ci.yml still invokes only the existing fixed MLX test files and never runs tests/test_mlx_quantized_optimizer_state.py; on Linux that file is only collected/skipped because of pytest.importorskip("mlx.core"). As a result, regressions in the new QuantizedMomentAdam(W) update, checkpoint, and compile behavior won't fail CI unless the Apple Silicon job is extended to run that file.

Useful? React with 👍 / 👎.

def apply_single(self, gradient, parameter, state):
packed = state["m"]
# tuple OR list: tree_map/tree_unflatten rebuild the triple as a list.
quantized = isinstance(packed, (tuple, list))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Quantize migrated array moments on resume

When adamw_8bit resumes from a checkpoint written by the old implementation (which collapsed the same setting to plain AdamW) or from a user switching an AdamW checkpoint to the 8-bit optimizer, state['m'] loads as a plain mx.array. Because this flag is derived only from the saved container type, it stays false and line 136 never quantizes the eligible moment, so the run continues with full-precision first-moment state despite the 8-bit optimizer selection; use is_quantizable(parameter) to quantize migrated array moments after the parent update.

Useful? React with 👍 / 👎.

@Lyxot

Lyxot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor
  • LoRA is numerically excellent on every case. Loss MAPE ranges from 0.075% to 0.243%; every endpoint is within 0.77%.
  • MLX FFT tracks closely on Qwen and less closely on Gemma. Qwen3 is 1.04% MAPE, Qwen3.5 is 0.73%, and Gemma is 5.99%. Gemma remains the least-close MLX case at a -4.56% endpoint deviation.
MLX curves Qwen3 CUDA/MLX curves

Same-backend paired differences

Differences are AdamW8bit - AdamW at the same optimizer step. Mean is signed; mean absolute prevents cancellation; max absolute includes its step.

Backend / model / track Loss mean Loss mean abs Loss max abs Grad mean Grad mean abs Grad max abs
MLX Gemma 4 E2B LoRA -0.000607 0.005867 0.020876 (s11) -0.23529 0.41314 9.10919 (s11)
MLX Gemma 4 E2B FFT -0.106615 0.109132 0.520171 (s13) -9.44215 16.51803 289.96713 (s11)
MLX Qwen3.5 2B LoRA -0.000764 0.001286 0.007301 (s4) +0.00484 0.02261 0.27336 (s6)
MLX Qwen3.5 2B FFT -0.006300 0.013791 0.186347 (s13) -0.19373 0.37446 3.35983 (s13)
MLX Qwen3 0.6B LoRA +0.002381 0.002596 0.010839 (s4) +0.01213 0.01690 0.28001 (s4)
MLX Qwen3 0.6B FFT +0.004630 0.015883 0.100675 (s10) -0.00541 0.50009 5.13548 (s9)
CUDA Qwen3 0.6B LoRA +0.000408 0.001228 0.004073 (s12) +0.00089 0.01421 0.08721 (s3)
CUDA Qwen3 0.6B FFT +0.170600 0.171012 1.939212 (s8) +6.68646 6.86979 160.40625 (s8)
Backend / model / track Final loss AdamW → 8-bit Loss MAPE Loss ratio p05 / median / p95 Grad ratio p05 / median / p95
MLX Gemma 4 E2B LoRA 1.5477 → 1.5595 (+0.77%) 0.24% 0.994 / 1.000 / 1.005 0.897 / 1.002 / 1.573
MLX Gemma 4 E2B FFT 1.7860 → 1.7045 (-4.56%) 5.99% 0.823 / 0.953 / 1.001 0.360 / 0.911 / 1.944
MLX Qwen3.5 2B LoRA 0.9227 → 0.9220 (-0.07%) 0.10% 0.996 / 1.000 / 1.002 0.985 / 1.002 / 1.024
MLX Qwen3.5 2B FFT 1.9072 → 1.8962 (-0.58%) 0.73% 0.987 / 0.999 / 1.010 0.906 / 0.999 / 1.032
MLX Qwen3 0.6B LoRA 1.2892 → 1.2914 (+0.17%) 0.15% 1.000 / 1.001 / 1.004 0.991 / 1.000 / 1.016
MLX Qwen3 0.6B FFT 1.3552 → 1.3737 (+1.37%) 1.04% 0.982 / 1.004 / 1.019 0.955 / 0.998 / 1.232
CUDA Qwen3 0.6B LoRA 1.1946 → 1.1940 (-0.05%) 0.07% 0.999 / 1.000 / 1.002 0.986 / 0.999 / 1.019
CUDA Qwen3 0.6B FFT 1.4430 → 1.4536 (+0.73%) 11.03% 0.999 / 1.068 / 1.191 0.986 / 1.135 / 1.619
Paired differences

Memory and wall time

Backend / model / track Train seconds AdamW → 8-bit State GiB AdamW → 8-bit Peak GiB AdamW → 8-bit
MLX Gemma 4 E2B LoRA 185.6 → 171.7 (-7.5%) 0.189 → 0.151 (-20.2%) 8.94 → 9.10 (+1.8%)
MLX Gemma 4 E2B FFT 215.7 → 225.9 (+4.7%) 19.087 → 14.628 (-23.4%) 69.82 → 65.37 (-6.4%)
MLX Qwen3.5 2B LoRA 213.6 → 196.7 (-7.9%) 0.090 → 0.072 (-20.2%) 5.97 → 5.97 (-0.0%)
MLX Qwen3.5 2B FFT 228.6 → 179.4 (-21.5%) 8.246 → 6.315 (-23.4%) 27.35 → 25.40 (-7.1%)
MLX Qwen3 0.6B LoRA 59.0 → 73.4 (+24.4%) 0.075 → 0.060 (-19.6%) 2.79 → 2.70 (-3.3%)
MLX Qwen3 0.6B FFT 94.6 → 86.6 (-8.6%) 2.221 → 1.700 (-23.4%) 8.67 → 8.11 (-6.5%)
CUDA Qwen3 0.6B LoRA 12.9 → 13.8 (+6.9%) 0.075 → 0.019 (-74.6%) 1.44 → 1.39 (-3.9%)
CUDA Qwen3 0.6B FFT 14.9 → 14.9 (+0.3%) 2.220 → 1.993 (-10.3%) 5.57 → 5.16 (-7.4%)
Resource deltas

Training-call time excludes full-dataset formatting/tokenization. These are single paired measurements, so small speed differences are not throughput claims. State bytes are exact recursive array sums; peak allocation is allocator-reported and, on MLX unified memory, is not resident system memory.

Dataset, order, and protocol

  • Dataset: unsloth/alpaca-cleaned@0fe581eb78617869b6e17dd195a3fe4045b3723d, default/train, 51,760 rows.
  • MLX explicitly and effectively used dataset_order="torch_randperm" with seed 3407 over the full dataset. CUDA used HF Trainer RandomSampler with seed=data_seed=3407.
  • MLX used runtime CCE and gradient checkpointing in all 12 runs. CUDA used gradient checkpointing; CUDA CCE remained unavailable because this Unsloth build disables it on Torch 2.11, so the supported fused-CE fallback ran.
  • Shared training configuration: batch 4, accumulation 2, 30 optimizer steps, maximum length 512, bf16 compute, lr 2e-4, cosine schedule, 10 warmup steps, weight decay 0.01, no evaluation or checkpoints.
  • MLX grad norms are true fp32 pre-clip norms of token-normalized gradients. CUDA grad norms are HF Trainer pre-clip global norms with nonbinding max_grad_norm=1e9; raw cross-backend grad values are not equivalent.

No functional change: comment and docstring text only, verified by ast.parse and
the test suite.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants