mirror of https://github.com/razor-ai/soup.git
128 lines
4.0 KiB
Python
128 lines
4.0 KiB
Python
"""v0.44.0 Part A — `soup monitor` GPU live-monitor primitives.
|
|
|
|
Pure-Python helpers for parsing nvidia-smi CSV output and Apple Silicon
|
|
`powermetrics` output. Subprocess invocations use list args (no shell).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import subprocess # noqa: S404 — list-args invocation only
|
|
from dataclasses import dataclass
|
|
from typing import List, Optional, Tuple
|
|
|
|
# Bounds (defence-in-depth)
|
|
_NVIDIA_SMI_TIMEOUT_S = 5
|
|
_MAX_GPUS = 128
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GpuSample:
|
|
"""One row of nvidia-smi output for a single GPU."""
|
|
|
|
index: int
|
|
name: str
|
|
util_gpu_pct: Optional[float]
|
|
util_mem_pct: Optional[float]
|
|
mem_used_mb: Optional[float]
|
|
mem_total_mb: Optional[float]
|
|
temp_c: Optional[float]
|
|
power_w: Optional[float]
|
|
|
|
|
|
def _parse_float_or_none(text: str) -> Optional[float]:
|
|
cleaned = text.strip()
|
|
if not cleaned or cleaned in {"[N/A]", "N/A", "[Not Supported]"}:
|
|
return None
|
|
# nvidia-smi suffixes units in some configs; keep numeric prefix only.
|
|
head = cleaned.split()[0]
|
|
try:
|
|
return float(head)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
def parse_nvidia_smi_csv(text: str) -> List[GpuSample]:
|
|
"""Parse `nvidia-smi --query-gpu=... --format=csv,noheader` output.
|
|
|
|
Expected query order:
|
|
index,name,utilization.gpu,utilization.memory,memory.used,memory.total,temperature.gpu,power.draw
|
|
Lines that don't have exactly 8 columns are skipped silently.
|
|
"""
|
|
if not isinstance(text, str):
|
|
raise TypeError("text must be str")
|
|
samples: List[GpuSample] = []
|
|
for line in text.splitlines():
|
|
if not line.strip():
|
|
continue
|
|
cols = [col.strip() for col in line.split(",")]
|
|
if len(cols) != 8:
|
|
continue
|
|
try:
|
|
index = int(cols[0])
|
|
except (ValueError, TypeError):
|
|
continue
|
|
if index < 0 or index >= _MAX_GPUS:
|
|
continue
|
|
# Reject embedded NUL byte in the GPU name (defence-in-depth).
|
|
name = cols[1]
|
|
if "\x00" in name:
|
|
continue
|
|
samples.append(
|
|
GpuSample(
|
|
index=index,
|
|
name=name,
|
|
util_gpu_pct=_parse_float_or_none(cols[2]),
|
|
util_mem_pct=_parse_float_or_none(cols[3]),
|
|
mem_used_mb=_parse_float_or_none(cols[4]),
|
|
mem_total_mb=_parse_float_or_none(cols[5]),
|
|
temp_c=_parse_float_or_none(cols[6]),
|
|
power_w=_parse_float_or_none(cols[7]),
|
|
)
|
|
)
|
|
return samples
|
|
|
|
|
|
def query_nvidia_smi() -> Tuple[bool, List[GpuSample]]:
|
|
"""Invoke nvidia-smi and return (ok, samples). ok=False when smi is missing
|
|
or returns a non-zero exit. Never raises."""
|
|
smi_path = shutil.which("nvidia-smi")
|
|
if smi_path is None:
|
|
return False, []
|
|
argv = [
|
|
smi_path,
|
|
"--query-gpu=index,name,utilization.gpu,utilization.memory,"
|
|
"memory.used,memory.total,temperature.gpu,power.draw",
|
|
"--format=csv,noheader,nounits",
|
|
]
|
|
try:
|
|
result = subprocess.run( # noqa: S603 — list args, no shell
|
|
argv,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=_NVIDIA_SMI_TIMEOUT_S,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return False, []
|
|
if result.returncode != 0:
|
|
return False, []
|
|
return True, parse_nvidia_smi_csv(result.stdout or "")
|
|
|
|
|
|
def detect_apple_silicon() -> bool:
|
|
"""Best-effort detection of Apple Silicon hardware (Mac M-series).
|
|
|
|
Uses `platform.system()` + `platform.machine()` — the conditional logic
|
|
here is intentionally simple to avoid the prior version's parser-priority
|
|
bug where `if X if Y else Z:` produced a load-bearing-coincidence on
|
|
every platform.
|
|
"""
|
|
try:
|
|
import platform
|
|
except ImportError:
|
|
return False
|
|
if platform.system() != "Darwin":
|
|
return False
|
|
return platform.machine().lower() in {"arm64", "aarch64"}
|