blob: 75be1317740e83446ca9f416134b42e9ccb1613d [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Backfill planning of a node list job next to an --exclusive=topo job.
A job whose required node list is as long as its fixed node count is only
evaluated on those nodes. select/cons_tres simulates a running
--exclusive=topo job ending over the whole topology unit it holds, so it has
to copy the partitions and the node usage of the whole units of the nodes it
is handed, not of those nodes alone. With copies of the required nodes alone,
the simulation reaches nodes it has no copy of: it logs "memory is
under-allocated" and "node_state mis-count" errors on every backfill cycle,
deallocates the exclusive job's GRES from the live node, and does not see an
exclusive job in a partition holding only other nodes of the unit at all, so
the waiting job is planned for now instead of after it.
An --exclusive=topo job holding a GRES runs on one node of a block while a
-N1 -w job for another node of the block waits behind it, with the exclusive
job in the same partition and in a partition holding only its half of the
block. Backfill must plan the waiting job for the end of the exclusive job
without logging those errors and without touching the GRES in use, and the
job must start on its node once the exclusive job is gone.
"""
import datetime
import re
import pytest
import atf
_BLOCK_SIZE = 4
_PARTITION = "test_150_20"
_HALF_PARTITION = "test_150_20_half"
_GRES = "r1"
_ERROR_PATTERN = "under-allocated|mis-count"
# Backfill rounds a start time estimate up to the next bf_resolution boundary
_BF_RESOLUTION_SEC = 60
# The times are parsed, so scontrol has to print them in the standard format
_TIME_FORMAT = "SLURM_TIME_FORMAT=standard"
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
component="sbin/slurmctld",
reason="Ticket 25865: select/cons_tres copies whole topology units 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("TopologyPlugin", "topology/block")
atf.require_config_parameter("SchedulerType", "sched/backfill")
atf.require_config_parameter_includes("SchedulerParameters", ("bf_interval", 1))
atf.require_config_parameter_includes(
"SchedulerParameters", ("bf_resolution", _BF_RESOLUTION_SEC)
)
atf.require_config_parameter_includes("SchedulerParameters", ("sched_interval", 1))
# bf_min_prio_reserve keeps backfill from planning the jobs below its
# priority threshold, and the planned start time 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 block is made of the created ones.
nodes = atf.require_nodes(
_BLOCK_SIZE, [("CPUs", 2), ("RealMemory", 512), ("Gres", f"{_GRES}:1")]
)
block = nodes[-_BLOCK_SIZE:]
atf.require_config_file(
"topology.conf",
f"BlockName=b1 Nodes={','.join(block)}\nBlockSizes={_BLOCK_SIZE}\n",
)
# The jobs are submitted to these partitions, so nodes outside the block
# can not take them.
atf.require_config_parameter(
"PartitionName",
{
_PARTITION: {"Nodes": ",".join(block)},
_HALF_PARTITION: {"Nodes": ",".join(block[_BLOCK_SIZE // 2 :])},
},
)
atf.require_slurm_running()
return block
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 _submit(args):
return atf.submit_job_sbatch(
f"-N1 -o /dev/null {args} --wrap 'sleep infinity'", fatal=True
)
@pytest.mark.parametrize(
"exclusive_partition",
[_PARTITION, _HALF_PARTITION],
ids=["same_partition", "partition_cutting_the_block"],
)
def test_node_list_job_planned_after_exclusive_topo_job(setup, exclusive_partition):
"""-N1 -w a waits behind the --exclusive=topo job on c until it ends."""
node_a, _, node_c, _ = setup
exclusive = _submit(
f"-p {exclusive_partition} -w {node_c} --exclusive=topo --gres={_GRES}:1 -t5"
)
atf.wait_for_job_state(exclusive, "RUNNING", fatal=True)
exclusive_end = datetime.datetime.fromisoformat(
atf.get_job_parameter(exclusive, "EndTime", env_vars=_TIME_FORMAT, quiet=True)
)
log = _slurmctld_log()
mark = _log_mark(log)
job = _submit(f"-p {_PARTITION} -w {node_a} -t1")
for _ in atf.timer(fatal=True):
start = atf.get_job_parameter(
job, "StartTime", default="Unknown", env_vars=_TIME_FORMAT, quiet=True
)
if start != "Unknown":
break
state = atf.get_job_parameter(job, "JobState", quiet=True)
assert (
state == "PENDING"
), f"Job ({job}) should wait for job {exclusive}, is {state}"
planned = datetime.datetime.fromisoformat(start)
assert (
exclusive_end
<= planned
<= exclusive_end + datetime.timedelta(seconds=_BF_RESOLUTION_SEC)
), f"Job ({job}) planned for {start}, job {exclusive} ends at {exclusive_end}"
gres_used = _gres_used(node_c)
assert (
gres_used == f"{_GRES}:1"
), f"Planning job {job} changed the GRES in use on {node_c} 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 {job}:\n{errors}"
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_a], f"Job ({job}) should run on {node_a}, got {allocated}"