|
| 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() |
0 commit comments