* Reduce and tighten comments and docstrings in tests Shorten verbose comments and docstrings across the test suite without changing any test logic. Remove narration that restates the next line, collapse long module and test docstrings to a single line, and drop banner separators. Keep regression context (issue and PR references, run ids), skip reasons, mocking and timing rationale, license headers, lint and type directives, and commented-out code. Comments and docstrings only: an AST signature check confirms no code, assertions, or string literals changed, and the suite byte-compiles cleanly. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
195 lines
6.9 KiB
Python
195 lines
6.9 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
"""Regression tests for the CUDA-vs-MLX dispatch gates Studio 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 Studio 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)
|
|
|
|
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}"
|