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(),
    }