unsloth/studio/backend/tests/test_utils.py
yzxcj797 9e11d53a30
fix(hardware): report driver-level free VRAM in the GPU summary (#9362)
* fix(hardware): report driver-level free VRAM in the GPU summary

get_gpu_memory_info computed free as total - torch-allocated, which sees
neither this process's reserved cache nor any other process's
allocations: the summary could report a card as free while a model was
resident. The summary feeds /system-info, whose consumers include
training-method auto-selection — an overstated free picks a method that
OOMs on launch.

The CUDA and XPU paths now take free from trusted_mem_get_info (the
driver figure, with the platform's free over-report caps already applied
by that helper — Windows ROCm WDDM), falling back to total - reserved
when the probe fails; reserved includes allocated, so the fallback bound
is tighter than the old allocated-only figure. allocated/reserved/
utilization semantics are unchanged, and the MLX path (AGX usage against
unified memory) keeps its existing shape.

* fix(hardware): avoid context-pinning GPU summary probes

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

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

* fix(hardware): translate ROCm telemetry GPU ids

* fix(hardware): resolve NVIDIA UUID visibility masks

* fix(hardware): reject parent telemetry for MIG

* fix(hardware): decline stacked ROCm masks

* fix(hardware): validate ROCm telemetry inventory

* fix(hardware): preserve partition telemetry scope

* Subtract driver used-memory from torch's total, not the driver's

nvidia-smi reports reserved framebuffer separately from used, so
driver_total - used hands that reservation back as free. It is 726 MiB on
a B200, and torch's props.total_memory already excludes it: NVML total
minus NVML v2 reserved equals props.total_memory to the byte, so
subtracting used from torch's total is exactly the driver's free.

Measured on a B200 with a 2 GiB tensor resident, before this change:
free_gb 176.450 against nvidia-smi memory.free 175.745, and
free_gb + allocated_gb exceeded total_gb. After: 175.743, and the sum
sits back under total. A completely full card reported 0.71 GB free
before and reports 0 now, which is the failure this PR set out to fix.

Same helper for the ROCm sysfs and Windows branches. It is a no-op where
reserved is 0, which is every AMD path (amd-smi has no reserved concept
and derives free as total - used) and any NVIDIA GPU reporting none.

Also:
- Do not adopt a zero driver total for a ROCm APU; utilization_pct
  divides by it, so the whole report would be lost.
- Sort the nvidia-smi devices by visible_ordinal. Rows arrive in physical
  order, so a reordering mask ("1,0" or two UUIDs) returned a list whose
  position contradicted its own visible_ordinal.

Tests: reserved-framebuffer, full-card and clamp cases for the free
figure, telemetry-follows-the-mask for the reordered UUID case, and three
XPU tests covering the branch this PR added, including a torch without
xpu.mem_get_info (added in 2.6, and the floor here is 2.4).

* Decline the Windows ROCm adapter path under GPU_DEVICE_ORDINAL

GPU_DEVICE_ORDINAL sits at the ROCclr layer, so it renumbers HIP devices
on Windows as well as Linux, and _get_parent_visible_gpu_spec never reads
it. The adapter list therefore stayed unfiltered while idx was already a
renumbered torch ordinal, so GPU_DEVICE_ORDINAL=1 paired torch device 0
with physical adapter 0 and published the wrong card's free VRAM.

The other two probes already decline on this variable: the amd-smi path
explicitly, and the Linux sysfs path through _rocm_visibility_mask_active.
Windows was the one that did not. Share the predicate so the three cannot
drift apart again.

* Decline SMI free-VRAM telemetry when CUDA order is not PCI_BUS_ID

CUDA enumerates FASTEST_FIRST by default while nvidia-smi always reports
PCI order, and this module only pins PCI_BUS_ID with setdefault, so a user
who exports FASTEST_FIRST keeps it. torch ordinal 0 is then the fastest
card while row 0 is the lowest bus id, and matching visible_ordinal to the
torch ordinal reads the wrong card. Two equal-sized cards, a 3090 beside a
4090, slip past the total-scope check, so nothing else catches it.

Gate the probe on a shared index space, the same condition the llama.cpp
SM probe already applies. Declining falls through to mem_get_info, which
is ordinal-correct. A single-GPU host is exempt so the common case does
not pay a primary context for a mismatch it cannot have.

* Tighten the comments this PR adds

* Ignore a ROCR mask on Windows, and let a UUID mask past the order gate

Two device-identity gaps in the free-VRAM probe.

ROCR_VISIBLE_DEVICES is Linux-only, so on Windows HIP a stray or inherited
value masks nothing while _get_parent_visible_gpu_spec still read it as the
ordinal to physical mapping. Two equal-capacity cards then defeat the
total-scope guard and the wrong card's free VRAM is published. The
llama.cpp backend already skips ROCR on win32 for exactly this reason;
mirror it at the source so every consumer of the spec agrees.

The CUDA order gate was also too broad. A full-GPU UUID mask names its
devices absolutely and CUDA enumerates them in the order listed, so
CUDA_DEVICE_ORDER cannot reorder them and there is no shared ordinal space
to check. Gating that case sent it to mem_get_info and spent the ~612 MiB
primary context this path exists to avoid. Apply the gate only where a
numeric id is actually mapped to an ordinal.

* Make the ROCm probe tests mean something on ROCm hardware

Both were reported failing on a real gfx1151 host.

The partition test left the Linux sysfs branch to the real machine, so it
proved nothing on either kind of host. Where sysfs is unreachable the
branch never runs; where it is reachable the inventory came from the
host's own card rather than the partition, the totals matched, and a
figure came back. It now pins the sysfs path too, with the inventory
reporting the partition's own total against a whole-card sysfs reading.

The HIP ordinal test read the visibility variables from the real
environment while its siblings all isolate. A runner exporting one, to
pin a GPU for a measurement running alongside, changed the result:
GPU_DEVICE_ORDINAL=1 failed it before this change and passes after.

No production change: the sysfs branch already declines a partition,
because _rocm_system_wide_vram_by_index compares the sysfs total against
the inventory total and drops anything outside 10 percent.

* Pin the probe behind amd.py's Windows elevation guard

amd-smi elevates a child on Windows without a HIP runtime, and the
UAC/DiskPart prompt that follows cannot be suppressed. This path reaches
amd-smi indirectly through _amd_smi_ids_for_hip_ids, so a later refactor
calling it directly would reintroduce the prompt with nothing failing.

Assert on the spawn rather than the return value: with the platform faked
to Windows and no HIP SDK, the probe must issue no subprocess at all and
still answer from the per-adapter counters. A second test runs the same
predicate against the real OS so the guard is also pinned where it
actually applies.

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

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

* Add a temporary Windows ROCm hardware check for PR #9362

CI is Linux-only, so the Windows-gated branches (the WDDM free cap, the
amd-smi elevation guard) have never executed on hardware. This script runs
the cross-process VRAM differential and counts amd-smi spawns on a real
Windows AMD box. To be removed before merge.

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

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

* Make the amd-smi leg of the Windows check actually test the guard

Counting spawns alone is wrong on a box with a HIP SDK, where amd-smi is
allowed and calling it is correct. Adds a forced-refusal run every call site
must honour, records whether amd-smi is even on PATH, and states which of
the three cases the host did not exercise.

* Call detect_hardware() in the Windows check before reading the gates

IS_ROCM is False at import and only set by detect_hardware(), so the gate
dump reported every ROCm branch as inactive on a ROCm box. Adds the call and
a self-check that fails when a ROCm torch build still reads IS_ROCM False,
so a stale dump cannot pass silently.

* Require an SMI-confirmed GPU count for the order exemption, and clamp free at the total

get_physical_gpu_count falls back to torch on an nvidia-smi -L failure, which
counts VISIBLE devices, so a one-device mask on a multi-GPU host read as
single-GPU and granted the CUDA_DEVICE_ORDER exemption that the mask is
precisely the reason to withhold. The count is cached, so one transient
timeout disabled the gate for the process lifetime. Track whether the count
came from the SMI and require that.

_free_in_torch_scope also lost its upper clamp when it replaced the three
min(total_bytes, free_bytes) call sites; both SMI parsers accept signed
values, so a negative used counter advertised more free than the card has.

* Take an APU's used VRAM from the WDDM counters, and attribute a lone adapter past placeholders

hipMemGetInfo is process-local on Windows WDDM, so an APU took its total AND
its free from a source blind to other processes. The total must still come
from HIP (the GTT pool), but used is available from the GPU Adapter Memory
counters, so resolve the total first and still prefer telemetry for used.

Dedicated Usage saturates at the carve-out on a unified part (measured: 48 GiB
held reports +29.19 dedicated, +19.02 shared), so the value is dedicated plus
shared for the compute adapter. Selection still keys off dedicated alone:
display and placeholder adapters report 0 dedicated while holding gigabytes of
shared.

_match_adapter_used_to_devices now attributes a single visible device when the
one supra-threshold counter is accompanied only by counters at exactly zero.
Zero is what makes it safe, not the cardinality: a sub-floor counter can be the
visible card idle, which keeps [6 GiB, 10 MiB] over [8 GiB] unknown.

* Decline Windows ROCm counter telemetry for free_gb unless the instances are the visible set

_match_adapter_used_to_devices ranks by capacity, and once the noise filter has
dropped counters the ranking can hand a hidden adapter's smaller reading to a
busier visible card. Randomised over the mapping, a card holding 86 GiB of 89
reports 10, overstating free by 76 GiB. The System tab has carried that
best-effort mapping since #7072 and still does; free_gb is a new consumer and
feeds training-method selection, where overstating free is what OOMs.

Require one counter instance per visible device before using it here, the same
cardinality assumption _rocm_windows_aggregate_used_bytes rests on, and fall
through to mem_get_info otherwise.

* Tighten the Windows unified-memory used path

Three separate holes in the counter route for free_gb:

Uncertain is not unified. _rocm_props_total_is_carve_out answers True for an
unclassifiable device as well, because a too-small total hides models. That
justifies the driver total, not adding Shared Usage, which is not part of a
discrete card's props.total_memory. Adds a stricter positive check for the
used figure and leaves the total rule alone.

A failed Shared query is not zero shared usage. Past the carve-out the overflow
lives entirely in Shared, so defaulting to zero reports the measured 48 GiB case
as 30.5 and overstates free by 19 GiB. Nothing here knows where the carve-out
sits, so decline. A query that succeeds but omits the LUID is a real zero and is
still kept.

The cardinality check ran against a different PowerShell sample than attribution
did, since the mapper re-queried the counters. The validated snapshot is now
passed through, which also drops a second ~1.3 s call.

---------

Co-authored-by: imagineer99 <samleejackson0@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-08-23 22:24:24 -07:00

616 lines
23 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
import sys
import types
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, is_hf_authentication_error
# ========== 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()
@needs_torch
def test_clears_mps_on_apple_silicon_without_mlx(self):
"""An Apple Silicon host with a broken MLX stack reports CPU, but diffusion and video
still run on Metal, so the MPS allocator has to be released on that path too."""
with (
patch("utils.hardware.hardware.get_device", return_value = DeviceType.CPU),
patch("utils.hardware.hardware.is_apple_silicon", return_value = True),
patch("torch.mps.empty_cache") as mock_empty,
):
clear_gpu_cache()
mock_empty.assert_called_once()
@needs_torch
def test_does_not_clear_mps_on_a_non_apple_cpu_host(self):
with (
patch("utils.hardware.hardware.get_device", return_value = DeviceType.CPU),
patch("utils.hardware.hardware.is_apple_silicon", return_value = False),
patch("torch.mps.empty_cache") as mock_empty,
):
clear_gpu_cache()
mock_empty.assert_not_called()
# ========== 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)),
# Driver truth from a context-free SMI/sysfs probe: another process
# and torch's cache leave only 9 of 16 GiB free.
patch(
"utils.hardware.hardware._context_free_cuda_memory_info",
return_value = 9 * (1024**3),
),
patch(
"utils.hardware.hardware.trusted_mem_get_info",
side_effect = AssertionError("native telemetry must avoid mem_get_info"),
),
):
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"] - 9.0) < 0.01
assert abs(result["utilization_pct"] - 25.0) < 0.1
@needs_torch
def test_cuda_free_falls_back_to_reserved_when_probe_fails(self):
mock_props = MagicMock()
mock_props.total_memory = 16 * (1024**3)
mock_props.name = "NVIDIA Test GPU"
def _boom():
raise RuntimeError("driver unavailable")
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)),
patch("utils.hardware.hardware._context_free_cuda_memory_info", return_value = None),
patch("utils.hardware.hardware.trusted_mem_get_info", side_effect = _boom),
):
result = get_gpu_memory_info()
# Reserved includes allocated, so the fallback bound is 16 - 6, not
# the old allocated-only 12.
assert abs(result["free_gb"] - 10.0) < 0.01
@needs_torch
def test_rocm_apu_free_uses_the_matching_driver_total(self):
mock_props = MagicMock()
mock_props.total_memory = 8 * (1024**3)
mock_props.name = "AMD Radeon 8060S Graphics"
with (
patch("utils.hardware.hardware.get_device", return_value = DeviceType.CUDA),
patch("utils.hardware.hardware.IS_ROCM", True),
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 = 1 * (1024**3)),
patch("torch.cuda.memory_reserved", return_value = 2 * (1024**3)),
patch("utils.hardware.hardware._rocm_props_total_is_carve_out", return_value = True),
patch(
"utils.hardware.hardware._context_free_cuda_memory_info",
side_effect = AssertionError("an APU needs hipMemGetInfo's GTT total"),
),
patch(
"utils.hardware.hardware.trusted_mem_get_info",
return_value = (98 * (1024**3), 100 * (1024**3)),
),
):
result = get_gpu_memory_info()
assert abs(result["total_gb"] - 100.0) < 0.01
assert abs(result["free_gb"] - 98.0) < 0.01
# --- XPU (Intel GPU) ---
def _xpu_torch(self, mem_get_info):
"""A torch stub exposing only what the XPU branch touches."""
props = types.SimpleNamespace(total_memory = 16 * (1024**3), name = "Intel Arc A770")
xpu = types.SimpleNamespace(
current_device = lambda: 0,
get_device_properties = lambda _o: props,
memory_allocated = lambda _o: 2 * (1024**3),
memory_reserved = lambda _o: 3 * (1024**3),
)
if mem_get_info is not None:
xpu.mem_get_info = mem_get_info
return types.SimpleNamespace(xpu = xpu)
def _xpu_result(self, monkeypatch, mem_get_info):
monkeypatch.setitem(sys.modules, "torch", self._xpu_torch(mem_get_info))
monkeypatch.setattr(_hw_module, "get_device", lambda: DeviceType.XPU)
monkeypatch.setattr(_hw_module, "rocm_windows_free_is_untrusted", lambda: False)
return get_gpu_memory_info()
def test_xpu_free_comes_from_the_driver(self, monkeypatch):
# 12 of 16 GiB free system-wide, against 2 GiB allocated by this process:
# the old total - allocated would have claimed 14.
result = self._xpu_result(monkeypatch, lambda _o: (12 * (1024**3), 16 * (1024**3)))
assert abs(result["free_gb"] - 12.0) < 0.01
assert abs(result["total_gb"] - 16.0) < 0.01
def test_xpu_falls_back_to_reserved_when_the_probe_fails(self, monkeypatch):
def _boom(_o):
raise RuntimeError("level zero unavailable")
result = self._xpu_result(monkeypatch, _boom)
assert abs(result["free_gb"] - 13.0) < 0.01
def test_xpu_falls_back_on_a_torch_without_mem_get_info(self, monkeypatch):
# torch.xpu.mem_get_info is newer than the floor this backend supports,
# so its absence must degrade, not raise.
result = self._xpu_result(monkeypatch, None)
assert abs(result["free_gb"] - 13.0) < 0.01
# --- 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()
def test_hf_authentication_error_follows_wrapped_401(self):
response = type("Response", (), {"status_code": 401})()
auth_error = Exception("request failed")
auth_error.response = response
wrapper = RuntimeError("model validation failed")
wrapper.__cause__ = auth_error
assert is_hf_authentication_error(wrapper) is True
def test_hf_authentication_error_does_not_treat_429_as_invalid(self):
response = type("Response", (), {"status_code": 429})()
rate_error = Exception("too many requests")
rate_error.response = response
assert is_hf_authentication_error(rate_error) is False
# --- 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"