test commit

This commit is contained in:
2026-06-08 23:04:38 +02:00
commit 380badbd4a
10 changed files with 1326 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
"""
providers/__init__.py — Provider registry and auto-detection factory.
Usage:
from .providers import auto_detect_provider, BaseGPUProvider, GPUSnapshot
provider = auto_detect_provider(interval_ms=1000)
snap = provider.get_snapshot()
Detection strategy — align with ComfyUI's own device selection:
ComfyUI uses torch.cuda.is_available() for NVIDIA and torch.xpu.is_available()
for Intel. We follow the same signals so the monitor always tracks whichever
device ComfyUI is actually running on.
Detection order:
1. torch.cuda.is_available() + torch.version.roc → AMDProvider (ROCm)
2. torch.cuda.is_available() → NvidiaProvider (NVIDIA)
3. torch.xpu.is_available() → IntelProvider (Intel Arc)
4. ze_loader.dll present → IntelProvider (fallback)
5. pynvml available → NvidiaProvider (fallback)
6. Last resort → IntelProvider (limited)
Fallback tiers 4-5 cover edge cases where torch is not yet imported or the
user is running a non-standard environment without torch.
"""
import logging
from .base import BaseGPUProvider, GPUSnapshot
logger = logging.getLogger("XPUSYSMonitor")
def auto_detect_provider(interval_ms: int = 1000) -> BaseGPUProvider:
"""
Detect the available GPU hardware and return the appropriate provider.
Primary strategy: mirror ComfyUI's own torch-based device selection so the
monitor always tracks the same device that ComfyUI is running inference on.
Fallback strategy: raw driver/library probing for non-standard environments.
"""
# --- Primary: follow torch (mirrors ComfyUI model_management.py) ---
if _detect_nvidia_torch():
# torch.cuda available — determine if NVIDIA or AMD via torch.version.roc
if _is_amd_rocme():
# torch.version.roc is not None → AMD ROCm
logger.info("XPUSYSMonitor: torch.cuda + ROCm — using AMDProvider.")
from .amd import AMDProvider
return AMDProvider(interval_ms=interval_ms)
else:
# torch.version.roc is None → NVIDIA
logger.info("XPUSYSMonitor: torch.cuda (NVIDIA) — using NvidiaProvider.")
from .nvidia import NvidiaProvider
return NvidiaProvider(interval_ms=interval_ms)
if _detect_intel_torch():
logger.info("XPUSYSMonitor: torch.xpu available — using IntelProvider.")
from .intel import IntelProvider
return IntelProvider(interval_ms=interval_ms)
# --- Fallback: raw driver probing (torch not imported yet / non-std env) ---
if _detect_intel_driver():
logger.info(
"XPUSYSMonitor: ze_loader.dll found (torch unavailable) — "
"using IntelProvider."
)
from .intel import IntelProvider
return IntelProvider(interval_ms=interval_ms)
if _detect_nvidia_driver():
logger.info(
"XPUSYSMonitor: NVIDIA driver found (torch unavailable) — "
"using NvidiaProvider."
)
from .nvidia import NvidiaProvider
return NvidiaProvider(interval_ms=interval_ms)
# --- Last resort ---
logger.warning(
"XPUSYSMonitor: no supported GPU detected — "
"falling back to IntelProvider (limited functionality)."
)
from .intel import IntelProvider
return IntelProvider(interval_ms=interval_ms)
# ---------------------------------------------------------------------------
# Primary detectors — torch-based (align with ComfyUI)
# ---------------------------------------------------------------------------
def _detect_nvidia_torch() -> bool:
"""Return True if torch has a working CUDA backend (mirrors ComfyUI)."""
try:
import torch
return torch.cuda.is_available()
except Exception:
return False
def _detect_intel_torch() -> bool:
"""Return True if torch has a working XPU backend (mirrors ComfyUI)."""
try:
import torch
return torch.xpu.is_available()
except Exception:
return False
# ---------------------------------------------------------------------------
# Fallback detectors — raw driver / library probing
# Used when torch is not yet available or in non-standard environments.
# ---------------------------------------------------------------------------
def _detect_intel_driver() -> bool:
"""Return True if Intel Level Zero runtime (ze_loader.dll) is present."""
import ctypes
try:
ctypes.WinDLL("ze_loader.dll")
return True
except OSError:
return False
def _detect_nvidia_driver() -> bool:
"""Return True if pynvml is installed and NVIDIA driver is reachable."""
try:
import pynvml
pynvml.nvmlInit()
count = pynvml.nvmlDeviceGetCount()
pynvml.nvmlShutdown()
return count > 0
except Exception:
return False
def _is_amd_rocme() -> bool:
"""
Check if torch.cuda is backed by AMD ROCm.
Returns True if torch.version.roc is not None (AMD ROCm PyTorch).
Returns False if torch.version.roc is None (NVIDIA or standard CUDA).
"""
try:
import torch
# torch.version.roc: True/str = AMD ROCm, None = NVIDIA/other
return torch.version.roc is not None
except Exception:
return False
__all__ = [
"BaseGPUProvider",
"GPUSnapshot",
"auto_detect_provider",
]
+283
View File
@@ -0,0 +1,283 @@
"""
providers/_utils.py — Shared CPU/RAM utility functions.
Used by all providers to avoid duplicating Windows system-level reads.
"""
from __future__ import annotations
import ctypes
import logging
from ctypes import wintypes
from typing import Any, Dict, Tuple
logger = logging.getLogger("XPUSYSMonitor")
# ---------------------------------------------------------------------------
# Admin detection
# ---------------------------------------------------------------------------
def _is_admin() -> bool:
try:
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
# ---------------------------------------------------------------------------
# CPU info (model name + thread count)
# ---------------------------------------------------------------------------
def _get_cpu_info() -> Tuple[str, int]:
"""Return (model_name, logical_thread_count). Called once at startup."""
model = ""
try:
import winreg
key = winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"HARDWARE\DESCRIPTION\System\CentralProcessor\0",
)
model, _ = winreg.QueryValueEx(key, "ProcessorNameString")
winreg.CloseKey(key)
model = " ".join(model.strip().split()) # collapse extra spaces
except Exception:
try:
import platform
model = platform.processor()
except Exception:
model = "Unknown CPU"
threads = 0
try:
import psutil
threads = psutil.cpu_count(logical=True) or 0
except Exception:
import os
threads = os.cpu_count() or 0
return model, threads
# ---------------------------------------------------------------------------
# CPU / RAM polling
# ---------------------------------------------------------------------------
def _read_cpu_ram_stats(psutil_ok: bool) -> Dict[str, Any]:
"""
Poll CPU utilisation, frequency, and RAM stats.
Returns a dict consumed by every provider's _poll().
"""
out: Dict[str, Any] = {
"cpu_pct": 0.0,
"cpu_freq_ghz": 0.0,
"ram_pct": 0.0,
"ram_total_gb": 0.0,
"ram_used_gb": 0.0,
"ram_free_gb": 0.0,
"commit_used_gb": 0.0,
"commit_limit_gb": 0.0,
}
if psutil_ok:
try:
import psutil
out["cpu_pct"] = psutil.cpu_percent(interval=None)
freq = psutil.cpu_freq()
if freq is not None:
out["cpu_freq_ghz"] = round(freq.current / 1000.0, 2)
mem = psutil.virtual_memory()
out["ram_pct"] = mem.percent
gb = 1024 ** 3
out["ram_total_gb"] = round(mem.total / gb, 1)
out["ram_used_gb"] = round(mem.used / gb, 1)
out["ram_free_gb"] = round(mem.available / gb, 1)
except Exception:
logger.debug("XPUSYSMonitor: psutil read failed.", exc_info=True)
else:
# Fallback: GlobalMemoryStatusEx
try:
class MEMORYSTATUSEX(ctypes.Structure):
_fields_ = [
("dwLength", wintypes.DWORD),
("dwMemoryLoad", wintypes.DWORD),
("ullTotalPhys", ctypes.c_ulonglong),
("ullAvailPhys", ctypes.c_ulonglong),
("ullTotalPageFile", ctypes.c_ulonglong),
("ullAvailPageFile", ctypes.c_ulonglong),
("ullTotalVirtual", ctypes.c_ulonglong),
("ullAvailVirtual", ctypes.c_ulonglong),
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
]
state = MEMORYSTATUSEX()
state.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(state))
gb = 1024 ** 3
out["ram_pct"] = float(state.dwMemoryLoad)
out["ram_total_gb"] = round(state.ullTotalPhys / gb, 1)
out["ram_free_gb"] = round(state.ullAvailPhys / gb, 1)
out["ram_used_gb"] = round((state.ullTotalPhys - state.ullAvailPhys) / gb, 1)
out["commit_used_gb"] = round(
(state.ullTotalPageFile - state.ullAvailPageFile) / gb, 1
)
out["commit_limit_gb"] = round(state.ullTotalPageFile / gb, 1)
except Exception:
pass
return out
# ---------------------------------------------------------------------------
# Windows Commit Charge (for RAM capsule)
# ---------------------------------------------------------------------------
def _read_commit_charge() -> Tuple[float, float]:
"""Return (commit_used_gb, commit_limit_gb) via GlobalMemoryStatusEx."""
try:
class MEMORYSTATUSEX(ctypes.Structure):
_fields_ = [
("dwLength", wintypes.DWORD),
("dwMemoryLoad", wintypes.DWORD),
("ullTotalPhys", ctypes.c_ulonglong),
("ullAvailPhys", ctypes.c_ulonglong),
("ullTotalPageFile", ctypes.c_ulonglong),
("ullAvailPageFile", ctypes.c_ulonglong),
("ullTotalVirtual", ctypes.c_ulonglong),
("ullAvailVirtual", ctypes.c_ulonglong),
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
]
state = MEMORYSTATUSEX()
state.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(state))
gb = 1024 ** 3
used = (state.ullTotalPageFile - state.ullAvailPageFile) / gb
limit = state.ullTotalPageFile / gb
return round(used, 1), round(limit, 1)
except Exception:
return 0.0, 0.0
# ---------------------------------------------------------------------------
# Windows Performance Data Helper (PDH) — GPU engine utilisation
#
# Uses pdh.dll via ctypes to query:
# \GPU Engine(*)\Utilization Percentage
#
# This is the same source Task Manager uses — zero pip dependencies.
# ---------------------------------------------------------------------------
class _PdhQuery:
"""Thin ctypes wrapper around PDH API for GPU engine utilisation."""
def __init__(self):
self._pdh = None
self._query = None
self._counters: list = []
self._ok = False
def init(self) -> bool:
if self._ok:
return True
try:
self._pdh = ctypes.windll.pdh # pdh.dll
# PdhOpenQueryW
self._pdh.PdhOpenQueryW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(ctypes.c_void_p)]
self._pdh.PdhOpenQueryW.restype = wintypes.LONG
# PdhAddEnglishCounterW
self._pdh.PdhAddEnglishCounterW.argtypes = [
ctypes.c_void_p, wintypes.LPCWSTR, wintypes.DWORD,
ctypes.POINTER(ctypes.c_void_p),
]
self._pdh.PdhAddEnglishCounterW.restype = wintypes.LONG
# PdhCollectQueryData
self._pdh.PdhCollectQueryData.argtypes = [ctypes.c_void_p]
self._pdh.PdhCollectQueryData.restype = wintypes.LONG
# PdhGetFormattedCounterValue
self._pdh.PdhGetFormattedCounterValue.argtypes = [
ctypes.c_void_p, wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
ctypes.c_void_p,
]
self._pdh.PdhGetFormattedCounterValue.restype = wintypes.LONG
# PdhRemoveCounter / PdhCloseQuery
self._pdh.PdhRemoveCounter.argtypes = [ctypes.c_void_p]
self._pdh.PdhRemoveCounter.restype = wintypes.LONG
self._pdh.PdhCloseQuery.argtypes = [ctypes.c_void_p]
self._pdh.PdhCloseQuery.restype = wintypes.LONG
# Open a query
self._query = ctypes.c_void_p()
ret = self._pdh.PdhOpenQueryW(None, 0, ctypes.byref(self._query))
if ret != 0: # ERROR_SUCCESS
logger.warning(f"XPUSYSMonitor: PdhOpenQueryW failed — 0x{ret:08x}")
return False
# Enumerate GPU engine counters
# The \GPU Engine(*)\Utilization Percentage counter path covers all GPU engines
# (3D, Compute, Copy) on both NVIDIA and AMD GPUs.
counter_path = "\\GPU Engine(*)\\Utilization Percentage"
counter_buf = ctypes.c_void_p()
ret = self._pdh.PdhAddEnglishCounterW(
self._query, counter_path, 0, ctypes.byref(counter_buf)
)
if ret != 0:
logger.warning(f"XPUSYSMonitor: PDH GPU counter not available — 0x{ret:08x}")
self._pdh.PdhCloseQuery(self._query)
return False
self._counters = [counter_buf]
self._ok = True
logger.info("XPUSYSMonitor: PDH GPU utilisation counters OK.")
return True
except Exception as exc:
logger.debug(f"XPUSYSMonitor: PDH init error — {exc}")
return False
def read_gpu_utilization(self) -> float:
"""Query total GPU utilisation % across all engines."""
if not self._ok:
return 0.0
try:
# Collect
self._pdh.PdhCollectQueryData(self._query)
# Read the wildcard counter — it aggregates across all GPU engines
# We use PDH_FMT_DOUBLE (0x00000200) | PDH_FMT_NOCAP100 (0x00008000)
fmt = 0x00008200 # PDH_FMT_DOUBLE | PDH_FMT_NOCAP100
class PDH_FMT_COUNTERVALUE_DOUBLE(ctypes.Structure):
_fields_ = [
("CStatus", wintypes.DWORD),
("doubleValue", ctypes.c_double),
]
for counter in self._counters:
dwType = wintypes.DWORD(0)
val = PDH_FMT_COUNTERVALUE_DOUBLE()
ret = self._pdh.PdhGetFormattedCounterValue(
counter, fmt, ctypes.byref(dwType), ctypes.byref(val)
)
if ret == 0 and val.CStatus == 0: # PDH_CSTATUS_VALID_DATA
return min(val.doubleValue, 100.0)
return 0.0
except Exception:
return 0.0
def close(self) -> None:
if self._pdh and self._query:
for c in self._counters:
self._pdh.PdhRemoveCounter(c)
self._pdh.PdhCloseQuery(self._query)
self._ok = False
__all__ = [
"_is_admin",
"_get_cpu_info",
"_read_cpu_ram_stats",
"_read_commit_charge",
"_PdhQuery",
]
+241
View File
@@ -0,0 +1,241 @@
"""
providers/amd.py — AMD GPU hardware provider for Windows ROCm.
VRAM free/total : torch.cuda.mem_get_info(device) (driver-level, ROCm 6+)
PyTorch stats : torch.cuda.memory_allocated / memory_reserved
GPU load : Windows PDH API (\\GPU Engine(*)\\Utilization Percentage)
GPU frequency : Unavailable without vendor API -> 0
GPU temperature : Unavailable without vendor API -> -1
Power : Unavailable without vendor API -> -1 / False
No dependency on rocm_smi_lib — works with native Windows ROCm PyTorch.
GPU utilisation via PDH (pdh.dll, zero pip deps). Temperature/freq/power
return unavailable sentinels on Windows where no vendor driver API exists.
"""
import logging
import sys
from typing import Tuple
from .base import BaseGPUProvider, GPUSnapshot
from ._utils import _get_cpu_info, _read_cpu_ram_stats, _PdhQuery, _is_admin
logger = logging.getLogger("XPUSYSMonitor")
# ---------------------------------------------------------------------------
# AMDProvider
# ---------------------------------------------------------------------------
class AMDProvider(BaseGPUProvider):
"""
Hardware provider for AMD GPUs on Windows ROCm.
Uses torch.cuda for VRAM and PyTorch allocator stats.
Uses Windows PDH API for GPU engine utilisation.
Does NOT require rocm_smi_lib.
Temperature, core clock, and power return unavailable sentinels
since no standard Python-accessible driver API exists on Windows.
"""
GPU_VENDOR = "amd"
def __init__(self, interval_ms: int = 1000):
self._torch_ok = False
self._psutil_ok = False
self._device_index = 0
self._is_admin = _is_admin()
self._cpu_model = ""
self._cpu_threads = 0
self._check_torch()
self._check_psutil()
# Windows PDH — GPU engine utilisation (graceful if unavailable)
self._pdh = _PdhQuery()
self._pdh_ok = self._pdh.init()
# BaseGPUProvider.__init__ starts the polling thread — call last
super().__init__(interval_ms=interval_ms)
logger.info(
f"XPUSYSMonitor: AMDProvider started "
f"(torch={self._torch_ok}, pdh={self._pdh_ok})"
)
# ------------------------------------------------------------------
# Initialisation
# ------------------------------------------------------------------
def _check_torch(self) -> None:
"""Check if torch.cuda is available (ROCm PyTorch on Windows)."""
try:
import torch
if torch.cuda.is_available():
self._torch_ok = True
logger.info(
f"XPUSYSMonitor: torch.cuda OK (AMD ROCm), "
f"device count={torch.cuda.device_count()}"
)
else:
logger.warning("XPUSYSMonitor: torch.cuda not available.")
except Exception as exc:
logger.warning(f"XPUSYSMonitor: torch import error — {exc}")
def _check_psutil(self) -> None:
try:
import psutil
psutil.cpu_percent(interval=None)
self._psutil_ok = True
self._cpu_model, self._cpu_threads = _get_cpu_info()
logger.info(
f"XPUSYSMonitor: psutil OK — CPU={self._cpu_model!r}, "
f"threads={self._cpu_threads}"
)
except Exception as exc:
logger.warning(f"XPUSYSMonitor: psutil not available — {exc}")
# ------------------------------------------------------------------
# Hardware reads
# ------------------------------------------------------------------
def _read_device_name(self) -> str:
"""Return the GPU model name via torch.cuda."""
if self._torch_ok:
try:
import torch
return torch.cuda.get_device_name(self._device_index)
except Exception:
pass
return "AMD GPU (ROCm)"
def _read_vram(self) -> Tuple[float, float, float]:
"""
Return (free_gb, total_gb, driver_used_gb) via torch.cuda.mem_get_info.
mem_get_info() returns (free_bytes, total_bytes) from the driver,
which works on ROCm 6+ PyTorch on Windows.
"""
if self._torch_ok:
try:
import torch
free_bytes, total_bytes = torch.cuda.mem_get_info(self._device_index)
gb = 1024 ** 3
free_gb = free_bytes / gb
total_gb = total_bytes / gb
used_gb = max(0.0, total_gb - free_gb)
return free_gb, total_gb, used_gb
except Exception:
# Fallback: total from device properties
try:
import torch
total_gb = torch.cuda.get_device_properties(self._device_index).total_memory / (1024 ** 3)
return 0.0, total_gb, 0.0
except Exception:
pass
return 0.0, 0.0, 0.0
def _read_torch_stats(self) -> Tuple[float, float]:
"""Return (allocated_gb, reserved_gb) from torch.cuda allocator."""
if not self._torch_ok:
return 0.0, 0.0
try:
import torch
idx = self._device_index
gb = 1024 ** 3
return (
torch.cuda.memory_allocated(idx) / gb,
torch.cuda.memory_reserved(idx) / gb,
)
except Exception:
return 0.0, 0.0
def _read_gpu_load(self) -> float:
"""
Return GPU utilisation % via Windows PDH API.
Falls back to 0 if PDH is unavailable (non-Windows, or
counters not installed by the AMD driver).
"""
if self._pdh_ok:
return self._pdh.read_gpu_utilization()
return 0.0
def _read_gpu_freq_mhz(self) -> float:
"""
GPU core frequency in MHz.
Unavailable on Windows without vendor driver API.
"""
return 0.0
def _read_gpu_temp_c(self) -> float:
"""
GPU core temperature in C.
Unavailable on Windows without vendor driver API.
"""
return -1.0
def _read_power(self) -> Tuple[float, float, bool]:
"""
Return (power_w, tgp_w, power_available).
Unavailable on Windows without vendor driver API.
"""
return -1.0, 0.0, False
# ------------------------------------------------------------------
# Poll — called by BaseGPUProvider._loop() every interval
# ------------------------------------------------------------------
def _poll(self) -> None:
"""Collect all hardware metrics and push a fresh GPUSnapshot."""
snap = GPUSnapshot(gpu_vendor=self.GPU_VENDOR)
snap.is_admin = self._is_admin
if not self._torch_ok:
snap.error = "AMD ROCm (torch.cuda) unavailable"
else:
try:
snap.device_name = self._read_device_name()
# VRAM — driver level via torch.cuda.mem_get_info
free_gb, total_gb, driver_used_gb = self._read_vram()
snap.vram_total_gb = total_gb
snap.vram_free_gb = free_gb
snap.vram_driver_used_gb = driver_used_gb
# PyTorch allocator stats
snap.vram_allocated_gb, snap.vram_reserved_gb = self._read_torch_stats()
# GPU metrics
snap.gpu_load_pct = self._read_gpu_load()
snap.gpu_freq_mhz = self._read_gpu_freq_mhz()
snap.gpu_temp_c = self._read_gpu_temp_c()
# Power
snap.power_w, snap.tgp_w, snap.power_available = self._read_power()
except Exception as exc:
logger.debug(f"XPUSYSMonitor: AMDProvider poll error — {exc}")
snap.error = str(exc)
# CPU / RAM — always collected regardless of GPU state
sys_stats = _read_cpu_ram_stats(self._psutil_ok)
snap.cpu_pct = sys_stats.get("cpu_pct", 0.0)
snap.cpu_freq_ghz = sys_stats.get("cpu_freq_ghz", 0.0)
snap.cpu_model = self._cpu_model
snap.cpu_threads = self._cpu_threads
snap.ram_pct = sys_stats.get("ram_pct", 0.0)
snap.ram_total_gb = sys_stats.get("ram_total_gb", 0.0)
snap.ram_used_gb = sys_stats.get("ram_used_gb", 0.0)
snap.ram_free_gb = sys_stats.get("ram_free_gb", 0.0)
snap.commit_used_gb = sys_stats.get("commit_used_gb", 0.0)
snap.commit_limit_gb = sys_stats.get("commit_limit_gb", 0.0)
self._update_snapshot(snap)
__all__ = ["AMDProvider"]
+134
View File
@@ -0,0 +1,134 @@
"""
providers/base.py — Abstract base class and shared data contract.
All GPU provider implementations must subclass BaseGPUProvider and
implement get_snapshot(). The GPUSnapshot dataclass is the single
contract between the hardware layer and every consumer (server,
predictor, frontend).
"""
from __future__ import annotations
import threading
from dataclasses import dataclass, field
from typing import Optional
# ---------------------------------------------------------------------------
# Shared data snapshot — the contract between providers and consumers
# ---------------------------------------------------------------------------
@dataclass
class GPUSnapshot:
# --- VRAM (all in GB) ---
vram_total_gb: float = 0.0 # driver total
vram_free_gb: float = 0.0 # driver free
vram_driver_used_gb: float = 0.0 # total - free (all consumers)
vram_allocated_gb: float = 0.0 # torch allocated (ComfyUI workload)
vram_reserved_gb: float = 0.0 # torch cached / reserved pool
# --- GPU ---
gpu_load_pct: float = 0.0
gpu_freq_mhz: float = 0.0 # current GPU clock in MHz (0 = unavailable)
gpu_temp_c: float = -1.0 # GPU core temp in °C (-1 = unavailable)
# --- Power ---
power_w: float = -1.0 # -1 = unavailable
power_available: bool = False
tgp_w: float = 0.0 # sustained TGP limit in W (0 = unknown)
device_name: str = ""
# --- CPU ---
cpu_pct: float = 0.0
cpu_freq_ghz: float = 0.0
cpu_model: str = ""
cpu_threads: int = 0
# --- RAM ---
ram_pct: float = 0.0
ram_total_gb: float = 0.0
ram_used_gb: float = 0.0
ram_free_gb: float = 0.0
# Windows Commit Charge (≈ Task Manager "已提交")
commit_used_gb: float = 0.0 # CommitTotal = ullTotalPageFile - ullAvailPageFile
commit_limit_gb: float = 0.0 # CommitLimit = ullTotalPageFile
# --- Meta ---
is_admin: bool = False
gpu_vendor: str = "" # "intel" | "nvidia" | "amd" | "unknown"
error: Optional[str] = None
# ---------------------------------------------------------------------------
# Abstract base provider
# ---------------------------------------------------------------------------
class BaseGPUProvider:
"""
Abstract base class for all GPU hardware providers.
Subclasses implement _poll() to fill a GPUSnapshot and call
_update_snapshot(snap) when done. The base class handles
thread-safe snapshot storage and the polling loop lifecycle.
Consumers always call get_snapshot() — they never see the
concrete provider type.
"""
# Subclasses set this to identify their vendor in GPUSnapshot.gpu_vendor
GPU_VENDOR: str = "unknown"
def __init__(self, interval_ms: int = 1000):
self._interval = max(100, interval_ms) / 1000.0
self._snapshot = GPUSnapshot(gpu_vendor=self.GPU_VENDOR)
self._lock = threading.Lock()
self._stop = threading.Event()
self._thread = threading.Thread(
target=self._loop, daemon=True, name=f"XPUSYSMonitor-{self.GPU_VENDOR}"
)
self._thread.start()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def get_snapshot(self) -> GPUSnapshot:
"""Return the latest hardware snapshot. Thread-safe."""
with self._lock:
return self._snapshot
def set_interval(self, ms: int) -> None:
"""Adjust polling interval at runtime."""
self._interval = max(100, ms) / 1000.0
def stop(self) -> None:
"""Stop the background polling thread."""
self._stop.set()
self._thread.join(timeout=5)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _update_snapshot(self, snap: GPUSnapshot) -> None:
"""Atomically replace the stored snapshot. Called from _poll()."""
with self._lock:
self._snapshot = snap
def _loop(self) -> None:
"""Background polling loop — calls _poll() every interval."""
while not self._stop.is_set():
try:
self._poll()
except Exception as exc:
snap = GPUSnapshot(gpu_vendor=self.GPU_VENDOR, error=str(exc))
self._update_snapshot(snap)
self._stop.wait(self._interval)
def _poll(self) -> None:
"""
Override in subclass: collect hardware data and call
_update_snapshot(snap) with a freshly built GPUSnapshot.
"""
raise NotImplementedError
+261
View File
@@ -0,0 +1,261 @@
"""
providers/nvidia.py — NVIDIA GPU hardware provider.
VRAM free/total : pynvml — nvmlDeviceGetMemoryInfo
PyTorch stats : torch.cuda.memory_allocated / memory_reserved
GPU load : pynvml — nvmlDeviceGetUtilizationRates
GPU frequency : pynvml — nvmlDeviceGetClockInfo (GRAPHICS clock)
GPU temperature : pynvml — nvmlDeviceGetTemperature (no admin required)
Power / TGP : pynvml — nvmlDeviceGetPowerUsage / GetEnforcedPowerLimit
(no admin required on most NVIDIA drivers)
Dependency: pip install pynvml
Included in nvidia-ml-py, which ships with most NVIDIA CUDA toolkits.
If pynvml is not installed or no NVIDIA driver is present, this provider
will raise ImportError / NVMLError at init time — the factory catches it.
"""
import logging
import os
from typing import Tuple
from .base import BaseGPUProvider, GPUSnapshot
from ._utils import _get_cpu_info, _read_cpu_ram_stats, _read_commit_charge, _is_admin
logger = logging.getLogger("XPUSYSMonitor")
# ---------------------------------------------------------------------------
# NvidiaProvider
# ---------------------------------------------------------------------------
class NvidiaProvider(BaseGPUProvider):
"""
Hardware provider for NVIDIA GPUs.
Uses pynvml (nvidia-ml-py) for all GPU metrics. Unlike the Intel
provider, temperature and power do NOT require administrator privileges
on NVIDIA drivers.
Only the first GPU (index 0) is monitored — multi-GPU support can be
added later if needed.
"""
GPU_VENDOR = "nvidia"
def __init__(self, interval_ms: int = 1000):
self._nvml_ok = False
self._handle = None # nvml device handle for GPU 0
self._torch_ok = False
self._psutil_ok = False
self._device_index = 0
self._is_admin = _is_admin()
self._cpu_model = ""
self._cpu_threads = 0
self._init_nvml()
self._check_torch()
self._check_psutil()
# BaseGPUProvider.__init__ starts the polling thread — call last
super().__init__(interval_ms=interval_ms)
logger.info(
f"XPUSYSMonitor: NvidiaProvider started "
f"(nvml={self._nvml_ok}, torch={self._torch_ok})"
)
# ------------------------------------------------------------------
# Initialisation
# ------------------------------------------------------------------
def _init_nvml(self) -> None:
"""Initialise pynvml and grab the device handle for GPU 0."""
try:
import pynvml
pynvml.nvmlInit()
count = pynvml.nvmlDeviceGetCount()
if count == 0:
logger.warning("XPUSYSMonitor: pynvml — no NVIDIA devices found.")
return
self._handle = pynvml.nvmlDeviceGetHandleByIndex(self._device_index)
self._nvml_ok = True
name = pynvml.nvmlDeviceGetName(self._handle)
# pynvml may return bytes on older versions
if isinstance(name, bytes):
name = name.decode("utf-8", errors="replace")
logger.info(f"XPUSYSMonitor: pynvml OK — device[0] = {name!r}")
except ImportError:
logger.warning(
"XPUSYSMonitor: pynvml not installed — "
"run `pip install pynvml` to enable NVIDIA support."
)
except Exception as exc:
logger.warning(f"XPUSYSMonitor: pynvml init error — {exc}")
def _check_torch(self) -> None:
try:
import torch
if torch.cuda.is_available():
self._torch_ok = True
logger.info(
f"XPUSYSMonitor: torch.cuda OK, "
f"device count={torch.cuda.device_count()}"
)
else:
logger.warning("XPUSYSMonitor: torch.cuda not available.")
except Exception as exc:
logger.warning(f"XPUSYSMonitor: torch import error — {exc}")
def _check_psutil(self) -> None:
try:
import psutil
psutil.cpu_percent(interval=None) # baseline call
self._psutil_ok = True
self._cpu_model, self._cpu_threads = _get_cpu_info()
logger.info(
f"XPUSYSMonitor: psutil OK — CPU={self._cpu_model!r}, "
f"threads={self._cpu_threads}"
)
except Exception as exc:
logger.warning(f"XPUSYSMonitor: psutil not available — {exc}")
# ------------------------------------------------------------------
# Hardware reads
# ------------------------------------------------------------------
def _read_device_name(self) -> str:
try:
import pynvml
name = pynvml.nvmlDeviceGetName(self._handle)
if isinstance(name, bytes):
name = name.decode("utf-8", errors="replace")
return name
except Exception:
return "NVIDIA GPU"
def _read_vram(self) -> Tuple[float, float, float]:
"""Return (free_gb, total_gb, driver_used_gb)."""
try:
import pynvml
info = pynvml.nvmlDeviceGetMemoryInfo(self._handle)
gb = 1024 ** 3
total = info.total / gb
free = info.free / gb
used = info.used / gb
return free, total, used
except Exception:
return 0.0, 0.0, 0.0
def _read_torch_stats(self) -> Tuple[float, float]:
"""Return (allocated_gb, reserved_gb) from torch.cuda allocator."""
if not self._torch_ok:
return 0.0, 0.0
try:
import torch
idx = self._device_index
gb = 1024 ** 3
return (
torch.cuda.memory_allocated(idx) / gb,
torch.cuda.memory_reserved(idx) / gb,
)
except Exception:
return 0.0, 0.0
def _read_gpu_load(self) -> float:
"""Return GPU utilisation % via nvmlDeviceGetUtilizationRates."""
try:
import pynvml
rates = pynvml.nvmlDeviceGetUtilizationRates(self._handle)
return float(rates.gpu)
except Exception:
return 0.0
def _read_gpu_freq_mhz(self) -> float:
"""Return current GPU graphics clock in MHz."""
try:
import pynvml
# NVML_CLOCK_GRAPHICS = 0
return float(pynvml.nvmlDeviceGetClockInfo(self._handle, 0))
except Exception:
return 0.0
def _read_gpu_temp_c(self) -> float:
"""Return GPU temperature in °C. No admin required on NVIDIA."""
try:
import pynvml
# NVML_TEMPERATURE_GPU = 0
return float(pynvml.nvmlDeviceGetTemperature(self._handle, 0))
except Exception:
return -1.0
def _read_power(self) -> Tuple[float, float, bool]:
"""Return (power_w, tgp_w, power_available)."""
try:
import pynvml
# nvmlDeviceGetPowerUsage returns milliwatts
power_mw = pynvml.nvmlDeviceGetPowerUsage(self._handle)
power_w = power_mw / 1000.0
# Enforced power limit (TGP) in milliwatts
try:
tgp_mw = pynvml.nvmlDeviceGetEnforcedPowerLimit(self._handle)
tgp_w = tgp_mw / 1000.0
except Exception:
tgp_w = 0.0
return power_w, tgp_w, True
except Exception:
return -1.0, 0.0, False
# ------------------------------------------------------------------
# Poll — called by BaseGPUProvider._loop() every interval
# ------------------------------------------------------------------
def _poll(self) -> None:
"""Collect all hardware metrics and push a fresh GPUSnapshot."""
snap = GPUSnapshot(gpu_vendor=self.GPU_VENDOR)
snap.is_admin = self._is_admin
if not self._nvml_ok or self._handle is None:
# nvml unavailable — still collect CPU/RAM
snap.error = "pynvml unavailable"
else:
try:
snap.device_name = self._read_device_name()
# VRAM
free_gb, total_gb, driver_used_gb = self._read_vram()
snap.vram_total_gb = total_gb
snap.vram_free_gb = free_gb
snap.vram_driver_used_gb = driver_used_gb
# torch allocator stats
snap.vram_allocated_gb, snap.vram_reserved_gb = self._read_torch_stats()
# GPU metrics
snap.gpu_load_pct = self._read_gpu_load()
snap.gpu_freq_mhz = self._read_gpu_freq_mhz()
snap.gpu_temp_c = self._read_gpu_temp_c()
# Power
snap.power_w, snap.tgp_w, snap.power_available = self._read_power()
except Exception as exc:
logger.debug(f"XPUSYSMonitor: NvidiaProvider poll error — {exc}")
snap.error = str(exc)
# CPU / RAM — always collected regardless of GPU state
sys = _read_cpu_ram_stats(self._psutil_ok)
snap.cpu_pct = sys.get("cpu_pct", 0.0)
snap.cpu_freq_ghz = sys.get("cpu_freq_ghz", 0.0)
snap.cpu_model = self._cpu_model
snap.cpu_threads = self._cpu_threads
snap.ram_pct = sys.get("ram_pct", 0.0)
snap.ram_total_gb = sys.get("ram_total_gb", 0.0)
snap.ram_used_gb = sys.get("ram_used_gb", 0.0)
snap.ram_free_gb = sys.get("ram_free_gb", 0.0)
snap.commit_used_gb = sys.get("commit_used_gb", 0.0)
snap.commit_limit_gb = sys.get("commit_limit_gb", 0.0)
self._update_snapshot(snap)