blob: 140c3eaeba8dfd3f207269f7e33f661ffad8db79 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Job-scoped GPU requests combined with --segment on topology/block.
Ticket 25621: --gpus (and other forms that synthesize a job-scoped GPU
count) combined with --segment never scheduled, because the whole-job
GPU target was not apportioned per segment. Verify the job now allocates
and that a count not divisible by the segment count is rejected.
"""
import itertools
import pytest
import atf
# 8 nodes, each with 4 GPUs, split into four topology/block base blocks.
NUM_NODES = 8
GPUS_PER_NODE = 4
# Jobs take half the cluster, so the block plugin has more than one candidate
# node set and segment placement can be got wrong.
JOB_NODES = 4
topology_conf = """
BlockName=b1 Nodes=node[1-2]
BlockName=b2 Nodes=node[3-4]
BlockName=b3 Nodes=node[5-6]
BlockName=b4 Nodes=node[7-8]
BlockSizes=2,4,8
"""
BLOCK_SIZE = 2
BASE_BLOCKS = [
{"node1", "node2"},
{"node3", "node4"},
{"node5", "node6"},
{"node7", "node8"},
]
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 5),
"sbin/slurmctld",
reason="Ticket 25621: per-segment job-scoped GRES was added in 26.05",
)
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_Core")
atf.require_config_parameter("TopologyPlugin", "topology/block")
atf.require_config_parameter_includes("GresTypes", "gpu")
# Fake GPUs: one tty per GPU on a node (shared across nodes on this host).
for tty_num in range(GPUS_PER_NODE):
atf.require_tty(tty_num)
atf.require_config_parameter(
"Name", {"gpu": {"File": f"/dev/tty[0-{GPUS_PER_NODE - 1}]"}}, source="gres"
)
atf.require_nodes(NUM_NODES, [("Gres", f"gpu:{GPUS_PER_NODE}"), ("CPUs", 8)])
atf.require_config_file("topology.conf", topology_conf)
atf.require_slurm_running()
def _valid_placements(num_nodes):
"""Node sets that are a union of whole base blocks."""
return [
set().union(*combo)
for combo in itertools.combinations(BASE_BLOCKS, num_nodes // BLOCK_SIZE)
]
def _submit_and_get_alloc(job_args):
"""Submit a job, wait until it runs and return (job_id, num_nodes, job_gres)."""
job_id = atf.submit_job("sbatch", f"-t1 {job_args}", "sleep infinity", fatal=True)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), f"Job with '{job_args}' should have started running"
num_nodes = int(atf.get_job_parameter(job_id, "NumNodes", fatal=True))
job_gres = atf.get_job_parameter(job_id, "JOB_GRES", fatal=True)
return job_id, num_nodes, job_gres
# Job-scoped GPU requests (gres_per_job) combined with multiple segments used
# to fail because the whole-job GPU target was not apportioned per segment.
# --gpus-per-node is node-scoped and always worked; keep it as a control.
# -N8 --segment=2 is four segments, so the accumulator is reset more than once.
# -N4 --segment=4 is a single segment, which the divisibility rule does not apply
# to: the same --gpus=7 is rejected at --segment=2.
runnable_params = [
("-N4 --gpus=16 --segment=2", 4, 16),
("-N4 --gpus=8 --segment=2", 4, 8),
("-N4 --gpus-per-node=4 --segment=2", 4, 16),
("-N4 -n16 --gpus-per-task=1 --segment=2", 4, 16),
("-N8 --gpus=32 --segment=2", 8, 32),
("-N4 --gpus=7 --segment=4", 4, 7),
]
@pytest.mark.parametrize("job_args,exp_nodes,exp_gpus", runnable_params)
def test_segment_gpus_allocates(job_args, exp_nodes, exp_gpus):
"""Segmented job-scoped GPU requests allocate all nodes and GPUs."""
job_id, num_nodes, job_gres = _submit_and_get_alloc(job_args)
assert num_nodes == exp_nodes, f"Expected {exp_nodes} nodes, got {num_nodes}"
assert (
job_gres == f"gpu:{exp_gpus}"
), f"Expected JOB_GRES gpu:{exp_gpus}, got '{job_gres}'"
node_list = atf.get_job_parameter(job_id, "NodeList", fatal=True)
valid = _valid_placements(exp_nodes)
assert (
set(atf.node_range_to_list(node_list)) in valid
), f"Each segment should fill whole base blocks {valid}, got '{node_list}'"
def test_segment_gpus_indivisible_rejected():
"""A job-scoped GPU count not divisible by the segment count is rejected
at submit.
-N4 --segment=2 gives 2 segments; --gpus=7 is not divisible by 2.
"""
result = atf.run_command(
f'sbatch -t1 -N{JOB_NODES} --segment=2 --gpus=7 --wrap="true"', xfail=True
)
assert (
result["exit_code"] != 0
), "Indivisible job-scoped GPU request should be rejected at submit"
assert (
"Requested topology configuration is not available" in result["stderr"]
), f"Expected the topology rejection, got '{result['stderr']}'"