blob: 1f1d283b605675534624be650e07c3aab51ed02f [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Changing a node's GPU set and its configured Gres count.
drain_absent_gres is left unset. A registration that is short of the
configured count keeps the missing device's index, drains it, and marks the
node INVAL. The automatic drain is cleared when the device returns. Editing
slurm.conf so the count matches the devices that are left, in the saved
order, brings the node back.
A GPU that reports no UUID is not in that saved order. Its drain stays on
the index slurmd reports.
"""
import re
import pytest
import atf
pytestmark = pytest.mark.slow
NODE = "node1"
GPU_A = "GPU-aaaaaaaa-0000-0000-0000-000000000000"
GPU_B = "GPU-bbbbbbbb-1111-1111-1111-111111111111"
GPU_C = "GPU-cccccccc-2222-2222-2222-222222222222"
GPU_D = "GPU-dddddddd-3333-3333-3333-333333333333"
GPU_E = "GPU-eeeeeeee-4444-4444-4444-444444444444"
@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_config_parameter_excludes("SlurmctldParameters", "drain_absent_gres")
# config_overrides would keep a short node up and hide the INVAL a Gres
# edit is supposed to clear.
atf.require_config_parameter_excludes("SlurmdParameters", "config_overrides")
for tty in range(5):
atf.require_tty(tty)
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)])
atf.require_version(
(26, 11),
component="bin/scontrol",
reason="Ticket 25180: stable GPU slots 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 _detail():
return atf.get_node_detail(NODE)
def _state():
return atf.get_node_field(NODE, "State") or ""
def _wait_configured(count):
for _ in atf.timer(timeout=60, fatal=True):
detail = _detail()
if bool(detail and re.search(f"\\bGres=gpu:a100:{count}\\b", detail)):
break
def _resume_node():
atf.run_command(
f"scontrol update NodeName={NODE} State=RESUME",
user=atf.properties["slurm-user"],
quiet=True,
)
def _clear_drains():
atf.update_tres_drain(NODE, "gres/gpu", "Index=ALL", action="Resume", quiet=True)
atf.update_tres_drain(NODE, "gres/gpu", "Uuid=ALL", action="Resume", quiet=True)
def _reset(*devices, count):
"""Register exactly these devices at this configured count, undrained.
slurmd is stopped, then slurmctld is restarted with a clean state, so
this device set is the node's first registration and slots bind in
report order. The controller is stopped before the Gres edit:
set_node_parameter() restarts with saved state while slurmctld is up.
"""
atf.stop_slurmd(NODE, quiet=True)
atf.require_config_file("fake_gpus.conf", atf.fake_gpus_conf(*devices))
atf.stop_slurmctld(quiet=True)
atf.set_node_parameter(NODE, "Gres", f"gpu:a100:{count}")
atf.restart_slurmctld(clean=True, quiet=True)
atf.start_slurmd(NODE)
_clear_drains()
_resume_node()
for _ in atf.timer(timeout=60, fatal=True):
drained = atf.get_node_tres_drained(NODE)
if drained is None:
break
atf.wait_for_node_state(NODE, "IDLE", operator="==", fatal=True)
_wait_configured(count)
def _reported_index(uuid):
"""Return the index slurmd reports for uuid, then resume that drain."""
result = atf.update_tres_drain(NODE, "gres/gpu", f"Uuid={uuid}", reason="pos")
assert result["exit_code"] == 0, result
def _index():
match = re.fullmatch(
r"gpu\(IDX:(\d+)\)\(pos\)", atf.get_node_tres_drained(NODE) or ""
)
return int(match.group(1)) if match else None
for _ in atf.timer(timeout=60, fatal=True):
index = _index()
if index is not None:
break
index = _index()
atf.update_tres_drain(NODE, "gres/gpu", f"Uuid={uuid}", action="Resume", quiet=True)
for _ in atf.timer(timeout=60, fatal=True):
drained = atf.get_node_tres_drained(NODE)
if drained is None:
break
atf.wait_for_node_state(NODE, "IDLE", operator="==", fatal=True)
return index
def _job_gres(count):
"""Run a job that asks for count GPUs and return its detailed scontrol text."""
job_id = atf.submit_job_sbatch(
f"-w {NODE} --gres=gpu:a100:{count} --wrap 'sleep infinity'",
fatal=True,
)
assert atf.wait_for_job_state(job_id, "RUNNING", timeout=60), atf.get_job(job_id)
return atf.run_command_output(
f"scontrol -d show job {job_id}", fatal=True, quiet=True
)
def test_remove_gpu_then_lower_configured_count():
"""Register three GPUs, remove one, then set Gres one lower in slurm.conf.
The short registration marks the node INVAL and drains it. The Gres edit
clears the INVAL. The registration drain stays until State=RESUME. The two
GPUs that remain are reported at indexes 0 and 1, and a job can take both.
"""
_reset((0, GPU_A), (1, GPU_B), (2, GPU_C), count=3)
assert _reported_index(GPU_A) == 0, "GPU_A should be reported at index 0"
assert _reported_index(GPU_B) == 1, "GPU_B should be reported at index 1"
assert _reported_index(GPU_C) == 2, "GPU_C should be reported at index 2"
atf.require_config_file(
"fake_gpus.conf", atf.fake_gpus_conf((0, GPU_A), (1, GPU_B))
)
atf.start_slurmd(NODE)
atf.wait_for_node_state(NODE, "INVALID_REG", fatal=True)
assert "lower than configured" in _detail(), _detail()
atf.set_node_parameter(NODE, "Gres", "gpu:a100:2")
atf.wait_for_node_state(NODE, ["INVALID_REG", "DOWN"], reverse=True, fatal=True)
assert "DRAIN" in _state(), _detail()
_wait_configured(2)
assert atf.get_node_tres_drained(NODE) is None, _detail()
_resume_node()
atf.wait_for_node_state(NODE, "IDLE", operator="==", fatal=True)
assert _reported_index(GPU_A) == 0, "GPU_A should be reported at index 0"
assert _reported_index(GPU_B) == 1, "GPU_B should be reported at index 1"
detail = _job_gres(2)
assert re.search(r"GRES=gpu:a100:2\(IDX:0-1\)", detail), detail
def test_absent_gpu_is_drained_at_its_index_until_it_returns():
"""Without drain_absent_gres, an absent GPU leaves the node INVAL.
The configured count stays 3 and DrainedTRES names the missing GPU's
index. drain_absent_gres keeps the node up instead; this module leaves it
unset. The automatic drain ends when the device returns, with no resume.
"""
_reset((0, GPU_A), (1, GPU_B), (2, GPU_C), count=3)
atf.require_config_file(
"fake_gpus.conf", atf.fake_gpus_conf((0, GPU_A), (2, GPU_C))
)
atf.start_slurmd(NODE, fatal=True)
atf.wait_for_node_state(NODE, "INVALID_REG", fatal=True)
drained = atf.get_node_tres_drained(NODE) or ""
assert "gpu(IDX:1)" in drained, _detail()
assert "not_found" in drained, _detail()
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (1, GPU_B), (2, GPU_C))
assert atf.get_node_tres_drained(NODE) is None, _detail()
def test_removing_the_first_gpu_renumbers_the_ones_slurmd_reports():
"""Dropping GPU_A leaves GPU_B and GPU_C in the order slurmd reports.
Nothing was drained, so the node has no drain record for GPU_A.
"""
_reset((0, GPU_A), (1, GPU_B), (2, GPU_C), count=3)
atf.set_gpu_set(NODE, atf.fake_gpus_conf((1, GPU_B), (2, GPU_C)), 2)
atf.wait_for_node_state(NODE, "IDLE", operator="==", fatal=True)
_wait_configured(2)
assert atf.get_node_tres_drained(NODE) is None, _detail()
assert _reported_index(GPU_B) == 0, "GPU_B should be reported at index 0"
assert _reported_index(GPU_C) == 1, "GPU_C should be reported at index 1"
detail = _job_gres(2)
assert re.search(r"GRES=gpu:a100:2\(IDX:0-1\)", detail), detail
def test_swap_puts_the_new_gpu_in_the_departed_slot():
"""Replacing GPU_B with GPU_D, at the same count, binds GPU_D to slot 1.
GPU_A and GPU_C stay where they were. No drain was recorded, so GPU_B
leaves no drain behind and the node is not DEGRADED.
"""
_reset((0, GPU_A), (1, GPU_B), (2, GPU_C), count=3)
atf.set_gpu_set(NODE, atf.fake_gpus_conf((0, GPU_A), (1, GPU_D), (2, GPU_C)), 3)
atf.wait_for_node_state(NODE, "IDLE", operator="==", fatal=True)
_wait_configured(3)
assert atf.get_node_tres_drained(NODE) is None, _detail()
assert _reported_index(GPU_A) == 0, "GPU_A should be reported at index 0"
assert _reported_index(GPU_D) == 1, "GPU_D should be reported at index 1"
assert _reported_index(GPU_C) == 2, "GPU_C should be reported at index 2"
detail = _job_gres(3)
assert re.search(r"GRES=gpu:a100:3\(IDX:0-2\)", detail), detail
def test_adding_a_gpu_binds_it_to_the_new_slot():
"""Raising Gres from 2 to 3 and registering a new GPU binds that GPU at 2.
GPU_A and GPU_B keep the slots they had at the smaller count.
"""
_reset((0, GPU_A), (1, GPU_B), count=2)
assert _reported_index(GPU_A) == 0, "GPU_A should be reported at index 0"
assert _reported_index(GPU_B) == 1, "GPU_B should be reported at index 1"
atf.set_gpu_set(NODE, atf.fake_gpus_conf((0, GPU_A), (1, GPU_B), (2, GPU_D)), 3)
atf.wait_for_node_state(NODE, "IDLE", operator="==", fatal=True)
_wait_configured(3)
assert atf.get_node_tres_drained(NODE) is None, _detail()
assert _reported_index(GPU_A) == 0, "GPU_A should be reported at index 0"
assert _reported_index(GPU_B) == 1, "GPU_B should be reported at index 1"
assert _reported_index(GPU_D) == 2, "GPU_D should be reported at index 2"
detail = _job_gres(3)
assert re.search(r"GRES=gpu:a100:3\(IDX:0-2\)", detail), detail
def test_excess_detected_gpus_stay_ignored_on_reregistration():
"""A repeat registration still ignores GPUs above the configured count."""
_reset(
(0, GPU_A),
(1, GPU_B),
(2, GPU_C),
(3, GPU_D),
(4, GPU_E),
count=4,
)
_wait_configured(4)
atf.restart_slurmd_with_fake_gpus(
NODE,
(0, GPU_A),
(1, GPU_B),
(2, GPU_C),
(3, GPU_D),
(4, GPU_E),
)
atf.wait_for_node_state(NODE, "IDLE", operator="==", fatal=True)
assert "INVAL" not in _state(), _detail()
def test_uuidless_drop_keeps_the_drain_on_its_index():
"""Without a UUID, lowering the count keeps a drain on its index.
The last device is the one removed, so the drained index is still in
range. The node stays up and DEGRADED, and the undrained GPU is the one
a one-GPU job gets.
"""
_reset((0, "(null)"), (1, "(null)"), (2, "(null)"), count=3)
result = atf.update_tres_drain(NODE, "gres/gpu", "Index=0", reason="low")
assert result["exit_code"] == 0, result
for _ in atf.timer(timeout=60, fatal=True):
drained = atf.get_node_tres_drained(NODE)
if drained == "gpu(IDX:0)(low)":
break
atf.set_gpu_set(NODE, atf.fake_gpus_conf((0, "(null)"), (1, "(null)")), 2)
for _ in atf.timer(timeout=60, fatal=True):
drained = atf.get_node_tres_drained(NODE)
if drained == "gpu(IDX:0)(low)":
break
_wait_configured(2)
state = _state()
assert "INVAL" not in state, _detail()
assert "DEGRADED" in state, _detail()
detail = _job_gres(1)
assert re.search(r"GRES=gpu:a100:1\(IDX:1\)", detail), detail