blob: 26afaa28005a64d5bf5a497f1b39170b28d642d5 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""--exclusive=topo on a partition whose topology plugin does not enforce it.
select/cons_tres keeps other jobs off the whole topology unit of a running
--exclusive=topo job, and simulates such a job ending over its whole unit,
only where the topology plugin of the job's partition enforces exclusive
units, as topology/block does and topology/tree does not. With a block and
a tree topology configured together, an --exclusive=topo job holding a GRES
runs on one node of a block partition and of a tree partition: another job
must run next to it on the tree partition's leaf switch, planning a third
job there must leave the GRES in use alone and log no cons_tres error, and
the same pair on the block partition must stay apart until the exclusive
job ends, also when the second job comes through the tree partition, which
holds the nodes of both units: the partition of the running job decides
what it holds.
Backfill holds the whole unit of an --exclusive=topo job free only where
the topology plugin of its partition enforces it, too: a job planned next to
it on the tree partition starts when the jobs on its own node end, not when
the exclusive job does.
"""
import datetime
import re
import pytest
import atf
_UNIT_SIZE = 4
_BLOCK_PARTITION = "test_150_21_block"
_TREE_PARTITION = "test_150_21_tree"
_GRES = "r1"
_ERROR_PATTERN = "under-allocated|mis-count"
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
component="sbin/slurmctld",
reason="Ticket 25865: exclusive topology per partition plugin since 26.11",
)
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_Core_Memory")
atf.require_config_parameter("DefMemPerNode", "128")
atf.require_config_parameter_includes("GresTypes", _GRES)
atf.require_config_parameter("SchedulerType", "sched/backfill")
atf.require_config_parameter_includes("SchedulerParameters", ("bf_interval", 1))
atf.require_config_parameter_includes("SchedulerParameters", ("sched_interval", 1))
# bf_min_prio_reserve keeps backfill from planning the jobs below its
# priority threshold, and planning next to the exclusive job is what is
# checked here.
atf.require_config_parameter_includes(
"SchedulerParameters", ("bf_min_prio_reserve", 0)
)
# require_nodes() can return qualifying nodes of the base configuration
# before the ones it creates; the units are made of the created ones.
nodes = atf.require_nodes(
2 * _UNIT_SIZE, [("CPUs", 2), ("RealMemory", 512), ("Gres", f"{_GRES}:1")]
)
block_unit = nodes[-2 * _UNIT_SIZE : -_UNIT_SIZE]
tree_unit = nodes[-_UNIT_SIZE:]
block_nodes = ",".join(block_unit)
tree_nodes = ",".join(tree_unit)
# Every node is a member of both topologies: a block of the block
# topology and a leaf switch of the tree topology hold the same nodes.
atf.require_config_file(
"topology.yaml",
f"""
- topology: block_topo
cluster_default: true
block:
block_sizes:
- {_UNIT_SIZE}
blocks:
- block: b1
nodes: "{block_nodes}"
- block: b2
nodes: "{tree_nodes}"
- topology: tree_topo
cluster_default: false
tree:
switches:
- switch: root
children: s[1-2]
- switch: s1
nodes: "{block_nodes}"
- switch: s2
nodes: "{tree_nodes}"
""",
)
# The tree partition holds the nodes of both units, so a job through it
# can be sent next to an --exclusive=topo job of the block partition.
atf.require_config_parameter(
"PartitionName",
{
_BLOCK_PARTITION: {"Nodes": block_nodes, "Topology": "block_topo"},
_TREE_PARTITION: {
"Nodes": f"{block_nodes},{tree_nodes}",
"Topology": "tree_topo",
},
},
)
atf.require_slurm_running()
return block_unit, tree_unit
def _slurmctld_log():
"""The slurmctld log path, case preserved and checked readable.
get_config_parameter() casefolds the value it returns, so it can not be
used for a path; get_config() preserves case.
"""
config = atf.get_config(live=False, quiet=True)
path = next(
(
value
for key, value in config.items()
if key.casefold() == "slurmctldlogfile"
),
None,
)
assert path, "SlurmctldLogFile must be set to check the slurmctld log"
atf.run_command(
f'test -r "{path}"',
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
return path
def _log_mark(log):
"""Current line count of the slurmctld log, to read forward from later."""
return int(
atf.run_command_output(
f"wc -l < {log}",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
).strip()
)
def _gres_used(node):
output = atf.run_command_output(f"scontrol show node {node} -d", fatal=True)
match = re.search(r"GresUsed=(\S+)", output)
assert match, f"No GresUsed in the details of node {node}:\n{output}"
return match.group(1)
def _job_time(job_dict, field):
if job_dict[field] == "Unknown":
return None
return datetime.datetime.strptime(job_dict[field], "%Y-%m-%dT%H:%M:%S")
def _submit(args):
return atf.submit_job_sbatch(
f"-N1 -o /dev/null {args} --wrap 'sleep infinity'", fatal=True
)
def test_tree_partition_does_not_enforce_exclusive_topo(setup):
"""Jobs run and are planned next to an --exclusive=topo job on tree."""
_, (node_a, node_b, *_) = setup
# The exclusive job outlasts its mate, so the plan of a job behind the
# mate shows whether backfill holds the leaf switch for the exclusive job.
exclusive = _submit(
f"-p {_TREE_PARTITION} -w {node_a} --exclusive=topo --gres={_GRES}:1 -t10"
)
atf.wait_for_job_state(exclusive, "RUNNING", fatal=True)
mate = _submit(f"-p {_TREE_PARTITION} -w {node_b} -t2")
assert atf.wait_for_job_state(mate, "RUNNING"), (
f"Job ({mate}) should run on {node_b} next to the --exclusive=topo job "
f"{exclusive}: topology/tree does not enforce exclusive units"
)
# The mate holds a CPU of its node, so a whole-node job for it is
# planned by backfill instead of started.
log = _slurmctld_log()
mark = _log_mark(log)
planned = _submit(f"-p {_TREE_PARTITION} -w {node_b} --exclusive -t1")
for _ in atf.timer(fatal=True):
jobs = atf.get_jobs(env_vars="SLURM_TIME_FORMAT=standard", quiet=True)
start = _job_time(jobs[planned], "StartTime")
if start:
break
state = jobs[planned]["JobState"]
assert (
state == "PENDING"
), f"Job ({planned}) should wait for job {mate} on {node_b}, is {state}"
end_exclusive = _job_time(jobs[exclusive], "EndTime")
assert start < end_exclusive, (
f"Job ({planned}) is planned at {start}, but should not wait for the "
f"--exclusive=topo job {exclusive} to end at {end_exclusive}: "
"topology/tree does not enforce exclusive units"
)
gres_used = _gres_used(node_a)
assert (
gres_used == f"{_GRES}:1"
), f"Planning job {planned} changed the GRES in use on {node_a} to {gres_used}"
errors = atf.run_command_output(
f"tail -n +{mark + 1} {log} | grep -E '{_ERROR_PATTERN}' || true",
user=atf.properties["slurm-user"],
quiet=True,
).strip()
assert (
not errors
), f"slurmctld logged errors while planning job {planned}:\n{errors}"
atf.cancel_jobs([planned, mate, exclusive], fatal=True)
@pytest.mark.parametrize(
"pending_partition",
[_BLOCK_PARTITION, _TREE_PARTITION],
ids=["same_partition", "tree_partition_sharing_the_block"],
)
def test_block_partition_enforces_exclusive_topo(setup, pending_partition):
"""A job for another node of the block waits for the --exclusive=topo job.
The partition of the running job decides what it holds, so a job sent
through the tree partition to a node of the block waits too.
"""
(node_a, node_b, *_), _ = setup
exclusive = _submit(
f"-p {_BLOCK_PARTITION} -w {node_a} --exclusive=topo --gres={_GRES}:1 -t5"
)
atf.wait_for_job_state(exclusive, "RUNNING", fatal=True)
job = _submit(f"-p {pending_partition} -w {node_b} -t1")
assert atf.wait_for_job_state(job, "PENDING", desired_reason="Resources"), (
f"Job ({job}) should wait for the --exclusive=topo job {exclusive} "
f"holding the block of {node_b}: topology/block enforces exclusive units"
)
atf.cancel_jobs([exclusive], fatal=True)
assert atf.wait_for_job_state(
job, "RUNNING"
), f"Job ({job}) never started once job {exclusive} was gone"
allocated = atf.node_range_to_list(
atf.get_job_parameter(job, "NodeList", quiet=True)
)
assert allocated == [node_b], f"Job ({job}) should run on {node_b}, got {allocated}"
atf.cancel_jobs([job], fatal=True)