Files
nvcurve/tests/test_rm_power.py
ARIA e148c83622 feat: experimental NVIDIA power control via RM ioctl interface
Adds an experimental power-cap mode using the undocumented RM ioctl
interface (based on panchovix's LACT PR #1205) to set power limits
below the VBIOS minimum (down to 30 W).

- hal/rm_power.py: RM ioctl power-cap read/write/reset + runtime probe
- limits.py: power_cap_mode (nvml/ioctl) with support detection
- config.py: persist power_cap_mode per GPU
- profiles: record/apply power_cap_mode
- server.py: POST /api/limits validates ioctl support (409 on failure)
- cli.py: profile save falls back to persisted mode
- client.py: power_cap_mode in Limits
- frontend: toggle + warning with panchovix attribution (LACT #1205)
- tests: test_rm_power.py (unit) + integration coverage
- Makefile: add test_rm_power.py to make test

Also includes automated linter reformatting (prettier, ruff, shellcheck,
isort, markdownlint) that the linter would apply anyway.
2026-09-17 22:44:27 +02:00

356 lines
13 KiB
Python

"""Unit tests for the RM power-limit interface (fake RM, no hardware).
Standalone (no pytest required):
python tests/test_rm_power.py
Also works under pytest if available. Ports the test battery from LACT PR
#1205 (ilya-zlobintsev/LACT): layout discovery, NVML cross-validation,
write minimality, readback verification, and failure restoration.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from nvcurve.hal.rm_power import ( # noqa: E402
_CTRL_GPU_GET_ATTACHED_IDS,
_CTRL_GPU_GET_ID_INFO_V2,
_CTRL_GPU_GET_PCI_INFO,
_PWR_GET_CONTROL,
_PWR_GET_INFO,
_PWR_SET_CONTROL,
EXTENDED_LAYOUT,
LEGACY_LAYOUT,
PciLocation,
PowerLimitBounds,
RmPowerError,
_u32,
probe,
resolve_gpu_instance,
set_limit,
)
PASS = 0
FAIL = 0
def check(name: str, cond: bool) -> None:
global PASS, FAIL
if cond:
PASS += 1
print(f" PASS {name}")
else:
FAIL += 1
print(f" FAIL {name}")
BOUNDS = PowerLimitBounds(min_mw=250_000, default_mw=300_000, max_mw=325_000)
class FakeRm:
"""In-memory fake of the RM power-limit client (both wire layouts)."""
def __init__(self, layout, current: int) -> None:
self.layout = layout
self.control = bytearray(layout.control_size)
self.control[0:8] = bytes([0xFF, 0, 0, 0, 1, 0, 0, 0])
self.control[layout.request_at - 4 : layout.request_at] = bytes(
[0x67, 0x67, 0, 0]
)
self.control[layout.request_at : layout.request_at + 4] = current.to_bytes(
4, "little"
)
self.control[layout.client_at] = 0xFE
self.reads: list[tuple[int, int]] = []
self.writes: list[bytes] = []
self.fail_first_write = False
self.fail_readback = False
self.fail_restore = False
def query(self, cmd: int, data: bytearray) -> None:
if cmd == _PWR_GET_INFO:
self.reads.append((cmd, len(data)))
if len(data) != self.layout.info_size:
raise RmPowerError("Unsupported INFO size")
data[0:8] = bytes([0xFF, 0, 0, 0, 1, 0, 0, 0])
for index, value in enumerate([250_000, 300_000, 325_000]):
offset = self.layout.info_min_at + 4 * index
data[offset : offset + 4] = value.to_bytes(4, "little")
elif cmd == _PWR_GET_CONTROL:
self.reads.append((cmd, len(data)))
if len(data) != self.layout.control_size:
raise RmPowerError("Unsupported CONTROL size")
if data[self.layout.client_at] != 0xFE:
raise AssertionError("unexpected client selector on GET")
if self.fail_readback and len(self.writes) == 1:
raise RmPowerError("readback unavailable")
data[:] = self.control
elif cmd == _PWR_SET_CONTROL:
if len(data) != self.layout.control_size:
raise AssertionError("bad SET size")
if data[4:8] != (1).to_bytes(4, "little"):
raise AssertionError("bad SET mask")
if data[self.layout.client_at] != 0xFE:
raise AssertionError("bad SET client selector")
# Only the request field may differ from the current state.
for i, (a, b) in enumerate(zip(data, self.control, strict=True)):
if self.layout.request_at <= i < self.layout.request_at + 4:
continue
if a != b:
raise AssertionError(f"SET modified byte {i:#x}")
self.writes.append(bytes(data))
if self.fail_restore and len(self.writes) > 1:
raise RmPowerError("restore unavailable")
self.control[:] = data
if self.fail_first_write and len(self.writes) == 1:
raise RmPowerError("SET failed after modifying hardware")
else:
raise AssertionError(f"unexpected command {cmd:#x}")
def test_detects_both_layouts_with_gets_without_a_driver_version() -> None:
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
rm = FakeRm(layout, 250_000)
support = probe(BOUNDS, 250_000, rm.query)
check(
f"{layout.name}: detected",
support.bounds == BOUNDS and support.layout == layout,
)
expected = (
[(_PWR_GET_INFO, 0x924), (_PWR_GET_CONTROL, 0x328)]
if layout == EXTENDED_LAYOUT
else [
(_PWR_GET_INFO, 0x924),
(_PWR_GET_INFO, 0x488),
(_PWR_GET_CONTROL, 0x188),
]
)
check(f"{layout.name}: GETs only, expected sequence", rm.reads == expected)
check(f"{layout.name}: no writes during discovery", rm.writes == [])
def test_unknown_layout_and_nvml_mismatches_never_write() -> None:
calls: list[tuple[int, int]] = []
def failing(cmd: int, data: bytearray) -> None:
calls.append((cmd, len(data)))
raise RmPowerError("Unsupported payload")
try:
probe(BOUNDS, 250_000, failing)
check("unknown layout rejected", False)
except RmPowerError:
check("unknown layout rejected", True)
check(
"unknown layout: only GETs attempted",
calls == [(_PWR_GET_INFO, 0x924), (_PWR_GET_INFO, 0x488)],
)
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
rm = FakeRm(layout, 250_000)
try:
probe(BOUNDS, 300_000, rm.query)
check(f"{layout.name}: current mismatch rejected", False)
except RmPowerError:
check(f"{layout.name}: current mismatch rejected", True)
other_bounds = PowerLimitBounds(
min_mw=BOUNDS.min_mw, default_mw=BOUNDS.default_mw, max_mw=350_000
)
try:
probe(other_bounds, 250_000, rm.query)
check(f"{layout.name}: bounds mismatch rejected", False)
except RmPowerError:
check(f"{layout.name}: bounds mismatch rejected", True)
check(f"{layout.name}: no writes on mismatch", rm.writes == [])
def test_rejects_unrecognized_headers_masks_and_client_values() -> None:
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
for at, value in [(0, 0), (4, 3), (layout.client_at, 0xF8)]:
rm = FakeRm(layout, 250_000)
rm.control[at] = value
try:
probe(BOUNDS, 250_000, rm.query)
check(f"{layout.name}: bad header/client rejected", False)
except RmPowerError:
check(f"{layout.name}: bad header/client rejected", True)
check(f"{layout.name}: no writes on bad header", rm.writes == [])
for current in (0, 0xFFFFFFFF):
rm = FakeRm(layout, current)
try:
probe(BOUNDS, current, rm.query)
check(f"{layout.name}: empty request rejected", False)
except RmPowerError:
check(f"{layout.name}: empty request rejected", True)
# The extended layout has additional mask words. Accepting only its low
# word would allow an unexpected client to be included in a later SET.
rm = FakeRm(EXTENDED_LAYOUT, 250_000)
rm.control[8] = 1
try:
probe(BOUNDS, 250_000, rm.query)
check("extended: nonzero mask word rejected", False)
except RmPowerError:
check("extended: nonzero mask word rejected", True)
check("extended: no writes on mask violation", rm.writes == [])
def test_changes_only_fe_request_and_keeps_vbios_maximum() -> None:
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
rm = FakeRm(layout, 250_000)
support = probe(BOUNDS, 250_000, rm.query)
for cap in (150_000, 30_000, 250_000):
set_limit(cap, support, rm.query)
check(
f"{layout.name}: set {cap} mW",
_u32(rm.control, layout.request_at) == cap,
)
writes = len(rm.writes)
for cap in (0, 29_999, 325_001, 350_000, 0xFFFFFFFF):
try:
set_limit(cap, support, rm.query)
check(f"{layout.name}: out-of-range {cap} rejected", False)
except RmPowerError:
check(f"{layout.name}: out-of-range {cap} rejected", True)
check(
f"{layout.name}: no writes for out-of-range caps",
len(rm.writes) == writes,
)
def test_restores_previous_below_minimum_request_after_set_or_readback_failure() -> (
None
):
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
for fail_set in (False, True):
rm = FakeRm(layout, 100_000)
original = bytes(rm.control)
rm.fail_first_write = fail_set
rm.fail_readback = not fail_set
support = probe(BOUNDS, 100_000, rm.query)
try:
set_limit(150_000, support, rm.query)
check(f"{layout.name}: failure reported", False)
except RmPowerError:
check(f"{layout.name}: failure reported", True)
check(f"{layout.name}: restore issued", len(rm.writes) == 2)
check(
f"{layout.name}: previous request restored",
bytes(rm.control) == original,
)
def test_reports_restore_failure_and_rejects_wrong_client_before_writing() -> None:
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
rm = FakeRm(layout, 100_000)
rm.fail_first_write = True
rm.fail_restore = True
support = probe(BOUNDS, 100_000, rm.query)
try:
set_limit(150_000, support, rm.query)
check(f"{layout.name}: restore failure reported", False)
except RmPowerError as exc:
check(
f"{layout.name}: restore failure reported",
"restoration also failed" in str(exc),
)
rm = FakeRm(layout, 250_000)
rm.control[layout.client_at] = 0xF8
try:
set_limit(150_000, support, rm.query)
check(f"{layout.name}: wrong client rejected", False)
except RmPowerError:
check(f"{layout.name}: wrong client rejected", True)
check(f"{layout.name}: no writes for wrong client", rm.writes == [])
# ── PCI identity → RM instance resolution ────────────────────────────────────
def test_resolves_pci_identity_when_minor_and_rm_orders_differ() -> None:
# This host has Ada at minor 5/RM 4 and the 5090 at minor 4/RM 5.
# IDs are opaque and enumeration order must not select the device.
pci = PciLocation(domain=0, bus=0x0D, dev=0, func=0)
instances = resolve_gpu_instance(pci, lambda cmd, data: _fake_root(cmd, data))
check("resolves by PCI identity", instances == (5, 2))
def _fake_root(cmd: int, data: bytearray) -> None:
if cmd == _CTRL_GPU_GET_ATTACHED_IDS:
data[0:4] = (0x2E00).to_bytes(4, "little")
data[4:8] = (0x0D00).to_bytes(4, "little")
elif cmd == _CTRL_GPU_GET_PCI_INFO:
gpu_id = _u32(data, 0)
bus = 0x2E if gpu_id == 0x2E00 else 0x0D
data[8:10] = bus.to_bytes(2, "little")
elif cmd == _CTRL_GPU_GET_ID_INFO_V2:
if _u32(data, 0) != 0x0D00:
raise AssertionError("unexpected gpu id in ID_INFO_V2")
data[8:12] = (5).to_bytes(4, "little")
data[12:16] = (2).to_bytes(4, "little")
else:
raise AssertionError(f"unexpected command {cmd:#x}")
def test_does_not_fall_back_to_another_gpu_when_pci_is_missing() -> None:
pci = PciLocation(domain=1, bus=0x0D, dev=0, func=0)
def query(cmd: int, data: bytearray) -> None:
if cmd == _CTRL_GPU_GET_ATTACHED_IDS:
data[0:4] = (0x0D00).to_bytes(4, "little")
elif cmd == _CTRL_GPU_GET_PCI_INFO:
data[8:10] = (0x0D).to_bytes(2, "little")
else:
raise AssertionError("must not allocate a GPU from another PCI domain")
try:
resolve_gpu_instance(pci, query)
check("foreign PCI domain rejected", False)
except RmPowerError:
check("foreign PCI domain rejected", True)
def test_rejects_nonzero_pci_function() -> None:
pci = PciLocation(domain=0, bus=0x0D, dev=0, func=1)
try:
resolve_gpu_instance(pci, lambda cmd, data: None)
check("nonzero function rejected", False)
except RmPowerError:
check("nonzero function rejected", True)
def test_propagates_rm_query_failure() -> None:
pci = PciLocation(domain=0, bus=0x0D, dev=0, func=0)
try:
resolve_gpu_instance(
pci, lambda cmd, data: (_ for _ in ()).throw(RmPowerError("RM unavailable"))
)
check("RM query failure propagated", False)
except RmPowerError as exc:
check("RM query failure propagated", "RM unavailable" in str(exc))
def main() -> int:
tests = [
test_detects_both_layouts_with_gets_without_a_driver_version,
test_unknown_layout_and_nvml_mismatches_never_write,
test_rejects_unrecognized_headers_masks_and_client_values,
test_changes_only_fe_request_and_keeps_vbios_maximum,
test_restores_previous_below_minimum_request_after_set_or_readback_failure,
test_reports_restore_failure_and_rejects_wrong_client_before_writing,
test_resolves_pci_identity_when_minor_and_rm_orders_differ,
test_does_not_fall_back_to_another_gpu_when_pci_is_missing,
test_rejects_nonzero_pci_function,
test_propagates_rm_query_failure,
]
for t in tests:
print(f"== {t.__name__} ==")
t()
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())