blob: 375b478bdbbcb8010f4548130f9406dfa5d4891e [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""GRES chunk configuration edits and validation (gres.conf ChunkSizes).
Each case rewrites gres.conf and restarts only slurmd, which is what an
administrator does after editing a node. Going through set_config_parameter()
would reconfigure the controller too, rebuilding its node state and hiding
what the registration itself decided.
The node has six devices: two of Type plain and four of Type chunked, so drain
checks can keep each Type's declared count and only fail on the chunk rule.
"""
import re
import pytest
import atf
pytestmark = pytest.mark.slow
PLAIN = "Name=gpu Type=plain File=/dev/tty[4-5]"
CHUNKED = "Name=gpu Type=chunked File=/dev/tty[0-3]"
BASELINE = [f"{CHUNKED} ChunkSizes=2", PLAIN]
# Each chunked device given as two files, for the MultipleFiles case
TTY_CNT = 10
node_name = None
@pytest.fixture(scope="module", autouse=True)
def setup():
global node_name
# slurmd parses ChunkSizes/ChunkOrder, slurmctld allocates by them and
# scontrol shows them
for component in ["sbin/slurmd", "bin/scontrol"]:
atf.require_version(
(26, 11),
component,
reason="Issue 51025: gres.conf ChunkSizes/ChunkOrder are new 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_nodes(1, [("CPUs", 8), ("Gres", "gpu:chunked:4,gpu:plain:2")])
for i in range(TTY_CNT):
atf.require_tty(i)
gpu_nodes = sorted(
name
for name, params in atf.get_nodes(live=False, quiet=True, fatal=True).items()
if name != "DEFAULT" and "gpu:chunked:4" in str(params.get("Gres", ""))
)
assert gpu_nodes, "Expected require_nodes() to configure a node with GPUs"
node_name = gpu_nodes[0]
atf.require_config_parameter("NodeName", _body(BASELINE), source="gres")
atf.require_slurm_running()
def _body(records):
return "\nNodeName=".join(f"{node_name} {r}" for r in records)
def _write_gres_conf(records):
atf.run_command(
f"cat > {atf.properties['slurm-config-dir']}/gres.conf",
input=f"NodeName={_body(records)}\n",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
def _stop_slurmd():
"""Stop the node's slurmd, False if none was running."""
if not atf.slurmd_pids(node_name):
return False
atf.stop_slurmd(node_name)
return True
def _reload_slurmd(records):
"""Restart slurmd on the new gres.conf, then let the caller wait.
Each caller polls for the result it expects. Waiting for a registration
here instead can miss one: slurmd_start_time has one second resolution,
so a restart right after the previous start looks unchanged.
"""
_write_gres_conf(records)
assert atf.slurmd_pids(node_name), f"Expected a slurmd for {node_name}"
atf.start_slurmd(node_name, wait="process", quiet=True)
def _all_gres_chunks():
"""Return {gres: (sizes, order)} from scontrol -d, one entry per Type."""
output = atf.run_command_output(
f"scontrol show node -d {node_name}", fatal=True, quiet=True
)
return {
gres: (sizes, order)
for gres, sizes, order in re.findall(
r"([\w:]+):sizes=([^;,\s]+);order=([^;,\s]+)",
(re.search(r"GresChunks=(\S+)", output) or [None, ""])[1],
)
}
def _gres_chunks():
"""Return the chunked Type's sizes and order, or (None, None)."""
return _all_gres_chunks().get("gpu:chunked", (None, None))
def _wait_for_chunks(expected):
"""atf.start_slurmd() returns before the node re-registers, so poll."""
reported = None
for _ in atf.timer():
reported = _gres_chunks()
if reported == expected:
return
pytest.fail(f"Expected GresChunks {expected}, got {reported}")
@pytest.fixture
def restore():
"""Start from and return to a healthy baseline.
The restore goes through a distinct layout first, so it proves a fresh
registration was processed even if the controller kept sizes=2 through a
rejected edit.
"""
atf.wait_for_node_state(node_name, "IDLE", operator=atf.SetMatch.EQUAL, fatal=True)
_wait_for_chunks(("2", "0/1/2/3"))
yield
atf.cancel_all_jobs(fatal=True)
_reload_slurmd([f"{CHUNKED} ChunkSizes=4", PLAIN])
_wait_for_chunks(("4", "0/1/2/3"))
_reload_slurmd(BASELINE)
_wait_for_chunks(("2", "0/1/2/3"))
atf.run_command(
f"scontrol update nodename={node_name} state=resume",
user=atf.properties["slurm-user"],
quiet=True,
)
atf.wait_for_node_state(node_name, "IDLE", operator=atf.SetMatch.EQUAL, fatal=True)
@pytest.mark.parametrize(
"chunk_params, expected",
[
("ChunkSizes=2,4", ("2/4", "0/1/2/3")),
("ChunkSizes=2 ChunkOrder=0,2,1,3", ("2", "0/2/1/3")),
],
ids=["sizes", "order"],
)
def test_edit_is_picked_up_on_slurmd_restart(restore, chunk_params, expected):
"""An edit to the chunk options alone must not be ignored.
Device counts, types and record counts are unchanged, so the controller
has to compare the chunk config itself.
"""
_reload_slurmd([f"{CHUNKED} {chunk_params}", PLAIN])
_wait_for_chunks(expected)
def test_edit_is_picked_up_on_reconfigure(restore):
"""scontrol reconfigure is the other way to apply an edit."""
_write_gres_conf([f"{CHUNKED} ChunkSizes=4", PLAIN])
atf.run_command(
"scontrol reconfigure",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
_wait_for_chunks(("4", "0/1/2/3"))
error = atf.run_command_error(
f"sbatch -N1 -w {node_name} --gres=gpu:chunked:2 --wrap 'true'",
xfail=True,
fatal=True,
)
assert "Requested node configuration is not available" in error, error
def _run(job_args):
job_id = atf.submit_job_sbatch(
f"-N1 -w {node_name} {job_args} --wrap 'sleep infinity'", fatal=True
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), f"Job {job_id} ({job_args}) should reach RUNNING"
return job_id
def test_removal_turns_chunks_off(restore):
_reload_slurmd([CHUNKED, PLAIN])
_wait_for_chunks((None, None))
output = atf.run_command_output(
f"scontrol show node -d {node_name}", fatal=True, quiet=True
)
assert (
"GresChunks" not in output
), "A node without chunks should have no GresChunks line"
job_id = atf.submit_job_sbatch(
f"-N1 -w {node_name} --gres=gpu:chunked:3 --wrap 'true'", fatal=True
)
assert atf.wait_for_job_state(
job_id, "COMPLETED"
), "After removing ChunkSizes, a three device request must run"
def test_chunk_state_of_each_type_survives_slurmctld_restart(restore):
"""Two Types may each set ChunkSizes, and both survive a slurmctld restart.
slurmd is stopped before the restart, so what scontrol reports afterwards
has to come from the node state file. Otherwise the node schedules with
chunks off until slurmd checks back in.
"""
expected = {"gpu:chunked": ("2", "0/1/2/3"), "gpu:plain": ("1/2", "0/1")}
_reload_slurmd([f"{CHUNKED} ChunkSizes=2", f"{PLAIN} ChunkSizes=1,2"])
for _ in atf.timer():
if _all_gres_chunks() == expected:
break
else:
pytest.fail(f"Expected GresChunks {expected}, got {_all_gres_chunks()}")
state = atf.get_node_parameter(node_name, "state")
assert "DRAIN" not in str(state), f"{node_name} drained: {state}"
assert _stop_slurmd(), f"Expected to stop the slurmd for {node_name}"
atf.restart_slurmctld(quiet=True)
restored = _all_gres_chunks()
atf.start_slurmd(node_name, quiet=True)
assert (
restored == expected
), f"GresChunks after a slurmctld restart was {restored}, expected {expected}"
@pytest.mark.parametrize(
"records, diagnostic",
[
([f"{CHUNKED} ChunkSizes=2,3,6", PLAIN], "invalid ChunkSizes 2,3,6"),
([f"{CHUNKED} ChunkSizes=4,2", PLAIN], "invalid ChunkSizes 4,2"),
([f"{CHUNKED} ChunkSizes=abc", PLAIN], "invalid ChunkSizes abc"),
([f"{CHUNKED} ChunkSizes=2,2", PLAIN], "invalid ChunkSizes 2,2"),
([f"{CHUNKED} ChunkSizes=0", PLAIN], "invalid ChunkSizes 0"),
([f"{CHUNKED} ChunkSizes=-2", PLAIN], "invalid ChunkSizes -2"),
(
[f"{CHUNKED} ChunkSizes=2 ChunkOrder=a,b", PLAIN],
"invalid ChunkOrder a,b",
),
(
[
f"{CHUNKED} ChunkSizes=2 "
f"ChunkOrder={','.join(str(i) for i in range(1025))}",
PLAIN,
],
"has too many entries",
),
(
[f"{CHUNKED} ChunkSizes=2 ChunkOrder=0,1,1,3", PLAIN],
"invalid ChunkOrder 0,1,1,3",
),
(
[f"{CHUNKED} ChunkSizes=2 ChunkOrder=4,5,6,7", PLAIN],
"invalid ChunkOrder 4,5,6,7",
),
([f"{CHUNKED} ChunkOrder=0,1,2,3", PLAIN], "ChunkOrder requires ChunkSizes"),
(
["Name=gpu Type=chunked Count=4 ChunkSizes=2", PLAIN],
"ChunkSizes requires File or MultipleFiles",
),
(
[
"Name=gpu Type=chunked File=/dev/tty[0-1] ChunkSizes=2",
"Name=gpu Type=chunked File=/dev/tty[2-3] ChunkSizes=4",
PLAIN,
],
"ChunkSizes and ChunkOrder must match",
),
(
[
"Name=gpu Type=chunked File=/dev/tty[0-1] ChunkSizes=2",
"Name=gpu Type=chunked File=/dev/tty[2-3]",
PLAIN,
],
"ChunkSizes and ChunkOrder must match",
),
(
[
"Name=gpu Type=chunked File=/dev/tty[0-1] ChunkSizes=2 "
"ChunkOrder=0,1,2,3",
"Name=gpu Type=chunked File=/dev/tty[2-3] ChunkSizes=2 "
"ChunkOrder=1,0,2,3",
PLAIN,
],
"ChunkSizes and ChunkOrder must match",
),
],
ids=[
"not_a_multiple",
"not_ascending",
"unparsable",
"repeated_size",
"zero_size",
"negative_size",
"order_unparsable",
"order_too_long",
"order_not_a_permutation",
"order_outside_domain",
"order_without_sizes",
"no_file",
"lines_disagree",
"line_omits_sizes",
"order_lines_disagree",
],
)
def test_invalid_config_stops_slurmd(records, diagnostic):
"""slurmd must log the error and exit rather than register.
slurmd runs in the foreground, since a daemonizing slurmd forks before it
reads gres.conf. order_outside_domain names devices the Type does not
have. line_omits_sizes is a second line of the Type that leaves ChunkSizes
out, which is an error rather than inherited.
"""
_write_gres_conf(records)
assert _stop_slurmd(), f"Expected to stop the slurmd for {node_name}"
result = atf.run_command(
f"timeout 15 {atf.properties['slurm-sbin-dir']}/slurmd -D -N {node_name}",
user="root",
xfail=True,
quiet=True,
)
# Put the node back before asserting, so a failure does not strand it
_write_gres_conf(BASELINE)
atf.start_slurmd(node_name, quiet=True)
assert result["exit_code"] == 1, result
assert diagnostic in result["stderr"], result
@pytest.mark.parametrize(
"records, diagnostic",
[
(
[f"{CHUNKED} ChunkSizes=2,8", PLAIN],
r"ChunkSizes value \(2,8\) is larger than the 4 devices configured",
),
(
[f"{CHUNKED} ChunkSizes=3", PLAIN],
r"(?i)chunk.*divide",
),
(
[f"{CHUNKED} ChunkSizes=2 ChunkOrder=0,1", PLAIN],
r"ChunkOrder value \(0,1\) has 2 entries but 4 devices",
),
],
ids=[
"size_above_domain",
"size_does_not_divide",
"order_length_mismatch",
],
)
def test_unhonourable_config_drains_node(restore, records, diagnostic):
"""The controller drains a node whose chunk layout does not fit its devices.
slurmd only sees gres.conf text, so it starts on all of these.
"""
_reload_slurmd(records)
atf.wait_for_node_state(node_name, "DRAIN", fatal=True)
reason = str(atf.get_node_parameter(node_name, "reason"))
assert re.search(diagnostic, reason), f"Expected {diagnostic!r}, got {reason!r}"
assert (
_all_gres_chunks() == {}
), "A rejected layout should not be reported as GresChunks"
def test_multiple_files_satisfy_the_file_requirement(restore):
"""MultipleFiles gives a chunked device its files as well as File does."""
_reload_slurmd(
[
f"Name=gpu Type=chunked MultipleFiles=/dev/tty{i},/dev/tty{i + 6} "
"ChunkSizes=2,4"
for i in range(4)
]
+ [PLAIN]
)
_wait_for_chunks(("2/4", "0/1/2/3"))
state = atf.get_node_parameter(node_name, "state")
assert "DRAIN" not in str(state), f"{node_name} drained: {state}"
def test_explicit_gres_in_a_whole_node_job(restore):
"""Flags=explicit still only gives what was asked for, in any count.
A whole-node job is exempt from the chunk sizes, so 3 is allowed, and it
gets exactly the devices it requested rather than every device.
"""
_reload_slurmd(
[f"{CHUNKED} ChunkSizes=2,4 Flags=explicit", f"{PLAIN} Flags=explicit"]
)
_wait_for_chunks(("2/4", "0/1/2/3"))
atf.wait_for_node_state(node_name, "IDLE", operator=atf.SetMatch.EQUAL, fatal=True)
job_id = _run("--exclusive --gres=gpu:chunked:3")
indexes = atf.get_job_gres_indexes(job_id)
assert len(indexes) == 3 and indexes <= {
0,
1,
2,
3,
}, f"Expected three chunked devices, got {indexes}"
atf.cancel_jobs([job_id], fatal=True)
job_id = _run("--exclusive")
output = atf.run_command_output(
f"scontrol show job -d {job_id}", fatal=True, quiet=True
)
assert "IDX:" not in output, "A job that asks for no GPU should get none"
@pytest.mark.parametrize("spec", ["--core-spec=1", "--thread-spec=1"])
def test_spec_options_make_a_whole_node_job(spec):
"""With AllowSpecResourcesUsage, --core-spec and --thread-spec are exempt.
The job holds the node to itself, so it gets every device on it, even for
a count that is not a chunk size.
"""
atf.set_config_parameter("AllowSpecResourcesUsage", "YES")
atf.wait_for_node_state(node_name, "IDLE", operator=atf.SetMatch.EQUAL, fatal=True)
job_id = _run(f"{spec} --gres=gpu:chunked:3")
assert atf.get_job_gres_indexes(job_id) == set(
range(6)
), "A whole-node job should receive every device on the node"