blob: 97790886197442d563a5c48ca20cd64d3a4faccf [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test slurmd message forwarding with a tree width below the node count.
Since 23.11 slurmds also forward to dynamic nodes, which they do not know from
slurm.conf, and to CLOUD nodes with no address in slurm.conf, so TreeWidth no
longer has to disable the fanout for them.
"""
import re
import pytest
import atf
# Above the width of the reverse tree that step completions go up (7)
NODE_COUNT = 8
# The job end goes through the first of these nodes to the second one
JOB_END_NODES = 3
CLOUD_NODES = [f"cloud{i}" for i in range(1, NODE_COUNT + 1)]
# Below JOB_END_NODES, so that slurmctld must send through a slurmd, and not 1,
# so that its forwards do not mix with the srun --treewidth=1 ones
TREE_WIDTH = 2
# A slurmd that forwarded may only fail after it replies, so each test runs
# its job twice on the same nodes
RUNS = 2
# The sender logs this when a slurmd did not forward, and then sends to the
# other nodes itself, so the job can still work
FORWARD_ERROR = "failed to forward the message"
# Above the ports the kernel picks for other sockets (32768-60999 by default),
# so that none can take a port before its slurmd binds it
PORT_BASE = 61700
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_nodes(NODE_COUNT)
atf.require_config_parameter("TreeWidth", TREE_WIDTH)
# A forwarding slurmd logs the nodes it forwards the message to
atf.require_config_parameter_includes("DebugFlags", "Route")
# A slurmd logs each RPC it processes at debug2
atf.require_config_parameter(
"SlurmdDebug", ["debug2", "debug3", "debug4", "debug5"]
)
# Both route by switch or partition instead of by tree width
atf.require_config_parameter_excludes("TopologyParam", "RouteTree")
atf.require_config_parameter_excludes("TopologyParam", "RoutePart")
# slurmctld only learns the address of these nodes when they register.
# A NodeName dict replaces every node line, so keep the others.
node_lines = atf.get_config(live=False, quiet=True)["NodeName"]
atf.require_config_parameter(
"NodeName",
node_lines
| {
node: {"State": "CLOUD", "Port": PORT_BASE + NODE_COUNT + i}
for i, node in enumerate(CLOUD_NODES)
},
)
atf.require_config_parameter("ResumeProgram", "/bin/true")
atf.require_config_parameter("SuspendProgram", "/bin/true")
atf.require_config_parameter(
"MaxNodeCount", len(atf.get_nodes(live=False, quiet=True)) + NODE_COUNT
)
# Required for MaxNodeCount
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_slurm_running()
def sorted_nodes(node_list):
"""The nodes in the order Slurm sorts a node list."""
return atf.node_range_to_list(atf.node_list_to_range(node_list))
@pytest.fixture(scope="module", params=["static", "dynamic", "mixed", "cloud"])
def nodes(request):
"""NODE_COUNT static nodes, dynamic nodes, half of each, or CLOUD nodes.
The dynamic nodes sort after the static ones, so that a static slurmd
forwards to a dynamic node.
"""
if request.param == "cloud":
# require_slurm_running() started their slurmds with the others
return sorted_nodes(CLOUD_NODES)
dynamic_count = {"static": 0, "dynamic": NODE_COUNT, "mixed": NODE_COUNT // 2}
dynamic = [f"node{100 + i}" for i in range(dynamic_count[request.param])]
if dynamic:
request.addfinalizer(lambda: remove_dynamic_nodes(dynamic))
for i, node in enumerate(dynamic):
atf.start_slurmd(node, f"-Z -b --conf 'Port={PORT_BASE + i}'")
atf.wait_for_node_state(dynamic, "IDLE", fatal=True)
static = sorted_nodes(
[
node
for node in atf.get_nodes(live=False, fatal=True)
if node != "DEFAULT" and node not in CLOUD_NODES
]
)
return sorted_nodes(static[: NODE_COUNT - len(dynamic)] + dynamic)
def remove_dynamic_nodes(dynamic):
"""Stop the slurmds of the dynamic nodes and delete the nodes."""
errors = []
for node in dynamic:
atf.stop_slurmd(node, quiet=True)
if node in atf.get_nodes(quiet=True):
result = atf.run_command(
f"scontrol delete NodeName={node}",
user=atf.properties["slurm-user"],
quiet=True,
)
if result["exit_code"] != 0:
errors.append(f"Failed to delete node {node}: {result['stderr']}")
if errors:
pytest.fail("; ".join(errors))
@pytest.fixture(scope="module")
def slurmd_logs(nodes):
"""Log file of the slurmd of each node."""
log_file = atf.get_config()["SlurmdLogFile"]
return {node: log_file.replace("%n", node) for node in nodes}
@pytest.fixture(scope="module")
def slurmctld_log():
"""Log file of slurmctld."""
return atf.get_config()["SlurmctldLogFile"]
@pytest.fixture(autouse=True)
def empty_logs(slurmd_logs, slurmctld_log):
"""Empty the logs, so each test reads only its own lines."""
atf.run_command(
f"truncate -s 0 {slurmctld_log} {' '.join(slurmd_logs.values())}",
user="root",
fatal=True,
quiet=True,
)
def grep_log(log_file, text):
"""Lines of a root-owned log file that contain text."""
# grep exits with 1 when no line matches, and with 2 when it fails
return atf.run_command_output(
f"grep -F -- '{text}' {log_file} || [ $? -eq 1 ]",
user="root",
fatal=True,
quiet=True,
)
def forwarded(log_file, width):
"""Node lists that the slurmd of a log file forwarded to with width."""
return re.findall(
rf"hl=(\S+) tree_width {width}$",
grep_log(log_file, "ROUTE: split_hostlist:"),
re.MULTILINE,
)
def run_on_all_nodes(nodes, slurmctld_log, width):
"""Run one task on each node with srun --treewidth=width.
Each node must run its task and be IDLE again after the job, and neither
srun nor slurmctld may fail to forward a message.
"""
result = atf.run_job(
f"-N{len(nodes)} -w {','.join(nodes)} --treewidth={width}"
" printenv SLURMD_NODENAME"
)
assert (
result["exit_code"] == 0
), f"srun --treewidth={width} must succeed: {result['stderr']}"
tasks = sorted(result["stdout"].split())
assert tasks == sorted(nodes), f"each node must run one task, got {tasks}"
assert (
FORWARD_ERROR not in result["stderr"]
), f"srun must not fail to forward: {result['stderr']}"
# The job end must reach each slurmd, so this fails if one of them died.
# A dynamic node is also DYNAMIC_NORM, and a CLOUD node CLOUD.
for _ in atf.timer():
node_states = atf.get_nodes(quiet=True)
not_idle = {
node: node_states[node]["state"]
for node in nodes
if set(node_states[node]["state"]) - {"DYNAMIC_NORM", "CLOUD"} != {"IDLE"}
}
if not not_idle:
break
assert not not_idle, f"each node must be IDLE again after the job, got {not_idle}"
errors = grep_log(slurmctld_log, FORWARD_ERROR)
assert errors == "", f"slurmctld must not fail to forward: {errors}"
def test_srun_treewidth(nodes, slurmd_logs, slurmctld_log):
"""slurmds forward the launch of srun --treewidth=1.
srun sends the launch to the first node only. Each slurmd forwards the
rest of the node list with the same width, so every node but the last one
forwards once per launch.
"""
for _ in range(RUNS):
run_on_all_nodes(nodes, slurmctld_log, 1)
expected = {node: [] for node in nodes}
for i, node in enumerate(nodes[:-1]):
expected[node] = [atf.node_list_to_range(nodes[i + 1 :])] * RUNS
forwards = {node: forwarded(log, 1) for node, log in slurmd_logs.items()}
assert forwards == expected, (
"each slurmd but the last one must forward the launch to the nodes"
f" after it, expected {expected}, got {forwards}"
)
def test_job_end(nodes, slurmd_logs, slurmctld_log):
"""slurmctld sends the job end through a slurmd.
TreeWidth is below the node count, so slurmctld cannot send the job end
to every node itself, and a slurmd must forward it. The launch uses
--treewidth=off, so srun sends it to every node itself.
"""
# Start at the last node before the dynamic ones, so that in the mixed case
# a static slurmd forwards the job end to a dynamic node
states = atf.get_nodes(quiet=True)
dynamic = [
i for i, node in enumerate(nodes) if "DYNAMIC_NORM" in states[node]["state"]
]
start = max(dynamic[0] - 1, 0) if dynamic else 0
nodes = nodes[start : start + JOB_END_NODES]
for _ in range(RUNS):
run_on_all_nodes(nodes, slurmctld_log, "off")
# Each job end makes the first node forward once to the second one, and so
# does each prolog launch with PrologFlags=Alloc
per_job = 2 if atf.config_parameter_includes("PrologFlags", "Alloc") else 1
expected = [nodes[1]] * per_job * RUNS
forwards = forwarded(slurmd_logs[nodes[0]], TREE_WIDTH)
assert forwards == expected, (
f"{nodes[0]} must forward each job end with tree width {TREE_WIDTH},"
f" expected {expected}, got {forwards}"
)
def test_setdebug(nodes, slurmd_logs):
"""scontrol setdebug nodes= reaches each slurmd.
scontrol gets the addresses of the nodes from slurmctld and sends the
request through the slurmds with TreeWidth. The level stays the one in
slurm.conf, so the other tests log the same.
"""
level = atf.get_config_parameter("SlurmdDebug")
result = atf.run_command(
f"scontrol setdebug {level} nodes={atf.node_list_to_range(nodes)}",
user=atf.properties["slurm-user"],
fatal=True,
)
assert (
FORWARD_ERROR not in result["stderr"]
), f"scontrol must not fail to forward: {result['stderr']}"
for node, log_file in slurmd_logs.items():
assert grep_log(
log_file, "REQUEST_SET_DEBUG_LEVEL"
), f"{node} must get the debug level request"
forwards = {node: forwarded(log, TREE_WIDTH) for node, log in slurmd_logs.items()}
assert any(
forwards.values()
), f"a slurmd must forward the request with tree width {TREE_WIDTH}, got {forwards}"
def test_getaddrs(nodes):
"""scontrol getaddrs reports the address and port of each node."""
output = atf.run_command_output(
f"scontrol getaddrs {atf.node_list_to_range(nodes)}", fatal=True
)
# Each line is "<node>: <address>:<port>"
addrs = dict(line.split(": ", 1) for line in output.splitlines())
assert set(addrs) == set(nodes), f"getaddrs must report each node, got {output}"
for node, addr in addrs.items():
address, port = addr.rsplit(":", 1)
assert address not in (
"0.0.0.0",
"[::]",
), f"{node} must be reported with an address, got {addr}"
assert int(port) == atf.get_node_parameter(
node, "port"
), f"{node} must be reported with its port, got {addr}"