blob: cd0a77080dce721595332f80a6991e7c4b01602d [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""UUID vs AutoDetect, and env_uuid fallback when no UUID is available.
test_144_15 covers UUID with AutoDetect=off and no fake_gpus.conf.
test_144_10 covers env_uuid when fake_gpus.conf already supplies UUIDs.
This file covers the merge of the two: UUID as a sanity check against
AutoDetect, UUID filling in when AutoDetect has no UUID, and env_uuid
falling back to numeric indices when neither source provides one.
"""
import re
import pytest
import atf
GPU_0 = "GPU-11111111-1111-1111-1111-111111111111"
GPU_1 = "GPU-22222222-2222-2222-2222-222222222222"
GPU_2 = "GPU-33333333-3333-3333-3333-333333333333"
GPU_WRONG = "GPU-99999999-9999-9999-9999-999999999999"
NODE = "node1"
pytestmark = pytest.mark.slow
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
"sbin/slurmd",
reason="MR 4271: merging gres.conf UUIDs with AutoDetect UUIDs is 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")
atf.require_config_parameter_includes("SlurmdParameters", "config_overrides")
atf.require_config_parameter("IgnoreSystemd", "yes", source="cgroup")
atf.require_tty(0)
atf.require_tty(1)
atf.require_tty(2)
atf.require_config_file(
"gres.conf",
"AutoDetect=off\n" "Name=gpu File=/dev/tty0\n" "Name=gpu File=/dev/tty1\n",
)
atf.require_nodes(1, [("Gres", "gpu:2"), ("CPUs", 2)])
atf.require_slurm_running()
yield
# gpu_g_get_system_gpu_list() uses fake_gpus.conf whenever it exists, so
# the file must not outlive this module even if a test aborts partway.
_remove_fake_gpus()
def _write_conf(name, content):
atf.run_command(
f"cat > {atf.properties['slurm-config-dir']}/{name}",
input=content,
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
def _remove_fake_gpus():
atf.run_command(
f"rm -f {atf.properties['slurm-config-dir']}/fake_gpus.conf",
user=atf.properties["slurm-user"],
quiet=True,
)
def _reload_slurmd(expect_idle=True, expect_reason=None):
"""Restart the test slurmd so it re-reads gres.conf / fake_gpus.conf."""
slurm_user = atf.properties["slurm-user"]
log_path = f"{atf.properties['slurm-logs-dir']}/slurmd.{NODE}.log"
atf.stop_slurmd(NODE, quiet=True)
atf.run_command(f": > {log_path}", user="root", fatal=True, quiet=True)
atf.start_slurmd(NODE, wait="process", quiet=True)
# The log was just truncated, so its startup banner belongs to this reload.
for _ in atf.timer(fatal=True):
if "slurmd version" in (
atf.run_command_output(f"cat {log_path}", user="root", quiet=True) or ""
):
break
if expect_idle:
# A DRAIN left by an earlier test outlives the reload, and slurmctld
# refuses RESUME until a clean registration clears INVALID_REG. RESUME is
# also invalid on a node that is already IDLE, so drive on state.
for _ in atf.timer(fatal=True):
state = str(atf.get_node_parameter(NODE, "state") or "")
if "DRAIN" not in state and "DOWN" not in state:
break
atf.run_command(
f"scontrol update nodename={NODE} state=RESUME",
user=slurm_user,
xfail=True,
quiet=True,
)
assert atf.wait_for_node_state(
NODE, "IDLE", operator=atf.SetMatch.EQUAL
), f"{NODE} did not become IDLE after slurmd reload"
else:
assert atf.wait_for_node_state(
NODE, "DRAIN"
), f"{NODE} should be DRAIN after UUID mismatch"
# Node state alone is not a barrier here: a previous test can leave NODE
# DRAIN with its own Reason, which the wait above would match immediately.
if expect_reason is not None:
for _ in atf.timer(fatal=True):
if re.search(expect_reason, atf.get_node_parameter(NODE, "reason") or ""):
break
def test_env_uuid_falls_back_to_index_without_uuid():
"""Verify env_uuid without UUID or AutoDetect UUID uses numeric indices"""
_remove_fake_gpus()
_write_conf(
"gres.conf",
"Name=gpu File=/dev/tty0 Flags=env_uuid\n"
"Name=gpu File=/dev/tty1 Flags=env_uuid\n",
)
_reload_slurmd()
output = atf.run_job_output(
"-n1 --gpus=1 printenv CUDA_VISIBLE_DEVICES",
fatal=True,
)
value = output.strip()
assert re.fullmatch(
r"\d+", value
), f"CUDA_VISIBLE_DEVICES should fall back to an index, got: {value!r}"
log_path = f"{atf.properties['slurm-logs-dir']}/slurmd.{NODE}.log"
log_text = atf.run_command_output(f"cat {log_path}", user="root", quiet=True) or ""
assert "Flags=env_uuid set but no GPU UUID available" in log_text, (
"slurmd should warn when env_uuid has no UUID or AutoDetect UUID; "
f"last log excerpt: {log_text[-2000:]!r}"
)
def test_uuid_without_env_uuid_keeps_numeric_env():
"""Verify UUID alone does not rewrite CUDA_VISIBLE_DEVICES."""
_remove_fake_gpus()
_write_conf(
"gres.conf",
f"Name=gpu File=/dev/tty0 UUID={GPU_0}\n"
f"Name=gpu File=/dev/tty1 UUID={GPU_1}\n",
)
_reload_slurmd()
output = atf.run_job_output(
"-n1 --gpus=1 printenv CUDA_VISIBLE_DEVICES",
fatal=True,
)
value = output.strip()
assert re.fullmatch(
r"\d+", value
), f"without Flags=env_uuid, CUDA_VISIBLE_DEVICES should stay an index, got: {value!r}"
def test_uuid_fills_in_when_autodetect_has_no_uuid():
"""Verify UUID is used when AutoDetect (fake_gpus) reports no UUID"""
_write_conf(
"fake_gpus.conf",
"(null)|2|0-1|(null)|/dev/tty0|(null)|nvidia_gpu_env,amd_gpu_env\n"
"(null)|2|0-1|(null)|/dev/tty1|(null)|nvidia_gpu_env,amd_gpu_env\n",
)
_write_conf(
"gres.conf",
f"Name=gpu File=/dev/tty0 Flags=env_uuid UUID={GPU_0}\n"
f"Name=gpu File=/dev/tty1 Flags=env_uuid UUID={GPU_1}\n",
)
_reload_slurmd()
output = atf.run_job_output(
"-n1 --gpus=1 printenv CUDA_VISIBLE_DEVICES",
fatal=True,
)
value = output.strip()
assert value in (
GPU_0,
GPU_1,
), f"CUDA_VISIBLE_DEVICES should be the configured UUID, got: {value!r}"
def test_uuid_matches_autodetect_uuid():
"""Verify UUID that agrees with AutoDetect is accepted"""
_write_conf(
"fake_gpus.conf",
f"(null)|2|0-1|(null)|/dev/tty0|{GPU_0}|nvidia_gpu_env,amd_gpu_env\n"
f"(null)|2|0-1|(null)|/dev/tty1|{GPU_1}|nvidia_gpu_env,amd_gpu_env\n",
)
_write_conf(
"gres.conf",
f"Name=gpu File=/dev/tty0 Flags=env_uuid UUID={GPU_0}\n"
f"Name=gpu File=/dev/tty1 Flags=env_uuid UUID={GPU_1}\n",
)
_reload_slurmd()
output = atf.run_job_output(
"-n1 --gpus=2 printenv CUDA_VISIBLE_DEVICES",
fatal=True,
)
value = output.strip()
assert value in (
f"{GPU_0},{GPU_1}",
f"{GPU_1},{GPU_0}",
), f"CUDA_VISIBLE_DEVICES should list the matching UUIDs, got: {value!r}"
def test_configured_uuid_duplicate_is_fatal():
"""Verify a configured cross-device UUID collision is fatal to slurmd.
A configured UUID that collides with the UUID AutoDetect found on a
different device is a configuration error. Neither device can be named
unambiguously, so slurmd refuses to start rather than identifying either
one by numeric index.
"""
_write_conf(
"fake_gpus.conf",
"(null)|2|0-1|(null)|/dev/tty0|(null)|nvidia_gpu_env,amd_gpu_env\n"
f"(null)|2|0-1|(null)|/dev/tty1|{GPU_0}|nvidia_gpu_env,amd_gpu_env\n",
)
_write_conf(
"gres.conf",
f"Name=gpu File=/dev/tty0 Flags=env_uuid UUID={GPU_0}\n"
"Name=gpu File=/dev/tty1 Flags=env_uuid\n",
)
atf.stop_slurmd(NODE, quiet=True)
log_path = f"{atf.properties['slurm-logs-dir']}/slurmd.{NODE}.log"
atf.run_command(f": > {log_path}", user="root", fatal=True, quiet=True)
# slurmd forks before loading GRES, so the parent exits 0 either way.
# The fatal shows up as the daemon not surviving, and in its log.
atf.start_slurmd(NODE, wait=None, quiet=True)
for _ in atf.timer():
log_text = (
atf.run_command_output(f"cat {log_path}", user="root", quiet=True) or ""
)
if "Duplicate UUID=" in log_text:
break
else:
pytest.fail(
"slurmd should log the cross-device UUID collision; "
f"last log excerpt: {log_text[-2000:]!r}"
)
assert "after merging" in log_text, (
"the collision should be reported as a post-merge duplicate; "
f"last log excerpt: {log_text[-2000:]!r}"
)
# The error is logged before the process unwinds, so the pid outlives it.
for _ in atf.timer(fatal=True):
if not atf.slurmd_pids(NODE):
break
def test_uuid_partial_mismatch_excludes_only_that_device():
"""Verify only the mismatching UUID is dropped; the other GPU remains."""
_write_conf(
"fake_gpus.conf",
f"(null)|2|0-1|(null)|/dev/tty0|{GPU_0}|nvidia_gpu_env,amd_gpu_env\n"
f"(null)|2|0-1|(null)|/dev/tty1|{GPU_1}|nvidia_gpu_env,amd_gpu_env\n",
)
_write_conf(
"gres.conf",
f"Name=gpu File=/dev/tty0 Flags=env_uuid UUID={GPU_0}\n"
f"Name=gpu File=/dev/tty1 Flags=env_uuid UUID={GPU_WRONG}\n",
)
_reload_slurmd(expect_idle=False, expect_reason=r"\(1 < 2\)")
log_path = f"{atf.properties['slurm-logs-dir']}/slurmd.{NODE}.log"
log_text = atf.run_command_output(f"cat {log_path}", user="root", quiet=True) or ""
assert "mismatching Cores, Links or UUID" in log_text, (
"slurmd log should report the UUID mismatch; "
f"last log excerpt: {log_text[-2000:]!r}"
)
reason = atf.get_node_parameter(NODE, "reason") or ""
assert re.search(r"\(1 < 2\)", reason), (
"the matching GPU should still be reported (1 of 2); " f"got Reason={reason!r}"
)
def test_uuid_mismatch_excludes_device():
"""Verify UUID that disagrees with AutoDetect excludes the device.
slurm.conf still advertises gpu:2, so a slurmd that drops both devices
reports a lower count and the node is drained.
"""
_write_conf(
"fake_gpus.conf",
f"(null)|2|0-1|(null)|/dev/tty0|{GPU_0}|nvidia_gpu_env,amd_gpu_env\n"
f"(null)|2|0-1|(null)|/dev/tty1|{GPU_1}|nvidia_gpu_env,amd_gpu_env\n",
)
_write_conf(
"gres.conf",
f"Name=gpu File=/dev/tty0 Flags=env_uuid UUID={GPU_WRONG}\n"
f"Name=gpu File=/dev/tty1 Flags=env_uuid UUID={GPU_WRONG}x\n",
)
_reload_slurmd(expect_idle=False, expect_reason=r"\(0 < 2\)")
log_path = f"{atf.properties['slurm-logs-dir']}/slurmd.{NODE}.log"
log_text = atf.run_command_output(f"cat {log_path}", user="root", quiet=True) or ""
assert "mismatching Cores, Links or UUID" in log_text, (
"slurmd log should report the UUID mismatch; "
f"last log excerpt: {log_text[-2000:]!r}"
)
reason = atf.get_node_parameter(NODE, "reason") or ""
assert re.search(r"\(0 < 2\)", reason), (
"both mismatching GPUs should be excluded (0 of 2); " f"got Reason={reason!r}"
)
def test_uuid_mismatch_above_count_keeps_node_idle():
"""Verify an excluded device only drains the node if the count drops.
gres.conf(5) makes the drain conditional: on a mismatch "the device is
excluded and, if that drops the node below its configured GRES count, the
node's state is set to invalid and the node is drained". With three
detected devices and gpu:2 configured, excluding one still satisfies the
count, so the node must stay usable.
"""
_write_conf(
"fake_gpus.conf",
f"(null)|2|0-1|(null)|/dev/tty0|{GPU_0}|nvidia_gpu_env,amd_gpu_env\n"
f"(null)|2|0-1|(null)|/dev/tty1|{GPU_1}|nvidia_gpu_env,amd_gpu_env\n"
f"(null)|2|0-1|(null)|/dev/tty2|{GPU_2}|nvidia_gpu_env,amd_gpu_env\n",
)
_write_conf(
"gres.conf",
f"Name=gpu File=/dev/tty0 Flags=env_uuid UUID={GPU_0}\n"
f"Name=gpu File=/dev/tty1 Flags=env_uuid UUID={GPU_1}\n"
f"Name=gpu File=/dev/tty2 Flags=env_uuid UUID={GPU_WRONG}\n",
)
_reload_slurmd()
gres = ""
for _ in atf.timer(fatal=True):
gres = atf.get_node_parameter(NODE, "gres") or ""
if gres:
break
assert re.search(r"gpu:2\b", gres, re.IGNORECASE), (
f"{NODE} should still register its configured gpu:2 when only the "
f"UUID-mismatched device is excluded; got Gres={gres!r}"
)
output = atf.run_job_output(
"-n1 --gpus=2 printenv CUDA_VISIBLE_DEVICES", fatal=True
)
assert set(output.strip().split(",")) == {GPU_0, GPU_1}, (
"the two matching GPUs should still be allocatable; "
f"got CUDA_VISIBLE_DEVICES={output.strip()!r}"
)
@pytest.mark.parametrize("var", ["CUDA_VISIBLE_DEVICES", "ROCR_VISIBLE_DEVICES"])
def test_env_uuid_mixed_uuid_falls_back_for_both_vars(var):
"""Verify the no-mixing rule covers both documented variables.
gres.conf(5) names CUDA_VISIBLE_DEVICES and ROCR_VISIBLE_DEVICES together,
and says that if any allocated device has no UUID then all devices in that
variable use numeric indices.
"""
_remove_fake_gpus()
_write_conf(
"gres.conf",
f"Name=gpu File=/dev/tty0 Flags=env_uuid UUID={GPU_0}\n"
"Name=gpu File=/dev/tty1 Flags=env_uuid\n",
)
_reload_slurmd()
output = atf.run_job_output(f"-n1 --gpus=2 printenv {var}", fatal=True)
value = output.strip()
assert re.fullmatch(
r"\d+,\d+", value
), f"{var} should fall back to indices for a mixed allocation, got: {value!r}"
def test_env_uuid_step_scope_uses_uuid_when_step_devices_have_one():
"""Verify the no-mixing rule is scoped to what the step is allocated.
gres.conf(5) scopes the fallback to the devices "allocated to a job or
step", so a step given only the UUID-bearing device has no UUID-less
device of its own and must see the UUID.
"""
_remove_fake_gpus()
_write_conf(
"gres.conf",
f"Name=gpu File=/dev/tty0 Flags=env_uuid UUID={GPU_0}\n"
"Name=gpu File=/dev/tty1 Flags=env_uuid\n",
)
_reload_slurmd()
job_script = "step_scope.sh"
job_output = "step_scope.out"
atf.make_bash_script(
job_script,
"srun --exact -n1 --gpus=1 --mem=0 printenv CUDA_VISIBLE_DEVICES",
)
job_id = atf.submit_job_sbatch(
f"--cpus-per-gpu=1 --gpus=2 -N1 -n1 -t1 -o {job_output} {job_script}",
fatal=True,
)
atf.wait_for_job_state(job_id, "DONE", fatal=True)
atf.assert_file_contents(job_output, GPU_0)