mirror of
https://github.com/unslothai/unsloth.git
synced 2026-07-10 00:08:58 +00:00
1246 lines
43 KiB
Python
1246 lines
43 KiB
Python
"""Tests for the MLX public trainer compatibility surface."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import builtins
|
|
import importlib
|
|
import importlib.util
|
|
import platform
|
|
import sys
|
|
import types
|
|
import warnings
|
|
|
|
import pytest
|
|
|
|
_MLX_SKIP_REASON = "MLX public trainer API is only active on the MLX backend"
|
|
|
|
|
|
def _import_mlx_unsloth():
|
|
"""Import unsloth and skip when the current platform is not using MLX."""
|
|
# Skip before importing unsloth so non-MLX hosts missing optional GPU deps
|
|
# (e.g. bitsandbytes) skip cleanly instead of erroring at collection.
|
|
if not (
|
|
platform.system() == "Darwin"
|
|
and platform.machine() == "arm64"
|
|
and importlib.util.find_spec("mlx") is not None
|
|
):
|
|
pytest.skip(_MLX_SKIP_REASON)
|
|
unsloth = importlib.import_module("unsloth")
|
|
if getattr(unsloth, "DEVICE_TYPE", None) != "mlx":
|
|
pytest.skip(_MLX_SKIP_REASON)
|
|
return unsloth
|
|
|
|
|
|
class _DummyModel:
|
|
"""Small model stub that satisfies MLXTrainer constructor probes."""
|
|
|
|
def trainable_parameters(self):
|
|
"""Return no trainable parameters for constructor-only tests."""
|
|
return {}
|
|
|
|
|
|
class _DummyVLMModel(_DummyModel):
|
|
"""Small VLM model stub for MLX vision trainer constructor probes."""
|
|
|
|
_is_vlm_model = True
|
|
|
|
|
|
def test_mlx_exports_unsloth_trainer_api():
|
|
"""MLX imports should expose the public Unsloth trainer API."""
|
|
unsloth = _import_mlx_unsloth()
|
|
from unsloth import (
|
|
RawTextDataLoader,
|
|
TextPreprocessor,
|
|
UnslothTrainer,
|
|
UnslothTrainingArguments,
|
|
clear_gpu_memory,
|
|
get_gpu_memory_stats,
|
|
)
|
|
|
|
assert RawTextDataLoader is unsloth.RawTextDataLoader
|
|
assert TextPreprocessor is unsloth.TextPreprocessor
|
|
assert UnslothTrainer is unsloth.UnslothTrainer
|
|
assert UnslothTrainingArguments is unsloth.UnslothTrainingArguments
|
|
assert get_gpu_memory_stats is unsloth.get_gpu_memory_stats
|
|
assert clear_gpu_memory is unsloth.clear_gpu_memory
|
|
assert issubclass(UnslothTrainer, unsloth.MLXTrainer)
|
|
assert issubclass(UnslothTrainingArguments, unsloth.MLXTrainingConfig)
|
|
assert importlib.util.find_spec("unsloth.memory") is None
|
|
|
|
|
|
def test_non_mlx_exports_public_trainer_api_when_available():
|
|
"""GPU/ROCm imports should keep exporting the public Unsloth trainer API."""
|
|
try:
|
|
unsloth = importlib.import_module("unsloth")
|
|
except ImportError as exc:
|
|
# Non-MLX import pulls the optional GPU stack (numpy/torch/unsloth-zoo,
|
|
# bitsandbytes/triton, and _gpu_init can re-raise missing deps as
|
|
# ImportError). Skip when any of it is unavailable rather than failing
|
|
# collection on CPU/ROCm/XPU review hosts.
|
|
pytest.skip(f"non-MLX import dependency unavailable: {exc}")
|
|
if getattr(unsloth, "DEVICE_TYPE", None) == "mlx":
|
|
pytest.skip("non-MLX export smoke test only runs on GPU/ROCm backends")
|
|
|
|
assert unsloth.UnslothTrainer is not None
|
|
assert unsloth.UnslothTrainingArguments is not None
|
|
assert callable(unsloth.get_gpu_memory_stats)
|
|
assert callable(unsloth.clear_gpu_memory)
|
|
assert importlib.util.find_spec("unsloth.memory") is None
|
|
|
|
|
|
def test_mlx_training_arguments_accept_trl_style_kwargs():
|
|
"""TRL/SFTConfig-style kwargs should normalize without breaking MLX config."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
with pytest.warns(RuntimeWarning, match = "bf16.*dataset_kwargs"):
|
|
args = unsloth.UnslothTrainingArguments(
|
|
max_length = 123,
|
|
max_steps = 10,
|
|
warmup_ratio = 0.2,
|
|
remove_unused_columns = False,
|
|
dataset_kwargs = {"skip_prepare_dataset": True},
|
|
bf16 = True,
|
|
)
|
|
|
|
assert args.max_seq_length == 123
|
|
assert args.warmup_steps == 2
|
|
assert args.remove_unused_columns is False
|
|
assert args.dataset_kwargs == {"skip_prepare_dataset": True}
|
|
assert args.bf16 is True
|
|
assert args.warmup_ratio == 0.2
|
|
assert args._unsloth_mlx_max_seq_length_explicit is False
|
|
assert args._unsloth_mlx_warmup_steps_explicit is False
|
|
|
|
|
|
def test_mlx_training_arguments_do_not_warn_for_implemented_or_falsey_extras():
|
|
"""Implemented and falsey inert compatibility kwargs should stay quiet."""
|
|
unsloth = _import_mlx_unsloth()
|
|
supported_eval_kwargs = {}
|
|
if "eval_strategy" in unsloth._MLX_TRAINING_CONFIG_FIELDS:
|
|
supported_eval_kwargs = {"eval_strategy": "no", "eval_delay": 1}
|
|
|
|
with warnings.catch_warnings(record = True) as caught:
|
|
warnings.simplefilter("always")
|
|
args = unsloth.UnslothTrainingArguments(
|
|
warmup_ratio = 0.2,
|
|
max_steps = 10,
|
|
padding_free = False,
|
|
remove_unused_columns = False,
|
|
assistant_only_loss = False,
|
|
completion_only_loss = False,
|
|
**supported_eval_kwargs,
|
|
)
|
|
|
|
assert args.warmup_steps == 2
|
|
assert args.padding_free is False
|
|
assert args.remove_unused_columns is False
|
|
assert args.completion_only_loss is False
|
|
if supported_eval_kwargs:
|
|
assert args.eval_strategy == "no"
|
|
assert args.eval_delay == 1
|
|
assert caught == []
|
|
|
|
|
|
def test_mlx_training_arguments_prefer_canonical_max_seq_length():
|
|
"""Canonical MLX config fields should win over compatibility aliases."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
args = unsloth.UnslothTrainingArguments(max_seq_length = 456, max_length = 123)
|
|
dict_args = unsloth.UnslothTrainingArguments(
|
|
{"max_length": 123, "max_seq_length": 456},
|
|
)
|
|
|
|
assert args.max_seq_length == 456
|
|
assert args.max_length == 456
|
|
assert args._unsloth_mlx_max_length_value == 456
|
|
assert dict_args.max_seq_length == 456
|
|
assert dict_args.max_length == 456
|
|
assert dict_args._unsloth_mlx_max_length_value == 456
|
|
assert args._unsloth_mlx_max_seq_length_explicit is True
|
|
assert dict_args._unsloth_mlx_max_seq_length_explicit is True
|
|
|
|
|
|
def test_mlx_training_arguments_preserve_explicit_positive_warmup_steps():
|
|
"""Explicit warmup_steps should take precedence over warmup_ratio."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
args = unsloth.UnslothTrainingArguments(
|
|
max_steps = 10,
|
|
warmup_steps = 5,
|
|
warmup_ratio = 0.1,
|
|
)
|
|
|
|
assert args.warmup_steps == 5
|
|
assert args._unsloth_mlx_warmup_steps_explicit is True
|
|
|
|
|
|
def test_mlx_clear_gpu_memory_uses_metal_fallback(monkeypatch):
|
|
"""Older MLX releases expose cache clearing under mx.metal.clear_cache."""
|
|
unsloth = _import_mlx_unsloth()
|
|
import mlx.core as mx
|
|
|
|
called = []
|
|
metal = getattr(mx, "metal", None) or type("Metal", (), {})()
|
|
monkeypatch.delattr(mx, "clear_cache", raising = False)
|
|
monkeypatch.setattr(mx, "metal", metal, raising = False)
|
|
monkeypatch.setattr(metal, "clear_cache", lambda: called.append("metal"), raising = False)
|
|
|
|
unsloth.clear_gpu_memory()
|
|
|
|
assert called == ["metal"]
|
|
|
|
|
|
def test_mlx_training_arguments_preserve_explicit_epoch_training():
|
|
"""Epoch-based configs should not inherit the MLX max_steps default."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
args = unsloth.UnslothTrainingArguments(num_train_epochs = 1, warmup_ratio = 0.1)
|
|
default_args = unsloth.UnslothTrainingArguments()
|
|
|
|
assert args.num_train_epochs == 1
|
|
assert args.max_steps == -1
|
|
assert args.warmup_ratio == 0.1
|
|
assert args._unsloth_mlx_warmup_steps_explicit is False
|
|
assert default_args.max_steps == unsloth.MLXTrainingConfig.max_steps
|
|
|
|
|
|
def test_mlx_training_arguments_keep_mlx_dataset_order_default():
|
|
"""Training arguments alone should not override MLX's native data order."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
args = unsloth.UnslothTrainingArguments(max_steps = 1)
|
|
explicit_default = unsloth.UnslothTrainingArguments(
|
|
max_steps = 1,
|
|
dataset_order = "default",
|
|
)
|
|
|
|
assert args.dataset_order == "default"
|
|
assert args._unsloth_mlx_dataset_order_explicit is False
|
|
assert args._unsloth_mlx_max_seq_length_explicit is False
|
|
assert explicit_default.dataset_order == "default"
|
|
assert explicit_default._unsloth_mlx_dataset_order_explicit is True
|
|
|
|
|
|
def test_mlx_training_arguments_warn_on_meaningful_inert_kwargs():
|
|
"""Unsupported TrainingArguments knobs should not be silently ignored."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
with pytest.warns(RuntimeWarning, match = "push_to_hub.*save_strategy"):
|
|
args = unsloth.UnslothTrainingArguments(
|
|
save_strategy = "steps",
|
|
push_to_hub = True,
|
|
padding_free = False,
|
|
)
|
|
|
|
assert args.save_strategy == "steps"
|
|
assert args.push_to_hub is True
|
|
assert args.padding_free is False
|
|
|
|
|
|
def test_mlx_training_arguments_reject_unknown_kwargs():
|
|
"""Unknown SFTConfig flags should fail instead of becoming inert attributes."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
with pytest.raises(NotImplementedError, match = "assistant_only_loss"):
|
|
unsloth.UnslothTrainingArguments(assistant_only_loss = True)
|
|
|
|
completion_args = unsloth.UnslothTrainingArguments(completion_only_loss = True)
|
|
assert completion_args.completion_only_loss is True
|
|
|
|
|
|
def test_mlx_training_arguments_reject_unsupported_object_flags():
|
|
"""Object-style SFTConfig flags should not be silently dropped."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
class ArgsObject:
|
|
max_steps = 1
|
|
assistant_only_loss = True
|
|
|
|
with pytest.raises(NotImplementedError, match = "assistant_only_loss"):
|
|
unsloth._coerce_mlx_training_args(ArgsObject())
|
|
|
|
class CompletionArgsObject:
|
|
max_steps = 1
|
|
completion_only_loss = True
|
|
|
|
completion_args = unsloth._coerce_mlx_training_args(CompletionArgsObject())
|
|
assert completion_args.completion_only_loss is True
|
|
|
|
|
|
def test_mlx_training_arguments_accept_output_dir_positional():
|
|
"""A single positional output_dir should match TrainingArguments behavior."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
args = unsloth.UnslothTrainingArguments("custom-outputs", max_steps = 3)
|
|
|
|
assert args.output_dir == "custom-outputs"
|
|
assert args.max_steps == 3
|
|
|
|
|
|
def test_mlx_training_arguments_normalize_optim_and_object_aliases():
|
|
"""Common notebook optimizer names and object aliases should normalize."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
class Scheduler:
|
|
value = "cosine"
|
|
|
|
class ArgsObject:
|
|
optim = "adamw_8bit"
|
|
eval_steps = None
|
|
lr_scheduler_type = Scheduler()
|
|
max_length = 321
|
|
max_steps = 10
|
|
num_train_epochs = 3.0
|
|
save_steps = 500
|
|
save_strategy = "no"
|
|
warmup_ratio = 0.1
|
|
warmup_steps = 0
|
|
|
|
with pytest.warns(RuntimeWarning, match = "save_strategy"):
|
|
args = unsloth._coerce_mlx_training_args(ArgsObject())
|
|
|
|
assert args.optim == "adamw"
|
|
assert args.eval_steps == 0
|
|
assert args.lr_scheduler_type == "cosine"
|
|
assert args.max_seq_length == 321
|
|
assert args.num_train_epochs == 3
|
|
assert type(args.num_train_epochs) is int
|
|
assert args.save_steps == 0
|
|
assert args.warmup_steps == 1
|
|
assert args._unsloth_mlx_warmup_steps_explicit is False
|
|
|
|
|
|
def test_mlx_training_arguments_accept_supported_notebook_kwargs():
|
|
"""Supported SFT notebooks should be able to pass their current args."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
with pytest.warns(
|
|
RuntimeWarning,
|
|
match = "bf16.*dataset_kwargs.*gradient_checkpointing_kwargs.*save_strategy",
|
|
):
|
|
args = unsloth.UnslothTrainingArguments(
|
|
bf16 = True,
|
|
dataset_kwargs = {"skip_prepare_dataset": True},
|
|
dataset_num_proc = 4,
|
|
dataset_text_field = "text",
|
|
embedding_learning_rate = 5e-5,
|
|
fp16 = False,
|
|
gradient_accumulation_steps = 8,
|
|
gradient_checkpointing = True,
|
|
gradient_checkpointing_kwargs = {"use_reentrant": False},
|
|
learning_rate = 1e-4,
|
|
logging_steps = 2,
|
|
lr_scheduler_type = "cosine",
|
|
max_grad_norm = 0.3,
|
|
max_length = 1024,
|
|
max_steps = 10,
|
|
num_train_epochs = 1,
|
|
optim = "paged_adamw_8bit",
|
|
output_dir = "outputs",
|
|
padding_free = False,
|
|
per_device_train_batch_size = 1,
|
|
remove_unused_columns = False,
|
|
report_to = "none",
|
|
save_strategy = "steps",
|
|
seed = 123,
|
|
warmup_ratio = 0.1,
|
|
weight_decay = 0.01,
|
|
)
|
|
|
|
assert args.dataset_num_proc == 4
|
|
assert args.dataset_text_field == "text"
|
|
assert args.embedding_learning_rate == 5e-5
|
|
assert args.gradient_accumulation_steps == 8
|
|
assert args.gradient_checkpointing is True
|
|
assert args.learning_rate == 1e-4
|
|
assert args.logging_steps == 2
|
|
assert args.lr_scheduler_type == "cosine"
|
|
assert args.max_grad_norm == 0.3
|
|
assert args.max_seq_length == 1024
|
|
assert args.max_steps == 10
|
|
assert args.num_train_epochs == 1
|
|
assert args.optim == "adamw"
|
|
assert args.output_dir == "outputs"
|
|
assert args.per_device_train_batch_size == 1
|
|
assert args.report_to == "none"
|
|
assert args.seed == 123
|
|
assert args.warmup_ratio == 0.1
|
|
assert args.warmup_steps == 1
|
|
assert args.weight_decay == 0.01
|
|
assert args.dataset_kwargs == {"skip_prepare_dataset": True}
|
|
assert args.gradient_checkpointing_kwargs == {"use_reentrant": False}
|
|
assert args.save_strategy == "steps"
|
|
|
|
|
|
def test_mlx_training_arguments_honor_direct_no_save_strategy():
|
|
"""Direct kwargs should map save_strategy=no to save_steps=0."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
with pytest.warns(RuntimeWarning, match = "save_strategy"):
|
|
args = unsloth.UnslothTrainingArguments(
|
|
save_strategy = "no",
|
|
save_steps = 500,
|
|
)
|
|
|
|
assert args.save_steps == 0
|
|
|
|
|
|
def test_mlx_trainer_accepts_common_sft_kwargs():
|
|
"""UnslothTrainer should accept common SFTTrainer kwargs on MLX."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
with warnings.catch_warnings(record = True) as caught:
|
|
warnings.simplefilter("always")
|
|
trainer = unsloth.UnslothTrainer(
|
|
model = _DummyModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = {"max_steps": 1},
|
|
dataset_num_proc = 8,
|
|
max_length = 456,
|
|
optim = "adamw_bnb_8bit",
|
|
processing_class = object(),
|
|
)
|
|
|
|
assert trainer.args.max_steps == 1
|
|
assert trainer.args.dataset_num_proc == 8
|
|
assert trainer.args.max_seq_length == 456
|
|
assert trainer.args.max_grad_norm == 1.0
|
|
assert trainer.args.optim == "adamw"
|
|
assert trainer.args.dataset_order == "torch_randperm"
|
|
assert trainer._unsloth_mlx_ignored_trainer_kwargs == {}
|
|
assert caught == []
|
|
|
|
|
|
def test_mlx_trainer_preserves_explicit_dataset_order():
|
|
"""UnslothTrainer should only set torch_randperm when order is implicit."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
explicit_default = unsloth.UnslothTrainer(
|
|
model = _DummyModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = unsloth.UnslothTrainingArguments(
|
|
max_steps = 1,
|
|
dataset_order = "default",
|
|
),
|
|
)
|
|
explicit_sequential = unsloth.UnslothTrainer(
|
|
model = _DummyModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = unsloth.UnslothTrainingArguments(
|
|
max_steps = 1,
|
|
dataset_order = "sequential",
|
|
),
|
|
)
|
|
implicit_with_override = unsloth.UnslothTrainer(
|
|
model = _DummyModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = unsloth.UnslothTrainingArguments(max_steps = 1),
|
|
dataset_num_proc = 4,
|
|
)
|
|
implicit_streaming = unsloth.UnslothTrainer(
|
|
model = _DummyModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = unsloth.UnslothTrainingArguments(max_steps = 1, streaming = True),
|
|
)
|
|
explicit_no_clip = unsloth.UnslothTrainer(
|
|
model = _DummyModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = unsloth.UnslothTrainingArguments(
|
|
max_steps = 1,
|
|
max_grad_norm = 0.0,
|
|
),
|
|
)
|
|
|
|
assert explicit_default.args.dataset_order == "default"
|
|
assert explicit_sequential.args.dataset_order == "sequential"
|
|
assert implicit_with_override.args.dataset_order == "torch_randperm"
|
|
assert implicit_streaming.args.dataset_order == "default"
|
|
assert implicit_with_override.args.max_grad_norm == 1.0
|
|
assert explicit_no_clip.args.max_grad_norm == 0.0
|
|
|
|
|
|
def test_mlx_trainer_uses_model_context_length_when_implicit():
|
|
"""UnslothTrainer should mirror CUDA's max_length bridge precedence."""
|
|
unsloth = _import_mlx_unsloth()
|
|
model = _DummyModel()
|
|
model.max_seq_length = 321
|
|
max_length_model = _DummyModel()
|
|
max_length_model.max_seq_length = 321
|
|
none_model = _DummyModel()
|
|
none_model.max_seq_length = 321
|
|
explicit_seq_model = _DummyModel()
|
|
explicit_seq_model.max_seq_length = 321
|
|
clamped_seq_model = _DummyModel()
|
|
clamped_seq_model.max_seq_length = 321
|
|
model_max_length = _DummyModel()
|
|
model_max_length.max_length = 777
|
|
metadata_model = _DummyModel()
|
|
metadata_model.config = type("Config", (), {"max_position_embeddings": 888})()
|
|
metadata_tokenizer = type("Tokenizer", (), {"model_max_length": 999})()
|
|
explicit_max_length_no_model = _DummyModel()
|
|
trainer_override_model = _DummyModel()
|
|
trainer_override_model.max_seq_length = 321
|
|
config_override_model = _DummyModel()
|
|
config_override_model.max_seq_length = 432
|
|
|
|
implicit = unsloth.UnslothTrainer(
|
|
model = model,
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = unsloth.UnslothTrainingArguments(max_steps = 1),
|
|
)
|
|
max_length_args = unsloth.UnslothTrainer(
|
|
model = max_length_model,
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = unsloth.UnslothTrainingArguments(max_steps = 1, max_length = 123),
|
|
)
|
|
none_args = unsloth.UnslothTrainer(
|
|
model = none_model,
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = unsloth.UnslothTrainingArguments(max_steps = 1, max_seq_length = None),
|
|
)
|
|
explicit_seq = unsloth.UnslothTrainer(
|
|
model = explicit_seq_model,
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = unsloth.UnslothTrainingArguments(max_steps = 1, max_seq_length = 123),
|
|
)
|
|
clamped_seq = unsloth.UnslothTrainer(
|
|
model = clamped_seq_model,
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = unsloth.UnslothTrainingArguments(max_steps = 1, max_seq_length = 654),
|
|
)
|
|
model_max_length_only = unsloth.UnslothTrainer(
|
|
model = model_max_length,
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = unsloth.UnslothTrainingArguments(max_steps = 1),
|
|
)
|
|
metadata_ignored = unsloth.UnslothTrainer(
|
|
model = metadata_model,
|
|
tokenizer = metadata_tokenizer,
|
|
train_dataset = [],
|
|
args = unsloth.UnslothTrainingArguments(max_steps = 1),
|
|
)
|
|
explicit_max_length = unsloth.UnslothTrainer(
|
|
model = explicit_max_length_no_model,
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = unsloth.UnslothTrainingArguments(max_steps = 1, max_length = 123),
|
|
)
|
|
trainer_override = unsloth.UnslothTrainer(
|
|
model = trainer_override_model,
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = unsloth.UnslothTrainingArguments(max_steps = 1),
|
|
max_seq_length = 654,
|
|
)
|
|
config_with_override = unsloth.UnslothTrainer(
|
|
model = config_override_model,
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = unsloth.MLXTrainingConfig(max_steps = 1),
|
|
dataset_num_proc = 4,
|
|
)
|
|
|
|
assert implicit.args.max_seq_length == 321
|
|
assert implicit.args.max_length == 321
|
|
assert max_length_args.args.max_seq_length == 321
|
|
assert max_length_args.args.max_length == 321
|
|
assert none_args.args.max_seq_length == 321
|
|
assert none_args.args.max_length == 321
|
|
assert explicit_seq.args.max_seq_length == 123
|
|
assert explicit_seq.args.max_length == 123
|
|
assert clamped_seq.args.max_seq_length == 321
|
|
assert clamped_seq.args.max_length == 321
|
|
assert model_max_length_only.args.max_seq_length == 777
|
|
assert model_max_length_only.args.max_length == 777
|
|
assert metadata_ignored.args.max_seq_length == 1024
|
|
assert metadata_ignored.args.max_length == 1024
|
|
assert explicit_max_length.args.max_seq_length == 123
|
|
assert explicit_max_length.args.max_length == 123
|
|
assert trainer_override.args.max_seq_length == 654
|
|
assert trainer_override.args.max_length == 654
|
|
assert config_with_override.args.max_seq_length == 432
|
|
assert config_with_override.args.max_length == 432
|
|
|
|
|
|
def test_mlx_trainer_processing_class_overrides_explicit_none_tokenizer():
|
|
"""TRL passes tokenizer=None while processing_class carries the tokenizer."""
|
|
unsloth = _import_mlx_unsloth()
|
|
tokenizer = object()
|
|
|
|
class Processor:
|
|
pass
|
|
|
|
processor = Processor()
|
|
processor.tokenizer = tokenizer
|
|
|
|
trainer = unsloth.UnslothTrainer(
|
|
model = _DummyModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = {"max_steps": 1},
|
|
processing_class = processor,
|
|
)
|
|
|
|
assert trainer.processor is processor
|
|
assert trainer.tokenizer is tokenizer
|
|
|
|
|
|
def test_mlx_trainer_vision_collator_processor_overrides_processing_class():
|
|
"""Vision notebooks pass the tokenizer as processing_class and processor in collator."""
|
|
unsloth = _import_mlx_unsloth()
|
|
tokenizer = object()
|
|
|
|
class Processor:
|
|
pass
|
|
|
|
processor = Processor()
|
|
processor.tokenizer = tokenizer
|
|
collator = unsloth.UnslothVisionDataCollator(_DummyVLMModel(), processor)
|
|
|
|
trainer = unsloth.UnslothTrainer(
|
|
model = _DummyVLMModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = {"max_steps": 1},
|
|
processing_class = tokenizer,
|
|
data_collator = collator,
|
|
)
|
|
|
|
assert trainer.processor is processor
|
|
assert trainer.tokenizer is tokenizer
|
|
|
|
|
|
def test_mlx_trainer_preserves_explicit_processor_over_vision_collator():
|
|
"""Explicit processor kwargs should stay authoritative over collator metadata."""
|
|
unsloth = _import_mlx_unsloth()
|
|
tokenizer = object()
|
|
explicit_processor = object()
|
|
|
|
class Processor:
|
|
pass
|
|
|
|
collator_processor = Processor()
|
|
collator_processor.tokenizer = tokenizer
|
|
collator = unsloth.UnslothVisionDataCollator(_DummyVLMModel(), collator_processor)
|
|
|
|
trainer = unsloth.UnslothTrainer(
|
|
model = _DummyVLMModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = {"max_steps": 1},
|
|
processor = explicit_processor,
|
|
processing_class = tokenizer,
|
|
data_collator = collator,
|
|
)
|
|
|
|
assert trainer.processor is explicit_processor
|
|
assert trainer.tokenizer is tokenizer
|
|
|
|
|
|
def test_mlx_trainer_forwards_vision_collator_positional_defaults():
|
|
"""Vision collator CUDA-style positionals should route into MLX args."""
|
|
unsloth = _import_mlx_unsloth()
|
|
collator = unsloth.UnslothVisionDataCollator(
|
|
_DummyVLMModel(),
|
|
object(),
|
|
2048,
|
|
None,
|
|
"max",
|
|
-100,
|
|
False,
|
|
None,
|
|
None,
|
|
True,
|
|
None,
|
|
False,
|
|
)
|
|
|
|
trainer = unsloth.UnslothTrainer(
|
|
model = _DummyVLMModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = {"max_steps": 1},
|
|
data_collator = collator,
|
|
)
|
|
|
|
assert trainer.args.max_seq_length == 2048
|
|
assert trainer.args.image_size == "max"
|
|
assert trainer.args.completion_only_loss is False
|
|
|
|
|
|
def test_mlx_vision_collator_default_does_not_override_explicit_args():
|
|
"""Implicit collator defaults should not override explicit trainer args."""
|
|
unsloth = _import_mlx_unsloth()
|
|
collator = unsloth.UnslothVisionDataCollator(_DummyVLMModel(), object())
|
|
|
|
trainer = unsloth.UnslothTrainer(
|
|
model = _DummyVLMModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = unsloth.UnslothTrainingArguments(
|
|
max_steps = 1,
|
|
completion_only_loss = False,
|
|
),
|
|
data_collator = collator,
|
|
)
|
|
|
|
assert trainer.args.completion_only_loss is False
|
|
|
|
|
|
def test_mlx_trainer_rejects_unsafe_unsupported_sft_kwargs():
|
|
"""Unsupported kwargs that change training semantics should fail on MLX."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
with pytest.raises(NotImplementedError, match = "peft_config"):
|
|
unsloth.UnslothTrainer(
|
|
model = _DummyModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
peft_config = object(),
|
|
)
|
|
|
|
|
|
def test_mlx_trainer_rejects_compute_metrics():
|
|
"""compute_metrics is still unsupported by MLXTrainer."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
with pytest.raises(NotImplementedError, match = "compute_metrics"):
|
|
unsloth.UnslothTrainer(
|
|
model = _DummyModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
compute_metrics = lambda *_: None,
|
|
)
|
|
|
|
|
|
def test_mlx_trainer_accepts_callbacks():
|
|
"""Callbacks are routed to MLXTrainer when the zoo backend supports them."""
|
|
unsloth = _import_mlx_unsloth()
|
|
from transformers import TrainerCallback
|
|
|
|
if not unsloth._mlx_trainer_supports_kwarg("callbacks"):
|
|
pytest.skip("requires unsloth-zoo MLXTrainer callback support")
|
|
|
|
class Callback(TrainerCallback):
|
|
pass
|
|
|
|
trainer = unsloth.UnslothTrainer(
|
|
model = _DummyModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
callbacks = [Callback()],
|
|
)
|
|
assert any(isinstance(cb, Callback) for cb in trainer.callback_handler.callbacks)
|
|
|
|
|
|
def test_mlx_trainer_rejects_callbacks_with_old_zoo(monkeypatch):
|
|
"""Older unsloth-zoo builds should fail clearly instead of TypeError."""
|
|
unsloth = _import_mlx_unsloth()
|
|
from transformers import TrainerCallback
|
|
|
|
monkeypatch.setattr(
|
|
unsloth,
|
|
"_mlx_trainer_supports_kwarg",
|
|
lambda name: name != "callbacks",
|
|
)
|
|
|
|
with pytest.raises(NotImplementedError, match = "callbacks require"):
|
|
unsloth.UnslothTrainer(
|
|
model = _DummyModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
callbacks = [TrainerCallback()],
|
|
)
|
|
|
|
|
|
def test_mlx_trainer_rejects_custom_data_collator():
|
|
"""MLXTrainer owns batching; custom SFT data collators must not be ignored."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
with pytest.raises(NotImplementedError, match = "data_collator"):
|
|
unsloth.UnslothTrainer(
|
|
model = _DummyModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
data_collator = object(),
|
|
)
|
|
|
|
|
|
def test_mlx_trainer_rejects_text_completion_only_loss():
|
|
"""Text MLX training should not silently ignore completion_only_loss=True."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
with pytest.raises(NotImplementedError, match = "completion_only_loss=True"):
|
|
unsloth.UnslothTrainer(
|
|
model = _DummyModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = unsloth.UnslothTrainingArguments(
|
|
max_steps = 1,
|
|
completion_only_loss = True,
|
|
),
|
|
)
|
|
|
|
|
|
def test_mlx_trainer_allows_vlm_completion_only_loss():
|
|
"""VLM MLX training supports completion_only_loss during collation."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
class VLMModel(_DummyModel):
|
|
_is_vlm_model = True
|
|
|
|
trainer = unsloth.UnslothTrainer(
|
|
model = VLMModel(),
|
|
tokenizer = None,
|
|
train_dataset = [],
|
|
args = unsloth.UnslothTrainingArguments(
|
|
max_steps = 1,
|
|
completion_only_loss = True,
|
|
),
|
|
)
|
|
|
|
assert trainer.args.completion_only_loss is True
|
|
|
|
|
|
def test_mlx_trainer_accepts_trl_style_positional_args():
|
|
"""TRL-style positional `(model, args, ...)` should not be read as tokenizer."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
args = unsloth.UnslothTrainingArguments("trl-outputs", max_steps = 2)
|
|
trainer = unsloth.UnslothTrainer(
|
|
_DummyModel(),
|
|
args,
|
|
train_dataset = [],
|
|
tokenizer = None,
|
|
)
|
|
|
|
assert trainer.args is args
|
|
assert trainer.args.output_dir == "trl-outputs"
|
|
assert trainer.train_dataset == []
|
|
|
|
|
|
def test_mlx_trainer_accepts_trl_none_placeholder_positionals():
|
|
"""Explicit TRL default placeholders should preserve later positional args."""
|
|
unsloth = _import_mlx_unsloth()
|
|
dataset = [{"text": "hello"}]
|
|
processing_class = object()
|
|
|
|
trainer = unsloth.UnslothTrainer(
|
|
_DummyModel(),
|
|
None,
|
|
None,
|
|
dataset,
|
|
None,
|
|
processing_class,
|
|
)
|
|
|
|
assert getattr(trainer.train_dataset, "_dataset", trainer.train_dataset) is dataset
|
|
assert getattr(trainer, "_mlx_train_dataset_for_batches", dataset) is dataset
|
|
assert trainer.tokenizer is processing_class
|
|
assert trainer.args.max_steps == 60
|
|
|
|
|
|
def test_mlx_trainer_accepts_short_trl_none_placeholder_positionals():
|
|
"""Short TRL placeholder calls should keep the fourth arg as train_dataset."""
|
|
unsloth = _import_mlx_unsloth()
|
|
dataset = [{"text": "hello"}]
|
|
|
|
trainer = unsloth.UnslothTrainer(
|
|
_DummyModel(),
|
|
None,
|
|
None,
|
|
dataset,
|
|
)
|
|
|
|
assert trainer.train_dataset is dataset
|
|
assert trainer.eval_dataset is None
|
|
assert trainer.args.max_steps == 60
|
|
|
|
|
|
def test_mlx_trainer_accepts_short_trl_placeholders_with_keyword_dataset():
|
|
"""Short TRL placeholders should not conflict with keyword train_dataset."""
|
|
unsloth = _import_mlx_unsloth()
|
|
dataset = [{"text": "hello"}]
|
|
|
|
trainer = unsloth.UnslothTrainer(
|
|
_DummyModel(),
|
|
None,
|
|
None,
|
|
train_dataset = dataset,
|
|
)
|
|
|
|
assert trainer.train_dataset is dataset
|
|
assert trainer.eval_dataset is None
|
|
assert trainer.args.max_steps == 60
|
|
|
|
|
|
def test_mlx_trainer_preserves_mlx_positional_schema_with_none_tokenizer():
|
|
"""MLX-style `(model, tokenizer, train_dataset, ...)` should still work."""
|
|
unsloth = _import_mlx_unsloth()
|
|
dataset = [{"text": "hello"}]
|
|
|
|
trainer = unsloth.UnslothTrainer(
|
|
_DummyModel(),
|
|
None,
|
|
dataset,
|
|
None,
|
|
)
|
|
|
|
assert trainer.tokenizer is None
|
|
assert trainer.train_dataset is dataset
|
|
assert trainer.eval_dataset is None
|
|
|
|
|
|
def test_mlx_compatibility_shims_are_installed():
|
|
"""Old notebook imports should resolve to the MLX public API after unsloth import."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
trl = importlib.import_module("trl")
|
|
trainer_module = importlib.import_module("unsloth.trainer")
|
|
chat_templates = importlib.import_module("unsloth.chat_templates")
|
|
dataset_utils = importlib.import_module("unsloth_zoo.dataset_utils")
|
|
|
|
assert importlib.util.find_spec("trl") is not None
|
|
assert importlib.util.find_spec("unsloth.trainer") is not None
|
|
assert unsloth.trainer is trainer_module
|
|
assert unsloth.chat_templates is chat_templates
|
|
assert trl.SFTTrainer is unsloth.UnslothTrainer
|
|
assert issubclass(trl.SFTConfig, unsloth.UnslothTrainingArguments)
|
|
assert trainer_module.UnslothTrainer is unsloth.UnslothTrainer
|
|
assert trainer_module.UnslothVisionDataCollator is unsloth.UnslothVisionDataCollator
|
|
assert chat_templates.train_on_responses_only is dataset_utils.train_on_responses_only
|
|
assert callable(unsloth.train_on_responses_only)
|
|
|
|
|
|
def test_mlx_trl_shim_preserves_existing_trl_module(monkeypatch):
|
|
"""The MLX TRL shim should patch, not replace, an already-loaded TRL module."""
|
|
unsloth = _import_mlx_unsloth()
|
|
trl = types.ModuleType("trl")
|
|
trl.__path__ = ["real-trainer-package"]
|
|
trl.existing_marker = object()
|
|
trl.ExistingExport = object()
|
|
trl.__all__ = ["ExistingExport", "BrokenExport"]
|
|
|
|
def _raise_for_broken_export(name):
|
|
if name == "BrokenExport":
|
|
raise RuntimeError("optional dependency missing")
|
|
raise AttributeError(name)
|
|
|
|
trl.__getattr__ = _raise_for_broken_export
|
|
monkeypatch.setitem(sys.modules, "trl", trl)
|
|
|
|
unsloth._install_mlx_trl_sft_shim()
|
|
|
|
assert sys.modules["trl"] is trl
|
|
assert trl.__path__ == ["real-trainer-package"]
|
|
assert trl.SFTTrainer is unsloth.UnslothTrainer
|
|
assert issubclass(trl.SFTConfig, unsloth.UnslothTrainingArguments)
|
|
assert trl.__UNSLOTH_MLX_COMPAT__ is True
|
|
assert "ExistingExport" in trl.__all__
|
|
assert "BrokenExport" not in trl.__all__
|
|
assert "SFTTrainer" in trl.__all__
|
|
assert "SFTConfig" in trl.__all__
|
|
|
|
|
|
def test_mlx_trl_shim_installs_real_trl_or_stub(monkeypatch):
|
|
"""The MLX TRL shim should prefer real TRL and stub only if unavailable."""
|
|
unsloth = _import_mlx_unsloth()
|
|
monkeypatch.delitem(sys.modules, "trl", raising = False)
|
|
real_trl_available = importlib.util.find_spec("trl") is not None
|
|
|
|
unsloth._install_mlx_trl_sft_shim()
|
|
trl = importlib.import_module("trl")
|
|
|
|
if real_trl_available:
|
|
assert trl.__version__ != "0.0.0+unsloth-mlx"
|
|
else:
|
|
assert trl.__version__ == "0.0.0+unsloth-mlx"
|
|
assert trl.SFTTrainer is unsloth.UnslothTrainer
|
|
assert issubclass(trl.SFTConfig, unsloth.UnslothTrainingArguments)
|
|
assert trl.__UNSLOTH_MLX_COMPAT__ is True
|
|
|
|
|
|
def test_mlx_trl_star_import_exports_public_shims():
|
|
"""Existing `from trl import *` callers should receive MLX SFT shims."""
|
|
unsloth = _import_mlx_unsloth()
|
|
namespace = {}
|
|
|
|
exec("from trl import *", namespace)
|
|
|
|
assert namespace["SFTTrainer"] is unsloth.UnslothTrainer
|
|
assert issubclass(namespace["SFTConfig"], unsloth.UnslothTrainingArguments)
|
|
|
|
|
|
def test_mlx_rl_trainers_stub_with_clear_error(monkeypatch):
|
|
"""GRPO/DPO/ORPO trainers have no MLX path, so the shim retargets the ones trl
|
|
exposes to a clear NotImplementedError instead of a confusing CUDA crash, and
|
|
never invents trainers trl does not have."""
|
|
unsloth = _import_mlx_unsloth()
|
|
trl = types.ModuleType("trl")
|
|
trl.__path__ = ["real-trainer-package"]
|
|
|
|
class _RealTrainer:
|
|
def __init__(self, *args, **kwargs):
|
|
raise AssertionError("the real torch/CUDA trainer must not run on MLX")
|
|
|
|
trl.GRPOTrainer = _RealTrainer
|
|
trl.DPOTrainer = _RealTrainer
|
|
monkeypatch.setitem(sys.modules, "trl", trl)
|
|
|
|
unsloth._install_mlx_trl_sft_shim()
|
|
|
|
for name in ("GRPOTrainer", "DPOTrainer"):
|
|
assert getattr(trl, name) is not _RealTrainer
|
|
with pytest.raises(NotImplementedError) as exc:
|
|
getattr(trl, name)(model = None, args = None)
|
|
assert "MLX" in str(exc.value) and name in str(exc.value)
|
|
# trainers trl never exposed must not be invented
|
|
assert not hasattr(trl, "PPOTrainer")
|
|
# idempotent: a second install keeps the same stub
|
|
stub = trl.GRPOTrainer
|
|
unsloth._install_mlx_trl_sft_shim()
|
|
assert trl.GRPOTrainer is stub
|
|
|
|
|
|
def test_mlx_rl_trainer_stub_is_lazy_import_safe(monkeypatch):
|
|
"""Stubbing unsupported trl trainers must not resolve them: trl lazy-imports
|
|
pull torch, so on a torch-free MLX install a getattr probe would crash
|
|
`import unsloth`. The shim reads __all__/vars metadata and never triggers
|
|
trl's __getattr__ for a trainer it is about to replace."""
|
|
unsloth = _import_mlx_unsloth()
|
|
trl = types.ModuleType("trl")
|
|
trl.__path__ = ["real-trainer-package"]
|
|
trl.__all__ = ["SFTTrainer", "SFTConfig", "GRPOTrainer", "DPOTrainer"]
|
|
resolved = []
|
|
|
|
def _lazy_getattr(name):
|
|
resolved.append(name)
|
|
raise ImportError(f"lazy import of {name} would pull torch")
|
|
|
|
trl.__getattr__ = _lazy_getattr
|
|
monkeypatch.setitem(sys.modules, "trl", trl)
|
|
|
|
unsloth._install_mlx_trl_sft_shim() # must not raise despite the lazy trl
|
|
|
|
# trainers declared in __all__ are stubbed WITHOUT ever resolving the real one
|
|
assert resolved == []
|
|
for name in ("GRPOTrainer", "DPOTrainer"):
|
|
with pytest.raises(NotImplementedError):
|
|
getattr(trl, name)(model = None)
|
|
|
|
|
|
def test_mlx_stubs_trl_trainers_outside_fixed_set(monkeypatch):
|
|
"""Any non-SFT trainer trl exports (e.g. a newer RLOOTrainer not in the fixed
|
|
list) must be stubbed too, so no torch trainer slips through on MLX."""
|
|
unsloth = _import_mlx_unsloth()
|
|
trl = types.ModuleType("trl")
|
|
trl.__path__ = ["real-trainer-package"]
|
|
trl.__all__ = ["SFTTrainer", "SFTConfig", "RLOOTrainer"]
|
|
monkeypatch.setitem(sys.modules, "trl", trl)
|
|
|
|
unsloth._install_mlx_trl_sft_shim()
|
|
|
|
with pytest.raises(NotImplementedError) as exc:
|
|
trl.RLOOTrainer(model = None)
|
|
assert "MLX" in str(exc.value) and "RLOOTrainer" in str(exc.value)
|
|
# SFT stays usable; only non-SFT trainers are stubbed
|
|
assert trl.SFTTrainer is unsloth.UnslothTrainer
|
|
|
|
|
|
def test_mlx_preserve_dataset_order_is_accepted():
|
|
"""preserve_dataset_order=True must be accepted (it is a real MLX config field),
|
|
not rejected as an unknown/unsupported argument."""
|
|
unsloth = _import_mlx_unsloth()
|
|
args = unsloth.UnslothTrainingArguments(
|
|
output_dir = "mlx-out",
|
|
max_steps = 10,
|
|
preserve_dataset_order = True,
|
|
)
|
|
assert getattr(args, "preserve_dataset_order", False) is True
|
|
|
|
|
|
def test_mlx_sftconfig_alias_keeps_trl_epoch_default(monkeypatch):
|
|
"""`trl.SFTConfig` (aliased on MLX) keeps TRL's default training length: with
|
|
no explicit max_steps/num_train_epochs it runs TRL's 3 epochs, not the native
|
|
MLX 60-step default. An explicit length is authoritative and untouched."""
|
|
unsloth = _import_mlx_unsloth()
|
|
trl = types.ModuleType("trl")
|
|
trl.__path__ = ["real-trainer-package"]
|
|
monkeypatch.setitem(sys.modules, "trl", trl)
|
|
|
|
unsloth._install_mlx_trl_sft_shim()
|
|
|
|
# no explicit length -> TRL epoch default (3 epochs, step cap disabled)
|
|
cfg = trl.SFTConfig(output_dir = "mlx-out")
|
|
assert cfg.num_train_epochs == 3
|
|
assert cfg.max_steps == -1
|
|
# explicit step / epoch counts stay exactly as written
|
|
assert trl.SFTConfig(output_dir = "mlx-out", max_steps = 17).max_steps == 17
|
|
assert trl.SFTConfig(output_dir = "mlx-out", num_train_epochs = 2).num_train_epochs == 2
|
|
|
|
|
|
def test_mlx_vision_collator_is_constructor_compatible():
|
|
"""Vision notebooks should be able to instantiate the collator placeholder."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
collator = unsloth.UnslothVisionDataCollator("model", "processor", flag = True)
|
|
|
|
assert collator.model == "model"
|
|
assert collator.processor == "processor"
|
|
assert collator.kwargs == {"completion_only_loss": True, "flag": True}
|
|
|
|
|
|
def test_mlx_train_on_responses_only_returns_shared_mask_function():
|
|
"""The MLX public shim should expose the shared response-mask helper."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
class Tokenizer:
|
|
def __call__(
|
|
self,
|
|
text,
|
|
add_special_tokens = False,
|
|
):
|
|
return types.SimpleNamespace(
|
|
input_ids = {
|
|
"<user>": [1],
|
|
"<assistant>": [2],
|
|
}[text]
|
|
)
|
|
|
|
def convert_tokens_to_ids(self, token):
|
|
return token
|
|
|
|
mask_fn = unsloth.train_on_responses_only(
|
|
None,
|
|
instruction_part = "<user>",
|
|
response_part = "<assistant>",
|
|
tokenizer = Tokenizer(),
|
|
return_function = True,
|
|
)
|
|
masked = mask_fn(
|
|
{
|
|
"input_ids": [[1, 10, 2, 20, 21, 1, 11]],
|
|
}
|
|
)
|
|
|
|
assert masked == {"labels": [[-100, -100, -100, 20, 21, -100, -100]]}
|
|
|
|
last_mask_fn = unsloth.train_on_responses_only(
|
|
None,
|
|
instruction_part = "<user>",
|
|
response_part = "<assistant>",
|
|
tokenizer = Tokenizer(),
|
|
return_function = True,
|
|
last_response_only = True,
|
|
)
|
|
last_masked = last_mask_fn(
|
|
{
|
|
"input_ids": [[1, 10, 2, 20, 1, 11, 2, 30]],
|
|
}
|
|
)
|
|
|
|
assert last_masked == {"labels": [[-100, -100, -100, -100, -100, -100, -100, 30]]}
|
|
|
|
|
|
def test_mlx_get_chat_template_uses_light_tokenizer_patch(monkeypatch):
|
|
"""MLX notebooks should not import CUDA-heavy tokenizer/save helpers."""
|
|
_import_mlx_unsloth()
|
|
from unsloth.chat_templates import get_chat_template
|
|
import unsloth_zoo.tokenizer_utils as tokenizer_utils
|
|
|
|
class Tokenizer:
|
|
is_fast = True
|
|
padding_side = "right"
|
|
eos_token = "<eos>"
|
|
bos_token = "<bos>"
|
|
unk_token = "<unk>"
|
|
pad_token = "<pad>"
|
|
added_tokens_decoder = {}
|
|
|
|
def fake_patch_tokenizer(model, tokenizer):
|
|
return model, tokenizer
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def guarded_import(name, *args, **kwargs):
|
|
if name.startswith("unsloth.models") or name.startswith("unsloth.save"):
|
|
raise AssertionError(f"unexpected CUDA-heavy import: {name}")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(tokenizer_utils, "patch_tokenizer", fake_patch_tokenizer)
|
|
monkeypatch.setattr(builtins, "__import__", guarded_import)
|
|
|
|
tokenizer = get_chat_template(
|
|
Tokenizer(),
|
|
chat_template = ("{{ messages }}", "<eos>"),
|
|
)
|
|
|
|
assert tokenizer.chat_template == "{{ messages }}"
|
|
assert tokenizer.padding_side == "right"
|
|
|
|
|
|
def test_mlx_gpu_memory_stats_helper_shape():
|
|
"""The portable memory helper should return CUDA-shaped values."""
|
|
unsloth = _import_mlx_unsloth()
|
|
|
|
stats, used, total = unsloth.get_gpu_memory_stats()
|
|
|
|
assert isinstance(stats.name, str)
|
|
assert hasattr(stats, "total_memory")
|
|
assert isinstance(used, float)
|
|
assert total > 0
|
|
|
|
|
|
def test_mlx_torch_cuda_compatibility_shim():
|
|
"""Existing CUDA memory and move calls should run on MLX."""
|
|
unsloth = _import_mlx_unsloth()
|
|
torch = pytest.importorskip("torch")
|
|
from transformers.tokenization_utils_base import BatchEncoding
|
|
|
|
stats, used, total = unsloth.get_gpu_memory_stats()
|
|
cuda_stats = torch.cuda.get_device_properties(0)
|
|
|
|
assert cuda_stats.name == stats.name
|
|
assert cuda_stats.total_memory == stats.total_memory
|
|
assert torch.cuda.get_device_name(0) == stats.name
|
|
assert torch.cuda.max_memory_reserved() == int(used * 1024 * 1024 * 1024)
|
|
assert torch.cuda.max_memory_allocated() == torch.cuda.max_memory_reserved()
|
|
# current (non-max) APIs report live active memory, not the peak high-water
|
|
# mark, and never exceed it.
|
|
assert 0 <= torch.cuda.memory_reserved() <= torch.cuda.max_memory_reserved()
|
|
assert torch.cuda.memory_allocated() == torch.cuda.memory_reserved()
|
|
assert torch.cuda.device_count() == 1
|
|
assert torch.cuda.current_device() == 0
|
|
assert torch.cuda.get_device_capability() == (0, 0)
|
|
assert total > 0
|
|
|
|
free_bytes, total_bytes = torch.cuda.mem_get_info()
|
|
assert total_bytes == int(total * 1024 * 1024 * 1024)
|
|
assert 0 <= free_bytes <= total_bytes
|
|
|
|
torch.cuda.empty_cache()
|
|
torch.cuda.synchronize()
|
|
torch.cuda.reset_peak_memory_stats()
|
|
torch.cuda.set_device(0)
|
|
|
|
tensor = torch.tensor([1, 2, 3])
|
|
assert tensor.to("cuda") is tensor
|
|
assert tensor.cuda() is tensor
|
|
assert tensor.to(device = "cuda") is tensor
|
|
assert tensor.to("cuda", dtype = torch.float32).dtype == torch.float32
|
|
|
|
batch = BatchEncoding({"input_ids": tensor})
|
|
assert batch.to("cuda") is batch
|
|
assert batch.to(device = "cuda") is batch
|