Coverage for orchestrant/monitoring/gpu.py: 67%
94 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 11:22 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 11:22 +0000
1"""Vendor-neutral GPU probing (NVML for NVIDIA, gpu_amd for AMD) for the system monitors."""
3from __future__ import annotations
5import importlib
6from contextlib import suppress
7from typing import TYPE_CHECKING, Self
9from loguru import logger
11from orchestrant.monitoring.gpu_amd import AMD_AVAILABLE, AmdGpuProbe
12from orchestrant.monitoring.gpu_types import GPUSnapshot
15if TYPE_CHECKING:
16 from types import TracebackType
19__all__ = ["AMD_AVAILABLE", "PYNVML_AVAILABLE", "GPUProbe", "GPUSnapshot"]
22try:
23 # Optional extra; debug, not warning, below: on AMD hosts the AMD backend takes over.
24 pynvml = importlib.import_module("pynvml")
25except ImportError:
26 # importlib above, not `import`: ty would type pynvml as the module and reject None.
27 pynvml = None
28 logger.debug("nvidia-ml-py not installed; NVIDIA GPU monitoring disabled")
31# Patched by tests; call sites still test `pynvml is not None`, since a bool narrows nothing.
32PYNVML_AVAILABLE = pynvml is not None
35class GPUProbe:
36 """Shared GPU probe resolving one NVML or AMD device; also a context manager."""
38 def __init__(self, gpu_index: int = 0, *, vendor: str | None = None) -> None:
39 """Probe the requested vendor (or whichever answers) at ``gpu_index``.
41 Args:
42 gpu_index: AMD devices are ordered largest dedicated VRAM first.
43 vendor: ``"nvidia"`` or ``"amd"``; ``None`` tries NVIDIA, then AMD.
44 """
45 self.gpu_index = gpu_index
46 self.vendor = "none"
47 self.backend = "none"
48 self._handle: object | None = None
49 self.gpu_name = "N/A"
50 self.memory_type = ""
51 self.memory_bandwidth_mbps = 0
52 self.available = False
53 self._amd: AmdGpuProbe | None = None
55 if vendor in (None, "nvidia") and PYNVML_AVAILABLE and pynvml is not None:
56 self._init_nvidia()
57 if not self.available and vendor in (None, "amd") and AMD_AVAILABLE:
58 self._init_amd()
59 if not self.available:
60 logger.debug("No GPU backend answered for device index {}", gpu_index)
62 def _init_nvidia(self) -> None:
63 """Initialize the NVML device handle for ``gpu_index``."""
64 if pynvml is None: # ty needs the identity test in this frame too
65 return
66 try:
67 pynvml.nvmlInit()
68 self._handle = pynvml.nvmlDeviceGetHandleByIndex(self.gpu_index)
69 name = pynvml.nvmlDeviceGetName(self._handle)
70 if isinstance(name, bytes):
71 name = name.decode("utf-8")
72 self.gpu_name = name
73 self.vendor = "nvidia"
74 self.backend = "nvml"
75 self.available = True
76 logger.success("GPU monitoring initialized: {}", self.gpu_name)
77 except (pynvml.NVMLError, RuntimeError) as exc:
78 logger.debug("NVIDIA GPU monitoring unavailable: {}", exc)
79 self._handle = None
81 def _init_amd(self) -> None:
82 """Initialize the platform AMD backend for ``gpu_index``."""
83 try:
84 probe = AmdGpuProbe(self.gpu_index)
85 except Exception as exc: # pragma: no cover - defensive: driver/DLL quirks
86 logger.debug("AMD GPU monitoring unavailable: {}", exc)
87 return
88 if not probe.available:
89 return
90 self._amd = probe
91 self.vendor = "amd"
92 self.backend = probe.backend
93 self.gpu_name = probe.name
94 self.memory_type = probe.memory_type
95 self.memory_bandwidth_mbps = probe.memory_bandwidth_mbps
96 self._handle = probe.handle
97 self.available = True
99 def read(self) -> GPUSnapshot | None:
100 """Read current GPU metrics, or None if unavailable."""
101 if self._amd is not None:
102 return self._amd.read()
103 if not self.available or self._handle is None or pynvml is None:
104 return None
106 try:
107 util = pynvml.nvmlDeviceGetUtilizationRates(self._handle)
108 mem = pynvml.nvmlDeviceGetMemoryInfo(self._handle)
109 temp = float(
110 pynvml.nvmlDeviceGetTemperature(
111 self._handle, pynvml.NVML_TEMPERATURE_GPU
112 )
113 )
115 power = 0.0
116 with suppress(Exception):
117 power = pynvml.nvmlDeviceGetPowerUsage(self._handle) / 1000.0
119 return GPUSnapshot(
120 utilization=float(util.gpu),
121 memory_used_bytes=int(mem.used),
122 memory_total_bytes=int(mem.total),
123 temperature_celsius=temp,
124 power_watts=power,
125 vendor="nvidia",
126 )
127 except (pynvml.NVMLError, RuntimeError) as exc:
128 logger.debug("Error reading GPU metrics: {}", exc)
129 return None
131 def shutdown(self) -> None:
132 """Release GPU resources; idempotent, and read() returns None afterwards."""
133 if self._amd is not None:
134 self._amd.shutdown()
135 self._amd = None
136 self._handle = None
137 self.available = False
138 return
139 if (
140 self._handle is not None
141 and PYNVML_AVAILABLE
142 and self.available
143 and pynvml is not None
144 ):
145 with suppress(Exception):
146 pynvml.nvmlShutdown()
147 logger.debug("GPU monitoring shutdown complete")
148 self._handle = None
149 self.available = False
151 def __enter__(self) -> Self:
152 """Enter context manager."""
153 return self
155 def __exit__(
156 self,
157 _exc_type: type[BaseException] | None,
158 _exc_val: BaseException | None,
159 _exc_tb: TracebackType | None,
160 ) -> None:
161 """Exit context manager and ensure cleanup."""
162 self.shutdown()