blob: 3b04aa915bb51dd2402555215600128c9dad9187 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""GRES device chunk allocation (gres.conf ChunkSizes/ChunkOrder).
Four nodes, each with a different static chunk layout, so every allocation
rule is checked without rewriting gres.conf. Every job is pinned with -w to
the node whose layout it checks.
"""
import json
import re
import pytest
import atf
# Invalid for every layout below, so every chunk rule rejects it.
BAD_COUNT = 3
# "offset": a non-chunked Type holds devices 0-2 and the chunked Type 3-6, so
# chunk positions and device indexes differ, and pairs aligned from device 0
# would give {4, 5} instead. Links on the chunked records only pairs devices 4
# and 5, across the chunk boundary, and must be ignored. Links on the plain
# records pairs devices 0 and 1, and must still count.
PLAIN_GPUS = 3
OFFSET_GPUS = 4
OFFSET_PAIRS = [{3, 4}, {5, 6}]
# "order": the same devices, as two chunked Types with their own sizes.
# ChunkOrder numbers the chunked Type from 0, so chunk positions 0-1 hold its
# devices 0 and 2, which are device indexes 3 and 5.
PLAIN_SIZES = "1,3"
CHUNK_ORDER = "0,2,1,3"
ORDER_PAIRS = [{3, 5}, {4, 6}]
# "untyped": no Type and no Cores, the gres.html example shape, split over two
# lines that must both set ChunkSizes to form one domain
UNTYPED_GPUS = 4
# "six": one Type, several sizes so best fit is observable, largest not a power
# of two. Two sockets, so a whole-node --gpus-per-socket request fits.
SIX_GPUS = 6
SIX_SIZES = "1,2,6"
node = {}
@pytest.fixture(scope="module", autouse=True)
def setup():
# slurmd parses ChunkSizes/ChunkOrder, slurmctld allocates by them and
# scontrol shows them
for component in ["sbin/slurmd", "sbin/slurmctld", "bin/scontrol"]:
atf.require_version(
(26, 11),
component,
reason="Bug 51025: gres.conf ChunkSizes/ChunkOrder are new in 26.11",
)
atf.require_config_parameter("SelectType", "select/cons_tres")
# CR_CPU keeps a single-task job off a whole socket, so several chunk jobs
# fit at once. Overwrite rather than extend: the stock cr_core_memory
# cannot be combined with CR_CPU, and scontrol rejects the pair.
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
# A constrained job sees its devices renumbered from 0, which would hide a
# ChunkOrder permutation leaking into the job environment.
atf.require_config_parameter("ConstrainDevices", "no", source="cgroup")
atf.require_config_parameter_includes("GresTypes", "gpu")
# Logs which chunk rule turned a node down
atf.require_config_parameter_includes("DebugFlags", "SelectType")
atf.require_nodes(4, [("CPUs", 8), ("Gres", f"gpu:{SIX_GPUS}")])
for i in range(max(SIX_GPUS, PLAIN_GPUS + OFFSET_GPUS)):
atf.require_tty(i)
gpu_nodes = sorted(
name
for name, params in atf.get_nodes(live=False, quiet=True, fatal=True).items()
if name != "DEFAULT" and f"gpu:{SIX_GPUS}" in str(params.get("Gres", ""))
)
assert len(gpu_nodes) >= 4, f"Expected four GPU nodes, got {gpu_nodes}"
node.update(zip(["offset", "order", "untyped", "six"], gpu_nodes))
layouts = {
"offset": (
f"gpu:plain:{PLAIN_GPUS},gpu:chunked:{OFFSET_GPUS}",
[
"Name=gpu Type=plain File=/dev/tty0 Links=-1,1,0,0,0,0,0",
"Name=gpu Type=plain File=/dev/tty1 Links=1,-1,0,0,0,0,0",
"Name=gpu Type=plain File=/dev/tty2 Links=0,0,-1,0,0,0,0",
"Name=gpu Type=chunked File=/dev/tty3 ChunkSizes=2 "
"Links=0,0,0,-1,0,0,0",
"Name=gpu Type=chunked File=/dev/tty4 ChunkSizes=2 "
"Links=0,0,0,0,-1,1,0",
"Name=gpu Type=chunked File=/dev/tty5 ChunkSizes=2 "
"Links=0,0,0,0,1,-1,0",
"Name=gpu Type=chunked File=/dev/tty6 ChunkSizes=2 "
"Links=0,0,0,0,0,0,-1",
],
),
"order": (
f"gpu:plain:{PLAIN_GPUS},gpu:chunked:{OFFSET_GPUS}",
[
f"Name=gpu Type=plain File=/dev/tty[0-2] ChunkSizes={PLAIN_SIZES}",
f"Name=gpu Type=chunked File=/dev/tty[3-6] ChunkSizes=2 "
f"ChunkOrder={CHUNK_ORDER}",
],
),
"untyped": (
f"gpu:{UNTYPED_GPUS}",
[
"Name=gpu File=/dev/tty[0-1] ChunkSizes=2",
"Name=gpu File=/dev/tty[2-3] ChunkSizes=2",
],
),
"six": (
f"gpu:chunked:{SIX_GPUS}",
[f"Name=gpu Type=chunked File=/dev/tty[0-5] ChunkSizes={SIX_SIZES}"],
),
}
lines = []
atf.set_node_parameter(node["six"], "Sockets", 2)
atf.set_node_parameter(node["six"], "CoresPerSocket", 4)
atf.set_node_parameter(node["six"], "ThreadsPerCore", 1)
for role, (gres, records) in layouts.items():
atf.set_node_parameter(node[role], "Gres", gres)
lines += [f"{node[role]} {record}" for record in records]
atf.require_config_parameter("NodeName", "\nNodeName=".join(lines), source="gres")
atf.require_slurm_running()
def _parse_gres_chunks(value):
"""Return {gres: (sizes, order)} from a GresChunks value."""
return {
gres: (sizes, order)
for gres, sizes, order in re.findall(
r"([\w:]+):sizes=([^;,\s]+);order=([^;,\s]+)", value or ""
)
}
def _gres_chunks(name):
"""Return {gres: (sizes, order)} from scontrol -d, one entry per Type."""
output = atf.run_command_output(
f"scontrol show node -d {name}", fatal=True, quiet=True
)
return _parse_gres_chunks((re.search(r"GresChunks=(\S+)", output) or [None, ""])[1])
def _json_gres_chunks(name):
"""Return {gres: (sizes, order)} from the gres_chunks field of --json."""
output = atf.run_command_output(
f"scontrol --json show node -d {name}", fatal=True, quiet=True
)
return _parse_gres_chunks(json.loads(output)["nodes"][0].get("gres_chunks"))
def _run(job_args, state="RUNNING"):
"""Submit a job, wait for state, and return its ID."""
job_id = atf.submit_job_sbatch(f"{job_args} --wrap 'sleep infinity'", fatal=True)
assert atf.wait_for_job_state(
job_id, state
), f"Job {job_id} ({job_args}) should reach {state}"
return job_id
def _assert_rejected(job_args, command="sbatch"):
if command == "sbatch":
job_args += " --wrap 'true'"
else:
job_args += " true"
error = atf.run_command_error(f"{command} {job_args}", xfail=True, fatal=True)
assert (
"Requested node configuration is not available" in error
), f"{job_args} should be rejected at submit. Got: {error}"
def _log_count(log_file, text):
output = atf.run_command_output(
f"grep -c '{text}' {log_file}", user="root", quiet=True
)
return int(output.strip() or 0)
def _assert_two_aligned_chunks(job_args, pairs):
"""Two concurrent jobs must land on distinct aligned chunks.
Holding two at once makes the second one take the other chunk. This
checks the chunk boundaries the jobs see, and
test_misaligned_free_devices_are_not_a_chunk checks that free devices
across a boundary are not taken as a chunk.
"""
allocated = [atf.get_job_gres_indexes(_run(job_args)) for _ in range(2)]
for indexes in allocated:
assert indexes in pairs, f"Expected one of {pairs}, got {indexes}"
assert allocated[0] != allocated[1], f"Two jobs share chunk {allocated[0]}"
@pytest.mark.parametrize(
"role, chunks",
[
("offset", {"gpu:chunked": ("2", "0/1/2/3")}),
(
"order",
{
"gpu:plain": (PLAIN_SIZES.replace(",", "/"), "0/1/2"),
"gpu:chunked": ("2", CHUNK_ORDER.replace(",", "/")),
},
),
("untyped", {"gpu": ("2", "0/1/2/3")}),
("six", {"gpu:chunked": (SIX_SIZES.replace(",", "/"), "0/1/2/3/4/5")}),
],
)
def test_chunk_config_is_reported(role, chunks):
"""Each layout registers without draining and reports its chunk state.
The order is numbered within each Type, as ChunkOrder is written. offset:
only the chunked Type has a domain, with Links on the record not rejected.
order: each chunked Type has its own. untyped: records with no Type and no
Cores still build one, and both lines join it. The JSON output carries the
same value.
"""
name = node[role]
assert atf.wait_for_node_state(name, "IDLE", operator=atf.SetMatch.EQUAL), (
f"{name} should register IDLE (state="
f"{atf.get_node_parameter(name, 'state')}, "
f"reason={atf.get_node_parameter(name, 'reason')})"
)
assert (
_gres_chunks(name) == chunks
), f"Expected {name} to report GresChunks {chunks}"
assert (
_json_gres_chunks(name) == chunks
), f"Expected {name} to report gres_chunks {chunks} in its JSON output"
def test_chunk_config_is_only_shown_with_details():
output = atf.run_command_output(
f"scontrol show node {node['offset']}", fatal=True, quiet=True
)
assert "GresChunks" not in output, "GresChunks is documented as -d only"
@pytest.mark.parametrize(
"job_args, pairs",
[
("--gres=gpu:chunked:2", OFFSET_PAIRS),
("--gpus-per-node=chunked:2", OFFSET_PAIRS),
("--gpus-per-task=chunked:2 -n1", OFFSET_PAIRS),
],
ids=["gres", "gpus_per_node", "gpus_per_task"],
)
def test_offset_chunks_are_aligned(job_args, pairs):
"""Chunks are aligned within the Type, not from device 0.
Aligning from device 0, or honouring Links, which pairs devices across the
boundary, would give {4, 5}.
"""
_assert_two_aligned_chunks(f"-N1 -w {node['offset']} {job_args}", pairs)
def test_enforce_links_is_not_applied_to_chunks():
"""--gres-flags=enforce-links does not apply to chunked devices.
Links on the chunked records only pairs devices 4 and 5, which is no
aligned chunk, so enforcing them would leave no pair to run on.
"""
_assert_two_aligned_chunks(
f"-N1 -w {node['offset']} --gres=gpu:chunked:2 --gres-flags=enforce-links",
OFFSET_PAIRS,
)
def test_links_of_other_types_still_apply():
"""Links is only dropped from chunked records, not from the whole node.
enforce-links rejects devices with no links, so the plain pair only runs
if its Links survived.
"""
job_id = _run(
f"-N1 -w {node['offset']} --gres=gpu:plain:2 --gres-flags=enforce-links"
)
assert atf.get_job_gres_indexes(job_id) == {
0,
1,
}, "The linked plain pair should be allocated"
def test_slurmd_warns_about_dropped_links():
log_file = atf.get_config_parameter("SlurmdLogFile").replace("%n", node["offset"])
assert (
_log_count(
log_file,
"Ignoring Links for Name=gpu Type=chunked, ChunkSizes takes precedence",
)
>= OFFSET_GPUS
), f"Expected a dropped Links warning per chunked line in {log_file}"
def test_order_chunks_follow_chunk_order():
"""Chunks are aligned ranges of chunk positions, not device indexes.
Applying ChunkOrder as raw device indexes, or ignoring it, gives a
different pair.
"""
_assert_two_aligned_chunks(
f"-N1 -w {node['order']} --gres=gpu:chunked:2", ORDER_PAIRS
)
def test_untyped_chunks_are_aligned():
_assert_two_aligned_chunks(
f"-N1 -w {node['untyped']} --gres=gpu:2", [{0, 1}, {2, 3}]
)
def test_each_type_uses_its_own_sizes():
"""Two chunked Types on one node each follow their own ChunkSizes.
1 is a size of plain only, so it runs there and is rejected for chunked.
"""
job_id = _run(f"-N1 -w {node['order']} --gres=gpu:plain:1")
assert atf.get_job_gres_indexes(job_id) in (
{0},
{1},
{2},
), "A plain request should get one of the plain devices"
_assert_rejected(f"-N1 -w {node['order']} --gres=gpu:chunked:1")
def test_untyped_request_on_a_single_type_node_follows_its_chunks():
"""An untyped request on a node of one chunked Type follows its rules."""
job_id = _run(f"-N1 -w {node['six']} --gres=gpu:2")
assert atf.get_job_gres_indexes(job_id) in (
{0, 1},
{2, 3},
{4, 5},
), "An untyped request should get an aligned pair"
@pytest.mark.parametrize(
"job_args",
[
"--gpus=chunked:2 --gpus-per-node=chunked:2",
"--gpus=chunked:2 --gpus-per-task=chunked:2 -n1",
],
ids=["gpus_per_node", "gpus_per_task"],
)
def test_gpus_with_a_fixed_per_node_count_runs(job_args):
"""--gpus is accepted once another option fixes the count per node."""
job_id = _run(f"-N1 -w {node['six']} {job_args}")
assert atf.get_job_gres_indexes(job_id) in (
{0, 1},
{2, 3},
{4, 5},
), "The job should get an aligned pair"
def test_chunk_order_does_not_permute_the_job_environment():
"""ChunkOrder only permutes how chunks are formed, not what jobs see."""
job_id = atf.submit_job_sbatch(
f"-N1 -w {node['order']} --gres=gpu:chunked:2 "
"--wrap 'echo $CUDA_VISIBLE_DEVICES > visible.out; sleep infinity'",
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), f"Expected job {job_id} to reach RUNNING"
expected = ",".join(str(i) for i in sorted(atf.get_job_gres_indexes(job_id)))
atf.assert_file_contents(
"visible.out",
expected,
message=f"CUDA_VISIBLE_DEVICES should be {expected}",
)
@pytest.mark.parametrize(
"job_args",
[
"--gres=gpu:chunked:2",
f"--gres=gpu:chunked:{OFFSET_GPUS}",
"--gres=gpu:plain:1",
],
ids=["chunk_size", "whole_domain", "non_chunked_type"],
)
def test_allowed_requests_run(job_args):
"""A chunk size, the whole domain, or another Type all run.
The whole domain is allowed even though it is not a configured size, and
the plain Type is outside the domain so 1 is fine there.
"""
_run(f"-N1 -w {node['offset']} {job_args}")
@pytest.mark.parametrize(
"job_args",
[
f"-N1 -w {{offset}} --gres=gpu:chunked:{BAD_COUNT}",
"-N1 -w {offset} --gres=gpu:2",
"-N1 -w {order} --gres=gpu:2",
"-N1 -w {untyped} --gres=gpu:1",
"-N1 -w {six} --gpus=2",
"-N1 -w {six} --gpus-per-socket=chunked:2 --sockets-per-node=1",
f"-N1 -w {{offset}} --exclusive=user --gres=gpu:chunked:{BAD_COUNT}",
f"-N1 -w {{six}} --core-spec=1 --gres=gpu:chunked:{BAD_COUNT}",
f"-N1 -w {{six}} --thread-spec=1 --gres=gpu:chunked:{BAD_COUNT}",
],
ids=[
"non_chunk_size",
"untyped_on_mixed_node",
"untyped_on_all_chunked_mixed_node",
"untyped_non_chunk_size",
"gpus",
"gpus_per_socket",
"exclusive_user",
"core_spec_not_allowed",
"thread_spec_not_allowed",
],
)
def test_bad_requests_are_rejected(job_args):
"""Requests that cannot be an aligned chunk are rejected at submit.
An untyped request on a node that mixes Types could span them, although 2
is a chunk size, whether the other Type is plain or chunked too. --gpus
leaves the per-node split open and --gpus-per-socket asks for a socket
shape, both on a node of one chunked Type with a valid size, so no other
rule applies.
--exclusive=user still shares the node, so it is not exempt, and nor are
--core-spec and --thread-spec without AllowSpecResourcesUsage.
"""
_assert_rejected(job_args.format(**node))
@pytest.mark.parametrize("count", [2, BAD_COUNT], ids=["chunk_size", "non_chunk_size"])
def test_exclusive_receives_every_device(count):
"""A whole-node job is exempt and receives every device on the node."""
job_id = _run(f"--exclusive -N1 -w {node['offset']} --gres=gpu:chunked:{count}")
assert atf.get_job_gres_indexes(job_id) == set(
range(PLAIN_GPUS + OFFSET_GPUS)
), "A whole-node job should receive every device on the node"
def test_exclusive_allows_gpus_per_socket():
"""--gpus-per-socket is allowed in a whole-node allocation."""
job_id = _run(
f"--exclusive -N1 -w {node['six']} --gpus-per-socket=chunked:2 "
"--sockets-per-node=1"
)
assert atf.get_job_gres_indexes(job_id) == set(
range(SIX_GPUS)
), "A whole-node job should receive every device on the node"
@pytest.fixture
def exclusive_partition():
name = "chunk_exclusive"
atf.run_command(
f"scontrol create partitionname={name} nodes={node['six']} "
"oversubscribe=exclusive",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
yield name
atf.cancel_all_jobs(quiet=True)
atf.run_command(
f"scontrol delete partitionname={name}",
user=atf.properties["slurm-user"],
quiet=True,
)
def test_exclusive_partition_receives_every_device(exclusive_partition):
"""A partition with OverSubscribe=EXCLUSIVE is a whole-node allocation."""
job_id = _run(f"-p {exclusive_partition} -N1 --gres=gpu:chunked:{BAD_COUNT}")
assert atf.get_job_gres_indexes(job_id) == set(
range(SIX_GPUS)
), "A whole-node job should receive every device on the node"
@pytest.mark.parametrize("spec", ["--core-spec=1", "--thread-spec=1"])
def test_spec_options_leave_the_node_shared(spec):
"""Without AllowSpecResourcesUsage these do not make the job whole-node.
The job still follows the chunk rules and gets only its chunk.
"""
job_id = _run(f"-N1 -w {node['six']} {spec} --gres=gpu:chunked:2")
assert atf.get_job_gres_indexes(job_id) in (
{0, 1},
{2, 3},
{4, 5},
), "The job should get an aligned pair, not the whole node"
def test_steps_are_not_chunk_allocated():
"""A step inside an allocation may take any subset of the job's devices."""
job_id = atf.submit_job_sbatch(
f"-N1 -w {node['offset']} --gres=gpu:chunked:2 "
"--wrap 'srun --overlap --gres=gpu:chunked:1 true && "
"srun --overlap --gres=gpu:chunked:2 true'",
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "COMPLETED"
), "A step of 1 device should run inside a 2 device allocation"
def test_concurrent_single_device_steps_use_every_device():
"""One-device steps may fill a chunk side by side.
The steps start together and hold their device for a while, so a step
that could not get a device of its own would have to wait for another
step's, and both would report the same one.
"""
atf.make_bash_script(
"steps.sh",
f"""for i in $(seq {OFFSET_GPUS}); do
srun --exact -n1 --gres=gpu:chunked:1 \\
bash -c 'echo $CUDA_VISIBLE_DEVICES; sleep 5' > step.$i.out &
done
wait
touch steps.done
sleep infinity
""",
)
job_id = atf.submit_job_sbatch(
f"-N1 -n{OFFSET_GPUS} -w {node['offset']} "
f"--gres=gpu:chunked:{OFFSET_GPUS} steps.sh",
fatal=True,
)
assert atf.wait_for_file(
"steps.done"
), f"Expected the steps of job {job_id} to finish"
devices = [
atf.run_command_output(f"cat step.{i}.out", fatal=True, quiet=True).strip()
for i in range(1, OFFSET_GPUS + 1)
]
assert all(
d.isdigit() for d in devices
), f"Expected each step to get exactly one device, got {devices}"
assert {int(d) for d in devices} == atf.get_job_gres_indexes(
job_id, fatal=True
), f"Expected the steps to use every device of the job once, got {devices}"
def _run_one_six_device_job():
job_id = _run(f"-N1 -w {node['six']} --gres=gpu:chunked:1")
indexes = atf.get_job_gres_indexes(job_id)
assert len(indexes) == 1, f"Expected exactly one device, got {indexes}"
return job_id, next(iter(indexes))
def _fill_six():
"""Hold every device of the six node, one job each, keyed by device."""
holders = {}
for _ in range(SIX_GPUS):
job_id, device = _run_one_six_device_job()
holders[device] = job_id
assert set(holders) == set(range(SIX_GPUS)), holders
return holders
@pytest.fixture
def upper_pair_holder():
"""Fill the six node, then free everything but device 4."""
holders = _fill_six()
atf.cancel_jobs([holders[i] for i in (0, 1, 2, 3, 5)], fatal=True)
def test_misaligned_free_devices_are_not_a_chunk():
"""Two free neighbours that straddle a chunk boundary are not a chunk.
With devices 1 and 2 free, first fit would run a two device job on them.
It has to wait until a whole aligned pair is free, and then take it.
"""
holders = _fill_six()
atf.cancel_jobs([holders[1], holders[2]], fatal=True)
job_id = atf.submit_job_sbatch(
f"-N1 -w {node['six']} --gres=gpu:chunked:2 --wrap 'sleep infinity'",
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "PENDING", desired_reason="Resources"
), "Devices 1 and 2 are free but not an aligned pair, so the job must wait"
atf.cancel_jobs([holders[0]], fatal=True)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "Freeing device 0 completes the pair of 0 and 1"
indexes = atf.get_job_gres_indexes(job_id)
assert indexes == {0, 1}, f"Expected the aligned pair {{0, 1}}, got {indexes}"
def test_best_fit_completes_a_partly_used_chunk(upper_pair_holder):
"""Best fit fills device 5 and keeps the free lower pair for bigger jobs.
First fit would take device 0 and split a pair a two device job needs.
"""
_, device = _run_one_six_device_job()
assert device == 5, f"Best fit should complete the pair of 4, got {device}"
job_id = _run(f"-N1 -w {node['six']} --gres=gpu:chunked:2")
indexes = atf.get_job_gres_indexes(job_id)
assert indexes in (
{0, 1},
{2, 3},
), f"A two device job should get a whole free pair, got {indexes}"
def test_single_node_request_needs_no_node_count():
"""One task already pins the job to one node, so no -N1 is needed."""
_run("--gres=gpu:chunked:2", state="RUNNING")
@pytest.mark.parametrize(
"job_args, devices",
[
(
f"--gres=gpu:chunked:{OFFSET_GPUS}",
set(range(PLAIN_GPUS, PLAIN_GPUS + OFFSET_GPUS)),
),
(
"-n4 --ntasks-per-node=2 --gpus-per-task=chunked:2",
set(range(PLAIN_GPUS, PLAIN_GPUS + OFFSET_GPUS)),
),
("--exclusive --gres=gpu:chunked:2", set(range(PLAIN_GPUS + OFFSET_GPUS))),
],
ids=["per_node", "per_task", "exclusive"],
)
def test_multi_node_takes_every_device(job_args, devices):
"""A job spanning nodes must hold the whole domain on each of them.
The offset and order nodes share the chunked Type's layout. An exclusive
job is exempt and gets every device on each node.
"""
job_id = _run(f"-N2 -w {node['offset']},{node['order']} {job_args}")
node_cnt = int(atf.get_job_parameter(job_id, "NumNodes"))
assert node_cnt == 2, f"Expected the job to span 2 nodes, got {node_cnt}"
# One block per group of nodes with an identical allocation, so checking
# every block with the node count rules out half a domain on each node
for indexes in atf.get_job_gres_index_blocks(job_id):
assert indexes == devices, f"Each node must give {devices}, got {indexes}"
@pytest.mark.parametrize(
"job_args",
[
"-N2 --gres=gpu:chunked:2",
"--nodes=1-2 --gres=gpu:chunked:2",
"--gpus-per-task=2 -n2",
"--tres-per-task=gres/gpu=2 -n2",
"-n2 --gres=gpu:chunked:2",
"-n2 --gpus-per-node=chunked:2",
"-n2 --ntasks-per-node=1 --gpus-per-task=chunked:2",
"-N2 -w {offset},{order} -n4 --gpus-per-task=chunked:2",
"-w {offset},{order} -n3 --ntasks-per-node=2 --gpus-per-task=chunked:2",
],
ids=[
"fixed_two_nodes",
"node_range",
"per_task",
"tres_per_task",
"tasks_per_node_gres",
"tasks_gpus_per_node",
"one_task_per_node",
"node_count_without_tasks_per_node",
"uneven_tasks_per_node",
],
)
def test_unpinned_partial_requests_are_rejected(job_args):
"""A partial request that could span nodes is rejected at submit.
Node filtering only knows the node bounds, so leaving it through would
fail at allocation and retry forever. Several tasks without -N1 could be
spread over nodes, and one task per node fixes a per-node count that
spans nodes, which is still partial. A node count alone does not fix the
tasks on each node, and three tasks two per node leave one node with less
than its whole domain.
"""
_assert_rejected(job_args.format(**node))
WHOLE_UNTYPED = [set(range(UNTYPED_GPUS))]
UNTYPED_PAIRS = [{0, 1}, {2, 3}]
@pytest.mark.parametrize(
"job_args, expected",
[
("--gpus-per-task=2 -n2 --ntasks-per-node=2", WHOLE_UNTYPED),
("--gpus-per-task=2 -n2 -N1", WHOLE_UNTYPED),
("--tres-per-task=gres/gpu=2 -n2 --ntasks-per-node=2", WHOLE_UNTYPED),
("--tres-per-task=gres/gpu=2 -n2 -N1", WHOLE_UNTYPED),
("--gpus-per-task=1 -n2 -N1", UNTYPED_PAIRS),
("--tres-per-task=gres/gpu=1 -n2 -N1", UNTYPED_PAIRS),
],
ids=[
"ntasks_per_node",
"one_node",
"tres_ntasks_per_node",
"tres_one_node",
"one_node_partial",
"tres_one_node_partial",
],
)
def test_pinned_per_task_requests_run(job_args, expected):
"""Pinning tasks per node makes a per task request definite.
With -N1 the total may also be less than the whole domain.
"""
job_id = _run(f"-w {node['untyped']} {job_args}")
indexes = atf.get_job_gres_indexes(job_id)
assert indexes in expected, f"Expected one of {expected}, got {indexes}"
@pytest.mark.parametrize("command", ["srun", "salloc"])
def test_srun_and_salloc_follow_the_chunk_rules(command):
"""The rules hold when srun or salloc creates the allocation."""
_assert_rejected(f"-N1 -w {node['offset']} --gres=gpu:chunked:{BAD_COUNT}", command)
step = "printenv CUDA_VISIBLE_DEVICES"
if command == "salloc":
step = f"srun {step}"
output = atf.run_command_output(
f"{command} -N1 -w {node['offset']} --gres=gpu:chunked:2 {step}",
fatal=True,
)
devices = {int(d) for d in output.strip().split(",")}
assert devices in OFFSET_PAIRS, f"Expected one of {OFFSET_PAIRS}, got {devices}"
def test_rejected_node_is_logged():
"""DebugFlags=SelectType names the chunk rule that turned a node down."""
log_file = atf.get_config_parameter("SlurmctldLogFile")
text = "which is not one of the configured ChunkSizes"
before = _log_count(log_file, text)
_assert_rejected(f"-N1 -w {node['six']} --gres=gpu:chunked:{BAD_COUNT}")
assert (
_log_count(log_file, text) > before
), f"Expected a new chunk rule line in {log_file}"