Files
VoiceStudio/backend/core/device_caps.py
Palash Debnath 1eb59c6f18 feat(diagnostics): say why a GPU host fell back to CPU (#1274) (#1425)
The About page reported 'Compute device: cpu / GPU active: no / VRAM 0.00 GB' on a machine with a working GPU. Every line was true and none was usable — it is also exactly what a machine with no GPU at all reports, so the report could not distinguish a driver that isn't loaded from a container that cannot open the device from a ROCm older than the card.

The probe already knew all of it; torch.cuda.is_available() returning False simply produced no note. Each cause now reads differently: a missing device node names the --device flags, a permissions failure names --group-add and how to find the host's real render/video GIDs (copied numbers are the most common way this ends up on CPU in Docker), a card newer than the shipped ROCm points at rocminfo, an HSA_OVERRIDE_GFX_VERSION that is doing more harm than good is named first because it is both likelier and cheaper to test, and an unreachable NVIDIA driver gets its own advice.

The probe never diagnoses from a measurement it did not complete: when torch.cuda.is_available() itself raises, the exception is reported and no device findings are asserted beside it. Metadata access that raises is contained too — this runs on the path whose whole job is to explain a failure, so it cannot become one.
2026-08-08 15:23:38 +05:30

586 lines
26 KiB
Python

"""Canonical host compute-capability probe — the single source of truth for
"what can this machine actually accelerate on."
Every routing decision (the engine compatibility matrix, ``/setup/preflight``,
``/system/diagnose``, and the synth-time no-silent-fallback gating) reads from
``detect_host_caps()`` so the probe and the model loader can never disagree.
Design contract (load-bearing):
- **Never raises** to a caller. A broken torch / driver crash degrades to a
cached CPU-only ``probe_ok=False`` result; every endpoint stays responsive
(local-first: the app must work with no GPU and even with a broken torch).
- **No network call** — driver/sysctl reads only, no tensor allocation, so it
stays kernel-free on cold start.
- **No new regex** on any driver/device string (CodeQL py/polynomial-redos):
the only string parse is the ``int(driver.split(".")[0])`` shape reused
from the wizard, and arch comparison is plain list membership.
- Distinguishes **ROCm from CUDA** (unlike the gguf ``hardware_probe``):
ROCm-on-HIP presents through ``torch.cuda`` but is reported ``family="rocm"``.
The ``get_best_device()`` loader (``services.model_manager``) delegates its
*family* decision here while keeping its own DirectML branch and the ROCm
``HSA_OVERRIDE_GFX_VERSION`` env side-effect — the probe **reads**, the loader
**writes**. (The gguf ``hardware_probe.detect_capabilities()`` rebase onto this
module is a deliberate follow-up: it has its own torch-mocked test suite and a
VRAM-driven quant table that is unaffected by the family rename, so it is kept
out of this backend-only slice.)
"""
from __future__ import annotations
import functools
import os
import platform as _platform
import re
import sys
from dataclasses import dataclass
from typing import Literal
DeviceFamily = Literal["cuda", "rocm", "mps", "xpu", "cpu"]
# Stable substring stamped onto notes that represent a real kernel-launch risk
# (arch/driver mismatch) — as opposed to advisory notes (multi-GPU, VRAM query
# failed, DirectML present). ``engine_routing`` keys the "accelerated, but…"
# caveat off this marker so advisory notes never downgrade an accelerated badge.
KERNEL_RISK_MARKER = "may fail at kernel launch"
# Substring marking a DirectML-present (Windows GPU) host. The probe reports
# such hosts as ``family="cpu"`` (DirectML is not a torch device family); the
# router reads this marker to explain the neutral badge instead of "no GPU".
DIRECTML_MARKER = "DirectML device present"
# NOTE: the NVIDIA driver-version check (min R555 for the bundled CUDA runtime)
# is intentionally NOT done here — it requires shelling to ``nvidia-smi``, which
# would put a subprocess on the cold-start probe path. That check stays in
# ``wizard._detect_gpu`` (preflight), which already runs it. The probe only
# emits the torch-visible SM-arch caveat (cheap, metadata-only).
# ── ROCm GFX version overrides ───────────────────────────────────────────
# AMD GPUs on ROCm present through ``torch.cuda`` but some consumer parts have
# GFX IDs the installed ROCm build wasn't compiled for. Setting
# ``HSA_OVERRIDE_GFX_VERSION`` runs them on the closest supported architecture.
# Applied (with side effects) by ``model_manager._configure_rocm_if_needed``;
# read here so ``arch_unsupported()`` doesn't flag a GPU we know how to remap.
#
# Values are the TARGET gfx name, not the HSA version string, so callers can
# check whether the installed wheel actually contains that target before
# treating the remap as a solution (``hsa_override_for`` derives the env-var
# form). Remapping onto an architecture the build doesn't ship is not a fix —
# it just moves the failure from "no kernel for gfx1151" to "no kernel for
# gfx1100".
ROCM_GFX_OVERRIDES = {
# RDNA 3.5 (Strix Point / Strix Halo APUs) — override to gfx1100
"gfx1150": "gfx1100", "gfx1151": "gfx1100",
# RDNA 3 (RX 7000 series) — override to gfx1100
"gfx1101": "gfx1100", "gfx1102": "gfx1100", "gfx1103": "gfx1100",
# RDNA 2 (RX 6000 series) — override to gfx1030
"gfx1031": "gfx1030", "gfx1032": "gfx1030", "gfx1034": "gfx1030",
# Vega (RX Vega / Radeon VII) — override to gfx900 / gfx906
"gfx902": "gfx900", "gfx906": "gfx906",
}
def hsa_override_for(target_gfx: str) -> str:
"""``"gfx1100"`` → ``"11.0.0"``, the form HSA_OVERRIDE_GFX_VERSION wants.
The digits are major / minor / step, with the last two characters always
one digit each: gfx1100 → 11.0.0, gfx1030 → 10.3.0, gfx906 → 9.0.6.
"""
digits = _normalize_arch(target_gfx).removeprefix("gfx")
if len(digits) < 3 or not digits.isdigit():
raise ValueError(f"not a gfx architecture name: {target_gfx!r}")
return f"{digits[:-2]}.{digits[-2]}.{digits[-1]}"
def _normalize_arch(tag: str) -> str:
"""``"gfx90a:xnack+"`` → ``"gfx90a"``. Feature flags dropped, lowercased."""
return str(tag).split(":")[0].strip().lower()
def build_arch_list(torch) -> list[str]:
"""This torch build's compiled architecture list, or ``[]`` if unknown.
Prefers the public ``get_arch_list`` and falls back to the private
``_get_arch_list`` (older wheels only expose the latter).
"""
for name in ("get_arch_list", "_get_arch_list"):
fn = getattr(torch.cuda, name, None)
if callable(fn):
try:
return [str(a) for a in (fn() or [])]
except Exception:
return []
return []
_CUDA_ARCH_TAG = re.compile(r"^(sm|compute)_(\d+)([a-z]?)$")
def cuda_build_covers(arch_list, major: int, minor: int) -> bool:
"""Can a torch build compiled for ``arch_list`` run on CC ``major.minor``?
NOT an exact-tag match, because NVIDIA's compatibility rules are not exact
and PyTorch depends on that (#1285):
* **SASS (``sm_XY``) is binary-compatible upward within a major version** —
a cubin built for 8.6 runs on any 8.x device with minor ≥ 6. This is why
the official wheels ship ``sm_80``/``sm_86`` and **no ``sm_89``**: the
8.6 kernels already cover Ada. An exact-match gate therefore declared
every RTX 40-series card (4060…4090, all sm_89) unsupported and
force-routed it to CPU, which is exactly what #1285 reported.
* **PTX (``compute_XY``) JIT-compiles forward** to any newer architecture,
so embedded PTX at or below the device's capability is a valid path.
* **An ``a``/``f`` suffix (``sm_90a``) is architecture-SPECIFIC** — those
cubins deliberately do not forward-run, so they only count on an exact
capability match.
Unparseable entries are skipped rather than guessed at.
"""
device_cc = major * 10 + minor
for entry in arch_list or ():
m = _CUDA_ARCH_TAG.match(str(entry).strip())
if not m:
continue
kind, digits, suffix = m.group(1), m.group(2), m.group(3)
try:
cc = int(digits)
except ValueError:
continue
e_major, e_minor = divmod(cc, 10)
if suffix:
# Arch-specific: exact capability only, whatever the kind.
if cc == device_cc:
return True
continue
if kind == "sm":
if e_major == major and e_minor <= minor:
return True
elif cc <= device_cc:
return True
return False
def gfx_for_hsa_override(value: str) -> str | None:
"""``"11.0.0"`` → ``"gfx1100"``. The inverse of :func:`hsa_override_for`.
``None`` for anything that isn't a three-part numeric version — the user
set something we don't understand, and a guess is worse than leaving it be.
"""
parts = str(value).strip().split(".")
if len(parts) != 3 or not all(p.isdigit() for p in parts):
return None
major, minor, step = parts
if len(minor) != 1 or len(step) != 1:
return None
return f"gfx{int(major)}{minor}{step}"
#: The ROCm kernel driver interface. Its absence, or its presence without
#: permission, are the two commonest reasons a ROCm host silently runs on CPU.
_KFD_DEVICE = "/dev/kfd"
def why_no_gpu(torch) -> tuple[str, ...]:
"""Why ``torch.cuda.is_available()`` said no, as user-facing advisories.
This branch used to produce **nothing** (#1274/#1228). A host with a GPU
the app could not use reported "Compute device: cpu / GPU active: no" and
stopped there — true, useless, and indistinguishable from a machine that
has no GPU at all. Two rounds of back-and-forth per report followed, and
the reporter still ended up guessing (numeric ``--group-add`` values
copied from another host, an ``HSA_OVERRIDE_GFX_VERSION`` that may or may
not have been needed).
The distinctions worth making are cheap, and the probe already knows them:
* the wheel has no GPU support compiled in at all — no amount of
device-passing or env vars will change that;
* it is a ROCm wheel and ``/dev/kfd`` is absent — in a container that is a
missing ``--device`` flag, not a driver problem;
* ``/dev/kfd`` is there but this process cannot open it — a group
membership problem, which is the one that bites hardest in Docker
because the ``render``/``video`` GIDs differ between hosts and the
numbers are usually copied from somewhere else;
* everything is present and the runtime still enumerated nothing — the
GPU is likely newer than this build's ROCm.
Never raises, and returns ``()`` rather than guessing when it cannot tell.
"""
# Metadata access itself can raise: `torch.version` is a module attribute
# on a real torch, but a partially-initialised or shimmed torch-like object
# can expose it as a property that throws. This function's contract is that
# it never raises — it is called from the diagnostics path, where an
# exception would take out the very report meant to explain the problem
# (CodeRabbit, #1425).
try:
version = getattr(torch, "version", None)
hip = getattr(version, "hip", None)
cuda = getattr(version, "cuda", None)
except Exception: # noqa: BLE001 - never raise from a diagnostic
return ()
if not hip and not cuda:
# A build with no GPU support compiled in. Deliberately silent: this
# is also every macOS wheel (MPS is probed separately, below) and
# every CPU Docker image, so a note here would fire on hosts that are
# working exactly as intended. The situations worth explaining are the
# ones where the build clearly meant to use a GPU and could not.
return ()
if hip:
# /dev/kfd only exists on Linux; on any other platform its absence
# says nothing, so don't invent a reason.
if sys.platform.startswith("linux"):
if not os.path.exists(_KFD_DEVICE):
return (
f"ROCm {hip} is installed but {_KFD_DEVICE} is not "
"present — the amdgpu kernel driver isn't loaded, or (in "
"Docker) the container was started without "
"--device /dev/kfd --device /dev/dri",
)
if not os.access(_KFD_DEVICE, os.R_OK | os.W_OK):
return (
f"ROCm {hip} is installed and {_KFD_DEVICE} exists, but "
"this process cannot open it — add the groups that own "
"it (`ls -l /dev/kfd /dev/dri/render*`; in Docker pass "
"--group-add with THAT host's render/video GIDs, which "
"differ between machines)",
)
override = (os.environ.get("HSA_OVERRIDE_GFX_VERSION") or "").strip()
if override:
# Checked BEFORE blaming the ROCm version, because it is the more
# likely cause and the cheaper thing to test. An override remaps
# the GPU onto a different architecture, and pointing a natively
# supported card at one the runtime cannot match to the physical
# agent can leave HSA with no usable agents at all — which is not
# "a kernel failed" but "there is no device", exactly what the
# #1274 reporter saw. Their card (gfx1151) is natively supported
# by the ROCm this image ships, so the override they set is very
# likely what hid it.
return (
f"ROCm {hip} is installed and the device nodes are reachable, "
f"but no GPU was enumerated while HSA_OVERRIDE_GFX_VERSION="
f"{override} is set. Try removing that override first — this "
"ROCm supports most current cards natively, and remapping one "
"it already supports can leave the runtime with no usable "
"device. VoiceStudio sets the override itself when a card "
"genuinely needs it",
)
return (
f"ROCm {hip} is installed and the device nodes are reachable, but "
"no GPU was enumerated — most often a card newer than this "
"build's ROCm. Check `rocminfo` on the host",
)
return (
f"this is a CUDA {cuda} build but no CUDA device was found — the "
"NVIDIA driver is missing or too old, or (in Docker) the container "
"was started without --gpus all",
)
def arch_unsupported(torch) -> tuple[str, tuple[str, ...]] | None:
"""``(device_arch, build_archs)`` when device 0's architecture is absent
from this torch build's compiled arch list — i.e. kernels cannot launch
("no kernel image is available for execution"). ``None`` means supported,
unknown, or not applicable.
**CUDA and ROCm name architectures in different namespaces.** A CUDA build
reports ``sm_89`` / ``compute_89``; a ROCm build reports ``gfx1100``. The
check must therefore branch on the build — comparing a CUDA ``sm_`` tag
against a ROCm ``gfx`` list can never match, which made *every* ROCm host
look unsupported and silently force-routed it to CPU (#1228). Callers must
get the verdict from here rather than re-deriving a tag.
Never raises: any missing/odd metadata degrades to ``None`` (compatible),
matching the pre-existing fail-open contract.
"""
try:
if not torch.cuda.is_available():
return None
arch_list = build_arch_list(torch)
if not arch_list:
return None
if getattr(getattr(torch, "version", None), "hip", None) is not None:
# ── ROCm / HIP: arch_list holds gfx names ─────────────────────
override = os.environ.get("HSA_OVERRIDE_GFX_VERSION")
if override:
# An override remaps the device onto some other gfx target, so
# the native gfx name no longer describes what will run — but
# the remap is only valid if this build SHIPS that target. A
# stale or copy-pasted value (the #1228 reporter had set
# 11.0.0 on a card that no longer needs it) must not buy a free
# pass into kernels that don't exist. Unparseable values are
# left alone: the user asked for something we don't understand,
# and guessing would be worse than trusting them.
target = gfx_for_hsa_override(override)
if target is None or _normalize_arch(target) in {
_normalize_arch(a) for a in arch_list
}:
return None
return f"{target} (HSA_OVERRIDE_GFX_VERSION={override})", tuple(arch_list)
props = torch.cuda.get_device_properties(0)
gfx = _normalize_arch(getattr(props, "gcnArchName", "") or "")
if not gfx:
return None
build = {_normalize_arch(a) for a in arch_list}
if gfx in build:
return None
# _configure_rocm_if_needed() can remap this GPU onto a supported
# target before any kernel launches — but only counts as a fix if
# the build actually SHIPS that target. Remapping gfx1151 onto
# gfx1100 in a wheel that has neither just relocates the failure.
target = ROCM_GFX_OVERRIDES.get(gfx)
if target and _normalize_arch(target) in build:
return None
return gfx, tuple(arch_list)
# ── CUDA: arch_list holds sm_/compute_ tags ──────────────────────
major, minor = torch.cuda.get_device_capability(0)
sm_tag = f"sm_{major}{minor}"
if cuda_build_covers(arch_list, major, minor):
return None
return sm_tag, tuple(arch_list)
except Exception:
# Arch metadata unavailable on this torch build — treat as compatible.
return None
@dataclass(frozen=True)
class HostCaps:
"""Snapshot of the host's accelerator capability. Immutable + cached."""
family: DeviceFamily
"""Best available accelerator family, else ``"cpu"``."""
available_families: tuple[DeviceFamily, ...]
"""Everything usable; **always includes** ``"cpu"`` (invariant)."""
device_name: str = ""
"""Device 0's name, e.g. ``"NVIDIA RTX 4090"`` / ``"Apple Silicon (MPS)"``."""
vram_gb: float = 0.0
"""CUDA/ROCm total VRAM in GB; MPS = system RAM / 2; 0 for cpu/xpu."""
driver: str | None = None
"""Raw ROCm HIP version string (``torch.version.hip``) or ``None``. The
NVIDIA driver-version check is owned by ``wizard._detect_gpu`` (it already
shells to ``nvidia-smi``); the probe stays subprocess-free."""
notes: tuple[str, ...] = ()
"""Author-controlled English advisories (never user input). Empty on a
clean accelerated host."""
probe_ok: bool = True
"""``False`` only when torch could not be imported (degraded CPU-only)."""
def _probe() -> HostCaps:
"""Run the probe once. Enumerates every failure branch from the spec's
degradation contract; never raises."""
try:
import torch
except Exception:
return HostCaps(
family="cpu",
available_families=("cpu",),
notes=("torch not importable; treating host as CPU-only",),
probe_ok=False,
)
notes: list[str] = []
# Probe EVERY accelerator independently into this list (don't short-circuit
# after the first hit) so `available_families` is honest on hybrid hosts
# (e.g. an NVIDIA GPU + an Intel iGPU exposed via IPEX). The preferred
# `family` is chosen by priority at the end.
detected: list[DeviceFamily] = []
device_name = ""
vram_gb = 0.0
driver: str | None = None
# ── CUDA / ROCm (both present through torch.cuda) ────────────────────
cuda_ok = False
cuda_probe_failed = False
try:
cuda_ok = bool(torch.cuda.is_available())
except Exception as exc: # broken CUDA init (forked process / driver crash)
cuda_probe_failed = True
notes.append(f"CUDA init raised: {type(exc).__name__}")
if cuda_ok:
try:
count = int(torch.cuda.device_count())
except Exception:
count = 0
if count == 0:
notes.append("CUDA reports available but device_count==0")
else:
is_rocm = getattr(torch.version, "hip", None) is not None
detected.append("rocm" if is_rocm else "cuda")
if is_rocm:
driver = getattr(torch.version, "hip", None)
if count > 1:
notes.append(f"{count} GPUs detected; routing reflects device 0")
try:
device_name = torch.cuda.get_device_name(0)
except Exception:
device_name = ""
try:
_free, total = torch.cuda.mem_get_info()
vram_gb = float(total) / (1024 ** 3)
except Exception:
notes.append("VRAM query failed")
# Arch mismatch — sm_ tags on CUDA, gfx names on ROCm. Shared with
# model_manager.check_device_compatibility() so probe and loader
# can never disagree (they used to, on every ROCm host — #1228).
mismatch = arch_unsupported(torch)
if mismatch is not None:
device_arch, archs = mismatch
notes.append(
f"{device_name or 'GPU'} ({device_arch}) not in this torch "
f"build's archs ({', '.join(archs)}) — {KERNEL_RISK_MARKER}"
)
elif not cuda_probe_failed:
# A GPU-capable build that found nothing must say why (#1274/#1228).
# Silence here is what made "Compute device: cpu" indistinguishable
# from a machine with no GPU at all.
#
# Only when the probe actually completed, though. If
# `torch.cuda.is_available()` RAISED we know nothing about the host's
# devices, and `why_no_gpu()` would report its findings as fact —
# "no CUDA device was found" beside "CUDA init raised", which reads as
# a diagnosis when it is an unfinished probe. The exception note above
# is the whole truth in that case (CodeRabbit, #1425).
notes.extend(why_no_gpu(torch))
# ── Intel XPU via IPEX ───────────────────────────────────────────────
try:
import intel_extension_for_pytorch # noqa: F401
if hasattr(torch, "xpu") and torch.xpu.is_available():
detected.append("xpu")
if not device_name:
try:
device_name = torch.xpu.get_device_name(0)
except Exception:
# XPU present but unnamed — family classification still holds.
pass
notes.append("XPU VRAM not queried (unreliable across IPEX versions)")
except Exception:
# IPEX absent or XPU probe failed — no XPU on this host.
pass
# ── Apple Silicon MPS ────────────────────────────────────────────────
try:
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
detected.append("mps")
if not device_name:
device_name = "Apple Silicon (MPS)"
if not vram_gb:
try:
import psutil
vram_gb = float(psutil.virtual_memory().total) / (1024 ** 3) / 2
except Exception:
notes.append("psutil unavailable; MPS VRAM unknown")
except Exception:
# MPS probe raised on a non-Apple/old torch — treat as no MPS.
pass
# ── DirectML — Windows GPU, NOT a torch device family ────────────────
try:
import torch_directml
if torch_directml.device_count() > 0:
notes.append(
f"{DIRECTML_MARKER} (Windows GPU); torch-family probe treats "
f"as non-accelerated"
)
except Exception:
# torch_directml absent (the common case) — no DirectML on this host.
pass
# Preferred family by priority; cpu when nothing accelerated was detected.
family: DeviceFamily = "cpu"
for pref in ("cuda", "rocm", "xpu", "mps"):
if pref in detected:
family = pref # type: ignore[assignment]
break
# available_families: every detected accelerator + cpu, deduped, cpu last.
available: tuple[DeviceFamily, ...] = tuple(dict.fromkeys([*detected, "cpu"]))
return HostCaps(
family=family,
available_families=available,
device_name=device_name,
vram_gb=vram_gb,
driver=driver,
notes=tuple(notes),
probe_ok=True,
)
@functools.lru_cache(maxsize=1)
def detect_host_caps() -> HostCaps:
"""Cached per-process host capabilities. Never raises, makes no network
call, kernel-free on cold start. Host compute capability does not change at
runtime in any supported desktop flow (no GPU hot-plug; switching the active
engine does not re-probe — routing is recomputed from these same caps), so
a single probe per process is correct. ``probe_ok=False`` is cached too."""
return _probe()
def refresh() -> HostCaps:
"""Clear the cache and re-probe. **TEST-ONLY** — nothing in the running app
calls this (host caps are immutable per process)."""
detect_host_caps.cache_clear()
return detect_host_caps()
def mlx_supported() -> tuple[bool, str]:
"""``(ok, reason)``. ``ok=True`` **only** on Apple Silicon
(``sys.platform == "darwin"`` and ``platform.machine() == "arm64"``) with
torch MPS available — the shared gate for MLX-Audio / MLX-Whisper (#390).
Gates on exact-string equality (no regex → no CodeQL surface). On any
non-Apple host it returns ``False`` **before** any package import, so a
stray ``mlx_*`` wheel on Linux/Windows never reports available.
"""
if sys.platform != "darwin" or _platform.machine() != "arm64":
if sys.platform == "darwin":
return (False, "MLX requires Apple Silicon; this Mac is Intel")
return (
False,
f"MLX requires Apple Silicon; this host is "
f"{sys.platform}/{_platform.machine()}",
)
try:
import torch
except Exception:
return (False, "torch not importable; cannot confirm MPS")
try:
if torch.backends.mps.is_available():
return (True, "")
except Exception:
# MPS query raised — fall through to the conservative unavailable path.
pass
return (
False,
"Apple Silicon detected but torch MPS unavailable; "
"reinstall torch with MPS support",
)
__all__ = [
"DeviceFamily",
"HostCaps",
"detect_host_caps",
"refresh",
"mlx_supported",
"arch_unsupported",
"gfx_for_hsa_override",
"hsa_override_for",
"build_arch_list",
"ROCM_GFX_OVERRIDES",
"KERNEL_RISK_MARKER",
"DIRECTML_MARKER",
]