Add MLX backend support for CLI unsloth train (#6709)

* feat(studio): route CLI trainer to MLX backend

* fix(studio): harden MLX trainer routing

* fix(studio): harden MLX trainer adapter routing

* test(studio): assert MLX CLI activation order

* fix(studio): address MLX CLI review feedback

* feat(cli): support MLX in legacy script

* fix(cli): adapt MLX tokenizer for raw text

* fix(cli): omit unsupported MLX eval batch arg

* fix(cli): feed raw text to MLX trainer

* Fix CLI MLX routing and Python 3.9 annotations

Route the MLX backend through create_mlx_trainer_adapter so the torch-free
Apple Silicon path never imports trainer.py (torch/unsloth/trl). Replace
from __future__ import annotations with typing.Optional/Union so the CLI
annotations stay Python 3.9 compatible without the unused-import lint hit.

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

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

* Strip return_tensors from MLX raw-text tokenizer proxy

On a torch-free MLX install, RawTextDataLoader calls the tokenizer with
return_tensors='pt'; the callable proxy forwarded that to the HF
tokenizer, which tried to build torch tensors and failed before
training. Drop return_tensors so the MLX path returns plain token ids.

* Tighten CLI MLX-backend comments

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Long Yixing 2026-07-08 18:25:26 +08:00 committed by GitHub
commit 38dacb8a1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1162 additions and 262 deletions

View file

@ -62,7 +62,6 @@ from loggers import get_logger
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Callable
from dataclasses import dataclass
import pandas as pd
from datasets import Dataset
from utils.datasets.cache_safe import load_dataset_cache_safe as load_dataset
@ -86,6 +85,11 @@ from utils.native_path_leases import child_env_without_native_path_secret
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
)
from .training import (
TrainingProgress,
create_mlx_trainer_adapter,
should_use_mlx_training_backend,
)
logger = get_logger(__name__)
@ -104,31 +108,16 @@ def _build_report_targets(training_args) -> list[str] | str:
return report_to or "none"
@dataclass
class TrainingProgress:
"""Training progress tracking"""
epoch: float = 0
step: int = 0
total_steps: int = 0
loss: Optional[float] = None
learning_rate: Optional[float] = None
is_training: bool = False
is_completed: bool = False
error: Optional[str] = None
status_message: str = "Ready to train" # Current stage
elapsed_seconds: Optional[float] = None
eta_seconds: Optional[float] = None
grad_norm: Optional[float] = None
num_tokens: Optional[int] = None
eval_loss: Optional[float] = None
class UnslothTrainer:
"""
Unsloth Training Backend
"""
def __new__(cls, *args, **kwargs):
if cls is UnslothTrainer and should_use_mlx_training_backend():
return create_mlx_trainer_adapter(*args, **kwargs)
return super().__new__(cls)
def __init__(self):
self.model = None
self.tokenizer = None

View file

@ -14,17 +14,19 @@ import json as _json
import math
import multiprocessing as mp
import os
import platform
import queue
import re
import shutil
import threading
import time
import traceback
import structlog
from datetime import datetime, timezone
from loggers import get_logger
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Optional, Tuple, Any, TYPE_CHECKING
from typing import Optional, Tuple, Any, Callable, Union, TYPE_CHECKING
if TYPE_CHECKING:
import matplotlib.pyplot as plt
@ -98,6 +100,107 @@ def _coerce_optional_nonneg_float(name: str, value):
return coerced
def is_apple_silicon_training_platform() -> bool:
return platform.system() == "Darwin" and platform.machine() == "arm64"
def is_mlx_training_device(device: Any) -> bool:
return (
str(device).lower() == "mlx"
or str(device).lower().endswith(".mlx")
or getattr(device, "name", "").lower() == "mlx"
)
def should_use_mlx_training_backend(*, device: Optional[Any] = None) -> bool:
if device is not None:
return is_mlx_training_device(device)
return is_apple_silicon_training_platform()
def _build_training_worker_config(values: dict[str, Any]) -> dict[str, Any]:
"""Build the normalized worker config shared by Studio and the CLI adapter."""
config = {
"model_name": values["model_name"],
"project_name": values.get("project_name"),
"training_type": values.get("training_type", "LoRA/QLoRA"),
"hf_token": values.get("hf_token", ""),
"load_in_4bit": values.get("load_in_4bit", True),
"max_seq_length": values.get("max_seq_length", 2048),
"vision_image_size": values.get("vision_image_size"),
"hf_dataset": values.get("hf_dataset", ""),
"local_datasets": values.get("local_datasets"),
"local_eval_datasets": values.get("local_eval_datasets"),
"format_type": values.get("format_type", ""),
"subset": values.get("subset"),
"train_split": values.get("train_split", "train"),
"eval_split": values.get("eval_split"),
"eval_steps": values.get("eval_steps", 0.00),
"dataset_streaming": values.get("dataset_streaming", False),
"dataset_slice_start": values.get("dataset_slice_start"),
"dataset_slice_end": values.get("dataset_slice_end"),
"custom_format_mapping": values.get("custom_format_mapping"),
"is_dataset_image": values.get("is_dataset_image", False),
"is_dataset_audio": values.get("is_dataset_audio", False),
"is_embedding": values.get("is_embedding", False),
"num_epochs": values.get("num_epochs", 3),
"learning_rate": values.get("learning_rate", "2e-4"),
"embedding_learning_rate": values.get("embedding_learning_rate"),
"batch_size": values.get("batch_size", 2),
"gradient_accumulation_steps": values.get("gradient_accumulation_steps", 4),
"warmup_steps": values.get("warmup_steps"),
"warmup_ratio": values.get("warmup_ratio"),
"max_steps": values.get("max_steps", 0),
"save_steps": values.get("save_steps", 0),
"weight_decay": values.get("weight_decay", 0.001),
"max_grad_norm": values.get("max_grad_norm", 0.0),
"max_grad_value": _coerce_optional_nonneg_float(
"max_grad_value", values.get("max_grad_value")
),
"max_grad_leaf_norm": _coerce_optional_nonneg_float(
"max_grad_leaf_norm", values.get("max_grad_leaf_norm")
),
"cast_norm_output_to_input_dtype": _coerce_optional_bool(
values.get("cast_norm_output_to_input_dtype"), True
),
"random_seed": _coerce_seed(values.get("random_seed")),
"packing": values.get("packing", False),
"optim": values.get("optim", "adamw_8bit"),
"lr_scheduler_type": values.get("lr_scheduler_type", "linear"),
"use_lora": values.get("use_lora", True),
"lora_r": values.get("lora_r", 16),
"lora_alpha": values.get("lora_alpha", 16),
"lora_dropout": values.get("lora_dropout", 0.0),
"target_modules": values.get("target_modules"),
"gradient_checkpointing": values.get("gradient_checkpointing", "unsloth"),
"use_rslora": values.get("use_rslora", False),
"use_loftq": values.get("use_loftq", False),
"train_on_completions": values.get("train_on_completions", False),
"finetune_vision_layers": values.get("finetune_vision_layers", True),
"finetune_language_layers": values.get("finetune_language_layers", True),
"finetune_attention_modules": values.get("finetune_attention_modules", True),
"finetune_mlp_modules": values.get("finetune_mlp_modules", True),
"enable_wandb": values.get("enable_wandb", False),
"wandb_token": values.get("wandb_token"),
"wandb_project": values.get("wandb_project", "unsloth-training"),
"enable_tensorboard": values.get("enable_tensorboard", False),
"tensorboard_dir": values.get("tensorboard_dir", "runs"),
"resume_from_checkpoint": values.get("resume_from_checkpoint"),
"trust_remote_code": values.get("trust_remote_code", False),
"approved_remote_code_fingerprint": values.get("approved_remote_code_fingerprint"),
"subject": values.get("subject"),
"gpu_ids": values.get("gpu_ids"),
"s3_config": values.get("s3_config"),
"disable_xet": values.get("disable_xet", False),
}
for key in ("output_dir", "allow_external_output_dir"):
if key in values:
config[key] = values.get(key)
if config["training_type"] == "Full Finetuning":
config["load_in_4bit"] = False
return config
_HF_TMP_CHECKPOINT_RE = re.compile(r"^tmp-checkpoint-\d+$")
@ -133,7 +236,7 @@ def _s3_dataset_name(s3_dataset: Any) -> Optional[str]:
return f"s3://{bucket}/{prefix}" if prefix else f"s3://{bucket}"
def _cleanup_cancelled_checkpoints(output_dir: str | os.PathLike) -> None:
def _cleanup_cancelled_checkpoints(output_dir: Union[str, os.PathLike]) -> None:
"""Remove only HF Trainer ``tmp-checkpoint-<step>/`` partials after a cancel.
Completed ``checkpoint-<int>/`` dirs survive. Symlinked output_dir / children
@ -183,7 +286,7 @@ PLOT_HEIGHT = 3.5
@dataclass
class TrainingProgress:
"""Mirror of trainer.TrainingProgress so the parent never imports heavy ML modules."""
"""Shared training progress payload for Studio and backend-aware trainers."""
epoch: float = 0
step: int = 0
@ -200,6 +303,423 @@ class TrainingProgress:
num_tokens: Optional[int] = None
eval_loss: Optional[float] = None
peak_memory_gb: Optional[float] = None
output_dir: Optional[str] = None
class _MLXTrainerAdapter:
"""Adapts the legacy UnslothTrainer API to the shared Studio MLX worker path."""
def __init__(self):
self.model = None
self.tokenizer = None
self.trainer = None
self.training_thread = None
self.training_progress = TrainingProgress()
self.progress_callbacks: list[Callable[[TrainingProgress], None]] = []
self.is_training = False
self.should_stop = False
self.save_on_stop = True
self.load_in_4bit = True
self.output_dir = None
self.is_cpt = False
self.is_vlm = False
self.is_audio = False
self.is_audio_vlm = False
self.model_name = None
self.max_seq_length = None
self._model_config: dict[str, Any] = {}
self._peft_config: dict[str, Any] = {}
self._dataset_config: dict[str, Any] = {}
self._event_queue: Optional[queue.Queue] = None
self._stop_queue: Optional[queue.Queue] = None
self._pump_thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
def _activate_transformers_for_model(self, model_name: str, hf_token: Optional[str]) -> None:
try:
from utils.transformers_version import activate_transformers_for_subprocess
activate_transformers_for_subprocess(model_name, hf_token)
except Exception as exc:
logger.warning("MLX trainer adapter Transformers activation failed", error = str(exc))
def add_progress_callback(self, callback: Callable[[TrainingProgress], None]):
self.progress_callbacks.append(callback)
def _update_progress(self, **kwargs):
with self._lock:
for key, value in kwargs.items():
if hasattr(self.training_progress, key):
setattr(self.training_progress, key, value)
progress = self.training_progress
for callback in self.progress_callbacks:
try:
callback(progress)
except Exception:
pass
def load_model(
self,
model_name: str,
max_seq_length: int = 2048,
load_in_4bit: bool = True,
hf_token: Optional[str] = None,
is_dataset_image: bool = False,
is_dataset_audio: bool = False,
trust_remote_code: bool = False,
full_finetuning: bool = False,
gpu_ids: Optional[list[int]] = None,
) -> bool:
self.model_name = model_name
self.max_seq_length = max_seq_length
self.load_in_4bit = load_in_4bit
self._audio_type = None
self._activate_transformers_for_model(model_name, hf_token)
try:
from utils.models import detect_audio_type, is_vision_model
self._audio_type = detect_audio_type(model_name, hf_token)
if self._audio_type == "audio_vlm":
self.is_audio = False
self.is_audio_vlm = bool(is_dataset_audio)
self._audio_type = None
else:
self.is_audio = self._audio_type is not None
self.is_audio_vlm = False
vision = is_vision_model(model_name, hf_token = hf_token) if not self.is_audio else False
self.is_vlm = not self.is_audio_vlm and vision and bool(is_dataset_image)
except Exception as exc:
logger.warning("MLX trainer adapter model type detection failed", error = str(exc))
self.is_vlm = False
self.is_audio = False
self.is_audio_vlm = False
self.model = object()
self.tokenizer = object()
self._model_config = {
"model_name": model_name,
"max_seq_length": max_seq_length,
"load_in_4bit": load_in_4bit,
"hf_token": hf_token or "",
"is_dataset_image": bool(is_dataset_image),
"is_dataset_audio": bool(is_dataset_audio),
"trust_remote_code": bool(trust_remote_code),
"gpu_ids": gpu_ids,
}
self._update_progress(
is_training = False,
is_completed = False,
error = None,
step = 0,
loss = 0.0,
epoch = 0,
status_message = f"Queued MLX model load: {model_name}",
)
return True
def prepare_model_for_training(
self,
use_lora: bool = True,
finetune_vision_layers: bool = True,
finetune_language_layers: bool = True,
finetune_attention_modules: bool = True,
finetune_mlp_modules: bool = True,
target_modules: Optional[Union[list, str]] = None,
lora_r: int = 16,
lora_alpha: int = 16,
lora_dropout: float = 0.0,
use_gradient_checkpointing: Union[str, bool] = "unsloth",
use_rslora: bool = False,
use_loftq: bool = False,
) -> bool:
self._peft_config = {
"use_lora": bool(use_lora),
"lora_r": lora_r,
"lora_alpha": lora_alpha,
"lora_dropout": lora_dropout,
"target_modules": target_modules,
"gradient_checkpointing": use_gradient_checkpointing,
"use_rslora": bool(use_rslora),
"use_loftq": bool(use_loftq),
"finetune_vision_layers": bool(finetune_vision_layers),
"finetune_language_layers": bool(finetune_language_layers),
"finetune_attention_modules": bool(finetune_attention_modules),
"finetune_mlp_modules": bool(finetune_mlp_modules),
}
self._update_progress(status_message = "Queued MLX training setup")
return True
def load_and_format_dataset(
self,
dataset_source: Optional[str],
format_type: str = "auto",
local_datasets: Optional[list[str]] = None,
local_eval_datasets: Optional[list[str]] = None,
custom_format_mapping: Optional[dict[str, Any]] = None,
subset: Optional[str] = None,
train_split: str = "train",
eval_split: Optional[str] = None,
dataset_streaming: bool = False,
eval_steps: float = 0.00,
dataset_slice_start: Optional[int] = None,
dataset_slice_end: Optional[int] = None,
is_cpt: bool = False,
s3_config: dict = None,
) -> Optional[tuple]:
self._dataset_config = {
"hf_dataset": dataset_source or "",
"local_datasets": local_datasets,
"local_eval_datasets": local_eval_datasets,
"format_type": format_type or "",
"custom_format_mapping": custom_format_mapping,
"subset": subset,
"train_split": train_split or "train",
"eval_split": eval_split,
"dataset_streaming": bool(dataset_streaming),
"eval_steps": eval_steps or 0.0,
"dataset_slice_start": dataset_slice_start,
"dataset_slice_end": dataset_slice_end,
"s3_config": s3_config,
}
self.is_cpt = bool(is_cpt)
self._update_progress(status_message = "Queued MLX dataset load")
return ({"dataset": [], "final_format": "deferred_mlx_cli", "success": True}, None)
def start_training(
self,
dataset = None,
eval_dataset = None,
**training_args,
) -> bool:
if self.is_training and self.training_thread and self.training_thread.is_alive():
return False
if self._pump_thread and self._pump_thread.is_alive():
self._pump_thread.join(timeout = 2.0)
if self._pump_thread.is_alive():
self._update_progress(error = "Previous training event pump is still finalizing")
return False
if not self._model_config:
self._update_progress(error = "Model not loaded")
return False
if not self._dataset_config:
self._update_progress(error = "Dataset not loaded")
return False
if self.is_cpt:
self._update_progress(
error = "Continued Pretraining is not supported for MLX training yet.",
is_training = False,
is_completed = False,
)
return False
config = self._build_worker_config(training_args)
event_queue = queue.Queue()
stop_queue = queue.Queue()
self._event_queue = event_queue
self._stop_queue = stop_queue
self.should_stop = False
self.is_training = True
self.training_progress = TrainingProgress(
is_training = True,
status_message = "Initializing MLX training...",
)
self.training_thread = threading.Thread(
target = self._run_training_thread,
args = (config, event_queue, stop_queue),
daemon = True,
)
self._pump_thread = threading.Thread(
target = self._pump_events,
args = (event_queue, self.training_thread),
daemon = True,
)
self.training_thread.start()
self._pump_thread.start()
return True
def _build_worker_config(self, training_args: dict[str, Any]) -> dict[str, Any]:
peft = {
"use_lora": True,
"lora_r": 16,
"lora_alpha": 16,
"lora_dropout": 0.0,
"target_modules": None,
"gradient_checkpointing": "unsloth",
"use_rslora": False,
"use_loftq": False,
"finetune_vision_layers": True,
"finetune_language_layers": True,
"finetune_attention_modules": True,
"finetune_mlp_modules": True,
**self._peft_config,
}
output_dir = training_args.get("output_dir")
if output_dir:
output_dir = os.path.abspath(os.path.expanduser(str(output_dir)))
values = {
**self._model_config,
**self._dataset_config,
**training_args,
"training_type": (
"Continued Pretraining"
if self.is_cpt
else "LoRA/QLoRA"
if peft["use_lora"]
else "Full Finetuning"
),
**peft,
"output_dir": output_dir,
"allow_external_output_dir": bool(output_dir),
}
config = _build_training_worker_config(values)
config["resolved_gpu_ids"] = None
config["gpu_selection"] = None
return config
def _run_training_thread(
self, config: dict[str, Any], event_queue: queue.Queue, stop_queue: queue.Queue
):
try:
self._run_mlx_worker(config, event_queue, stop_queue)
except Exception as exc:
if event_queue is not None:
event_queue.put(
{
"type": "error",
"error": str(exc),
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
}
)
def _run_mlx_worker(
self, config: dict[str, Any], event_queue: queue.Queue, stop_queue: queue.Queue
):
from .worker import run_mlx_training_process
run_mlx_training_process(
event_queue = event_queue,
stop_queue = stop_queue,
config = config,
)
def _pump_events(self, event_queue: queue.Queue, training_thread: threading.Thread):
while True:
event = None
try:
event = event_queue.get(timeout = 0.25)
except queue.Empty:
pass
if event is not None:
self._handle_event(event)
continue
if not training_thread.is_alive():
self._drain_events(event_queue)
with self._lock:
if self.training_progress.is_training:
self.training_progress.is_training = False
if self.should_stop:
self.training_progress.status_message = "Training stopped."
elif (
not self.training_progress.error
and not self.training_progress.is_completed
):
self.training_progress.error = "Training process exited unexpectedly"
self.is_training = False
self._event_queue = None
self._stop_queue = None
return
def _drain_events(self, event_queue: Optional[queue.Queue] = None):
event_queue = event_queue or self._event_queue
if event_queue is None:
return
while True:
try:
self._handle_event(event_queue.get_nowait())
except queue.Empty:
return
def _handle_event(self, event: dict[str, Any]):
etype = event.get("type")
if etype == "status":
self._update_progress(
status_message = event.get("status_message") or event.get("message") or ""
)
return
if etype == "progress":
self._update_progress(
step = event.get("step", self.training_progress.step),
epoch = event.get("epoch", self.training_progress.epoch),
loss = event.get("loss", self.training_progress.loss),
learning_rate = event.get("learning_rate", self.training_progress.learning_rate),
total_steps = event.get("total_steps", self.training_progress.total_steps),
elapsed_seconds = event.get(
"elapsed_seconds",
self.training_progress.elapsed_seconds,
),
eta_seconds = event.get("eta_seconds", self.training_progress.eta_seconds),
grad_norm = event.get("grad_norm", self.training_progress.grad_norm),
num_tokens = event.get("num_tokens", self.training_progress.num_tokens),
eval_loss = event.get("eval_loss", self.training_progress.eval_loss),
peak_memory_gb = event.get("peak_memory_gb", self.training_progress.peak_memory_gb),
)
return
if etype == "complete":
status_message = event.get("status_message") or "Training completed"
output_dir = event.get("output_dir")
was_cancelled = self.should_stop or status_message.strip().lower() in {
"training cancelled",
"training stopped",
}
self.output_dir = output_dir
self._update_progress(
is_training = False,
is_completed = not was_cancelled,
error = None,
status_message = status_message,
output_dir = output_dir,
)
self.is_training = False
return
if etype == "error":
self._update_progress(
is_training = False,
is_completed = False,
error = event.get("error") or event.get("message") or "Training failed",
)
self.is_training = False
return
def stop_training(self, save: bool = True):
self.should_stop = True
self.save_on_stop = bool(save)
if self._stop_queue is not None:
self._stop_queue.put({"type": "stop", "save": save})
status_message = (
"Stopping training and saving checkpoint..." if save else "Cancelling training..."
)
self._update_progress(status_message = status_message)
return True
def get_training_progress(self) -> TrainingProgress:
pump_thread = self._pump_thread
training_thread = self.training_thread
if (
pump_thread is not None
and pump_thread.is_alive()
and (training_thread is None or not training_thread.is_alive())
and threading.current_thread() is not pump_thread
):
pump_thread.join(timeout = 5.0)
if pump_thread is None or not pump_thread.is_alive():
self._drain_events()
with self._lock:
return replace(self.training_progress)
def create_mlx_trainer_adapter(*args, **kwargs):
return _MLXTrainerAdapter(*args, **kwargs)
class TrainingBackend:
@ -296,86 +816,7 @@ class TrainingBackend:
# treat this fresh setup as a recoverable death.
self._pump_running = False
# Build config dict for the subprocess
config = {
"model_name": kwargs["model_name"],
"project_name": kwargs.get("project_name"),
"training_type": kwargs.get("training_type", "LoRA/QLoRA"),
"hf_token": kwargs.get("hf_token", ""),
"load_in_4bit": kwargs.get("load_in_4bit", True),
"max_seq_length": kwargs.get("max_seq_length", 2048),
"vision_image_size": kwargs.get("vision_image_size"),
"hf_dataset": kwargs.get("hf_dataset", ""),
"local_datasets": kwargs.get("local_datasets"),
"local_eval_datasets": kwargs.get("local_eval_datasets"),
"format_type": kwargs.get("format_type", ""),
"subset": kwargs.get("subset"),
"train_split": kwargs.get("train_split", "train"),
"eval_split": kwargs.get("eval_split"),
"eval_steps": kwargs.get("eval_steps", 0.00),
"dataset_streaming": kwargs.get("dataset_streaming", False),
"dataset_slice_start": kwargs.get("dataset_slice_start"),
"dataset_slice_end": kwargs.get("dataset_slice_end"),
"custom_format_mapping": kwargs.get("custom_format_mapping"),
"is_dataset_image": kwargs.get("is_dataset_image", False),
"is_dataset_audio": kwargs.get("is_dataset_audio", False),
"is_embedding": kwargs.get("is_embedding", False),
"num_epochs": kwargs.get("num_epochs", 3),
"learning_rate": kwargs.get("learning_rate", "2e-4"),
"embedding_learning_rate": kwargs.get("embedding_learning_rate"),
"batch_size": kwargs.get("batch_size", 2),
"gradient_accumulation_steps": kwargs.get("gradient_accumulation_steps", 4),
"warmup_steps": kwargs.get("warmup_steps"),
"warmup_ratio": kwargs.get("warmup_ratio"),
"max_steps": kwargs.get("max_steps", 0),
"save_steps": kwargs.get("save_steps", 0),
"weight_decay": kwargs.get("weight_decay", 0.001),
"max_grad_norm": kwargs.get("max_grad_norm", 0.0),
"max_grad_value": _coerce_optional_nonneg_float(
"max_grad_value", kwargs.get("max_grad_value")
),
"max_grad_leaf_norm": _coerce_optional_nonneg_float(
"max_grad_leaf_norm", kwargs.get("max_grad_leaf_norm")
),
"cast_norm_output_to_input_dtype": _coerce_optional_bool(
kwargs.get("cast_norm_output_to_input_dtype"), True
),
# MLX/CUDA/embedding workers need an int (transformers.set_seed(None) raises).
"random_seed": _coerce_seed(kwargs.get("random_seed")),
"packing": kwargs.get("packing", False),
"optim": kwargs.get("optim", "adamw_8bit"),
"lr_scheduler_type": kwargs.get("lr_scheduler_type", "linear"),
"use_lora": kwargs.get("use_lora", True),
"lora_r": kwargs.get("lora_r", 16),
"lora_alpha": kwargs.get("lora_alpha", 16),
"lora_dropout": kwargs.get("lora_dropout", 0.0),
"target_modules": kwargs.get("target_modules"),
"gradient_checkpointing": kwargs.get("gradient_checkpointing", "unsloth"),
"use_rslora": kwargs.get("use_rslora", False),
"use_loftq": kwargs.get("use_loftq", False),
"train_on_completions": kwargs.get("train_on_completions", False),
"finetune_vision_layers": kwargs.get("finetune_vision_layers", True),
"finetune_language_layers": kwargs.get("finetune_language_layers", True),
"finetune_attention_modules": kwargs.get("finetune_attention_modules", True),
"finetune_mlp_modules": kwargs.get("finetune_mlp_modules", True),
"enable_wandb": kwargs.get("enable_wandb", False),
"wandb_token": kwargs.get("wandb_token"),
"wandb_project": kwargs.get("wandb_project", "unsloth-training"),
"enable_tensorboard": kwargs.get("enable_tensorboard", False),
"tensorboard_dir": kwargs.get("tensorboard_dir", "runs"),
"resume_from_checkpoint": kwargs.get("resume_from_checkpoint"),
"trust_remote_code": kwargs.get("trust_remote_code", False),
"approved_remote_code_fingerprint": kwargs.get("approved_remote_code_fingerprint"),
"subject": kwargs.get("subject"),
"gpu_ids": kwargs.get("gpu_ids"),
"s3_config": kwargs.get("s3_config"),
# Flipped to True only by the HTTP-fallback respawn after a stall.
"disable_xet": kwargs.get("disable_xet", False),
}
# Full finetuning always runs in 16-bit; LoRA/QLoRA/CPT keep the request.
if config["training_type"] == "Full Finetuning":
config["load_in_4bit"] = False
config = _build_training_worker_config(kwargs)
# Split GPU validation from placement around the VRAM hook:
# * Explicit gpu_ids are validated here (raises -> the route returns 400
@ -401,7 +842,7 @@ class TrainingBackend:
)
defer_auto_selection = False
if _hw.DEVICE == _hw.DeviceType.MLX:
if should_use_mlx_training_backend(device = _hw.DEVICE):
config["resolved_gpu_ids"] = None
config["gpu_selection"] = None
elif gpu_ids:
@ -1022,17 +1463,22 @@ class TrainingBackend:
self._progress.is_training = True
elif etype == "complete":
self._progress.is_training = False
self._progress.is_completed = True
self._output_dir = event.get("output_dir")
msg = event.get("status_message", "Training completed")
stopped = self._should_stop or msg.strip().lower() in {
"training cancelled",
"training stopped",
}
self._progress.is_training = False
self._progress.is_completed = not stopped
self._output_dir = event.get("output_dir")
self._progress.output_dir = self._output_dir
self._progress.status_message = msg
if not self._db_run_created and self.current_job_id and self._db_config:
db_action = "create_and_finalize"
else:
db_action = "finalize"
db_action_kwargs = {
"status": "stopped" if self._should_stop else "completed",
"status": "stopped" if stopped else "completed",
"output_dir": self._output_dir,
}

View file

@ -1309,14 +1309,18 @@ def _normalize_mlx_studio_scheduler(value):
def _resolve_mlx_local_dataset_files(file_paths: list) -> list[str]:
"""Resolve Studio local dataset uploads without importing the GPU trainer."""
"""Resolve CLI paths and 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))
)
dataset_path = Path(os.path.expanduser(str(dataset_file)))
if dataset_path.is_absolute():
file_path = str(dataset_path)
elif dataset_path.exists():
file_path = str(dataset_path.resolve())
else:
file_path = str(resolve_dataset_path(str(dataset_file)))
file_path_obj = Path(file_path)
if file_path_obj.is_dir():
@ -1355,6 +1359,58 @@ def _mlx_local_dataset_loader_for_files(files: list[str]) -> str:
raise ValueError(f"Unsupported dataset format: {files[0]}")
_MLX_WORKER_COMPLETE = "_mlx_worker_complete"
def _start_mlx_stop_poller(stop_queue):
import queue as _queue
import threading
stop_save = [True]
stop_requested = [False]
trainer_ref = [None]
def is_stop_requested():
return stop_requested[0]
def poll_stop():
while True:
try:
msg = stop_queue.get(timeout = 0.25)
if msg and msg.get("type") == _MLX_WORKER_COMPLETE:
return
if msg and msg.get("type") == "stop":
stop_save[0] = msg.get("save", True)
stop_requested[0] = True
trainer = trainer_ref[0]
if trainer is not None:
trainer.stop_requested = True
return
except _queue.Empty:
continue
except (EOFError, OSError):
return
stop_thread = threading.Thread(target = poll_stop, daemon = True)
stop_thread.start()
return stop_save, stop_requested, trainer_ref, is_stop_requested, stop_thread
def _resolve_mlx_output_dir(config, model_name):
from utils.paths import resolve_output_dir, default_run_dir_name
output_dir = config.get("output_dir", "")
if not output_dir:
output_dir = f"{default_run_dir_name(model_name)}_{int(time.time())}"
return str(resolve_output_dir(output_dir))
if config.get("allow_external_output_dir"):
output_path = Path(output_dir).expanduser()
if not output_path.is_absolute():
output_path = Path.cwd() / output_path
return str(output_path.resolve())
return str(resolve_output_dir(output_dir))
def _run_mlx_training(event_queue, stop_queue, config):
"""Self-contained MLX training path for Apple Silicon.
@ -1363,8 +1419,6 @@ def _run_mlx_training(event_queue, stop_queue, config):
"""
import time
import math
import threading
import queue as _queue
from pathlib import Path
def _send(event_type, **kwargs):
@ -1374,31 +1428,9 @@ def _run_mlx_training(event_queue, stop_queue, config):
kwargs["message"] = sm
event_queue.put({"type": event_type, "ts": time.time(), **kwargs})
_stop_save = [True]
_stop_requested = [False]
_trainer_ref = [None]
def _is_stop_requested():
return _stop_requested[0]
def _poll_stop():
while True:
try:
msg = stop_queue.get(timeout = 1.0)
if msg and msg.get("type") == "stop":
_stop_save[0] = msg.get("save", True)
_stop_requested[0] = True
trainer = _trainer_ref[0]
if trainer is not None:
trainer.stop_requested = True
return
except _queue.Empty:
continue
except (EOFError, OSError):
return
stop_thread = threading.Thread(target = _poll_stop, daemon = True)
stop_thread.start()
_stop_save, _stop_requested, _trainer_ref, _is_stop_requested, _stop_thread = (
_start_mlx_stop_poller(stop_queue)
)
_send("status", status_message = "Loading MLX libraries...")
@ -1804,21 +1836,14 @@ def _run_mlx_training(event_queue, stop_queue, config):
# ── 5. Build output dir ──
# Resolve to ~/.unsloth/studio/outputs/ so the export page finds it
from utils.paths import resolve_output_dir, ensure_dir
from utils.paths import ensure_dir
output_dir = config.get("output_dir", "")
if not output_dir:
output_dir = build_default_output_dir_name(
model_name,
config.get("project_name"),
)
output_dir = str(resolve_output_dir(output_dir))
output_dir = _resolve_mlx_output_dir(config, model_name)
ensure_dir(Path(output_dir))
# ── 6. Create trainer ──
eval_steps_val = config.get("eval_steps", 0) or 0
if isinstance(eval_steps_val, float) and 0 < eval_steps_val < 1:
# Studio sometimes sends fraction-of-total-steps
eval_steps_val = max(1, int(eval_steps_val * max_steps))
else:
eval_steps_val = int(eval_steps_val)
@ -2043,12 +2068,27 @@ def _run_mlx_training(event_queue, stop_queue, config):
# ── 11. Run training ──
gc.collect()
mx.synchronize()
trainer.train(resume_from_checkpoint = resume_from_checkpoint)
_save_model = trainer.save_model
def _skip_internal_final_save(*args, **kwargs):
raise ValueError("worker owns final save")
trainer.save_model = _skip_internal_final_save
try:
trainer.train(resume_from_checkpoint = resume_from_checkpoint)
finally:
trainer.save_model = _save_model
# ── 12. Save and finalize ──
if trainer.stop_requested and not _stop_save[0]:
# User clicked "Cancel" (save=False) — skip saving
_send("complete", output_dir = None, status_message = "Training cancelled")
if trainer.stop_requested:
if not _stop_save[0]:
# Cancel (save=False): skip saving.
_send("complete", output_dir = None, status_message = "Training cancelled")
else:
_send("status", status_message = "Saving stopped model...")
mx.synchronize()
trainer.save_model(output_dir)
_send("complete", output_dir = output_dir, status_message = "Training stopped")
else:
_send("status", status_message = "Saving model...")
mx.synchronize()
@ -2067,6 +2107,79 @@ def _run_mlx_training(event_queue, stop_queue, config):
pass
def _is_current_process_apple_silicon() -> bool:
import platform
return platform.system() == "Darwin" and platform.machine() == "arm64"
def run_mlx_training_process(
*,
event_queue: Any,
stop_queue: Any,
config: dict,
transformers_activated: bool = False,
) -> None:
"""MLX worker entrypoint shared by Studio subprocesses and the CLI adapter."""
model_name = config["model_name"]
backend_path = str(Path(__file__).resolve().parent.parent.parent)
if backend_path not in sys.path:
sys.path.insert(0, backend_path)
from utils.hf_xet_fallback import child_should_disable_xet
if child_should_disable_xet(config):
os.environ["HF_HUB_DISABLE_XET"] = "1"
os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "0"
if not transformers_activated:
# Must precede detect_hardware(): its MLX stack check imports mlx_lm, hence transformers.
_activate_transformers_version_or_warn(model_name, config.get("hf_token") or None)
from utils.hardware import hardware as _hw
_hw.detect_hardware()
if _hw.DEVICE != _hw.DeviceType.MLX:
event_queue.put(
{
"type": "error",
"error": "MLX training requires Apple Silicon with the MLX backend available.",
"stack": "",
"ts": time.time(),
}
)
return
if config.get("is_dataset_audio"):
event_queue.put(
{
"type": "error",
"error": "Audio dataset training is not yet supported on Apple Silicon.",
"stack": "",
"ts": time.time(),
}
)
return
try:
try:
_run_mlx_training(event_queue, stop_queue, config)
finally:
try:
stop_queue.put({"type": _MLX_WORKER_COMPLETE})
except (EOFError, OSError, ValueError):
pass
except Exception as exc:
event_queue.put(
{
"type": "error",
"error": str(exc),
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
}
)
def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) -> None:
"""Subprocess entrypoint. Fresh Python — no stale module state.
@ -2141,36 +2254,26 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
if backend_path not in sys.path:
sys.path.insert(0, backend_path)
from .training import is_apple_silicon_training_platform, should_use_mlx_training_backend
mlx_backend_requested = is_apple_silicon_training_platform()
mlx_transformers_activated = False
if mlx_backend_requested and _is_current_process_apple_silicon():
# Must precede detect_hardware(): its MLX stack check imports mlx_lm, hence transformers.
_activate_transformers_version_or_warn(model_name, config.get("hf_token") or None)
mlx_transformers_activated = True
from utils.hardware import hardware as _hw
_hw.detect_hardware()
if _hw.DEVICE == _hw.DeviceType.MLX:
if config.get("is_dataset_audio"):
event_queue.put(
{
"type": "error",
"error": "Audio dataset training is not yet supported on Apple Silicon.",
"stack": "",
"ts": time.time(),
}
)
return
# Activate correct transformers version (Gemma-4 needs a 5.x sidecar, etc.)
# Must happen before any transformers/mlx-lm imports in _run_mlx_training.
# Non-fatal: fall through with whatever version is installed, but log
# the failure instead of swallowing it (issue #6103).
_activate_transformers_version_or_warn(model_name, config.get("hf_token") or None)
try:
_run_mlx_training(event_queue, stop_queue, config)
except Exception as exc:
event_queue.put(
{
"type": "error",
"error": str(exc),
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
}
)
if mlx_backend_requested or should_use_mlx_training_backend(device = _hw.DEVICE):
run_mlx_training_process(
event_queue = event_queue,
stop_queue = stop_queue,
config = config,
transformers_activated = mlx_transformers_activated,
)
return
# ── 1. Activate correct transformers version BEFORE any ML imports ──
@ -2693,7 +2796,8 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
if backend_path not in sys.path:
sys.path.insert(0, backend_path)
from core.training.trainer import UnslothTrainer, TrainingProgress
from core.training.training import TrainingProgress
from core.training.trainer import UnslothTrainer
from utils.paths import (
ensure_dir,
resolve_output_dir,

View file

@ -6,9 +6,15 @@ empty-chat-template crash) before train(). The real methods are bound onto a lig
fake self so the production logic runs against controlled batches."""
import importlib
import json
import os
import queue
import subprocess
import sys
import threading
import types
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
@ -184,5 +190,231 @@ class TestChatTemplateRendersEmpty(unittest.TestCase):
self.assertFalse(s._chat_template_renders_empty())
def _clear_trainer_module(package: str):
sys.modules.pop(f"{package}.trainer", None)
pkg = sys.modules.get(package)
if pkg is not None and hasattr(pkg, "trainer"):
delattr(pkg, "trainer")
def _set_training_platform(monkeypatch, package: str, backend: str):
training_mod = importlib.import_module(f"{package}.training")
from utils.hardware import hardware as hw
monkeypatch.setattr(hw, "DEVICE", None)
monkeypatch.setattr(
training_mod.platform,
"system",
lambda: "Darwin" if backend == "mlx" else "Linux",
)
monkeypatch.setattr(
training_mod.platform,
"machine",
lambda: "arm64" if backend == "mlx" else "x86_64",
)
def _load_trainer_module(
monkeypatch,
backend: str,
package: str = "core.training",
):
_set_training_platform(monkeypatch, package, backend)
_clear_trainer_module(package)
if package in sys.modules:
importlib.reload(sys.modules[package])
trainer_mod = importlib.import_module(f"{package}.trainer")
training_mod = importlib.import_module(f"{package}.training")
monkeypatch.setattr(
training_mod._MLXTrainerAdapter,
"_activate_transformers_for_model",
lambda self, model_name, hf_token: None,
)
return trainer_mod
class _ExitedProc:
def join(self, timeout = None):
return None
def is_alive(self):
return False
class _TerminableProc:
def __init__(self):
self.terminated = False
self._done = threading.Event()
def join(self, timeout = None):
self._done.wait(timeout = timeout or 5)
def is_alive(self):
return not self.terminated
def terminate(self):
self.terminated = True
self._done.set()
def test_unsloth_trainer_dispatches_for_mlx_and_torch(monkeypatch):
trainer_mod = _load_trainer_module(monkeypatch, "mlx")
mlx_trainer = trainer_mod.UnslothTrainer()
assert type(mlx_trainer).__module__ == "core.training.training"
assert mlx_trainer.get_training_progress().status_message == "Ready to train"
trainer_mod = _load_trainer_module(monkeypatch, "torch")
assert trainer_mod.UnslothTrainer().__class__ is trainer_mod.UnslothTrainer
def test_cli_mlx_trainer_activates_before_importing_trainer():
repo_root = Path(__file__).resolve().parents[3]
script = """
import json
import sys
import unsloth_cli.commands.train as train_cmd
from studio.backend.core.training import training as training_mod
from utils.hardware import hardware as hw
training_mod.platform.system = lambda: "Darwin"
training_mod.platform.machine = lambda: "arm64"
hw.DEVICE = None
events = []
def fake_activate(model_name, hf_token):
events.append({
"model_name": model_name,
"trainer_loaded": "studio.backend.core.training.trainer" in sys.modules,
})
train_cmd._activate_mlx_transformers = fake_activate
trainer = train_cmd._create_cli_trainer("mlx-community/Qwen3-0.6B-4bit", None)
print(json.dumps({
"trainer_module": type(trainer).__module__,
"events": events,
}))
"""
env = os.environ.copy()
env["PYTHONPATH"] = os.pathsep.join(
[str(repo_root), str(repo_root / "studio" / "backend"), env.get("PYTHONPATH", "")]
)
result = subprocess.run(
[sys.executable, "-c", script],
cwd = repo_root,
env = env,
text = True,
stdout = subprocess.PIPE,
stderr = subprocess.PIPE,
check = True,
)
payload = json.loads(result.stdout)
assert payload["trainer_module"] == "studio.backend.core.training.training"
assert payload["events"] == [
{"model_name": "mlx-community/Qwen3-0.6B-4bit", "trainer_loaded": False}
]
def test_mlx_adapter_builds_config_and_reports_completion(tmp_path, monkeypatch):
trainer_mod = _load_trainer_module(monkeypatch, "mlx")
captured = {}
def fake_run_worker(config, event_queue, stop_queue):
captured["config"] = config
event_queue.put({"type": "progress", "step": 1, "total_steps": 1, "loss": 0.25})
event_queue.put(
{"type": "complete", "status_message": "done", "output_dir": config["output_dir"]}
)
trainer = trainer_mod.UnslothTrainer()
monkeypatch.setattr(trainer, "_run_mlx_worker", fake_run_worker)
assert trainer.load_model("mlx-community/Qwen3-0.6B-4bit", max_seq_length = 1024)
assert trainer.prepare_model_for_training(use_lora = False)
dataset, eval_dataset = trainer.load_and_format_dataset("org/dataset")
output_dir = tmp_path / "mlx-out"
assert trainer.start_training(
dataset = dataset,
eval_dataset = eval_dataset,
output_dir = output_dir,
project_name = "Sales Assistant",
max_steps = 1,
learning_rate = 3e-4,
)
trainer.training_thread.join(timeout = 5)
progress = trainer.get_training_progress()
config = captured["config"]
assert progress.is_completed
assert progress.output_dir == str(output_dir.resolve())
progress.status_message = "mutated"
assert trainer.get_training_progress().status_message == "done"
assert config["model_name"] == "mlx-community/Qwen3-0.6B-4bit"
assert config["project_name"] == "Sales Assistant"
assert config["hf_dataset"] == "org/dataset"
assert config["training_type"] == "Full Finetuning"
assert config["load_in_4bit"] is False
assert config["max_seq_length"] == 1024
assert config["learning_rate"] == 3e-4
assert config["output_dir"] == str(output_dir.resolve())
assert config["allow_external_output_dir"] is True
def test_mlx_worker_helpers_cover_cli_paths(tmp_path, monkeypatch):
_load_trainer_module(monkeypatch, "mlx")
from core.training.worker import (
_resolve_mlx_local_dataset_files,
_resolve_mlx_output_dir,
)
dataset = tmp_path / "train.jsonl"
dataset.write_text('{"text":"hello"}\n', encoding = "utf-8")
monkeypatch.chdir(tmp_path)
assert _resolve_mlx_local_dataset_files(["train.jsonl"]) == [str(dataset)]
assert _resolve_mlx_output_dir(
{"output_dir": "cli-out", "allow_external_output_dir": True},
"mlx-community/Qwen3-0.6B-4bit",
) == str((tmp_path / "cli-out").resolve())
def test_run_mlx_training_process_applies_side_effects_before_hardware_detection(monkeypatch):
_load_trainer_module(monkeypatch, "mlx")
from core.training import worker
from utils.hardware import hardware as hw
order = []
def fake_activate(model_name, hf_token):
order.append(("activate", model_name, hf_token))
def fake_detect_hardware():
order.append("detect")
hw.DEVICE = hw.DeviceType.CPU
return hw.DEVICE
monkeypatch.delenv("HF_HUB_DISABLE_XET", raising = False)
monkeypatch.delenv("HF_HUB_ENABLE_HF_TRANSFER", raising = False)
monkeypatch.setattr(worker, "_activate_transformers_version_or_warn", fake_activate)
monkeypatch.setattr(hw, "detect_hardware", fake_detect_hardware)
event_queue = queue.Queue()
worker.run_mlx_training_process(
event_queue = event_queue,
stop_queue = queue.Queue(),
config = {"model_name": "mlx-community/Gemma-4-12B", "disable_xet": True},
)
event = event_queue.get_nowait()
assert order == [("activate", "mlx-community/Gemma-4-12B", None), "detect"]
assert os.environ["HF_HUB_DISABLE_XET"] == "1"
assert os.environ["HF_HUB_ENABLE_HF_TRANSFER"] == "0"
assert "MLX training requires Apple Silicon" in event["error"]
if __name__ == "__main__":
unittest.main()

View file

@ -22,24 +22,179 @@ import argparse
import os
def _is_mlx_backend(unsloth_module):
return bool(getattr(unsloth_module, "_IS_MLX", False))
def _normalize_dtype(dtype, is_mlx):
if is_mlx and isinstance(dtype, str) and dtype.strip().lower() in {"", "none", "auto"}:
return None
return dtype
def _prepare_device_map(is_mlx):
if is_mlx:
return None, False
from unsloth.models.loader_utils import prepare_device_map
return prepare_device_map()
class _CallableTokenizerProxy:
def __init__(self, tokenizer):
self._tokenizer = tokenizer
def __getattr__(self, name):
return getattr(self._tokenizer, name)
def __call__(self, text, *args, **kwargs):
# MLX/torch-free: never request torch tensors; keep plain python ids.
kwargs.pop("return_tensors", None)
wrapped = getattr(self._tokenizer, "_tokenizer", None)
if callable(wrapped):
return wrapped(text, *args, **kwargs)
add_special_tokens = kwargs.get("add_special_tokens", False)
input_ids = self._tokenizer.encode(text, add_special_tokens = add_special_tokens)
return {"input_ids": input_ids}
def _tokenizer_for_raw_text_loader(tokenizer, is_mlx):
if not is_mlx or callable(tokenizer):
return tokenizer
return _CallableTokenizerProxy(tokenizer)
def _raw_text_loader_for_backend(
RawTextDataLoader,
tokenizer,
is_mlx,
chunk_size = 2048,
stride = 512,
):
return RawTextDataLoader(
_tokenizer_for_raw_text_loader(tokenizer, is_mlx),
chunk_size,
stride,
return_tokenized = not is_mlx,
)
def _train_with_legacy_save_control(trainer, is_mlx):
if not is_mlx:
return trainer.train()
original_save_model = getattr(trainer, "save_model", None)
if original_save_model is None:
return trainer.train()
def skip_internal_final_save(*args, **kwargs):
raise ValueError("legacy unsloth-cli.py owns final save")
trainer.save_model = skip_internal_final_save
try:
return trainer.train()
finally:
trainer.save_model = original_save_model
def _iter_quantization_methods(quantization):
if isinstance(quantization, list):
return quantization
return [quantization]
def _save_or_push_model(model, tokenizer, args, is_mlx):
if not args.save_model:
print("Warning: The model is not saved!")
return
# Enter the GGUF branch when saving or pushing GGUF, so --push_gguf works
# without --save_gguf (the local save is guarded separately below).
if args.save_gguf or args.push_gguf:
if not args.save_gguf:
print("Warning: --save_gguf not set, pushing GGUF to hub without saving locally.")
for quantization_method in _iter_quantization_methods(args.quantization):
if args.save_gguf:
print(f"Saving model with quantization method: {quantization_method}")
model.save_pretrained_gguf(
args.save_path,
tokenizer,
quantization_method = quantization_method,
)
if args.push_model or args.push_gguf:
model.push_to_hub_gguf(
args.hub_path,
tokenizer,
quantization_method = quantization_method,
token = args.hub_token,
)
return
if is_mlx:
model.save_pretrained_merged(
args.save_path,
tokenizer,
save_method = args.save_method,
push_to_hub = args.push_model,
repo_id = args.hub_path if args.push_model else None,
token = args.hub_token,
)
return
model.save_pretrained_merged(args.save_path, tokenizer, save_method = args.save_method)
if args.push_model:
model.push_to_hub_merged(args.hub_path, tokenizer, args.save_method, token = args.hub_token)
def _build_sft_config(SFTConfig, args, is_mlx, bf16_supported):
config_kwargs = dict(
per_device_train_batch_size = args.per_device_train_batch_size,
gradient_accumulation_steps = args.gradient_accumulation_steps,
warmup_steps = args.warmup_steps,
max_steps = args.max_steps,
learning_rate = args.learning_rate,
fp16 = not bf16_supported,
bf16 = bf16_supported,
logging_steps = args.logging_steps,
optim = args.optim,
weight_decay = args.weight_decay,
lr_scheduler_type = args.lr_scheduler_type,
seed = args.seed,
output_dir = args.output_dir,
report_to = args.report_to,
max_length = args.max_seq_length,
dataset_num_proc = 2,
packing = args.packing,
)
if is_mlx:
if args.per_device_eval_batch_size != 4:
print("Warning: --per_device_eval_batch_size is ignored on MLX without eval data.")
else:
config_kwargs["per_device_eval_batch_size"] = args.per_device_eval_batch_size
return SFTConfig(**config_kwargs)
def run(args):
import unsloth
from unsloth import FastLanguageModel
from datasets import load_dataset
from transformers.utils import strtobool
from trl import SFTTrainer, SFTConfig
from unsloth import is_bfloat16_supported
from unsloth.models.loader_utils import prepare_device_map
import logging
from unsloth import RawTextDataLoader
logging.getLogger("hf-to-gguf").setLevel(logging.WARNING)
is_mlx = _is_mlx_backend(unsloth)
# Load model and tokenizer
device_map, distributed = prepare_device_map()
device_map, distributed = _prepare_device_map(is_mlx)
model, tokenizer = FastLanguageModel.from_pretrained(
model_name = args.model_name,
max_seq_length = args.max_seq_length,
dtype = args.dtype,
dtype = _normalize_dtype(args.dtype, is_mlx),
load_in_4bit = args.load_in_4bit,
device_map = device_map,
)
@ -92,11 +247,13 @@ def run(args):
def load_dataset_smart(args):
from transformers.utils import strtobool
if args.raw_text_file:
loader = RawTextDataLoader(tokenizer, args.chunk_size, args.stride)
loader = _raw_text_loader_for_backend(
RawTextDataLoader, tokenizer, is_mlx, args.chunk_size, args.stride
)
dataset = loader.load_from_file(args.raw_text_file)
elif args.dataset.endswith((".txt", ".md", ".json", ".jsonl")):
# Auto-detect local raw text files
loader = RawTextDataLoader(tokenizer)
loader = _raw_text_loader_for_backend(RawTextDataLoader, tokenizer, is_mlx)
dataset = loader.load_from_file(args.dataset)
else:
use_modelscope = strtobool(os.environ.get("UNSLOTH_USE_MODELSCOPE", "False"))
@ -115,27 +272,9 @@ def run(args):
print("Data is formatted and ready!")
# Configure training arguments
training_args = SFTConfig(
per_device_train_batch_size = args.per_device_train_batch_size,
per_device_eval_batch_size = args.per_device_eval_batch_size,
gradient_accumulation_steps = args.gradient_accumulation_steps,
warmup_steps = args.warmup_steps,
max_steps = args.max_steps,
learning_rate = args.learning_rate,
fp16 = not is_bfloat16_supported(),
bf16 = is_bfloat16_supported(),
logging_steps = args.logging_steps,
optim = args.optim,
weight_decay = args.weight_decay,
lr_scheduler_type = args.lr_scheduler_type,
seed = args.seed,
output_dir = args.output_dir,
report_to = args.report_to,
max_length = args.max_seq_length,
dataset_num_proc = 2,
ddp_find_unused_parameters = False if distributed else None,
packing = args.packing,
)
training_args = _build_sft_config(SFTConfig, args, is_mlx, is_bfloat16_supported())
if distributed:
training_args.ddp_find_unused_parameters = False
# Initialize trainer
trainer = SFTTrainer(
@ -145,57 +284,9 @@ def run(args):
args = training_args,
)
trainer.train()
_train_with_legacy_save_control(trainer, is_mlx)
# Save model
if args.save_model:
# If args.quantization is a list, save once per quantization method
# Enter the GGUF branch when saving *or* pushing GGUF, so --push_gguf
# works even when --save_gguf is omitted (the local save is guarded
# separately below).
if args.save_gguf or args.push_gguf:
# Push-only GGUF (no --save_gguf) skips the local save; warn so it is not silent.
if not args.save_gguf:
print("Warning: --save_gguf not set, pushing GGUF to hub without saving locally.")
if isinstance(args.quantization, list):
for quantization_method in args.quantization:
if args.save_gguf:
print(f"Saving model with quantization method: {quantization_method}")
model.save_pretrained_gguf(
args.save_path,
tokenizer,
quantization_method = quantization_method,
)
if args.push_model or args.push_gguf:
model.push_to_hub_gguf(
args.hub_path,
tokenizer,
quantization_method = quantization_method,
token = args.hub_token,
)
else:
if args.save_gguf:
print(f"Saving model with quantization method: {args.quantization}")
model.save_pretrained_gguf(
args.save_path,
tokenizer,
quantization_method = args.quantization,
)
if args.push_model or args.push_gguf:
model.push_to_hub_gguf(
args.hub_path,
tokenizer,
quantization_method = args.quantization,
token = args.hub_token,
)
else:
model.save_pretrained_merged(args.save_path, tokenizer, args.save_method)
if args.push_model:
model.push_to_hub_merged(
args.hub_path, tokenizer, args.save_method, token = args.hub_token
)
else:
print("Warning: The model is not saved!")
_save_or_push_model(model, tokenizer, args, is_mlx)
if __name__ == "__main__":

View file

@ -7,10 +7,42 @@ from typing import Optional
import typer
from unsloth_cli._inference import ensure_studio_backend_path
from unsloth_cli.config import Config, load_config
from unsloth_cli.options import add_options_from_config
def _should_use_mlx_backend_for_cli() -> bool:
ensure_studio_backend_path()
from studio.backend.core.training.training import should_use_mlx_training_backend
return should_use_mlx_training_backend()
def _activate_mlx_transformers(model_name: str, hf_token: Optional[str]) -> None:
# Activate before any transformers import: adapter model-type detection imports utils.models.
ensure_studio_backend_path()
from utils.transformers_version import activate_transformers_for_subprocess
try:
activate_transformers_for_subprocess(model_name, hf_token)
except Exception as exc:
typer.echo(f"Warning: failed to activate Transformers sidecar: {exc}", err = True)
def _create_cli_trainer(model_name: str, hf_token: Optional[str]):
if _should_use_mlx_backend_for_cli():
_activate_mlx_transformers(model_name, hf_token)
# MLX is torch-free: use the lightweight adapter, not trainer.py (imports torch/unsloth/trl at load).
ensure_studio_backend_path()
from studio.backend.core.training.training import create_mlx_trainer_adapter
return create_mlx_trainer_adapter()
ensure_studio_backend_path()
from studio.backend.core.training.trainer import UnslothTrainer
return UnslothTrainer()
@add_options_from_config(Config)
def train(
config: Optional[Path] = typer.Option(
@ -39,6 +71,7 @@ def train(
typer.echo(f"Error: {e}", err = True)
raise typer.Exit(code = 2)
config_overrides = config_overrides or {}
cfg.apply_overrides(**config_overrides)
# CLI/env tokens take precedence; guard against unresolved typer.Option
@ -83,9 +116,7 @@ def train(
)
raise typer.Exit(code = 2)
from studio.backend.core.training.trainer import UnslothTrainer
trainer = UnslothTrainer()
trainer = _create_cli_trainer(cfg.model, hf_token)
# Load model (trainer.is_vlm is set after this)
if not trainer.load_model(
@ -124,13 +155,20 @@ def train(
try:
while trainer.training_thread and trainer.training_thread.is_alive():
progress = trainer.get_training_progress()
if getattr(progress, "error", None):
break
time.sleep(1)
except KeyboardInterrupt:
typer.echo("Stopping training (Ctrl+C detected)...")
trainer.stop_training()
finally:
if trainer.training_thread:
trainer.training_thread.join()
progress = trainer.get_training_progress()
if getattr(progress, "error", None):
trainer.training_thread.join(timeout = 5)
else:
trainer.training_thread.join()
final = trainer.get_training_progress()
if getattr(final, "error", None):