blob: 86c377ea6b3c0f93921bf045a02dc052fd574814 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""A drained GPU that leaves the node stays identified in its accounting event.
The event table records every drained TRES in TresReference. An absent device
keeps its index. A drain whose device was configured away is dropped, so the
event stops naming it.
This uses a STATIC node. Configuring a drained GPU away requires reducing the node's
configured GRES and its fake_gpus.conf together (so the node is not marked
INVALID) while preserving the controller's node record, which holds the
saved UUID order. atf.set_node_parameter() restarts Slurm with
clean=False and does exactly that.
"""
import re
import pytest
import atf
pytestmark = pytest.mark.slow
NODE = "node1" # require_nodes(1, ...) yields node1
# Fake GPU device UUIDs, in fake_gpus.conf device order.
GPU_A = "GPU-aaaaaaaa-0000-0000-0000-000000000000"
GPU_B = "GPU-bbbbbbbb-1111-1111-1111-111111111111"
GPU_C = "GPU-cccccccc-2222-2222-2222-222222222222"
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_config_parameter_includes("GresTypes", "gpu")
atf.require_accounting()
atf.require_config_parameter_includes("AccountingStorageTRES", "gres/gpu")
# Auto-drain a UUID-bearing device instead of downing the node when short.
atf.require_config_parameter_includes("SlurmctldParameters", "drain_absent_gres")
# Auto-drain works with config_overrides too (test_190_19), but it makes
# the controller skip checking reported devices against slurm.conf, and
# these tests cover the default. ATF enables it by default. Drop that
# token and keep any other SlurmdParameters instead of replacing the list.
atf.require_config_parameter_excludes("SlurmdParameters", "config_overrides")
atf.require_tty(0)
atf.require_tty(1)
atf.require_tty(2)
atf.require_tty(3)
atf.require_config_file("gres.conf", "Autodetect=full\n")
atf.require_config_file(
"fake_gpus.conf", atf.fake_gpus_conf((0, GPU_A), (1, GPU_B), (2, GPU_C))
)
atf.require_nodes(1, [("Gres", "gpu:a100:3"), ("CPUs", 2)])
# get_node_events() queries the TresReference event format field, which only exists
# in 26.11 sacctmgr; an older sacctmgr exits 1 on an unknown field.
atf.require_version(
(26, 11),
component="bin/sacctmgr",
reason="Ticket 25180: TresReference event format field added in 26.11",
)
atf.require_version(
(26, 11),
component="sbin/slurmd",
reason="Ticket 25180: GRES device slots keyed by UUID added in 26.11",
)
atf.require_slurm_running()
def _clear_gpu_drains():
"""Resume every gpu drain, with every GPU present.
Index=ALL leaves a device that is absent drained, so this only clears
everything once the GPUs are back. A GPU that drain_absent_gres drained
and that has returned is resumed like any other. A registration still in
flight can drain again, so keep resuming until nothing remains.
"""
def _cleared():
result = atf.update_tres_drain(
NODE, "gres/gpu", "Index=ALL", action="Resume", quiet=True
)
assert result["exit_code"] == 0, result
return not (atf.get_node_tres_drained(NODE) or "")
for _ in atf.timer(timeout=60, fatal=True):
v = _cleared()
if v:
break
def _restore_three_uuid_gpus():
atf.require_config_file(
"fake_gpus.conf", atf.fake_gpus_conf((0, GPU_A), (1, GPU_B), (2, GPU_C))
)
atf.set_node_parameter(NODE, "Gres", "gpu:a100:3")
# A test can leave a different saved order. The report above is then
# refused, and State=RESUME drops that order. Register again so the
# controller saves [A, B, C].
atf.run_command(
f"scontrol update NodeName={NODE} State=RESUME",
user=atf.properties["slurm-user"],
quiet=True,
)
atf.start_slurmd(NODE)
_clear_gpu_drains()
@pytest.fixture(autouse=True)
def restore_gpus(setup):
"""Leave every test with three UUID GPUs and nothing drained.
These cases shrink Gres= and drop a drain, drain by UUID, or auto-drain
an absent device.
Without a function-scoped restore, leftover drains and GPU sets leak into
the next test (and into later files if this one is not last).
"""
yield
# function_setup cancels jobs only after this teardown, and the restore
# restarts the controller, so do not leave a job holding the GPUs.
atf.cancel_all_jobs(fatal=True, quiet=True)
_restore_three_uuid_gpus()
def test_configured_away_drain_leaves_the_event():
"""A drain whose device was configured away is dropped from TresReference."""
# Devices A@0, B@1, C@2. Drain two of them by UUID.
result = atf.update_tres_drain(
NODE, "gres/gpu", f"Uuid={GPU_A},{GPU_B}", reason="rma"
)
assert result["exit_code"] == 0, result
present = (
f"gres/gpu:a100(IDX:0)(UUID:{GPU_A})," f"gres/gpu:a100(IDX:1)(UUID:{GPU_B})"
)
for _ in atf.timer(timeout=60, fatal=True):
u = atf.get_open_node_event_tres_reference(NODE)
if u == present:
break
assert re.search(
r"(?:^|,)gres/gpu=2(?:,|$)", atf.get_open_node_event_tres(NODE) or ""
), f"both drained GPUs should be counted: {atf.get_node_events(NODE)!r}"
# Pull GPU_B and configure two GPUs. GPU_C takes the slot GPU_B held, so
# GPU_B's drain is dropped. GPU_A stays drained and the event stays open
# naming only GPU_A.
atf.set_gpu_set(NODE, atf.fake_gpus_conf((0, GPU_A), (2, GPU_C)), 2)
kept = f"gres/gpu:a100(IDX:0)(UUID:{GPU_A})"
for _ in atf.timer(timeout=60, fatal=True):
u = atf.get_open_node_event_tres_reference(NODE)
if u == kept:
break
assert GPU_B not in (atf.get_node_tres_drained(NODE) or ""), atf.get_node_detail(
NODE
)
# GPU_B holds no slot, so the event counts only GPU_A.
assert re.search(
r"(?:^|,)gres/gpu=1(?:,|$)", atf.get_open_node_event_tres(NODE) or ""
), f"dropped GPU_B should not be counted: {atf.get_node_events(NODE)!r}"
# GPU_B returns after GPU_C and binds the new slot. The dropped drain does
# not come back, so the two undrained GPUs can run a job and the event
# still names only A.
atf.set_gpu_set(NODE, atf.fake_gpus_conf((0, GPU_A), (2, GPU_C), (3, GPU_B)), 3)
job_id = atf.submit_job_sbatch(
f"-w {NODE} --gres=gpu:a100:2 --wrap 'sleep infinity'", fatal=True
)
assert atf.wait_for_job_state(
job_id, "RUNNING", timeout=60
), f"GPU_B did not come back undrained: {atf.get_node_detail(NODE)}"
for _ in atf.timer(timeout=60, fatal=True):
u = atf.get_open_node_event_tres_reference(NODE)
if u == kept:
break
assert re.search(
r"(?:^|,)gres/gpu=1(?:,|$)", atf.get_open_node_event_tres(NODE) or ""
), f"GPU_B should stay uncounted: {atf.get_node_events(NODE)!r}"
def test_auto_drained_gpu_is_named_in_tres_reference():
"""drain_absent_gres records the empty slot's index, not a UUID.
The slot is the one GPU_B is bound to, which earlier tests may have moved.
The slot is empty, so the event names only that index.
"""
atf.require_config_file(
"fake_gpus.conf", atf.fake_gpus_conf((0, GPU_A), (2, GPU_C))
)
atf.start_slurmd(NODE)
for _ in atf.timer(timeout=60, fatal=True):
u = atf.get_open_node_event_tres_reference(NODE)
if bool(re.fullmatch(r"gres/gpu:a100\(IDX:\d+\)", u or "")):
break
def test_uuidless_index_slide_matches_drainedtres():
"""Shrinking below a UUID-less drained index drops that drain from the event.
_gres_bit_drain_resize() discards high bits when Gres= shrinks, and
TresReference must agree with DrainedTRES afterwards.
"""
atf.require_config_file(
"fake_gpus.conf",
atf.fake_gpus_conf((0, "(null)"), (1, "(null)"), (2, "(null)")),
)
atf.set_node_parameter(NODE, "Gres", "gpu:a100:3")
# A registration with no UUID map drops an auto-drain of an empty index.
# Resume anything still listed so the index drain below is the only one.
_clear_gpu_drains()
result = atf.update_tres_drain(NODE, "gres/gpu", "Index=0,2", reason="slide")
assert result["exit_code"] == 0, result
for _ in atf.timer(timeout=60, fatal=True):
u = atf.get_open_node_event_tres_reference(NODE)
if u == "gres/gpu:a100(IDX:0,2)":
break
atf.set_gpu_set(NODE, atf.fake_gpus_conf((0, "(null)"), (1, "(null)")), 2)
for _ in atf.timer(timeout=60, fatal=True):
u = atf.get_open_node_event_tres_reference(NODE)
if u == "gres/gpu:a100(IDX:0)":
break
drained = atf.get_node_tres_drained(NODE) or ""
assert re.search(
r"gpu\(IDX:0\)", drained
), f"DrainedTRES should keep index 0: {atf.get_node_detail(NODE)}"
assert not re.search(
r"gpu\(IDX:[^)]*2", drained
), f"DrainedTRES should have dropped index 2: {atf.get_node_detail(NODE)}"