Files
wehub-resource-sync e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

476 lines
16 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for utils/hardware and utils/utils: device detection, GPU memory, error formatting.
Passes on any platform (NVIDIA/CUDA, Apple Silicon/MLX, CPU-only). No ML framework
is imported at top level; tests needing torch/mlx internals skip when unavailable.
"""
import platform
from unittest.mock import patch, MagicMock
import pytest
# --- Conditional framework imports ---
try:
import torch
HAS_TORCH = True
except ImportError:
HAS_TORCH = False
try:
import mlx.core as mx
HAS_MLX = True
except ImportError:
HAS_MLX = False
needs_torch = pytest.mark.skipif(not HAS_TORCH, reason = "PyTorch not installed")
needs_mlx = pytest.mark.skipif(not HAS_MLX, reason = "MLX not installed")
from utils.hardware import (
get_device,
detect_hardware,
is_apple_silicon,
clear_gpu_cache,
get_gpu_memory_info,
log_gpu_memory,
DeviceType,
)
import utils.hardware.hardware as _hw_module
from utils.utils import format_error_message
# ========== Helpers ==========
def _actual_device() -> str:
"""Return the real device string for the current machine."""
if HAS_TORCH and torch.cuda.is_available():
return "cuda"
if is_apple_silicon() and HAS_MLX:
return "mlx"
return "cpu"
def _reset_and_detect():
"""Reset the cached DEVICE global and re-run detection."""
_hw_module.DEVICE = None
return detect_hardware()
# ========== get_device() ==========
class TestGetDevice:
"""Tests for get_device() — should agree with the real hardware."""
def setup_method(self):
self._saved_device = _hw_module.DEVICE
def teardown_method(self):
_hw_module.DEVICE = self._saved_device
def test_returns_valid_device_type(self):
result = get_device()
assert result in (DeviceType.CUDA, DeviceType.MLX, DeviceType.CPU)
def test_matches_actual_hardware(self):
assert get_device().value == _actual_device()
# --- Mocked paths ---
@needs_torch
def test_returns_cuda_when_cuda_available(self):
with (
patch("utils.hardware.hardware._has_torch", return_value = True),
patch("torch.cuda.is_available", return_value = True),
):
assert _reset_and_detect() == DeviceType.CUDA
@needs_torch
def test_detect_survives_device0_probe_failure(self, capsys):
# is_available() True but the device-0 name probe raises: startup must
# still resolve CUDA rather than crash.
with (
patch("utils.hardware.hardware._has_torch", return_value = True),
patch("torch.cuda.is_available", return_value = True),
patch("torch.cuda.device_count", return_value = 1),
patch("torch.cuda.get_device_properties", side_effect = RuntimeError("probe")),
):
assert _reset_and_detect() == DeviceType.CUDA
assert "<unavailable>" in capsys.readouterr().out
@needs_mlx
def test_returns_mlx_when_on_apple_silicon_with_mlx(self):
with (
patch("utils.hardware.hardware._has_torch", return_value = False),
patch("utils.hardware.hardware.is_apple_silicon", return_value = True),
patch("utils.hardware.hardware._has_mlx", return_value = True),
patch("utils.hardware.hardware._has_usable_mlx_stack", return_value = True),
):
assert _reset_and_detect() == DeviceType.MLX
def test_returns_cpu_when_nothing_available(self):
with (
patch("utils.hardware.hardware._has_torch", return_value = False),
patch("utils.hardware.hardware.is_apple_silicon", return_value = False),
patch("utils.hardware.hardware._has_mlx", return_value = False),
):
assert _reset_and_detect() == DeviceType.CPU
# ========== is_apple_silicon() ==========
class TestIsAppleSilicon:
def test_returns_bool(self):
assert isinstance(is_apple_silicon(), bool)
def test_true_on_darwin_arm64(self):
with patch("utils.hardware.hardware.platform") as mock_plat:
mock_plat.system.return_value = "Darwin"
mock_plat.machine.return_value = "arm64"
assert is_apple_silicon() is True
def test_false_on_linux_x86(self):
with patch("utils.hardware.hardware.platform") as mock_plat:
mock_plat.system.return_value = "Linux"
mock_plat.machine.return_value = "x86_64"
assert is_apple_silicon() is False
def test_false_on_darwin_x86(self):
"""Intel Mac should return False."""
with patch("utils.hardware.hardware.platform") as mock_plat:
mock_plat.system.return_value = "Darwin"
mock_plat.machine.return_value = "x86_64"
assert is_apple_silicon() is False
# ========== clear_gpu_cache() ==========
class TestClearGpuCache:
"""clear_gpu_cache() must never raise, regardless of platform."""
def test_does_not_raise(self):
clear_gpu_cache()
@needs_torch
def test_calls_cuda_cache_when_cuda(self):
with (
patch("utils.hardware.hardware.get_device", return_value = DeviceType.CUDA),
patch("torch.cuda.empty_cache") as mock_empty,
patch("torch.cuda.ipc_collect") as mock_ipc,
):
clear_gpu_cache()
mock_empty.assert_called_once()
mock_ipc.assert_called_once()
@needs_mlx
def test_mlx_does_not_raise(self):
"""MLX cache clear is a no-op — should just succeed."""
with patch("utils.hardware.hardware.get_device", return_value = DeviceType.MLX):
clear_gpu_cache()
def test_noop_on_cpu(self):
with patch("utils.hardware.hardware.get_device", return_value = DeviceType.CPU):
clear_gpu_cache()
# ========== get_gpu_memory_info() ==========
class TestGetGpuMemoryInfo:
def test_returns_dict(self):
result = get_gpu_memory_info()
assert isinstance(result, dict)
def test_has_available_key(self):
assert "available" in get_gpu_memory_info()
def test_has_backend_key(self):
assert "backend" in get_gpu_memory_info()
def test_backend_matches_device(self):
# _backend_label swaps "cuda" for "rocm" on AMD hosts; elsewhere it
# equals get_device().value.
from utils.hardware.hardware import _backend_label
result = get_gpu_memory_info()
assert result["backend"] == _backend_label(get_device())
# --- When a GPU IS available ---
@pytest.mark.skipif(_actual_device() == "cpu", reason = "No GPU available on this machine")
def test_gpu_available_fields(self):
result = get_gpu_memory_info()
assert result["available"] is True
assert result["total_gb"] > 0
assert result["allocated_gb"] >= 0
assert result["free_gb"] >= 0
assert 0 <= result["utilization_pct"] <= 100
assert "device_name" in result
# --- CUDA-specific mocked test ---
@needs_torch
def test_cuda_path_returns_correct_fields(self):
mock_props = MagicMock()
mock_props.total_memory = 16 * (1024**3)
mock_props.name = "NVIDIA Test GPU"
with (
patch("utils.hardware.hardware.get_device", return_value = DeviceType.CUDA),
patch("torch.cuda.current_device", return_value = 0),
patch("torch.cuda.get_device_properties", return_value = mock_props),
patch("torch.cuda.memory_allocated", return_value = 4 * (1024**3)),
patch("torch.cuda.memory_reserved", return_value = 6 * (1024**3)),
):
result = get_gpu_memory_info()
assert result["available"] is True
assert result["backend"] == "cuda"
assert result["device_name"] == "NVIDIA Test GPU"
assert abs(result["total_gb"] - 16.0) < 0.01
assert abs(result["allocated_gb"] - 4.0) < 0.01
assert abs(result["free_gb"] - 12.0) < 0.01
assert abs(result["utilization_pct"] - 25.0) < 0.1
# --- MLX-specific mocked test ---
@needs_mlx
def test_mlx_path_returns_correct_fields(self):
mock_psutil_mem = MagicMock()
mock_psutil_mem.total = 32 * (1024**3) # 32 GB unified
mock_psutil = MagicMock()
mock_psutil.virtual_memory.return_value = mock_psutil_mem
with (
patch("utils.hardware.hardware.get_device", return_value = DeviceType.MLX),
patch.dict("sys.modules", {"psutil": mock_psutil}),
):
result = get_gpu_memory_info()
assert result["available"] is True
assert result["backend"] == "mlx"
assert "Apple Silicon" in result["device_name"]
assert abs(result["total_gb"] - 32.0) < 0.01
# --- CPU-only path ---
def test_cpu_path_returns_unavailable(self):
with patch("utils.hardware.hardware.get_device", return_value = DeviceType.CPU):
result = get_gpu_memory_info()
assert result["available"] is False
assert result["backend"] == "cpu"
# --- Error resilience ---
@needs_torch
def test_cuda_error_returns_unavailable(self):
with (
patch("utils.hardware.hardware.get_device", return_value = DeviceType.CUDA),
patch(
"torch.cuda.current_device",
side_effect = RuntimeError("CUDA init failed"),
),
):
result = get_gpu_memory_info()
assert result["available"] is False
assert "error" in result
# ========== log_gpu_memory() ==========
class TestLogGpuMemory:
def test_does_not_raise(self):
log_gpu_memory("test")
def test_logs_gpu_info_when_available(self, capfd):
fake_info = {
"available": True,
"backend": "cuda",
"device_name": "FakeGPU",
"allocated_gb": 2.0,
"total_gb": 16.0,
"utilization_pct": 12.5,
"free_gb": 14.0,
}
with patch("utils.hardware.hardware.get_gpu_memory_info", return_value = fake_info):
log_gpu_memory("unit-test")
captured = capfd.readouterr()
assert "unit-test" in captured.out
assert "CUDA" in captured.out
assert "FakeGPU" in captured.out
def test_logs_cpu_fallback_when_no_gpu(self, capfd):
fake_info = {"available": False, "backend": "cpu"}
with patch("utils.hardware.hardware.get_gpu_memory_info", return_value = fake_info):
log_gpu_memory("cpu-test")
captured = capfd.readouterr()
assert "No GPU available" in captured.out
# ========== CUDA_DEVICE_ORDER pinning ==========
class TestCudaDeviceOrder:
"""Importing the hardware module pins CUDA_DEVICE_ORDER=PCI_BUS_ID when unset,
but setdefault keeps an explicit user override, so nvidia-smi indices, torch
ordinals, and CUDA_VISIBLE_DEVICES agree on a mixed-GPU host."""
@staticmethod
def _order_after_fresh_import(preset):
# Fresh interpreter so the module-level setdefault runs against a clean env.
import os, subprocess, sys
from pathlib import Path
env = os.environ.copy()
backend = str(Path(__file__).resolve().parents[1])
existing = env.get("PYTHONPATH", "")
# Avoid a trailing os.pathsep (empty entry -> cwd on sys.path) when unset.
env["PYTHONPATH"] = (backend + os.pathsep + existing) if existing else backend
if preset is None:
env.pop("CUDA_DEVICE_ORDER", None)
else:
env["CUDA_DEVICE_ORDER"] = preset
out = subprocess.run(
[
sys.executable,
"-c",
"import os, utils.hardware.hardware; print(os.environ.get('CUDA_DEVICE_ORDER'))",
],
env = env,
capture_output = True,
text = True,
check = True,
)
return out.stdout.strip().splitlines()[-1]
def test_import_pins_pci_bus_id_when_unset(self):
assert self._order_after_fresh_import(None) == "PCI_BUS_ID"
def test_import_respects_explicit_user_override(self):
assert self._order_after_fresh_import("FASTEST_FIRST") == "FASTEST_FIRST"
# ========== _print_cuda_device_list() ==========
class TestPrintCudaDeviceList:
"""The startup console lists every CUDA GPU with its index, not just
device 0, so a multi-GPU host shows the full available set."""
@needs_torch
def test_lists_all_devices_when_multi_gpu(self, capsys):
props = [
MagicMock(name = "p0"),
MagicMock(name = "p1"),
]
props[0].name = "NVIDIA GeForce RTX 5090"
props[1].name = "NVIDIA RTX PRO 6000 Blackwell Workstation Edition"
with (
patch("torch.cuda.device_count", return_value = 2),
patch("torch.cuda.get_device_properties", side_effect = lambda i: props[i]),
):
_hw_module._print_cuda_device_list(is_rocm = False)
out = capsys.readouterr().out
assert "[0] NVIDIA GeForce RTX 5090" in out
assert "[1] NVIDIA RTX PRO 6000 Blackwell Workstation Edition" in out
assert "CUDA_DEVICE_ORDER=" in out
@needs_torch
def test_silent_on_single_gpu(self, capsys):
with patch("torch.cuda.device_count", return_value = 1):
_hw_module._print_cuda_device_list(is_rocm = False)
assert capsys.readouterr().out == ""
@needs_torch
def test_never_raises_on_probe_failure(self, capsys):
with patch("torch.cuda.device_count", side_effect = RuntimeError("no cuda")):
_hw_module._print_cuda_device_list(is_rocm = False)
assert capsys.readouterr().out == ""
@needs_torch
def test_rocm_label_omits_cuda_device_order(self, capsys):
# CUDA_DEVICE_ORDER governs CUDA only, so the ROCm listing must not claim it.
props = [MagicMock(), MagicMock()]
props[0].name = "AMD Instinct MI300X"
props[1].name = "AMD Instinct MI300X"
with (
patch("torch.cuda.device_count", return_value = 2),
patch("torch.cuda.get_device_properties", side_effect = lambda i: props[i]),
):
_hw_module._print_cuda_device_list(is_rocm = True)
out = capsys.readouterr().out
assert "ROCm devices (2):" in out
assert "CUDA_DEVICE_ORDER" not in out
assert "[0] AMD Instinct MI300X" in out
# ========== format_error_message() ==========
class TestFormatErrorMessage:
def test_not_found(self):
err = Exception("Repository not found for unsloth/test")
msg = format_error_message(err, "unsloth/test")
assert "not found" in msg.lower()
assert "test" in msg
def test_unauthorized(self):
err = Exception("401 Unauthorized")
msg = format_error_message(err, "some/model")
assert "authentication" in msg.lower() or "unauthorized" in msg.lower()
def test_gated_model(self):
err = Exception("Access to model requires authentication")
msg = format_error_message(err, "meta/llama")
assert "authentication" in msg.lower()
def test_invalid_token(self):
err = Exception("Invalid user token")
msg = format_error_message(err, "any/model")
assert "invalid" in msg.lower()
# --- OOM on CUDA ---
@needs_torch
def test_cuda_oom(self):
err = Exception("CUDA out of memory")
with patch("utils.hardware.get_device", return_value = DeviceType.CUDA):
msg = format_error_message(err, "big/model")
assert "GPU" in msg
assert "big/model" not in msg
assert "model" in msg
# --- OOM on MLX ---
@needs_mlx
def test_mlx_oom(self):
err = Exception("MLX backend out of memory")
with patch("utils.hardware.get_device", return_value = DeviceType.MLX):
msg = format_error_message(err, "unsloth/huge-model")
assert "Apple Silicon" in msg
# --- OOM on CPU ---
def test_cpu_oom(self):
err = Exception("not enough memory to allocate")
with patch("utils.hardware.get_device", return_value = DeviceType.CPU):
msg = format_error_message(err, "any/model")
assert "system" in msg.lower()
# --- Generic fallback ---
def test_generic_error(self):
err = Exception("Something completely unexpected")
msg = format_error_message(err, "any/model")
assert msg == "Something completely unexpected"