Merge branch 'develop' into develop-v5

This commit is contained in:
nicolargo
2026-07-14 10:01:35 +02:00
3 changed files with 103 additions and 5 deletions

View File

@@ -79,6 +79,8 @@ class ArmGPU:
self.device_folders = get_device_list(drm_root_folder)
# State for delta-based proc% computation
self._last_sample: dict[str, tuple[int, int]] = {}
# Denominator for GPU mem% -- static system property, read once. See #3611.
self._mem_capacity_bytes = get_mem_capacity_bytes(os.path.join(proc_root_folder, 'meminfo'))
def exit(self):
"""Close ARM GPU class."""
@@ -97,7 +99,7 @@ class ArmGPU:
'key': 'gpu_id',
'gpu_id': f'arm{index}',
'name': get_device_name(driver),
'mem': compute_mem_percent(snapshot),
'mem': compute_mem_percent(snapshot, self._mem_capacity_bytes),
'proc': self._compute_proc_percent(device, snapshot),
'temperature': get_temperature(device),
'fan_speed': None,
@@ -367,12 +369,59 @@ def aggregate_fdinfo(
return per_device
def compute_mem_percent(snapshot: dict | None) -> int | None:
"""Return memory usage in % from a per-device fdinfo snapshot."""
def _parse_meminfo(text: str) -> dict[str, int]:
"""Parse /proc/meminfo -> {field: bytes}. Kernel reports values in kB (KiB)."""
fields: dict[str, int] = {}
for line in text.splitlines():
key, _, value = line.partition(':')
parts = value.split()
if not parts:
continue
try:
fields[key.strip()] = int(parts[0]) * 1024
except ValueError:
continue
return fields
def get_mem_capacity_bytes(meminfo_path: str = '/proc/meminfo') -> int | None:
"""Denominator for ARM/v3d GPU memory %, or None to keep legacy behaviour.
WHY NOT drm-total-memory: on Raspberry Pi 5 (BCM2712, DRM/V3D), the fdinfo
field ``drm-total-memory`` reports the firmware CMA reservation (``gpu_mem``
in config.txt, often 4 MiB headless) -- NOT a GPU capacity. The Pi 5 uses
unified memory: v3d buffers are allocated on demand from system RAM through
DRM/CMA, so used/drm-total pins near 93% while idle. Do NOT revert the
denominator to drm-total-memory. See issue #3611.
Cascade:
1. CmaTotal (the pool the DRM/CMA allocator draws from), if present and > 0
2. MemTotal (kernel without CMA / GPU not on the CMA path; unified memory)
3. None -> caller falls back to drm-total-memory (legacy, pre-#3611 output)
"""
text = read_file(meminfo_path)
if text is None:
return None
fields = _parse_meminfo(text)
if fields.get('CmaTotal', 0) > 0:
return fields['CmaTotal']
if fields.get('MemTotal', 0) > 0:
return fields['MemTotal']
return None
def compute_mem_percent(snapshot: dict | None, capacity_bytes: int | None = None) -> int | None:
"""Return memory usage in % from a per-device fdinfo snapshot.
``capacity_bytes`` (CmaTotal/MemTotal, see get_mem_capacity_bytes) is the
denominator. When None (e.g. /proc/meminfo unreadable), fall back to the
fdinfo ``drm-total-memory`` total -- the pre-#3611 behaviour, preserved
byte-for-byte.
"""
if snapshot is None:
return None
total = snapshot.get('mem_total_bytes', 0)
used = snapshot.get('mem_used_bytes', 0)
total = capacity_bytes if capacity_bytes else snapshot.get('mem_total_bytes', 0)
if total <= 0:
return None
return max(0, min(100, round(used / total * 100)))

View File

@@ -0,0 +1,4 @@
MemTotal: 8192000 kB
MemFree: 512000 kB
CmaTotal: 3072 kB
CmaFree: 1024 kB

View File

@@ -19,6 +19,7 @@ from glances.plugins.gpu.cards.arm import (
compute_mem_percent,
get_device_list,
get_device_name,
get_mem_capacity_bytes,
parse_fdinfo,
)
@@ -111,6 +112,16 @@ class TestArmComputeHelpers:
snapshot = {'mem_total_bytes': 100, 'mem_used_bytes': 500}
assert compute_mem_percent(snapshot) == 100
def test_compute_mem_percent_capacity_denominator(self):
# capacity_bytes (CmaTotal/MemTotal) overrides the fdinfo drm-total.
snapshot = {'mem_total_bytes': 100, 'mem_used_bytes': 500}
assert compute_mem_percent(snapshot, capacity_bytes=1000) == 50
def test_compute_mem_percent_capacity_clamped(self):
# used > capacity -> v3d spilled past the CMA pool; clamp to 100.
snapshot = {'mem_total_bytes': 100, 'mem_used_bytes': 500}
assert compute_mem_percent(snapshot, capacity_bytes=100) == 100
def test_get_device_name_known(self):
assert get_device_name('panthor') == 'Mali (Panthor)'
assert get_device_name('msm') == 'Adreno (msm)'
@@ -119,6 +130,38 @@ class TestArmComputeHelpers:
assert get_device_name('unknown-driver-xyz') == 'ARM GPU'
class TestArmMemCapacity:
"""Denominator cascade for GPU mem% (issue #3611). Mocks /proc/meminfo."""
def _write_meminfo(self, tmp_path, content):
path = tmp_path / 'meminfo'
path.write_text(content)
return str(path)
def test_cma_total_present(self, tmp_path):
# CmaTotal > 0 -> denominator is CmaTotal (in bytes: kB * 1024).
path = self._write_meminfo(
tmp_path,
"MemTotal: 8192000 kB\nCmaTotal: 262144 kB\n",
)
assert get_mem_capacity_bytes(path) == 262144 * 1024
def test_cma_total_zero_falls_back_to_mem_total(self, tmp_path):
path = self._write_meminfo(
tmp_path,
"MemTotal: 8192000 kB\nCmaTotal: 0 kB\n",
)
assert get_mem_capacity_bytes(path) == 8192000 * 1024
def test_cma_total_absent_falls_back_to_mem_total(self, tmp_path):
path = self._write_meminfo(tmp_path, "MemTotal: 8192000 kB\n")
assert get_mem_capacity_bytes(path) == 8192000 * 1024
def test_meminfo_unreadable_returns_none(self, tmp_path):
# Non-Linux / restricted container -> None -> caller keeps legacy path.
assert get_mem_capacity_bytes(str(tmp_path / 'does-not-exist')) is None
@pytest.mark.skipif(not LINUX, reason="ARM GPU backend is Linux-only")
class TestArmBackendDiscovery:
"""Discovery tests against the committed test fixtures."""
@@ -149,7 +192,9 @@ class TestArmBackendDiscovery:
assert stats[0]['fan_speed'] is None
def test_mem_aggregated_from_fdinfo(self, arm_backend):
# Two clients: 1024 + 512 KiB used over 2048 + 1024 KiB total = 50%
# Numerator: 1024 + 512 = 1536 KiB resident (from fdinfo).
# Denominator: CmaTotal = 3072 KiB (from the proc/meminfo fixture, #3611).
# -> 1536 / 3072 = 50%
stats = arm_backend.get_device_stats()
assert stats[0]['mem'] == 50