Spaces:
Paused
Paused
File size: 5,533 Bytes
dc03fa5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | """Graceful GPU detection.
Every GPU question in this package routes through here, because the naive form
of the check is a crash rather than a false:
subprocess.run(["nvidia-smi"], capture_output=True).returncode == 0
``subprocess.run`` **raises** ``FileNotFoundError`` when the binary is not on
PATH. On a CPU Colab runtime nvidia-smi is not installed, so that expression does
not evaluate to ``False`` — it terminates the process. That exact line shipped in
the Colab notebook and killed every CPU session before the node could start.
The rules this module follows:
* Absent tooling, broken tooling and a hung probe all mean **"no usable GPU"**,
never an exception. A node that cannot see a GPU is a perfectly good CPU node.
* Detection is cached, because it shells out and is called from both startup and
configuration.
* Nothing here imports torch. The node serves GGUF through llama.cpp and has no
torch dependency; adding one just to ask about CUDA would be a large install
for a question ``nvidia-smi`` already answers.
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
from dataclasses import dataclass
logger = logging.getLogger("thox.gpu")
#: Seconds to wait for nvidia-smi. A wedged driver can hang it indefinitely.
_PROBE_TIMEOUT_SECONDS = 60
#: Layers to offload when a GPU is present and no explicit count is configured.
#: -1 means "all layers" in llama.cpp; it clamps itself to what fits in VRAM.
GPU_LAYERS_ALL = -1
_cached: "GpuInfo | None" = None
@dataclass(frozen=True)
class GpuInfo:
"""What we could determine about GPU availability."""
available: bool
name: str = ""
detail: str = ""
def describe(self) -> str:
"""One line suitable for a startup log or a notebook cell."""
if self.available:
return f"GPU available: {self.name or 'unknown device'}"
return f"No usable GPU ({self.detail or 'nvidia-smi not found'}); running CPU-only"
def probe_gpu(*, use_cache: bool = True) -> GpuInfo:
"""Determine GPU availability without ever raising.
Ordering matters: ``shutil.which`` is checked before ``subprocess.run`` so
the missing-binary case never reaches a call that would raise. The
try/except then covers a binary that exists but cannot execute — a driver or
library mismatch surfaces as ``OSError`` on exec, and a wedged driver as a
timeout.
"""
global _cached
if use_cache and _cached is not None:
return _cached
info: GpuInfo
binary = shutil.which("nvidia-smi")
if binary is None:
info = GpuInfo(available=False, detail="nvidia-smi not on PATH")
else:
try:
completed = subprocess.run(
[binary, "--query-gpu=name", "--format=csv,noheader"],
capture_output=True,
text=True,
timeout=_PROBE_TIMEOUT_SECONDS,
)
except (OSError, subprocess.SubprocessError) as exc:
# Binary present but unusable: driver/library mismatch, or it hung.
info = GpuInfo(available=False, detail=f"nvidia-smi failed: {exc}")
else:
if completed.returncode != 0:
stderr = (completed.stderr or "").strip().splitlines()
info = GpuInfo(
available=False,
detail=f"nvidia-smi exited {completed.returncode}"
+ (f": {stderr[0]}" if stderr else ""),
)
else:
name = (completed.stdout or "").strip().splitlines()
info = GpuInfo(
available=bool(name),
name=name[0].strip() if name else "",
detail="" if name else "nvidia-smi reported no devices",
)
_cached = info
logger.info("%s", info.describe())
return info
def has_gpu() -> bool:
"""``True`` when a usable GPU was detected. Never raises."""
return probe_gpu().available
def resolve_gpu_layers(configured: str | int | None) -> int:
"""Translate the configured layer count into a number for llama.cpp.
``"auto"`` (the default) offloads everything when a GPU is present and
nothing when it is not, so the same configuration is correct on both Colab
runtime types. An explicit number is always honoured — including a non-zero
value on a machine where detection failed, since an operator who sets it
knows something the probe does not.
"""
if configured is None or configured == "":
configured = "auto"
if isinstance(configured, str) and configured.strip().lower() == "auto":
return GPU_LAYERS_ALL if has_gpu() else 0
try:
return int(configured)
except (TypeError, ValueError):
logger.warning("invalid gpu layer count %r; falling back to CPU-only (0)", configured)
return 0
def reset_cache() -> None:
"""Clear the cached probe. For tests, and after installing drivers."""
global _cached
_cached = None
def environment_report() -> dict[str, object]:
"""Everything the ``doctor`` command needs to explain this machine.
Deliberately returns data rather than printing, so it can be asserted on in
tests and rendered differently by callers.
"""
info = probe_gpu()
return {
"gpu_available": info.available,
"gpu_name": info.name,
"gpu_detail": info.detail,
"nvidia_smi": shutil.which("nvidia-smi"),
"cpu_count": os.cpu_count(),
}
|