blob: 14413136a8b341f1a3131e157fb1b940104dc32d [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""SlurmctldParameters=drain_absent_gres auto-drains a GPU that goes absent.
When a UUID-bearing device GRES (a GPU under NVML/full autodetect) reports fewer
devices than are configured, the default is to mark the whole node INVAL. With
``SlurmctldParameters=drain_absent_gres`` the controller drains the empty index
and keeps the node up. The absent GPU keeps its index. ``DrainedTRES`` names
that index. The automatic ``not_found`` drain is cleared when a device fills
the index. The drain counts toward ``MaxDrainedTres`` for its type.
This uses a STATIC node and restarts *only slurmd* (``atf.start_slurmd``) between
device-set changes. That is deliberate: the controller compares the
registration with the UUID order saved from an earlier registration. That
order is saved with the node state, so a controller restart does not lose it.
"""
import pathlib
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. _reset() binds them
# to slots 0, 1 and 2.
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_INDEX = {GPU_A: 0, GPU_B: 1, GPU_C: 2}
# A replacement GPU, never seen before.
GPU_D = "GPU-dddddddd-3333-3333-3333-333333333333"
GPU_E = "GPU-eeeeeeee-4444-4444-4444-444444444444"
# Auto-drain reason recorded for an absent device (see _slot_drain_absent).
NOT_FOUND = "not_found"
UUID_ORDER_DRAIN = "GPU UUID order does not match the saved order"
@pytest.fixture(scope="module", autouse=True)
def setup():
# Before any node line is written. 26.05 scontrol rejects MaxDrainedTres,
# and require_nodes() asks scontrol to expand the node name.
atf.require_version(
(26, 11),
component="bin/scontrol",
reason="Ticket 25180: SlurmctldParameters=drain_absent_gres added in 26.11",
)
atf.require_version(
(26, 11),
component="sbin/slurmd",
reason="Ticket 25180: GRES device slots and MaxDrainedTres added in 26.11",
)
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_includes("GresTypes", "shard")
atf.require_config_parameter_includes("GresTypes", "bandwidth")
atf.require_config_parameter_includes(
"SelectTypeParameters", "MULTIPLE_SHARING_GRES_PJ"
)
# 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)
# Shards are configured as a count, not per device file: the controller
# then spreads them over the sharing GPUs itself, which is the placement a
# absent GPU has to be kept out of. A File-per-shard gres.conf instead
# makes the reported shard count drop with the GPU, and shared GRES never
# takes part in auto-drain, so the node would go INVAL before any of that.
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))
)
# Typed limit. An absent GPU keeps the type of its slot, so one absent
# a100 stays within it and two exceed it.
atf.require_nodes(
1,
[
("Gres", "gpu:a100:3,shard:300"),
("CPUs", 2),
("MaxDrainedTres", "gres/gpu:a100:1"),
],
)
atf.require_slurm_running()
def _register_slurmd(*devices):
"""Rewrite fake_gpus.conf and restart slurmd, without requiring the node up.
Without drain_absent_gres a registration short of the configured count
leaves the node INVAL, which ``atf.restart_slurmd_with_fake_gpus`` treats
as a failure to settle.
"""
atf.require_config_file("fake_gpus.conf", atf.fake_gpus_conf(*devices))
atf.start_slurmd(NODE)
def _holds_all_gpus(job_id):
"""True when the job's GRES allocation is still IDX:0-2."""
return (
re.search(r"gpu(?::a100)?:3\(IDX:0-2\)", str(atf.get_job(job_id))) is not None
)
def _hold_highest_gpu():
"""Return a running job that holds every GPU, including the highest index.
The node has two CPUs, so three one-GPU jobs cannot all run. One job
asking for all three GPUs holds index 2, which a shrink from three
devices to two would cut off.
"""
_reset()
job_id = atf.submit_job_sbatch(
f"-w {NODE} --gres=gpu:a100:3 --wrap 'sleep infinity'",
fatal=True,
)
assert atf.wait_for_job_state(job_id, "RUNNING", timeout=60), str(
atf.get_job(job_id)
)
assert _holds_all_gpus(job_id), str(atf.get_job(job_id))
for _ in atf.timer(timeout=60, fatal=True):
detail = atf.get_node_detail(NODE) or ""
if re.search(r"GresUsed=gpu:(?:[^:]*:)?3\(IDX:0-2\)", detail):
break
return job_id
@pytest.fixture
def restore_drain_absent_gres():
"""Put drain_absent_gres back where the module has it after the test.
The module needs it in SlurmctldParameters and not in SlurmdParameters.
A test that moves it would otherwise leave the later tests without it.
"""
yield
atf.require_config_parameter_excludes("SlurmdParameters", "drain_absent_gres")
atf.require_config_parameter_includes("SlurmctldParameters", "drain_absent_gres")
def _reset():
"""Restore all three GPUs and clear every drain, leaving the node clean.
Establishes the full [A, B, C] UUID map on the controller (the baseline a
subsequent removal is diffed against) and clears any GRES drain or node DRAIN
a prior test left behind.
"""
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (1, GPU_B), (2, GPU_C))
# All previously-absent devices are present again; clear their drains, then
# undo any node-level DRAIN from an over-limit escalation.
atf.update_tres_drain(NODE, "gres/gpu", "Index=ALL", action="Resume", quiet=True)
atf.run_command(
f"scontrol update NodeName={NODE} State=RESUME",
user=atf.properties["slurm-user"],
quiet=True,
)
# A prior test can leave a different saved order. The first registration
# is then refused, and State=RESUME drops that order. Register again so
# the controller saves [A, B, C].
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (1, GPU_B), (2, GPU_C))
for _ in atf.timer(timeout=60, fatal=True):
f = atf.get_node_tres_drained(NODE)
if f is None:
break
def _uuid_drained(uuid, reason=NOT_FOUND):
"""DrainedTRES lists an absent device by the UUID's saved index.
Adjacent drained indexes share one range, such as gpu(IDX:0-1).
"""
def _pred(detail):
return any(
GPU_INDEX[uuid] in atf.range_to_list(idx) and reason in why
for idx, why in re.findall(
r"gpu\(IDX:([-0-9,]+)\)\(([^)]*)\)", detail or ""
)
)
return _pred
def _wait_drained(predicate):
for _ in atf.timer(timeout=60, fatal=True):
_value = atf.get_node_detail(NODE)
if predicate(_value):
break
def test_absent_gpu_auto_drains_and_node_stays_up():
"""A GPU reported absent is drained in its slot; the node stays up and
DEGRADED (not INVAL/DOWN) with the absent GPU shown in DrainedTRES."""
_reset()
# Drop GPU_B (slot 1); GPU_A and GPU_C remain. Gres stays gpu:a100:3.
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (2, GPU_C))
for _ in atf.timer(timeout=60, fatal=True):
_value = atf.get_node_detail(NODE)
if (_uuid_drained(GPU_B))(_value):
break
state = atf.get_node_field(NODE, "State")
assert "INVAL" not in state, atf.get_node_detail(NODE)
assert "DOWN" not in state, atf.get_node_detail(NODE)
# One absent a100 is within MaxDrainedTres=gres/gpu:a100:1.
assert "DRAIN" not in state, atf.get_node_detail(NODE)
assert "DEGRADED" in state, atf.get_node_detail(NODE)
def test_all_gpus_absent_keep_shards_drained():
"""Losing every sharing GPU keeps the shard layout, every shard drained.
With drain_absent_gres, a registration with no GPU is every GPU absent:
the node stays up and each slot is drained, so the shards on them are
shown drained rather than dropped, ready for when the GPUs come back.
"""
_reset()
assert re.search(
r"shard:a100:0\(0/100,0/100,0/100\)", atf.get_node_detail(NODE)
), atf.get_node_detail(NODE)
_register_slurmd()
for _ in atf.timer(timeout=60, fatal=True):
detail = atf.get_node_detail(NODE) or ""
if "d/100,d/100,d/100" in detail:
break
state = atf.get_node_field(NODE, "State") or ""
assert "INVAL" not in state, atf.get_node_detail(NODE)
def test_gpus_all_absent_drain_only_until_back():
"""A registration with no GPU drains every slot until the GPUs are back.
That is what a GPU driver that failed to load looks like. Each empty slot
is drained as not_found and the node stays up. The drains clear when the
GPUs are reported again, so nothing outlives the absence.
"""
_reset()
_register_slurmd()
_wait_drained(
lambda detail: bool(
detail and re.search(rf"gpu\(IDX:0-2\)\([^)]*{NOT_FOUND}", detail)
)
)
state = atf.get_node_field(NODE, "State") or ""
assert "INVAL" not in state, atf.get_node_detail(NODE)
_register_slurmd((0, GPU_A), (1, GPU_B), (2, GPU_C))
for _ in atf.timer(timeout=60, fatal=True):
if atf.get_node_tres_drained(NODE) is None:
break
def test_auto_drain_is_idempotent_across_reregistration():
"""Re-registering with the same absent GPU does not re-INVAL the node or
double-count the drain."""
_reset()
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (2, GPU_C))
_wait_drained(_uuid_drained(GPU_B))
# Register again with the identical reduced set, waiting for the NEW
# registration to be processed before asserting (else we would read the
# stale first-registration state and the idempotence check is vacuous).
atf.start_slurmd(NODE)
for _ in atf.timer(timeout=60, fatal=True):
s = atf.get_node_field(NODE, "State")
if s is not None and "DEGRADED" in s and ("INVAL" not in s):
break
detail = atf.get_node_detail(NODE)
assert "DOWN" not in atf.get_node_field(NODE, "State"), detail
assert "DRAIN" not in atf.get_node_field(NODE, "State"), detail
# Exactly one device is drained -- no duplicate/second projection.
assert (
atf.get_node_tres_drained(NODE) == f"gpu(IDX:1)(gres/gpu:{NOT_FOUND})"
), detail
@pytest.mark.parametrize(
("devices", "fitting"),
[
(
((0, GPU_B), (1, GPU_A), (2, GPU_C)),
((0, GPU_A), (1, GPU_B), (2, GPU_C)),
),
(
((0, GPU_A), (2, GPU_C), (3, GPU_D)),
((0, GPU_A), (2, GPU_C)),
),
],
ids=["remaining_uuid_moved", "new_uuid_appended_past_hole"],
)
def test_refused_uuid_order_drains_until_a_report_fits(devices, fitting):
"""A moved UUID or an append past a hole drains without changing slots."""
_reset()
if fitting != ((0, GPU_A), (1, GPU_B), (2, GPU_C)):
atf.restart_slurmd_with_fake_gpus(NODE, *fitting)
_wait_drained(_uuid_drained(GPU_B))
_register_slurmd(*devices)
for _ in atf.timer(timeout=60, fatal=True):
detail = atf.get_node_detail(NODE) or ""
state = atf.get_node_field(NODE, "State") or ""
if "DRAIN" in state and UUID_ORDER_DRAIN in detail:
break
assert "INVAL" not in (atf.get_node_field(NODE, "State") or ""), detail
_register_slurmd(*fitting)
for _ in atf.timer(timeout=60, fatal=True):
detail = atf.get_node_detail(NODE) or ""
state = atf.get_node_field(NODE, "State") or ""
if "DRAIN" not in state and "INVAL" not in state:
break
assert UUID_ORDER_DRAIN not in detail, detail
def test_resuming_uuid_order_drain_accepts_replacement_order():
"""State=RESUME drops the refused order for replacement cards."""
_reset()
replacement_order = ((0, GPU_B), (1, GPU_A), (2, GPU_C))
_register_slurmd(*replacement_order)
for _ in atf.timer(timeout=60, fatal=True):
detail = atf.get_node_detail(NODE) or ""
state = atf.get_node_field(NODE, "State") or ""
if "DRAIN" in state and UUID_ORDER_DRAIN in detail:
break
atf.run_command(
f"scontrol update NodeName={NODE} State=RESUME",
user=atf.properties["slurm-user"],
fatal=True,
)
_register_slurmd(*replacement_order)
for _ in atf.timer(timeout=60, fatal=True):
detail = atf.get_node_detail(NODE) or ""
state = atf.get_node_field(NODE, "State") or ""
if "DRAIN" not in state and "INVAL" not in state:
break
assert UUID_ORDER_DRAIN not in detail, detail
def test_resuming_uuid_order_drain_resumes_uuid_drains():
"""State=RESUME of the UUID-order drain resumes the drains by UUID too.
GPU_A is drained by UUID before the refused order. After State=RESUME a
report without GPU_B drains only GPU_B's slot, and GPU_A stays resumed.
"""
_reset()
atf.update_tres_drain(NODE, "gres/gpu", f"Uuid={GPU_A}", reason="admin", fatal=True)
replacement_order = ((0, GPU_B), (1, GPU_A), (2, GPU_C))
_register_slurmd(*replacement_order)
for _ in atf.timer(timeout=60, fatal=True):
detail = atf.get_node_detail(NODE) or ""
state = atf.get_node_field(NODE, "State") or ""
if "DRAIN" in state and UUID_ORDER_DRAIN in detail:
break
atf.run_command(
f"scontrol update NodeName={NODE} State=RESUME",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.restart_slurmd_with_fake_gpus(NODE, *replacement_order)
assert atf.get_node_tres_drained(NODE) is None, atf.get_node_detail(NODE)
# The saved order is now B, A, C, so GPU_B is index 0.
atf.restart_slurmd_with_fake_gpus(NODE, (1, GPU_A), (2, GPU_C))
for _ in atf.timer(timeout=60, fatal=True):
drained = atf.get_node_tres_drained(NODE) or ""
if NOT_FOUND in drained:
break
assert re.fullmatch(r"gpu\(IDX:0\)\([^)]*not_found\)", drained), drained
def test_replacement_gpu_takes_the_slot_and_old_drain_is_dropped():
"""A replacement GPU takes the absent GPU's slot.
Its new UUID is bound to the empty slot. The drain of the old UUID lost
that slot and is dropped, so the node has three undrained GPUs.
"""
_reset()
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (2, GPU_C))
_wait_drained(_uuid_drained(GPU_B))
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (1, GPU_D), (2, GPU_C))
for _ in atf.timer(timeout=60, fatal=True):
f = atf.get_node_tres_drained(NODE)
if f is None:
break
state = atf.get_node_field(NODE, "State")
assert "INVAL" not in state and "DRAIN" not in state, atf.get_node_detail(NODE)
assert "DEGRADED" not in state, atf.get_node_detail(NODE)
job_id = atf.submit_job_sbatch(
f"-w {NODE} --gres=gpu:a100:3 --wrap 'sleep infinity'", fatal=True
)
assert atf.wait_for_job_state(job_id, "RUNNING"), atf.get_node_detail(NODE)
assert _holds_all_gpus(job_id), str(atf.get_job(job_id))
def test_gpu_lost_while_slurmctld_was_down_is_auto_drained():
"""A GPU that disappears while the controller is down is still named.
An absent device is identified by the slot its UUID was bound to, so the
slots are saved with the node state. A controller that came back without
them would see only a device set short of the configured count, with
nothing to say which UUID is gone, and would INVAL the node instead of
draining the device that went away.
"""
_reset()
# Stop slurmd first: the controller must recover the slots from state, not
# from a registration that happens to arrive after it restarts.
atf.stop_slurmd(NODE, quiet=True)
atf.restart_slurmctld(clean=False, quiet=True)
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (2, GPU_C))
for _ in atf.timer(timeout=60, fatal=True):
_value = atf.get_node_detail(NODE)
if (_uuid_drained(GPU_B))(_value):
break
assert "INVAL" not in atf.get_node_field(NODE, "State"), atf.get_node_detail(NODE)
def test_replacing_two_absent_gpus_drops_both_drains():
"""Replacing two absent GPUs drops both drains.
GPU_A and GPU_B are absent and drained in their slots, which is over the
typed MaxDrainedTres limit. GPU_D and GPU_E then take those slots, and
both drains are dropped.
"""
_reset()
atf.restart_slurmd_with_fake_gpus(NODE, (2, GPU_C))
_wait_drained(lambda d: _uuid_drained(GPU_A)(d) and _uuid_drained(GPU_B)(d))
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_D), (1, GPU_E), (2, GPU_C))
for _ in atf.timer(timeout=60, fatal=True):
f = atf.get_node_tres_drained(NODE)
if f is None:
break
state = atf.get_node_field(NODE, "State") or ""
assert "DEGRADED" not in state, atf.get_node_detail(NODE)
def test_absent_gpus_count_toward_their_type_limit():
"""An absent GPU counts toward MaxDrainedTres for the type of its slot.
MaxDrainedTres=gres/gpu:a100:1 allows one absent a100, so two drain the
node without marking it DOWN or INVAL.
"""
_reset()
atf.restart_slurmd_with_fake_gpus(NODE, (2, GPU_C))
for _ in atf.timer(timeout=60, fatal=True):
s = atf.get_node_field(NODE, "State")
if s is not None and "DRAIN" in s and ("DOWN" not in s) and ("INVAL" not in s):
break
drained = atf.get_node_tres_drained(NODE)
assert _uuid_drained(GPU_A)(drained) and _uuid_drained(GPU_B)(drained), drained
def test_absent_gpu_cannot_be_resumed():
"""An empty slot stays drained until a device fills it.
Index= of that slot is refused. Index=ALL and State=RESUME clear a drain
on a GPU that is still there, leave the absent one, and say so.
"""
_reset()
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (2, GPU_C))
_wait_drained(_uuid_drained(GPU_B))
refused = atf.update_tres_drain(NODE, "gres/gpu", "Index=1", action="Resume")
assert refused["exit_code"] != 0, refused
assert "Absent GRES device cannot be resumed" in refused["stderr"], refused
assert "Absent GRES stays drained" not in refused["stderr"], refused
assert _uuid_drained(GPU_B)(atf.get_node_detail(NODE)), atf.get_node_detail(NODE)
held = atf.update_tres_drain(NODE, "gres/gpu", "Index=0", reason="hold")
assert held["exit_code"] == 0, held
cleared = atf.update_tres_drain(NODE, "gres/gpu", "Index=ALL", action="Resume")
assert cleared["exit_code"] == 0, cleared
assert "Absent GRES stays drained" in cleared["stderr"], cleared
assert "index 1" in cleared["stderr"], cleared
detail = atf.get_node_detail(NODE)
assert _uuid_drained(GPU_B)(detail), detail
assert "hold" not in detail, detail
resumed = atf.run_command(
f"scontrol update NodeName={NODE} State=RESUME",
user=atf.properties["slurm-user"],
)
assert resumed["exit_code"] == 0, resumed
assert "Absent GRES stays drained" in resumed["stderr"], resumed
assert "index 1" in resumed["stderr"], resumed
assert _uuid_drained(GPU_B)(atf.get_node_detail(NODE)), atf.get_node_detail(NODE)
def test_absent_slot_uuid_does_not_select_the_empty_slot():
"""Uuid does not name an empty slot. The drain stays."""
_reset()
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (2, GPU_C))
_wait_drained(_uuid_drained(GPU_B))
missed = atf.update_tres_drain(NODE, "gres/gpu", f"Uuid={GPU_B}", action="Resume")
assert missed["exit_code"] != 0, missed
assert "Requested GRES UUID not found on node" in missed["stderr"], missed
assert _uuid_drained(GPU_B)(atf.get_node_detail(NODE)), atf.get_node_detail(NODE)
def test_set_drain_of_a_present_index_leaves_the_absent_index():
"""SetDrain of a present index does not clear an absent index.
scontrol names the index it leaves drained.
"""
_reset()
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (2, GPU_C))
_wait_drained(_uuid_drained(GPU_B))
held = atf.update_tres_drain(NODE, "gres/gpu", "Index=0", reason="hold")
assert held["exit_code"] == 0, held
replaced = atf.update_tres_drain(
NODE, "gres/gpu", "Index=0", action="SetDrain", reason="hold"
)
assert replaced["exit_code"] == 0, replaced
assert "Absent GRES stays drained" in replaced["stderr"], replaced
assert "index 1" in replaced["stderr"], replaced
detail = atf.get_node_detail(NODE)
assert _uuid_drained(GPU_B)(detail), detail
assert re.search(r"gpu\(IDX:0\)\(hold\)", detail), detail
def test_show_drainedtres_names_an_absent_index_and_no_uuid():
"""show drainedtres keeps an empty slot's index and prints no Uuid."""
_reset()
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (2, GPU_C))
_wait_drained(_uuid_drained(GPU_B))
output = atf.run_command_output(
f"scontrol show drainedtres NodeName={NODE}",
user=atf.properties["slurm-user"],
fatal=True,
)
lines = [ln for ln in output.splitlines() if ln.startswith("NodeName=")]
assert len(lines) == 1, output
assert "Index=1" in lines[0], output
assert "Uuid=" not in lines[0], output
def test_admin_drain_reason_survives_drop_and_return():
"""An administratively drained GPU that then goes absent keeps its admin
reason (auto-drain does not clobber it) and its slot."""
_reset()
# Admin-drain GPU_B (present, slot 1) with a distinct reason.
result = atf.update_tres_drain(
NODE, "gres/gpu", f"Uuid={GPU_B}", reason="admin_held"
)
assert result["exit_code"] == 0, result
assert re.search(
r"gpu\(IDX:1\)\(admin_held\)", atf.get_node_detail(NODE)
), atf.get_node_detail(NODE)
# Drop GPU_B: it is absent but must keep the admin reason, not be
# relabeled as an auto "not_found" drain.
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (2, GPU_C))
for _ in atf.timer(timeout=60, fatal=True):
d = atf.get_node_tres_drained(NODE)
if d == "gpu(IDX:1)(admin_held)":
break
assert "INVAL" not in atf.get_node_field(NODE, "State"), atf.get_node_detail(NODE)
# GPU_B returns to its slot, still with the admin reason.
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (1, GPU_B), (2, GPU_C))
for _ in atf.timer(timeout=60, fatal=True):
d = atf.get_node_detail(NODE)
if bool(re.search("gpu\\(IDX:1\\)\\(admin_held\\)", d)):
break
# Clean up so a later run starts fresh.
atf.update_tres_drain(NODE, "gres/gpu", "Index=ALL", action="Resume", quiet=True)
def _visible_gpus(job_id):
"""GPU indexes a 2-GPU step of ``job_id`` is given, from CUDA_VISIBLE_DEVICES.
The step asks for the GPUs itself: a whole-node job is granted every GPU
without requesting any, and a step only inherits the GRES its job requested.
"""
output = atf.run_job_output(
f"--jobid={job_id} --overlap --gres=gpu:2 printenv CUDA_VISIBLE_DEVICES",
fatal=True,
)
return [dev for dev in output.strip().split(",") if dev]
@pytest.mark.parametrize(
"job_args",
["--gres=gpu:2", "--exclusive"],
ids=["gres_gpu_2", "whole_node"],
)
def test_absent_gpu_job_is_bound_to_every_granted_gpu(job_args):
"""With an absent GPU, a job is bound to every GPU it was granted.
The present GPUs keep controller slots 0 and 2. slurmd numbers the two
GPUs it finds 0 and 1, and the copy sent to slurmd uses those indexes.
scontrol keeps 0 and 2. Both the scheduler's pick (--gres) and the
whole-node grant (--exclusive) are checked.
"""
_reset()
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (2, GPU_C))
_wait_drained(_uuid_drained(GPU_B))
job_id = atf.submit_job_sbatch(
f"-w {NODE} {job_args} --wrap 'sleep infinity'",
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), f"job did not run with an absent GPU: {atf.get_node_detail(NODE)}"
job = str(atf.get_job(job_id))
assert re.search(r"gpu(?::a100)?:2\(IDX:0,2\)", job), job
assert sorted(_visible_gpus(job_id)) == ["0", "1"], job
@pytest.fixture
def prolog_epilog(tmp_path):
"""Record each job's CUDA_VISIBLE_DEVICES from Prolog and Epilog.
Yields the directory the scripts write prolog.<jobid> and epilog.<jobid>
to, and unsets both parameters afterwards.
"""
prolog_dir = pathlib.Path(tmp_path)
prolog_dir.chmod(0o777)
for name in ("prolog", "epilog"):
out = f"{prolog_dir}/{name}.$SLURM_JOB_ID"
atf.make_bash_script(
str(prolog_dir / f"{name}.sh"),
"\n".join(
[
f'echo "$CUDA_VISIBLE_DEVICES" > {out}',
f"chmod a+r {out}",
]
),
)
atf.set_config_parameter("Prolog", str(prolog_dir / "prolog.sh"))
atf.set_config_parameter("Epilog", str(prolog_dir / "epilog.sh"))
yield prolog_dir
atf.set_config_parameter("Prolog", None)
atf.set_config_parameter("Epilog", None)
def test_prolog_and_epilog_see_slurmd_indexes_across_a_hole(prolog_epilog):
"""Prolog and epilog are given slurmd's indexes across a hole.
The job is granted controller indexes 0 and 2. slurmd has those GPUs
at 0 and 1, and both scripts record that CUDA_VISIBLE_DEVICES.
"""
_reset()
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (2, GPU_C))
_wait_drained(_uuid_drained(GPU_B))
job_id = atf.submit_job_sbatch(
f"-w {NODE} --gres=gpu:2 --wrap 'sleep infinity'",
fatal=True,
)
assert atf.wait_for_job_state(job_id, "RUNNING", timeout=60), str(
atf.get_job(job_id)
)
job = str(atf.get_job(job_id))
assert re.search(r"gpu(?::a100)?:2\(IDX:0,2\)", job), job
atf.assert_file_contents(str(prolog_epilog / f"prolog.{job_id}"), "0,1")
# The epilog runs when the job is cancelled too.
atf.cancel_jobs([job_id], fatal=True)
atf.assert_file_contents(str(prolog_epilog / f"epilog.{job_id}"), "0,1")
def test_reconfigure_toggles_drain_absent_gres(restore_drain_absent_gres):
"""The controller setting governs, and a reconfigure is enough.
With the token only in SlurmdParameters, an absent GPU makes the node
INVAL ("count reported lower than configured"). Its slot stays bound to
its UUID, so once the token is in SlurmctldParameters the next
registration with the GPU still absent drains it and keeps the node up.
"""
_reset()
atf.require_config_parameter_excludes("SlurmctldParameters", "drain_absent_gres")
atf.require_config_parameter_includes("SlurmdParameters", "drain_absent_gres")
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):
detail = atf.get_node_detail(NODE)
if (
detail is not None
and "INVAL" in detail
and ("lower than configured" in detail)
):
break
atf.require_config_parameter_excludes("SlurmdParameters", "drain_absent_gres")
atf.require_config_parameter_includes("SlurmctldParameters", "drain_absent_gres")
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (2, GPU_C))
_wait_drained(_uuid_drained(GPU_B))
state = atf.get_node_field(NODE, "State")
assert "INVAL" not in state, atf.get_node_detail(NODE)
assert "DEGRADED" in state, atf.get_node_detail(NODE)
def _held_gpu_stays_allocated_across_reregistration():
"""A job holding the highest GPU keeps it when that GPU goes absent.
Without drain_absent_gres the node goes INVAL and the job stays RUNNING.
The registration is refused, so the job and GresUsed still name index 2.
Restoring that GPU and resuming must not hand it to a second job of the
same type while the first job runs.
"""
job_id = _hold_highest_gpu()
_register_slurmd((0, GPU_A), (1, GPU_B))
for _ in atf.timer(timeout=60, fatal=True):
detail = atf.get_node_detail(NODE)
if detail is not None and "INVAL" in detail:
break
assert atf.wait_for_job_state(job_id, "RUNNING"), str(atf.get_job(job_id))
detail = atf.get_node_detail(NODE)
assert re.search(r"GresUsed=gpu:(?:[^:]*:)?3\(IDX:0-2\)", detail), detail
job = str(atf.get_job(job_id))
assert re.search(r"gpu(?::a100)?:3\(IDX:0-2\)", job), job
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (1, GPU_B), (2, GPU_C))
atf.run_command(
f"scontrol update NodeName={NODE} State=RESUME",
user=atf.properties["slurm-user"],
fatal=True,
)
for _ in atf.timer(timeout=60, fatal=True):
state = atf.get_node_field(NODE, "State")
if state is not None and "INVAL" not in state and ("DOWN" not in state):
break
_second_job_pends(job_id)
def _second_job_pends(job_id, idx="0-2"):
"""A one-GPU job pends while job_id holds every GPU index.
``idx`` is job_id's GPU line. An absent GPU keeps its saved index.
"""
job2 = atf.submit_job_sbatch(
f"-w {NODE} --gres=gpu:a100:1 --wrap 'sleep infinity'",
fatal=True,
)
assert not atf.wait_for_job_state(job2, "RUNNING", timeout=20), str(
atf.get_job(job2)
)
assert atf.wait_for_job_state(job2, "PENDING"), str(atf.get_job(job2))
job = str(atf.get_job(job_id))
assert re.search(rf"gpu(?::a100)?:3\(IDX:{idx}\)", job), job
assert atf.wait_for_job_state(job_id, "RUNNING"), "job should reach RUNNING"
def test_held_gpu_is_not_given_to_a_second_job():
"""drain_absent_gres on: an absent held GPU is drained in place.
The node stays up, the job keeps all three GPUs, and no second job is
given the absent one.
"""
job_id = _hold_highest_gpu()
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (1, GPU_B))
_wait_drained(_uuid_drained(GPU_C))
assert atf.wait_for_job_state(job_id, "RUNNING"), str(atf.get_job(job_id))
detail = atf.get_node_detail(NODE)
assert re.search(r"GresUsed=gpu:(?:[^:]*:)?3\(IDX:0-2\)", detail), detail
_second_job_pends(job_id)
def test_step_without_gres_runs_in_a_job_holding_an_absent_gpu():
"""A job that holds an absent GPU can still start a step without GRES.
The job keeps the absent GPU, and the node stays up. A step given no
GRES does not use that GPU, so it runs.
"""
job_id = _hold_highest_gpu()
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (1, GPU_B))
_wait_drained(_uuid_drained(GPU_C))
assert atf.wait_for_job_state(job_id, "RUNNING"), str(atf.get_job(job_id))
detail = atf.get_node_detail(NODE)
assert re.search(r"GresUsed=gpu:(?:[^:]*:)?3\(IDX:0-2\)", detail), detail
state = atf.get_node_field(NODE, "State") or ""
assert not {"DOWN", "DRAIN", "INVAL"} & set(state.split("+")), detail
atf.run_job(f"--jobid={job_id} --overlap --gres=none hostname", fatal=True)
def test_held_gpu_is_not_given_to_a_second_job_without_auto_drain(
restore_drain_absent_gres,
):
"""The same refusal with drain_absent_gres off, the default.
Reconfigure drops the token, as test_reconfigure_toggles_drain_absent_gres
does, then a held GPU that disappears still leaves the node INVAL instead
of shrinking the device bitmap out from under the job.
"""
_reset()
atf.require_config_parameter_excludes("SlurmctldParameters", "drain_absent_gres")
_held_gpu_stays_allocated_across_reregistration()
def test_held_gpu_survives_slurmctld_restart():
"""A slurmctld restart while the held GPU is absent keeps the allocation.
The absent GPU keeps its slot, drained, so the recovered allocation and
GresUsed still name index 2.
"""
job_id = _hold_highest_gpu()
atf.restart_slurmd_with_fake_gpus(NODE, (0, GPU_A), (1, GPU_B))
_wait_drained(_uuid_drained(GPU_C))
atf.restart_slurmctld(clean=False, quiet=True)
assert atf.wait_for_job_state(job_id, "RUNNING", timeout=60), str(
atf.get_job(job_id)
)
for _ in atf.timer(timeout=60, fatal=True):
_value = atf.get_node_detail(NODE)
if re.search(r"GresUsed=gpu:(?:[^:]*:)?3\(IDX:0-2\)", _value or ""):
break
job = str(atf.get_job(job_id))
assert re.search(r"gpu(?::a100)?:3\(IDX:0-2\)", job), job
assert _uuid_drained(GPU_C)(atf.get_node_detail(NODE)), atf.get_node_detail(NODE)
def _wait_inval():
for _ in atf.timer(timeout=60, fatal=True):
state = atf.get_node_field(NODE, "State")
if state is not None and "INVAL" in state:
break
def _first_registration(*devices):
"""Register NODE with devices as the first registration a controller sees.
Restarting slurmctld with clean state forgets the node's earlier device
set. slurmd is stopped first, or it would register the old devices with
the new controller, and the slurmd started after it makes the node's
first registration.
"""
atf.stop_slurmd(NODE)
atf.require_config_file("fake_gpus.conf", atf.fake_gpus_conf(*devices))
atf.restart_slurmctld(clean=True, quiet=True)
atf.start_slurmd(NODE)
def test_absent_gpu_without_uuid_is_not_auto_drained():
"""A device that reports no UUID cannot be auto-drained.
Nothing identifies which device is absent, so the node goes INVAL as
without drain_absent_gres. The controller must never have seen UUIDs for
these devices: losing them would read as every device going absent.
"""
_first_registration((0, "(null)"), (1, "(null)"), (2, "(null)"))
for _ in atf.timer(timeout=60, fatal=True):
state = atf.get_node_field(NODE, "State")
if state is not None and "INVAL" not in state and ("DOWN" not in state):
break
_register_slurmd((0, "(null)"), (2, "(null)"))
_wait_inval()
assert atf.get_node_tres_drained(NODE) is None, atf.get_node_detail(NODE)
def test_gpu_absent_at_first_registration_is_not_auto_drained():
"""A node short at its first registration has nothing to compare against.
With no earlier device set, the controller cannot tell which device is
absent, so the node goes INVAL instead and nothing is drained. The short
report saves no order, so a registration with every device brings the
node back and saves [A, B, C].
"""
_first_registration((0, GPU_A), (2, GPU_C))
_wait_inval()
assert atf.get_node_tres_drained(NODE) is None, atf.get_node_detail(NODE)
_register_slurmd((0, GPU_A), (1, GPU_B), (2, GPU_C))
for _ in atf.timer(timeout=60, fatal=True):
state = atf.get_node_field(NODE, "State")
if state is not None and "INVAL" not in state:
break
assert atf.get_node_tres_drained(NODE) is None, atf.get_node_detail(NODE)
atf.update_tres_drain(NODE, "gres/gpu", f"Uuid={GPU_B}", reason="b", fatal=True)
assert atf.get_node_tres_drained(NODE) == "gpu(IDX:1)(b)", atf.get_node_detail(NODE)
@pytest.fixture
def short_count_only_gres():
"""Configure a count-only GRES of 4 on slurmctld while slurmd reports 2.
slurmd reports the count its slurm.conf had when it started, since it
makes up any count that gres.conf does not describe. So slurmd starts
with 2, and only slurmctld is restarted with 4. The module's Gres is put
back after the test.
"""
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,shard:300,bandwidth:lustre:2")
atf.stop_slurmctld(quiet=True)
atf.set_node_parameter(NODE, "Gres", "gpu:a100:3,shard:300,bandwidth:lustre:4")
atf.start_slurmctld(quiet=True)
yield
atf.stop_slurmd(NODE, quiet=True)
atf.stop_slurmctld(quiet=True)
atf.set_node_parameter(NODE, "Gres", "gpu:a100:3,shard:300")
atf.start_slurmctld(quiet=True)
atf.start_slurmd(NODE, fatal=True)
def test_short_count_only_gres_still_invalidates_the_node(short_count_only_gres):
"""drain_absent_gres covers only devices identified by UUID.
A count-only GRES has no device to drain, so a registration with fewer
of it than configured still marks the node INVAL.
"""
_wait_inval()
detail = atf.get_node_detail(NODE)
assert "gres/bandwidth count reported lower than configured" in detail, detail
@pytest.fixture
def drainable_count_only_gres():
"""Configure a count-only GRES of 4 on the node, as gres.conf has it.
The module's gres.conf and Gres are put back after the test, which also
starts the node's slurmd again.
"""
atf.require_config_file(
"fake_gpus.conf", atf.fake_gpus_conf((0, GPU_A), (1, GPU_B), (2, GPU_C))
)
atf.require_config_file(
"gres.conf",
"Autodetect=full\n"
f"NodeName={NODE} Name=bandwidth Type=lustre Count=4 Flags=CountOnly\n",
)
atf.set_node_parameter(NODE, "Gres", "gpu:a100:3,shard:300,bandwidth:lustre:4")
yield
atf.require_config_file("gres.conf", "Autodetect=full\n")
atf.set_node_parameter(NODE, "Gres", "gpu:a100:3,shard:300")
def test_lowering_a_drained_count_only_gres_clears_degraded(
drainable_count_only_gres,
):
"""A restart that configures a drained count-only GRES away clears DEGRADED.
The drain is cut to the new configured count of 0, so nothing is left
drained. slurmctld starts while the node's slurmd is stopped, so it is
the restart, not a registration, that has to clear DEGRADED, and sinfo -R
must not list the node.
"""
_reset()
atf.update_tres_drain(
NODE, "gres/bandwidth:lustre", "Count=2", reason="bw", fatal=True
)
assert atf.wait_for_node_state(NODE, "DEGRADED"), atf.get_node_detail(NODE)
atf.stop_slurmd(NODE, quiet=True)
atf.stop_slurmctld(quiet=True)
atf.set_node_parameter(NODE, "Gres", "gpu:a100:3,shard:300,bandwidth:lustre:0")
atf.start_slurmctld(quiet=True)
assert atf.get_node_tres_drained(NODE) is None, atf.get_node_detail(NODE)
state = atf.get_node_field(NODE, "State") or ""
assert "DEGRADED" not in state, atf.get_node_detail(NODE)
output = atf.run_command_output(
f"sinfo -R -h -n {NODE} -o '%N'",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
assert NODE not in output.split(), output