diff --git a/glances/plugins/gpu/cards/arm.py b/glances/plugins/gpu/cards/arm.py index c8bc9991..41c47f34 100644 --- a/glances/plugins/gpu/cards/arm.py +++ b/glances/plugins/gpu/cards/arm.py @@ -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))) diff --git a/tests-data/plugins/gpu/arm/proc/meminfo b/tests-data/plugins/gpu/arm/proc/meminfo new file mode 100644 index 00000000..77ec6887 --- /dev/null +++ b/tests-data/plugins/gpu/arm/proc/meminfo @@ -0,0 +1,4 @@ +MemTotal: 8192000 kB +MemFree: 512000 kB +CmaTotal: 3072 kB +CmaFree: 1024 kB diff --git a/tests/test_plugin_gpu.py b/tests/test_plugin_gpu.py index e24061d6..168045e0 100755 --- a/tests/test_plugin_gpu.py +++ b/tests/test_plugin_gpu.py @@ -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