* Replace standalone Studio wording with Unsloth Replace the single word Studio with Unsloth wherever it is used as shorthand for Unsloth Studio in docs, CLI output, UI strings, i18n locales, workflow display names, comments and docstrings. Kept unchanged: the full name Unsloth Studio, third party product names (LM Studio, Visual Studio, Mac Studio), feature names (Recipe Studio, Fine-tuning Studio and its translations), and all identifiers such as env vars, commands, paths and filenames. * Address review feedback on the Studio wording rename Use "an" before Unsloth where the rename left the article as "a". Restore the split brand where Unsloth and Studio render as two halves of the full product name: the onboarding sidebar subtitle and the IPv6 localhost warning. Scope two messages to the full name Unsloth Studio where plain Unsloth was misleading: the AMD README bullet and the CLI studio setup error.
201 lines
7.3 KiB
Python
201 lines
7.3 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
"""Regression tests for the CUDA-vs-MLX dispatch gates Unsloth relies on.
|
|
|
|
Two gates: (1) ``unsloth._IS_MLX`` (import-time, delegates to the zoo MLX
|
|
runtime gate behind a local precheck barrier); (2)
|
|
``utils.hardware.detect_hardware()`` (runtime, CUDA->XPU->MLX->CPU). These
|
|
are the canaries against "MLX support accidentally hijacks CUDA/AMD/Intel
|
|
users": we check the _IS_MLX helper structure, flip both gates True under a
|
|
spoofed Darwin+arm64 with a fake mlx module, and confirm both stay CUDA-side
|
|
on the real host. No real MLX install needed.
|
|
"""
|
|
|
|
import ast
|
|
import importlib
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
UNSLOTH_INIT = REPO_ROOT / "unsloth" / "__init__.py"
|
|
|
|
|
|
# 1. Source-level structure check on _IS_MLX (no platform dependencies).
|
|
|
|
|
|
def test_is_mlx_gate_uses_three_required_predicates():
|
|
"""_IS_MLX must AND Darwin+arm64+importable-mlx; dropping any breaks dispatch."""
|
|
tree = ast.parse(UNSLOTH_INIT.read_text())
|
|
|
|
target = None
|
|
for node in ast.walk(tree):
|
|
if (
|
|
isinstance(node, ast.Assign)
|
|
and len(node.targets) == 1
|
|
and isinstance(node.targets[0], ast.Name)
|
|
and node.targets[0].id == "_IS_MLX"
|
|
):
|
|
target = node.value
|
|
break
|
|
assert target is not None, "_IS_MLX assignment not found in unsloth/__init__.py"
|
|
assert isinstance(target, ast.Call), "_IS_MLX must call the shared MLX helper"
|
|
expr_src = ast.unparse(target)
|
|
assert expr_src == "_is_mlx_available()", "_IS_MLX must delegate to the shared MLX runtime gate"
|
|
|
|
helper = None
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.FunctionDef) and node.name == "_is_mlx_available":
|
|
helper = node
|
|
break
|
|
assert helper is not None, "_is_mlx_available helper not found"
|
|
|
|
helper_src = ast.unparse(helper)
|
|
assert (
|
|
"platform.system()" in helper_src
|
|
and "'Darwin'" in helper_src
|
|
and "platform.machine()" in helper_src
|
|
and "'arm64'" in helper_src
|
|
and "find_spec" in helper_src
|
|
and "'mlx'" in helper_src
|
|
and "from unsloth_zoo.mlx import is_mlx_available" in helper_src
|
|
), "_IS_MLX helper must precheck local MLX predicates before importing zoo"
|
|
assert (
|
|
"from unsloth_zoo.mlx import is_mlx_available" in helper_src
|
|
and "return is_mlx_available()" in helper_src
|
|
), "_IS_MLX helper must delegate final detection to the shared zoo MLX runtime gate"
|
|
assert helper_src.index("UNSLOTH_FORCE_GPU_PATH") < helper_src.index(
|
|
"from unsloth_zoo.mlx import is_mlx_available"
|
|
), "_IS_MLX helper must run the local MLX precheck before importing zoo"
|
|
|
|
|
|
# 2. Runtime gate behavior with platform spoofed to Apple Silicon + fake mlx.
|
|
# Re-evaluates the expression rather than reloading unsloth (avoids a torch
|
|
# cascade-reload).
|
|
|
|
|
|
def _evaluate_is_mlx_precheck(platform_module, importlib_util, os_module):
|
|
"""Re-evaluate the local _is_mlx_available precheck with injected deps."""
|
|
return (
|
|
os_module.environ.get("UNSLOTH_FORCE_GPU_PATH", "0") != "1"
|
|
and platform_module.system() == "Darwin"
|
|
and platform_module.machine() == "arm64"
|
|
and importlib_util.find_spec("mlx") is not None
|
|
)
|
|
|
|
|
|
def test_is_mlx_gate_true_on_apple_silicon_with_mlx_present(monkeypatch):
|
|
import platform
|
|
import importlib.util
|
|
|
|
# Fake mlx so find_spec returns a non-None ModuleSpec.
|
|
fake_mlx = types.ModuleType("mlx")
|
|
fake_mlx.__spec__ = importlib.machinery.ModuleSpec("mlx", loader = None)
|
|
fake_mlx.__path__ = []
|
|
monkeypatch.setitem(sys.modules, "mlx", fake_mlx)
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
|
monkeypatch.setattr(platform, "machine", lambda: "arm64")
|
|
|
|
import os
|
|
|
|
assert _evaluate_is_mlx_precheck(platform, importlib.util, os) is True
|
|
|
|
|
|
def test_is_mlx_gate_false_when_mlx_missing(monkeypatch):
|
|
import platform
|
|
import importlib.util
|
|
|
|
# Apple Silicon but no mlx -> gate must be False.
|
|
monkeypatch.delitem(sys.modules, "mlx", raising = False)
|
|
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
|
monkeypatch.setattr(platform, "machine", lambda: "arm64")
|
|
|
|
real_find_spec = importlib.util.find_spec
|
|
|
|
def _no_mlx(name, *args, **kwargs):
|
|
if name == "mlx":
|
|
return None
|
|
return real_find_spec(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(importlib.util, "find_spec", _no_mlx)
|
|
|
|
import os
|
|
|
|
assert _evaluate_is_mlx_precheck(platform, importlib.util, os) is False
|
|
|
|
|
|
def test_is_mlx_gate_false_on_non_apple_silicon():
|
|
"""On the real Linux+CUDA / AMD / Intel test host, the gate stays False."""
|
|
import platform
|
|
import importlib.util
|
|
|
|
if platform.system() == "Darwin" and platform.machine() == "arm64":
|
|
import pytest
|
|
pytest.skip("Test host is Apple Silicon; CUDA-side canary doesn't apply.")
|
|
|
|
import os
|
|
|
|
assert _evaluate_is_mlx_precheck(platform, importlib.util, os) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. detect_hardware() picks MLX only when CUDA+XPU are both unavailable AND
|
|
# the host is Apple Silicon AND mlx is importable.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _import_studio_hardware():
|
|
"""Lazy import of the Unsloth hardware module (studio/backend on sys.path)."""
|
|
studio_backend = REPO_ROOT / "studio" / "backend"
|
|
if str(studio_backend) not in sys.path:
|
|
sys.path.insert(0, str(studio_backend))
|
|
from utils.hardware import hardware as hw # type: ignore
|
|
|
|
return hw
|
|
|
|
|
|
def test_detect_hardware_picks_mlx_when_only_apple_silicon_available(monkeypatch):
|
|
hw = _import_studio_hardware()
|
|
|
|
# Force CUDA + XPU off so detect_hardware falls through to MLX.
|
|
import torch
|
|
|
|
monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
|
|
if hasattr(torch, "xpu"):
|
|
monkeypatch.setattr(torch.xpu, "is_available", lambda: False)
|
|
|
|
# Spoof Apple Silicon + importable mlx.core for _has_mlx().
|
|
import platform
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
|
monkeypatch.setattr(platform, "machine", lambda: "arm64")
|
|
|
|
fake_mlx = types.ModuleType("mlx")
|
|
fake_mlx_core = types.ModuleType("mlx.core")
|
|
fake_mlx.core = fake_mlx_core
|
|
monkeypatch.setitem(sys.modules, "mlx", fake_mlx)
|
|
monkeypatch.setitem(sys.modules, "mlx.core", fake_mlx_core)
|
|
|
|
# detect_hardware now gates MLX on the full stack via _has_usable_mlx_stack()
|
|
# (utils.mlx_repair.mlx_stack_available imports mlx_lm/mlx_vlm and checks
|
|
# versions); faking mlx.core alone no longer satisfies it. This test asserts the
|
|
# dispatch decision when the stack IS usable, so model that directly.
|
|
monkeypatch.setattr(hw, "_has_usable_mlx_stack", lambda: True)
|
|
|
|
detected = hw.detect_hardware()
|
|
assert detected == hw.DeviceType.MLX, f"expected MLX, got {detected!r}"
|
|
|
|
|
|
def test_detect_hardware_picks_cuda_on_real_host():
|
|
"""Canary: a real CUDA host must dispatch to CUDA even if mlx is importable."""
|
|
import torch
|
|
|
|
if not torch.cuda.is_available():
|
|
import pytest
|
|
pytest.skip("No CUDA available on this host; canary not applicable.")
|
|
|
|
hw = _import_studio_hardware()
|
|
detected = hw.detect_hardware()
|
|
assert detected == hw.DeviceType.CUDA, f"CUDA host must dispatch to CUDA, got {detected!r}"
|