Studio: fix per-GPU VRAM reporting on Windows ROCm (#7238)

* Studio: fix per-GPU VRAM reporting on Windows ROCm

On Windows ROCm without a HIP SDK, amd-smi is disabled and the System tab fell
back to torch mem_get_info, which reports free==total there (ROCm/ROCm#1909), so
used VRAM showed as 0. The perf-counter fallback also summed every adapter into a
single device with only GPU 0's total, hiding the second GPU.

Read per-adapter Dedicated Usage (LUID-instanced) for used and take each GPU's
total from torch properties, and treat the free==total case as unknown rather
than 0, so every GPU shows real usage. NVIDIA, Linux ROCm, Apple and CPU paths
are unchanged. Final validation needs a real Windows AMD box.

Fixes #7072

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: report unknown VRAM instead of fabricating or zeroing it

Two gaps in the Windows ROCm VRAM path. When more adapters are actively using
VRAM than are visible to the process (a GPU outside the visibility mask), the
per-adapter attribution paired usage by size and fabricated a per-GPU value;
report unknown for every device in that case rather than mis-assign. And the
System API turned an unknown (None) used value into 0 with ``or 0``, then
reported the full card as free, re-hiding the exact case this change surfaces;
keep None so the UI shows unknown.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Render unknown VRAM as Unknown instead of zero in the System tab

The backend reports null usage when it is unknown (e.g. the Windows ROCm
perf counter is unavailable or localized), but the System tab coerced
null to 0 and derived free from it, fabricating a 0-used/full-free total.
Preserve null and render the translated Unknown for per-device used, free
and utilization, and mark the aggregate VRAM tile unknown when any device
is unknown.

* Render unknown VRAM as Unknown in the floating monitor and the util tile

The floating VRAM monitor and the aggregate utilization ring both still
coerced a null usage to 0, showing a fabricated 0.00 GiB / full free / 0%
on the same Windows ROCm no-counter case the resources tab already
handles. Guard both on whether every device reports a finite usage and
render Unknown (value and percent) instead of a concrete 0.

* Attribute per-adapter VRAM usage only when capacity forces the mapping

On Windows/ROCm there is no shared key between LUID performance-counter
instances and torch ordinals, so usage was paired to devices purely by capacity
ranking. That pairing is only trustworthy when capacity forces it (a usage
larger than every smaller device can sit on one card). When a smaller-capacity
device could equally hold a strictly larger usage (for example an 8 GiB card
near full beside a lightly used 48 GiB card), the two values are swappable
without violating any capacity, so the ranking is a guess with no key to break
the tie. A wrong guess both mislabels the System tab and feeds
routes/training_vram.py a wrong per-index free value, driving a wrong
keep-resident decision.

Report unknown for every device when the assignment is ambiguous, keeping the
attribution only for the capacity-forced case. Returning None is the
conservative direction: training_vram treats a missing index as zero free, so it
never keeps a chat model into an OOM. Add regression tests for the
not-capacity-ordered, same-capacity, single-fits-both, and capacity-forced
cases.

* Report unknown VRAM usage when a hidden adapter survives the noise filter

When HIP_VISIBLE_DEVICES exposes a subset of the physical adapters, the LUID
usage counters cover cards outside the visibility mask too. The sub-64 MiB noise
filter could drop a genuinely-idle visible card's real usage while keeping a
hidden larger card's high usage, which was then clamped onto the smaller visible
device and reported as fully used (for example a hidden 48 GiB card at 40 GiB
shown as a visible 8 GiB card fully used, with its true 10 MiB usage filtered
out). That fabricated reading also feeds routes/training_vram.py a wrong
per-index free value.

Flag extra adapters on the raw counter count (before the noise filter, since an
idle visible card can itself fall below the floor) and, when a kept usage exceeds
its ranked visible capacity, report unknown rather than clamp a hidden card's
usage onto a visible device. The genuinely-idle-noise and capacity-forced
single-model cases are unchanged. Add a regression test for the hidden
high-use-adapter case in both counter orders.

* Report unknown when only a placeholder adapter counter survives the noise filter

When more raw counters than visible devices are present but every counter sits
below the 64 MiB noise floor (an idle real GPU alongside a Windows Basic Render
Driver placeholder), the non_trivial-or-raw fallback resurrected the raw
magnitude-sorted counters and could attribute the placeholder to a real GPU while
dropping a real card's reading. With a single visible device the swap-ambiguity
check cannot catch it (it needs at least two ranks), so the fabricated value
reached the System tab and automatic GPU selection.

Return unknown for every device in that case instead of falling back to raw
counters. With the earlier guards this completes the invariant: a concrete
per-GPU usage is emitted only when the assignment is capacity-forced, and every
ambiguous, extra-adapter, placeholder-fallback, or count-mismatch path reports
unknown. Add a regression test for the placeholder fallback in both counter
orders and the two-idle-GPU case.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: attribute Windows/ROCm VRAM only when capacity forces a clean bijection

With more raw adapter counters than visible devices, a survivor that merely
fits a visible card was pinned to it by magnitude ranking, fabricating a hidden
GPU's usage onto an idle visible card whose true reading was dropped by the
sub-threshold noise filter (two visible 48/8 GiB cards using 40 GiB / 10 MiB
beside a hidden 6 GiB adapter returned [40, 6]). Emit a concrete per-device
value only when the supra-threshold counters number exactly the visible devices
(every visible card has one real reading, the extras were sub-threshold
placeholders) AND the ranked usage strictly exceeds every smaller visible card's
capacity. When a visible card is idle (fewer supra-threshold counters than
devices) a survivor could be the hidden GPU's usage, so every device reports
unknown; more active counters than visible cards, the smallest card, and any
merely-fitting usage stay unknown too. The reporter's loaded-card display is
preserved (40 GiB / 0.5 GiB across 48/8 GiB -> [40, None]). Adds a regression
test for the reported case plus an exhaustive capacity-forced/bijection matrix.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: keep the unified-memory total when Windows-ROCm used is unknown

_apply_unified_memory_correction gated both the total and the used update on
torch_used_gb being known, so on a unified-memory APU (Strix Halo) where torch
reports used=None (the Windows-ROCm free==total sentinel) but an authoritative
full-GTT total, the device kept amd-smi's small dedicated carve-out and
underreported its capacity on the System tab. Adopt torch's larger total
independently of used; overwrite used only when torch's is known (otherwise keep
amd-smi's dedicated-usage figure) and recompute utilization against the
corrected total. Adds regression tests.

* Tighten comments in the ROCm/Windows VRAM reporting path

* Tighten comments further in the ROCm/Windows VRAM reporting path

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
This commit is contained in:
Daniel Han 2026-07-20 05:27:53 -07:00 committed by GitHub
commit 66808ab25d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 716 additions and 86 deletions

View file

@ -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)

View file

@ -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

View file

@ -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 "<InstanceName>|<CookedValue>" 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,

View file

@ -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() {
<span
className={cn(
"shrink-0 tabular-nums",
usageTextClass(vramPercent),
vramUsageKnown
? usageTextClass(vramPercent)
: "text-muted-foreground",
)}
>
{Math.round(vramPercent)}%
{vramUsageKnown ? `${Math.round(vramPercent)}%` : "--"}
</span>
</div>
<div className="text-xs text-muted-foreground font-mono tabular-nums">
{formatGiB(vramUsed)} / {formatGiB(vramTotal)}
{vramUsageKnown ? formatGiB(vramUsed) : unknownLabel} /{" "}
{formatGiB(vramTotal)}
</div>
<Progress
value={vramPercent}
value={vramUsageKnown ? vramPercent : 0}
className="mt-1 h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(vramPercent)}
/>

View file

@ -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 (
<div className="flex min-w-0 flex-col gap-2 rounded-md border border-border/60 bg-muted/20 p-3">
@ -102,10 +105,10 @@ function MetricTile({
<span
className={cn(
"shrink-0 font-mono text-xs tabular-nums",
usageTextClass(safePercent),
percentKnown ? usageTextClass(safePercent) : "text-muted-foreground",
)}
>
{formatPercent(safePercent)}
{percentKnown ? formatPercent(safePercent) : "--"}
</span>
</div>
<div className="min-w-0">
@ -117,7 +120,7 @@ function MetricTile({
</div>
</div>
<Progress
value={safePercent}
value={percentKnown ? safePercent : 0}
aria-label={label}
className="h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(safePercent)}
@ -194,18 +197,30 @@ export function ResourcesTab() {
(sum, device) => 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 (
<div className="flex flex-col gap-6">
@ -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}
/>
</div>
</SettingsSection>
@ -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 (
<div
key={`${device.index ?? index}-${device.name ?? "gpu"}`}
@ -374,7 +415,7 @@ export function ResourcesTab() {
</div>
<div className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
<span>
{formatPercent(safePercent)}{" "}
{percentText}{" "}
{t("settings.resources.gpu.vramUtilization")}
</span>
</div>
@ -382,17 +423,17 @@ export function ResourcesTab() {
<div className="grid gap-1 text-xs text-muted-foreground sm:grid-cols-3 sm:gap-2">
<span className="min-w-0 truncate font-mono tabular-nums">
{t("settings.resources.gpu.used", {
value: formatGiB(used),
value: usedText,
})}
</span>
<span className="min-w-0 truncate font-mono tabular-nums sm:text-center">
{t("settings.resources.gpu.free", {
value: formatGiB(free),
value: freeText,
})}
</span>
<span className="min-w-0 truncate font-mono tabular-nums sm:text-right">
{t("settings.resources.gpu.total", {
value: formatGiB(total),
value: totalText,
})}
</span>
</div>