blob: 6ddaeec7e0385e07a082c1d2f3f63fe03e724428 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""acct_gather_energy/gpu survives a step node that holds no GPU of its own.
A step may request fewer GPUs than the number of nodes it spans, and step
gres allocation then satisfies the whole request on the first node it picks,
leaving the step's gres record on every other node with no local bitmap.
Before slurmd 26.11 the energy plugin's step filter passed that missing
bitmap to a bit count under DebugFlags=Energy and a slurmd log level of
verbose or above, crashing the slurmstepd at step launch, and since 26.11
it reports that it gathers no power for the step there instead. Two fake
nodes stage the shape hardware-free: each carries a gpu gres backed by a
tty character device, and a two-node step asking for a single GPU takes it
on one node while the other node holds a bitmap-less record. A second step
of the same job asks for no GRES at all, so a guard widened to gpu-less
steps would log the report on both nodes and fail the count.
"""
import os
import re
import pytest
import atf
# Resolved in setup: the two nodes carrying a gpu gres, sorted by name.
gpu_nodes = []
# The report the filter logs on a node whose step record has no bitmap.
NO_BITMAP_REPORT = "Step holds gpus with no allocation bitmap, no power gathering"
# The count line the filter logs on the node that does hold the GPU: set bits
# out of the node's configured GPU count, which setup pins at one. Neither
# report carries a regular expression metacharacter, so each doubles as the
# pattern a log wait greps for.
USABLE_GPUS_REPORT = "usable_gpus = 1 of 1"
@pytest.fixture(scope="module", autouse=True)
def setup():
global gpu_nodes
atf.require_version(
(26, 11),
"sbin/slurmd",
reason=(
"Issue 50982: the energy plugin's no-bitmap step guard was added "
"in 26.11"
),
)
atf.require_config_parameter("SelectType", "select/cons_tres")
# select/cons_tres refuses to start without a CPU, core or socket
# allocation unit, and the select/linear variants carry cr_memory alone,
# which left slurmctld down there. The units are mutually exclusive, so
# an additive include would leave slurm.conf unloadable: a value that
# already names a unit is kept, anything else becomes CR_CPU.
atf.require_config_parameter(
"SelectTypeParameters",
[
"CR_CPU",
"CR_Core",
"CR_Socket",
"CR_CPU_Memory",
"CR_Core_Memory",
"CR_Socket_Memory",
],
)
atf.require_config_parameter_includes("GresTypes", "gpu")
# The filter runs from the plugin the stepd loads for energy, and it
# reports through the Energy debug flag, so the per-node slurmd log is
# its channel. No accounting poll is involved, since the stepd runs the
# filter once at step launch.
atf.require_config_parameter("AcctGatherEnergyType", "acct_gather_energy/gpu")
atf.require_config_parameter_includes("DebugFlags", "Energy")
# Both reports go through log_flag(ENERGY), which prints at the verbose
# level or above, and the unguarded count only ran there too, so the
# module needs SlurmdDebug at verbose or higher. A level that already
# qualifies is kept, a lower one is raised to verbose.
atf.require_config_parameter(
"SlurmdDebug", ["verbose", "debug", "debug2", "debug3", "debug4", "debug5"]
)
atf.require_tty(0)
atf.require_nodes(2, [("Gres", "gpu:1"), ("CPUs", 2)])
# Learn the gres nodes from the written slurm.conf and scope a gres.conf
# record to each, so every slurmd declaring the gpu gres loads it. The
# job runs on the first two of them, and a two-node step asking for one
# GPU leaves exactly one of them without a bitmap, whatever the variant's
# node count.
# get_nodes() indexes the NodeName=DEFAULT stanza separately instead of
# merging it, while require_nodes() above counts a node as qualifying with
# a gres its DEFAULT stanza supplies, so the DEFAULT parameters have to be
# folded in here for the two views to agree. require_nodes() takes gpu:1
# as a floor, while the count line pins "of 1" and the gres.conf written
# below backs one device per node, so every gpu node has to carry exactly
# one untyped gpu, or the module skips.
nodes = atf.get_nodes(live=False, quiet=True)
default_params = nodes.get("DEFAULT", {})
def _gpu_count(params):
merged = dict(default_params)
merged.update(params)
match = re.search(
r"(?:^|,)gpu:(\d+)(?:,|$)",
str(merged.get("Gres") or merged.get("gres") or ""),
)
return int(match.group(1)) if match else 0
gpu_counts = {
name: _gpu_count(params)
for name, params in nodes.items()
if name != "DEFAULT" and _gpu_count(params)
}
all_gpu_nodes = sorted(gpu_counts)
if len(all_gpu_nodes) < 2:
pytest.skip(
f"this test requires two nodes carrying the gpu gres, got {all_gpu_nodes}",
allow_module_level=True,
)
if any(count != 1 for count in gpu_counts.values()):
pytest.skip(
f"this test requires every gpu node at exactly Gres=gpu:1, got {gpu_counts}",
allow_module_level=True,
)
gpu_nodes = all_gpu_nodes[:2]
atf.require_config_file(
"gres.conf",
"".join(f"NodeName={node} Name=gpu File=/dev/tty0\n" for node in all_gpu_nodes),
)
atf.require_slurm_running()
def _slurmd_log_path(node):
"""The per-node slurmd log file the module reads its reports from.
SlurmdLogFile holds the real location, and may carry the %n placeholder
that gives every node of a multiple slurmd host its own log. Auto-config
leaves SlurmdLogFile unset, so the log directory of the controller is the
fallback, the same way atf derives it. conftest fills slurm-logs-dir only
in auto-config mode, so a local config needs that fallback derived here.
"""
log_file = atf.get_config_parameter(
"SlurmdLogFile", default=None, live=False, quiet=True
)
if log_file:
return log_file.replace("%n", node)
if "slurm-logs-dir" not in atf.properties:
atf.properties["slurm-logs-dir"] = os.path.dirname(
atf.get_config_parameter("SlurmctldLogFile", live=False, quiet=True)
)
return f"{atf.properties['slurm-logs-dir']}/slurmd.{node}.log"
def _slurmd_log_size(node):
"""The current byte size of the node's slurmd log.
Recorded before an observation window opens, so a later read scans only
what the daemon appended from there on.
"""
return int(
atf.run_command_output(
f"stat -c %s {_slurmd_log_path(node)}",
user="root",
quiet=True,
fatal=True,
)
)
def _slurmd_log_tail(node, offset):
"""The node's slurmd log from a byte offset recorded earlier.
The daemon logs through an O_APPEND descriptor and a restart reopens the
same file in append mode, so a recorded-offset tail read sees exactly the
lines written from that offset on while preserving the archived log.
"""
return atf.run_command_output(
f"tail -c +{offset + 1} {_slurmd_log_path(node)}",
user="root",
quiet=True,
fatal=True,
)
def _wait_for_slurmd_log(node, pattern, offset):
"""Poll the slurmd log from a byte offset for an extended regular expression.
The grep exit code is the polled condition, so the command is never
fatal, and the pattern is an extended regular expression whose literal
periods and brackets the caller escapes. Only what the daemon appended
from the offset on is scanned, as _slurmd_log_tail describes. The wait is
bounded and never fails by itself: the tests judge the log they get.
"""
command = f"tail -c +{offset + 1} {_slurmd_log_path(node)} | grep -E '{pattern}'"
for _ in atf.timer():
if atf.run_command(command, user="root", quiet=True)["exit_code"] == 0:
return
@pytest.fixture(scope="module")
def step_reports(setup):
"""The slurmd log each node appended while one two-node GPU job ran.
Runs the job once and hands both tests the same observation, since the
pair of reports is one event: the job asks for a GPU on each node so the
step has one to take, and the step spans both nodes while asking for a
single GPU, which is the request that leaves one node bitmap-less. A
second step asks for no GRES and must log neither report. Returns the
job's final state and the per-node log text. The tests own every
assertion, so a regression fails a test instead of erroring here. The
two report lines are the tests' proof that the crash shape was reached,
so a reworded report, or a getter that starts handing this node a bitmap
of its own, has to be mirrored here.
"""
offsets = {node: _slurmd_log_size(node) for node in gpu_nodes}
job_id = atf.submit_job_sbatch(
f"-N2 -w {','.join(gpu_nodes)} --ntasks-per-node=1 --gpus=2 -t1 "
"-J no_bitmap_energy_step "
'--wrap "srun -N2 --ntasks-per-node=1 --gpus=1 --mpi=none sleep 10 '
'&& srun -N2 --ntasks-per-node=1 --gres=none --mpi=none true"',
fatal=True,
)
# The job's own limit is one minute, so a step that hangs at launch, the
# shape of the fixed crash, ends as TIMEOUT within it. The wait outlives
# that limit by far, lag of the limit enforcement included, so the job's
# final state, not this wait, decides the tests.
atf.wait_for_job_state(job_id, "DONE", timeout=180, fatal=True)
job_state = atf.get_job_parameter(job_id, "JobState")
# Each node logs at most one of the two reports for the GPU step, so
# waiting for either one waits out the write without assuming which node
# got the GPU. A node that logs neither is left to the tests to judge.
either = f"{NO_BITMAP_REPORT}|{USABLE_GPUS_REPORT}"
for node, offset in offsets.items():
_wait_for_slurmd_log(node, either, offset=offset)
return {
"job_state": job_state,
"text": {
node: _slurmd_log_tail(node, offset) for node, offset in offsets.items()
},
}
def test_bitmapless_node_reports_no_power_gathering(step_reports):
"""The node left without a local bitmap logs the guard and stays up.
The step spans both nodes and asks for one GPU, so one node resolves the
step's gpu record with no local bitmap. That node runs the filter at step
launch, where the unguarded form crashed the stepd, and reports that it
gathers no power for the step instead.
"""
# A slurmstepd that dies at launch never answers srun, which hangs until
# the job's time limit ends it, so a job that completed is the crash
# regression's contradiction.
assert step_reports["job_state"] == "COMPLETED", (
f"the two-node GPU job must complete, it ended "
f"{step_reports['job_state']}: a slurmstepd crash at step launch leaves "
f"srun hanging until the time limit"
)
reporting = [
node for node, text in step_reports["text"].items() if NO_BITMAP_REPORT in text
]
assert len(reporting) == 1, (
f"exactly one node must log the no-bitmap report {NO_BITMAP_REPORT!r}, "
f"got {reporting!r}"
)
def test_allocated_node_still_filters_by_bitmap(step_reports):
"""The node holding the GPU keeps its normal bitmap-filtered path.
The guard must not swallow the healthy form: the node the step's single
GPU landed on resolves a real bitmap, so the plugin logs the usable count
line there, and it is the other node of the pair.
"""
counting = [
node
for node, text in step_reports["text"].items()
if USABLE_GPUS_REPORT in text
]
assert len(counting) == 1, (
f"exactly one node must log the usable count line {USABLE_GPUS_REPORT!r}, "
f"got {counting!r}"
)
reporting = [
node for node, text in step_reports["text"].items() if NO_BITMAP_REPORT in text
]
assert (
counting != reporting
), f"the counting node and the no-bitmap node must differ, both are {counting!r}"