mirror of
https://github.com/nicolargo/glances.git
synced 2026-07-31 15:26:16 -04:00
322 lines
12 KiB
Python
Executable File
322 lines
12 KiB
Python
Executable File
#!/usr/bin/env python
|
|
#
|
|
# Glances - An eye on your system
|
|
#
|
|
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
|
|
#
|
|
# SPDX-License-Identifier: LGPL-3.0-only
|
|
#
|
|
|
|
"""Tests for the GPU plugin (ARM and Tegra backends)."""
|
|
|
|
import pytest
|
|
|
|
from glances.globals import LINUX
|
|
from glances.plugins.gpu.cards import tegra
|
|
from glances.plugins.gpu.cards.arm import (
|
|
ArmGPU,
|
|
aggregate_fdinfo,
|
|
compute_mem_percent,
|
|
get_device_list,
|
|
get_device_name,
|
|
get_mem_capacity_bytes,
|
|
parse_fdinfo,
|
|
)
|
|
|
|
ARM_TEST_DATA_ROOT = './tests-data/plugins/gpu/arm'
|
|
ARM_DRM_ROOT = f'{ARM_TEST_DATA_ROOT}/sys/class/drm'
|
|
ARM_PROC_ROOT = f'{ARM_TEST_DATA_ROOT}/proc'
|
|
|
|
TEGRA_TEST_DATA_ROOT = './tests-data/plugins/gpu/tegra'
|
|
TEGRA_GPU_FOLDER = f'{TEGRA_TEST_DATA_ROOT}/sys/devices/platform/gpu.0'
|
|
TEGRA_THERMAL_ROOT = f'{TEGRA_TEST_DATA_ROOT}/sys/class/thermal'
|
|
|
|
|
|
@pytest.fixture
|
|
def arm_backend():
|
|
"""Return an ArmGPU instance wired to the test fixtures."""
|
|
return ArmGPU(drm_root_folder=ARM_DRM_ROOT, proc_root_folder=ARM_PROC_ROOT)
|
|
|
|
|
|
@pytest.fixture
|
|
def gpu_plugin(glances_stats):
|
|
"""Return the GPU plugin instance from glances_stats."""
|
|
return glances_stats.get_plugin('gpu')
|
|
|
|
|
|
class TestArmFdinfoParser:
|
|
"""Unit tests for the fdinfo parser (pure, no I/O)."""
|
|
|
|
def test_valid_panthor_record(self):
|
|
text = (
|
|
"pos:\t0\n"
|
|
"drm-driver:\tpanthor\n"
|
|
"drm-pdev:\tfe9b0000.gpu\n"
|
|
"drm-engine-fragment:\t1000 ns\n"
|
|
"drm-engine-vertex-tiler:\t2000 ns\n"
|
|
"drm-total-memory:\t2048 KiB\n"
|
|
"drm-resident-memory:\t1024 KiB\n"
|
|
)
|
|
record = parse_fdinfo(text)
|
|
assert record is not None
|
|
assert record['driver'] == 'panthor'
|
|
assert record['pdev'] == 'fe9b0000.gpu'
|
|
assert record['engine_total_ns'] == 3000
|
|
assert record['mem_total_bytes'] == 2048 * 1024
|
|
assert record['mem_used_bytes'] == 1024 * 1024
|
|
|
|
def test_not_a_drm_fd(self):
|
|
assert parse_fdinfo("pos:\t0\nflags:\t02100002\n") is None
|
|
|
|
def test_empty_text(self):
|
|
assert parse_fdinfo("") is None
|
|
|
|
def test_missing_driver_line(self):
|
|
text = "pos:\t0\ndrm-pdev:\tfe9b0000.gpu\ndrm-engine-foo:\t1 ns\n"
|
|
assert parse_fdinfo(text) is None
|
|
|
|
def test_memory_default_unit_is_kib(self):
|
|
text = "drm-driver:\tmsm\ndrm-total-memory:\t1\n"
|
|
record = parse_fdinfo(text)
|
|
assert record is not None
|
|
assert record['mem_total_bytes'] == 1024
|
|
|
|
def test_unknown_memory_unit_ignored(self):
|
|
text = "drm-driver:\tmsm\ndrm-total-memory:\t42 QB\n"
|
|
record = parse_fdinfo(text)
|
|
assert record is not None
|
|
assert record['mem_total_bytes'] == 0
|
|
|
|
def test_malformed_lines_do_not_crash(self):
|
|
text = "drm-driver:\tpanthor\ndrm-engine-bogus:\tnotanumber ns\ndrm-total-memory:\txyz\nno-colon-here\n"
|
|
record = parse_fdinfo(text)
|
|
assert record is not None
|
|
assert record['engine_total_ns'] == 0
|
|
assert record['mem_total_bytes'] == 0
|
|
|
|
|
|
class TestArmComputeHelpers:
|
|
"""Unit tests for small helpers."""
|
|
|
|
def test_compute_mem_percent_none(self):
|
|
assert compute_mem_percent(None) is None
|
|
|
|
def test_compute_mem_percent_zero_total(self):
|
|
assert compute_mem_percent({'mem_total_bytes': 0, 'mem_used_bytes': 0}) is None
|
|
|
|
def test_compute_mem_percent_half(self):
|
|
snapshot = {'mem_total_bytes': 1000, 'mem_used_bytes': 500}
|
|
assert compute_mem_percent(snapshot) == 50
|
|
|
|
def test_compute_mem_percent_clamped(self):
|
|
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)'
|
|
|
|
def test_get_device_name_unknown(self):
|
|
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."""
|
|
|
|
def test_device_enumeration(self, arm_backend):
|
|
assert len(arm_backend.device_folders) == 1
|
|
device, driver, _pdev = arm_backend.device_folders[0]
|
|
assert driver == 'panthor'
|
|
assert device.endswith('card0')
|
|
|
|
def test_stats_shape(self, arm_backend):
|
|
stats = arm_backend.get_device_stats()
|
|
assert isinstance(stats, list)
|
|
assert len(stats) == 1
|
|
entry = stats[0]
|
|
for key in ('key', 'gpu_id', 'name', 'mem', 'proc', 'temperature', 'fan_speed'):
|
|
assert key in entry
|
|
assert entry['key'] == 'gpu_id'
|
|
assert entry['gpu_id'] == 'arm0'
|
|
assert entry['name'] == 'Mali (Panthor)'
|
|
|
|
def test_temperature(self, arm_backend):
|
|
stats = arm_backend.get_device_stats()
|
|
assert stats[0]['temperature'] == 45
|
|
|
|
def test_fan_speed_always_none(self, arm_backend):
|
|
stats = arm_backend.get_device_stats()
|
|
assert stats[0]['fan_speed'] is None
|
|
|
|
def test_mem_aggregated_from_fdinfo(self, arm_backend):
|
|
# 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
|
|
|
|
def test_proc_first_call_is_none(self, arm_backend):
|
|
# Delta-based: first call has no previous sample.
|
|
stats = arm_backend.get_device_stats()
|
|
assert stats[0]['proc'] is None
|
|
|
|
def test_proc_second_call_is_int(self, arm_backend):
|
|
arm_backend.get_device_stats()
|
|
stats = arm_backend.get_device_stats()
|
|
assert isinstance(stats[0]['proc'], int)
|
|
assert 0 <= stats[0]['proc'] <= 100
|
|
|
|
|
|
class TestArmBackendNoHardware:
|
|
"""Backend must degrade gracefully with no hardware / no sysfs."""
|
|
|
|
def test_missing_drm_root(self):
|
|
backend = ArmGPU(
|
|
drm_root_folder='/this/path/does/not/exist',
|
|
proc_root_folder='/this/path/does/not/exist',
|
|
)
|
|
assert backend.device_folders == []
|
|
assert backend.get_device_stats() == []
|
|
|
|
def test_missing_proc_root(self):
|
|
backend = ArmGPU(
|
|
drm_root_folder=ARM_DRM_ROOT,
|
|
proc_root_folder='/this/path/does/not/exist',
|
|
)
|
|
stats = backend.get_device_stats() if LINUX else []
|
|
if LINUX:
|
|
assert len(stats) == 1
|
|
assert stats[0]['mem'] is None
|
|
assert stats[0]['proc'] is None
|
|
|
|
|
|
@pytest.mark.skipif(not LINUX, reason="ARM GPU backend is Linux-only")
|
|
class TestArmAggregation:
|
|
"""Aggregation layer tests."""
|
|
|
|
def test_aggregate_fdinfo_ignores_non_drm(self):
|
|
devices = get_device_list(ARM_DRM_ROOT)
|
|
per_device = aggregate_fdinfo(ARM_PROC_ROOT, devices)
|
|
assert len(per_device) == 1
|
|
bucket = next(iter(per_device.values()))
|
|
# Sum of the two fdinfo entries' engine counters and memory.
|
|
# Client 1: 5_000_000 + 3_000_000 + 0 = 8_000_000 ns
|
|
# Client 2: 1_000_000 + 500_000 = 1_500_000 ns
|
|
assert bucket['engine_total_ns'] == 9_500_000
|
|
assert bucket['mem_total_bytes'] == 3072 * 1024
|
|
assert bucket['mem_used_bytes'] == 1536 * 1024
|
|
|
|
|
|
# Tegra (Jetson) sysfs fallback tests against committed fixtures.
|
|
class TestTegraBackend:
|
|
def test_is_tegra_by_name(self):
|
|
# NVML reports names such as "Orin (nvgpu)" on Jetson.
|
|
assert tegra.is_tegra('Orin (nvgpu)') is True # nosec B101
|
|
assert tegra.is_tegra('NVIDIA Tegra Xavier (nvgpu)') is True # nosec B101
|
|
|
|
def test_is_tegra_rejects_discrete(self):
|
|
# A discrete GPU name with no Tegra sysfs node must not trigger.
|
|
assert tegra.is_tegra('NVIDIA GeForce RTX 4090', gpu_device_folder='/nope') is False # nosec B101
|
|
|
|
def test_is_tegra_by_sysfs(self):
|
|
assert tegra.is_tegra(None, gpu_device_folder=TEGRA_GPU_FOLDER) is True # nosec B101
|
|
assert tegra.is_tegra(None, gpu_device_folder='/this/does/not/exist') is False # nosec B101
|
|
|
|
def test_proc_permille_to_percent(self):
|
|
# Fixture load node holds 774 per-mille -> 77 %.
|
|
assert tegra.get_proc(gpu_device_folder=TEGRA_GPU_FOLDER) == 77 # nosec B101
|
|
|
|
def test_proc_missing_node(self):
|
|
assert tegra.get_proc(gpu_device_folder='/this/does/not/exist') is None # nosec B101
|
|
|
|
def test_temperature_picks_gpu_thermal_zone(self):
|
|
# Must select the gpu-thermal zone (51312 -> 51 C), not cpu/soc zones.
|
|
assert tegra.get_temperature(thermal_root=TEGRA_THERMAL_ROOT) == 51 # nosec B101
|
|
|
|
def test_temperature_missing_root(self):
|
|
assert tegra.get_temperature(thermal_root='/this/does/not/exist') is None # nosec B101
|
|
|
|
|
|
class TestGpuPluginIntegration:
|
|
"""End-to-end plugin test."""
|
|
|
|
def test_plugin_name(self, gpu_plugin):
|
|
assert gpu_plugin.plugin_name == 'gpu'
|
|
|
|
def test_get_key_returns_gpu_id(self, gpu_plugin):
|
|
assert gpu_plugin.get_key() == 'gpu_id'
|
|
|
|
def test_update_does_not_crash(self, gpu_plugin):
|
|
gpu_plugin.update()
|
|
assert isinstance(gpu_plugin.get_raw(), list)
|
|
|
|
def test_exit_tolerates_none_backends(self, gpu_plugin):
|
|
"""Regression guard: exit() must not crash if a backend failed to init."""
|
|
saved = (gpu_plugin.nvidia, gpu_plugin.amd, gpu_plugin.intel, gpu_plugin.arm)
|
|
try:
|
|
gpu_plugin.nvidia = None
|
|
gpu_plugin.amd = None
|
|
gpu_plugin.intel = None
|
|
gpu_plugin.arm = None
|
|
# Should not raise
|
|
gpu_plugin.exit()
|
|
finally:
|
|
gpu_plugin.nvidia, gpu_plugin.amd, gpu_plugin.intel, gpu_plugin.arm = saved
|
|
|
|
@pytest.mark.skipif(not LINUX, reason="ARM GPU backend is Linux-only")
|
|
def test_arm_backend_can_be_injected(self, gpu_plugin):
|
|
gpu_plugin.arm = ArmGPU(
|
|
drm_root_folder=ARM_DRM_ROOT,
|
|
proc_root_folder=ARM_PROC_ROOT,
|
|
)
|
|
# Force a real update (the plugin rate-limits via refresh_timer).
|
|
gpu_plugin.refresh_timer.reset(0)
|
|
gpu_plugin.update()
|
|
stats = gpu_plugin.get_raw()
|
|
arm_entries = [s for s in stats if s.get('gpu_id', '').startswith('arm')]
|
|
assert len(arm_entries) == 1
|
|
assert arm_entries[0]['name'] == 'Mali (Panthor)'
|