blob: 6a1acc7f02ff3d42cfba21bcfd9af29b81f5e55f [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Verify --gres-flags=enforce-links where a node's request gets split.
The link requirement covers everything a job gets on a node, however the
selection code divides it up and however the request is spelled.
- By socket. A job whose cores span both sockets has its per-node request
divided between them, down to a single device per part. The requirement is
still decided against the whole allocation: per part, the first device
would be picked blind to the links and bind every later pick.
- By socket, where the socket also binds. A per-socket request names how many
devices of each socket the job gets, so a call may only hand over that
socket's devices. The set they come from is still the node's and may reach
into the others, which is where the calls that follow take theirs.
- By spelling. --gpus=N -N1 asks the same as --gpus-per-node=N -N1, so both
must be refused and accepted alike. Both are pinned to one node: spread
over several, a node holding a single device is exempt.
Each case is paired with a counter-example that still runs, or is still
refused, without the flag.
Three nodes, two sockets and four GPUs each:
- node1: GPUs 0,1 on socket 0 and 2,3 on socket 1, linked in those pairs
- node2: no Links configured
- node3: linked only as {1,3}, one device per socket. GPU 0 comes first on
socket 0 and is linked to nothing, so a pick going by index order, or
looking for the node's set within socket 0 alone, would take it and lose
the node.
"""
import re
import pytest
import atf
pytestmark = pytest.mark.slow
SOCKETS = 2
CORES_PER_SOCKET = 4
GPUS = 4
# Setup
@pytest.fixture(scope="module", autouse=True)
def setup():
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")
atf.require_config_parameter_includes("GresTypes", "gpu")
atf.require_nodes(
3,
[
("Gres", f"gpu:{GPUS}"),
("Sockets", SOCKETS),
("CoresPerSocket", CORES_PER_SOCKET),
("ThreadsPerCore", 1),
],
)
gpu_file = f"{atf.module_tmp_path}/gpu"
for i in range(GPUS):
atf.run_command(f"touch {gpu_file}{i}", fatal=True)
# node1: GPUs 0,1 on socket 0 and GPUs 2,3 on socket 1, each pair linked to
# itself only, so a linked pair never spans a socket.
# node2: the same GPUs with no Links, i.e. no link information at all.
# node3: the same GPUs linked only as {1,3}, so the only linked pair holds
# one device of each socket, and it is not the first device of either.
atf.require_config_parameter(
"NodeName",
f"""node1 Name=gpu File={gpu_file}0 Cores=0-3 Links=-1,1,0,0
NodeName=node1 Name=gpu File={gpu_file}1 Cores=0-3 Links=1,-1,0,0
NodeName=node1 Name=gpu File={gpu_file}2 Cores=4-7 Links=0,0,-1,1
NodeName=node1 Name=gpu File={gpu_file}3 Cores=4-7 Links=0,0,1,-1
NodeName=node2 Name=gpu File={gpu_file}0 Cores=0-3
NodeName=node2 Name=gpu File={gpu_file}1 Cores=0-3
NodeName=node2 Name=gpu File={gpu_file}2 Cores=4-7
NodeName=node2 Name=gpu File={gpu_file}3 Cores=4-7
NodeName=node3 Name=gpu File={gpu_file}0 Cores=0-3 Links=-1,0,0,0
NodeName=node3 Name=gpu File={gpu_file}1 Cores=0-3 Links=0,-1,0,1
NodeName=node3 Name=gpu File={gpu_file}2 Cores=4-7 Links=0,0,-1,0
NodeName=node3 Name=gpu File={gpu_file}3 Cores=4-7 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 _job_gpu_indexes(job_id):
"""Return the set of GPU indexes allocated to a job on its first node."""
out = atf.run_command_output(f"scontrol show job {job_id} -d", fatal=True)
match = re.search(r"GRES=gpu:[^(]*\(IDX:([0-9,\-]+)\)", out)
assert match is not None, f"No GPU IDX for job {job_id}:\n{out}"
return _parse_idx(match.group(1))
def _job_gpus_by_node(job_id):
"""{node name: set of GPU indexes} for a job spanning several nodes.
One allocation line per group of nodes given the same resources. Indexes
are per node, so the total is the sum of the sizes, not their union.
"""
out = atf.run_command_output(f"scontrol show job {job_id} -d", fatal=True)
matches = re.findall(
r"Nodes=(\S+) CPU_IDs=\S+ Mem=\S+ GRES=gpu:[^(]*\(IDX:([0-9,\-]+)\)", out
)
assert matches, f"No GPU IDX for job {job_id}:\n{out}"
return {node: _parse_idx(idx) for node, idx in matches}
def _job_gpu_count(job_id):
"""Return how many GPUs a job holds across all of its nodes."""
return sum(len(idx) for idx in _job_gpus_by_node(job_id).values())
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 _hold_every_gpu(node):
"""Occupy each of the node's GPUs with a one-GPU job.
Returns {gpu: job id}. The GPU a holder lands on is read back, since the
link-count sort does not fix the order.
"""
holder = {}
for _ in range(GPUS):
job_id = atf.submit_job_sbatch(
f'--gpus-per-node=1 -w {node} --wrap "sleep infinity"', fatal=True
)
assert atf.wait_for_job_state(
job_id, "RUNNING", fatal=True
), f"holder job {job_id} should run"
indexes = _job_gpu_indexes(job_id)
assert len(indexes) == 1, f"holder {job_id} took GPUs {sorted(indexes)}"
holder[next(iter(indexes))] = job_id
assert set(holder) == set(
range(GPUS)
), f"every GPU should be held, got {sorted(holder)}"
return holder
def _free_all_gpus_but(node, holder, keep):
"""Cancel every holder but the one on 'keep' and wait for the release.
JobState reads CANCELLED while the GRES are still allocated, so wait on
the node's own used-GPU set.
"""
for gpu, job_id in holder.items():
if gpu != keep:
atf.run_command(f"scancel {job_id}", fatal=True)
for _ in atf.timer(fatal=True):
if _node_used_gpus(node) == {keep}:
break
def test_enforce_links_spans_sockets_when_first_socket_unlinked():
"""With GPU 1 busy the only pair left is {2,3}, so a job whose cores span
both sockets must get it. Picking GPU 0 first, whose partner is busy,
would leave nothing to link to and lose the node."""
holder = _hold_every_gpu("node1")
_free_all_gpus_but("node1", holder, keep=1)
job_id = atf.submit_job_sbatch(
"--gres-flags=enforce-links --gpus-per-node=2 --ntasks=2 "
'--ntasks-per-socket=1 -w node1 --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "enforce-links job should run on the linked pair left on socket 1"
assert _job_gpu_indexes(job_id) == {
2,
3,
}, "enforce-links must select the linked pair {2,3}"
def test_no_flag_spans_sockets_when_first_socket_unlinked():
"""Control: the same request without the flag runs, so a pend is the link
requirement and not a shortage of GPUs or cores."""
holder = _hold_every_gpu("node1")
_free_all_gpus_but("node1", holder, keep=1)
job_id = atf.submit_job_sbatch(
"--gpus-per-node=2 --ntasks=2 --ntasks-per-socket=1 -w node1 "
'--wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "a job without enforce-links should run on the free GPUs"
assert (
len(_job_gpu_indexes(job_id)) == 2
), "a job without enforce-links should get two GPUs"
def test_enforce_links_per_socket_takes_the_cross_socket_pair():
"""Each pick is bound to its socket, but the set it draws from is the node's.
node3's only pair is {1,3}, one per socket, and GPU 0 comes first on
socket 0 linked to nothing. The pick for socket 0 must hand over GPU 1.
"""
job_id = atf.submit_job_sbatch(
"--gres-flags=enforce-links --gpus-per-socket=1 --sockets-per-node=2 "
'--ntasks=2 --ntasks-per-socket=1 -w node3 --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "enforce-links should run on node3's cross-socket linked pair"
assert _job_gpu_indexes(job_id) == {
1,
3,
}, "enforce-links must select the linked pair {1,3}"
def test_no_flag_per_socket_runs_on_node3():
"""Control: the same request without the flag runs, so a pend is the link
requirement and not a shortage of GPUs or cores."""
job_id = atf.submit_job_sbatch(
"--gpus-per-socket=1 --sockets-per-node=2 --ntasks=2 "
'--ntasks-per-socket=1 -w node3 --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "a job without enforce-links should run on node3"
assert (
len(_job_gpu_indexes(job_id)) == 2
), "a job without enforce-links should get one GPU on each socket"
def test_enforce_links_per_socket_never_yields_an_unlinked_pair():
"""node1's pairs each sit inside one socket, so no cross-socket pair is
linked. A per-socket request over both cannot have both the count and the
links, and the links hold: the job must not get one device per socket."""
job_id = atf.submit_job_sbatch(
"--gres-flags=enforce-links --gpus-per-socket=1 --sockets-per-node=2 "
'--ntasks=2 --ntasks-per-socket=1 -w node1 --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "the per-socket count should give way to a linked set, so the job runs"
assert _job_gpu_indexes(job_id) in (
{0, 1},
{2, 3},
), "enforce-links must give a linked pair, even at the cost of the count"
def test_no_flag_per_socket_runs_on_node1():
"""Control for the case above. The identical request without the flag runs
on node1, so the pend is caused by the link requirement and not by the
per-socket count or by a shortage of cores."""
job_id = atf.submit_job_sbatch(
"--gpus-per-socket=1 --sockets-per-node=2 --ntasks=2 "
'--ntasks-per-socket=1 -w node1 --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "a job without enforce-links should run on node1"
assert (
len(_job_gpu_indexes(job_id)) == 2
), "a job without enforce-links should get one GPU on each socket"
def test_gpus_flag_spread_when_unlinked_node_comes_first():
"""A per-job request names no count per node, so a node's share is the
leftover once the nodes still to come are set aside.
A node given a single device is exempt, and node2 can only ever be given
one. Its share has to be held to what it can supply for that to be seen:
the plain leftover reads as two, and node2 then loses the exemption. Only
the last node is safe from that, so node2 is deliberately the earlier.
"""
job_id = atf.submit_job_sbatch(
"--gres-flags=enforce-links --gpus=3 -N2 -w node2,node3 "
'--wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "enforce-links should spread a 3-GPU request over node2 and node3"
by_node = _job_gpus_by_node(job_id)
assert _job_gpu_count(job_id) == 3, f"the job should hold three GPUs, got {by_node}"
assert by_node.get("node3") == {
1,
3,
}, f"node3 should supply its linked pair, got {by_node}"
assert (
len(by_node.get("node2", set())) == 1
), f"node2 should supply the single device it is exempt for, got {by_node}"
def test_no_flag_spread_when_unlinked_node_comes_first():
"""Control: the same request without the flag runs, so a pend is the link
requirement and not a shortage of GPUs across the two nodes."""
job_id = atf.submit_job_sbatch(
'--gpus=3 -N2 -w node2,node3 --wrap "sleep infinity"', fatal=True
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "a 3-GPU request over two nodes should run without the flag"
assert (
_job_gpu_count(job_id) == 3
), "a job without enforce-links should get three GPUs"
def test_gpus_per_node_rejected_without_links():
"""Control: node2 has no link information, so --gpus-per-node=2 with
enforce-links is refused at submission."""
result = atf.run_command(
"sbatch --gres-flags=enforce-links --gpus-per-node=2 -N1 -w node2 "
'--wrap "true"',
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 rejected for the node configuration, got {result}"
def test_gpus_flag_rejected_without_links():
"""--gpus=2 -N1 asks for the same thing as --gpus-per-node=2 -N1, so it
must be refused on node2 for the same reason."""
result = atf.run_command(
'sbatch --gres-flags=enforce-links --gpus=2 -N1 -w node2 --wrap "true"',
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 rejected for the node configuration, got {result}"
def test_gpus_per_node_rejected_when_linked_set_too_small():
"""Control: node1's largest linked set is a pair, so a three GPU
enforce-links request is refused at submission."""
result = atf.run_command(
"sbatch --gres-flags=enforce-links --gpus-per-node=3 -N1 -w node1 "
'--wrap "true"',
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 rejected for the node configuration, got {result}"
def test_gpus_flag_rejected_when_linked_set_too_small():
"""--gpus=3 -N1 must be refused on node1 for the same reason as
--gpus-per-node=3 -N1: no three of its GPUs are mutually linked."""
result = atf.run_command(
'sbatch --gres-flags=enforce-links --gpus=3 -N1 -w node1 --wrap "true"',
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 rejected for the node configuration, got {result}"