blob: 8ddc07f4738a6523f64eb6c41502c196508fcaaa [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""A step's GPU frequency setup covers every GRES record the step holds.
A step keeps one device bitmap per typed GRES record, so an untyped request
met by a later record leaves the first record all-zero. Two typed gpu
records over tty devices, each bound to one socket's cores, stage that
shape without GPU hardware: a first step takes the first type's cores, so
the frequency step gets its GPU from the second record. Since slurmd 26.11
the setup gathers every record. With GpuFreqDef unset, a --gpu-freq step in
that shape sets up its one device and a step with no request runs no setup;
with GpuFreqDef set, a step holding both GPUs sets up two.
"""
import re
from pathlib import Path
import pytest
import atf
# The first-requested type, bound to the cores the low step holds, so the
# frequency step's record for it stays all-zero.
empty_type = "ttya"
# The second-requested type, bound to the cores left for the frequency
# step, the record that satisfies its request.
used_type = "ttyb"
# The DebugFlags=Gres line gres_g_step_hardware_init() logs once it has
# gathered a step's devices. It is the only device-count observable without
# a GPU library, so a reword of that log line must be mirrored here.
setup_line = "setting up {count} gres/gpu device(s) allocated to the step on this node"
# The line gpu/generic, the plugin used without AutoDetect, prints to a
# step's stderr when the setup reaches it.
plugin_line = "GpuFreq=control_disabled"
# setup_line without its count, for the step that must never log it.
setup_line_tail = setup_line.partition("{count} ")[2]
@pytest.fixture(scope="module", autouse=True)
def setup():
# Gate on version before any host write, so an old slurmd skips cleanly.
atf.require_version(
(26, 11),
"sbin/slurmd",
reason="Issue 50982: a step's hardware setup gathers every GRES record since 26.11",
)
# The second job needs GpuFreqDef set while the first ran without it.
atf.require_config_parameter("GpuFreqDef", None)
atf.require_config_parameter("SelectType", "select/cons_tres")
# Strict, not _includes: CR_Core next to a variant's CR_CPU is invalid.
atf.require_config_parameter("SelectTypeParameters", "CR_Core")
atf.require_config_parameter_includes("GresTypes", "gpu")
# The setup line is a verbose log_flag(GRES) line in the slurmd log.
atf.require_config_parameter("SlurmdDebug", "verbose")
atf.require_config_parameter_includes("DebugFlags", "Gres")
atf.require_tty(0)
atf.require_tty(1)
# One typed record per socket, since gres.conf(5) wants all the cores of
# a socket together under Cores=. The low step books cores 0-1, so the
# frequency step gets cores 2-3, away from the first type's device.
atf.require_config_file(
"gres.conf",
f"Name=gpu Type={empty_type} Cores=0-1 File=/dev/tty0\n"
f"Name=gpu Type={used_type} Cores=2-3 File=/dev/tty1\n",
)
atf.require_nodes(
1,
[
("Gres", f"gpu:{empty_type}:1,gpu:{used_type}:1"),
("CPUs", 4),
("Sockets", 2),
("CoresPerSocket", 2),
("ThreadsPerCore", 1),
],
)
atf.require_slurm_running()
def _slurmd_logs():
"""The slurmd log files, listed as root since slurmd runs as root."""
slurmd_log_conf = atf.get_config_parameter("SlurmdLogFile", live=False, quiet=True)
if not slurmd_log_conf:
pytest.fail("SlurmdLogFile is not set in slurm.conf")
pattern = re.sub(r"%[hn]", "*", slurmd_log_conf)
log_files = atf.run_command_output(f"ls {pattern}", user="root", quiet=True).split()
if not log_files:
pytest.fail(f"no slurmd log found matching SlurmdLogFile={slurmd_log_conf}")
return log_files
def _slurmd_log_has(needle, step_id, wait=True):
"""True once a line of the given step carries the literal needle.
slurmstepd brackets its lines with the step ID, which scopes the needle
to one step. Without wait the log is read once, for a line that must
never appear. False leaves the failure wording to the caller.
"""
command = (
f"grep -F '{needle}' {' '.join(_slurmd_logs())}"
f" | grep -E '\\[{re.escape(step_id)}( |\\])'"
)
if not wait:
return atf.run_command(command, user="root", quiet=True)["exit_code"] == 0
for _ in atf.timer():
if atf.run_command(command, user="root", quiet=True)["exit_code"] == 0:
return True
return False
def _step_id(step_files, name):
"""The <job>.<step> ID a recorded step wrote for itself, or fail."""
step = atf.run_command_output(f"cat {step_files[f'{name}_step']}", quiet=True)
step_id = f"{step_files[f'{name}_job']}.{step.strip()}"
if not re.fullmatch(r"[0-9]+\.[0-9]+", step_id):
pytest.fail(f"the {name} step left no usable step ID, got {step_id!r}")
return step_id
@pytest.fixture(scope="module")
def step_files():
"""Run the two jobs and return the recorded steps' files and job IDs.
Job 1 runs with GpuFreqDef unset over both GPUs and all four cores. A
background step with no GRES books two cores first and marks a flag
file, so the --gpu-freq step gets the other two cores and the GPU whose
first record is empty; a step with no request then takes the same shape.
Job 2 runs with GpuFreqDef set, one step with no request over both GPUs.
Each recorded step writes its stderr, exit code, GPUs and step ID.
"""
flag_file = "low_step_started"
# What the background low-cores step writes into the flag file.
low_step_mark = "low_step_running"
# Absolute paths: the tests run in per-function directories.
files = {
f"{step}_{item}": str(Path(f"{step}_{item}").absolute())
for step in ("one", "none", "both")
for item in ("stderr", "rc", "gpus", "step")
}
# What a recorded step runs: note its GPUs and its step ID.
record = {
step: f"echo ${{SLURM_STEP_GPUS:-}} > {files[f'{step}_gpus']};"
f" echo $SLURM_STEP_ID > {files[f'{step}_step']}"
for step in ("one", "none", "both")
}
def run_job(batch_file, script):
"""Submit the script over both GPUs and all four cores, return its ID."""
batch_output = f"{batch_file}.out"
atf.make_bash_script(batch_file, script)
job_id = atf.submit_job_sbatch(
f"-N1 -n1 -c4 -t2 -J test_144_20 "
f"--gres=gpu:{empty_type}:1,gpu:{used_type}:1 "
f"-o {batch_output} {batch_file}",
fatal=True,
)
# Pending plus run time; the job's two-minute limit bounds the latter.
if not atf.wait_for_job_state(job_id, "COMPLETED", timeout=130):
batch_log = atf.run_command_output(f"cat {batch_output}", quiet=True)
pytest.fail(f"job {job_id} did not complete, batch output: {batch_log!r}")
return job_id
# The background step sleeps forever rather than a fixed time a slow step
# could outrun; the script kills it once the exit codes are recorded, and
# the job's time limit bounds it anyway.
staged_job = run_job(
"staged.sh",
f"""srun --mpi=none --exact -n1 -c2 --gres=none bash -c 'echo {low_step_mark} > {flag_file} && sleep infinity' &
low_srun_pid=$!
for i in $(seq 1 120); do
if [ -e {flag_file} ]; then
break
fi
sleep 0.5
done
if [ ! -e {flag_file} ]; then
echo "the background low-cores step never started" >&2
exit 1
fi
srun --mpi=none --exact -n1 -c2 --gres=gpu:1 --gpu-freq=low bash -c '{record['one']}' 2> {files['one_stderr']}
echo $? > {files['one_rc']}
srun --mpi=none --exact -n1 -c2 --gres=gpu:1 bash -c '{record['none']}' 2> {files['none_stderr']}
echo $? > {files['none_rc']}
kill $low_srun_pid 2> /dev/null
wait
exit 0
""",
)
files["one_job"] = staged_job
files["none_job"] = staged_job
# slurmd re-reads slurm.conf on reconfigure and hands every new stepd its
# configuration; ATF restores the file after the module.
atf.set_config_parameter("GpuFreqDef", "low")
files["both_job"] = run_job(
"default.sh",
f"""srun --mpi=none --exact -n1 -c4 --gres=gpu:2 bash -c '{record['both']}' 2> {files['both_stderr']}
echo $? > {files['both_rc']}
exit 0
""",
)
return files
@pytest.mark.parametrize("step", ["one", "none", "both"])
def test_steps_complete(step_files, step):
"""Every recorded step exits 0: the setup never fails a step."""
atf.assert_file_contents(
step_files[f"{step}_rc"], "0", message=f"the {step} step should exit 0"
)
def test_setup_covers_the_later_record(step_files):
"""The --gpu-freq step whose first record is empty sets up its one device.
SLURM_STEP_GPUS names the second type's device, the staging premise, and
the slurmd log shows one gathered device for the step. GpuFreqDef is
unset for that job, so the option alone brings the step to the setup.
"""
atf.assert_file_contents(
step_files["one_gpus"],
"1",
message="staging premise: the --gpu-freq step must get its GPU from the"
" second gres.conf record (SLURM_STEP_GPUS=1), or the empty-first-record"
" shape is no longer staged and this module needs re-staging",
)
needle = setup_line.format(count=1)
step = _step_id(step_files, "one")
assert _slurmd_log_has(
needle, step
), f"expected {needle!r} for step {step} in the slurmd log"
def test_no_request_runs_no_setup(step_files):
"""A step with no frequency request in the same shape runs no setup.
With GpuFreqDef unset and no --gpu-freq the setup is never called: no
gathered-device line for the step, no gpu/generic line in its stderr. A
line every step logs is checked first, so an absence means the setup line
was never written, not that the log or the step ID went unread.
"""
atf.assert_file_contents(
step_files["none_gpus"],
"1",
message="staging premise: the no-request step must get its GPU from the"
" second gres.conf record (SLURM_STEP_GPUS=1), or the empty-first-record"
" shape is no longer staged and this module needs re-staging",
)
step = _step_id(step_files, "none")
assert _slurmd_log_has(
"starting 1 tasks", step
), f"no slurmstepd line for step {step} in the slurmd log"
assert not _slurmd_log_has(
setup_line_tail, step, wait=False
), f"unexpected {setup_line_tail!r} for step {step} in the slurmd log"
stderr = atf.run_command_output(
f"cat {step_files['none_stderr']}", quiet=True, fatal=True
)
assert (
plugin_line not in stderr
), f"the no-request step's stderr should not carry {plugin_line}, got {stderr!r}"
def test_gpu_freq_def_covers_both_records(step_files):
"""A GpuFreqDef step holding both GPUs sets up two devices.
The step requests no frequency and its job has GpuFreqDef set, so the
default brings it to the setup, which gathers its two records.
"""
atf.assert_file_contents(
step_files["both_gpus"],
"0,1",
message="the GpuFreqDef step should hold both devices",
)
needle = setup_line.format(count=2)
step = _step_id(step_files, "both")
assert _slurmd_log_has(
needle, step
), f"expected {needle!r} for step {step} in the slurmd log"
@pytest.mark.parametrize("step", ["one", "both"])
def test_setup_reaches_the_plugin_once(step_files, step):
"""Each frequency step's stderr carries the gpu/generic line once.
gpu/generic prints the line once per call, so one line per step pins the
single call over the union of the step's records, on purpose. The fixture
waited for the job to complete, so the file is read once: polling for a
count of one would break on the very transient a second line arrives
after, which is the shape this pins against.
"""
stderr = atf.run_command_output(f"cat {step_files[f'{step}_stderr']}", quiet=True)
assert (
stderr.count(plugin_line) == 1
), f"the {step} step's stderr should carry {plugin_line} exactly once, got {stderr!r}"