diff --git a/studio/backend/main.py b/studio/backend/main.py index a1ff4d60da..0ffc4489a1 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -1149,11 +1149,15 @@ def _get_cached_system_gpu_info(logger) -> dict[str, Any]: util = util_devices.get(idx, {}) total_vram = util.get("vram_total_gb") or dev.get("memory_total_gb") or 0 - used_vram = util.get("vram_used_gb") or 0 + # Keep None (usage unknown, e.g. Windows ROCm perf counter) so the UI + # shows unknown, not a fabricated 0 used / full free. + used_vram = util.get("vram_used_gb") enriched_dev = dict(dev) enriched_dev["vram_used_gb"] = used_vram - enriched_dev["vram_free_gb"] = round(total_vram - used_vram, 2) if total_vram else 0 + enriched_dev["vram_free_gb"] = ( + round(total_vram - used_vram, 2) if total_vram and used_vram is not None else None + ) enriched_dev["vram_utilization_pct"] = util.get("vram_utilization_pct") enriched_devices.append(enriched_dev) diff --git a/studio/backend/tests/test_rocm_windows_vram_7072.py b/studio/backend/tests/test_rocm_windows_vram_7072.py new file mode 100644 index 0000000000..b4079831b7 --- /dev/null +++ b/studio/backend/tests/test_rocm_windows_vram_7072.py @@ -0,0 +1,361 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Regression tests for issue #7072 -- "VRAM Usage in System Tab is wrong". + +Reporter: dual AMD (Radeon PRO W7900 ~48GB + W7500 8GB), Windows 10, ROCm 7.13, +torch 2.11.0+rocm7.13. On Windows without a HIP SDK, amd-smi is permanently +disabled (avoids a UAC/DiskPart prompt) and hipMemGetInfo returns free==total +(used 0). Two symptoms followed: + + * System tab (/api/system -> get_visible_gpu_utilization) showed ~0 VRAM used + on every GPU (torch mem_get_info free==total quirk; ROCm/ROCm#1909). + * get_gpu_utilization()'s Windows fallback SUMMED "GPU Adapter Memory\\Dedicated + Usage" across all adapters into ONE fake device with only GPU 0's total, so + the second GPU never appeared. + +The fix reads the per-adapter (LUID-instanced) Dedicated Usage performance +counter -- Task Manager's source -- for per-GPU used, takes per-GPU total from +torch device properties, and guards the free==total mem_get_info quirk. CI has no +AMD GPU/Windows, so torch, the performance counter, and platform are all mocked. +""" + +from __future__ import annotations + +import subprocess +import sys +import types + +import pytest + +from utils.hardware import hardware as hw + +GB = 1024**3 +MiB = 1024**2 + + +# ----------------------------------------------------------------------------- # +# Fakes +# ----------------------------------------------------------------------------- # +def _fake_torch( + devices, + *, + free_equals_total = False, + used_per_device = None, +): + """Build a fake `torch` module. devices: list of (name, total_bytes).""" + dev = list(devices) + + class _Props: + def __init__(self, name, total): + self.name = name + self.total_memory = total + + def get_device_properties(i): + name, total = dev[i] + return _Props(name, total) + + def mem_get_info(i): + _, total = dev[i] + if free_equals_total: + return (total, total) + used = used_per_device[i] if used_per_device is not None else 0 + return (total - used, total) + + t = types.ModuleType("torch") + t.__version__ = "2.11.0+rocm7.13" + t.version = types.SimpleNamespace(hip = "7.13", cuda = None) + t.cuda = types.SimpleNamespace( + is_available = lambda: len(dev) > 0, + device_count = lambda: len(dev), + current_device = lambda: 0, + get_device_properties = get_device_properties, + mem_get_info = mem_get_info, + memory_allocated = lambda i: 0, + memory_reserved = lambda i: 0, + ) + return t + + +def _adapter_output(adapters): + if not adapters: + return "__NONE__\n" + return "".join(f"{name}|{int(used)}\n" for name, used in adapters) + + +def _subprocess_run(*, adapter_output = "__NONE__\n", util_output = "12.0\n"): + def fake_run(cmd, *a, **k): + joined = " ".join(cmd) if isinstance(cmd, list) else str(cmd) + if "GPU Adapter Memory" in joined and "InstanceName" in joined: + out = adapter_output + elif "engtype_3D" in joined or "GPU Engine" in joined: + out = util_output + else: + out = "-1\n" + return subprocess.CompletedProcess(args = cmd, returncode = 0, stdout = out, stderr = "") + + return fake_run + + +@pytest.fixture +def win_rocm(monkeypatch): + """Configure the hardware module as a Windows ROCm host with 2 visible GPUs.""" + monkeypatch.setattr(hw, "get_device", lambda: hw.DeviceType.CUDA) + monkeypatch.setattr(hw, "IS_ROCM", True) + monkeypatch.setattr(hw.platform, "system", lambda: "Windows") + monkeypatch.setattr(hw.sys, "platform", "win32") + monkeypatch.setattr(hw, "_smi_query", lambda *a, **k: None) # amd-smi disabled + # Visible set via HIP mask so we don't shell out to amd-smi for the count. + monkeypatch.setenv("HIP_VISIBLE_DEVICES", "0,1") + monkeypatch.delenv("CUDA_VISIBLE_DEVICES", raising = False) + monkeypatch.delenv("ROCR_VISIBLE_DEVICES", raising = False) + return monkeypatch + + +REPORTER_ADAPTERS = [ + ("luid_0x00000000_0x0000d1e2_phys_0", 40.0 * GB), # W7900, model loaded + ("luid_0x00000000_0x0000e34a_phys_0", 0.5 * GB), # W7500, idle + ("luid_0x00000000_0x0000f001_phys_0", 3 * MiB), # Basic Render Driver +] +DEVICES = [("AMD Radeon PRO W7900", 48 * GB), ("AMD Radeon PRO W7500", 8 * GB)] + + +# ----------------------------------------------------------------------------- # +# System tab (get_visible_gpu_utilization) -- the reporter's screenshot +# ----------------------------------------------------------------------------- # +def test_system_tab_shows_per_gpu_used(win_rocm, monkeypatch): + monkeypatch.setitem(sys.modules, "torch", _fake_torch(DEVICES, free_equals_total = True)) + monkeypatch.setattr( + hw.subprocess, "run", _subprocess_run(adapter_output = _adapter_output(REPORTER_ADAPTERS)) + ) + + devices = hw.get_visible_gpu_utilization()["devices"] + by_idx = {d["index"]: d for d in devices} + assert len(devices) == 2 + assert by_idx[0]["vram_total_gb"] == 48.0 + assert by_idx[0]["vram_used_gb"] == pytest.approx(40.0, abs = 0.01) # not 0 + assert by_idx[1]["vram_total_gb"] == 8.0 # own total + # The 3 MiB Basic Render Driver counter makes this a hidden-adapter case: only + # the 40 GiB is forced onto the 48 GiB card; the idle card reads Unknown. + assert by_idx[1]["vram_used_gb"] is None + assert by_idx[1]["vram_utilization_pct"] is None + assert all( + d["vram_used_gb"] <= d["vram_total_gb"] for d in devices if d["vram_used_gb"] is not None + ) + + +def test_gpu_utilization_does_not_collapse(win_rocm, monkeypatch): + monkeypatch.setitem(sys.modules, "torch", _fake_torch(DEVICES, free_equals_total = True)) + monkeypatch.setattr( + hw.subprocess, "run", _subprocess_run(adapter_output = _adapter_output(REPORTER_ADAPTERS)) + ) + + result = hw.get_gpu_utilization() + devices = result["devices"] + assert sorted(d["index"] for d in devices) == [0, 1] # both GPUs, no collapse + assert {d["vram_total_gb"] for d in devices} == {48.0, 8.0} + assert result["vram_total_gb"] == 48.0 # legacy primary mirror preserved + + +def test_localized_counter_reports_unknown_not_zero(win_rocm, monkeypatch): + monkeypatch.setitem(sys.modules, "torch", _fake_torch(DEVICES, free_equals_total = True)) + monkeypatch.setattr(hw.subprocess, "run", _subprocess_run(adapter_output = "__NONE__\n")) + + devices = hw.get_visible_gpu_utilization()["devices"] + assert len(devices) == 2 # both still shown with correct totals + assert {d["vram_total_gb"] for d in devices} == {48.0, 8.0} + assert all(d["vram_used_gb"] is None for d in devices) # unknown, not fake 0 + assert all(d["vram_utilization_pct"] is None for d in devices) + + +# ----------------------------------------------------------------------------- # +# mem_get_info free==total guard scoping +# ----------------------------------------------------------------------------- # +def test_mem_get_info_guard_scopes_to_windows_rocm(monkeypatch): + torch_mod = _fake_torch(DEVICES, free_equals_total = True) + monkeypatch.setattr(hw, "get_device", lambda: hw.DeviceType.CUDA) + monkeypatch.setitem(sys.modules, "torch", torch_mod) + + # Windows ROCm -> used unknown (None), total kept. + monkeypatch.setattr(hw, "IS_ROCM", True) + monkeypatch.setattr(hw.sys, "platform", "win32") + win = hw._torch_get_per_device_info([0, 1]) + assert [d["used_gb"] for d in win] == [None, None] + assert [d["total_gb"] for d in win] == [48.0, 8.0] + + # Linux ROCm -> unchanged numeric used. + monkeypatch.setattr(hw.sys, "platform", "linux") + assert [d["used_gb"] for d in hw._torch_get_per_device_info([0, 1])] == [0.0, 0.0] + + # Windows NVIDIA -> guard must not fire. + monkeypatch.setattr(hw, "IS_ROCM", False) + monkeypatch.setattr(hw.sys, "platform", "win32") + assert [d["used_gb"] for d in hw._torch_get_per_device_info([0, 1])] == [0.0, 0.0] + + +# ----------------------------------------------------------------------------- # +# Per-adapter attribution helpers (pure unit) +# ----------------------------------------------------------------------------- # +def test_match_adapter_pairs_and_clamps(): + assert hw._match_adapter_used_to_devices([40 * GB, 0.5 * GB], [48 * GB, 8 * GB]) == [ + 40 * GB, + 0.5 * GB, + ] + assert hw._match_adapter_used_to_devices([100 * GB], [48 * GB]) == [48 * GB] # clamp + assert hw._match_adapter_used_to_devices([40 * GB], [48 * GB, 8 * GB]) == [40 * GB, None] + + +def test_match_adapter_reports_unknown_when_more_active_than_visible(): + # More adapters actively using VRAM than are visible (a GPU outside the mask): + # attribution would fabricate a value, so report unknown for every device. + assert hw._match_adapter_used_to_devices([40 * GB, 0.5 * GB], [8 * GB]) == [None] + + +def test_match_adapter_reports_unknown_when_hidden_high_use_adapter_survives_filter(): + # Idle 8 GiB card (10 MiB noise) beside a hidden 48 GiB card at 40 GiB: the + # 40 GiB can't fit the 8 GiB device, so clamping there would fabricate. Unknown. + assert hw._match_adapter_used_to_devices([40 * GB, 10 * MiB], [8 * GB]) == [None] + # Order of the counters must not matter. + assert hw._match_adapter_used_to_devices([10 * MiB, 40 * GB], [8 * GB]) == [None] + + +def test_match_adapter_reports_unknown_for_placeholder_fallback(): + # Every counter below the 64 MiB floor plus a placeholder: no LUID-to-ordinal + # mapping tells placeholder from idle GPU, so report unknown, not fabricate. + # Single visible 8 GiB card idle (10 MiB) beside a 50 MiB placeholder counter. + assert hw._match_adapter_used_to_devices([50 * MiB, 10 * MiB], [8 * GB]) == [None] + # Order of the counters must not matter. + assert hw._match_adapter_used_to_devices([10 * MiB, 50 * MiB], [8 * GB]) == [None] + # Two idle visible GPUs plus a placeholder: all three counters below the floor. + assert hw._match_adapter_used_to_devices([50 * MiB, 10 * MiB, 5 * MiB], [48 * GB, 8 * GB]) == [ + None, + None, + ] + + +def test_match_adapter_reports_unknown_when_usage_not_capacity_ordered(): + # 8 GiB card at 7 GiB beside a 48 GiB card at 5 GiB: the bigger usage still fits + # the smaller card, so both pairings are feasible -> unknown. + assert hw._match_adapter_used_to_devices([7 * GB, 5 * GB], [8 * GB, 48 * GB]) == [None, None] + # Device order must not matter (same physical situation, ordinals flipped). + assert hw._match_adapter_used_to_devices([7 * GB, 5 * GB], [48 * GB, 8 * GB]) == [None, None] + # Same-capacity cards with unequal usage are equally unattributable. + assert hw._match_adapter_used_to_devices([12 * GB, 8 * GB], [24 * GB, 24 * GB]) == [None, None] + # A single usage that fits both cards can sit on either -> unknown. + assert hw._match_adapter_used_to_devices([5 * GB], [48 * GB, 8 * GB]) == [None, None] + # But a capacity-forced assignment (usage exceeds the smaller card) is kept: + # 40 GiB can only be the 48 GiB card, so it is not fabrication. + assert hw._match_adapter_used_to_devices([40 * GB], [48 * GB, 8 * GB]) == [40 * GB, None] + + +def test_match_adapter_reports_unknown_when_hidden_usage_fits_visible_card(): + # A survivor that merely *fits* a visible card must not be pinned onto it. Two + # cards (48/8 GiB) at 40 GiB / 10 MiB beside a hidden 6 GiB adapter: the 6 GiB + # fits the idle 8 GiB card but isn't forced -> Unknown; only 40 GiB is forced. + assert hw._match_adapter_used_to_devices([40 * GB, 10 * MiB, 6 * GB], [48 * GB, 8 * GB]) == [ + 40 * GB, + None, + ] + # Counter order must not matter. + assert hw._match_adapter_used_to_devices([6 * GB, 40 * GB, 10 * MiB], [48 * GB, 8 * GB]) == [ + 40 * GB, + None, + ] + # A single visible card with a hidden adapter is never attributable: a fitting + # survivor could be the hidden GPU's while the visible card is idle. + assert hw._match_adapter_used_to_devices([6 * GB, 10 * MiB], [8 * GB]) == [None] + + +def test_match_adapter_capacity_forced_matrix(): + """Exhaustive hidden-adapter matrix for the capacity-forced rule. + + A value is emitted only when the supra-threshold counters number exactly the + visible devices AND a device's ranked usage strictly exceeds every smaller + card's capacity. Otherwise (a visible card idle, a merely-fitting usage, or the + smallest card) every device reports unknown. + """ + m = hw._match_adapter_used_to_devices + # -- exactly-n supra-threshold counters, capacity-forced survivors are kept - # + # Both visible cards have a real reading (the 3 MiB is a placeholder): 40 GiB + # forced onto the 48 GiB card, 0.5 GiB not forced -> None. + assert m([40 * GB, 0.5 * GB, 3 * MiB], [48 * GB, 8 * GB]) == [40 * GB, None] + # Three visible cards all active (supra-threshold) + placeholder: 40 > 24 and + # 20 > 8, both forced; the 8 GiB card is not forced -> None. + assert m([40 * GB, 20 * GB, 5 * GB, 3 * MiB], [48 * GB, 24 * GB, 8 * GB]) == [ + 40 * GB, + 20 * GB, + None, + ] + # -- fewer supra-threshold counters than visible cards -> all unknown ------ # + # A visible card is idle, so even a "forced" 40 could be the hidden GPU's. + assert m([40 * GB, 3 * MiB, 3 * MiB], [48 * GB, 8 * GB]) == [None, None] + assert m([40 * GB, 10 * MiB, 10 * MiB], [48 * GB, 8 * GB]) == [None, None] + assert m([40 * GB, 20 * GB, 3 * MiB, 3 * MiB], [48 * GB, 24 * GB, 8 * GB]) == [ + None, + None, + None, + ] + # Middle usage (6 GiB) fits both the 24 and 8 GiB cards, and only two cards are + # active for three visible -> not a bijection -> all unknown. + assert m([40 * GB, 6 * GB, 3 * MiB, 3 * MiB], [48 * GB, 24 * GB, 8 * GB]) == [ + None, + None, + None, + ] + # -- hidden larger than every visible card -> all unknown ----------------- # + assert m([40 * GB, 10 * MiB], [8 * GB]) == [None] + assert m([48 * GB, 3 * MiB, 3 * MiB], [24 * GB, 8 * GB]) == [None, None] + # -- more active adapters than visible cards -> all unknown --------------- # + assert m([40 * GB, 7 * GB, 6 * GB, 3 * MiB], [48 * GB, 8 * GB]) == [None, None] + assert m([40 * GB, 7 * GB, 6 * GB, 3 * MiB, 3 * MiB], [48 * GB, 8 * GB]) == [None, None] + # -- every counter below the noise floor (placeholder fallback) -> unknown - # + assert m([50 * MiB, 10 * MiB], [8 * GB]) == [None] + assert m([50 * MiB, 10 * MiB, 5 * MiB], [48 * GB, 8 * GB]) == [None, None] + # -- equal-capacity cards with a hidden adapter: nothing is forced -------- # + assert m([40 * GB, 40 * GB, 3 * MiB], [48 * GB, 48 * GB]) == [None, None] + assert m([40 * GB, 30 * GB, 3 * MiB], [48 * GB, 48 * GB]) == [None, None] + + +def test_perf_counter_parser_and_sentinel(monkeypatch): + monkeypatch.setattr(hw.platform, "system", lambda: "Windows") + monkeypatch.setattr( + hw.subprocess, "run", _subprocess_run(adapter_output = _adapter_output(REPORTER_ADAPTERS)) + ) + parsed = hw._rocm_windows_perf_counter_vram_by_adapter() + assert parsed is not None and len(parsed) == 3 + assert parsed[0][0].startswith("luid_") + monkeypatch.setattr(hw.subprocess, "run", _subprocess_run(adapter_output = "__NONE__\n")) + assert hw._rocm_windows_perf_counter_vram_by_adapter() is None + + +# ----------------------------------------------------------------------------- # +# Unified-memory (Strix Halo APU) total reconciliation (Codex #7238) +# ----------------------------------------------------------------------------- # +def test_unified_memory_adopts_torch_total_even_when_used_unknown(): + """Windows ROCm unified-memory APU: torch's used is None but its total (the full + GTT pool) is authoritative. The correction must still adopt the larger total; + used stays at amd-smi's figure when torch's is unknown.""" + metrics = {"vram_total_gb": 8.0, "vram_used_gb": 2.0, "vram_utilization_pct": 25.0} + hw._apply_unified_memory_correction(metrics, {"total_gb": 124.0, "used_gb": None, "index": 0}) + assert metrics["vram_total_gb"] == 124.0 # full unified pool, not the 8 GB carve-out + assert metrics["vram_used_gb"] == 2.0 # amd-smi used preserved (torch's was None) + assert metrics["vram_utilization_pct"] == pytest.approx(round(2.0 / 124.0 * 100, 1)) + + +def test_unified_memory_overwrites_used_when_torch_used_known(): + """When torch reports both a larger total and a known used, both are adopted + and utilization is recomputed against the corrected total (unchanged path).""" + metrics = {"vram_total_gb": 8.0, "vram_used_gb": 2.0, "vram_utilization_pct": 25.0} + hw._apply_unified_memory_correction(metrics, {"total_gb": 124.0, "used_gb": 40.0, "index": 0}) + assert metrics["vram_total_gb"] == 124.0 + assert metrics["vram_used_gb"] == 40.0 + assert metrics["vram_utilization_pct"] == pytest.approx(round(40.0 / 124.0 * 100, 1)) + + +def test_unified_memory_no_op_when_torch_total_not_larger(): + """A discrete GPU where torch total does not exceed amd-smi's is left untouched.""" + metrics = {"vram_total_gb": 48.0, "vram_used_gb": 10.0, "vram_utilization_pct": 20.8} + hw._apply_unified_memory_correction(metrics, {"total_gb": 48.0, "used_gb": None, "index": 0}) + assert metrics["vram_total_gb"] == 48.0 + assert metrics["vram_used_gb"] == 10.0 + assert metrics["vram_utilization_pct"] == 20.8 diff --git a/studio/backend/utils/hardware/hardware.py b/studio/backend/utils/hardware/hardware.py index adc9a54aab..9fef53e65e 100644 --- a/studio/backend/utils/hardware/hardware.py +++ b/studio/backend/utils/hardware/hardware.py @@ -538,21 +538,31 @@ def _torch_get_physical_gpu_count() -> Optional[int]: def _torch_get_per_device_info(device_indices: list[int]) -> list[Dict[str, Any]]: - """Query torch for per-GPU name, total VRAM, and used VRAM.""" + """Query torch for per-GPU name, total VRAM, and used VRAM. + + ``used_gb`` is ``None`` on Windows ROCm when ``hipMemGetInfo`` reports + ``free == total`` (ROCm/ROCm#1909): that 0 means unknown, not empty. + """ mod, _ = _torch_get_device_module() if mod is None: return [] + # free==total is a Windows-ROCm-only quirk. + _win_rocm = sys.platform == "win32" and IS_ROCM devices = [] for ordinal, phys_idx in enumerate(device_indices): try: # torch ordinals are 0-based relative to CUDA_VISIBLE_DEVICES. props = mod.get_device_properties(ordinal) total_bytes = props.total_memory + used_bytes: Optional[int] # Prefer mem_get_info (system-wide) so auto-select sees other consumers. if hasattr(mod, "mem_get_info"): free_bytes, total_bytes = mod.mem_get_info(ordinal) used_bytes = total_bytes - free_bytes + # free==total is the broken-API sentinel, not an idle GPU. + if _win_rocm and free_bytes == total_bytes: + used_bytes = None else: used_bytes = mod.memory_allocated(ordinal) devices.append( @@ -561,7 +571,7 @@ def _torch_get_per_device_info(device_indices: list[int]) -> list[Dict[str, Any] "visible_ordinal": ordinal, "name": props.name, "total_gb": round(total_bytes / (1024**3), 2), - "used_gb": round(used_bytes / (1024**3), 2), + "used_gb": round(used_bytes / (1024**3), 2) if used_bytes is not None else None, } ) except Exception as e: @@ -724,20 +734,30 @@ def _rocm_linux_sysfs_vram_gb() -> tuple[Optional[float], Optional[float]]: return None, None -def _rocm_windows_perf_counter_vram_gb() -> tuple[Optional[float], Optional[float]]: - """Query system-wide dedicated GPU VRAM via Windows Performance Counters. +# ── Windows AMD/ROCm per-adapter VRAM (issue #7072) ────────────────────────── +# amd-smi is disabled and hipMemGetInfo reports free==total, so read used from the +# per-LUID "GPU Adapter Memory" perf counters and take each total from torch, so +# every GPU shows instead of one fake device with GPU 0's total. +# Placeholder adapters (Basic Render Driver / idle iGPU) drop only when they would +# outnumber the real torch devices. +_ROCM_WIN_ADAPTER_MIN_BYTES = 64 * 1024 * 1024 # 64 MiB - Same data source as Task Manager, so cross-process usage is accurate. - Works for any GPU vendor without amd-smi or nvidia-smi. - Returns (used_gb, total_gb) or (None, None) on failure. + +def _rocm_windows_perf_counter_vram_by_adapter() -> Optional[list[tuple[str, float]]]: + """Per-adapter dedicated VRAM usage on Windows via Performance Counters. + + Returns ``[(instance_name, used_bytes)]`` (one per LUID-named adapter), or + ``None`` when the counter is unavailable/localized/empty so callers fall back. """ if platform.system() != "Windows": - return None, None + return None try: + # Emit "|" per sample, or a __NONE__ sentinel. ps = ( "$s=(Get-Counter '\\GPU Adapter Memory(*)\\Dedicated Usage'" " -ErrorAction SilentlyContinue).CounterSamples;" - "if($s){($s|Measure-Object CookedValue -Sum).Sum}else{-1}" + "if($s){$s|ForEach-Object{'{0}|{1}' -f $_.InstanceName,[int64]$_.CookedValue}}" + "else{'__NONE__'}" ) r = subprocess.run( ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], @@ -746,16 +766,167 @@ def _rocm_windows_perf_counter_vram_gb() -> tuple[Optional[float], Optional[floa timeout = 5, ) if r.returncode != 0 or not r.stdout.strip(): - return None, None - used_bytes = float(r.stdout.strip()) - if used_bytes < 0: - return None, None - import torch as _torch - - total_bytes = _torch.cuda.get_device_properties(0).total_memory - return round(used_bytes / (1024**3), 2), round(total_bytes / (1024**3), 2) + return None + adapters: list[tuple[str, float]] = [] + for line in r.stdout.splitlines(): + line = line.strip() + if not line or line == "__NONE__" or "|" not in line: + continue + instance, _, raw = line.rpartition("|") + try: + used = float(raw.strip()) + except (ValueError, TypeError): + continue + if used < 0: + continue + adapters.append((instance.strip(), used)) + return adapters or None except Exception: - return None, None + return None + + +def _match_adapter_used_to_devices( + adapter_useds: list[float], device_totals: list[float] +) -> list[Optional[float]]: + """Attribute per-adapter used bytes to torch devices by capacity ranking. + + Windows shares no key between LUID counters and torch ordinals, so usages are + ranked against device totals and each is trusted only when capacity *forces* it + (it exceeds every smaller device); an ambiguous ranking reports unknown + (``None``) rather than fabricate a per-index free. + + Extra counters mean a hidden/display adapter, and the noise filter may have + dropped a real reading, so values are emitted only when the supra-threshold + counters number EXACTLY the visible devices AND capacity forces the mapping; + otherwise every device is unknown. Best-effort but correct for the common + loaded-card case (#7072). Returns a list aligned to ``device_totals``. + """ + n = len(device_totals) + if n == 0: + return [] + useds = sorted(adapter_useds, reverse = True) + ranked_positions = sorted(range(n), key = lambda i: -device_totals[i]) + ranked_totals = [device_totals[pos] for pos in ranked_positions] + assigned: list[Optional[float]] + # More counters than devices -> a hidden/display adapter (check before noise filter). + if len(useds) > n: + non_trivial = [u for u in useds if u >= _ROCM_WIN_ADAPTER_MIN_BYTES] + if len(non_trivial) != n: + # Not a clean bijection (a masked GPU is busy or a visible card idle): + # no counter maps to a specific card, so report unknown. + return [None] * n + # Exactly n supra-threshold counters: extras were placeholders, so a + # capacity-ranked bijection is plausible. + useds = non_trivial + ranked_useds = [useds[rank] for rank in range(n)] + # A usage above its ranked capacity is a hidden larger GPU; clamping onto the + # smaller card would fabricate a fully-used reading. + for rank in range(n): + if ranked_useds[rank] > ranked_totals[rank]: + return [None] * n + # Capacity forces the mapping only when the usage exceeds the next-smaller + # capacity; the smallest card and merely-fitting usages stay unknown. + # Keeps 40 GiB over 48/8 GiB -> [40, None]. + assigned = [None] * n + for rank, pos in enumerate(ranked_positions): + if rank + 1 < n and ranked_useds[rank] > ranked_totals[rank + 1]: + assigned[pos] = min(ranked_useds[rank], device_totals[pos]) + return assigned + # No hidden adapters: every counter is a visible card, so ranking is a permutation. + ranked_useds = [useds[rank] if rank < len(useds) else 0.0 for rank in range(n)] + # Ambiguous if a strictly larger usage also fits the next smaller card: the two + # could be swapped without breaking capacity, so ranking can't tell them apart. + for rank in range(n - 1): + upper, lower = ranked_useds[rank], ranked_useds[rank + 1] + if upper > lower and upper <= ranked_totals[rank + 1]: + return [None] * n + assigned = [None] * n + for rank, pos in enumerate(ranked_positions): + if rank < len(useds): + assigned[pos] = min(useds[rank], device_totals[pos]) + return assigned + + +def _rocm_windows_per_device_vram(device_indices: list[int]) -> list[Dict[str, Any]]: + """Per-GPU VRAM on Windows AMD/ROCm: total from torch properties (reliable), + used from the per-adapter Dedicated Usage counter. + + Returns ``{index, visible_ordinal, name, used_gb, total_gb}`` per visible GPU + (``used_gb`` may be ``None`` when the counter is unavailable), or ``[]`` when + torch can't enumerate devices so callers fall through to the torch last resort. + """ + if platform.system() != "Windows": + return [] + mod, _ = _torch_get_device_module() + if mod is None: + return [] + # Totals/names from torch properties (mem_get_info's free==total quirk zeroes used). + dev_meta: list[Dict[str, Any]] = [] + for ordinal, phys_idx in enumerate(device_indices): + try: + props = mod.get_device_properties(ordinal) + dev_meta.append( + { + "index": phys_idx, + "visible_ordinal": ordinal, + "name": props.name, + "total_bytes": int(props.total_memory), + } + ) + except Exception as e: + logger.debug("torch property probe failed for ordinal %d: %s", ordinal, e) + if not dev_meta: + return [] + + adapters = _rocm_windows_perf_counter_vram_by_adapter() + if adapters: + assigned = _match_adapter_used_to_devices( + [used for _, used in adapters], + [d["total_bytes"] for d in dev_meta], + ) + else: + # Counter unavailable: show every GPU with a correct total, used unknown. + assigned = [None] * len(dev_meta) + + devices: list[Dict[str, Any]] = [] + for meta, used_bytes in zip(dev_meta, assigned): + total_gb = round(meta["total_bytes"] / (1024**3), 2) + used_gb = round(used_bytes / (1024**3), 2) if used_bytes is not None else None + devices.append( + { + "index": meta["index"], + "visible_ordinal": meta["visible_ordinal"], + "name": meta["name"], + "used_gb": used_gb, + "total_gb": total_gb, + } + ) + return devices + + +def _rocm_windows_device_payload_entry( + device: DeviceType, dev: Dict[str, Any], gpu_util_pct: Optional[float] +) -> Dict[str, Any]: + """Build a ``get_gpu_utilization`` device entry from a per-device VRAM dict.""" + total_gb = dev["total_gb"] + used_gb = dev["used_gb"] + return { + "available": True, + "backend": _backend_label(device), + "index": dev["index"], + "visible_ordinal": dev["visible_ordinal"], + "name": dev.get("name", "Unknown"), + "gpu_utilization_pct": gpu_util_pct, + "temperature_c": None, + "vram_used_gb": used_gb, + "vram_total_gb": total_gb, + "vram_utilization_pct": round((used_gb / total_gb) * 100, 1) + if total_gb and total_gb > 0 and used_gb is not None + else None, + "power_draw_w": None, + "power_limit_w": None, + "power_utilization_pct": None, + } def _gpu_utilization_payload( @@ -821,30 +992,24 @@ def get_gpu_utilization() -> Dict[str, Any]: index_kind = result.get("index_kind"), ) - # Fallback Windows ROCm + # Fallback Windows ROCm: per-adapter VRAM attribution (issue #7072), so + # every visible GPU is shown instead of a sum collapsed onto one device. if IS_ROCM and platform.system() == "Windows": - _win_used, _win_total = _rocm_windows_perf_counter_vram_gb() - if _win_used is not None and _win_total is not None: - _win_util = _rocm_windows_perf_counter_gpu_util_pct() + _win_ids = _get_parent_visible_gpu_spec().get("numeric_ids") + if not _win_ids: + _win_ids = list(range(_torch_get_physical_gpu_count() or 0)) + _win_devices = _rocm_windows_per_device_vram(_win_ids) + if _win_devices: + # A single visible GPU can own the aggregate 3D-engine utilization; + # across several GPUs the sum isn't per-device, so leave it unset. + _win_util = ( + _rocm_windows_perf_counter_gpu_util_pct() if len(_win_devices) == 1 else None + ) return _gpu_utilization_payload( device, [ - { - "available": True, - "backend": _backend_label(device), - "index": 0, - "visible_ordinal": 0, - "gpu_utilization_pct": _win_util, - "temperature_c": None, - "vram_used_gb": _win_used, - "vram_total_gb": _win_total, - "vram_utilization_pct": round((_win_used / _win_total) * 100, 1) - if _win_total > 0 - else None, - "power_draw_w": None, - "power_limit_w": None, - "power_utilization_pct": None, - } + _rocm_windows_device_payload_entry(device, _wd, _win_util) + for _wd in _win_devices ], ) @@ -901,7 +1066,7 @@ def get_gpu_utilization() -> Dict[str, Any]: "vram_used_gb": _used, "vram_total_gb": _total, "vram_utilization_pct": round((_used / _total) * 100, 1) - if _total > 0 + if _total > 0 and _used is not None else None, "power_draw_w": None, "power_limit_w": None, @@ -995,19 +1160,27 @@ def _apply_unified_memory_correction( endpoints stay in sync on AMD iGPUs with unified memory. """ torch_total_gb = torch_info["total_gb"] + torch_used_gb = torch_info.get("used_gb") smi_total_gb = device_metrics.get("vram_total_gb") or 0.0 + # torch sees the full unified (GTT) pool; amd-smi only the dedicated carve-out. + # Adopt torch's larger total regardless of used: on Windows ROCm torch_used is + # None (free==total sentinel) but its total stays authoritative. Overwrite used + # only when torch's is known, then recompute utilization against whatever remains. if torch_total_gb > smi_total_gb: - torch_used_gb = torch_info["used_gb"] device_metrics["vram_total_gb"] = torch_total_gb - device_metrics["vram_used_gb"] = torch_used_gb + if torch_used_gb is not None: + device_metrics["vram_used_gb"] = torch_used_gb + _used_for_pct = device_metrics.get("vram_used_gb") device_metrics["vram_utilization_pct"] = ( - round((torch_used_gb / torch_total_gb) * 100, 1) if torch_total_gb > 0 else None + round((_used_for_pct / torch_total_gb) * 100, 1) + if torch_total_gb > 0 and _used_for_pct is not None + else None ) logger.debug( - "ROCm unified memory: replaced amd-smi VRAM (%.2f GB) with " - "torch mem_get_info total (%.2f GB) for device %s", - smi_total_gb, + "ROCm unified memory: adopted torch mem_get_info total (%.2f GB) over " + "amd-smi (%.2f GB) for device %s", torch_total_gb, + smi_total_gb, torch_info.get("index"), ) @@ -1067,6 +1240,49 @@ def get_visible_gpu_utilization() -> Dict[str, Any]: _reconcile_rocm_unified_memory(result, numeric_ids) return result + # Windows AMD/ROCm (issue #7072): the System tab's VRAM source. The torch + # fallback below would report used==0 (free==total), so read per-adapter + # Dedicated Usage instead; total from torch properties. + if IS_ROCM and platform.system() == "Windows": + win_numeric_ids = parent_visible_spec.get("numeric_ids") + if win_numeric_ids: + win_ids = win_numeric_ids + win_index_kind = "physical" + else: + win_ids = list(range(_torch_get_physical_gpu_count() or 0)) + win_index_kind = "relative" + win_devices = _rocm_windows_per_device_vram(win_ids) + if win_devices: + devices = [] + for wd in win_devices: + total = wd["total_gb"] + used = wd["used_gb"] + devices.append( + { + "index": wd["index"], + "index_kind": win_index_kind, + "visible_ordinal": wd["visible_ordinal"], + "name": wd.get("name"), + "gpu_utilization_pct": None, + "temperature_c": None, + "vram_used_gb": used, + "vram_total_gb": total, + "vram_utilization_pct": round((used / total) * 100, 1) + if total and total > 0 and used is not None + else None, + "power_draw_w": None, + "power_limit_w": None, + "power_utilization_pct": None, + } + ) + return { + "available": True, + "backend": _backend_label(device), + "parent_visible_gpu_ids": win_numeric_ids or [], + "devices": devices, + "index_kind": win_index_kind, + } + # Torch-based fallback for CUDA (nvidia-smi unavailable, AMD ROCm) and XPU (Intel) if device in (DeviceType.CUDA, DeviceType.XPU): parent_ids = get_parent_visible_gpu_ids() @@ -1094,7 +1310,7 @@ def get_visible_gpu_utilization() -> Dict[str, Any]: "vram_used_gb": used, "vram_total_gb": total, "vram_utilization_pct": round((used / total) * 100, 1) - if total > 0 + if total > 0 and used is not None else None, "power_draw_w": None, "power_limit_w": None, diff --git a/studio/frontend/src/components/floating-monitor.tsx b/studio/frontend/src/components/floating-monitor.tsx index 1272a577e9..e38e2e5882 100644 --- a/studio/frontend/src/components/floating-monitor.tsx +++ b/studio/frontend/src/components/floating-monitor.tsx @@ -70,13 +70,18 @@ export function FloatingMonitor() { (sum, device) => sum + (device.memory_total_gb ?? 0), 0, ); - const vramUsed = devices.reduce( - (sum, device) => sum + (device.vram_used_gb ?? 0), - 0, - ); + // null usage = unknown (e.g. Windows ROCm perf counter): treating it as 0 + // fabricates a 0-used readout, so the aggregate is unknown if any device is. + const vramUsageKnown = + devices.length > 0 && + devices.every((device) => Number.isFinite(device.vram_used_gb)); + const vramUsed = vramUsageKnown + ? devices.reduce((sum, device) => sum + (device.vram_used_gb ?? 0), 0) + : 0; const vramPercent = clampPercent( - vramTotal > 0 ? (vramUsed / vramTotal) * 100 : 0, + vramUsageKnown && vramTotal > 0 ? (vramUsed / vramTotal) * 100 : 0, ); + const unknownLabel = t("settings.resources.environment.unknown"); const hasGpu = (systemInfo.gpu?.available ?? false) && devices.length > 0; @@ -164,17 +169,20 @@ export function FloatingMonitor() { - {Math.round(vramPercent)}% + {vramUsageKnown ? `${Math.round(vramPercent)}%` : "--"}
- {formatGiB(vramUsed)} / {formatGiB(vramTotal)} + {vramUsageKnown ? formatGiB(vramUsed) : unknownLabel} /{" "} + {formatGiB(vramTotal)}
diff --git a/studio/frontend/src/features/settings/tabs/resources-tab.tsx b/studio/frontend/src/features/settings/tabs/resources-tab.tsx index 9a359f0f38..eb7fa03cf9 100644 --- a/studio/frontend/src/features/settings/tabs/resources-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/resources-tab.tsx @@ -90,8 +90,11 @@ function MetricTile({ label: string; value: string; detail: string; - percent: number; + // null = usage unknown (e.g. Windows ROCm perf counter): show a dash and + // empty bar rather than a fabricated 0%. + percent: number | null; }) { + const percentKnown = isFiniteNumber(percent); const safePercent = clampPercent(percent); return (
@@ -102,10 +105,10 @@ function MetricTile({ - {formatPercent(safePercent)} + {percentKnown ? formatPercent(safePercent) : "--"}
@@ -117,7 +120,7 @@ function MetricTile({
sum + (device.memory_total_gb ?? 0), 0, ); - const vramUsed = devices.reduce( - (sum, device) => sum + (device.vram_used_gb ?? 0), - 0, - ); - const vramFree = devices.reduce( - (sum, device) => - sum + - (device.vram_free_gb ?? - Math.max(0, (device.memory_total_gb ?? 0) - (device.vram_used_gb ?? 0))), - 0, - ); - const vramPercent = vramTotal > 0 ? (vramUsed / vramTotal) * 100 : 0; + // null usage = unknown (e.g. Windows ROCm perf counter): treating it as 0 + // fabricates a 0-used total, so the aggregate is unknown if any device is. + const vramUsageKnown = + devices.length > 0 && + devices.every((device) => isFiniteNumber(device.vram_used_gb)); + const vramUsed = vramUsageKnown + ? devices.reduce((sum, device) => sum + (device.vram_used_gb ?? 0), 0) + : null; + const vramFree = vramUsageKnown + ? devices.reduce( + (sum, device) => + sum + + (device.vram_free_gb ?? + Math.max( + 0, + (device.memory_total_gb ?? 0) - (device.vram_used_gb ?? 0), + )), + 0, + ) + : null; + const vramPercent = + vramUsageKnown && isFiniteNumber(vramUsed) && vramTotal > 0 + ? (vramUsed / vramTotal) * 100 + : 0; return { devices, @@ -218,6 +233,7 @@ export function ResourcesTab() { vramUsed, vramFree, vramPercent, + vramUsageKnown, }; }, [systemInfo]); @@ -259,6 +275,7 @@ export function ResourcesTab() { : modelsFolderLoaded ? t("settings.resources.environment.unknown") : t("common.loading"); + const unknownLabel = t("settings.resources.environment.unknown"); return (
@@ -327,17 +344,21 @@ export function ResourcesTab() { label={t("settings.resources.liveMonitor.vram")} value={ hasGpu - ? `${formatGiB(metrics.vramUsed)} / ${formatGiB(metrics.vramTotal)}` + ? metrics.vramUsageKnown + ? `${formatGiB(metrics.vramUsed)} / ${formatGiB(metrics.vramTotal)}` + : `${unknownLabel} / ${formatGiB(metrics.vramTotal)}` : t("settings.resources.liveMonitor.noGpu") } detail={ hasGpu - ? t("settings.resources.liveMonitor.free", { - value: formatGiB(metrics.vramFree), - }) + ? metrics.vramUsageKnown + ? t("settings.resources.liveMonitor.free", { + value: formatGiB(metrics.vramFree), + }) + : unknownLabel : backendLabel } - percent={metrics.vramPercent} + percent={metrics.vramUsageKnown ? metrics.vramPercent : null} />
@@ -346,13 +367,33 @@ export function ResourcesTab() { {hasGpu ? ( metrics.devices.map((device, index) => { const ordinal = deviceOrdinal(device); - const total = device.memory_total_gb ?? 0; - const used = device.vram_used_gb ?? 0; - const free = device.vram_free_gb ?? Math.max(0, total - used); + // Preserve null (unknown, e.g. Windows ROCm perf counter); coercing + // to 0 would render a fabricated 0 used / full free. + const total = device.memory_total_gb ?? null; + const used = device.vram_used_gb ?? null; + const free = + device.vram_free_gb ?? + (isFiniteNumber(total) && isFiniteNumber(used) + ? Math.max(0, total - used) + : null); const percent = device.vram_utilization_pct ?? - (total > 0 ? (used / total) * 100 : null); + (isFiniteNumber(total) && total > 0 && isFiniteNumber(used) + ? (used / total) * 100 + : null); const safePercent = clampPercent(percent); + const usedText = isFiniteNumber(used) + ? formatGiB(used) + : unknownLabel; + const freeText = isFiniteNumber(free) + ? formatGiB(free) + : unknownLabel; + const totalText = isFiniteNumber(total) + ? formatGiB(total) + : unknownLabel; + const percentText = isFiniteNumber(percent) + ? formatPercent(safePercent) + : unknownLabel; return (
- {formatPercent(safePercent)}{" "} + {percentText}{" "} {t("settings.resources.gpu.vramUtilization")}
@@ -382,17 +423,17 @@ export function ResourcesTab() {
{t("settings.resources.gpu.used", { - value: formatGiB(used), + value: usedText, })} {t("settings.resources.gpu.free", { - value: formatGiB(free), + value: freeText, })} {t("settings.resources.gpu.total", { - value: formatGiB(total), + value: totalText, })}