blob: 364cb7bc994a0b6bc652a5f1db63d448fc6ca649 [file]
############################################################################
# Copyright (C) SchedMD LLC.
############################################################################
"""GPU selection for jobs and steps when GPUs are bound to specific cores."""
import re
import pytest
import atf
# One GPU per socket, so a single-socket step forces pass 2 of the GRES
# selection to fill more than one remaining device.
_NUM_GPUS = 4
# Spare sockets that hold no GPU of their own, so a request that cannot be
# placed on a GPU-bound socket is one --gres-flags=enforce-binding has to
# reject rather than one the node simply cannot fit.
_NUM_SOCKETS = _NUM_GPUS + 2
_ALL_GPUS = ",".join(str(i) for i in range(_NUM_GPUS))
_OVERLAP_GRES_UNFIXED = atf.get_version("sbin/slurmctld") < (26, 5, 3)
_OVERLAP_GRES_REASON = "Ticket 24862: --overlap GRES double-select fixed in 26.05.3"
node_name = None
@pytest.fixture(scope="module", autouse=True)
def setup():
global node_name
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_Socket_Memory")
atf.require_config_parameter_includes("GresTypes", "gpu")
atf.require_nodes(
1,
[
("Sockets", _NUM_SOCKETS),
("CoresPerSocket", 1),
("ThreadsPerCore", 1),
("Gres", f"gpu:{_NUM_GPUS}"),
],
)
# GPU's need to point to existing device files
for i in range(_NUM_GPUS):
atf.require_tty(i)
gpu_nodes = [
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 gpu_nodes, "Expected require_nodes() to configure a node with GPUs"
node_name = gpu_nodes[0]
# Bind GPU i to socket i (core i), so a step on one socket owns just one
# GPU and pass 2 must supply the rest.
gpu_lines = []
for i in range(_NUM_GPUS):
prefix = node_name if i == 0 else f"NodeName={node_name}"
gpu_lines.append(f"{prefix} Name=gpu Cores={i} File=/dev/tty{i}")
atf.require_config_parameter("NodeName", "\n".join(gpu_lines), source="gres")
atf.require_slurm_running()
@pytest.fixture(scope="function")
def single_socket_gpu_job():
"""Job holding every GPU, with its cores confined to a single socket.
The preconditions are asserted here so that every test sharing the
fixture checks them in one place, and so that a topology that does not
reproduce the condition says which precondition it failed rather than
only that the step came up short on GPUs.
"""
job_id = atf.submit_job_sbatch(
f"-N1 -n1 --gpus-per-node={_NUM_GPUS} --wrap 'sleep infinity'",
fatal=True,
)
atf.wait_for_job_state(job_id, "RUNNING", fatal=True)
job_gres = atf.get_job_parameter(job_id, "GRES", fatal=True, quiet=True)
assert job_gres and f"gpu:{_NUM_GPUS}(IDX:" in job_gres, (
f"Expected job to hold every GPU (gpu:{_NUM_GPUS}(IDX:...)), "
f"got GRES={job_gres!r} - topology did not produce the "
f"expected no-enforce-binding allocation"
)
num_cpus = atf.get_job_parameter(job_id, "NumCPUs", fatal=True, quiet=True)
assert num_cpus == 1, (
f"Expected job to hold a single socket (1 CPU with CoresPerSocket=1), "
f"got NumCPUs={num_cpus} - topology did not confine the job to one "
f"socket, so the step already covers every GPU-bound socket"
)
return job_id
def test_gpu_socket_sharing():
"""Test allocating multiple core-bound gpus with enforce-binding"""
output = atf.run_command_output(
f"srun --gres-flags=enforce-binding --ntasks-per-socket=1 \
--cpus-per-task=1 --ntasks-per-node=2 -N1 \
--gpus-per-task=1 scontrol show nodes {node_name} -d",
fatal=True,
)
assert (
re.search(r"GresUsed=gpu:(?:[^:]*:)?2\(", output) is not None
), "Verify that job allocated 2 gpus"
def test_gpu_socket_sharing_no_alloc():
"""Test allocating multiple core-bound gpus with enforce-binding without enough resources"""
output = atf.run_command(
f"srun --gres-flags=enforce-binding --ntasks-per-socket=1 \
--cpus-per-task=2 --ntasks-per-node=2 -N1 \
--gpus-per-task=1 scontrol show nodes {node_name} -d",
xfail=True,
fatal=True,
)
assert output["exit_code"] != 0, "Verify that srun command failed"
expected_msg = r"srun: error: .+ Requested node configuration is not available"
assert (
re.search(
expected_msg,
str(output["stderr"]),
)
is not None
), "Verify that job is rejected with the right reason."
@pytest.mark.xfail(_OVERLAP_GRES_UNFIXED, reason=_OVERLAP_GRES_REASON)
def test_overlap_step_gets_all_gpus(single_socket_gpu_job):
"""An --overlap step must receive every GPU it requests.
srun --overlap shares all of the job's GRES with the step. The batch
job requests -n1 so CR_Socket_Memory confines its cores to one socket,
while --gpus-per-node (no enforce-binding) allocates every GPU across
all sockets. The --overlap step lands on that single socket but, per the
srun default, inherits every GPU the job holds, so it must be granted
all of them even though most are bound to sockets its cores do not
cover. A short allocation shows up as a denied device in the step's
cgroup if task/cgroup and ConstrainDevices=yes is configured
(ticket 24862 regression).
"""
job_id = single_socket_gpu_job
result = atf.run_job(
f"--jobid={job_id} --overlap -n1 --mem=0 "
f"bash -c 'echo $SLURM_STEP_GPUS; echo $CUDA_VISIBLE_DEVICES'",
fatal=True,
quiet=True,
)
output = result["stdout"].splitlines()
assert len(output) >= 2, (
f"Expected two output lines (SLURM_STEP_GPUS and "
f"CUDA_VISIBLE_DEVICES), got {result['stdout']!r}"
)
step_gpus = output[0].strip()
visible_devices = output[1].strip()
assert step_gpus == _ALL_GPUS, (
f"Step SLURM_STEP_GPUS should be {_ALL_GPUS!r} (every GPU), "
f"got {step_gpus!r} - the --overlap step was not granted all of "
f"the job's GPUs (ticket 24862 regression)"
)
assert visible_devices == _ALL_GPUS, (
f"Step CUDA_VISIBLE_DEVICES should be exported and agree with "
f"SLURM_STEP_GPUS ({_ALL_GPUS}), got {visible_devices!r} - the "
f"step's GPU allocation is not rendered consistently across both "
f"variables (ticket 24862 regression)"
)
@pytest.mark.xfail(_OVERLAP_GRES_UNFIXED, reason=_OVERLAP_GRES_REASON)
@pytest.mark.parametrize("first_step_overlaps", [True, False])
def test_concurrent_steps_each_get_all_gpus(first_step_overlaps, single_socket_gpu_job):
"""Concurrent steps sharing an allocation each receive every GPU.
srun --overlap shares all resources with all other steps, even those
that did not specify --overlap. Two steps running at the same time
against the same allocation must therefore both be granted every GPU
the job holds, whichever of them asked to overlap.
"""
job_id = single_socket_gpu_job
first_step_gpus_file = "first_step_gpus"
overlap = "--overlap " if first_step_overlaps else ""
atf.run_command(
f"srun --jobid={job_id} {overlap}-n1 --mem=0 bash -c "
f"'echo $SLURM_STEP_GPUS > {first_step_gpus_file}; sleep infinity'",
background=True,
)
atf.assert_file_contents(
first_step_gpus_file,
_ALL_GPUS,
message=(
f"First step SLURM_STEP_GPUS should be {_ALL_GPUS!r} (every GPU) "
f"- the step was not granted all of the job's GPUs"
),
)
result = atf.run_job(
f"--jobid={job_id} --overlap -n1 --mem=0 bash -c 'echo $SLURM_STEP_GPUS'",
fatal=True,
quiet=True,
)
second_step_gpus = result["stdout"].strip()
assert second_step_gpus == _ALL_GPUS, (
f"Second step SLURM_STEP_GPUS should be {_ALL_GPUS!r} (every GPU), "
f"got {second_step_gpus!r} - an --overlap step running alongside "
f"another step was not granted all of the job's GPUs"
)
@pytest.mark.xfail(_OVERLAP_GRES_UNFIXED, reason=_OVERLAP_GRES_REASON)
@pytest.mark.parametrize("requested_gpus", range(2, _NUM_GPUS))
def test_overlap_step_gets_requested_gpu_subset(requested_gpus, single_socket_gpu_job):
"""An --overlap step requesting a subset gets that many distinct GPUs.
A job step may explicitly request a different generic resource count
than the job, so an --overlap step asking for fewer GPUs than the job
holds must be granted exactly that many, and no device twice.
"""
job_id = single_socket_gpu_job
result = atf.run_job(
f"--jobid={job_id} --overlap -n1 --mem=0 --gres=gpu:{requested_gpus} "
f"bash -c 'echo $SLURM_STEP_GPUS'",
fatal=True,
quiet=True,
)
step_gpus = result["stdout"].strip()
indices = step_gpus.split(",") if step_gpus else []
assert len(indices) == requested_gpus, (
f"Step requesting gpu:{requested_gpus} should be granted "
f"{requested_gpus} GPUs, got SLURM_STEP_GPUS={step_gpus!r}"
)
assert len(set(indices)) == len(indices), (
f"Step requesting gpu:{requested_gpus} should be granted distinct "
f"GPUs, got SLURM_STEP_GPUS={step_gpus!r}"
)
def test_non_overlap_step_gets_all_gpus():
"""A default (non --overlap) step also receives every GPU it requests.
This is not a reproducer of ticket 24862 (that bug was specific to
--overlap); it guards the shared non-overlap path against collateral
breakage from the fix. srun's own step lands on a single socket
(CR_Socket_Memory with -n1) while requesting every GPU across all
sockets, so it must still be granted all of them.
"""
result = atf.run_job(
f"-N1 -n1 --gpus-per-node={_NUM_GPUS} bash -c "
f"'echo $SLURM_CPUS_ON_NODE; echo $SLURM_STEP_GPUS'",
fatal=True,
quiet=True,
)
output = result["stdout"].splitlines()
assert len(output) >= 2, (
f"Expected two output lines (SLURM_CPUS_ON_NODE and "
f"SLURM_STEP_GPUS), got {result['stdout']!r}"
)
cpus_on_node = output[0].strip()
step_gpus = output[1].strip()
assert cpus_on_node == "1", (
f"Expected the step to hold a single socket (1 CPU with "
f"CoresPerSocket=1), got SLURM_CPUS_ON_NODE={cpus_on_node!r} - "
f"topology did not confine the step to one socket, so it already "
f"covers every GPU-bound socket"
)
assert step_gpus == _ALL_GPUS, (
f"Non-overlap step SLURM_STEP_GPUS should be {_ALL_GPUS!r} "
f"(every GPU), got {step_gpus!r}"
)