blob: eb28d30afb90630ab1786564f980eabab89f3519 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test the CPUs srun steps get with --exact and --overlap."""
import pytest
import atf
NODE_CPUS = 8
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_nodes(2, [("CPUs", NODE_CPUS), ("ThreadsPerCore", 1)])
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_Core_Memory")
atf.require_slurm_running()
def _allocate(nodes):
"""Return a job that holds whole nodes and has no step running."""
job_id = atf.submit_job_sbatch(
f"-N{nodes} -n{nodes * NODE_CPUS} --wrap 'sleep infinity'", fatal=True
)
atf.wait_for_job_state(job_id, "RUNNING", fatal=True)
# TODO: Issue 50980. A step started too soon after RUNNING can be
# rejected, so retry a step that holds no resources until one runs.
probe = f"srun --jobid={job_id} --overlap --mem=0 true"
for _ in atf.timer(fatal=True):
if atf.run_command_exit(probe) == 0:
break
for _ in atf.timer(fatal=True):
if not _step_states(job_id):
break
return job_id
def _step_states(job_id):
"""Return the sorted states of the srun steps of a job."""
return sorted(
step["State"]
for step_id, step in atf.get_steps(job_id, quiet=True, fatal=True).items()
if step_id.split(".")[1] not in ("batch", "extern", "interactive")
)
def _start_step(job_id, step_args, out_file, env_vars=None):
"""Start a step that writes its CPU count to out_file and keeps running."""
atf.run_command(
f"srun --jobid={job_id} --mem=0 {step_args} bash -c "
f"'[ $SLURM_PROCID -ne 0 ] || echo $SLURM_CPUS_ON_NODE > {out_file}; "
"exec sleep infinity'",
env_vars=env_vars,
background=True,
)
@pytest.mark.parametrize(
"step_args,env_vars,expected",
[
("-N1 -n1", None, [NODE_CPUS]),
("-N1 -n1 --exact", None, [1]),
("-N1 -n1 --exclusive", None, [1]),
("-N1 -n1", "SLURM_EXACT=1", [1]),
("-N1 -n1 -c1", None, [1]),
("-N1 -n1 -c2", None, [2]),
("-N2 -n2", None, [NODE_CPUS, NODE_CPUS]),
("-N2 -n2 --exact", None, [1, 1]),
("-N2 -n2 -c3", None, [3, 3]),
],
ids=[
"default",
"exact",
"exclusive",
"env-exact",
"cpus-per-task-1",
"cpus-per-task",
"default-2-nodes",
"exact-2-nodes",
"cpus-per-task-2-nodes",
],
)
def test_step_cpus(step_args, env_vars, expected):
"""Test that a step gets all CPUs of its nodes unless --exact is set."""
job_id = _allocate(2)
result = atf.run_job(
f"--jobid={job_id} --mem=0 {step_args} printenv SLURM_CPUS_ON_NODE",
env_vars=env_vars,
)
assert result["exit_code"] == 0, f"srun {step_args} should run"
cpus = sorted(int(cpu) for cpu in result["stdout"].split())
assert cpus == expected, f"srun {step_args} should get {expected} CPUs per node"
def test_exact_steps_run_in_parallel():
"""Test that --exact steps run in parallel and a default step waits."""
job_id = _allocate(1)
# Leave CPUs free, so the default step waits only for the whole node.
exact = NODE_CPUS // 2 - 1
for out_file in ["exact_1", "exact_2"]:
_start_step(job_id, f"-n{exact} --exact", out_file)
atf.assert_file_contents(out_file, str(exact))
_start_step(job_id, "-n1", "default")
for _ in atf.timer(fatal=True):
states = _step_states(job_id)
if len(states) == 3:
break
assert states == ["PENDING", "RUNNING", "RUNNING"], "The default step should wait"
def test_exact_steps_do_not_share():
"""Test that --exact steps do not share CPUs without --overlap."""
job_id = _allocate(1)
exact = NODE_CPUS - 2
_start_step(job_id, f"-n{exact} --exact", "exact_1")
atf.assert_file_contents("exact_1", str(exact))
_start_step(job_id, f"-n{exact} --exact", "exact_2")
for _ in atf.timer(fatal=True):
states = _step_states(job_id)
if len(states) == 2:
break
assert states == ["PENDING", "RUNNING"], "The second step should wait"
@pytest.mark.parametrize("overlap_by", ["option", "env"])
@pytest.mark.parametrize("default_first", [False, True], ids=["last", "first"])
def test_exact_overlap(default_first, overlap_by):
"""Test that --exact --overlap steps share CPUs with a default step."""
job_id = _allocate(1)
# Two overlap steps together use more CPUs than the job has.
overlap = NODE_CPUS - 2
if overlap_by == "option":
overlap_args, env_vars = f"-n{overlap} --exact --overlap", None
else:
overlap_args, env_vars = f"-n{overlap} --exact", "SLURM_OVERLAP=1"
steps = [
("overlap_1", overlap_args, env_vars, overlap),
("overlap_2", overlap_args, env_vars, overlap),
]
default = ("default", "-n1", None, NODE_CPUS)
steps = [default] + steps if default_first else steps + [default]
for out_file, step_args, env_vars, cpus in steps:
_start_step(job_id, step_args, out_file, env_vars)
atf.assert_file_contents(out_file, str(cpus))
assert _step_states(job_id) == ["RUNNING"] * 3, "All steps should run together"