blob: 3b0d87c21b02616943ec7385cc0c0198f152b8dd [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""GPUs that report no UUID are numbered when the controller builds the node.
A device without a UUID has no saved UUID order. Its index is its place in the
report, and slurmd reports its GPUs in device file order. Removing the first
GPU and lowering Gres, adding one in the middle, and moving the GPU types to
other device files at the same count all follow that order. The index is
read from a job, which is how this numbering is seen without a per-device
drain. The job's step has to run on a device of the type the job asked for,
so a controller numbering that is not slurmd's shows up as the wrong device.
Devices that report a UUID keep the order slurmctld saved. An index whose
device is absent stays empty. That order is covered by test_190_17.
"""
import re
import pytest
import atf
pytestmark = pytest.mark.slow
NODE = "node1"
# tty, type, uuid. The tty is not the index: after a device is removed, the
# next device keeps its tty and takes the lower index.
A100 = (0, "a100", "(null)")
V100 = (1, "v100", "(null)")
H100 = (2, "h100", "(null)")
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 5),
component="sbin/slurmd",
reason="Issue 50653: gres.conf Autodetect=full added in 26.05",
)
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_config_parameter_includes("GresTypes", "gpu")
# A short registration has to mark the node invalid. Either of these would
# keep it up and hide the Gres edit that follows.
atf.require_config_parameter_excludes("SlurmctldParameters", "drain_absent_gres")
atf.require_config_parameter_excludes("SlurmdParameters", "config_overrides")
atf.require_tty(0)
atf.require_tty(1)
atf.require_tty(2)
atf.require_config_file("gres.conf", "Autodetect=full\n")
atf.require_config_file("fake_gpus.conf", atf.fake_gpus_conf(A100, V100, H100))
atf.require_nodes(1, [("Gres", _gres(A100, V100, H100)), ("CPUs", 2)])
atf.require_slurm_running()
def _gres(*devices):
"""Gres= value whose per-type counts match ``devices``."""
counts = {}
order = []
for _tty, gpu_type, _uuid in devices:
if gpu_type not in counts:
order.append(gpu_type)
counts[gpu_type] = 0
counts[gpu_type] += 1
return ",".join(f"gpu:{gpu_type}:{counts[gpu_type]}" for gpu_type in order)
def _on_tty(tty, device):
"""The same GPU type, backed by another /dev/tty."""
return (tty, device[1], device[2])
def _detail():
return atf.get_node_detail(NODE)
def _gres_matches(detail, devices):
match = re.search(r"\bGres=(\S+)", detail or "")
if not match:
return False
return set(match.group(1).split(",")) == set(_gres(*devices).split(","))
def _wait_gres(*devices):
for _ in atf.timer(timeout=60, fatal=True):
if _gres_matches(_detail(), devices):
break
def _resume():
atf.run_command(
f"scontrol update NodeName={NODE} State=RESUME",
user=atf.properties["slurm-user"],
quiet=True,
)
def _reset(*devices):
"""Register exactly these devices, as the node's first registration.
slurmd is stopped, then slurmctld is restarted with a clean state, so
nothing saved from the previous device set can number these. 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", _gres(*devices))
atf.restart_slurmctld(clean=True, quiet=True)
atf.start_slurmd(NODE)
_resume()
atf.wait_for_node_state(NODE, "IDLE", operator="==", fatal=True)
_wait_gres(*devices)
def _index(gpu_type, devices):
"""Index a one-GPU job of ``gpu_type`` is given.
``devices`` is the fake_gpus.conf slurmd runs with. slurmd numbers its
GPUs in device file order and puts that number in SLURM_STEP_GPUS, so the
number names the device the job's step was bound to. That device has to
be a ``gpu_type``.
"""
job_id = atf.submit_job_sbatch(
f"-w {NODE} --gres=gpu:{gpu_type}:1 --wrap 'sleep infinity'",
fatal=True,
)
try:
assert atf.wait_for_job_state(job_id, "RUNNING", timeout=60), atf.get_job(
job_id
)
out = atf.run_command_output(
f"scontrol -d show job {job_id}", fatal=True, quiet=True
)
step_gpus = atf.run_job_output(
f"--jobid={job_id} --overlap --gres=gpu:{gpu_type}:1 "
"printenv SLURM_STEP_GPUS",
fatal=True,
).strip()
finally:
atf.cancel_jobs([job_id], fatal=True, quiet=True)
match = re.search(rf"GRES=gpu:{gpu_type}:1\(IDX:(\d+)\)", out)
assert match, out
tty, bound_type, _uuid = sorted(devices)[int(step_gpus)]
assert (
bound_type == gpu_type
), f"the {gpu_type} job's step was bound to /dev/tty{tty}, a {bound_type}"
return int(match.group(1))
def _indexes(devices, **expected):
for gpu_type, index in expected.items():
assert _index(gpu_type, devices) == index, _detail()
def test_remove_first_gpu_then_lower_configured_count():
"""Register three GPUs, remove the first, then set Gres one type lower.
The short registration marks the node invalid and drains it. The Gres
edit clears the invalid flag. The registration drain stays until
State=RESUME. The two GPUs that remain are numbered from the new
report, so each takes the index below the one it had.
"""
_reset(A100, V100, H100)
_indexes((A100, V100, H100), a100=0, v100=1, h100=2)
atf.require_config_file("fake_gpus.conf", atf.fake_gpus_conf(V100, H100))
atf.start_slurmd(NODE)
atf.wait_for_node_state(NODE, "INVALID_REG", fatal=True)
assert "lower than configured" in (_detail() or ""), _detail()
atf.set_node_parameter(NODE, "Gres", _gres(V100, H100))
atf.wait_for_node_state(NODE, ["INVALID_REG", "DOWN"], reverse=True, fatal=True)
assert "DRAIN" in (atf.get_node_field(NODE, "State") or ""), _detail()
_wait_gres(V100, H100)
_resume()
atf.wait_for_node_state(NODE, "IDLE", operator="==", fatal=True)
_indexes((V100, H100), v100=0, h100=1)
def test_same_count_swap_follows_the_report():
"""Two GPU types swapping device files swap indexes too.
The configured count stays 3. Only slurmd restarts. The v100 is now on
the first device file, so slurmd reports it first, and the controller
numbers it 0.
"""
# 26.05 keeps the old type on each index when the count does not change.
atf.require_version(
(26, 11),
component="sbin/slurmctld",
reason="a same-count GPU type change rebuilds the topology in 26.11",
)
_reset(A100, V100, H100)
_indexes((A100, V100, H100), a100=0, v100=1, h100=2)
swapped = (_on_tty(0, V100), _on_tty(1, A100), H100)
atf.restart_slurmd_with_fake_gpus(NODE, *swapped)
atf.wait_for_node_state(NODE, "IDLE", operator="==", fatal=True)
_indexes(swapped, a100=1, v100=0, h100=2)
def test_adding_a_gpu_in_the_middle_shifts_later_indexes():
"""A GPU inserted into the report takes that index and shifts the rest.
The device that was last is registered on the same tty, at the new
high index.
"""
_reset(A100, H100)
_indexes((A100, H100), a100=0, h100=1)
atf.require_config_file("fake_gpus.conf", atf.fake_gpus_conf(A100, V100, H100))
atf.set_node_parameter(NODE, "Gres", _gres(A100, V100, H100))
_resume()
atf.wait_for_node_state(NODE, "IDLE", operator="==", fatal=True)
_wait_gres(A100, V100, H100)
_indexes((A100, V100, H100), a100=0, v100=1, h100=2)
def test_same_count_reorder_follows_the_report():
"""Every GPU type moving to another device file is renumbered.
The configured count stays 3. Only slurmd restarts. Reversing the device
files reverses the order slurmd reports the types in, and the indexes
with it.
"""
# 26.05 keeps the old type on each index when the count does not change.
atf.require_version(
(26, 11),
component="sbin/slurmctld",
reason="a same-count GPU type change rebuilds the topology in 26.11",
)
_reset(A100, V100, H100)
_indexes((A100, V100, H100), a100=0, v100=1, h100=2)
reordered = (_on_tty(0, H100), _on_tty(1, V100), _on_tty(2, A100))
atf.restart_slurmd_with_fake_gpus(NODE, *reordered)
atf.wait_for_node_state(NODE, "IDLE", operator="==", fatal=True)
_indexes(reordered, a100=2, v100=1, h100=0)