Compare commits

...
Sign in to create a new pull request.

4 commits

Author SHA1 Message Date
DoubleMathew
da2b371916 pass grad norm in mlx worker 2026-05-08 00:04:55 -05:00
DoubleMathew
051b2024be Fix studio integration, local dataset files, chat templates without the torch gpu imports 2026-05-07 20:51:27 -05:00
DoubleMathew
aaa2fabd65 Merge remote-tracking branch 'origin/main' 2026-05-07 12:44:33 -05:00
DoubleMathew
797ae4cf40 mlx fixes 2026-05-07 12:43:40 -05:00
9 changed files with 155 additions and 32 deletions

View file

@ -94,11 +94,11 @@ class MLXInferenceBackend:
) )
try: try:
from unsloth_zoo.mlx_loader import FastMLXModel from unsloth_zoo.mlx.loader import FastMLXModel
except ImportError as e: except ImportError as e:
raise ImportError( raise ImportError(
"Unsloth: MLX inference requires unsloth-zoo with the MLX modules " "Unsloth: MLX inference requires unsloth-zoo with the MLX modules "
"(unsloth_zoo.mlx_loader). Reinstall via install.sh on Apple Silicon." "(unsloth_zoo.mlx.loader). Reinstall via install.sh on Apple Silicon."
) from e ) from e
model, tokenizer_or_processor = FastMLXModel.from_pretrained( model, tokenizer_or_processor = FastMLXModel.from_pretrained(

View file

@ -417,6 +417,55 @@ def _normalize_mlx_studio_scheduler(value):
return raw return raw
def _resolve_mlx_local_dataset_files(file_paths: list) -> list[str]:
"""Resolve Studio local dataset uploads without importing the GPU trainer."""
from utils.paths import resolve_dataset_path
all_files: list[str] = []
for dataset_file in file_paths or []:
file_path = (
dataset_file
if os.path.isabs(dataset_file)
else str(resolve_dataset_path(dataset_file))
)
file_path_obj = Path(file_path)
if file_path_obj.is_dir():
parquet_dir = (
file_path_obj / "parquet-files"
if (file_path_obj / "parquet-files").exists()
else file_path_obj
)
parquet_files = sorted(parquet_dir.glob("*.parquet"))
if parquet_files:
all_files.extend(str(p) for p in parquet_files)
continue
candidates: list[Path] = []
for ext in (".json", ".jsonl", ".csv", ".parquet"):
candidates.extend(sorted(file_path_obj.glob(f"*{ext}")))
if candidates:
all_files.extend(str(c) for c in candidates)
continue
raise ValueError(f"No supported data files in directory: {file_path_obj}")
all_files.append(str(file_path_obj))
return all_files
def _mlx_local_dataset_loader_for_files(files: list[str]) -> str:
first_ext = Path(files[0]).suffix.lower()
if first_ext in (".json", ".jsonl"):
return "json"
if first_ext == ".csv":
return "csv"
if first_ext == ".parquet":
return "parquet"
raise ValueError(f"Unsupported dataset format: {files[0]}")
def _run_mlx_training(event_queue, stop_queue, config): def _run_mlx_training(event_queue, stop_queue, config):
"""Self-contained MLX training path for Apple Silicon. """Self-contained MLX training path for Apple Silicon.
@ -442,8 +491,8 @@ def _run_mlx_training(event_queue, stop_queue, config):
import mlx.core as mx import mlx.core as mx
try: try:
from unsloth_zoo.mlx_loader import FastMLXModel from unsloth_zoo.mlx.loader import FastMLXModel
from unsloth_zoo.mlx_trainer import ( from unsloth_zoo.mlx.trainer import (
MLXTrainer, MLXTrainer,
MLXTrainingConfig, MLXTrainingConfig,
train_on_responses_only, train_on_responses_only,
@ -451,7 +500,7 @@ def _run_mlx_training(event_queue, stop_queue, config):
except ImportError as e: except ImportError as e:
raise ImportError( raise ImportError(
"Unsloth: MLX training requires unsloth-zoo with the MLX modules " "Unsloth: MLX training requires unsloth-zoo with the MLX modules "
"(unsloth_zoo.mlx_loader / unsloth_zoo.mlx_trainer). Reinstall via " "(unsloth_zoo.mlx.loader / unsloth_zoo.mlx.trainer). Reinstall via "
"install.sh on Apple Silicon." "install.sh on Apple Silicon."
) from e ) from e
from datasets import load_dataset from datasets import load_dataset
@ -572,7 +621,6 @@ def _run_mlx_training(event_queue, stop_queue, config):
return ds return ds
def _load_local(file_paths): def _load_local(file_paths):
from core.training.trainer import UnslothTrainer
from datasets import load_from_disk from datasets import load_from_disk
if len(file_paths) == 1: if len(file_paths) == 1:
@ -581,10 +629,10 @@ def _run_mlx_training(event_queue, stop_queue, config):
(p / "dataset_info.json").exists() or (p / "state.json").exists() (p / "dataset_info.json").exists() or (p / "state.json").exists()
): ):
return load_from_disk(str(p)) return load_from_disk(str(p))
all_files = UnslothTrainer._resolve_local_files(file_paths) all_files = _resolve_mlx_local_dataset_files(file_paths)
if not all_files: if not all_files:
raise ValueError("No local dataset files found") raise ValueError("No local dataset files found")
loader = UnslothTrainer._loader_for_files(all_files) loader = _mlx_local_dataset_loader_for_files(all_files)
return load_dataset(loader, data_files = all_files, split = "train") return load_dataset(loader, data_files = all_files, split = "train")
if hf_dataset: if hf_dataset:
@ -732,6 +780,7 @@ def _run_mlx_training(event_queue, stop_queue, config):
lr_scheduler_type = lr_scheduler_type, lr_scheduler_type = lr_scheduler_type,
optim = optim_name, optim = optim_name,
weight_decay = float(config.get("weight_decay", 0.001) or 0.001), weight_decay = float(config.get("weight_decay", 0.001) or 0.001),
max_grad_norm = float(config.get("max_grad_norm", 0.0) or 0.0),
logging_steps = 1, logging_steps = 1,
max_seq_length = max_seq_length, max_seq_length = max_seq_length,
seed = config.get("random_seed", 3407), seed = config.get("random_seed", 3407),
@ -820,7 +869,10 @@ def _run_mlx_training(event_queue, stop_queue, config):
# ── 9. Real-time progress callback ── # ── 9. Real-time progress callback ──
_send("status", status_message = f"Training {model_name}...") _send("status", status_message = f"Training {model_name}...")
def _on_step(step, total, loss, lr, tok_s, peak_gb, elapsed, num_tokens): def _on_step(
step, total, loss, lr, tok_s, peak_gb, elapsed, num_tokens,
grad_norm = None,
):
eta = (elapsed / step * (total - step)) if step > 0 else 0 eta = (elapsed / step * (total - step)) if step > 0 else 0
_send( _send(
"progress", "progress",
@ -831,7 +883,7 @@ def _run_mlx_training(event_queue, stop_queue, config):
total_steps = total, total_steps = total,
elapsed_seconds = elapsed, elapsed_seconds = elapsed,
eta_seconds = max(0, eta), eta_seconds = max(0, eta),
grad_norm = None, grad_norm = grad_norm,
num_tokens = num_tokens, num_tokens = num_tokens,
eval_loss = None, eval_loss = None,
status_message = None, status_message = None,
@ -846,6 +898,7 @@ def _run_mlx_training(event_queue, stop_queue, config):
"train/tokens_per_sec": tok_s, "train/tokens_per_sec": tok_s,
"train/peak_gb": peak_gb, "train/peak_gb": peak_gb,
"train/num_tokens": num_tokens, "train/num_tokens": num_tokens,
**({"train/grad_norm": grad_norm} if grad_norm is not None else {}),
}, },
step = step, step = step,
) )
@ -857,6 +910,8 @@ def _run_mlx_training(event_queue, stop_queue, config):
tb_writer.add_scalar("train/learning_rate", lr, step) tb_writer.add_scalar("train/learning_rate", lr, step)
tb_writer.add_scalar("train/tokens_per_sec", tok_s, step) tb_writer.add_scalar("train/tokens_per_sec", tok_s, step)
tb_writer.add_scalar("train/peak_gb", peak_gb, step) tb_writer.add_scalar("train/peak_gb", peak_gb, step)
if grad_norm is not None:
tb_writer.add_scalar("train/grad_norm", grad_norm, step)
except Exception: except Exception:
pass pass

View file

@ -56,11 +56,14 @@ def _install_fake_fast_mlx(monkeypatch, calls):
return _DummyModel(), _DummyTokenizer() return _DummyModel(), _DummyTokenizer()
unsloth_zoo_pkg = types.ModuleType("unsloth_zoo") unsloth_zoo_pkg = types.ModuleType("unsloth_zoo")
mlx_loader = types.ModuleType("unsloth_zoo.mlx_loader") mlx_pkg = types.ModuleType("unsloth_zoo.mlx")
mlx_loader = types.ModuleType("unsloth_zoo.mlx.loader")
mlx_loader.FastMLXModel = _FastMLXModel mlx_loader.FastMLXModel = _FastMLXModel
unsloth_zoo_pkg.mlx_loader = mlx_loader unsloth_zoo_pkg.mlx = mlx_pkg
mlx_pkg.loader = mlx_loader
monkeypatch.setitem(sys.modules, "unsloth_zoo", unsloth_zoo_pkg) monkeypatch.setitem(sys.modules, "unsloth_zoo", unsloth_zoo_pkg)
monkeypatch.setitem(sys.modules, "unsloth_zoo.mlx_loader", mlx_loader) monkeypatch.setitem(sys.modules, "unsloth_zoo.mlx", mlx_pkg)
monkeypatch.setitem(sys.modules, "unsloth_zoo.mlx.loader", mlx_loader)
def test_mlx_inference_text_load_forwards_studio_settings(monkeypatch): def test_mlx_inference_text_load_forwards_studio_settings(monkeypatch):

View file

@ -28,6 +28,23 @@ DEFAULT_ALPACA_TEMPLATE = """Below is an instruction that describes a task, pair
{}""" {}"""
def _is_mlx_runtime() -> bool:
try:
from unsloth_zoo.mlx.runtime import is_mlx_available
except ImportError:
return False
return is_mlx_available()
def _chat_template_kwargs() -> dict:
if not _is_mlx_runtime():
return {}
return {
"patch_saving": False,
"use_zoo_tokenizer_patch": True,
}
def get_tokenizer_chat_template(tokenizer, model_name): def get_tokenizer_chat_template(tokenizer, model_name):
""" """
Gets appropriate chat template for tokenizer based on model. Gets appropriate chat template for tokenizer based on model.
@ -60,6 +77,7 @@ def get_tokenizer_chat_template(tokenizer, model_name):
tokenizer = get_chat_template( tokenizer = get_chat_template(
tokenizer, tokenizer,
chat_template = matched_template, chat_template = matched_template,
**_chat_template_kwargs(),
) )
except Exception as e: except Exception as e:
logger.info(f"⚠️ Failed to apply Unsloth template '{matched_template}': {e}") logger.info(f"⚠️ Failed to apply Unsloth template '{matched_template}': {e}")
@ -79,6 +97,7 @@ def get_tokenizer_chat_template(tokenizer, model_name):
tokenizer = get_chat_template( tokenizer = get_chat_template(
tokenizer, tokenizer,
chat_template = "chatml", chat_template = "chatml",
**_chat_template_kwargs(),
) )
except Exception as e: except Exception as e:
logger.info(f"⚠️ Failed to apply default ChatML template: {e}") logger.info(f"⚠️ Failed to apply default ChatML template: {e}")
@ -255,7 +274,11 @@ def apply_chat_template_to_dataset(
if not (hasattr(tokenizer, 'chat_template') and tokenizer.chat_template): if not (hasattr(tokenizer, 'chat_template') and tokenizer.chat_template):
try: try:
from unsloth.chat_templates import get_chat_template from unsloth.chat_templates import get_chat_template
tokenizer = get_chat_template(tokenizer, chat_template = "alpaca") tokenizer = get_chat_template(
tokenizer,
chat_template = "alpaca",
**_chat_template_kwargs(),
)
logger.info(f"📝 Set alpaca chat template on tokenizer for model saving") logger.info(f"📝 Set alpaca chat template on tokenizer for model saving")
except Exception as e: except Exception as e:
logger.info(f"⚠️ Could not set alpaca template on tokenizer: {e}") logger.info(f"⚠️ Could not set alpaca template on tokenizer: {e}")

View file

@ -46,8 +46,8 @@ def test_wandb_init_strips_secret_keys():
def test_local_dataset_loader_uses_load_dataset_path(): def test_local_dataset_loader_uses_load_dataset_path():
src = WORKER.read_text() src = WORKER.read_text()
assert "_resolve_local_files" in src assert "_resolve_mlx_local_dataset_files" in src
assert "_loader_for_files" in src assert "_mlx_local_dataset_loader_for_files" in src
assert "data_files = all_files" in src or "data_files=all_files" in src assert "data_files = all_files" in src or "data_files=all_files" in src
@ -84,7 +84,7 @@ def test_poll_stop_returns_on_broken_pipe():
def test_unsloth_zoo_mlx_imports_have_friendly_error(): def test_unsloth_zoo_mlx_imports_have_friendly_error():
src = WORKER.read_text() src = WORKER.read_text()
assert "from unsloth_zoo.mlx_loader import FastMLXModel" in src assert "from unsloth_zoo.mlx.loader import FastMLXModel" in src
assert "from unsloth_zoo.mlx_trainer import" in src assert "from unsloth_zoo.mlx.trainer import" in src
assert "raise ImportError" in src assert "raise ImportError" in src
assert "install.sh" in src assert "install.sh" in src

View file

@ -12,16 +12,21 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import os, platform, importlib.util import os, importlib.util
os.environ["UNSLOTH_IS_PRESENT"] = "1" os.environ["UNSLOTH_IS_PRESENT"] = "1"
def _is_mlx_available():
try:
from unsloth_zoo.mlx.runtime import is_mlx_available
except ImportError:
return False
return is_mlx_available()
# Detect Apple Silicon + MLX before any torch/numpy imports # Detect Apple Silicon + MLX before any torch/numpy imports
_IS_MLX = ( _IS_MLX = _is_mlx_available()
platform.system() == "Darwin"
and platform.machine() == "arm64"
and importlib.util.find_spec("mlx") is not None
)
if _IS_MLX: if _IS_MLX:
try: try:
@ -31,18 +36,18 @@ if _IS_MLX:
"Unsloth: MLX support requires `unsloth-zoo` with MLX modules. " "Unsloth: MLX support requires `unsloth-zoo` with MLX modules. "
"Reinstall with `pip install unsloth-zoo` or rerun install.sh." "Reinstall with `pip install unsloth-zoo` or rerun install.sh."
) from _e ) from _e
# The mlx_trainer / mlx_loader submodules ship with unsloth-zoo's MLX # The mlx.trainer / mlx.loader submodules ship with unsloth-zoo's MLX
# support. An older installed unsloth-zoo (e.g. from PyPI before the # support. An older installed unsloth-zoo (e.g. from PyPI before the
# MLX release lands) will satisfy `import unsloth_zoo` but be missing # MLX release lands) will satisfy `import unsloth_zoo` but be missing
# these submodules. Surface the same friendly install hint instead of # these submodules. Surface the same friendly install hint instead of
# a raw ImportError on the submodule path. # a raw ImportError on the submodule path.
try: try:
from unsloth_zoo.mlx_trainer import MLXTrainer, MLXTrainingConfig from unsloth_zoo.mlx.trainer import MLXTrainer, MLXTrainingConfig
from unsloth_zoo.mlx_loader import FastMLXModel from unsloth_zoo.mlx.loader import FastMLXModel
except ImportError as _e: except ImportError as _e:
raise ImportError( raise ImportError(
"Unsloth: MLX support requires an unsloth-zoo build that includes " "Unsloth: MLX support requires an unsloth-zoo build that includes "
"`unsloth_zoo.mlx_trainer` and `unsloth_zoo.mlx_loader`. Upgrade with " "`unsloth_zoo.mlx.trainer` and `unsloth_zoo.mlx.loader`. Upgrade with "
"`pip install -U unsloth-zoo` or rerun install.sh." "`pip install -U unsloth-zoo` or rerun install.sh."
) from _e ) from _e

View file

@ -30,11 +30,9 @@ __all__ = [
from transformers import StoppingCriteria, StoppingCriteriaList from transformers import StoppingCriteria, StoppingCriteriaList
from torch import LongTensor, FloatTensor from torch import LongTensor, FloatTensor
from transformers.models.llama.modeling_llama import logger from transformers.models.llama.modeling_llama import logger
from .save import patch_saving_functions
import os import os
import shutil import shutil
from .tokenizer_utils import * from .tokenizer_utils import *
from .models._utils import patch_tokenizer
import re import re
from .ollama_template_mappers import OLLAMA_TEMPLATES from .ollama_template_mappers import OLLAMA_TEMPLATES
from unsloth_zoo.dataset_utils import ( from unsloth_zoo.dataset_utils import (
@ -1844,6 +1842,8 @@ def get_chat_template(
mapping = {"role" : "role", "content" : "content", "user" : "user", "assistant" : "assistant"}, mapping = {"role" : "role", "content" : "content", "user" : "user", "assistant" : "assistant"},
map_eos_token = True, map_eos_token = True,
system_message = None, system_message = None,
patch_saving = True,
use_zoo_tokenizer_patch = False,
): ):
assert(type(map_eos_token) is bool) assert(type(map_eos_token) is bool)
old_tokenizer = tokenizer old_tokenizer = tokenizer
@ -2026,6 +2026,12 @@ def get_chat_template(
.replace("'user'", "'" + mapping["user"] + "'")\ .replace("'user'", "'" + mapping["user"] + "'")\
.replace("'assistant'", "'" + mapping["assistant"] + "'") .replace("'assistant'", "'" + mapping["assistant"] + "'")
if use_zoo_tokenizer_patch:
# Studio MLX avoids the model-utils tokenizer wrapper because that
# import path pulls in Torch/GPU-specific modules before MLX training.
from unsloth_zoo.tokenizer_utils import patch_tokenizer
else:
from .models._utils import patch_tokenizer
_, tokenizer = patch_tokenizer(model = None, tokenizer = tokenizer) _, tokenizer = patch_tokenizer(model = None, tokenizer = tokenizer)
tokenizer.padding_side = old_padding_side tokenizer.padding_side = old_padding_side
@ -2059,7 +2065,9 @@ def get_chat_template(
# stopping_criteria = create_stopping_criteria(tokenizer, stop_word) # stopping_criteria = create_stopping_criteria(tokenizer, stop_word)
# Patch saving functions # Patch saving functions
tokenizer = patch_saving_functions(tokenizer) if patch_saving:
from .save import patch_saving_functions
tokenizer = patch_saving_functions(tokenizer)
# Add Ollama # Add Ollama
tokenizer._ollama_modelfile = ollama_modelfile tokenizer._ollama_modelfile = ollama_modelfile

View file

@ -20,21 +20,40 @@ __all__ = [
"DEVICE_COUNT", "DEVICE_COUNT",
"ALLOW_PREQUANTIZED_MODELS", "ALLOW_PREQUANTIZED_MODELS",
"ALLOW_BITSANDBYTES", "ALLOW_BITSANDBYTES",
"is_mlx_available",
] ]
import torch
import functools import functools
import inspect import inspect
import os
from unsloth_zoo.utils import Version from unsloth_zoo.utils import Version
def is_mlx_available():
try:
from unsloth_zoo.mlx.runtime import is_mlx_available as _is_mlx_available
except ImportError:
return False
return _is_mlx_available()
_IS_MLX = is_mlx_available()
if not _IS_MLX:
import torch
@functools.cache @functools.cache
def is_hip(): def is_hip():
if _IS_MLX:
return False
return bool(getattr(getattr(torch, "version", None), "hip", None)) return bool(getattr(getattr(torch, "version", None), "hip", None))
@functools.cache @functools.cache
def get_device_type(): def get_device_type():
if _IS_MLX:
return "mlx"
if hasattr(torch, "cuda") and torch.cuda.is_available(): if hasattr(torch, "cuda") and torch.cuda.is_available():
if is_hip(): if is_hip():
return "hip" return "hip"
@ -64,6 +83,8 @@ DEVICE_TYPE: str = get_device_type()
DEVICE_TYPE_TORCH = DEVICE_TYPE DEVICE_TYPE_TORCH = DEVICE_TYPE
if DEVICE_TYPE_TORCH == "hip": if DEVICE_TYPE_TORCH == "hip":
DEVICE_TYPE_TORCH = "cuda" DEVICE_TYPE_TORCH = "cuda"
elif DEVICE_TYPE_TORCH == "mlx":
DEVICE_TYPE_TORCH = "mps"
@functools.cache @functools.cache

View file

@ -160,6 +160,9 @@ else:
# INTEL GPU Specific Logic # INTEL GPU Specific Logic
if DEVICE_TYPE == "xpu": if DEVICE_TYPE == "xpu":
_gpu_getCurrentRawStream = torch._C._xpu_getCurrentRawStream _gpu_getCurrentRawStream = torch._C._xpu_getCurrentRawStream
elif DEVICE_TYPE == "mlx":
def _gpu_getCurrentRawStream(_index = 0):
return 0
# NVIDIA GPU Default Logic # NVIDIA GPU Default Logic
elif hasattr(torch._C, "_cuda_getCurrentRawStream"): elif hasattr(torch._C, "_cuda_getCurrentRawStream"):
_gpu_getCurrentRawStream = torch._C._cuda_getCurrentRawStream _gpu_getCurrentRawStream = torch._C._cuda_getCurrentRawStream
@ -206,6 +209,11 @@ if DEVICE_TYPE == "xpu":
XPU_STREAMS = () XPU_STREAMS = ()
WEIGHT_BUFFERS = [] WEIGHT_BUFFERS = []
ABSMAX_BUFFERS = [] ABSMAX_BUFFERS = []
elif DEVICE_TYPE == "mlx":
CUDA_STREAMS = ()
XPU_STREAMS = ()
WEIGHT_BUFFERS = []
ABSMAX_BUFFERS = []
else: else:
# NVIDIA GPU Default Logic # NVIDIA GPU Default Logic
if DEVICE_COUNT > 0: if DEVICE_COUNT > 0: