Add MLX-aware public Unsloth trainer API (#6462)

* feat: add mlx public trainer api

* test: cover mlx public trainer api

* fix: preserve mlx epoch trainer configs

* fix: pass mlx warmup ratio through config

* fix: align mlx trainer dataset order

* fix: keep mlx chat templates import-light

* fix: infer mlx trainer context length

* fix: mirror cuda mlx context defaults

* fix: align mlx notebook trainer defaults

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: keep mlx public helpers import-light

* refactor: reuse mlx optimizer normalization

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: address mlx review feedback

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: tighten mlx training argument parity

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: align mlx trainer eos default

* Fix MLX trainer to accept DataCollatorForSeq2Seq and handle TokenizerWrapper in get_chat_template

* Trim redundant docstrings on internal MLX helpers

* MLX review fixes: Studio optimizer import-safe on non-MLX hosts, preserve explicit max_length, skip MLX tests before import

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* MLX review round 2: defer max_length to model context, optimizer alias fallback for older zoo, skip non-MLX test on missing GPU deps

* MLX review round 3: keep chat_templates importable without torch on MLX

* fix: preserve MLX trainer notebook shims

* fix: ignore CUDA tokenizer moves on MLX

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: harden MLX trainer shims

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: unwrap MLX scheduler enum args

* fix: coerce integral MLX epoch counts

* fix: spoof CUDA compatibility APIs on MLX

* fix: harden MLX notebook compatibility shims

* MLX: add torch.cuda.mem_get_info to the compatibility shim

Notebook memory cells call torch.cuda.mem_get_info()[0] directly (not gated by
is_available), so on MLX it raises without a shim. Return (free, total) bytes
from the MLX device stats, consistent with the other torch.cuda compat helpers,
and add a matching assertion to the compat-API test.

* MLX: use active memory for mem_get_info; fix BatchEncoding.to keyword device

Address review on the MLX compatibility shim:
- torch.cuda.mem_get_info() now derives free bytes from current active MLX
  memory instead of the peak high-water mark, so a capacity check stays
  accurate after a transient spike or a prior run.
- BatchEncoding.to(device=...) passed by keyword no longer forwards a positional
  None alongside the keyword (which raised "multiple values for 'device'"), so
  non-CUDA keyword moves like .to(device="cpu") delegate correctly.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* MLX: accept preserve_dataset_order; stub RL trainers with a clear error

Two fixes so unmigrated notebooks behave predictably on MLX (torch present):

- preserve_dataset_order is a real MLXTrainingConfig field but was missing from
  the extra-argument allowlist, so passing it (as a config or trainer kwarg)
  could be rejected as unknown on a zoo without the field. Add it to
  _MLX_IMPLEMENTED_EXTRA_ARGUMENTS so the documented no-shuffle path is reachable.

- GRPO/DPO/ORPO (and KTO/PPO/Reward) have no MLX trainer yet. Retarget the ones
  the installed trl exposes to a stub that raises a clear 'not supported on MLX'
  error instead of importing the real torch/CUDA trainer and crashing deep
  inside it. Only existing trainers are retargeted (no invented attributes),
  idempotent across re-imports.

* MLX: make RL-trainer stubbing import-safe; back current-memory APIs with active memory

Address review on the MLX shims:
- The RL-trainer stub loop probed trl with getattr(_trl, name), which triggers
  trl's lazy trainer import and pulls torch -- that can crash import unsloth on a
  torch-free MLX install just to check existence. Decide what to stub from
  trl.__all__ + already-materialized attrs (vars) instead; never resolve the real
  trainer. All trl trainer names are in __all__, so they are still stubbed (even
  torch-free), and the probe no longer imports torch.
- torch.cuda.memory_reserved / memory_allocated (the current, non-max APIs) were
  aliased to peak max_memory_reserved. Back them with current active MLX memory so
  cleanup / capacity checks see live usage; max_* keep the peak high-water mark.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* MLX: keep TRL's SFTConfig epoch default under the trl.SFTConfig alias

Unmigrated notebooks import SFTConfig from trl, which the MLX build aliases to
the public training-args class. TRL/HF SFTConfig defaults to num_train_epochs=3
(max_steps=-1); the native MLX config defaults to max_steps=60. So an SFTConfig
built without an explicit length silently ran 60 MLX steps instead of TRL's 3
epochs under the alias. Alias trl.SFTConfig to a thin subclass that seeds the
TRL epoch default only when neither max_steps nor num_train_epochs is given;
explicit lengths pass through untouched, and the native public args class keeps
its MLX default. Epoch mode is supported by the MLX trainer.

* MLX CI: keep the GGUF reload smoke under the job timeout

The RELOAD-GGUF-via-llama-cli step timed out at 300s. BF16 GGUF decode is
CPU-bound on the macOS runner (~10s+/token), so generating 24 tokens landed
right on the 300s cliff and killed the process. This step is a save/reload
integrity smoke (it only needs a few chars of output), so the token count is
incidental: generate 8 tokens with explicit threads and a small headroom on the
subprocess timeout, all env-tunable (UNSLOTH_GGUF_RELOAD_N / _THREADS /
_TIMEOUT). Cuts the reload well under the 25 minute job budget.

* MLX: broaden trainer stubs, real peak-memory reset, fix shim tests

Address review on the MLX public API:
- The SFTConfig identity tests asserted trl.SFTConfig is UnslothTrainingArguments,
  but the alias now points at the _MLXSFTConfig subclass that preserves TRL's
  epoch default, so the MLX suite failed before testing the shim. Assert
  issubclass instead.
- torch.cuda.reset_peak_memory_stats was a no-op, so max_memory_reserved kept
  earlier model-load peaks across a scoped run. Wire it to mx.reset_peak_memory
  with the same core/metal fallback used for the reads.
- The unsupported-trainer stubs were a fixed list, so trainers outside it (a
  newer RLOOTrainer) still routed to the real torch trainer. Derive the set from
  trl.__all__ (every non-SFT *Trainer) so all non-SFT surfaces fail with a clear
  MLX message; names come from __all__ so trl is never resolved.
- The non-MLX export smoke skipped only on missing bitsandbytes/triton; other
  absent GPU deps (numpy/torch/unsloth-zoo, or _gpu_init re-raising ImportError)
  made it fail on CPU hosts. Skip on any ImportError.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: keep MLX notebook compatibility minimal

* MLX CI: force CPU + small context for the GGUF reload smoke

The RELOAD-GGUF-via-llama-cli step timed out even at 8 tokens (>420s), so it is a
fixed hang, not per-token cost: on the paravirtual macOS runner GPU llama.cpp's
Metal backend stalls, and the gemma3 GGUF advertises a 32768 context that llama-cli
would otherwise fully allocate. Run llama-cli CPU-only (-ngl 0) with a small context
(-c 256); keep generation short. All env-tunable (UNSLOTH_GGUF_RELOAD_NGL / _CTX /
_N / _THREADS / _TIMEOUT). Also print llama.cpp's partial stdout/stderr on timeout so
a future hang is diagnosable instead of an opaque TimeoutExpired.

* MLX CI: export the reload-smoke GGUF as q8_0, not bf16

The GGUF reload via llama-cli timed out on the runner even CPU-only with a tiny
context and 8 tokens. Root cause is the format, not the flags: the smoke exported
quantization_method='not_quantized', which maps to a bf16 GGUF, and llama.cpp's
bf16 CPU decode is unusably slow on the paravirtual macOS runner. Export q8_0
(fast_quantized, the exporter default and what users deploy) instead -- llama.cpp
has optimized q8_0 CPU kernels, so the fresh-process reload loads and generates in
seconds. The reload stays CPU-only (-ngl 0) with a small context.

* test: clear TRL shim before availability check

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: imagineer99 <samleejackson0@gmail.com>
This commit is contained in:
Long Yixing 2026-07-03 06:02:26 +08:00 committed by GitHub
commit d918245834
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 2595 additions and 62 deletions

View file

@ -1253,32 +1253,48 @@ def _adapt_for_mlx_vlm(
return adapted
_MLX_STUDIO_OPTIM_MAP = {
"adamw_8bit": "adamw",
"paged_adamw_8bit": "adamw",
"adamw_bnb_8bit": "adamw",
"paged_adamw_32bit": "adamw",
"adamw_torch": "adamw",
"adamw_torch_fused": "adamw",
"adamw": "adamw",
"adafactor": "adafactor",
"sgd": "sgd",
"adam": "adam",
"muon": "muon",
"lion": "lion",
}
_MLX_STUDIO_LR_SCHEDULERS = {"linear", "cosine", "constant"}
# Fallback alias map mirroring unsloth_zoo._normalize_mlx_optimizer_name, used
# only when mlx (Apple Silicon) is not importable so Studio config validation
# still works on non-MLX hosts. The zoo function stays the source of truth.
_MLX_STUDIO_ADAMW_ALIASES = frozenset(
(
"adamw_8bit",
"paged_adamw_8bit",
"adamw_bnb_8bit",
"paged_adamw_32bit",
"adamw_torch",
"adamw_torch_fused",
"paged_adamw",
"adamw_32bit",
"adamw_hf",
"adamw_anyprecision",
"adamw_apex_fused",
)
)
_MLX_STUDIO_NATIVE_OPTIMIZERS = ("adafactor", "adamw", "adam", "sgd", "muon", "lion")
def _normalize_mlx_studio_optimizer(value):
raw = str(value or "adamw_8bit").strip().lower()
try:
return _MLX_STUDIO_OPTIM_MAP[raw]
except KeyError:
supported = ", ".join(sorted(_MLX_STUDIO_OPTIM_MAP))
raise ValueError(
f"Unsupported optimizer for MLX training: {value!r}. " f"Supported values: {supported}."
)
from unsloth_zoo.mlx.trainer import _normalize_mlx_optimizer_name
return _normalize_mlx_optimizer_name(value or "adamw_8bit")
except (ImportError, ValueError):
# Missing mlx, or an older unsloth-zoo whose normalizer lacks CUDA/TRL
# aliases: map common adamw_* names locally so notebook defaults work.
opt = str(getattr(value, "value", value) or "adamw_8bit").strip().lower()
opt = opt.rsplit(".", 1)[-1].replace("-", "_")
if opt in _MLX_STUDIO_ADAMW_ALIASES:
opt = "adamw"
if opt not in _MLX_STUDIO_NATIVE_OPTIMIZERS:
supported = ", ".join(_MLX_STUDIO_NATIVE_OPTIMIZERS)
raise ValueError(
f"Unsupported optimizer for MLX training: {value!r}. "
f"Supported optimizers: {supported}."
)
return opt
def _normalize_mlx_studio_scheduler(value):

View file

@ -76,7 +76,7 @@ def test_mlx_studio_optimizer_aliases_are_explicit():
def test_mlx_studio_rejects_unknown_optimizer():
with pytest.raises(ValueError, match = "Unsupported optimizer for MLX training"):
with pytest.raises(ValueError, match = "Supported"):
_normalize_mlx_studio_optimizer("adamw_typo")

File diff suppressed because it is too large Load diff

View file

@ -403,10 +403,14 @@ def cmd_train(args) -> int:
metrics["gguf_dir"] = str(gguf_dir)
with Phase("save_gguf", metrics):
try:
# q8_0 (the exporter default), not bf16: llama.cpp has optimized q8_0
# CPU kernels, whereas bf16 CPU decode is unusably slow on the runner
# and made the fresh-process llama-cli reload below time out. q8_0 is
# also what users deploy by default.
model.save_pretrained_gguf(
str(gguf_dir),
tokenizer = tokenizer,
quantization_method = "not_quantized",
quantization_method = "fast_quantized",
)
gguf_files = sorted(gguf_dir.glob("*.gguf"))
if not gguf_files:
@ -565,31 +569,36 @@ def _reload_gguf(save_dir: Path, metrics: dict) -> int:
raise SystemExit(f"no .gguf files in {save_dir}")
gguf_path = gguf_files[0]
# This is a save/reload-integrity smoke; a few generated tokens are enough.
# Keep llama.cpp bounded on macOS runners where BF16 GGUF decode is CPU-bound.
# Save/reload-integrity smoke (assert below only needs a few chars). The GGUF is
# exported q8_0 (see save_gguf) because llama.cpp bf16 CPU decode is unusably slow
# on the runner. Run CPU-only (-ngl 0), cap the context (-c 256, the model
# advertises 32768), and keep generation short; all env-tunable.
n_predict = os.environ.get("UNSLOTH_GGUF_RELOAD_N", "8")
n_threads = os.environ.get("UNSLOTH_GGUF_RELOAD_THREADS", str(os.cpu_count() or 4))
n_ctx = os.environ.get("UNSLOTH_GGUF_RELOAD_CTX", "256")
n_gpu_layers = os.environ.get("UNSLOTH_GGUF_RELOAD_NGL", "0")
reload_timeout = int(os.environ.get("UNSLOTH_GGUF_RELOAD_TIMEOUT", "420"))
argv = [
str(llama_cli),
"-m",
str(gguf_path),
"-p",
PROMPT,
"-n",
n_predict,
"-t",
n_threads,
"-c",
n_ctx,
"-ngl",
n_gpu_layers,
"--temp",
"0",
"--seed",
str(SEED),
"--no-warmup",
]
with Phase("reload_gguf", metrics):
argv = [
str(llama_cli),
"-m",
str(gguf_path),
"-p",
PROMPT,
"-n",
n_predict,
"-t",
n_threads,
"--temp",
"0",
"--seed",
str(SEED),
"-c",
"256",
"--no-warmup",
]
try:
proc = subprocess.run(
argv,
@ -606,6 +615,7 @@ def _reload_gguf(save_dir: Path, metrics: dict) -> int:
return stream.decode("utf-8", errors = "replace")
return stream or ""
print(f" [reload:gguf] TIMEOUT running: {' '.join(argv)}", flush = True)
print(f" [reload:gguf] TIMEOUT stdout:\n{_decode(exc.stdout)[:1000]}", flush = True)
print(f" [reload:gguf] TIMEOUT stderr:\n{_decode(exc.stderr)[:1000]}", flush = True)
raise

File diff suppressed because it is too large Load diff

View file

@ -27,18 +27,25 @@ __all__ = [
"test_construct_chat_template",
]
from transformers import StoppingCriteria, StoppingCriteriaList
from torch import LongTensor, FloatTensor
from transformers.models.llama.modeling_llama import logger
from transformers.utils import logging
try:
from torch import LongTensor, FloatTensor
except ImportError:
LongTensor = FloatTensor = None
logger = logging.get_logger(__name__)
import os
import shutil
from .tokenizer_utils import *
import re
from .ollama_template_mappers import OLLAMA_TEMPLATES
from unsloth_zoo.dataset_utils import (
train_on_responses_only,
standardize_data_formats,
)
try:
from unsloth_zoo.dataset_utils import (
train_on_responses_only,
standardize_data_formats,
)
except ImportError:
# dataset_utils pulls torch; keep chat_templates importable on torch-free
# (MLX) hosts, which expose these via the backend-specific wrappers instead.
train_on_responses_only = standardize_data_formats = None
standardize_sharegpt = standardize_data_formats
CHAT_TEMPLATES = {}
DEFAULT_SYSTEM_MESSAGE = {}
@ -1838,11 +1845,24 @@ def get_chat_template(
map_eos_token = True,
system_message = None,
patch_saving = True,
use_zoo_tokenizer_patch = False,
use_zoo_tokenizer_patch = None,
):
assert(type(map_eos_token) is bool)
import sys
is_mlx_backend = getattr(sys.modules.get("unsloth"), "DEVICE_TYPE", None) == "mlx"
if use_zoo_tokenizer_patch is None:
use_zoo_tokenizer_patch = is_mlx_backend
old_tokenizer = tokenizer
# mlx-lm's TokenizerWrapper._tokenizer is the HF tokenizer, not the Rust
# backend the vocab-edit paths below need; unwrap here, re-wrap before return.
_mlx_tokenizer_wrapper = None
if is_mlx_backend and tokenizer.__class__.__name__ == "TokenizerWrapper":
_inner_tokenizer = getattr(tokenizer, "_tokenizer", None)
if _inner_tokenizer is not None and hasattr(_inner_tokenizer, "is_fast"):
_mlx_tokenizer_wrapper = tokenizer
tokenizer = _inner_tokenizer
IS_GEMMA = False
if tokenizer.__class__.__name__.startswith("Gemma"):
if chat_template == "chatml": chat_template = "gemma_chatml"
@ -1952,6 +1972,7 @@ def get_chat_template(
pass
# Must fix the sentence piece tokenizer since there's no tokenizer.model file!
from .tokenizer_utils import fix_sentencepiece_tokenizer
tokenizer = fix_sentencepiece_tokenizer(tokenizer, new_tokenizer, token_mapping,)
else:
pass
@ -1997,6 +2018,7 @@ def get_chat_template(
# Must fix the sentence piece tokenizer since there's no tokenizer.model file!
token_mapping = { old_eos_token : stop_word, }
from .tokenizer_utils import fix_sentencepiece_tokenizer
tokenizer = fix_sentencepiece_tokenizer(tokenizer, new_tokenizer, token_mapping,)
pass
@ -2057,13 +2079,25 @@ def get_chat_template(
# stopping_criteria = create_stopping_criteria(tokenizer, stop_word)
# Patch saving functions
if patch_saving:
if patch_saving and not is_mlx_backend:
from .save import patch_saving_functions
tokenizer = patch_saving_functions(tokenizer)
# Add Ollama
tokenizer._ollama_modelfile = ollama_modelfile
tokenizer._system_message = system_message
# Re-wrap so the trainer gets the same TokenizerWrapper type back.
if _mlx_tokenizer_wrapper is not None:
_mlx_tokenizer_wrapper._tokenizer = tokenizer
eos_token_id = getattr(tokenizer, "eos_token_id", None)
if eos_token_id is not None:
_mlx_tokenizer_wrapper._eos_token_ids = {eos_token_id}
_mlx_tokenizer_wrapper._chat_template = None
_mlx_tokenizer_wrapper.has_chat_template = (
getattr(tokenizer, "chat_template", None) is not None
)
tokenizer = _mlx_tokenizer_wrapper
return tokenizer#, stopping_criteria
@ -2749,6 +2783,15 @@ extra_eos_tokens = None,
def create_stopping_criteria(tokenizer, stop_word = "eos_token"):
try:
import torch
from transformers import StoppingCriteria, StoppingCriteriaList
except ImportError as exc:
raise ImportError(
"Unsloth: create_stopping_criteria requires PyTorch and is only "
"supported on Torch backends."
) from exc
class StoppingCriteriaSub(StoppingCriteria):
__slots__ = "stop_token", "single_match", "length",
@ -2828,10 +2871,10 @@ def test_chat_templates():
for j in range(len(messages)-1):
correct_prompt.append_message(correct_prompt.roles[j%2==1], messages[j+1]["content"])
correct_prompt.append_message(correct_prompt.roles[1], "")
correct_prompt = tokenizer.bos_token + correct_prompt.get_prompt()
template = vicuna_template
correct_tokenizer = AutoTokenizer.from_pretrained("lmsys/vicuna-7b-v1.5")
correct_prompt = correct_tokenizer.bos_token + correct_prompt.get_prompt()
correct_tokenizer.chat_template = template
our_prompt = correct_tokenizer.apply_chat_template(messages[1:], tokenize = False, add_generation_prompt = True)
assert(correct_prompt == our_prompt)
@ -2845,10 +2888,10 @@ def test_chat_templates():
for j in range(len(messages)-1):
correct_prompt.append_message(correct_prompt.roles[j%2==1], messages[j+1]["content"])
correct_prompt.append_message(correct_prompt.roles[1], "")
correct_prompt = tokenizer.bos_token + correct_prompt.get_prompt()
template = vicuna_old_template
correct_tokenizer = AutoTokenizer.from_pretrained("lmsys/vicuna-7b-v1.5")
correct_prompt = correct_tokenizer.bos_token + correct_prompt.get_prompt()
correct_tokenizer.chat_template = template
our_prompt = correct_tokenizer.apply_chat_template(messages[1:], tokenize = False, add_generation_prompt = True)
# We add </s> ourselves