blob: 68951dac86e96ab353e60fd631d0b3d6c0d577f0 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test single-node job placement with enable_one_node_select."""
import collections
import datetime
import re
import pytest
import atf
NODE_COUNT = 8
CPUS_PER_NODE = 4
NODE_NAMES = [f"node{i}" for i in range(1, NODE_COUNT + 1)]
LICENSE = "bftest"
SCHED_BASELINE = "bf_interval=1,sched_interval=1"
PARTITIONS = {"flat": "flat_topo", "tree": "tree_topo"}
FAST_PARTITION = "flat_fast"
ALL_PARTITIONS = {**PARTITIONS, FAST_PARTITION: "flat_fast_topo"}
TOPOLOGY_YAML = """
- topology: flat_topo
cluster_default: true
flat: true
- topology: tree_topo
cluster_default: false
tree:
switches:
- switch: root
children: s[1-2]
- switch: s1
nodes: node[1-4]
- switch: s2
nodes: node[5-8]
- topology: flat_fast_topo
cluster_default: false
enable_one_node_select: true
flat: true
"""
# A job start can span a slurmctld re-exec.
JOB_START_TIMEOUT = 60
# These jobs start in about half a second, and there are dozens of waits.
POLL_INTERVAL = 0.1
pytestmark = pytest.mark.slow
# Setup
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(25, 5), "bin/scontrol", reason="The topology.yaml option was added in 25.05"
)
atf.require_version(
(26, 11),
reason="Ticket 25772: enable_one_node_select in topology.yaml is new "
"in 26.11",
)
atf.require_nodes(NODE_COUNT, [("CPUs", CPUS_PER_NODE)])
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_config_file("topology.yaml", TOPOLOGY_YAML)
atf.require_config_parameter(
"PartitionName",
{
name: {"Nodes": "ALL", "Topology": topology}
for name, topology in ALL_PARTITIONS.items()
},
)
atf.require_config_parameter("SchedulerParameters", SCHED_BASELINE)
# Blocks the bf_busy_nodes probe without using node resources.
atf.require_config_parameter("Licenses", f"{LICENSE}:1")
atf.require_slurm_running()
# _eval_nodes_serial() and _eval_nodes_busy() iterate weight groups first.
weights = {atf.get_node_parameter(name, "weight") for name in NODE_NAMES}
assert len(weights) == 1, f"This test requires a uniform node Weight, got {weights}"
@pytest.fixture(scope="function", autouse=True)
def cleanup():
yield
atf.cancel_all_jobs(fatal=True, quiet=True)
for partition in ALL_PARTITIONS:
atf.run_command(
f"scontrol update PartitionName={partition} LLN=no",
user=atf.properties["slurm-user"],
quiet=True,
)
def configure(select_params="CR_CPU", sched_params=""):
# Each write re-execs slurmctld, so only write what differs.
# SelectTypeParameters can only change on a restart.
sched = f"{SCHED_BASELINE},{sched_params}" if sched_params else SCHED_BASELINE
settings = [
("SchedulerParameters", sched, False),
("SelectTypeParameters", select_params, True),
]
for name, value, restart in settings:
# get_config_parameter() returns the value casefolded.
if atf.get_config_parameter(name, live=False) != value.casefold():
atf.set_config_parameter(name, value, restart=restart)
# A restart does not wait for nodes to re-register.
reset_to_idle()
def set_partition_lln(partition, enabled):
# Must follow configure(); a reconfigure discards runtime partition updates.
atf.run_command(
f"scontrol update PartitionName={partition} "
f"LLN={'yes' if enabled else 'no'}",
user=atf.properties["slurm-user"],
fatal=True,
)
def run_single_node_jobs(partition, count):
# One at a time, so each job is placed against the previous one's usage.
nodes = []
for i in range(count):
job_id = atf.submit_job_sbatch(
f'-p {partition} -N1 -n1 --mem=1 --wrap="sleep infinity"', fatal=True
)
assert atf.wait_for_job_state(
job_id, "RUNNING", timeout=JOB_START_TIMEOUT, poll_interval=POLL_INTERVAL
), f"Verify that single-node job {i + 1} of {count} started"
nodes.append(atf.get_job_parameter(job_id, "NodeList", fatal=True))
return nodes
def reset_to_idle():
atf.cancel_all_jobs(fatal=True, quiet=True)
atf.wait_for_node_state(NODE_NAMES, "IDLE", poll_interval=POLL_INTERVAL, fatal=True)
def probe_future_placement(partition):
"""Return the nodes srun --test-only plans for a license-blocked job."""
result = atf.run_command(
f"srun --test-only -p {partition} -N1 -n1 --mem=1 -L {LICENSE}:1 -t 1 "
"/bin/true",
env_vars="SLURM_TIME_FORMAT=standard",
fatal=True,
)
output = result["stderr"] + result["stdout"]
# bf_busy_nodes is only used by _future_run_test().
start_match = re.search(r"to start at (\S+)", output)
assert start_match, f"Verify that srun --test-only reported a start time: {output}"
start_time = datetime.datetime.strptime(start_match.group(1), "%Y-%m-%dT%H:%M:%S")
assert start_time > datetime.datetime.now(), (
f"Verify the probe was deferred into _future_run_test() rather than "
f"answered by the run-now pass; it reported {start_match.group(1)}"
)
match = re.search(r"on nodes (\S+) in partition", output)
assert match, f"Verify that srun --test-only reported a placement: {output}"
return atf.node_range_to_list(match.group(1))
@pytest.mark.parametrize("partition", PARTITIONS)
def test_lln_partition_honored_single_node(partition):
"""Partition LLN=YES spreads single-node jobs."""
configure()
set_partition_lln(partition, False)
stacked = run_single_node_jobs(partition, 4)
assert len(set(stacked)) == 1, (
f"Verify that without LLN the four single-node jobs share one node, "
f"got {stacked}"
)
reset_to_idle()
# Past NODE_COUNT, "first empty node" would double up on one node.
set_partition_lln(partition, True)
counts = collections.Counter(run_single_node_jobs(partition, NODE_COUNT + 4))
assert len(counts) == NODE_COUNT, (
f"Verify that partition LLN=YES used every node before doubling up "
f"on any, got {dict(counts)}"
)
assert max(counts.values()) - min(counts.values()) <= 1, (
f"Verify that LLN kept per-node job counts balanced once no node was "
f"empty, which only ranking by idle CPUs achieves; got {dict(counts)}"
)
def test_lln_partition_ignored_with_one_node_select():
"""With enable_one_node_select, LLN=YES does not spread single-node jobs."""
configure()
set_partition_lln(FAST_PARTITION, True)
nodes = run_single_node_jobs(FAST_PARTITION, 4)
assert len(set(nodes)) < 4, (
f"Verify that with enable_one_node_select LLN=YES does not spread "
f"single-node jobs, got {nodes}"
)
@pytest.mark.parametrize("partition", PARTITIONS)
def test_lln_global_honored_single_node(partition):
"""SelectTypeParameters=CR_LLN spreads single-node jobs."""
configure(select_params="CR_CPU,CR_LLN")
set_partition_lln(partition, False)
spread = run_single_node_jobs(partition, 4)
assert len(set(spread)) == 4, (
f"Verify that CR_LLN sends each single-node job to a different node, "
f"got {spread}"
)
@pytest.mark.parametrize("partition", PARTITIONS)
def test_pack_serial_at_end_single_node(partition):
"""pack_serial_at_end sends a serial job to the last node."""
last_node = f"node{NODE_COUNT}"
configure()
baseline = run_single_node_jobs(partition, 1)[0]
assert baseline != last_node, (
f"Verify that without pack_serial_at_end the serial job does not take "
f"the last node, got {baseline}"
)
reset_to_idle()
configure(sched_params="pack_serial_at_end")
packed = run_single_node_jobs(partition, 1)[0]
assert packed == last_node, (
f"Verify that pack_serial_at_end sends the serial job to {last_node}, "
f"got {packed}"
)
@pytest.mark.parametrize("partition", PARTITIONS)
def test_bf_busy_nodes_single_node(partition):
"""bf_busy_nodes plans a single-node job onto a node already in use."""
configure(sched_params="bf_busy_nodes")
busy = f"node{NODE_COUNT}"
# Holds the license; its -t 5 end is the planned start, when every node,
# busy included, is free.
blocker = atf.submit_job_sbatch(
f"-p {partition} -w {busy} -N1 -n1 --mem=1 -L {LICENSE}:1 -t 5 "
'--wrap="sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
blocker, "RUNNING", timeout=JOB_START_TIMEOUT, poll_interval=POLL_INTERVAL
), "Verify that the license blocker job started"
planned = probe_future_placement(partition)
assert planned == [busy], (
f"Verify that bf_busy_nodes planned the job onto in-use {busy}, "
f"but got {planned}"
)