e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
1463 lines
59 KiB
Python
1463 lines
59 KiB
Python
# Copyright 2023-present Daniel Han-Chen & the Unsloth team. All rights reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import os, importlib.util, platform
|
|
|
|
os.environ["UNSLOTH_IS_PRESENT"] = "1"
|
|
|
|
# ── Windows console UTF-8 safety ─────────────────────────────────────────────
|
|
# Legacy Windows consoles (cp1252) can't encode Unsloth's emoji/box-drawing
|
|
# glyphs and crash with UnicodeEncodeError. Force stdout/stderr to UTF-8 only on
|
|
# Windows and only when not already UTF-8; no-op elsewhere. errors="replace"
|
|
# guarantees we never crash on an unencodable glyph.
|
|
if platform.system() == "Windows":
|
|
import sys as _sys
|
|
for _name in ("stdout", "stderr"):
|
|
_s = getattr(_sys, _name, None)
|
|
try:
|
|
_enc = (getattr(_s, "encoding", None) or "").lower()
|
|
if _s is not None and hasattr(_s, "reconfigure") and "utf" not in _enc:
|
|
_s.reconfigure(encoding = "utf-8", errors = "replace")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class _UnslothDeviceStats:
|
|
"""Portable device metadata used by backend memory-reporting helpers."""
|
|
|
|
def __init__(
|
|
self,
|
|
name,
|
|
total_memory = 0,
|
|
):
|
|
"""Store a display name and total memory in bytes."""
|
|
self.name = name
|
|
self.total_memory = int(total_memory or 0)
|
|
self.major = 0
|
|
self.minor = 0
|
|
self.multi_processor_count = 0
|
|
|
|
|
|
def _bytes_to_gb(value):
|
|
"""Convert byte counts to GiB rounded"""
|
|
return round(float(value or 0) / 1024 / 1024 / 1024, 3)
|
|
|
|
|
|
def _is_mlx_available():
|
|
# Transitional import barrier: keep non-Apple-Silicon imports from touching
|
|
# unsloth_zoo until unsloth_zoo.mlx is import-safe on GPU hosts. Then this
|
|
# can collapse back to the centralized zoo runtime call below.
|
|
if (
|
|
os.environ.get("UNSLOTH_FORCE_GPU_PATH", "0") == "1"
|
|
or platform.system() != "Darwin"
|
|
or platform.machine() != "arm64"
|
|
or importlib.util.find_spec("mlx") is None
|
|
):
|
|
return False
|
|
try:
|
|
from unsloth_zoo.mlx import is_mlx_available
|
|
except ImportError:
|
|
return False
|
|
return is_mlx_available()
|
|
|
|
|
|
# Detect Apple Silicon + MLX before any torch/numpy imports
|
|
_IS_MLX = _is_mlx_available()
|
|
|
|
if _IS_MLX:
|
|
try:
|
|
import unsloth_zoo
|
|
except ImportError as _e:
|
|
raise ImportError(
|
|
"Unsloth: MLX support requires `unsloth-zoo` with MLX modules. "
|
|
"Reinstall with `pip install unsloth-zoo` or rerun install.sh."
|
|
) from _e
|
|
# An older unsloth-zoo satisfies `import unsloth_zoo` but lacks the
|
|
# mlx.trainer / mlx.loader submodules. Surface a friendly install hint
|
|
# instead of a raw ImportError on the submodule path.
|
|
try:
|
|
from unsloth_zoo.mlx.trainer import (
|
|
MLXTrainer,
|
|
MLXTrainingConfig,
|
|
_is_vlm_model,
|
|
_normalize_mlx_optimizer_name,
|
|
)
|
|
from unsloth_zoo.mlx.loader import FastMLXModel
|
|
except ImportError as _e:
|
|
raise ImportError(
|
|
"Unsloth: MLX support requires an unsloth-zoo build that includes "
|
|
"`unsloth_zoo.mlx.trainer` and `unsloth_zoo.mlx.loader`. Upgrade with "
|
|
"`pip install -U unsloth-zoo` or rerun install.sh."
|
|
) from _e
|
|
|
|
import dataclasses as _dataclasses
|
|
import inspect as _inspect
|
|
import importlib.machinery as _machinery
|
|
import sys as _sys
|
|
import types as _types
|
|
import warnings as _warnings
|
|
|
|
__version__ = unsloth_zoo.__version__
|
|
DEVICE_TYPE = "mlx"
|
|
_MLX_TRAINER_ACCEPTS_VAR_KWARGS = False
|
|
_MLX_TRAINER_SUPPORTED_KWARGS = frozenset()
|
|
try:
|
|
_MLX_TRAINER_INIT_PARAMETERS = _inspect.signature(MLXTrainer.__init__).parameters
|
|
_MLX_TRAINER_ACCEPTS_VAR_KWARGS = any(
|
|
param.kind is _inspect.Parameter.VAR_KEYWORD
|
|
for param in _MLX_TRAINER_INIT_PARAMETERS.values()
|
|
)
|
|
_MLX_TRAINER_SUPPORTED_KWARGS = frozenset(
|
|
name
|
|
for name, param in _MLX_TRAINER_INIT_PARAMETERS.items()
|
|
if name != "self"
|
|
and param.kind
|
|
in (
|
|
_inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
_inspect.Parameter.KEYWORD_ONLY,
|
|
)
|
|
)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
def _mlx_trainer_supports_kwarg(name):
|
|
"""Return whether the installed zoo MLXTrainer accepts a kwarg."""
|
|
return _MLX_TRAINER_ACCEPTS_VAR_KWARGS or name in _MLX_TRAINER_SUPPORTED_KWARGS
|
|
|
|
def _is_mlx_cuda_device_target(device):
|
|
"""Return True when a torch .to/.cuda target asks for CUDA on MLX."""
|
|
if device is None:
|
|
return False
|
|
return str(device).lower().startswith("cuda")
|
|
|
|
def _patch_mlx_batch_encoding_to_cuda():
|
|
"""Treat tokenizer_output.to("cuda") as a no-op on the MLX backend."""
|
|
try:
|
|
from transformers.tokenization_utils_base import BatchEncoding
|
|
except Exception:
|
|
return
|
|
|
|
original_to = getattr(BatchEncoding, "to", None)
|
|
if original_to is None or getattr(original_to, "_unsloth_mlx_cuda_noop", False):
|
|
return
|
|
|
|
def batch_encoding_to(
|
|
self,
|
|
device = None,
|
|
*args,
|
|
**kwargs,
|
|
):
|
|
target = kwargs.get("device", device)
|
|
if _is_mlx_cuda_device_target(target):
|
|
return self
|
|
# device given by keyword: don't also pass the positional None, or the
|
|
# original raises "multiple values for 'device'" (e.g. .to(device="cpu")).
|
|
if "device" in kwargs:
|
|
return original_to(self, *args, **kwargs)
|
|
return original_to(self, device, *args, **kwargs)
|
|
|
|
batch_encoding_to._unsloth_mlx_cuda_noop = True
|
|
batch_encoding_to._unsloth_original_to = original_to
|
|
BatchEncoding.to = batch_encoding_to
|
|
|
|
_patch_mlx_batch_encoding_to_cuda()
|
|
|
|
# Load raw_text helpers without executing dataprep/__init__.py, which
|
|
# imports synthetic.py -> torch and would defeat the torch-free MLX path.
|
|
from pathlib import Path as _Path
|
|
|
|
_raw_text_path = _Path(__file__).resolve().parent / "dataprep" / "raw_text.py"
|
|
_raw_text_spec = importlib.util.spec_from_file_location("unsloth._mlx_raw_text", _raw_text_path)
|
|
if _raw_text_spec is None or _raw_text_spec.loader is None:
|
|
raise ImportError("Unsloth: could not load MLX raw_text dataprep helpers.")
|
|
_raw_text = importlib.util.module_from_spec(_raw_text_spec)
|
|
_raw_text_spec.loader.exec_module(_raw_text)
|
|
RawTextDataLoader = _raw_text.RawTextDataLoader
|
|
TextPreprocessor = _raw_text.TextPreprocessor
|
|
del _raw_text, _raw_text_spec, _raw_text_path, _Path
|
|
|
|
class FastLanguageModel:
|
|
@staticmethod
|
|
def from_pretrained(*args, **kwargs):
|
|
return FastMLXModel.from_pretrained(*args, **kwargs)
|
|
|
|
@staticmethod
|
|
def get_peft_model(*args, **kwargs):
|
|
return FastMLXModel.get_peft_model(*args, **kwargs)
|
|
|
|
@staticmethod
|
|
def for_inference(*args, **kwargs):
|
|
return args[0] if args else None
|
|
|
|
class FastVisionModel(FastLanguageModel):
|
|
@staticmethod
|
|
def from_pretrained(*args, **kwargs):
|
|
kwargs.setdefault("text_only", False)
|
|
return FastMLXModel.from_pretrained(*args, **kwargs)
|
|
|
|
@staticmethod
|
|
def for_training(*args, **kwargs):
|
|
return args[0] if args else None
|
|
|
|
FastTextModel = FastLanguageModel
|
|
FastModel = FastLanguageModel
|
|
|
|
class FastSentenceTransformer:
|
|
@staticmethod
|
|
def from_pretrained(*args, **kwargs):
|
|
raise NotImplementedError(
|
|
"Unsloth: FastSentenceTransformer is not yet supported on MLX."
|
|
)
|
|
|
|
@staticmethod
|
|
def get_peft_model(*args, **kwargs):
|
|
raise NotImplementedError(
|
|
"Unsloth: FastSentenceTransformer is not yet supported on MLX."
|
|
)
|
|
|
|
def is_bfloat16_supported():
|
|
try:
|
|
import mlx.core as mx
|
|
name = mx.device_info().get("device_name", "") or ""
|
|
return not name.startswith(("Apple M1", "Apple M2"))
|
|
except Exception:
|
|
return True
|
|
|
|
is_bf16_supported = is_bfloat16_supported
|
|
|
|
def get_gpu_memory_stats():
|
|
"""Return MLX device stats, peak memory, and total memory in GiB."""
|
|
import mlx.core as mx
|
|
|
|
info = mx.device_info()
|
|
total = info.get("memory_size") or info.get("max_recommended_working_set_size") or 0
|
|
get_peak_memory = getattr(mx, "get_peak_memory", None)
|
|
if get_peak_memory is None and hasattr(mx, "metal"):
|
|
get_peak_memory = getattr(mx.metal, "get_peak_memory", None)
|
|
peak = get_peak_memory() if callable(get_peak_memory) else 0
|
|
stats = _UnslothDeviceStats(info.get("device_name", "Apple GPU"), total)
|
|
max_memory = _bytes_to_gb(total) or 1.0
|
|
return stats, _bytes_to_gb(peak), max_memory
|
|
|
|
def clear_gpu_memory():
|
|
"""Clear MLX's cached GPU memory for compatibility cleanup helpers."""
|
|
import mlx.core as mx
|
|
|
|
clear_cache = getattr(mx, "clear_cache", None)
|
|
if clear_cache is None and hasattr(mx, "metal"):
|
|
clear_cache = getattr(mx.metal, "clear_cache", None)
|
|
if callable(clear_cache):
|
|
clear_cache()
|
|
|
|
def _patch_mlx_torch_cuda_compat_api():
|
|
"""Expose CUDA-shaped torch helpers for compatibility callers on MLX."""
|
|
try:
|
|
import torch
|
|
except Exception:
|
|
return
|
|
|
|
cuda = getattr(torch, "cuda", None)
|
|
if cuda is not None and not getattr(cuda, "_unsloth_mlx_cuda_compat_api", False):
|
|
|
|
def get_device_properties(device = None):
|
|
"""Return MLX device stats through torch.cuda's compatibility API."""
|
|
return get_gpu_memory_stats()[0]
|
|
|
|
def get_device_name(device = None):
|
|
"""Return the MLX device name through torch.cuda's compatibility API."""
|
|
return get_device_properties(device).name
|
|
|
|
def max_memory_reserved(device = None):
|
|
"""Return MLX peak memory in bytes for torch.cuda compatibility API."""
|
|
return int(get_gpu_memory_stats()[1] * 1024 * 1024 * 1024)
|
|
|
|
def empty_cache():
|
|
"""Clear MLX cache through torch.cuda.empty_cache()."""
|
|
clear_gpu_memory()
|
|
|
|
def _mlx_active_memory_bytes():
|
|
"""Current active MLX memory in bytes (not the peak high-water mark)."""
|
|
import mlx.core as mx
|
|
|
|
get_active = getattr(mx, "get_active_memory", None)
|
|
if get_active is None and hasattr(mx, "metal"):
|
|
get_active = getattr(mx.metal, "get_active_memory", None)
|
|
return int(get_active()) if callable(get_active) else 0
|
|
|
|
def memory_current(device = None):
|
|
"""Return CURRENT MLX memory in bytes. torch.cuda.memory_reserved /
|
|
memory_allocated report live usage, not the peak (that is max_*)."""
|
|
return _mlx_active_memory_bytes()
|
|
|
|
def mem_get_info(device = None):
|
|
"""Return (free, total) bytes for torch.cuda compatibility API.
|
|
Free uses CURRENT active memory, not the peak high-water mark, so
|
|
a capacity check stays accurate after a transient spike."""
|
|
total = int(get_gpu_memory_stats()[2] * 1024 * 1024 * 1024)
|
|
return (max(total - _mlx_active_memory_bytes(), 0), total)
|
|
|
|
def reset_peak_memory_stats(device = None):
|
|
"""Reset MLX's peak-memory counter so a later max_memory_reserved /
|
|
max_memory_allocated scopes to the run, not earlier model-load peaks."""
|
|
import mlx.core as mx
|
|
|
|
reset = getattr(mx, "reset_peak_memory", None)
|
|
if reset is None and hasattr(mx, "metal"):
|
|
reset = getattr(mx.metal, "reset_peak_memory", None)
|
|
if callable(reset):
|
|
reset()
|
|
|
|
def synchronize(device = None):
|
|
"""Wait for queued MLX work when torch.cuda.synchronize() is called."""
|
|
import mlx.core as mx
|
|
|
|
sync = getattr(mx, "synchronize", None)
|
|
if callable(sync):
|
|
sync()
|
|
|
|
cuda.get_device_properties = get_device_properties
|
|
cuda.get_device_name = get_device_name
|
|
cuda.max_memory_reserved = max_memory_reserved
|
|
cuda.max_memory_allocated = max_memory_reserved
|
|
cuda.memory_reserved = memory_current
|
|
cuda.memory_allocated = memory_current
|
|
cuda.empty_cache = empty_cache
|
|
cuda.mem_get_info = mem_get_info
|
|
cuda.reset_peak_memory_stats = reset_peak_memory_stats
|
|
cuda.synchronize = synchronize
|
|
cuda.current_device = lambda: 0
|
|
cuda.device_count = lambda: 1
|
|
cuda.set_device = lambda device = None: None
|
|
cuda.get_device_capability = lambda device = None: (0, 0)
|
|
cuda.is_bf16_supported = lambda *args, **kwargs: is_bfloat16_supported()
|
|
cuda._unsloth_mlx_cuda_compat_api = True
|
|
|
|
tensor_to = getattr(torch.Tensor, "to", None)
|
|
if tensor_to is not None and not getattr(tensor_to, "_unsloth_mlx_cuda_noop", False):
|
|
|
|
def _coerce_mlx_dtype_to_torch(value):
|
|
"""Map MLX dtype objects to their torch dtype equivalents."""
|
|
try:
|
|
import mlx.core as mx
|
|
except Exception:
|
|
return value
|
|
dtype_map = {
|
|
mx.bool_: torch.bool,
|
|
mx.int8: torch.int8,
|
|
mx.int16: torch.int16,
|
|
mx.int32: torch.int32,
|
|
mx.int64: torch.int64,
|
|
mx.uint8: torch.uint8,
|
|
mx.float16: torch.float16,
|
|
mx.float32: torch.float32,
|
|
mx.bfloat16: torch.bfloat16,
|
|
}
|
|
mapped = dtype_map.get(value, None)
|
|
if mapped is not None:
|
|
return mapped
|
|
dtype_name = str(value).rsplit(".", 1)[-1]
|
|
name_map = {
|
|
"bool_": torch.bool,
|
|
"int8": torch.int8,
|
|
"int16": torch.int16,
|
|
"int32": torch.int32,
|
|
"int64": torch.int64,
|
|
"uint8": torch.uint8,
|
|
"float16": torch.float16,
|
|
"float32": torch.float32,
|
|
"bfloat16": torch.bfloat16,
|
|
}
|
|
return name_map.get(dtype_name, value)
|
|
|
|
def mlx_tensor_to(self, *args, **kwargs):
|
|
"""Ignore CUDA device targets while preserving dtype conversions."""
|
|
args = list(args)
|
|
kwargs = dict(kwargs)
|
|
removed_cuda_device = False
|
|
if args and _is_mlx_cuda_device_target(args[0]):
|
|
args.pop(0)
|
|
removed_cuda_device = True
|
|
if _is_mlx_cuda_device_target(kwargs.get("device", None)):
|
|
kwargs.pop("device", None)
|
|
removed_cuda_device = True
|
|
if removed_cuda_device and not args:
|
|
cuda_only_kwargs = ("non_blocking", "copy", "memory_format")
|
|
if all(key in cuda_only_kwargs for key in kwargs):
|
|
return self
|
|
if removed_cuda_device and not args and not kwargs:
|
|
return self
|
|
if args:
|
|
args[0] = _coerce_mlx_dtype_to_torch(args[0])
|
|
if "dtype" in kwargs:
|
|
kwargs["dtype"] = _coerce_mlx_dtype_to_torch(kwargs["dtype"])
|
|
return tensor_to(self, *args, **kwargs)
|
|
|
|
mlx_tensor_to._unsloth_mlx_cuda_noop = True
|
|
mlx_tensor_to._unsloth_original_to = tensor_to
|
|
torch.Tensor.to = mlx_tensor_to
|
|
|
|
tensor_cuda = getattr(torch.Tensor, "cuda", None)
|
|
if tensor_cuda is not None and not getattr(tensor_cuda, "_unsloth_mlx_cuda_noop", False):
|
|
|
|
def mlx_tensor_cuda(self, *args, **kwargs):
|
|
"""Treat tensor.cuda() as a no-op on MLX."""
|
|
return self
|
|
|
|
mlx_tensor_cuda._unsloth_mlx_cuda_noop = True
|
|
mlx_tensor_cuda._unsloth_original_cuda = tensor_cuda
|
|
torch.Tensor.cuda = mlx_tensor_cuda
|
|
|
|
_patch_mlx_torch_cuda_compat_api()
|
|
|
|
_MLX_TRAINING_CONFIG_FIELDS = {_field.name for _field in _dataclasses.fields(MLXTrainingConfig)}
|
|
_MLX_TRAINING_ARGUMENT_ALIASES = {
|
|
"max_length": "max_seq_length",
|
|
}
|
|
_MLX_COMPAT_EXTRA_ARGUMENTS = frozenset(
|
|
(
|
|
"bf16",
|
|
"dataloader_num_workers",
|
|
"dataloader_pin_memory",
|
|
"dataset_kwargs",
|
|
"ddp_find_unused_parameters",
|
|
"disable_tqdm",
|
|
"eval_strategy",
|
|
"evaluation_strategy",
|
|
"fp16",
|
|
"full_determinism",
|
|
"gradient_checkpointing_kwargs",
|
|
"hub_model_id",
|
|
"hub_token",
|
|
"log_level",
|
|
"logging_strategy",
|
|
"neftune_noise_alpha",
|
|
"optim_args",
|
|
"padding_free",
|
|
"push_to_hub",
|
|
"remove_unused_columns",
|
|
"save_on_each_node",
|
|
"save_safetensors",
|
|
"save_strategy",
|
|
"torch_compile",
|
|
)
|
|
)
|
|
_MLX_IMPLEMENTED_EXTRA_ARGUMENTS = frozenset(
|
|
(
|
|
"image_size",
|
|
"preserve_dataset_order",
|
|
"warmup_ratio",
|
|
)
|
|
)
|
|
_MLX_ALLOWED_EXTRA_ARGUMENTS = _MLX_COMPAT_EXTRA_ARGUMENTS | _MLX_IMPLEMENTED_EXTRA_ARGUMENTS
|
|
_MLX_UNSUPPORTED_TASK_ARGUMENTS = frozenset(
|
|
(
|
|
"assistant_only_loss",
|
|
"completion_only_loss",
|
|
)
|
|
)
|
|
|
|
def _is_mlx_no_save_strategy(value):
|
|
if hasattr(value, "value"):
|
|
value = value.value
|
|
strategy = str(value or "").strip().lower()
|
|
strategy = strategy.rsplit(".", 1)[-1]
|
|
return strategy in ("no", "none", "false")
|
|
|
|
_MLX_ADAMW_OPTIMIZER_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",
|
|
)
|
|
)
|
|
|
|
def _normalize_mlx_training_value(key, value):
|
|
if key == "eval_steps" and value is None:
|
|
return 0
|
|
if key == "num_train_epochs" and value is not None and not isinstance(value, bool):
|
|
try:
|
|
epochs = float(value)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
else:
|
|
if epochs.is_integer():
|
|
return int(epochs)
|
|
if key == "lr_scheduler_type" and hasattr(value, "value"):
|
|
return value.value
|
|
if key != "optim":
|
|
return value
|
|
try:
|
|
return _normalize_mlx_optimizer_name(value)
|
|
except ValueError:
|
|
# Older unsloth-zoo lacks CUDA/TRL optimizer aliases; map common
|
|
# adamw_* names so notebook defaults (optim="adamw_8bit") still work.
|
|
opt = str(getattr(value, "value", value) or "adamw").strip().lower()
|
|
opt = opt.rsplit(".", 1)[-1].replace("-", "_")
|
|
if opt in _MLX_ADAMW_OPTIMIZER_ALIASES:
|
|
return "adamw"
|
|
raise
|
|
|
|
def _mlx_training_argument_values(args):
|
|
values = {}
|
|
for field in _dataclasses.fields(MLXTrainingConfig):
|
|
if hasattr(args, field.name):
|
|
values[field.name] = _normalize_mlx_training_value(
|
|
field.name,
|
|
getattr(args, field.name),
|
|
)
|
|
for alias, target in _MLX_TRAINING_ARGUMENT_ALIASES.items():
|
|
if target not in values and hasattr(args, alias):
|
|
values[target if target in _MLX_ALLOWED_EXTRA_ARGUMENTS else alias] = getattr(
|
|
args, alias
|
|
)
|
|
for name in _MLX_ALLOWED_EXTRA_ARGUMENTS:
|
|
if hasattr(args, name):
|
|
values[name] = getattr(args, name)
|
|
for name in _MLX_UNSUPPORTED_TASK_ARGUMENTS:
|
|
if hasattr(args, name):
|
|
value = getattr(args, name)
|
|
if (
|
|
name == "completion_only_loss"
|
|
and value is not None
|
|
and name in _MLX_TRAINING_CONFIG_FIELDS
|
|
):
|
|
values[name] = value
|
|
elif value is not None and value is not False:
|
|
values[name] = value
|
|
if _is_mlx_no_save_strategy(values.get("save_strategy", None)):
|
|
values["save_steps"] = 0
|
|
return values
|
|
|
|
def _split_mlx_trainer_kwargs(kwargs):
|
|
trainer_kwargs = {}
|
|
config_kwargs = {}
|
|
ignored_kwargs = {}
|
|
for key, value in kwargs.items():
|
|
if key in _MLX_TRAINER_KWARGS:
|
|
trainer_kwargs[key] = value
|
|
continue
|
|
target = _MLX_TRAINING_ARGUMENT_ALIASES.get(key, key)
|
|
if target in _MLX_TRAINING_CONFIG_FIELDS or key in _MLX_ALLOWED_EXTRA_ARGUMENTS:
|
|
config_kwargs[key] = value
|
|
else:
|
|
ignored_kwargs[key] = value
|
|
return trainer_kwargs, config_kwargs, ignored_kwargs
|
|
|
|
def _is_mlx_training_args_like(value):
|
|
if isinstance(value, (MLXTrainingConfig, dict, str, os.PathLike)):
|
|
return True
|
|
return any(
|
|
hasattr(value, name)
|
|
for name in (
|
|
"output_dir",
|
|
"per_device_train_batch_size",
|
|
"gradient_accumulation_steps",
|
|
"max_steps",
|
|
"learning_rate",
|
|
)
|
|
)
|
|
|
|
def _should_use_trl_positional_schema(args):
|
|
if len(args) < 2:
|
|
return False
|
|
if _is_mlx_training_args_like(args[1]):
|
|
return True
|
|
# TRL callers often pass explicit defaults:
|
|
# SFTTrainer(model, None, None, train_dataset, ...)
|
|
return len(args) >= 3 and args[1] is None and (args[2] is None or callable(args[2]))
|
|
|
|
def _assign_mlx_positional_kwarg(kwargs, name, value):
|
|
if name in kwargs:
|
|
raise TypeError(
|
|
f"UnslothTrainer.__init__() got multiple values for argument " f"{name!r}"
|
|
)
|
|
kwargs[name] = value
|
|
|
|
def _normalize_mlx_trainer_init_args(args, kwargs):
|
|
kwargs = dict(kwargs)
|
|
if len(args) == 0:
|
|
return kwargs
|
|
|
|
use_trl_schema = _should_use_trl_positional_schema(args)
|
|
positional_names = (
|
|
_TRL_SFT_TRAINER_POSITIONAL_KWARGS if use_trl_schema else _MLX_TRAINER_POSITIONAL_KWARGS
|
|
)
|
|
if len(args) > len(positional_names):
|
|
raise TypeError(
|
|
f"UnslothTrainer.__init__() takes at most "
|
|
f"{len(positional_names)} positional arguments on MLX "
|
|
f"({len(args)} given)"
|
|
)
|
|
for name, value in zip(positional_names, args):
|
|
_assign_mlx_positional_kwarg(kwargs, name, value)
|
|
return kwargs
|
|
|
|
def _is_meaningful_mlx_extra_value(value):
|
|
if value is None or value is False:
|
|
return False
|
|
if isinstance(value, (str, bytes)) and len(value) == 0:
|
|
return False
|
|
if isinstance(value, (dict, list, tuple, set, frozenset)) and len(value) == 0:
|
|
return False
|
|
return True
|
|
|
|
def _warn_ignored_mlx_training_args(extra_kwargs):
|
|
names = sorted(
|
|
key
|
|
for key, value in extra_kwargs.items()
|
|
if (key in _MLX_COMPAT_EXTRA_ARGUMENTS and _is_meaningful_mlx_extra_value(value))
|
|
)
|
|
if not names:
|
|
return
|
|
_warnings.warn(
|
|
"Unsloth MLX: accepting but not applying unsupported "
|
|
"TrainingArguments kwargs: "
|
|
f"{', '.join(names)}. These options are not implemented by "
|
|
"MLXTrainer yet.",
|
|
RuntimeWarning,
|
|
stacklevel = 3,
|
|
)
|
|
|
|
def _is_meaningful_mlx_trainer_kwarg(key, value):
|
|
if key == "optimizers" and value == (None, None):
|
|
return False
|
|
return _is_meaningful_mlx_extra_value(value)
|
|
|
|
def _raise_unsupported_mlx_trainer_kwargs(ignored_kwargs):
|
|
names = sorted(
|
|
key
|
|
for key, value in ignored_kwargs.items()
|
|
if _is_meaningful_mlx_trainer_kwarg(key, value)
|
|
)
|
|
if not names:
|
|
return
|
|
raise NotImplementedError(
|
|
"Unsloth MLX: unsupported SFTTrainer kwargs cannot be ignored safely: "
|
|
f"{', '.join(names)}. Remove these kwargs or use a supported MLX "
|
|
"trainer configuration."
|
|
)
|
|
|
|
def _raise_unknown_mlx_training_args(extra_kwargs):
|
|
names = sorted(key for key in extra_kwargs if key not in _MLX_ALLOWED_EXTRA_ARGUMENTS)
|
|
if not names:
|
|
return
|
|
raise NotImplementedError(
|
|
"Unsloth MLX: unsupported TrainingArguments/SFTConfig kwargs: "
|
|
f"{', '.join(names)}. Remove these kwargs or use fields implemented "
|
|
"by MLXTrainingConfig."
|
|
)
|
|
|
|
def _positive_mlx_context_length(value):
|
|
if value is None or isinstance(value, bool):
|
|
return None
|
|
try:
|
|
length = int(value)
|
|
except (TypeError, ValueError, OverflowError):
|
|
return None
|
|
if length <= 0:
|
|
return None
|
|
return length
|
|
|
|
def _positive_mlx_training_number(value):
|
|
if value is None or isinstance(value, bool):
|
|
return None
|
|
try:
|
|
number = float(value)
|
|
except (TypeError, ValueError, OverflowError):
|
|
return None
|
|
if number <= 0:
|
|
return None
|
|
return number
|
|
|
|
def _set_mlx_cuda_style_context_length(args, length):
|
|
args.max_seq_length = length
|
|
args.max_length = length
|
|
args._unsloth_mlx_max_length_value = length
|
|
return args
|
|
|
|
class UnslothTrainingArguments(MLXTrainingConfig):
|
|
"""MLX-compatible public training arguments for Unsloth notebooks."""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
if len(args) == 1 and isinstance(args[0], dict):
|
|
kwargs = {**args[0], **kwargs}
|
|
elif len(args) == 1 and isinstance(args[0], (str, os.PathLike)):
|
|
kwargs = {"output_dir": os.fspath(args[0]), **kwargs}
|
|
elif args:
|
|
raise TypeError(
|
|
"UnslothTrainingArguments on MLX accepts keyword arguments, "
|
|
"a dict, or a single positional output_dir."
|
|
)
|
|
|
|
max_length_value = kwargs.get("max_length", None)
|
|
# Only the canonical max_seq_length marks context length explicit; TRL
|
|
# max_length stays a compatibility alias and defers to the model's
|
|
# context length when one is available.
|
|
max_seq_length_explicit = (
|
|
_positive_mlx_context_length(kwargs.get("max_seq_length", None)) is not None
|
|
)
|
|
if "max_length" in kwargs and "max_seq_length" not in kwargs:
|
|
kwargs["max_seq_length"] = kwargs["max_length"]
|
|
elif (
|
|
"max_length" in kwargs
|
|
and _positive_mlx_context_length(kwargs.get("max_seq_length", None)) is not None
|
|
):
|
|
max_length_value = kwargs["max_seq_length"]
|
|
if "num_train_epochs" in kwargs and "max_steps" not in kwargs:
|
|
kwargs["max_steps"] = -1
|
|
|
|
dataset_order_explicit = "dataset_order" in kwargs or bool(
|
|
kwargs.get("preserve_dataset_order", False)
|
|
)
|
|
append_eos_explicit = "append_eos" in kwargs
|
|
grad_clip_explicit = any(
|
|
name in kwargs for name in ("max_grad_norm", "max_grad_value", "max_grad_leaf_norm")
|
|
)
|
|
warmup_ratio = kwargs.get("warmup_ratio", None)
|
|
warmup_steps_supplied = "warmup_steps" in kwargs
|
|
warmup_steps_value = kwargs.get("warmup_steps", None)
|
|
warmup_steps_explicit = False
|
|
if warmup_steps_supplied:
|
|
try:
|
|
warmup_steps_explicit = int(warmup_steps_value) > 0
|
|
except (TypeError, ValueError):
|
|
warmup_steps_explicit = True
|
|
filtered_kwargs = {}
|
|
extra_kwargs = {}
|
|
for key, value in kwargs.items():
|
|
target = _MLX_TRAINING_ARGUMENT_ALIASES.get(key, key)
|
|
if key != target and target in kwargs:
|
|
continue
|
|
value = _normalize_mlx_training_value(target, value)
|
|
if target in _MLX_UNSUPPORTED_TASK_ARGUMENTS:
|
|
if (
|
|
target == "completion_only_loss"
|
|
and value is not None
|
|
and target in _MLX_TRAINING_CONFIG_FIELDS
|
|
):
|
|
filtered_kwargs[target] = value
|
|
elif _is_meaningful_mlx_extra_value(value):
|
|
extra_kwargs[key] = value
|
|
continue
|
|
if target in _MLX_TRAINING_CONFIG_FIELDS:
|
|
filtered_kwargs[target] = value
|
|
else:
|
|
extra_kwargs[target if target in _MLX_ALLOWED_EXTRA_ARGUMENTS else key] = value
|
|
|
|
_raise_unknown_mlx_training_args(extra_kwargs)
|
|
|
|
if _is_mlx_no_save_strategy(extra_kwargs.get("save_strategy", None)):
|
|
filtered_kwargs["save_steps"] = 0
|
|
|
|
if warmup_ratio is not None and not warmup_steps_explicit:
|
|
import math as _math
|
|
max_steps = filtered_kwargs.get(
|
|
"max_steps",
|
|
getattr(MLXTrainingConfig, "max_steps", 60),
|
|
)
|
|
try:
|
|
if int(max_steps) > 0:
|
|
filtered_kwargs["warmup_steps"] = max(
|
|
0,
|
|
_math.ceil(int(max_steps) * float(warmup_ratio)),
|
|
)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
super().__init__(**filtered_kwargs)
|
|
self._unsloth_mlx_dataset_order_explicit = dataset_order_explicit
|
|
self._unsloth_mlx_append_eos_explicit = append_eos_explicit
|
|
self._unsloth_mlx_max_seq_length_explicit = max_seq_length_explicit
|
|
self._unsloth_mlx_max_length_value = max_length_value
|
|
if "max_length" in kwargs:
|
|
self.max_length = max_length_value
|
|
self._unsloth_mlx_grad_clip_explicit = grad_clip_explicit
|
|
self._unsloth_mlx_warmup_steps_explicit = warmup_steps_explicit
|
|
self._unsloth_mlx_extra_args = extra_kwargs
|
|
for key, value in extra_kwargs.items():
|
|
setattr(self, key, value)
|
|
_warn_ignored_mlx_training_args(extra_kwargs)
|
|
|
|
def _resolve_mlx_cuda_style_max_seq_length(args, model = None):
|
|
model_max_seq_length = _positive_mlx_context_length(
|
|
getattr(model, "max_seq_length", None),
|
|
)
|
|
args_max_seq_length = _positive_mlx_context_length(
|
|
getattr(args, "max_seq_length", None),
|
|
)
|
|
args_max_seq_length_explicit = getattr(
|
|
args,
|
|
"_unsloth_mlx_max_seq_length_explicit",
|
|
None,
|
|
)
|
|
if args_max_seq_length_explicit is None:
|
|
default_max_seq_length = getattr(MLXTrainingConfig, "max_seq_length", 2048)
|
|
args_max_seq_length_explicit = (
|
|
args_max_seq_length is not None and args_max_seq_length != default_max_seq_length
|
|
)
|
|
if not args_max_seq_length_explicit:
|
|
args_max_seq_length = None
|
|
|
|
if args_max_seq_length is None and model_max_seq_length is not None:
|
|
args_max_seq_length = model_max_seq_length
|
|
elif (
|
|
args_max_seq_length is not None
|
|
and model_max_seq_length is not None
|
|
and args_max_seq_length > model_max_seq_length
|
|
):
|
|
print(
|
|
"Unsloth: You set `max_seq_length` as "
|
|
f"{args_max_seq_length} but the maximum the model supports is "
|
|
f"{model_max_seq_length}. We shall reduce it."
|
|
)
|
|
args_max_seq_length = model_max_seq_length
|
|
|
|
if args_max_seq_length is not None:
|
|
_set_mlx_cuda_style_context_length(args, args_max_seq_length)
|
|
return args
|
|
|
|
model_max_length = model_max_seq_length
|
|
if model_max_length is None:
|
|
model_max_length = _positive_mlx_context_length(
|
|
getattr(model, "max_length", None),
|
|
)
|
|
if model_max_length is not None:
|
|
_set_mlx_cuda_style_context_length(args, model_max_length)
|
|
return args
|
|
|
|
args_max_length = _positive_mlx_context_length(
|
|
getattr(args, "max_length", None),
|
|
)
|
|
if args_max_length is None:
|
|
args_max_length = _positive_mlx_context_length(
|
|
getattr(args, "_unsloth_mlx_max_length_value", None),
|
|
)
|
|
if args_max_length is not None:
|
|
_set_mlx_cuda_style_context_length(args, args_max_length)
|
|
if model is not None:
|
|
setattr(model, "max_seq_length", args_max_length)
|
|
return args
|
|
|
|
_set_mlx_cuda_style_context_length(args, 1024)
|
|
return args
|
|
|
|
def _apply_unsloth_trainer_mlx_defaults(
|
|
args,
|
|
model = None,
|
|
max_seq_length_explicit = False,
|
|
):
|
|
if (
|
|
not getattr(args, "streaming", False)
|
|
and not getattr(args, "preserve_dataset_order", False)
|
|
and not getattr(args, "_unsloth_mlx_dataset_order_explicit", False)
|
|
):
|
|
default_order = getattr(MLXTrainingConfig, "dataset_order", "default")
|
|
if getattr(args, "dataset_order", default_order) in (None, default_order):
|
|
args.dataset_order = "torch_randperm"
|
|
|
|
if isinstance(args, UnslothTrainingArguments) and not getattr(
|
|
args, "_unsloth_mlx_append_eos_explicit", False
|
|
):
|
|
args.append_eos = False
|
|
|
|
if isinstance(args, UnslothTrainingArguments) and not getattr(
|
|
args, "_unsloth_mlx_grad_clip_explicit", False
|
|
):
|
|
max_grad_norm = _positive_mlx_training_number(
|
|
getattr(args, "max_grad_norm", None),
|
|
)
|
|
max_grad_value = _positive_mlx_training_number(
|
|
getattr(args, "max_grad_value", None),
|
|
)
|
|
max_grad_leaf_norm = _positive_mlx_training_number(
|
|
getattr(args, "max_grad_leaf_norm", None),
|
|
)
|
|
if max_grad_norm is None and max_grad_value is None and max_grad_leaf_norm is None:
|
|
args.max_grad_norm = 1.0
|
|
|
|
if not max_seq_length_explicit:
|
|
_resolve_mlx_cuda_style_max_seq_length(args, model = model)
|
|
return args
|
|
|
|
def _coerce_mlx_training_args(args, overrides = None):
|
|
overrides = overrides or {}
|
|
if isinstance(args, MLXTrainingConfig) and not overrides:
|
|
return args
|
|
dataset_order_explicit = None
|
|
append_eos_explicit = None
|
|
max_seq_length_explicit = None
|
|
max_length_value = None
|
|
grad_clip_explicit = None
|
|
if args is None:
|
|
values = {}
|
|
elif isinstance(args, dict):
|
|
values = dict(args)
|
|
elif isinstance(args, (str, os.PathLike)):
|
|
values = {"output_dir": os.fspath(args)}
|
|
else:
|
|
dataset_order_explicit = getattr(
|
|
args,
|
|
"_unsloth_mlx_dataset_order_explicit",
|
|
False,
|
|
)
|
|
append_eos_explicit = getattr(
|
|
args,
|
|
"_unsloth_mlx_append_eos_explicit",
|
|
None,
|
|
)
|
|
max_seq_length_explicit = getattr(
|
|
args,
|
|
"_unsloth_mlx_max_seq_length_explicit",
|
|
None,
|
|
)
|
|
if max_seq_length_explicit is None:
|
|
args_max_seq_length = _positive_mlx_context_length(
|
|
getattr(args, "max_seq_length", None),
|
|
)
|
|
default_max_seq_length = getattr(MLXTrainingConfig, "max_seq_length", 2048)
|
|
max_seq_length_explicit = (
|
|
args_max_seq_length is not None
|
|
and args_max_seq_length != default_max_seq_length
|
|
)
|
|
max_length_value = getattr(
|
|
args,
|
|
"_unsloth_mlx_max_length_value",
|
|
getattr(args, "max_length", None),
|
|
)
|
|
grad_clip_explicit = getattr(
|
|
args,
|
|
"_unsloth_mlx_grad_clip_explicit",
|
|
None,
|
|
)
|
|
values = _mlx_training_argument_values(args)
|
|
if hasattr(args, "max_length"):
|
|
values["max_length"] = getattr(args, "max_length")
|
|
values.update(overrides)
|
|
coerced = UnslothTrainingArguments(**values)
|
|
if (
|
|
dataset_order_explicit is not None
|
|
and "dataset_order" not in overrides
|
|
and "preserve_dataset_order" not in overrides
|
|
):
|
|
coerced._unsloth_mlx_dataset_order_explicit = dataset_order_explicit
|
|
if append_eos_explicit is not None and "append_eos" not in overrides:
|
|
coerced._unsloth_mlx_append_eos_explicit = append_eos_explicit
|
|
if (
|
|
max_seq_length_explicit is not None
|
|
and "max_seq_length" not in overrides
|
|
and "max_length" not in overrides
|
|
):
|
|
coerced._unsloth_mlx_max_seq_length_explicit = max_seq_length_explicit
|
|
if max_length_value is not None and "max_length" not in overrides:
|
|
coerced._unsloth_mlx_max_length_value = max_length_value
|
|
coerced.max_length = max_length_value
|
|
if (
|
|
grad_clip_explicit is not None
|
|
and "max_grad_norm" not in overrides
|
|
and "max_grad_value" not in overrides
|
|
and "max_grad_leaf_norm" not in overrides
|
|
):
|
|
coerced._unsloth_mlx_grad_clip_explicit = grad_clip_explicit
|
|
return coerced
|
|
|
|
_MLX_TRAINER_POSITIONAL_KWARGS = (
|
|
"model",
|
|
"tokenizer",
|
|
"train_dataset",
|
|
"eval_dataset",
|
|
"dataset_text_field",
|
|
"max_seq_length",
|
|
"packing",
|
|
"data_collator",
|
|
"args",
|
|
"formatting_func",
|
|
"processor",
|
|
"callbacks",
|
|
)
|
|
_TRL_SFT_TRAINER_POSITIONAL_KWARGS = (
|
|
"model",
|
|
"args",
|
|
"data_collator",
|
|
"train_dataset",
|
|
"eval_dataset",
|
|
"processing_class",
|
|
"compute_loss_func",
|
|
"compute_metrics",
|
|
"callbacks",
|
|
"optimizers",
|
|
"optimizer_cls_and_kwargs",
|
|
"preprocess_logits_for_metrics",
|
|
"peft_config",
|
|
"formatting_func",
|
|
)
|
|
_MLX_TRAINER_KWARGS = frozenset(_MLX_TRAINER_POSITIONAL_KWARGS)
|
|
|
|
def _filter_supported_mlx_trainer_kwargs(trainer_kwargs):
|
|
"""Drop inert/empty kwargs unsupported by this zoo MLXTrainer."""
|
|
unsupported = {
|
|
key: value
|
|
for key, value in trainer_kwargs.items()
|
|
if not _mlx_trainer_supports_kwarg(key)
|
|
}
|
|
names = sorted(
|
|
key for key, value in unsupported.items() if _is_meaningful_mlx_extra_value(value)
|
|
)
|
|
if names:
|
|
subject = ", ".join(names)
|
|
verb = "requires" if len(names) == 1 else "require"
|
|
raise NotImplementedError(
|
|
"Unsloth MLX: "
|
|
f"{subject} {verb} an unsloth-zoo build with "
|
|
"matching MLXTrainer support. Upgrade unsloth-zoo together "
|
|
"with unsloth."
|
|
)
|
|
for key in unsupported:
|
|
trainer_kwargs.pop(key, None)
|
|
return trainer_kwargs
|
|
|
|
def _is_mlx_native_text_collator(collator):
|
|
"""HF pad/copy collators are redundant on MLX; match by class name."""
|
|
for klass in type(collator).__mro__:
|
|
name = klass.__name__
|
|
if name in (
|
|
"DataCollatorForSeq2Seq",
|
|
"DataCollatorWithPadding",
|
|
"DefaultDataCollator",
|
|
):
|
|
return True
|
|
if name == "DataCollatorForLanguageModeling":
|
|
# Plain causal padding is fine; MLM masking changes semantics.
|
|
return not bool(getattr(collator, "mlm", False))
|
|
return False
|
|
|
|
_MLX_VISION_COLLATOR_FORWARDED_KWARGS = frozenset(
|
|
("completion_only_loss", "formatting_func", "max_seq_length")
|
|
)
|
|
_MLX_VISION_COLLATOR_IMAGE_KWARGS = frozenset(("image_size", "resize"))
|
|
_MLX_VISION_COLLATOR_POSITIONAL_KWARGS = (
|
|
"max_seq_length",
|
|
"formatting_func",
|
|
"resize",
|
|
"ignore_index",
|
|
"train_on_responses_only",
|
|
"instruction_part",
|
|
"response_part",
|
|
"force_match",
|
|
"num_proc",
|
|
"completion_only_loss",
|
|
"pad_to_multiple_of",
|
|
"resize_dimension",
|
|
"snap_to_patch_size",
|
|
"last_response_only",
|
|
)
|
|
_MLX_VISION_COLLATOR_UNSUPPORTED_DEFAULTS = {
|
|
"ignore_index": -100,
|
|
"train_on_responses_only": False,
|
|
"instruction_part": None,
|
|
"response_part": None,
|
|
"force_match": True,
|
|
"num_proc": None,
|
|
"pad_to_multiple_of": None,
|
|
"resize_dimension": 0,
|
|
"snap_to_patch_size": False,
|
|
"last_response_only": False,
|
|
}
|
|
|
|
def _is_default_mlx_vision_collator_value(key, value):
|
|
"""Return whether an unsupported collator value is the CUDA default."""
|
|
if key not in _MLX_VISION_COLLATOR_UNSUPPORTED_DEFAULTS:
|
|
return False
|
|
default = _MLX_VISION_COLLATOR_UNSUPPORTED_DEFAULTS[key]
|
|
if default is None:
|
|
return value is None
|
|
if isinstance(default, bool):
|
|
return value is default
|
|
return value == default and type(value) is type(default)
|
|
|
|
def _has_mlx_training_arg_value(args, key):
|
|
"""Return whether training args already carry an explicit config value."""
|
|
if args is None or isinstance(args, (str, os.PathLike)):
|
|
return False
|
|
if isinstance(args, dict):
|
|
return key in args
|
|
return getattr(args, key, None) is not None
|
|
|
|
def _raise_unsupported_mlx_vision_collator_kwargs(collator_kwargs):
|
|
"""Reject VLM collator kwargs that cannot be ignored safely on MLX."""
|
|
unsupported = sorted(
|
|
key
|
|
for key, value in collator_kwargs.items()
|
|
if (
|
|
key not in _MLX_VISION_COLLATOR_FORWARDED_KWARGS
|
|
and key not in _MLX_VISION_COLLATOR_IMAGE_KWARGS
|
|
and (
|
|
(
|
|
key in _MLX_VISION_COLLATOR_UNSUPPORTED_DEFAULTS
|
|
and not _is_default_mlx_vision_collator_value(key, value)
|
|
)
|
|
or (
|
|
key not in _MLX_VISION_COLLATOR_UNSUPPORTED_DEFAULTS
|
|
and _is_meaningful_mlx_extra_value(value)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
if unsupported:
|
|
raise NotImplementedError(
|
|
"Unsloth MLX: unsupported UnslothVisionDataCollator kwargs "
|
|
f"cannot be ignored safely: {', '.join(unsupported)}."
|
|
)
|
|
|
|
class UnslothTrainer(MLXTrainer):
|
|
"""Backend-aware public trainer that routes supported SFT notebooks to MLX."""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
kwargs = _normalize_mlx_trainer_init_args(args, kwargs)
|
|
processing_class = kwargs.pop("processing_class", None)
|
|
processor_from_processing_class = False
|
|
if processing_class is not None:
|
|
if kwargs.get("processor", None) is None:
|
|
kwargs["processor"] = processing_class
|
|
processor_from_processing_class = True
|
|
if kwargs.get("tokenizer", None) is None:
|
|
kwargs["tokenizer"] = getattr(
|
|
processing_class,
|
|
"tokenizer",
|
|
processing_class,
|
|
)
|
|
kwargs.setdefault("tokenizer", None)
|
|
|
|
data_collator = kwargs.pop("data_collator", None)
|
|
if data_collator is not None:
|
|
if isinstance(data_collator, UnslothVisionDataCollator):
|
|
collator_processor = getattr(data_collator, "processor", None)
|
|
if collator_processor is not None and (
|
|
kwargs.get("processor", None) is None or processor_from_processing_class
|
|
):
|
|
kwargs["processor"] = collator_processor
|
|
if kwargs.get("tokenizer", None) is None:
|
|
kwargs["tokenizer"] = getattr(
|
|
collator_processor,
|
|
"tokenizer",
|
|
collator_processor,
|
|
)
|
|
collator_kwargs = getattr(data_collator, "kwargs", None) or {}
|
|
collator_explicit_kwargs = getattr(
|
|
data_collator,
|
|
"_unsloth_mlx_explicit_kwargs",
|
|
set(collator_kwargs),
|
|
)
|
|
collator_image_size = collator_kwargs.get(
|
|
"image_size",
|
|
collator_kwargs.get("resize", None),
|
|
)
|
|
if isinstance(collator_image_size, list):
|
|
collator_image_size = tuple(collator_image_size)
|
|
if (
|
|
isinstance(collator_image_size, str)
|
|
and collator_image_size.lower() == "max"
|
|
):
|
|
collator_image_size = "max"
|
|
if "image_size" not in kwargs and (
|
|
isinstance(collator_image_size, int)
|
|
or collator_image_size == "max"
|
|
or (
|
|
isinstance(collator_image_size, tuple)
|
|
and len(collator_image_size) == 2
|
|
and all(isinstance(x, int) for x in collator_image_size)
|
|
)
|
|
):
|
|
kwargs["image_size"] = collator_image_size
|
|
for collator_key in _MLX_VISION_COLLATOR_FORWARDED_KWARGS:
|
|
collator_defaulted_value = collator_key not in collator_explicit_kwargs
|
|
if collator_defaulted_value and _has_mlx_training_arg_value(
|
|
kwargs.get("args"), collator_key
|
|
):
|
|
continue
|
|
if (
|
|
collator_key in collator_kwargs
|
|
and collator_key not in kwargs
|
|
and collator_kwargs[collator_key] is not None
|
|
):
|
|
kwargs[collator_key] = collator_kwargs[collator_key]
|
|
_raise_unsupported_mlx_vision_collator_kwargs(collator_kwargs)
|
|
elif _is_mlx_native_text_collator(data_collator):
|
|
pass # redundant on MLX; MLXTrainer batches/masks/pads natively
|
|
else:
|
|
raise NotImplementedError(
|
|
"Unsloth MLX: custom data_collator is not supported by "
|
|
"MLXTrainer. Pass the dataset directly or use the MLX "
|
|
"trainer's native batching path."
|
|
)
|
|
|
|
trainer_kwargs, config_kwargs, ignored_kwargs = _split_mlx_trainer_kwargs(kwargs)
|
|
_raise_unsupported_mlx_trainer_kwargs(ignored_kwargs)
|
|
trainer_kwargs = _filter_supported_mlx_trainer_kwargs(trainer_kwargs)
|
|
trainer_kwargs["args"] = _coerce_mlx_training_args(
|
|
trainer_kwargs.get("args"),
|
|
config_kwargs,
|
|
)
|
|
if getattr(
|
|
trainer_kwargs["args"], "completion_only_loss", None
|
|
) is True and not _is_vlm_model(trainer_kwargs.get("model")):
|
|
raise NotImplementedError(
|
|
"Unsloth MLX: completion_only_loss=True is only supported "
|
|
"for VLM training. For text SFT, call train_on_responses_only "
|
|
"after constructing the trainer."
|
|
)
|
|
if getattr(
|
|
trainer_kwargs["args"], "train_on_completions", None
|
|
) is True and not _is_vlm_model(trainer_kwargs.get("model")):
|
|
raise NotImplementedError(
|
|
"Unsloth MLX: train_on_completions=True is only supported "
|
|
"for VLM training. For text SFT, call train_on_responses_only "
|
|
"after constructing the trainer."
|
|
)
|
|
trainer_kwargs["args"] = _apply_unsloth_trainer_mlx_defaults(
|
|
trainer_kwargs["args"],
|
|
model = trainer_kwargs.get("model"),
|
|
max_seq_length_explicit = (trainer_kwargs.get("max_seq_length") is not None),
|
|
)
|
|
|
|
super().__init__(**trainer_kwargs)
|
|
self.processing_class = (
|
|
processing_class
|
|
if processing_class is not None
|
|
else self.processor or self.tokenizer
|
|
)
|
|
if trainer_kwargs.get("max_seq_length") is not None:
|
|
_set_mlx_cuda_style_context_length(
|
|
self.args,
|
|
self.args.max_seq_length,
|
|
)
|
|
self._unsloth_mlx_ignored_trainer_kwargs = ignored_kwargs
|
|
|
|
class UnslothVisionDataCollator:
|
|
def __init__(
|
|
self,
|
|
model = None,
|
|
processor = None,
|
|
*args,
|
|
**kwargs,
|
|
):
|
|
explicit_kwargs = set(kwargs)
|
|
if len(args) > len(_MLX_VISION_COLLATOR_POSITIONAL_KWARGS):
|
|
raise TypeError(
|
|
"UnslothVisionDataCollator on MLX accepts at most "
|
|
f"{len(_MLX_VISION_COLLATOR_POSITIONAL_KWARGS)} positional "
|
|
"options after model and processor."
|
|
)
|
|
for key, value in zip(_MLX_VISION_COLLATOR_POSITIONAL_KWARGS, args):
|
|
if key in kwargs:
|
|
raise TypeError(
|
|
f"UnslothVisionDataCollator got multiple values for argument {key!r}"
|
|
)
|
|
kwargs[key] = value
|
|
explicit_kwargs.add(key)
|
|
if "completion_only_loss" not in kwargs:
|
|
kwargs["completion_only_loss"] = True
|
|
self.model = model
|
|
self.processor = processor
|
|
self.args = ()
|
|
self.kwargs = kwargs
|
|
self._unsloth_mlx_explicit_kwargs = explicit_kwargs
|
|
|
|
def __call__(self, features):
|
|
raise NotImplementedError(
|
|
"Unsloth: UnslothVisionDataCollator is a compatibility placeholder "
|
|
"on MLX. Pass the dataset to UnslothTrainer; MLXTrainer performs "
|
|
"vision batching internally."
|
|
)
|
|
|
|
def get_chat_template(*args, **kwargs):
|
|
"""Apply an Unsloth chat template through a lazy MLX-safe import."""
|
|
from .chat_templates import get_chat_template as _get_chat_template
|
|
return _get_chat_template(*args, **kwargs)
|
|
|
|
def apply_chat_template(*args, **kwargs):
|
|
"""Format a dataset with an Unsloth chat template through a lazy import."""
|
|
from .chat_templates import apply_chat_template as _apply_chat_template
|
|
return _apply_chat_template(*args, **kwargs)
|
|
|
|
def standardize_data_formats(*args, **kwargs):
|
|
"""Normalize ShareGPT-style datasets through the shared zoo helper."""
|
|
from unsloth_zoo.dataset_utils import standardize_data_formats as _standardize_data_formats
|
|
return _standardize_data_formats(*args, **kwargs)
|
|
|
|
def standardize_sharegpt(*args, **kwargs):
|
|
"""Alias ShareGPT standardization to the shared dataset-format helper."""
|
|
return standardize_data_formats(*args, **kwargs)
|
|
|
|
def train_on_responses_only(*args, **kwargs):
|
|
"""Mask non-response tokens through the shared zoo dataset helper."""
|
|
from unsloth_zoo.dataset_utils import train_on_responses_only as _train_on_responses_only
|
|
return _train_on_responses_only(*args, **kwargs)
|
|
|
|
def _safe_mlx_trl_star_exports(_trl):
|
|
"""Return importable TRL star exports plus the MLX SFT shims."""
|
|
exports = list(getattr(_trl, "__all__", ()))
|
|
safe_exports = []
|
|
for name in exports:
|
|
try:
|
|
getattr(_trl, name)
|
|
except Exception:
|
|
continue
|
|
safe_exports.append(name)
|
|
for name in ("SFTConfig", "SFTTrainer"):
|
|
if name not in safe_exports:
|
|
safe_exports.append(name)
|
|
return safe_exports
|
|
|
|
# trl trainers with no MLX implementation yet. Swap them for stubs that fail
|
|
# with a clear message instead of importing the real torch/CUDA trainer and
|
|
# crashing deep inside it, so an unmigrated GRPO/DPO/ORPO notebook is legible.
|
|
_MLX_UNSUPPORTED_TRL_TRAINERS = (
|
|
"GRPOTrainer",
|
|
"DPOTrainer",
|
|
"ORPOTrainer",
|
|
"KTOTrainer",
|
|
"PPOTrainer",
|
|
"RewardTrainer",
|
|
)
|
|
|
|
def _make_mlx_unsupported_trl_trainer(name):
|
|
def __init__(self, *args, **kwargs):
|
|
raise NotImplementedError(
|
|
f"Unsloth: {name} is not yet supported on the MLX (Apple Silicon) "
|
|
f"backend. Only SFT training runs on MLX today; use a CUDA/ROCm GPU "
|
|
f"for {name}."
|
|
)
|
|
|
|
return type(name, (), {"__init__": __init__, "_unsloth_mlx_unsupported": True})
|
|
|
|
class _MLXSFTConfig(UnslothTrainingArguments):
|
|
"""`trl.SFTConfig` alias that keeps TRL's default training length.
|
|
|
|
TRL/HF SFTConfig defaults to num_train_epochs=3 (max_steps=-1); the
|
|
native MLX config defaults to max_steps=60. An unmigrated notebook that
|
|
builds SFTConfig without an explicit length would otherwise silently run
|
|
60 MLX steps under this alias, so seed the TRL epoch default when neither
|
|
max_steps nor num_train_epochs is given (epoch mode is MLX-supported).
|
|
"""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
keys = set(kwargs)
|
|
if len(args) == 1 and isinstance(args[0], dict):
|
|
keys |= set(args[0])
|
|
if not ({"max_steps", "num_train_epochs"} & keys):
|
|
kwargs.setdefault("num_train_epochs", 3)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def _install_mlx_trl_sft_shim():
|
|
"""Install MLX-backed TRL SFT shims without replacing the TRL module."""
|
|
_trl = _sys.modules.get("trl")
|
|
if _trl is None:
|
|
try:
|
|
import trl as _trl
|
|
except ImportError:
|
|
_trl = _types.ModuleType("trl")
|
|
_trl.__version__ = "0.0.0+unsloth-mlx"
|
|
_trl.__package__ = "trl"
|
|
_trl.__path__ = []
|
|
_trl.__spec__ = _machinery.ModuleSpec("trl", loader = None, is_package = True)
|
|
_sys.modules["trl"] = _trl
|
|
|
|
_trl.SFTTrainer = UnslothTrainer
|
|
_trl.SFTConfig = _MLXSFTConfig
|
|
# Only retarget trainers the installed trl actually exposes (don't invent
|
|
# attributes); idempotent so re-importing unsloth is a no-op.
|
|
# Decide what to stub from trl's declared exports (__all__) and already
|
|
# materialized attrs only. A getattr probe here would trigger trl's lazy
|
|
# trainer import, pulling torch and breaking `import unsloth` on torch-free
|
|
# MLX just to check existence.
|
|
_trl_exports = set(getattr(_trl, "__all__", ()) or ())
|
|
# Stub every non-SFT trainer trl exposes, not just a fixed list, so newer
|
|
# trainers (RLOOTrainer, ...) also fail with a clear MLX message instead
|
|
# of importing the real torch trainer. Names come from __all__ so we never
|
|
# resolve them (that would trigger trl's lazy import and pull torch).
|
|
_unsupported = set(_MLX_UNSUPPORTED_TRL_TRAINERS) | {
|
|
_n for _n in _trl_exports if _n.endswith("Trainer") and _n != "SFTTrainer"
|
|
}
|
|
for _name in _unsupported:
|
|
_current = vars(_trl).get(_name)
|
|
if getattr(_current, "_unsloth_mlx_unsupported", False):
|
|
continue
|
|
if _name in _trl_exports or _current is not None:
|
|
setattr(_trl, _name, _make_mlx_unsupported_trl_trainer(_name))
|
|
_trl.__all__ = _safe_mlx_trl_star_exports(_trl)
|
|
_trl.__UNSLOTH_MLX_COMPAT__ = True
|
|
|
|
def _install_mlx_unsloth_trainer_shim():
|
|
module_name = f"{__name__}.trainer"
|
|
_trainer = _types.ModuleType(module_name)
|
|
_trainer.__package__ = __name__
|
|
_trainer.__spec__ = _machinery.ModuleSpec(module_name, loader = None)
|
|
_trainer.MLXTrainer = MLXTrainer
|
|
_trainer.MLXTrainingConfig = MLXTrainingConfig
|
|
_trainer.UnslothTrainer = UnslothTrainer
|
|
_trainer.UnslothTrainingArguments = UnslothTrainingArguments
|
|
_trainer.UnslothVisionDataCollator = UnslothVisionDataCollator
|
|
_sys.modules[module_name] = _trainer
|
|
globals()["trainer"] = _trainer
|
|
|
|
_install_mlx_trl_sft_shim()
|
|
_install_mlx_unsloth_trainer_shim()
|
|
|
|
else:
|
|
# GPU path: load everything from _gpu_init
|
|
from ._gpu_init import *
|
|
from ._gpu_init import __version__
|
|
|
|
def get_gpu_memory_stats():
|
|
"""Return CUDA/ROCm/XPU device stats, peak memory, and total memory in GiB."""
|
|
try:
|
|
import torch
|
|
if hasattr(torch, "xpu") and torch.xpu.is_available():
|
|
props = torch.xpu.get_device_properties(0)
|
|
peak = (
|
|
torch.xpu.max_memory_reserved()
|
|
if hasattr(torch.xpu, "max_memory_reserved")
|
|
else torch.xpu.max_memory_allocated()
|
|
)
|
|
total = getattr(props, "total_memory", 0)
|
|
return props, _bytes_to_gb(peak), _bytes_to_gb(total) or 1.0
|
|
if hasattr(torch, "cuda") and torch.cuda.is_available():
|
|
props = torch.cuda.get_device_properties(0)
|
|
peak = torch.cuda.max_memory_reserved()
|
|
total = getattr(props, "total_memory", 0)
|
|
return props, _bytes_to_gb(peak), _bytes_to_gb(total) or 1.0
|
|
except Exception:
|
|
pass
|
|
stats = _UnslothDeviceStats("Unknown GPU", 0)
|
|
return stats, 0.0, 1.0
|
|
|
|
def clear_gpu_memory():
|
|
"""Clear cached GPU memory on CUDA, ROCm, or XPU when available."""
|
|
try:
|
|
import torch
|
|
if hasattr(torch, "xpu") and torch.xpu.is_available():
|
|
torch.xpu.empty_cache()
|
|
elif hasattr(torch, "cuda") and torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
except Exception:
|
|
pass
|