gpu: scope per-engine VRAM to the engine's own card (fix Radeon showing NVIDIA)

The context-free VRAM query used gpu_memory(), which (via NVML/nvidia-smi)
reports every physical NVIDIA card regardless of CUDA_VISIBLE_DEVICES. So the
Vulkan/Radeon engine (CUDA_VISIBLE_DEVICES="") showed the NVIDIA 3090's VRAM in
its status instead of its own AMD card.

Switch the per-engine status poll (api/app.py) and the capability probe
(broker/capabilities.py) to visible_gpu_memory(), which honours
CUDA_VISIBLE_DEVICES (index OR UUID; empty -> no CUDA cards). Add
gpu_query.amd_gpu_memory() (amdgpu sysfs, driver-free) as the fallback so the
Radeon engine reports its real card; capabilities keeps its torch-loaded guard so
the torch-free front still enumerates the whole node. Bumps to 0.1.29.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
parent 9c150b3e
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API # Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here. # metadata and the admin web UI read from here.
__version__ = "0.1.28" __version__ = "0.1.29"
# Configure the CUDA caching allocator BEFORE torch is imported anywhere. # Configure the CUDA caching allocator BEFORE torch is imported anywhere.
# expandable_segments lets the allocator return freed pages to the driver even # expandable_segments lets the allocator return freed pages to the driver even
......
...@@ -250,11 +250,14 @@ def _cached_engine_vram(): ...@@ -250,11 +250,14 @@ def _cached_engine_vram():
return _ENGINE_VRAM_CACHE["vram"] return _ENGINE_VRAM_CACHE["vram"]
vram = None vram = None
try: try:
# Query VRAM context-free (pynvml → nvidia-smi → torch-if-inited) so an # Query VRAM context-free so an engine that never loads a torch model (the
# engine that never loads a torch model (the GGUF/llama.cpp engine) doesn't # GGUF/llama.cpp engine) doesn't pin a ~256 MiB CUDA context just to answer
# pin a ~256 MiB CUDA context just to answer this health poll. # this poll. Scope to THIS engine's cards: visible_gpu_memory() honours
from codai.models.gpu_query import gpu_memory # CUDA_VISIBLE_DEVICES, so the Vulkan/Radeon engine (CUDA_VISIBLE_DEVICES="")
gpus = gpu_memory() # reports nothing here and falls back to its amdgpu card via sysfs — instead
# of leaking the NVIDIA card's VRAM.
from codai.models.gpu_query import visible_gpu_memory, amd_gpu_memory
gpus = visible_gpu_memory() or amd_gpu_memory()
if gpus: if gpus:
used = free = total = 0 used = free = total = 0
devs = [] devs = []
...@@ -267,7 +270,7 @@ def _cached_engine_vram(): ...@@ -267,7 +270,7 @@ def _cached_engine_vram():
devs.append({"index": i, "name": _names[i], devs.append({"index": i, "name": _names[i],
"free": round(f / 1e9, 2), "total": round(t / 1e9, 2)}) "free": round(f / 1e9, 2), "total": round(t / 1e9, 2)})
n = len(devs) n = len(devs)
label = (_names.get(0, "CUDA") if n == 1 else f"{n}× CUDA") label = (devs[0]["name"] if n == 1 else f"{n}× GPU")
vram = {"used": round(used / 1e9, 2), "free": round(free / 1e9, 2), vram = {"used": round(used / 1e9, 2), "free": round(free / 1e9, 2),
"total": round(total / 1e9, 2), "gpu": label, "total": round(total / 1e9, 2), "gpu": label,
"devices": devs, "device_count": n} "devices": devs, "device_count": n}
......
...@@ -49,24 +49,28 @@ def build_hardware_summary() -> Dict[str, Any]: ...@@ -49,24 +49,28 @@ def build_hardware_summary() -> Dict[str, Any]:
total_vram_mb = 0 total_vram_mb = 0
available_vram_mb = 0 available_vram_mb = 0
# Prefer a context-free query (pynvml → nvidia-smi → torch-if-inited). This # In an ENGINE (torch already loaded), report THIS engine's cards via a
# never creates a CUDA primary context, so probing capabilities in an engine # context-free, CUDA_VISIBLE_DEVICES-scoped query — so the probe never pins a
# that hasn't loaded a torch model (the GGUF/llama.cpp engine) doesn't pin a # ~256 MiB CUDA context (the old torch.cuda.mem_get_info path did). The nvidia
# ~256 MiB context. It also works in the torch-free front (NVML/nvidia-smi # engine yields its NVIDIA card; the Vulkan/Radeon engine (CUDA_VISIBLE_DEVICES
# only — it imports torch solely if a context already exists in-process). # ="") yields nothing and falls through to the whole-node path below, as before.
# The torch-free FRONT also falls through to the whole-node path (it must stay
# torch-free and report every physical card).
import sys as _sys
try: try:
from codai.models.gpu_query import gpu_memory if "torch" in _sys.modules:
_gpus = gpu_memory() from codai.models.gpu_query import visible_gpu_memory
if _gpus: _gpus = visible_gpu_memory()
for d in _gpus: if _gpus:
device_total_mb = int(d["total"] / (1024 * 1024)) for d in _gpus:
gpus.append({ device_total_mb = int(d["total"] / (1024 * 1024))
"index": d["index"], gpus.append({
"name": d["name"], "index": d["index"],
"total_vram_mb": device_total_mb, "name": d["name"],
}) "total_vram_mb": device_total_mb,
total_vram_mb += device_total_mb })
available_vram_mb += int(d["free"] / (1024 * 1024)) total_vram_mb += device_total_mb
available_vram_mb += int(d["free"] / (1024 * 1024))
except Exception: except Exception:
pass pass
......
...@@ -149,6 +149,36 @@ def gpu_memory() -> Optional[List[Dict[str, Any]]]: ...@@ -149,6 +149,36 @@ def gpu_memory() -> Optional[List[Dict[str, Any]]]:
return _via_pynvml() or _via_nvidia_smi() or _via_torch_if_inited() return _via_pynvml() or _via_nvidia_smi() or _via_torch_if_inited()
def amd_gpu_memory() -> Optional[List[Dict[str, Any]]]:
"""Per-device VRAM for amdgpu cards via sysfs — driver-free, no context.
Reads ``/sys/class/drm/card*/device/mem_info_vram_{total,used}`` (present only
for amdgpu). Returns ``{index, name, uuid, free, total}`` (bytes) keyed by the
sysfs card index, or ``None`` when no amdgpu card is present. Used by the
Vulkan/Radeon engine (which has ``CUDA_VISIBLE_DEVICES=""``, so the NVML/CUDA
queries report nothing for it)."""
import glob
import re
out: List[Dict[str, Any]] = []
for total_path in sorted(glob.glob('/sys/class/drm/card*/device/mem_info_vram_total')):
used_path = total_path.replace('mem_info_vram_total', 'mem_info_vram_used')
try:
with open(total_path) as f:
total = int(f.read().strip())
except Exception:
continue
try:
with open(used_path) as f:
used = int(f.read().strip())
except Exception:
used = 0
m = re.search(r'card(\d+)', total_path)
idx = int(m.group(1)) if m else len(out)
out.append({"index": idx, "name": "AMD GPU", "uuid": None,
"free": max(0, total - used), "total": total})
return out or None
def _visible_tokens() -> Optional[Set[str]]: def _visible_tokens() -> Optional[Set[str]]:
"""Parse ``CUDA_VISIBLE_DEVICES`` into a set of selector tokens (physical """Parse ``CUDA_VISIBLE_DEVICES`` into a set of selector tokens (physical
index strings and/or ``GPU-<uuid>`` strings). index strings and/or ``GPU-<uuid>`` strings).
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment