blob: 226acfd1427e07fa6f2cfc630fecc02ac8293acb [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved
############################################################################
import os
import re
import pytest
import atf
register_order = ["b2", "a2", "b1", "a1"]
alpha_order = ["a1", "a2", "b1", "b2"]
node_list_arg = ",".join(alpha_order)
nnodes = len(register_order)
# Distinct CPU counts make the per-node run-length arrays order sensitive: a
# uniform array reads the same whatever order it was built in.
node_cpus = {"b2": 1, "a2": 2, "b1": 3, "a1": 4}
total_cpus = sum(node_cpus.values())
# A dynamic node registering with more CPUs than the machine has is drained as
# "Low CPUs", so fall back to one CPU per node and skip the tests that read the
# per-node arrays. The ordering tests do not care how many CPUs a node has.
host_cpus = os.cpu_count() or 1
uneven_cpus = host_cpus >= max(node_cpus.values())
needs_uneven_cpus = pytest.mark.skipif(
not uneven_cpus,
reason=f"needs {max(node_cpus.values())} CPUs, the machine has {host_cpus}",
)
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version((26, 5), "sbin/slurmd")
atf.require_version(
(25, 5), "bin/scontrol", reason="The topology.yaml option was added in 25.05"
)
# Bootstrap with one static node; dynamic nodes register on top.
atf.require_nodes(1)
atf.require_config_parameter("MaxNodeCount", nnodes + 1)
atf.require_config_parameter_includes("SlurmctldParameters", "cloud_reg_addrs")
# Required for MaxNodeCount.
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_config_file(
"topology.yaml",
"""
- topology: topo_alpha
cluster_default: true
flat:
alpha_step_rank: true
- topology: topo_plain
cluster_default: false
flat: true
""",
)
atf.require_config_parameter(
"PartitionName",
{
"alpha": {"Nodes": "ALL", "Topology": "topo_alpha"},
"plain": {"Nodes": "ALL", "Topology": "topo_plain"},
},
)
atf.require_slurm_running()
@pytest.fixture(scope="module")
def dynamic_nodes():
"""Register the dynamic nodes once for the whole module in register_order."""
base_port = 61000
for i, name in enumerate(register_order):
# Each one registered before the next, so the registration order holds
atf.start_slurmd(
name,
f"-Z -b --conf 'Port={base_port + i} "
f"CPUs={node_cpus[name] if uneven_cpus else 1}'",
)
for name in register_order:
assert atf.wait_for_node_state(
name, "IDLE", timeout=30
), f"Dynamic node {name} should reach IDLE state"
yield
for name in register_order:
# Make sure no jobs are running on the node so we can delete it cleanly.
atf.repeat_until(
lambda n=name: atf.get_node_parameter(n, "state"),
lambda states: "ALLOCATED" not in states and "MIXED" not in states,
fatal=False,
)
atf.stop_slurmd(name)
atf.run_command(f"scontrol delete NodeName={name}", user="slurm", fatal=False)
def _parse_task_nodes(output):
"""Parse 'tid: nodename' lines, return list of nodes ordered by tid."""
matches = re.findall(r"(\d+): (\S+)", output)
matches.sort(key=lambda x: int(x[0]))
return [name for _, name in matches]
def _run_step_layout(partition):
return atf.run_job_output(
f"-p {partition} --nodelist={node_list_arg} -N{nnodes} -n{nnodes} "
f"-m block --exclusive --mem=1 -l printenv SLURMD_NODENAME",
fatal=True,
)
def _run_step_env(job_args, env=None):
"""Run a step and report each task's placement and the emitted lists.
Returns (nodes, node_ids, step_list, job_list, cpus_per_node): the nodes
and node ids ordered by task id, plus the three job/step values, which
every task reports identically. SLURM_TASKS_PER_NODE is not reported: a
step's copy is derived from its own layout, so it cannot disagree with the
list next to it.
"""
output = atf.run_job_output(
f"{job_args} -l bash -c 'echo $SLURM_NODEID $SLURMD_NODENAME "
"$SLURM_STEP_NODELIST $SLURM_JOB_NODELIST $SLURM_JOB_CPUS_PER_NODE "
"$SLURM_NODELIST $SLURM_GTIDS'",
env_vars=env,
fatal=True,
)
matches = re.findall(r"(\d+): (\d+) (\S+) (\S+) (\S+) (\S+) (\S+) (\S+)", output)
matches.sort(key=lambda x: int(x[0]))
assert matches, f"Expected tasks to report their placement, got: {output}"
# An empty variable leaves the line one field short, and re.findall() drops
# it rather than failing, so count the labelled lines the step printed
labelled = len(re.findall(r"^\d+: ", output, re.M))
assert (
len(matches) == labelled
), f"Expected {labelled} task lines to parse, got {len(matches)}: {output}"
for m in matches:
assert m[6] == m[4], (
f"SLURM_NODELIST {m[6]} should match SLURM_JOB_NODELIST {m[4]} "
f"on {m[2]}"
)
# SLURM_GTIDS holds the task ids on the reporting node. A node earlier in
# the emitted list runs earlier tasks, whatever the distribution, so the
# lowest task id per node rises with the node id.
lowest_gtid = {}
for m in matches:
node_id = int(m[1])
lowest = min(int(tid) for tid in m[7].split(","))
lowest_gtid[node_id] = min(lowest_gtid.get(node_id, lowest), lowest)
by_node_id = [lowest_gtid[node_id] for node_id in sorted(lowest_gtid)]
assert by_node_id == sorted(by_node_id), (
f"SLURM_GTIDS should ascend with SLURM_NODEID, got {by_node_id} "
f"for node ids {sorted(lowest_gtid)}"
)
return (
[m[2] for m in matches],
[int(m[1]) for m in matches],
matches[0][3],
matches[0][4],
matches[0][5],
)
def _expand_reps(compressed):
"""Expand a run-length list such as '2(x2),1(x2)' into [2, 2, 1, 1]"""
values = []
for part in compressed.split(","):
if "(x" in part:
value, reps = part.split("(x")
values += [int(value)] * int(reps.rstrip(")"))
else:
values.append(int(part))
return values
def _require_rank_ordered_lists():
"""Emitting node lists in topology-rank order is 26.11+.
stepmgr builds the step lists, so with SlurmStepMgr this depends on the
slurmd version too.
"""
for component in ["sbin/slurmctld", "sbin/slurmd"]:
atf.require_version(
(26, 11),
component,
reason=f"Issue 50993: {component} emits node lists in topology-rank order in 26.11+",
)
def test_alpha_step_rank_enabled(dynamic_nodes):
"""With alpha_step_rank, tasks are laid out in alphabetical node order."""
output = _run_step_layout("alpha")
actual = _parse_task_nodes(output)
assert actual == alpha_order, (
f"Tasks should follow alphabetical node order: "
f"expected {alpha_order}, got {actual}"
)
def test_alpha_step_rank_disabled(dynamic_nodes):
"""Without alpha_step_rank, tasks follow node-table (registration) order."""
output = _run_step_layout("plain")
actual = _parse_task_nodes(output)
assert actual == register_order, (
f"Tasks should follow node-table order: "
f"expected {register_order}, got {actual}"
)
@pytest.mark.parametrize(
("partition", "expected_order"),
[("alpha", alpha_order), ("plain", register_order)],
)
@needs_uneven_cpus
def test_emitted_lists_match_task_order(dynamic_nodes, partition, expected_order):
"""SLURM_STEP_NODELIST and SLURM_JOB_NODELIST follow task placement."""
_require_rank_ordered_lists()
nodes, node_ids, step_list, job_list, cpus_per_node = _run_step_env(
f"-p {partition} --nodelist={node_list_arg} -N{nnodes} -n{nnodes} "
f"-m block --exclusive --mem=1"
)
expected_cpus = [node_cpus[node] for node in expected_order]
assert nodes == expected_order, f"Tasks should follow {expected_order}, got {nodes}"
assert (
atf.node_range_to_list(step_list) == expected_order
), f"SLURM_STEP_NODELIST should be {expected_order}, got {step_list}"
assert (
atf.node_range_to_list(job_list) == expected_order
), f"SLURM_JOB_NODELIST should be {expected_order}, got {job_list}"
assert node_ids == list(range(nnodes)), (
f"SLURM_NODEID should index SLURM_STEP_NODELIST: expected "
f"{list(range(nnodes))}, got {node_ids}"
)
assert _expand_reps(cpus_per_node) == expected_cpus, (
f"SLURM_JOB_CPUS_PER_NODE {cpus_per_node} should be {expected_cpus} "
f"for {expected_order}"
)
@pytest.mark.parametrize(
("partition", "expected_order"),
[("alpha", alpha_order), ("plain", register_order)],
)
@needs_uneven_cpus
def test_external_launcher_list_matches_task_order(
dynamic_nodes, partition, expected_order
):
"""An external launcher maps its own ranks onto SLURM_STEP_NODELIST, so it
has to be emitted in the same order as a regular step's."""
_require_rank_ordered_lists()
nodes, node_ids, step_list, job_list, cpus_per_node = _run_step_env(
f"-p {partition} --nodelist={node_list_arg} -N{nnodes} "
f"--external-launcher --exclusive --mem=1"
)
expected_cpus = [node_cpus[node] for node in expected_order]
assert nodes == expected_order, f"Tasks should follow {expected_order}, got {nodes}"
assert node_ids == list(range(nnodes)), (
f"SLURM_NODEID should index SLURM_STEP_NODELIST: expected "
f"{list(range(nnodes))}, got {node_ids}"
)
assert (
atf.node_range_to_list(step_list) == expected_order
), f"SLURM_STEP_NODELIST should be {expected_order}, got {step_list}"
assert (
atf.node_range_to_list(job_list) == expected_order
), f"SLURM_JOB_NODELIST should be {expected_order}, got {job_list}"
assert _expand_reps(cpus_per_node) == expected_cpus, (
f"SLURM_JOB_CPUS_PER_NODE {cpus_per_node} should be {expected_cpus} "
f"for {expected_order}"
)
@pytest.mark.parametrize(
("partition", "expected_order"),
[("alpha", alpha_order), ("plain", register_order)],
)
@needs_uneven_cpus
def test_job_arrays_align_with_nodelist(dynamic_nodes, partition, expected_order):
"""SLURM_JOB_CPUS_PER_NODE and SLURM_TASKS_PER_NODE are positional.
Both come from the allocation response, so they have to be read outside of
a step: a step's copies are derived from its own layout and so cannot
disagree with the list next to them. Allocating every CPU of nodes with
distinct CPU counts makes both arrays uneven, and one task per CPU makes
the task counts match them.
"""
_require_rank_ordered_lists()
output = atf.run_command_output(
f"salloc -p {partition} --nodelist={node_list_arg} -N{nnodes} "
f"-n{total_cpus} --exclusive --mem=1 "
"bash -c 'echo ARRAYS $SLURM_JOB_NODELIST $SLURM_JOB_CPUS_PER_NODE "
"$SLURM_TASKS_PER_NODE'",
fatal=True,
)
match = re.search(r"ARRAYS (\S+) (\S+) (\S+)", output)
assert match, f"Expected the allocation to report its arrays, got: {output}"
job_list, cpus_per_node, tasks_per_node = match.groups()
expected_cpus = [node_cpus[node] for node in expected_order]
assert (
atf.node_range_to_list(job_list) == expected_order
), f"SLURM_JOB_NODELIST should be {expected_order}, got {job_list}"
assert _expand_reps(cpus_per_node) == expected_cpus, (
f"SLURM_JOB_CPUS_PER_NODE {cpus_per_node} should be {expected_cpus} "
f"for {expected_order}"
)
assert _expand_reps(tasks_per_node) == expected_cpus, (
f"SLURM_TASKS_PER_NODE {tasks_per_node} should be {expected_cpus} "
f"for {expected_order}"
)
@pytest.mark.parametrize(
("partition", "expected_order"),
[("alpha", alpha_order), ("plain", register_order)],
)
@needs_uneven_cpus
def test_batch_env_matches_task_order(dynamic_nodes, partition, expected_order):
"""A batch script's node list and per-node arrays agree with each other.
The batch launch builds its own copies, so it needs its own coverage.
"""
_require_rank_ordered_lists()
output = f"{atf.module_tmp_path}/batch-{partition}.out"
job_id = atf.submit_job_sbatch(
f"-p {partition} --nodelist={node_list_arg} -N{nnodes} -n{total_cpus} "
f"--exclusive --mem=1 -o {output} "
"--wrap 'echo ARRAYS $SLURM_JOB_NODELIST $SLURM_JOB_CPUS_PER_NODE "
"$SLURM_TASKS_PER_NODE'",
fatal=True,
)
atf.wait_for_job_state(job_id, "DONE", fatal=True)
assert atf.wait_for_file(output), f"No output from job {job_id}"
# wait_for_file() only proves the inode exists, so re-read until the line
# the script wrote has actually been flushed. The trailing newline is what
# makes that test sound: without it the last \S+ group is satisfied by a
# token that is still being written.
arrays = r"ARRAYS (\S+) (\S+) (\S+)\n"
assert atf.repeat_until(
lambda: atf.run_command_output(f"cat {output}", fatal=True),
lambda text: re.search(arrays, text) is not None,
), f"Expected the batch script to report its arrays in {output}"
match = re.search(arrays, atf.run_command_output(f"cat {output}", fatal=True))
job_list, cpus_per_node, tasks_per_node = match.groups()
expected_cpus = [node_cpus[node] for node in expected_order]
assert (
atf.node_range_to_list(job_list) == expected_order
), f"SLURM_JOB_NODELIST should be {expected_order}, got {job_list}"
assert _expand_reps(cpus_per_node) == expected_cpus, (
f"SLURM_JOB_CPUS_PER_NODE {cpus_per_node} should be {expected_cpus} "
f"for {expected_order}"
)
assert _expand_reps(tasks_per_node) == expected_cpus, (
f"SLURM_TASKS_PER_NODE {tasks_per_node} should be {expected_cpus} "
f"for {expected_order}"
)
@pytest.mark.parametrize(
("partition", "expected_order"),
[("alpha", alpha_order), ("plain", register_order)],
)
@needs_uneven_cpus
def test_job_info_response_matches_task_order(dynamic_nodes, partition, expected_order):
"""A step joining an existing allocation gets the same order.
srun --jobid asks the controller for allocation info, which builds its
own node list and cpu counts.
"""
_require_rank_ordered_lists()
job_id = atf.submit_job_sbatch(
f"-p {partition} --nodelist={node_list_arg} -N{nnodes} -n{total_cpus} "
f"--exclusive --mem=1 --wrap 'sleep infinity'",
fatal=True,
)
atf.wait_for_job_state(job_id, "RUNNING", fatal=True)
nodes, node_ids, step_list, job_list, cpus_per_node = _run_step_env(
f"--jobid={job_id} --overlap -N{nnodes} --ntasks-per-node=1 --mem=1"
)
atf.cancel_jobs([job_id])
expected_cpus = [node_cpus[node] for node in expected_order]
assert nodes == expected_order, f"Tasks should follow {expected_order}, got {nodes}"
assert (
atf.node_range_to_list(step_list) == expected_order
), f"SLURM_STEP_NODELIST should be {expected_order}, got {step_list}"
assert (
atf.node_range_to_list(job_list) == expected_order
), f"SLURM_JOB_NODELIST should be {expected_order}, got {job_list}"
assert node_ids == list(range(nnodes)), (
f"SLURM_NODEID should index SLURM_STEP_NODELIST: expected "
f"{list(range(nnodes))}, got {node_ids}"
)
assert _expand_reps(cpus_per_node) == expected_cpus, (
f"SLURM_JOB_CPUS_PER_NODE {cpus_per_node} should be {expected_cpus} "
f"for {expected_order}"
)
def test_arbitrary_job_rejects_task_count_update(dynamic_nodes):
"""scontrol cannot change the task count of an arbitrary job.
The count is the number of entries in the requested node list, and
arbitrary_tpn is derived from it, so it cannot be set independently.
"""
atf.require_version(
(26, 11),
"sbin/slurmctld",
reason="Issue 50993: the task count of an arbitrary job is rejected in 26.11+",
)
hostfile = f"{atf.module_tmp_path}/hostfile_numtasks"
requested = [alpha_order[0], alpha_order[1]]
atf.run_command(
f"bash -c \"printf '%s\\n' {' '.join(requested)} > {hostfile}\"", fatal=True
)
# Held so the job stays pending: the count is only updatable while pending
job_id = atf.submit_job_sbatch(
f"-p plain --hold -n{len(requested)} -m arbitrary --mem=1 " f"--wrap 'true'",
fatal=True,
env_vars=f"SLURM_HOSTFILE={hostfile}",
)
output = atf.run_command_error(
f"scontrol update jobid={job_id} NumTasks=1", xfail=True, fatal=True
)
atf.cancel_jobs([job_id])
assert (
"not supported" in output.lower()
), f"Updating NumTasks of an arbitrary job should be refused, got: {output}"
@needs_uneven_cpus
def test_external_launcher_arbitrary_keeps_hostfile_order(dynamic_nodes):
"""An external launcher step built from an arbitrary list holds each host
once, in the hostfile's order.
The list is built separately from a regular step's, so a repeated host
has to collapse there too or it names more hosts than the layout. An
external launcher runs one task per node, so the repeat costs a task
rather than placing two on the same node.
"""
_require_rank_ordered_lists()
requested = [alpha_order[1], alpha_order[1], register_order[0], alpha_order[0]]
distinct = [alpha_order[1], register_order[0], alpha_order[0]]
hostfile = f"{atf.module_tmp_path}/hostfile_ext"
atf.run_command(
f"bash -c \"printf '%s\\n' {' '.join(requested)} > {hostfile}\"", fatal=True
)
nodes, node_ids, step_list, job_list, cpus_per_node = _run_step_env(
f"-p plain -n{len(requested)} -m arbitrary --external-launcher --mem=1",
env=f"SLURM_HOSTFILE={hostfile}",
)
assert (
nodes == distinct
), f"Tasks should follow the hostfile order {distinct}, got {nodes}"
assert node_ids == list(range(len(distinct))), (
f"SLURM_NODEID should index SLURM_STEP_NODELIST: expected "
f"{list(range(len(distinct)))}, got {node_ids}"
)
assert (
atf.node_range_to_list(step_list) == distinct
), f"SLURM_STEP_NODELIST should be {distinct}, got {step_list}"
assert (
atf.node_range_to_list(job_list) == distinct
), f"SLURM_JOB_NODELIST should be {distinct}, got {job_list}"
# The list is built separately here, so the array beside it needs its own
# check. One CPU per task, so the counts follow the repeated host.
expected_cpus = [requested.count(node) for node in distinct]
assert _expand_reps(cpus_per_node) == expected_cpus, (
f"SLURM_JOB_CPUS_PER_NODE {cpus_per_node} should be {expected_cpus} "
f"for {distinct}"
)
@needs_uneven_cpus
def test_arbitrary_keeps_hostfile_order(dynamic_nodes):
"""Arbitrary distribution emits nodes in the hostfile's order.
--nodes cannot be combined with --distribution=arbitrary, so the task
count alone drives the layout. A repeated host keeps the position of its
first occurrence.
"""
_require_rank_ordered_lists()
# Neither alphabetical nor registration order, with a repeated host
requested = [alpha_order[1], alpha_order[1], register_order[0], alpha_order[0]]
distinct = [alpha_order[1], register_order[0], alpha_order[0]]
hostfile = f"{atf.module_tmp_path}/hostfile"
atf.run_command(
f"bash -c \"printf '%s\\n' {' '.join(requested)} > {hostfile}\"", fatal=True
)
nodes, node_ids, step_list, job_list, cpus_per_node = _run_step_env(
f"-p plain -n{len(requested)} -m arbitrary --mem=1",
env=f"SLURM_HOSTFILE={hostfile}",
)
assert (
nodes == requested
), f"Tasks should follow the hostfile order {requested}, got {nodes}"
assert (
atf.node_range_to_list(step_list) == distinct
), f"SLURM_STEP_NODELIST should be {distinct}, got {step_list}"
assert node_ids == [
0,
0,
1,
2,
], f"SLURM_NODEID should index SLURM_STEP_NODELIST, got {node_ids}"
assert (
atf.node_range_to_list(job_list) == distinct
), f"SLURM_JOB_NODELIST should be {distinct}, got {job_list}"
# One CPU per task, so the counts follow the repeated host
expected_cpus = [requested.count(node) for node in distinct]
assert _expand_reps(cpus_per_node) == expected_cpus, (
f"SLURM_JOB_CPUS_PER_NODE {cpus_per_node} should be {expected_cpus} "
f"for {distinct}"
)
@pytest.mark.skipif(
atf.get_version("bin/scontrol") < (26, 11),
reason="Issue 50235: scontrol show topology prints AlphaStepRank since 26.11",
)
@pytest.mark.parametrize(
"topology, alpha_step_rank", [("topo_alpha", "true"), ("topo_plain", "false")]
)
def test_show_topology_alpha_step_rank(topology, alpha_step_rank):
"""Verify scontrol show topology reports AlphaStepRank for flat topologies"""
output = atf.run_command_output(f"scontrol show topology {topology}", fatal=True)
assert (
output.strip() == f"AlphaStepRank={alpha_step_rank}"
), f"scontrol show topology {topology} did not report its AlphaStepRank: {output}"
def test_batch_host_alpha_step_rank(dynamic_nodes):
"""With alpha_step_rank, the batch host is the lowest-ranked (alphabetically
first) node, even though it registered last."""
atf.require_version(
(26, 11),
"sbin/slurmctld",
reason="Batch host topology ranking was added in 26.11",
)
job_id = atf.submit_job_sbatch(
f"-p alpha --nodelist={node_list_arg} -N{nnodes} --exclusive --mem=1 "
f"--wrap 'sleep 60'",
fatal=True,
)
atf.wait_for_job_state(job_id, "RUNNING", fatal=True)
batch_host = atf.get_job_parameter(job_id, "BatchHost")
assert batch_host == alpha_order[0], (
f"Batch host should be the alphabetically-first node "
f"{alpha_order[0]}, got {batch_host}"
)