blob: aaf4dc1e6aa247679df6c89b2a739d9bb2d9a048 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""GRES device chunks are refused on a shared GRES (shard, mps).
Shared allocations are tracked on the shared GRES, so the chunk selection
cannot see the devices they consume. A node allowing both would hand out a
chunk whose devices are already carved up, and a chunk job and a shared job
would silently hold the same device. That is a double-booking, not a
scheduling inefficiency, so the node must drain instead.
One node shares its GPUs as shard and another as mps. Both start without
ChunkSizes so that Slurm comes up healthy, and the failing configuration is
applied afterwards by restarting only that node's slurmd.
"""
import pytest
import atf
pytestmark = pytest.mark.slow
CHUNK_GPUS = 4
SHARED_PER_GPU = {"shard": 2, "mps": 100}
node = {}
@pytest.fixture(scope="module", autouse=True)
def setup():
# slurmd parses ChunkSizes/ChunkOrder
atf.require_version(
(26, 11),
"sbin/slurmd",
reason="Issue 51025: gres.conf ChunkSizes/ChunkOrder are new in 26.11",
)
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_config_parameter_includes("GresTypes", "gpu")
for shared in SHARED_PER_GPU:
atf.require_config_parameter_includes("GresTypes", shared)
atf.require_nodes(2, [("CPUs", 8), ("Gres", f"gpu:{CHUNK_GPUS}")])
for i in range(CHUNK_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:{CHUNK_GPUS}" in str(params.get("Gres", ""))
)
assert len(gpu_nodes) >= 2, f"Expected two GPU nodes, got {gpu_nodes}"
node.update(zip(SHARED_PER_GPU, gpu_nodes))
for shared, per_gpu in SHARED_PER_GPU.items():
atf.set_node_parameter(
node[shared], "Gres", f"gpu:{CHUNK_GPUS},{shared}:{CHUNK_GPUS * per_gpu}"
)
atf.require_config_parameter("NodeName", _gres_conf(), source="gres")
atf.require_slurm_running()
def _gres_conf(target=None, gpu_params="", shared_params=""):
"""Return the gres.conf body, chunk free except on the target node."""
lines = []
for shared, per_gpu in SHARED_PER_GPU.items():
name = node[shared]
gpu_extra = gpu_params if shared == target else ""
shared_extra = shared_params if shared == target else ""
lines += [
f"{name} Name=gpu File=/dev/tty[0-{CHUNK_GPUS - 1}] {gpu_extra}",
f"{name} Name={shared} Count={CHUNK_GPUS * per_gpu} "
f"File=/dev/tty[0-{CHUNK_GPUS - 1}] {shared_extra}",
]
return "\nNodeName=".join(line.rstrip() for line in lines)
def _write_gres_conf(body):
"""Rewrite gres.conf without reconfiguring the controller."""
atf.run_command(
f"cat > {atf.properties['slurm-config-dir']}/gres.conf",
input=f"NodeName={body}\n",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
def _reload_slurmd(name, body):
"""Rewrite gres.conf and restart only that node's slurmd.
The caller polls for the result it expects. Waiting for a registration
here instead can miss one: slurmd_start_time has one second resolution,
so a restart right after the previous start looks unchanged.
"""
_write_gres_conf(body)
assert atf.slurmd_pids(name), f"Expected a slurmd running for {name}"
atf.start_slurmd(name, wait="process", quiet=True)
@pytest.fixture
def restore_gres_conf(request):
"""Put the chunk free config back and resume the node."""
name = node[request.node.callspec.params["shared"]]
atf.wait_for_node_state(name, "IDLE", operator=atf.SetMatch.EQUAL, fatal=True)
yield
_reload_slurmd(name, _gres_conf())
atf.run_command(
f"scontrol update nodename={name} state=resume",
user=atf.properties["slurm-user"],
quiet=True,
fatal=True,
)
@pytest.mark.parametrize(
"shared, gpu_params, shared_params",
[
("shard", "ChunkSizes=2", ""),
("shard", "", "ChunkSizes=2"),
("mps", "ChunkSizes=2", ""),
("mps", "", "ChunkSizes=2"),
],
ids=["shard_on_gpu", "shard_on_shard", "mps_on_gpu", "mps_on_mps"],
)
def test_chunks_with_shared_gres_drain_node(
restore_gres_conf, shared, gpu_params, shared_params
):
"""ChunkSizes on a shared GRES, or on a GRES carved into one, must drain.
Accepting it would let the chunk selection hand out devices the shared
state already considers allocated, so two jobs would hold the same device
without either being able to see the other.
"""
name = node[shared]
_reload_slurmd(name, _gres_conf(shared, gpu_params, shared_params))
assert atf.wait_for_node_state(name, "DRAIN"), (
f"Node {name} should drain when ChunkSizes is set on a shared "
f"GRES or on a GRES carved into one "
f"(gpu: '{gpu_params}', {shared}: '{shared_params}') "
f"(state={atf.get_node_parameter(name, 'state')}, "
f"reason={atf.get_node_parameter(name, 'reason')})"
)
reason = str(atf.get_node_parameter(name, "reason"))
assert "chunk" in reason.lower(), (
f"The drain reason should name the chunk rule that failed, so an "
f"admin can find it in the documented list. Got: {reason}"
)