* feat(routing): canonical host device probe + routing resolver (#21 PR 1/5) Foundational, backend-only slice of the GPU compatibility matrix (#21). No API or UI change — wiring lands in PRs 3–5. - `core/device_caps.py`: single source of truth for host accelerator capability. `detect_host_caps()` distinguishes ROCm from CUDA (unlike the gguf hardware_probe), never raises, makes no network call, stays kernel-free on cold start, and caches per process. Enumerates the full degradation contract (torch-unimportable→probe_ok=False, CUDA-init raises, device_count==0, multi-GPU, mem_get_info failure, arch mismatch, MPS, XPU, DirectML). Plus shared `mlx_supported()` gate (#390 groundwork) — exact-string platform check, no regex. - `services/engine_routing.py`: pure `resolve_routing(gpu_compat, caps)` → `{effective_device, routing_status, routing_reason}`; deterministic and byte-identical across OSes. Rules for accelerated / cpu_fallback (the no-silent-fallback signal) / cpu_only / unavailable, incl. the ROCm-not-in-set, DirectML-neutral, and XPU edges. - `get_best_device()` delegates its family decision to the probe so the loader and probe can never disagree; keeps the ROCm HSA env override and DirectML device-string return (probe reads, loader writes). String contract unchanged. - 39 unit tests (probe / resolver / mlx gate / reason-scrub contract); no new regex (CodeQL-clean), English-only (CJK guard green). The gguf hardware_probe rebase is a deliberate follow-up: it has its own torch-mocked suite and a VRAM-driven quant table unaffected by the family rename, so it stays out of this zero-risk slice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(routing): address review — full available_families + empty-except comments CodeRabbit / CodeQL review on PR 1: - `available_families` no longer drops secondary accelerators on hybrid hosts (e.g. NVIDIA + Intel-iGPU-via-IPEX). The probe now detects every accelerator independently and picks `family` by priority at the end, instead of short-circuiting after the first hit. Routing is unaffected (it keys off `family`), but the field is now honest. + hybrid-host test. - Annotated every `except: pass` in device_caps with an explanatory comment (CodeQL py/empty-except). - Removed the unused `_MIN_NVIDIA_DRIVER` constant — the driver-version check stays in wizard preflight (no subprocess on the probe path); documented why. - `get_best_device()` now checks MPS before DirectML, mirroring the probe's family-priority order so loader and probe never disagree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
79 lines
2.5 KiB
Python
79 lines
2.5 KiB
Python
"""Unit tests for core.device_caps.mlx_supported() — the shared MLX platform
|
|
gate (#390). Gates strictly on darwin+arm64+torch-MPS; returns the exact pinned
|
|
(ok, reason) tuples for every host so a stray mlx wheel on Linux/Windows/mac-Intel
|
|
never reports available.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import types
|
|
from unittest.mock import patch
|
|
|
|
from core import device_caps
|
|
|
|
|
|
def _mps_torch(available):
|
|
return types.SimpleNamespace(
|
|
backends=types.SimpleNamespace(
|
|
mps=types.SimpleNamespace(is_available=lambda: available)
|
|
)
|
|
)
|
|
|
|
|
|
def test_apple_silicon_with_mps_supported():
|
|
with patch.object(sys, "platform", "darwin"), \
|
|
patch("platform.machine", return_value="arm64"), \
|
|
patch.dict("sys.modules", {"torch": _mps_torch(True)}):
|
|
ok, reason = device_caps.mlx_supported()
|
|
assert ok is True
|
|
assert reason == ""
|
|
|
|
|
|
def test_apple_silicon_without_mps():
|
|
with patch.object(sys, "platform", "darwin"), \
|
|
patch("platform.machine", return_value="arm64"), \
|
|
patch.dict("sys.modules", {"torch": _mps_torch(False)}):
|
|
ok, reason = device_caps.mlx_supported()
|
|
assert ok is False
|
|
assert "torch MPS unavailable" in reason
|
|
|
|
|
|
def test_mac_intel_rejected():
|
|
with patch.object(sys, "platform", "darwin"), \
|
|
patch("platform.machine", return_value="x86_64"):
|
|
ok, reason = device_caps.mlx_supported()
|
|
assert ok is False
|
|
assert reason == "MLX requires Apple Silicon; this Mac is Intel"
|
|
|
|
|
|
def test_linux_rejected_before_import():
|
|
with patch.object(sys, "platform", "linux"), \
|
|
patch("platform.machine", return_value="x86_64"):
|
|
ok, reason = device_caps.mlx_supported()
|
|
assert ok is False
|
|
assert "this host is linux/x86_64" in reason
|
|
|
|
|
|
def test_windows_rejected():
|
|
with patch.object(sys, "platform", "win32"), \
|
|
patch("platform.machine", return_value="AMD64"):
|
|
ok, reason = device_caps.mlx_supported()
|
|
assert ok is False
|
|
assert "MLX requires Apple Silicon" in reason
|
|
|
|
|
|
def test_apple_silicon_torch_unimportable():
|
|
real_import = __import__
|
|
|
|
def _blocked(name, *a, **k):
|
|
if name == "torch":
|
|
raise ImportError("no torch")
|
|
return real_import(name, *a, **k)
|
|
|
|
with patch.object(sys, "platform", "darwin"), \
|
|
patch("platform.machine", return_value="arm64"), \
|
|
patch("builtins.__import__", _blocked):
|
|
ok, reason = device_caps.mlx_supported()
|
|
assert ok is False
|
|
assert "torch not importable" in reason
|