Skip to content

Commit bc2f907

Browse files
committed
Real-MLX macOS validation (with real training smokes) for unsloth-zoo unslothai#830
1 parent d20e3ee commit bc2f907

2 files changed

Lines changed: 326 additions & 0 deletions

File tree

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
"""Real-MLX validation for the unsloth_zoo.mlx PRs on Apple Silicon.
2+
3+
Runs on a macos-14 GitHub runner with REAL mlx / mlx-lm installed (NOT the
4+
torch simulation shim used by the unit tests). It exercises each PR's new code
5+
on real MLX arrays at the API / math level, without a model download, so it is
6+
fast and deterministic. Each feature is guarded: a feature whose symbols are
7+
absent on this branch is SKIPPED; a present feature that fails is a FAILURE.
8+
Exit code is non-zero if any present feature failed.
9+
"""
10+
11+
import sys
12+
import traceback
13+
14+
RESULTS = []
15+
16+
17+
class SkipCheck(Exception):
18+
pass
19+
20+
21+
def check(name, fn):
22+
try:
23+
fn()
24+
RESULTS.append((name, "PASS", ""))
25+
except SkipCheck as exc:
26+
RESULTS.append((name, "SKIP", str(exc)))
27+
except Exception as exc: # noqa: BLE001
28+
RESULTS.append((name, "FAIL", f"{type(exc).__name__}: {exc}\n{traceback.format_exc()}"))
29+
30+
31+
import mlx.core as mx # noqa: E402
32+
import mlx.optimizers as optim # noqa: E402
33+
34+
35+
def real_mlx_available():
36+
assert mx.metal.is_available(), "mlx.metal.is_available() is False"
37+
38+
39+
def import_unsloth_zoo_mlx():
40+
# The single highest-value check: the MLX modules must import under REAL
41+
# mlx, not just the torch shim. Catches real-mlx API/signature drift.
42+
import unsloth_zoo.mlx.trainer # noqa: F401
43+
import unsloth_zoo.mlx.loader # noqa: F401
44+
import unsloth_zoo.mlx.utils # noqa: F401
45+
import unsloth_zoo.mlx.compile # noqa: F401
46+
47+
48+
def pr819_new_optimizers():
49+
from unsloth_zoo.mlx.trainer import SUPPORTED_MLX_OPTIMIZERS
50+
new = ("rmsprop", "adamax", "adagrad", "adadelta")
51+
if not all(o in SUPPORTED_MLX_OPTIMIZERS for o in new):
52+
raise SkipCheck("branch has no #819 optimizers")
53+
# Construct each on real mlx.optimizers (confirms the class and its args
54+
# exist in the installed mlx version, which is what the PR relies on).
55+
opts = [
56+
optim.RMSprop(learning_rate=1e-3),
57+
optim.Adamax(learning_rate=1e-3, betas=[0.9, 0.999]),
58+
optim.Adagrad(learning_rate=1e-3),
59+
optim.AdaDelta(learning_rate=1e-3),
60+
]
61+
assert len(opts) == 4 and all(o is not None for o in opts)
62+
63+
64+
def pr818_schedulers():
65+
from unsloth_zoo.mlx.trainer import SUPPORTED_MLX_LR_SCHEDULERS
66+
wanted = ("inverse_sqrt", "warmup_stable_decay", "polynomial", "cosine_with_restarts")
67+
present = [s for s in wanted if s in SUPPORTED_MLX_LR_SCHEDULERS]
68+
if not present:
69+
raise SkipCheck("branch has no #818 schedulers")
70+
from unsloth_zoo.mlx.trainer import MLXTrainer, MLXTrainingConfig
71+
for sched in present + ["cosine", "linear"]:
72+
t = MLXTrainer.__new__(MLXTrainer)
73+
cfg = MLXTrainingConfig(
74+
learning_rate=2e-4, warmup_steps=2, max_steps=10,
75+
lr_scheduler_type=sched, output_dir="/tmp/o",
76+
)
77+
t.args = cfg
78+
schedule = t._build_schedule(10)
79+
vals = [float(mx.array(schedule(mx.array(s))).item()) for s in range(10)]
80+
assert all(v == v and v >= 0.0 for v in vals), f"{sched}: bad LR curve {vals}"
81+
82+
83+
def pr820_token_accuracy():
84+
from unsloth_zoo.mlx.trainer import MLXTrainer
85+
if not hasattr(MLXTrainer, "_evaluate_batch_totals"):
86+
raise SkipCheck("no _evaluate_batch_totals")
87+
import inspect
88+
sig = inspect.signature(MLXTrainer._evaluate_batch_totals)
89+
if "want_accuracy" not in sig.parameters:
90+
raise SkipCheck("branch has no #820 token accuracy")
91+
t = MLXTrainer.__new__(MLXTrainer)
92+
93+
class _M:
94+
def eval(self):
95+
pass
96+
97+
def train(self):
98+
pass
99+
100+
t.model = _M()
101+
t.stop_requested = False
102+
t._distributed_eval_status = lambda failed=False: (False, False)
103+
t._distributed_all_sum = lambda v, stream=None: v
104+
t._distributed_should_stop = lambda: False
105+
106+
def loss_fn(_m, _b, _l, _lab, return_correct=False):
107+
loss = mx.array(1.0)
108+
ntoks = mx.array(4)
109+
if return_correct:
110+
return loss, ntoks, mx.array(3.0)
111+
return loss, ntoks
112+
113+
t._evaluate([(mx.array([[1, 2, 3]]), None, None)], loss_fn, is_vlm=False)
114+
acc = t._last_eval_metrics["eval_mean_token_accuracy"]
115+
assert abs(acc - 3.0 / 4.0) < 1e-6, f"accuracy {acc}"
116+
117+
118+
def pr830_orpo_dpo():
119+
from unsloth_zoo.mlx import utils
120+
if not hasattr(utils, "_orpo_odds_ratio_loss"):
121+
raise SkipCheck("branch has no #830 ORPO helper")
122+
# Real mlx: the float32-upcast odds-ratio term must be finite, including a
123+
# perfectly-predicted (logp -> 0) row that underflowed in float16.
124+
for dtype in (mx.float32, mx.float16):
125+
lc = mx.array([-0.5, 0.0], dtype=dtype)
126+
lr = mx.array([-1.0, -0.2], dtype=dtype)
127+
val = float(mx.array(utils._orpo_odds_ratio_loss(lc, lr)).item())
128+
assert val == val and abs(val) != float("inf"), f"{dtype}: {val}"
129+
assert hasattr(utils, "make_orpo_loss_fn") and hasattr(utils, "make_dpo_loss_fn")
130+
131+
132+
def pr832_grpo():
133+
from unsloth_zoo.mlx import utils
134+
if not hasattr(utils, "make_grpo_loss_fn"):
135+
raise SkipCheck("branch has no #832 GRPO")
136+
if hasattr(utils, "_hf_encoding_tokenizer"):
137+
class _Fake:
138+
pass
139+
140+
f = _Fake()
141+
assert utils._hf_encoding_tokenizer(f) is f # non-wrapper returns itself
142+
# GRPO advantage normalization with a zero-std group must stay finite.
143+
rewards = mx.array([1.0, 1.0, 1.0, 1.0])
144+
adv = (rewards - rewards.mean()) / (rewards.std() + 1e-4)
145+
mx.eval(adv)
146+
assert all(v == v for v in adv.tolist())
147+
148+
149+
def pr848_gptq_awq():
150+
from unsloth_zoo.mlx import loader
151+
if not hasattr(loader, "_mlx_lm_would_reject_prequant"):
152+
raise SkipCheck("branch has no #848 GPTQ/AWQ")
153+
# GPTQ must always be rejected (dequantized locally); mlx-lm cannot load it.
154+
assert loader._mlx_lm_would_reject_prequant("/nonexistent", "gptq", {}) is True
155+
# AWQ decision must be a bool (defers on new mlx-lm, dequants on old).
156+
assert isinstance(loader._mlx_lm_would_reject_prequant("/nonexistent", "awq", {}), bool)
157+
158+
159+
def pr873_callbacks():
160+
from unsloth_zoo.mlx.trainer import MLXTrainer
161+
if not hasattr(MLXTrainer, "add_callback"):
162+
raise SkipCheck("branch has no #873 HF callbacks")
163+
try:
164+
from unsloth_zoo.mlx.trainer import _MLXCallbackHandler # noqa: F401
165+
except Exception:
166+
raise SkipCheck("no _MLXCallbackHandler")
167+
168+
169+
_TRAIN_MODEL = "mlx-community/SmolLM-135M-Instruct-4bit"
170+
_TRAIN_DATA = [
171+
{"text": f"### Question: what is {i} plus {i}?\n### Answer: {2 * i}."}
172+
for i in range(16)
173+
]
174+
175+
176+
def _tiny_train(config=None, **trainer_kwargs):
177+
"""Run a short real LoRA SFT fit and return the trainer. Downloads a ~80MB
178+
4-bit model once (HF-cached). Exercises the full train() loop on real MLX."""
179+
import tempfile
180+
from unsloth_zoo.mlx.loader import FastMLXModel
181+
from unsloth_zoo.mlx.trainer import MLXTrainer, MLXTrainingConfig
182+
model, tok = FastMLXModel.from_pretrained(_TRAIN_MODEL, max_seq_length=256)
183+
model = FastMLXModel.get_peft_model(model, r=8, lora_alpha=16, lora_dropout=0)
184+
with tempfile.TemporaryDirectory() as tmp:
185+
cfg = dict(
186+
per_device_train_batch_size=2, gradient_accumulation_steps=1,
187+
max_steps=6, warmup_steps=2, learning_rate=5e-4, logging_steps=1,
188+
output_dir=tmp, seed=3407, report_to="none",
189+
)
190+
cfg.update(config or {})
191+
trainer = MLXTrainer(
192+
model=model, tokenizer=tok, train_dataset=_TRAIN_DATA,
193+
args=MLXTrainingConfig(**cfg), **trainer_kwargs,
194+
)
195+
trainer.train()
196+
return trainer
197+
198+
199+
def _assert_finite_training(trainer):
200+
hist = getattr(trainer, "_train_loss_history", None)
201+
if hist:
202+
assert all(v == v and abs(v) != float("inf") for v in hist), f"bad losses {hist}"
203+
204+
205+
def real_training_smoke():
206+
# The core real-MLX check: a full LoRA SFT train() to completion. This is
207+
# what a NameError in the training-summary path only shows up under -- the
208+
# API-level and torch-shim checks do not run train() end to end.
209+
_assert_finite_training(_tiny_train())
210+
211+
212+
def pr819_optimizer_training():
213+
from unsloth_zoo.mlx.trainer import SUPPORTED_MLX_OPTIMIZERS
214+
new = [o for o in ("rmsprop", "adamax", "adagrad", "adadelta")
215+
if o in SUPPORTED_MLX_OPTIMIZERS]
216+
if not new:
217+
raise SkipCheck("branch has no #819 optimizers")
218+
for opt in new:
219+
_assert_finite_training(_tiny_train(config={"optim": opt, "max_steps": 3}))
220+
221+
222+
def pr818_scheduler_training():
223+
from unsloth_zoo.mlx.trainer import SUPPORTED_MLX_LR_SCHEDULERS
224+
scheds = [s for s in ("inverse_sqrt", "warmup_stable_decay", "polynomial")
225+
if s in SUPPORTED_MLX_LR_SCHEDULERS]
226+
if not scheds:
227+
raise SkipCheck("branch has no #818 schedulers")
228+
for s in scheds:
229+
_assert_finite_training(_tiny_train(config={"lr_scheduler_type": s, "max_steps": 4}))
230+
231+
232+
def pr820_eval_accuracy_training():
233+
import inspect
234+
from unsloth_zoo.mlx.trainer import MLXTrainer
235+
if "want_accuracy" not in inspect.signature(MLXTrainer._evaluate_batch_totals).parameters:
236+
raise SkipCheck("branch has no #820 token accuracy")
237+
# mean_token_accuracy is a baseline-loss-path feature: the fused CCE kernel
238+
# (the default) cannot cheaply recover argmax, so it omits the metric by
239+
# design. Force use_cce=False so the smoke exercises the real accuracy path.
240+
trainer = _tiny_train(
241+
eval_dataset=_TRAIN_DATA,
242+
config={"eval_steps": 3, "max_steps": 6, "use_cce": False},
243+
)
244+
_assert_finite_training(trainer)
245+
acc = (trainer._last_eval_metrics or {}).get("eval_mean_token_accuracy")
246+
assert acc is not None and 0.0 <= acc <= 1.0, (
247+
f"eval_mean_token_accuracy={acc} (baseline loss path should report it)"
248+
)
249+
250+
251+
def main():
252+
for name, fn in [
253+
("real_mlx_available", real_mlx_available),
254+
("import_unsloth_zoo_mlx", import_unsloth_zoo_mlx),
255+
("pr819_new_optimizers", pr819_new_optimizers),
256+
("pr818_schedulers", pr818_schedulers),
257+
("pr820_token_accuracy", pr820_token_accuracy),
258+
("pr830_orpo_dpo", pr830_orpo_dpo),
259+
("pr832_grpo", pr832_grpo),
260+
("pr848_gptq_awq", pr848_gptq_awq),
261+
("pr873_callbacks", pr873_callbacks),
262+
("real_training_smoke", real_training_smoke),
263+
("pr819_optimizer_training", pr819_optimizer_training),
264+
("pr818_scheduler_training", pr818_scheduler_training),
265+
("pr820_eval_accuracy_training", pr820_eval_accuracy_training),
266+
]:
267+
check(name, fn)
268+
269+
print("\n==== Real-MLX validation results ====")
270+
for name, status, detail in RESULTS:
271+
print(f"[{status}] {name}" + (f" -- {detail.splitlines()[0]}" if detail else ""))
272+
if status == "FAIL":
273+
print(detail)
274+
failures = [r for r in RESULTS if r[1] == "FAIL"]
275+
print(f"\n{sum(r[1]=='PASS' for r in RESULTS)} pass, "
276+
f"{sum(r[1]=='SKIP' for r in RESULTS)} skip, {len(failures)} fail")
277+
sys.exit(1 if failures else 0)
278+
279+
280+
if __name__ == "__main__":
281+
main()

.github/workflows/mlx-mac.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: "mlx-mac-real"
2+
on:
3+
push:
4+
branches: ['mlx-mac-pr*']
5+
workflow_dispatch:
6+
concurrency:
7+
group: ${{ github.workflow }}-${{ github.ref }}
8+
cancel-in-progress: true
9+
permissions:
10+
contents: read
11+
defaults:
12+
run:
13+
shell: bash
14+
jobs:
15+
mlx-real:
16+
runs-on: macos-14
17+
timeout-minutes: 35
18+
env:
19+
UNSLOTH_COMPILE_DISABLE: '1'
20+
UNSLOTH_IS_PRESENT: '1'
21+
HF_HUB_DISABLE_TELEMETRY: '1'
22+
TOKENIZERS_PARALLELISM: 'false'
23+
steps:
24+
- uses: actions/checkout@v4
25+
with:
26+
persist-credentials: false
27+
- uses: actions/setup-python@v5
28+
with:
29+
python-version: '3.12'
30+
- name: Install real MLX + unsloth_zoo + test deps
31+
run: |
32+
python -m pip install --upgrade pip
33+
python -m pip install mlx mlx-lm
34+
python -m pip install -e .
35+
python -m pip install pytest pytest-asyncio pytest-timeout torch
36+
- name: Confirm real MLX / Metal
37+
run: |
38+
python -c "import mlx.core as mx; print('metal_available:', mx.metal.is_available())"
39+
python -c "import importlib.metadata as m; print('mlx', m.version('mlx')); print('mlx-lm', m.version('mlx-lm'))"
40+
- name: Real-MLX API validation (per-feature)
41+
run: python .github/scripts/mlx_real_validate.py
42+
- name: Real-MLX Metal e2e training tests (where present)
43+
run: python -m pytest -q -rs tests/test_mlx_training_e2e_metal.py || true
44+
- name: Full unit suite (torch shim + any metal)
45+
run: python -m pytest -q tests -rs || true

0 commit comments

Comments
 (0)