blob: d6279cd377aa5be6c1c0d545b050ca8076812a88 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""GRES device chunks above size 2: nesting, best fit and ChunkOrder tiles.
Two nodes with eight devices and ChunkSizes=2,4,8. "nest" numbers its chunk
positions in device order, so every pair lies in a chunk of 4. "tile" uses the
ChunkOrder of a 2x2 tile, so a chunk of 4 is two pairs that are not
neighbours. Every job is pinned with -w to the node whose layout it checks.
"""
import pytest
import atf
GPUS = 8
SIZES = "2,4,8"
NEST_PAIRS = [{0, 1}, {2, 3}, {4, 5}, {6, 7}]
TILE_ORDER = "0,1,4,5,2,3,6,7"
TILE_QUADS = [{0, 1, 4, 5}, {2, 3, 6, 7}]
node = {}
@pytest.fixture(scope="module", autouse=True)
def setup():
# slurmd parses ChunkSizes/ChunkOrder and scontrol shows them
for component in ["sbin/slurmd", "bin/scontrol"]:
atf.require_version(
(26, 11),
component,
reason="Issue 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
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_config_parameter_includes("GresTypes", "gpu")
atf.require_nodes(2, [("CPUs", 8), ("Gres", f"gpu:chunked:{GPUS}")])
for i in range(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:chunked:{GPUS}" in str(params.get("Gres", ""))
)
assert len(gpu_nodes) >= 2, f"Expected two GPU nodes, got {gpu_nodes}"
node.update(zip(["nest", "tile"], gpu_nodes))
records = {
"nest": f"Name=gpu Type=chunked File=/dev/tty[0-{GPUS - 1}] "
f"ChunkSizes={SIZES}",
"tile": f"Name=gpu Type=chunked File=/dev/tty[0-{GPUS - 1}] "
f"ChunkSizes={SIZES} ChunkOrder={TILE_ORDER}",
}
atf.require_config_parameter(
"NodeName",
"\nNodeName=".join(f"{node[role]} {line}" for role, line in records.items()),
source="gres",
)
atf.require_slurm_running()
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 _hold_pairs():
"""Hold every device of the nest node, a pair per job, keyed by the pair."""
holders = {}
for _ in NEST_PAIRS:
job_id = _run(f"-N1 -w {node['nest']} --gres=gpu:chunked:2")
holders[frozenset(atf.get_job_gres_indexes(job_id))] = job_id
assert set(holders) == {
frozenset(pair) for pair in NEST_PAIRS
}, f"Expected the jobs to hold the aligned pairs {NEST_PAIRS}, got {holders}"
return holders
@pytest.mark.parametrize(
"role, order",
[("nest", "0/1/2/3/4/5/6/7"), ("tile", TILE_ORDER.replace(",", "/"))],
)
def test_chunk_config_is_reported(role, order):
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')})"
)
output = atf.run_command_output(
f"scontrol show node -d {name}", fatal=True, quiet=True
)
expected = f"GresChunks=gpu:chunked:sizes={SIZES.replace(',', '/')};order={order}"
assert expected in output, f"Expected {expected} in: {output}"
def test_free_pairs_across_a_boundary_are_not_a_chunk_of_4():
"""Chunks nest, so a chunk of 4 is only ever two pairs of one chunk of 4.
Devices 2-5 are four free neighbours, but they straddle the two chunks
of 4, so a four device job has to wait until one of those is whole.
"""
holders = _hold_pairs()
atf.cancel_jobs(
[holders[frozenset({2, 3})], holders[frozenset({4, 5})]], fatal=True
)
job_id = atf.submit_job_sbatch(
f"-N1 -w {node['nest']} --gres=gpu:chunked:4 --wrap 'sleep infinity'",
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "PENDING", desired_reason="Resources"
), "Devices 2-5 are free but not one chunk of 4, so the job must wait"
atf.cancel_jobs([holders[frozenset({0, 1})]], fatal=True)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "Freeing devices 0-1 completes the chunk of 0-3"
indexes = atf.get_job_gres_indexes(job_id)
assert indexes == {0, 1, 2, 3}, f"Expected the chunk {{0, 1, 2, 3}}, got {indexes}"
def test_best_fit_keeps_a_chunk_of_4_whole():
"""A pair goes into the partly used chunk of 4, not into a free one.
With only devices 4-5 held, first fit would take 0-1 and leave no free
chunk of 4 for the next job.
"""
holders = _hold_pairs()
atf.cancel_jobs(
[job_id for pair, job_id in holders.items() if pair != {4, 5}], fatal=True
)
job_id = _run(f"-N1 -w {node['nest']} --gres=gpu:chunked:2")
indexes = atf.get_job_gres_indexes(job_id)
assert indexes == {
6,
7,
}, f"Best fit should complete the chunk of 4-7, got {indexes}"
job_id = _run(f"-N1 -w {node['nest']} --gres=gpu:chunked:4")
indexes = atf.get_job_gres_indexes(job_id)
assert indexes == {0, 1, 2, 3}, f"Expected the free chunk of 0-3, got {indexes}"
def test_chunk_order_forms_chunks_of_4():
"""A chunk of 4 is four consecutive chunk positions, wherever they lead."""
allocated = [
atf.get_job_gres_indexes(_run(f"-N1 -w {node['tile']} --gres=gpu:chunked:4"))
for _ in TILE_QUADS
]
for indexes in allocated:
assert indexes in TILE_QUADS, f"Expected one of {TILE_QUADS}, got {indexes}"
assert allocated[0] != allocated[1], f"Two jobs share chunk {allocated[0]}"
def test_best_fit_follows_chunk_order():
"""Best fit fills a chunk of 4 by chunk position, not by device index.
The second pair completes the chunk of 4 the first one started, which is
devices 4-5 here and would be 2-3 in device order.
"""
first = atf.get_job_gres_indexes(
_run(f"-N1 -w {node['tile']} --gres=gpu:chunked:2")
)
assert first == {0, 1}, f"Expected the first pair at positions 0-1, got {first}"
second = atf.get_job_gres_indexes(
_run(f"-N1 -w {node['tile']} --gres=gpu:chunked:2")
)
assert second == {4, 5}, f"Expected the pair at positions 2-3, got {second}"