blob: f8a0aea7d0dbd258ccdb75398aa89f42da831efc [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""gres.conf Cores= is a scheduling preference, not a constraint.
gres.conf(5) promises that the cores named by Cores= are *preferred* when
scheduling a generic resource, and that only --gres-flags=enforce-binding
turns that preference into a constraint. A job that fits by total CPUs and
GPUs must therefore be accepted even when no socket-local packing of its
tasks exists, and a job that does not fit by totals must still be refused.
Topology: two nodes, each 2 sockets x 3 cores, with 2 GPUs bound per socket.
With --cpus-per-task=2 only one task fits on a socket alongside its GPU, so
the socket-local packing tops out at 4 CPUs per node -- 8 across the cluster
-- while the nodes really offer 12. Requests above that line are the ones
that can only be placed by relaxing the preference.
Ticket 25596.
"""
import pytest
import atf
# Populated by setup() from the nodes require_nodes() actually configured.
gpu_nodes = []
# The two documented spellings of a per-task GPU request. The ticket and its
# reproducer use --tres-per-task; --gpus-per-task is the shorthand.
_PER_TASK_GPU_OPTS = [
pytest.param("--gpus-per-task=1", id="gpus_per_task"),
pytest.param("--tres-per-task=gres/gpu=1", id="tres_per_task"),
]
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_Core_Memory")
atf.require_config_parameter_includes("GresTypes", "gpu")
atf.require_nodes(
2,
[
("Sockets", 2),
("CoresPerSocket", 3),
("ThreadsPerCore", 1),
("Gres", "gpu:4"),
],
)
# GPUs need to point to existing device files
for i in range(4):
atf.require_tty(i)
# Scope gres.conf to the nodes require_nodes() created rather than assuming
# their names: if they were ever addressed wrongly the GPUs would come up
# with no Cores= binding and every test here would pass without exercising
# the binding at all.
global gpu_nodes
gpu_nodes = sorted(
name
for name, params in atf.get_nodes(live=False, quiet=True, fatal=True).items()
if name != "DEFAULT" and "gpu:" in str(params.get("Gres", ""))
)
assert (
len(gpu_nodes) == 2
), f"Expected require_nodes() to configure 2 GPU nodes, got {gpu_nodes}"
node_list = ",".join(gpu_nodes)
# Bind 2 GPUs to socket 0 (cores 0-2) and 2 GPUs to socket 1 (cores 3-5).
atf.require_config_parameter(
"NodeName",
f"{node_list} Name=gpu Cores=0-2 File=/dev/tty[0-1]\n"
f"NodeName={node_list} Name=gpu Cores=3-5 File=/dev/tty[2-3]",
source="gres",
)
atf.require_slurm_running()
def _submit_and_run(job_args):
"""Submit a job that must be accepted and run, returning its scontrol dict.
A request the scheduler will not place is refused by sbatch outright rather
than left pending, so the submission is checked before the state is. That
refusal is the behavior under test, not setup noise, which is why it is
asserted on here instead of passing fatal=True.
"""
job_id = atf.submit_job_sbatch(f'{job_args} --wrap "sleep infinity"')
assert job_id != 0, (
"Job should be accepted at submit: gres.conf Cores= is a preference, "
"so a request that fits by total CPUs and GPUs must be placed even "
"when no socket-local packing of its tasks exists"
)
assert atf.wait_for_job_state(job_id, "RUNNING"), (
f"Job {job_id} should run: gres.conf Cores= is a preference, so a "
"request that fits by total CPUs and GPUs must be placed even when no "
"socket-local packing of its tasks exists"
)
return atf.get_job(job_id)
@pytest.mark.xfail(
atf.get_version("sbin/slurmctld") < (26, 11),
reason="Ticket 25596: a request that only fits by cluster totals was rejected"
" when no socket-local packing existed. Fixed in 26.11.",
)
@pytest.mark.parametrize("gpu_opt", _PER_TASK_GPU_OPTS)
def test_job_beyond_socket_local_packing_runs(gpu_opt):
"""A request that only fits by cluster totals must still be placed.
5 tasks x (2 CPUs + 1 GPU) needs 10 CPUs and 5 GPUs. That fits across the
two nodes (12 CPUs, 8 GPUs) but exceeds the 8 CPUs the socket-local
packing allows, so it can only be placed by relaxing the preference. Every
task still lands on a socket holding one of the job's GPUs; the tasks and
their GPUs are simply no longer paired socket by socket.
"""
job = _submit_and_run(f"-n5 -c2 {gpu_opt}")
assert (
job["NumNodes"] == 2
), f"Job should span both nodes, got NumNodes={job['NumNodes']}"
assert (
job["NumCPUs"] == 10
), f"Job should hold 10 CPUs, got NumCPUs={job['NumCPUs']}"
assert (
job["JOB_GRES"] == "gpu:5"
), f"Job should hold 5 GPUs, got JOB_GRES={job['JOB_GRES']}"
def test_cpus_may_exceed_the_cores_bound_to_the_gres():
"""Without enforce-binding a job may use CPUs its GRES is not bound to.
2 tasks x 2 CPUs against a single GPU needs 4 CPUs, but only the 3 cores of
that GPU's socket are bound to it. Because Cores= is a preference, the
allocation is free to reach into the other socket. Paired with
test_enforce_binding_confines_job_to_its_gres_socket, which is the same
request under the constraint.
"""
job = _submit_and_run("-N1 -n2 -c2 --gres=gpu:1")
# 4 CPUs cannot fit on one 3-core socket, so this count is itself the
# proof that the allocation crossed sockets.
assert job["NumCPUs"] == 4, (
"Job should hold 4 CPUs, reaching past the 3 cores bound to its GPU, "
f"got NumCPUs={job['NumCPUs']}"
)
def test_disable_binding_allows_cross_socket_cpus():
"""--gres-flags=disable-binding must not prevent that cross-socket use.
gres.conf(5) presents disable-binding as the way to use more CPUs than are
bound to a GRES, so the same request must be placed with the flag as
without it.
"""
job = _submit_and_run("-N1 -n2 -c2 --gres=gpu:1 --gres-flags=disable-binding")
assert job["NumCPUs"] == 4, (
"Job should hold 4 CPUs, reaching past the 3 cores bound to its GPU, "
f"got NumCPUs={job['NumCPUs']}"
)
def test_enforce_binding_confines_job_to_its_gres_socket():
"""--gres-flags=enforce-binding turns the preference into a constraint.
The contrast half of test_cpus_may_exceed_the_cores_bound_to_the_gres: the
identical request must be refused, because sbatch(1) promises the only
CPUs available are those bound to the selected GRES -- here the 3 cores of
one socket, one short of the 4 the job needs.
"""
result = atf.run_job(
"-N1 -n2 -c2 --gres=gpu:1 --gres-flags=enforce-binding true",
xfail=True,
fatal=True,
)
assert "Requested node configuration is not available" in result["stderr"], (
"srun should be refused because enforce-binding confines the job to "
f"the 3 cores bound to its GPU, got stderr={result['stderr']!r}"
)
def test_gres_request_beyond_cluster_totals_refused():
"""Relaxing the preference must not conjure GRES the cluster lacks.
9 tasks x (1 CPU + 1 GPU) wants 9 GPUs where the cluster has 8. The CPU
demand is satisfiable, so the GRES count is the only thing that can refuse
this -- which makes it the sharper of the two totals checks.
"""
result = atf.run_job("-n9 -c1 --gpus-per-task=1 true", xfail=True, fatal=True)
assert "Requested node configuration is not available" in result["stderr"], (
"srun should be refused for wanting 9 GPUs where the cluster has 8, "
f"got stderr={result['stderr']!r}"
)
def test_cpu_request_beyond_cluster_totals_never_runs():
"""Relaxing the preference must not overcommit the cluster's CPUs.
7 tasks x 2 CPUs wants 14 CPUs where the cluster has 12. Unlike the GRES
case this is not refused at submit -- the controller leaves it queued --
so what must hold is that it never starts.
"""
job_id = atf.submit_job_sbatch(
'-n7 -c2 --gpus-per-task=1 --wrap "sleep infinity"', fatal=True
)
# Proving a negative costs the whole timeout, so keep it short: the other
# jobs in this module all reach RUNNING within a couple of seconds, and no
# amount of extra waiting makes a 14-CPU job fit on 12 CPUs. fatal=True is
# deliberately omitted -- not reaching RUNNING is the expected outcome.
assert not atf.wait_for_job_state(job_id, "RUNNING", timeout=15), (
"Job wanting 14 CPUs should never start on a 12-CPU cluster, but it "
f"reached RUNNING with "
f"NumCPUs={atf.get_job_parameter(job_id, 'NumCPUs', fatal=True)}"
)
assert (
atf.get_job_parameter(job_id, "JobState", fatal=True) == "PENDING"
), "Job wanting more CPUs than the cluster has should stay pending"