blob: f38cb36d46a92511dc27da3ab4bb9ccc7e72dd9a [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Verify --gres-flags=enforce-links requires a job's GPUs to be linked.
Every GPU the job gets on a node must be linked to all the others. A node that
cannot supply such a set is not used: a request no node can satisfy is rejected
at submission, one whose set is busy pends.
Four nodes of four GPUs:
- node1: linked pairs {0,1} and {2,3}
- node2: no Links configured
- node3: only {2,3} linked, so an enforce-links job is forced off the first
GPUs, which index order alone would have picked
- node4: typed devices, where the only linked pair {1,3} spans the two
Types and neither Type holds a linked pair of its own
Each case is paired with a counter-example that still runs without the flag.
Shards cover the shared-GRES exemption.
"""
import re
import pytest
import atf
pytestmark = pytest.mark.slow
# Setup
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
"bin/srun",
reason="Issue 51169: --gres-flags=enforce-links added in 26.11",
)
atf.require_version(
(26, 11),
"bin/sbatch",
reason="Issue 51169: --gres-flags=enforce-links added in 26.11",
)
atf.require_version(
(26, 11),
"bin/scontrol",
reason="Issue 51169: --gres-flags=enforce-links added in 26.11",
)
atf.require_config_parameter("SelectType", "select/cons_tres")
# Overwrite rather than add to what a config variant set: adding CR_CORE to
# a variant that already selected another CR_* type is rejected by Slurm.
atf.require_config_parameter(
"SelectTypeParameters", "CR_CORE,MULTIPLE_SHARING_GRES_PJ"
)
atf.require_config_parameter_includes("GresTypes", "gpu")
atf.require_config_parameter_includes("GresTypes", "shard")
atf.require_nodes(
4,
[("Gres", "gpu:4,shard:8"), ("Sockets", 1), ("CoresPerSocket", 4)],
)
# node4's GPUs are typed. A typed request (gpu:a100:2) is matched against
# the typed GRES declared on the node's slurm.conf line, so replace the
# generic gpu:4 there with the per-Type breakdown. (Editing the existing
# line with set_node_parameter keeps the other NodeName lines, which
# require_config_parameter's dict form would drop.)
atf.set_node_parameter("node4", "Gres", "gpu:a100:2,gpu:v100:2,shard:8")
gpu_file = f"{atf.module_tmp_path}/gpu"
for i in range(4):
atf.run_command(f"touch {gpu_file}{i}", fatal=True)
# node1: two linked pairs (GPUs 0,1 linked; GPUs 2,3 linked).
# node2: the same GPUs with no Links, i.e. no link information at all.
# node3: only GPUs 2,3 are linked; GPUs 0 and 1 are linked to nothing.
# node4: typed GPUs, and only the cross-Type pair {1,3} is linked. The
# Links rows are node-global: four entries each, whatever the Type, so a
# typed request must be read against the type's devices at their global
# positions, not against type-local indexes 0 and 1.
atf.require_config_parameter(
"NodeName",
f"""node1 Name=gpu File={gpu_file}0 Links=-1,1,0,0
NodeName=node1 Name=gpu File={gpu_file}1 Links=1,-1,0,0
NodeName=node1 Name=gpu File={gpu_file}2 Links=0,0,-1,1
NodeName=node1 Name=gpu File={gpu_file}3 Links=0,0,1,-1
NodeName=node2 Name=gpu File={gpu_file}0
NodeName=node2 Name=gpu File={gpu_file}1
NodeName=node2 Name=gpu File={gpu_file}2
NodeName=node2 Name=gpu File={gpu_file}3
NodeName=node3 Name=gpu File={gpu_file}0 Links=-1,0,0,0
NodeName=node3 Name=gpu File={gpu_file}1 Links=0,-1,0,0
NodeName=node3 Name=gpu File={gpu_file}2 Links=0,0,-1,1
NodeName=node3 Name=gpu File={gpu_file}3 Links=0,0,1,-1
NodeName=node4 Name=gpu Type=a100 File={gpu_file}0 Links=-1,0,0,0
NodeName=node4 Name=gpu Type=a100 File={gpu_file}1 Links=0,-1,0,1
NodeName=node4 Name=gpu Type=v100 File={gpu_file}2 Links=0,0,-1,0
NodeName=node4 Name=gpu Type=v100 File={gpu_file}3 Links=0,1,0,-1""",
source="gres",
)
atf.require_slurm_running()
def _parse_idx(idx_str):
"""Expand an 'IDX' string like '0,2-3' into a set of ints."""
indexes = set()
for part in idx_str.split(","):
if "-" in part:
start, end = part.split("-")
indexes.update(range(int(start), int(end) + 1))
else:
indexes.add(int(part))
return indexes
def _allocated_gpu_indexes(node_detail):
"""Return the set of GPU indexes shown as used in 'scontrol show node -d'.
Typed devices report one GresUsed entry per Type, so gather them all."""
gres_used = re.search(r"GresUsed=(\S+)", node_detail)
assert gres_used is not None, f"No GresUsed found in:\n{node_detail}"
matches = re.findall(r"gpu:[^(]*\(IDX:([0-9,\-]+)\)", gres_used.group(1))
assert matches, f"No used GPU index found in:\n{node_detail}"
indexes = set()
for idx in matches:
indexes |= _parse_idx(idx)
return indexes
def _job_gpu_indexes(job_id):
"""GPU indexes allocated to a job, over every node.
One "Nodes=... GRES=" line per group of nodes, so reading only the first
would drop the GPUs given on the others.
"""
out = atf.run_command_output(f"scontrol show job {job_id} -d", fatal=True)
matches = re.findall(r"GRES=gpu:[^(]*\(IDX:([0-9,\-]+)\)", out)
assert matches, f"No GPU IDX for job {job_id}:\n{out}"
indexes = set()
for idx in matches:
indexes |= _parse_idx(idx)
return indexes
def _node_used_gpus(node):
"""Return the set of GPU indexes in use on a node, empty when none are."""
out = atf.run_command_output(f"scontrol show node {node} -d", fatal=True)
match = re.search(r"GresUsed=gpu:\d+\(IDX:([0-9,\-]+)\)", out)
if not match:
return set()
return _parse_idx(match.group(1))
def _leave_idle_cross_pair_on_node1():
"""Leave node1 with only the cross pair {0,2} idle.
Hold all four GPUs, then free 0 and 2. Their partners 1 and 3 stay busy,
so the idle pair is not linked. Returns {gpu: job id} still running.
"""
holder = {}
for _ in range(4):
job_id = atf.submit_job_sbatch(
'--gpus=1 -w node1 --wrap "sleep infinity"', fatal=True
)
assert atf.wait_for_job_state(
job_id, "RUNNING", fatal=True
), f"holder job {job_id} should run"
idx = _job_gpu_indexes(job_id)
assert len(idx) == 1, f"holder {job_id} took {idx}"
holder[next(iter(idx))] = job_id
assert set(holder) == {0, 1, 2, 3}, f"all four GPUs occupied: {holder}"
for gpu in (0, 2):
atf.run_command(f"scancel {holder[gpu]}", fatal=True)
# JobState reads CANCELLED while the job is still completing, at which
# point its GPUs are still allocated, so wait on the node's own used set.
for _ in atf.timer(fatal=True):
if _node_used_gpus("node1") == {1, 3}:
break
return {gpu: job_id for gpu, job_id in holder.items() if gpu in (1, 3)}
def test_two_gpu_job_prefers_a_linked_pair():
"""Without the flag a 2-GPU job still prefers a linked pair, by the default
link-count sort. A preference, not the requirement tested below."""
output = atf.run_command_output(
"srun --gpus-per-node=2 -w node1 scontrol show node node1 -d",
fatal=True,
)
indexes = _allocated_gpu_indexes(output)
assert indexes in (
{0, 1},
{2, 3},
), f"Expected a linked GPU pair, but got GPUs {sorted(indexes)}"
@pytest.mark.parametrize(
"gpu_flag",
[
"--gpus=2 -N1",
"--gpus-per-node=2 -N1",
"--gpus-per-task=2 -n1",
"--tres-per-task=gres/gpu:2 -n1",
"--gpus-per-socket=2 --sockets-per-node=1 -N1",
],
)
def test_enforce_links_two_gpu_gets_linked_pair(gpu_flag):
"""A 2-GPU request in any form must get the linked pair.
node3, not node1: only {2,3} are linked there. On node1 both pairs are
linked, so the default preference alone would satisfy the assertion.
"""
output = atf.run_command_output(
f"srun --gres-flags=enforce-links {gpu_flag} -w node3 "
"scontrol show node node3 -d",
fatal=True,
)
indexes = _allocated_gpu_indexes(output)
assert indexes == {
2,
3,
}, f"enforce-links {gpu_flag} got GPUs {sorted(indexes)}, not the linked pair"
def test_non_enforce_two_gpu_runs_on_node3():
"""Counter to the case above: without the flag a 2-GPU job on node3 still
runs (the link requirement is what constrains placement, not GPU supply)."""
output = atf.run_command_output(
"srun --gpus-per-node=2 -w node3 scontrol show node node3 -d",
fatal=True,
)
assert (
len(_allocated_gpu_indexes(output)) == 2
), "a non-enforce 2-GPU job must run on node3"
def test_enforce_links_single_gpu_succeeds():
"""A single device is trivially linked to itself, so a 1-GPU enforce-links
request runs normally."""
job_id = atf.submit_job_sbatch(
'--gres-flags=enforce-links --gpus-per-node=1 -w node1 --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "single-GPU enforce-links job should run"
def test_enforce_links_skips_unsatisfiable_node():
"""With no node named, the job must land on node1 or node3 and skip node2,
which has no link data."""
job_id = atf.submit_job_sbatch(
'--gres-flags=enforce-links --gpus-per-node=2 -N1 --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "enforce-links 2-GPU job should run on a node with a linked pair"
assert (
atf.get_job_parameter(job_id, "NodeList") != "node2"
), "enforce-links must skip node2, which has no link data"
def test_no_flag_allows_unlinked_gpus():
"""Counter-example: without the flag the same request runs on node2's
unlinked GPUs, so the pends below are the flag and not a GPU shortage."""
job_id = atf.submit_job_sbatch(
'--gpus-per-node=2 -w node2 --wrap "sleep infinity"', fatal=True
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "without enforce-links, a 2-GPU job must run on node2's unlinked GPUs"
def test_enforce_links_pends_when_linked_pair_busy():
"""With node3's pair {2,3} busy the job pends rather than taking the idle
unlinked GPUs 0 and 1.
Freeing the pair must start that same job, which shows the pend was the
link requirement and not a slow scheduling pass.
"""
holder = atf.submit_job_sbatch(
"--gres-flags=enforce-links --gpus-per-node=2 -w node3 "
'--wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
holder, "RUNNING"
), "holder should run on the linked pair {2,3}"
assert _node_used_gpus("node3") == {
2,
3,
}, "the holder should be on node3's only linked pair"
probe = atf.submit_job_sbatch(
"--gres-flags=enforce-links --gpus-per-node=2 -w node3 "
'--wrap "sleep infinity"',
fatal=True,
)
# Short timeout on purpose: this is a negative wait, so the default 45s
# would only slow the test down.
assert not atf.wait_for_job_state(
probe, "RUNNING", timeout=15
), "enforce-links must not use idle unlinked GPUs while {2,3} is busy"
assert (
atf.get_job_parameter(probe, "JobState") == "PENDING"
), "enforce-links job should pend while the linked pair is busy"
assert (
atf.get_job_parameter(probe, "Reason") == "Resources"
), "the enforce-links job should be waiting on resources"
atf.run_command(f"scancel {holder}", fatal=True)
assert atf.wait_for_job_state(
probe, "RUNNING", fatal=True
), "the pending job should start once the linked pair is freed"
assert _job_gpu_indexes(probe) == {
2,
3,
}, "the job that was pending should take the linked pair"
def test_non_enforce_uses_idle_unlinked_gpus_when_pair_busy():
"""Counter to the case above: with node3's linked pair busy, a non-enforce
2-GPU job runs on the idle unlinked GPUs instead of pending."""
holder = atf.submit_job_sbatch(
"--gres-flags=enforce-links --gpus-per-node=2 -w node3 "
'--wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(holder, "RUNNING"), "holder should run on {2,3}"
probe = atf.submit_job_sbatch(
'--gpus-per-node=2 -w node3 --wrap "sleep infinity"', fatal=True
)
assert atf.wait_for_job_state(
probe, "RUNNING"
), "a non-enforce job should run on node3's idle unlinked GPUs"
def test_enforce_links_no_large_enough_set_rejected():
"""No node has three mutually linked GPUs, so the request is rejected at
submission."""
result = atf.run_command(
"sbatch --gres-flags=enforce-links --gpus-per-node=3 -N1 "
'--wrap "sleep infinity"',
xfail=True,
)
assert result["exit_code"] != 0, "3-GPU enforce-links must be rejected"
assert (
"Requested node configuration is not available" in result["stderr"]
), f"expected a node-config rejection, got: {result['stderr']}"
def test_non_enforce_three_gpu_runs_on_node3():
"""Counter to the case above: without the flag a 3-GPU request on node3
runs, taking the linked pair plus an unlinked GPU."""
job_id = atf.submit_job_sbatch(
'--gpus-per-node=3 -w node3 --wrap "sleep infinity"', fatal=True
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "a non-enforce 3-GPU job must run on node3"
def test_enforce_links_request_exceeds_largest_clique_rejected():
"""node3's largest linked set is the pair {2,3}, so a 3-GPU enforce-links
request pinned there cannot be satisfied and is rejected at submission."""
result = atf.run_command(
"sbatch --gres-flags=enforce-links --gpus-per-node=3 -w node3 "
'--wrap "sleep infinity"',
xfail=True,
)
assert result["exit_code"] != 0, "3-GPU enforce-links on node3 must be rejected"
assert (
"Requested node configuration is not available" in result["stderr"]
), f"expected a node-config rejection, got: {result['stderr']}"
def test_enforce_links_no_link_data_rejected():
"""node2 has no Links, so no node can satisfy a 2-GPU enforce-links request
pinned there, and it is rejected at submission (fail-closed)."""
result = atf.run_command(
"sbatch --gres-flags=enforce-links --gpus-per-node=2 -w node2 "
'--wrap "sleep infinity"',
xfail=True,
)
assert result["exit_code"] != 0, "enforce-links on a no-link node must be rejected"
assert (
"Requested node configuration is not available" in result["stderr"]
), f"expected a node-config rejection, got: {result['stderr']}"
def test_enforce_links_single_gpu_ok_without_links():
"""A single device needs no link information, so a 1-GPU enforce-links
request runs even on node2, which has no Links configured."""
job_id = atf.submit_job_sbatch(
'--gres-flags=enforce-links --gpus-per-node=1 -w node2 --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "single-GPU enforce-links must run even with no link data"
def test_enforce_links_typed_request_scoped_to_type():
"""A typed request is held against the devices of that type alone. node4's
two a100s ({0,1}) are not linked, so --gres=gpu:a100:2 cannot be satisfied
anywhere and is rejected at submission."""
result = atf.run_command(
"sbatch --gres-flags=enforce-links --gres=gpu:a100:2 -w node4 " '--wrap "true"',
xfail=True,
)
assert result["exit_code"] != 0, "typed enforce-links must be rejected"
assert (
"Requested node configuration is not available" in result["stderr"]
), f"expected a node-config rejection, got: {result['stderr']}"
def test_non_enforce_typed_request_runs_on_node4():
"""Counter to the case above: without the flag the same typed request runs
on node4's unlinked a100 pair."""
job_id = atf.submit_job_sbatch(
'--gres=gpu:a100:2 -w node4 --wrap "sleep infinity"', fatal=True
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "a non-enforce typed 2-GPU job must run on node4"
assert _job_gpu_indexes(job_id) == {
0,
1,
}, "the a100 pair is node4's devices 0 and 1"
def test_enforce_links_untyped_uses_global_link_positions():
"""An untyped request is held against every device of the GRES name, and
the Links rows are node-global: node4's only linked pair is {1,3}, across
the two Types, so --gres=gpu:2 must get exactly that pair. Reading the
matrix type-locally would take indexes 0 and 1 instead."""
output = atf.run_command_output(
"srun --gres-flags=enforce-links --gres=gpu:2 -w node4 "
"scontrol show node node4 -d",
fatal=True,
)
indexes = _allocated_gpu_indexes(output)
assert indexes == {
1,
3,
}, f"enforce-links --gres=gpu:2 got GPUs {sorted(indexes)}, not the linked pair {{1,3}}"
def test_enforce_links_exempts_shared_gres():
"""Shared GRES are slices of one device and have no links, so the flag does
not apply. A multi-shard request on node2 must still run with it set."""
job_id = atf.submit_job_sbatch(
'--gres-flags=enforce-links --gres=shard:4 -w node2 --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "enforce-links must not block a shared-GRES (shard) request"
def test_enforce_links_pends_on_freed_cross_pair():
"""With only the idle cross pair {0,2} left on node1, the job must pend
rather than take two GPUs that are not linked."""
holder = _leave_idle_cross_pair_on_node1()
job_id = atf.submit_job_sbatch(
"--gres-flags=enforce-links --gpus=2 -N1 -w node1 " '--wrap "sleep infinity"',
fatal=True,
)
# Short timeout on purpose: this is a negative wait, so the default 45s
# would only slow the test down.
if atf.wait_for_job_state(job_id, "RUNNING", timeout=15):
assert False, (
"enforce-links must not run on the idle cross pair {0,2}, "
f"but it ran on GPUs {sorted(_job_gpu_indexes(job_id))}"
)
assert (
atf.get_job_parameter(job_id, "JobState") == "PENDING"
), "enforce-links job should pend while only a cross pair is idle"
assert (
atf.get_job_parameter(job_id, "Reason") == "Resources"
), "the enforce-links job should be waiting on resources"
# Freeing GPU 3 makes {2,3} idle and linked. The same job then has to run,
# which is what shows it was held back by the links and not by the
# scheduler taking its time.
atf.run_command(f"scancel {holder[3]}", fatal=True)
assert atf.wait_for_job_state(
job_id, "RUNNING", fatal=True
), "the pending job should start once a linked pair is freed"
assert _job_gpu_indexes(job_id) == {
2,
3,
}, "the job that was pending should take the linked pair"
def test_non_enforce_runs_on_freed_cross_pair():
"""Counter to the case above: with the same idle cross pair {0,2}, a
non-enforce 2-GPU job runs on it."""
_leave_idle_cross_pair_on_node1()
job_id = atf.submit_job_sbatch(
'--gpus=2 -N1 -w node1 --wrap "sleep infinity"', fatal=True
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "a non-enforce 2-GPU job must run on the idle cross pair {0,2}"
def test_enforce_links_node_level_4gpu_rejected():
"""A node-level 4-GPU enforce-links request needs a 4-clique, which node1
(two linked pairs) cannot form, so it is rejected at submission."""
result = atf.run_command(
"sbatch --gres-flags=enforce-links --gpus-per-node=4 -w node1 " '--wrap "true"',
xfail=True,
)
assert result["exit_code"] != 0, "node-level 4-GPU enforce-links must reject"
assert (
"Requested node configuration is not available" in result["stderr"]
), f"expected a node-config rejection, got: {result['stderr']}"
def test_enforce_links_multitask_4gpu_rejected():
"""Two tasks x 2 GPUs needs all four linked on node1, which has no such set,
so it is rejected. Feasibility sizes the set from the node total, not one
task's worth."""
result = atf.run_command(
"sbatch --gres-flags=enforce-links --ntasks=2 --gpus-per-task=2 -N1 "
'-w node1 --wrap "true"',
xfail=True,
)
assert result["exit_code"] != 0, "multi-task 4-GPU enforce-links must reject"
assert (
"Requested node configuration is not available" in result["stderr"]
), f"expected a node-config rejection, got: {result['stderr']}"
def test_non_enforce_multitask_4gpu_runs():
"""Counter to the case above: without the flag the same 2-task/4-GPU request
runs on node1 (the four GPUs need not be linked)."""
job_id = atf.submit_job_sbatch(
'--ntasks=2 --gpus-per-task=2 -N1 -w node1 --wrap "sleep infinity"', fatal=True
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "a non-enforce 2-task/4-GPU job must run on node1"
def test_enforce_links_per_job_spreads_over_nodes():
"""A per-job request names no per-node count, so node selection adds up what
each node offers. A node can only hand over one linked set, so it must
offer that rather than every GPU it has.
node1 and node3 offer a pair each, so 4 GPUs needs both. Advertising all
four would give the job node1 alone and fail selection every cycle.
node2 is excluded: with no link data its count would depend on the tie
break. node4 is excluded: it offers a pair too, which would leave the
assertions below guessing which two nodes the job took.
"""
job_id = atf.submit_job_sbatch(
"--gres-flags=enforce-links --gpus=4 --exclude=node2,node4 "
'--wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "a 4-GPU enforce-links job should run across node1 and node3"
assert (
atf.get_job_parameter(job_id, "NumNodes") == 2
), "the job should be spread over both usable nodes"
assert _node_used_gpus("node1") in (
{0, 1},
{2, 3},
), "node1 should give a linked pair"
assert _node_used_gpus("node3") == {
2,
3,
}, "node3 should give its only linked pair"
def test_enforce_links_per_job_not_satisfiable_rejected():
"""Counter to the case above: node1 and node3 offer a pair each, so six GPUs
cannot be found however the job is spread (node4's pair would make it
six, so it is excluded too)."""
result = atf.run_command(
"sbatch --gres-flags=enforce-links --gpus=6 --exclude=node2,node4 "
'--wrap "sleep infinity"',
xfail=True,
)
assert result["exit_code"] != 0, f"should be rejected, got {result}"
assert (
"Requested node configuration is not available" in result["stderr"]
), f"should be refused for the node configuration, got {result}"
def test_non_enforce_per_job_fits_on_one_node():
"""Counter to the two cases above: without the flag the GPUs need not be
linked, so four GPUs fit on a single node and six are available."""
job_id = atf.submit_job_sbatch(
'--gpus=4 --exclude=node2,node4 --wrap "sleep infinity"', fatal=True
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "a non-enforce 4-GPU job should run"
assert (
atf.get_job_parameter(job_id, "NumNodes") == 1
), "without the flag all four GPUs fit on one node"
def test_enforce_links_reported_by_scontrol_and_json():
"""The flag is echoed back as GresEnforceLinks, and as GRES_LINKS_ENFORCED
in the data parser JSON."""
job_id = atf.submit_job_sbatch(
'--gres-flags=enforce-links --gpus-per-node=1 -w node1 --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(job_id, "RUNNING"), "the job should run"
assert (
atf.get_job_parameter(job_id, "GresEnforceLinks") == "Yes"
), "scontrol should report the enforce-links flag"
flags = atf.get_jobs(job_id, use_json=True)[job_id]["flags"]
assert (
"GRES_LINKS_ENFORCED" in flags
), f"the JSON job flags should name enforce-links, got {flags}"