blob: 4b535fb36e5ad2aeffe6b69342b109d5217ea46e [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test srun --cpu-bind and --mem-bind task environment."""
import logging
import pytest
import atf
pytestmark = pytest.mark.slow
# These tests cover the path from the command line to the task environment.
#
# Every request below is built from an id the node was measured to hold, so no
# test depends on how srun treats an id that does not exist. Which ids srun
# accepts or rejects is not what these tests cover.
#
# The explicit ldom bindings are documented as unsupported unless the job holds
# the whole node, so the allocations here are exclusive. An exclusive
# allocation still leaves out CPUs the node withholds from jobs, such as
# specialized ones, and the tests that need the whole node skip on such a node.
# The test environment, set once by setup() and read by every test.
node_name = None
node_cpus = None
cpus_on_node = None
task_prog = None
cpu_ids = None
@pytest.fixture(scope="module", autouse=True)
def setup(taskget):
global node_name, node_cpus, cpus_on_node, task_prog, cpu_ids
atf.require_config_parameter_includes("TaskPlugin", "task/affinity")
# task/affinity builds an ldom mask out of the CPUs the node is configured
# with rather than out of the CPUs the step holds. An exclusive allocation
# on a node configured with fewer CPUs than it runs on is given every CPU
# while the ldom mask covers only the configured ones, so require_hardware.
node_name = atf.require_nodes(1, require_hardware=True)[0]
atf.require_slurm_running()
# --exclusive cannot stop a FORCE partition from oversubscribing CPUs
partition = atf.default_partition()
oversubscribe = atf.get_partition_parameter(partition, "OverSubscribe", "NO")
if oversubscribe.startswith("FORCE"):
atf.set_partition_parameter(partition, "OverSubscribe", "NO")
task_prog = taskget
node_cpus = atf.get_node_parameter(node_name, "cpus")
job_id, cpus_on_node = allocate()
cpu_ids = probe_cpu_ids(job_id)
logging.info(f"Usable CPU ids: {cpu_ids}")
atf.cancel_jobs([job_id], fatal=True)
@pytest.fixture(scope="function")
def allocation(setup):
"""Create a fresh exclusive allocation for the steps a test attaches."""
job_id, measured = allocate()
assert measured == cpus_on_node, (
f"Allocation should hold the {cpus_on_node} CPUs setup() measured, "
f"not {measured}"
)
return job_id
@pytest.fixture(scope="module")
def numa_cache(numaget):
"""Skip without a NUMA build, else hold the ids once a test measures them.
lscpu runs in a step of its own on the node, so read it here, while no
allocation holds the node.
"""
atf.require_build_config("HAVE_NUMA")
return {"cpu_topo": atf.get_node_cpu_topology(node_name, require_numa=True)}
@pytest.fixture(scope="function")
def numa_ids(allocation, numaget, numa_cache):
"""Return the NUMA ids the memory and ldom tests may bind to, or skip.
The ids are measured once, in the allocation of the first test that asks.
"""
if "ids" not in numa_cache:
ids, skip_reason = probe_numa_ids(allocation, numaget, numa_cache["cpu_topo"])
logging.info(f"Usable NUMA ids: {ids}")
numa_cache.update(ids=ids, skip_reason=skip_reason)
if numa_cache["skip_reason"]:
pytest.skip(numa_cache["skip_reason"])
return numa_cache["ids"]
def allocate():
"""Create an exclusive single-node allocation that is ready to take steps.
Returns the job id and the CPU count the allocation reports holding.
"""
job = atf.submit_job_sbatch(
f"--nodelist={node_name} -N1 --exclusive -t5 --wrap 'sleep infinity'",
fatal=True,
)
atf.wait_for_job_state(job, "RUNNING", fatal=True)
# TODO: Issue 50980. A step created too soon after the job reports RUNNING
# is rejected outright rather than waiting, and no job state marks that
# window, so the only reliable readiness signal is a step that got
# through. Retry until one does.
for _ in atf.timer():
probe = atf.run_command(
f"srun --jobid={job} -n1 printenv SLURM_CPUS_ON_NODE", quiet=True
)
if probe["exit_code"] == 0:
break
else:
pytest.fail(f"No step could be created in the allocation: {probe['stderr']}")
return job, int(probe["stdout"].strip())
def whole_node(
job_id, prog, cpu_bind=None, mem_bind=None, spread=False, label=False, **kwargs
):
"""Run prog in a step holding every CPU of the allocation.
The step names neither --cpus-per-task nor --exact, so it is given every
CPU the allocation holds on the node. spread launches one task per CPU
instead of giving every CPU to one task, and label prefixes each line of
output with the id of the task that wrote it.
"""
opts = [f"--jobid={job_id}"]
if spread:
opts.append(f"--ntasks={cpus_on_node}")
if cpu_bind is not None:
opts.append(f"'--cpu-bind={cpu_bind}'")
if mem_bind is not None:
opts.append(f"'--mem-bind={mem_bind}'")
if label:
opts.append("--label")
opts = " ".join(opts)
return atf.run_command(f"srun {opts} {prog}", **kwargs)
def probe_cpu_ids(job_id):
"""Return the CPU ids a whole-node step is given on this node."""
# One task per CPU, each bound to a single thread, so the union of the
# reported masks is every CPU a step may be given. A none binding would
# report whatever the task happened to inherit instead.
cpu_probe = whole_node(
job_id, task_prog, cpu_bind="threads", spread=True, fatal=True, quiet=True
)
tasks = atf.parse_taskget(cpu_probe["stdout"])
assert len(tasks) == cpus_on_node, f"Step should launch {cpus_on_node} tasks"
cpu_mask = 0
for task in tasks:
cpu_mask |= task["mask"]
return atf.mask_to_list(cpu_mask)
def probe_numa_ids(job_id, numa_prog, cpu_topo):
"""Return the NUMA ids a whole-node step may bind to.
cpu_topo is atf.get_node_cpu_topology() for the node.
A node without NUMA support returns None and the reason to skip.
"""
numa_probe = whole_node(
job_id,
numa_prog,
cpu_bind="verbose,none",
mem_bind="verbose,none",
quiet=True,
)
if "numa support not available" in numa_probe["stderr"]:
return None, f"This test requires NUMA support on node {node_name}"
assert (
numa_probe["exit_code"] == 0
), f"NUMA probe step should succeed: {numa_probe['stderr']}"
assert (
"mem-bind" in numa_probe["stderr"]
), f"NUMA probe step should report a verbose mem-bind line: {numa_probe['stderr']}"
bindings = atf.parse_numaget(numa_probe["stdout"])
assert bindings, "NUMA probe step should launch a task"
mem_mask = 0
for binding in bindings:
mem_mask |= binding["allowed_mask"]
# A locality domain is a NUMA node holding CPUs the step may run on, while
# memory may also be bound to a NUMA node that holds no CPUs at all. A
# domain may also hold no memory, so it is found from its CPUs.
ldom_ids = {cpu_topo[cpu_id]["numa_node"] for cpu_id in cpu_ids}
ids = {
"mem": atf.mask_to_list(mem_mask),
"ldom": sorted(ldom_ids),
}
return ids, None
def any_id(ids, form):
"""Return an id from ids, spelled for a map or a mask binding."""
assert ids, "Should have measured at least one usable id"
bind_id = ids[-1]
if form == "mask":
# srun.1 asks for a 0x prefix on a mask that does not begin with a
# digit, so every mask here carries one.
return f"0x{1 << bind_id:x}"
elif form == "map":
return str(bind_id)
def as_env_var(bind_type, var):
return f"SLURM_{bind_type.upper()}_BIND_{var}"
def task_envs(job_id, bind_type, value, *bind_vars, spread=False):
"""Return a {bind_var: value} dict per task, indexed by task id.
An unset variable is reported as None. spread launches one task per CPU,
which is what shows a binding generated for each task rather than the step.
"""
env_vars = [as_env_var(bind_type, var) for var in bind_vars]
# SLURM_PROCID is always set, so every task reports a line even when none
# of the variables asked for is, and grep never exits on finding nothing.
pattern = "|".join(["SLURM_PROCID"] + env_vars)
# A task's output is only identified by --label.
result = whole_node(
job_id,
f"""sh -c 'env | grep -E "^({pattern})="'""",
**{f"{bind_type}_bind": value},
spread=spread,
label=True,
fatal=True,
)
reported = {}
for line in result["stdout"].splitlines():
label, sep, assignment = line.partition(": ")
task_id = label.strip()
assert sep and task_id.isdigit(), f"Should have labeled {line} with a task id"
env_var, _, env_value = assignment.partition("=")
reported.setdefault(int(task_id), {})[env_var] = env_value
task_count = cpus_on_node if spread else 1
assert sorted(reported) == list(
range(task_count)
), f"Each of the {task_count} tasks should report once: {result['stdout']}"
# A variable a task does not have is absent from its environment.
return [
{env_var: env.get(env_var) for env_var in env_vars}
for task_id, env in sorted(reported.items())
]
def require_whole_node(binding):
"""Skip unless the allocation holds every CPU the node is configured with.
Without the whole node, rank_ldom, map_ldom and mask_ldom are rejected, and
none is replaced by a mask of the allocated CPUs.
"""
if cpus_on_node < node_cpus:
pytest.skip(
f"This test requires all {node_cpus} configured CPUs, not "
f"{cpus_on_node}, to use {binding}"
)
def single_task_env(job_id, bind_type, value, *bind_vars):
"""Return the bind variables the one task of a whole-node step sees."""
return task_envs(job_id, bind_type, value, *bind_vars)[0]
def assert_bind_env(env, bind_type, var, expected):
env_var = as_env_var(bind_type, var)
assert expected == env[env_var], f"Should have set {env_var} to {expected}"
def assert_bind_list(env, bind_type, form, bind_list):
"""Verify the LIST variable holds the map or mask list that was asked for."""
if form == "map":
assert_bind_env(env, bind_type, "LIST", bind_list)
elif form == "mask":
# srun does not document the mask spelling it echoes back.
env_var = as_env_var(bind_type, "LIST")
assert env[env_var], f"{env_var} should be non-empty"
@pytest.mark.parametrize("bind_type", ["mem", "cpu"])
@pytest.mark.parametrize(
"verbosity,expected",
[
("verbose", "verbose"),
("v", "verbose"),
("quiet", "quiet"),
("q", "quiet"),
],
)
def test_bind_verbose(allocation, bind_type, verbosity, expected):
"""Test that verbose/v and quiet/q both reach the VERBOSE variable."""
env = single_task_env(allocation, bind_type, f"{verbosity},none", "VERBOSE")
assert_bind_env(env, bind_type, "VERBOSE", expected)
@pytest.mark.parametrize(
"value,expected",
[
("none", "none"),
("no", "none"),
("local", "local"),
("rank", "rank"),
],
)
def test_mem_bind_type(allocation, value, expected):
"""Test that a mem-bind type reaches the TYPE variable."""
env = single_task_env(allocation, "mem", value, "TYPE")
assert_bind_env(env, "mem", "TYPE", expected)
@pytest.mark.parametrize("form", ["map", "mask"])
def test_mem_bind_list(allocation, numa_ids, form):
"""Test that a mem-bind map or mask sets both the TYPE and LIST variables."""
env_type = f"{form}_mem:"
bind_list = any_id(numa_ids["mem"], form)
env = single_task_env(allocation, "mem", env_type + bind_list, "TYPE", "LIST")
assert_bind_env(env, "mem", "TYPE", env_type)
assert_bind_list(env, "mem", form, bind_list)
@pytest.mark.parametrize("prefer", ["prefer", "p"])
def test_mem_bind_prefer(allocation, numa_ids, prefer):
"""Test that prefer sets SLURM_MEM_BIND_PREFER."""
opt = f"{prefer},map_mem:{any_id(numa_ids['mem'], 'map')}"
env = single_task_env(allocation, "mem", opt, "PREFER")
assert_bind_env(env, "mem", "PREFER", "prefer")
def test_mem_bind_no_prefer(allocation, numa_ids):
"""Test that SLURM_MEM_BIND_PREFER is unset without a prefer binding."""
opt = f"map_mem:{any_id(numa_ids['mem'], 'map')}"
env = single_task_env(allocation, "mem", opt, "PREFER")
prefer_var = as_env_var("mem", "PREFER")
assert (
env[prefer_var] is None
), f"{prefer_var} should be unset without a prefer binding"
@pytest.mark.parametrize(
"value,expected",
[
("none", "none"),
("no", "none"),
("rank_ldom", "rank_ldom"),
],
)
def test_cpu_bind_type(allocation, value, expected):
"""Test that an explicit cpu-bind type reaches the TYPE variable.
A step holding the whole node is left with the binding it asked for.
"""
require_whole_node(f"--cpu-bind={value}")
env = single_task_env(allocation, "cpu", value, "TYPE")
assert_bind_env(env, "cpu", "TYPE", expected)
@pytest.mark.parametrize("value", ["sockets", "cores", "threads", "ldoms"])
def test_cpu_bind_unit_type(allocation, value):
"""Test that a cpu-bind unit reports a documented TYPE.
A unit names how to generate a binding and is resolved into an explicit one.
Which one it picks depends on hardware and the allocation, so only the
documented set of values can be asserted.
"""
CPU_BIND_TYPES = [
"none",
"map_cpu:",
"mask_cpu:",
"rank_ldom",
"map_ldom:",
"mask_ldom:",
]
env = single_task_env(allocation, "cpu", value, "TYPE")
env_var = as_env_var("cpu", "TYPE")
assert (
env[env_var] in CPU_BIND_TYPES
), f"{env_var} should be one of {CPU_BIND_TYPES}, not {env[env_var]}"
@pytest.mark.parametrize("form", ["map", "mask"])
def test_cpu_bind_list(allocation, form):
"""Test that a cpu-bind map or mask sets both the TYPE and LIST variables.
Mask SLURM_CPU_BIND_LIST format is not documented, so only check non-empty.
"""
env_type = f"{form}_cpu:"
bind_list = any_id(cpu_ids, form)
env = single_task_env(allocation, "cpu", env_type + bind_list, "TYPE", "LIST")
assert_bind_env(env, "cpu", "TYPE", env_type)
assert_bind_list(env, "cpu", form, bind_list)
@pytest.mark.parametrize("form", ["map", "mask"])
def test_ldom_bind_list(allocation, numa_ids, form):
"""Test that an ldom map or mask sets both the TYPE and LIST variables.
Mask SLURM_CPU_BIND_LIST format is not documented, so only check non-empty.
"""
env_type = f"{form}_ldom:"
require_whole_node(f"--cpu-bind={env_type}")
bind_list = any_id(numa_ids["ldom"], form)
env = single_task_env(allocation, "cpu", env_type + bind_list, "TYPE", "LIST")
assert_bind_env(env, "cpu", "TYPE", env_type)
assert_bind_list(env, "cpu", form, bind_list)
def test_generated_cpu_bind_list(allocation):
"""Test that a generated binding sets a CPU map or mask for the whole step.
A map or mask the request named is echoed back, so only a binding Slurm
generates shows what it was resolved into. The binding is generated per
task, so every task is asked what it was given.
"""
# TODO: The *_BIND_LIST format is not documented, so the CPUs the list
# names cannot be read back without guessing its radix. Compare them with
# the CPUs each task holds once the format is specified.
type_var = as_env_var("cpu", "TYPE")
list_var = as_env_var("cpu", "LIST")
envs = task_envs(allocation, "cpu", "threads", "TYPE", "LIST", spread=True)
# The list names the binding of the whole step, not of one task within it.
reported = {(env[type_var], env[list_var]) for env in envs}
assert (
len(reported) == 1
), f"Every task should see the same {type_var} and {list_var}: {envs}"
bind_type, env_list = reported.pop()
assert bind_type in [
"map_cpu:",
"mask_cpu:",
], f"{type_var} should name a CPU map or mask, not {bind_type}"
assert env_list, f"{list_var} should be non-empty"