"""
Hardware Utilities
Cross-platform hardware inspection: CPU (core counts + model name), RAM,
GPU (vendor, model name, VRAM), and Apple Silicon chip identification.
This module stays a plain hardware-facts probe. It answers "what does this
machine have" (cores, chip/GPU model strings, memory sizes) and deliberately
does NOT answer AI-inference questions like "how many tokens/s will this
push" or "should this repo use Ollama or vLLM" — those derivations belong to
the consumer (e.g. ``best-engine-ai-helper``), which combines these raw facts
with its own domain tables (memory-bandwidth-per-chip, decode-efficiency-per-
backend, ...). Keeping the split this way lets every helper in the suite
(9+ repos) call the same hardware probe without pulling in AI-specific logic.
Probe order for the accelerator vendor mirrors real-world prevalence: Apple
Silicon first (macOS can otherwise surface stray nvidia-smi output from a VM
or eGPU passthrough), then NVIDIA, then AMD, then an Intel-iGPU check on
Linux, falling back to plain CPU.
Usage example
-------------
>>> import os_helper as osh
>>> info = osh.hardware_info()
>>> info["cpu"]["logical_cores"] > 0
True
>>> info["ram_gb"] > 0
True
Author
------
Warith HARCHAOUI, https://linkedin.com/in/warith-harchaoui
"""
from __future__ import annotations
import platform
import re
import subprocess
import sys
from typing import Any
from .logging_utils import debug, info
# ---------------------------------------------------------------------------
# Internal probe helper
# ---------------------------------------------------------------------------
def _probe(cmd: list[str], **kwargs: Any) -> str:
"""
Run a subprocess and return its stdout, swallowing any failure.
Hardware probes (``nvidia-smi``, ``rocm-smi``, ``system_profiler``, ...)
are optional tools that may simply not exist on a given machine. Every
caller in this module treats the result as a plain truthiness check, so
a missing binary or a non-zero exit must never raise — it must just look
like "nothing detected".
Parameters
----------
cmd : list[str]
Command and arguments, as passed to ``subprocess.run``.
**kwargs
Forwarded to ``subprocess.run`` (e.g. ``timeout``).
Returns
-------
str
Decoded stdout, or ``''`` on any error.
"""
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True, **kwargs)
return result.stdout
except (FileNotFoundError, subprocess.CalledProcessError, OSError) as exc:
# FileNotFoundError: binary not on PATH.
# CalledProcessError: binary exists but returned non-zero.
# OSError: platform refused to spawn the process at all.
debug(f"Hardware probe unavailable/failed: {cmd[0]} ({exc})")
return ""
def _parse_memory_gb(value_str: str) -> float | None:
"""
Parse a memory string like ``'96 GB'`` or ``'32768 MiB'`` into GB.
Parameters
----------
value_str : str
Raw string from ``system_profiler`` or a similar tool.
Returns
-------
float or None
Memory in GB, or None if the string could not be parsed.
"""
s = value_str.strip()
try:
if "GB" in s.upper():
return float(s.upper().replace("GB", "").strip())
if "GIB" in s.upper():
return float(s.upper().replace("GIB", "").strip())
if "MB" in s.upper():
return float(s.upper().replace("MB", "").strip()) / 1024.0
if "MIB" in s.upper():
return float(s.upper().replace("MIB", "").strip()) / 1024.0
# A bare integer is assumed to be bytes (e.g. some wmic output).
return float(s) / (1024**3)
except ValueError:
return None
# ---------------------------------------------------------------------------
# Platform
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# CPU
# ---------------------------------------------------------------------------
[docs]
def cpu_count_logical() -> int:
"""
Return the number of logical CPUs (including hyperthreads/SMT).
Returns
-------
int
``os.cpu_count()``, or 1 if the platform refuses to report it.
Examples
--------
>>> cpu_count_logical() >= 1
True
"""
import os
# os.cpu_count() can return None on exotic sandboxes; never surface that.
return os.cpu_count() or 1
[docs]
def cpu_count_physical() -> int | None:
"""
Return the number of physical CPU cores (excluding hyperthreads/SMT).
Delegates to ``psutil``, which already normalizes this across platforms
(it reads ``/proc/cpuinfo`` core ids on Linux, ``sysctl`` on macOS, and
the WMI processor table on Windows).
Returns
-------
int or None
Physical core count, or None when psutil cannot determine it (rare,
e.g. some container sandboxes).
"""
import psutil # mandatory runtime dependency; always importable
return psutil.cpu_count(logical=False)
[docs]
def cpu_percent() -> float:
"""
Return instantaneous CPU utilization as a percentage (0-100).
Delegates to ``psutil.cpu_percent``, sampled over a short blocking window
so the figure reflects genuinely current load rather than the misleading
``0.0`` psutil returns on a first call with no interval. A live figure,
not a hardware fact — call this only where a short (0.1s) blocking
sample is acceptable (diagnostics, one-shot CLI reports); do not call it
in a hot loop.
Returns
-------
float
CPU utilization percent, 0-100.
Examples
--------
>>> 0.0 <= cpu_percent() <= 100.0
True
"""
import psutil # mandatory runtime dependency; always importable
return float(psutil.cpu_percent(interval=0.1))
[docs]
def cpu_model() -> str | None:
"""
Return a human-readable CPU model string for the current machine.
Parameters
----------
None
Returns
-------
str or None
e.g. ``'Apple M2 Max'``, ``'AMD Ryzen 9 7950X'``,
``'Intel(R) Xeon(R) Platinum 8358 CPU @ 2.60GHz'``. None if no probe
on this platform yielded a usable string.
Examples
--------
>>> cpu_model() is None or isinstance(cpu_model(), str)
True
"""
plat = platform_name()
if plat == "darwin":
# sysctl's machdep.cpu.brand_string is the canonical CPU name on both
# Intel and Apple Silicon Macs.
out = _probe(["sysctl", "-n", "machdep.cpu.brand_string"]).strip()
return out or None
if plat == "linux":
# /proc/cpuinfo repeats "model name" once per logical core; the
# first occurrence is enough to name the chip.
try:
with open("/proc/cpuinfo", encoding="utf-8") as fh:
for line in fh:
if line.lower().startswith("model name"):
return line.split(":", 1)[1].strip() or None
except OSError:
debug("Could not read /proc/cpuinfo for CPU model detection.")
return None
# Windows: platform.processor() reads the registry-backed
# PROCESSOR_IDENTIFIER, which is a reasonable (if verbose) model string
# without needing to shell out to wmic (deprecated on modern Windows).
out = platform.processor().strip()
return out or None
# ---------------------------------------------------------------------------
# RAM
# ---------------------------------------------------------------------------
[docs]
def ram_gb() -> float:
"""
Return total system RAM in GB.
Returns
-------
float
Total RAM in GB. Always a positive float — ``psutil`` is a mandatory
runtime dependency, so this never raises ``ImportError``.
Examples
--------
>>> ram_gb() > 0
True
"""
import psutil # mandatory runtime dependency; always importable
return float(psutil.virtual_memory().total) / (1024**3)
[docs]
def available_ram_gb() -> float:
"""
Return system RAM currently free (not committed to any process), in GB.
Unlike :func:`ram_gb` (a static hardware fact: total installed memory),
this is a live figure that shrinks as other processes — including an
already-running local inference server — consume memory, and grows again
once they release it. Consumers that only care about total capacity
should keep using :func:`ram_gb`; this is for callers that need to know
what is realistically usable right now.
Returns
-------
float
Free RAM in GB, per ``psutil.virtual_memory().available`` (accounts
for reclaimable OS caches/buffers, so it is more representative of
"usable now" than the raw "free" figure some tools report).
Examples
--------
>>> 0 <= available_ram_gb() <= ram_gb()
True
"""
import psutil # mandatory runtime dependency; always importable
return float(psutil.virtual_memory().available) / (1024**3)
[docs]
def disk_usage_gb(path: str | None = None) -> dict[str, float]:
"""
Return free space and percent used for the filesystem holding ``path``.
Parameters
----------
path : str or None
Any path on the filesystem to report on. Defaults to the user's home
directory, since that is where most local caches (model weights,
package caches, build artifacts) actually live and eventually fill up.
Returns
-------
dict
``{"free_gb": float, "used_gb": float, "total_gb": float,
"percent_used": float}``.
Examples
--------
>>> usage = disk_usage_gb()
>>> 0.0 <= usage["percent_used"] <= 100.0
True
"""
import shutil
from pathlib import Path
target = path or str(Path.home())
total, used, free = shutil.disk_usage(target)
percent_used = round(used / total * 100, 1) if total else 0.0
return {
"free_gb": round(free / 1e9, 1),
"used_gb": round(used / 1e9, 1),
"total_gb": round(total / 1e9, 1),
"percent_used": percent_used,
}
# ---------------------------------------------------------------------------
# Accelerator vendor
# ---------------------------------------------------------------------------
[docs]
def gpu_vendor() -> str:
"""
Identify the primary compute accelerator vendor on this machine.
Checks, in order: Apple Silicon, NVIDIA (``nvidia-smi``), AMD
(``rocm-smi``), Intel integrated graphics (Linux ``lspci`` only), then
falls back to ``'cpu'``.
Returns
-------
str
One of: ``'apple'``, ``'nvidia'``, ``'amd'``, ``'intel'``, ``'cpu'``.
Examples
--------
>>> gpu_vendor() in ('apple', 'nvidia', 'amd', 'intel', 'cpu')
True
"""
plat = platform_name()
if plat == "darwin":
# system_profiler reliably reports the Apple Silicon chip name; older
# Intel Macs report neither "Apple M" nor "Apple A" and fall through.
out = _probe(["system_profiler", "SPHardwareDataType"])
if "Apple M" in out or "Apple A" in out:
info("Detected accelerator vendor: apple")
return "apple"
# NVIDIA works the same way on Linux and Windows.
if _probe(["nvidia-smi", "-L"]):
info("Detected accelerator vendor: nvidia")
return "nvidia"
# AMD's ROCm stack (Linux only, in practice).
if _probe(["rocm-smi", "--showid"]):
info("Detected accelerator vendor: amd")
return "amd"
# Intel Arc / integrated graphics — Linux-only probe via lspci.
if plat == "linux":
lspci = _probe(["lspci"])
if "Intel" in lspci and "VGA" in lspci:
info("Detected accelerator vendor: intel")
return "intel"
info("No accelerator detected; falling back to cpu")
return "cpu"
# ---------------------------------------------------------------------------
# Apple Silicon
# ---------------------------------------------------------------------------
[docs]
def apple_chip_name() -> str | None:
"""
Return the Apple Silicon chip name (e.g. ``'Apple M2 Max'``).
Returns
-------
str or None
The chip name, or None on non-macOS platforms or when the
``system_profiler`` "Chip:" line is absent (older Intel Macs).
"""
if platform_name() != "darwin":
return None
out = _probe(["system_profiler", "SPHardwareDataType"])
for line in out.splitlines():
if "Chip:" in line:
return line.partition("Chip:")[2].strip() or None
return None
[docs]
def apple_unified_memory_gb() -> float | None:
"""
Return the Apple Silicon unified-memory pool size in GB.
Returns
-------
float or None
Memory in GB, or None off macOS or when the value could not be
parsed from ``system_profiler``.
"""
if platform_name() != "darwin":
return None
out = _probe(["system_profiler", "SPHardwareDataType"])
for line in out.splitlines():
# The relevant line looks like: " Memory: 96 GB".
if "Memory:" in line and ("GB" in line or "MB" in line):
_, _, value = line.partition("Memory:")
parsed = _parse_memory_gb(value)
if parsed is not None:
return parsed
return None
# ---------------------------------------------------------------------------
# Discrete GPUs (NVIDIA / AMD)
# ---------------------------------------------------------------------------
[docs]
def nvidia_gpus() -> list[dict[str, Any]]:
"""
List every NVIDIA GPU visible to ``nvidia-smi`` with its name and VRAM.
Returns
-------
list of dict
One entry per GPU: ``{"vendor": "nvidia", "name": str, "vram_gb":
float}``. Empty list when ``nvidia-smi`` is unavailable or reports
nothing.
Examples
--------
>>> all('vram_gb' in g for g in nvidia_gpus())
True
"""
# CSV output, one line per GPU: "NVIDIA GeForce RTX 4090, 24564".
out = _probe(["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"])
gpus: list[dict[str, Any]] = []
for line in out.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) != 2:
continue
name, mib_str = parts
try:
vram_gb = float(mib_str) / 1024.0
except ValueError:
# A non-numeric memory field (rare driver hiccup) — skip this row
# rather than fabricate a VRAM figure.
continue
gpus.append({"vendor": "nvidia", "name": name, "vram_gb": round(vram_gb, 1)})
return gpus
[docs]
def amd_gpus() -> list[dict[str, Any]]:
"""
List every AMD GPU visible to ``rocm-smi`` with its name and VRAM.
ROCm's text output format has drifted across releases, so this is a
best-effort parse: it pairs up ``--showproductname`` names with
``--showmeminfo vram`` totals by GPU index. When the two calls disagree
on GPU count (or either returns nothing usable), it degrades to VRAM-only
entries rather than guessing a mismatched name.
Returns
-------
list of dict
One entry per GPU: ``{"vendor": "amd", "name": str | None,
"vram_gb": float}``. Empty list when ``rocm-smi`` is unavailable.
"""
# "GPU[0] : Card series: Radeon RX 7900 XTX" (indentation/whitespace
# varies by ROCm version, hence the loose "Card series" substring match).
name_out = _probe(["rocm-smi", "--showproductname"])
names: dict[int, str] = {}
for line in name_out.splitlines():
match = re.match(r"GPU\[(\d+)\]", line)
if match and "Card series" in line:
idx = int(match.group(1))
_, _, val = line.rpartition(":")
name = val.strip()
if name:
names[idx] = name
# "GPU[0] : VRAM Total Memory (B): 17163091968" — the byte count is the
# LAST colon-separated field, so rpartition (not partition) is required.
mem_out = _probe(["rocm-smi", "--showmeminfo", "vram"])
gpus: list[dict[str, Any]] = []
for line in mem_out.splitlines():
match = re.match(r"GPU\[(\d+)\]", line)
if not (match and "VRAM Total Memory" in line and "B)" in line):
continue
idx = int(match.group(1))
_, _, val = line.rpartition(":")
try:
vram_gb = float(val.strip()) / (1024**3)
except ValueError:
continue
gpus.append({"vendor": "amd", "name": names.get(idx), "vram_gb": round(vram_gb, 1)})
return gpus
[docs]
def gpus() -> list[dict[str, Any]]:
"""
List every discrete GPU on this machine, dispatched by detected vendor.
Apple Silicon is intentionally excluded: it has no discrete VRAM pool to
enumerate (see :func:`apple_unified_memory_gb` instead).
Returns
-------
list of dict
See :func:`nvidia_gpus` / :func:`amd_gpus` for the entry shape.
Empty list on Apple Silicon, Intel iGPU, or CPU-only machines.
"""
vendor = gpu_vendor()
if vendor == "nvidia":
return nvidia_gpus()
if vendor == "amd":
return amd_gpus()
return []
def _apple_gpu_utilization_percent() -> float | None:
"""
Apple Silicon GPU utilization WITHOUT ``powermetrics`` (no sudo needed).
``powermetrics`` is the well-known source for this figure but requires
elevated privileges on every macOS version to date, which is a non-starter
for a library call. IOKit's AGX accelerator driver already publishes the
same figure unprivileged, in the ``PerformanceStatistics`` dictionary
``ioreg`` prints for the GPU node (``"Device Utilization %"``) — the same
data source behind Activity Monitor's GPU History and third-party tools
such as asitop/Stats.app, just read directly instead of through them.
``IOAccelerator`` is the generic superclass every Apple Silicon generation's
concrete driver (``AGXAcceleratorG13X``, ``...G14X``, ``...G15X``, ...)
registers under, so this one query is forward-compatible without a
per-chip-generation class name table.
Returns
-------
float or None
Utilization percent, or None when ``ioreg`` is unavailable (non-macOS)
or the field is absent (unexpected driver output).
"""
out = _probe(["ioreg", "-r", "-d", "1", "-c", "IOAccelerator"], timeout=2.0)
match = re.search(r'"Device Utilization %"\s*=\s*(\d+)', out)
return float(match.group(1)) if match else None
[docs]
def gpu_utilization_percent(vendor: str | None = None) -> float | None:
"""
Return the current (live) GPU compute utilization, 0-100.
Apple Silicon (via IOKit, see :func:`_apple_gpu_utilization_percent` — no
``powermetrics``/sudo needed), NVIDIA (``nvidia-smi``), and AMD
(``rocm-smi``). A multi-GPU box reports the first card's utilization
only, matching :func:`gpus`'s single-card assumption elsewhere in this
module.
Parameters
----------
vendor : str or None
One of :func:`gpu_vendor`'s return values. Defaults to calling
:func:`gpu_vendor` when omitted.
Returns
-------
float or None
Utilization percent, or None when unavailable (wrong vendor, tool
not on PATH, or unparseable output) — never a fabricated number.
Examples
--------
>>> util = gpu_utilization_percent("cpu")
>>> util is None
True
"""
vendor = vendor if vendor is not None else gpu_vendor()
if vendor == "apple":
return _apple_gpu_utilization_percent()
if vendor == "nvidia":
out = _probe(
["nvidia-smi", "--query-gpu=utilization.gpu", "--format=csv,noheader,nounits"],
timeout=2.0,
)
first_line = out.strip().splitlines()[0] if out.strip() else ""
try:
return float(first_line.strip())
except ValueError:
return None
if vendor == "amd":
# "GPU[0] : GPU use (%): 12" — indentation/spacing varies by ROCm
# version, hence the loose substring match (same approach as amd_gpus).
out = _probe(["rocm-smi", "--showuse"], timeout=2.0)
for line in out.splitlines():
if "GPU use" in line:
_, _, val = line.rpartition(":")
try:
return float(val.strip())
except ValueError:
continue
return None
return None
# ---------------------------------------------------------------------------
# Aggregate
# ---------------------------------------------------------------------------
[docs]
def hardware_info() -> dict[str, Any]:
"""
Return a single snapshot of every hardware fact this module can detect.
Convenience aggregate over every other function in this module, useful
for a one-call "what is this machine" report (CLI ``detect`` commands,
diagnostics, bug reports). Mixes static facts (core counts, RAM capacity)
with live figures (``cpu_percent``, ``available_ram_gb``, ``disk``,
``gpu_utilization_percent``) sampled at call time — fine for a one-shot
report, not for polling in a hot loop (see the live functions' own
docstrings).
Returns
-------
dict
``{"platform": str, "cpu": {"physical_cores": int | None,
"logical_cores": int, "model": str | None, "percent": float},
"ram_gb": float, "available_ram_gb": float, "disk":
{"free_gb": float, "used_gb": float, "total_gb": float,
"percent_used": float}, "gpu_vendor": str, "gpus": list[dict],
"gpu_utilization_percent": float | None, "apple_chip": str | None,
"apple_unified_gb": float | None}``.
Examples
--------
>>> info = hardware_info()
>>> set(info) == {
... "platform", "cpu", "ram_gb", "available_ram_gb", "disk",
... "gpu_vendor", "gpus", "gpu_utilization_percent",
... "apple_chip", "apple_unified_gb",
... }
True
"""
vendor = gpu_vendor()
return {
"platform": platform_name(),
"cpu": {
"physical_cores": cpu_count_physical(),
"logical_cores": cpu_count_logical(),
"model": cpu_model(),
"percent": cpu_percent(),
},
"ram_gb": round(ram_gb(), 1),
"available_ram_gb": round(available_ram_gb(), 1),
"disk": disk_usage_gb(),
"gpu_vendor": vendor,
"gpus": gpus(),
"gpu_utilization_percent": gpu_utilization_percent(vendor),
# Apple's memory pool is unified (shared with the CPU), so it is
# reported separately rather than folded into the "gpus" VRAM list.
"apple_chip": apple_chip_name() if vendor == "apple" else None,
"apple_unified_gb": apple_unified_memory_gb() if vendor == "apple" else None,
}