| ############################################################################ | |
| # Copyright (C) SchedMD LLC. | |
| ############################################################################ | |
| """Test CPU affinity/binding as observed on the node (--cpu-bind).""" | |
| import collections | |
| import re | |
| import pytest | |
| import atf | |
| # Every test attaches steps with --jobid to a fresh exclusive single-node | |
| # allocation and reads taskget, not srun output, to detect the affinity each | |
| # task actually got. --cpu-bind=verbose is used only to confirm srun reported | |
| # the mask it applied, and to catch one it failed to apply. | |
| # | |
| # CPUs the step holds are measured, not inferred. CPU IDs may not be contiguous. | |
| # The node configuration and lscpu present distinct views of the set of CPUs, | |
| # and are not assumed equal to the CPUs the step holds. Every expectation is | |
| # phrased against that measured set. lscpu, through atf.get_node_cpu_topology(), | |
| # only answers which socket or core a measured CPU belongs to. | |
| # | |
| # The ldom bind types are covered in test_116_68, which requires a libnuma build. | |
| pytestmark = pytest.mark.slow | |
| # The test environment, set once by setup() and read by every test. | |
| file_prog = None | |
| test_node = None | |
| topo = None | |
| # cpus and mask name the same CPUs. | |
| # task_cnt is how many tasks a step that covers the allocation launches. | |
| Allocation = collections.namedtuple("Allocation", "job_id cpus mask task_cnt") | |
| @pytest.fixture(scope="module", autouse=True) | |
| def setup(taskget): | |
| global file_prog, test_node, topo | |
| atf.require_config_parameter_includes("TaskPlugin", "task/affinity") | |
| # Four CPUs keeps the binding patterns and CPU pairs distinct. | |
| # The socket/core groups come from lscpu, | |
| # so the node has to be configured with the hardware it runs on. | |
| test_node = atf.require_nodes(1, [("CPUs", 4)], 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") | |
| file_prog = taskget | |
| topo = atf.get_node_cpu_topology(test_node) | |
| @pytest.fixture(scope="function") | |
| def allocation(): | |
| """Create a fresh exclusive allocation and measure what a step in it holds.""" | |
| job_id = atf.submit_job_sbatch( | |
| f"--nodelist={test_node} -N1 --exclusive -t5 --wrap 'sleep infinity'", | |
| fatal=True, | |
| ) | |
| atf.wait_for_job_state(job_id, "RUNNING", fatal=True) | |
| # Read the CPU count first: it pins the probe's task count instead of | |
| # depending on srun's default, which is a function of SelectTypeParameters. | |
| # The probe's masks are checked against it below, so two tasks landing on | |
| # one CPU is caught there. | |
| # 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_id} -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']}") | |
| cpus_on_node = probe["stdout"].strip() | |
| # Measure the allocation from the step itself: --ntasks per CPU with -c1 and | |
| # threads binds a task to each CPU, so the union of what its tasks report is | |
| # what the allocation holds. Naming a bind type matters. A none binding | |
| # applies no affinity at all, so both the task and the verbose line report | |
| # an inherited cpuset instead of the allocation's CPUs. | |
| probe = atf.run_command( | |
| f"srun --jobid={job_id} --ntasks={cpus_on_node} " | |
| f"--cpu-bind=threads,verbose -c1 {file_prog}", | |
| fatal=True, | |
| quiet=True, | |
| ) | |
| task_data = atf.parse_taskget(probe["stdout"]) | |
| assert task_data, "Step should launch at least one task" | |
| assert_no_failed_binding(probe["stderr"]) | |
| mask = 0 | |
| for data in task_data: | |
| mask |= data["mask"] | |
| cpus = atf.mask_to_list(mask) | |
| assert len(cpus) == int( | |
| cpus_on_node | |
| ), f"Measured {len(cpus)} CPUs but SLURM_CPUS_ON_NODE reports {cpus_on_node}" | |
| yield Allocation(job_id, cpus, mask, len(task_data)) | |
| def bind_tasks(alloc, cpu_bind_args="", ntasks=None, cpus_per_task=1): | |
| """Run srun within the allocation and return (task data, verbose stderr). | |
| Pass ntasks when the task count must match the number of CPUs bound. | |
| Pass cpus_per_task=None when the tasks do not cover every CPU: omitting | |
| --cpus-per-task keeps them all in the step. | |
| """ | |
| opts = cpu_bind_args | |
| if ntasks is not None: | |
| opts += f" --ntasks={ntasks}" | |
| if cpus_per_task is not None: | |
| opts += f" -c{cpus_per_task}" | |
| cmd = f"srun --jobid={alloc.job_id} {opts} {file_prog}" | |
| output = atf.run_command(cmd, fatal=True) | |
| return atf.parse_taskget(output["stdout"]), output["stderr"] | |
| def bind_list(form, cpu_ids, counts=None): | |
| """Render cpu_ids as the list a map_cpu or mask_cpu binding takes.""" | |
| strs = [] | |
| for c in cpu_ids: | |
| s = "" | |
| if form == "map": | |
| s = str(c) | |
| elif form == "mask": | |
| s = f"0x{1 << c:x}" | |
| strs.append(s) | |
| if counts is not None: | |
| strs = [f"{s}*{c}" for s, c in zip(strs, counts)] | |
| return ",".join(strs) | |
| def order_ids(ids, pattern): | |
| """Reorder ids as forward, reverse, or alternating. | |
| Alternating puts odd positions in descending order before the even | |
| positions in ascending order. | |
| """ | |
| if pattern == "forward": | |
| return list(ids) | |
| if pattern == "reverse": | |
| return list(reversed(ids)) | |
| alternating = [] | |
| for pos, value in enumerate(ids): | |
| if pos % 2: | |
| alternating = [value] + alternating | |
| else: | |
| alternating = alternating + [value] | |
| return alternating | |
| def cover_masks(ids): | |
| """Return paired masks that together name every id. | |
| A leftover odd id gets a mask of its own, so the masks name every CPU | |
| the step holds, which is what an explicit mask needs. | |
| """ | |
| masks = [(1 << a) | (1 << b) for a, b in zip(ids[::2], ids[1::2])] | |
| if len(ids) % 2: | |
| masks.append(1 << ids[-1]) | |
| return masks | |
| def sample_ids(ids, limit=4): | |
| """Return at most limit ids, always including the first and last.""" | |
| if len(ids) <= limit: | |
| return ids | |
| last = len(ids) - 1 | |
| picks = sorted({round(i * last / (limit - 1)) for i in range(limit)}) | |
| return [ids[i] for i in picks] | |
| def cpu_group_masks(bind_type, allocation): | |
| """Map each CPU in allocation to the mask of its bind_type group.""" | |
| keyed = {} | |
| for cpu_id in allocation.cpus: | |
| cpu_topo = topo[cpu_id] | |
| if bind_type == "sockets": | |
| key = cpu_topo["socket"] | |
| elif bind_type == "cores": | |
| key = (cpu_topo["socket"], cpu_topo["core"]) | |
| else: | |
| key = cpu_id | |
| keyed.setdefault(key, []).append(cpu_id) | |
| group_mask = {} | |
| for cpu_ids in keyed.values(): | |
| mask = 0 | |
| for cpu_id in cpu_ids: | |
| mask |= 1 << cpu_id | |
| for cpu_id in cpu_ids: | |
| group_mask[cpu_id] = mask | |
| return group_mask | |
| def assert_no_failed_binding(verbose): | |
| assert "FAILED" not in verbose, f"srun reported a failed binding: {verbose}" | |
| def assert_verbose(verbose, expected_masks, bind_type=None): | |
| """Verify slurm intended to bind each task to the mask at its rank index.""" | |
| assert_no_failed_binding(verbose) | |
| ntasks = len(expected_masks) | |
| reported = bind_type if bind_type else r"[A-Z]+" | |
| pattern = rf"cpu-bind(?:-[a-z]+)?={reported}.*task +(\d+).*mask 0x([0-9a-fA-F]+)" | |
| found = re.findall(pattern, verbose) | |
| assert ( | |
| len(found) == ntasks | |
| ), f"Verbose should report {ntasks} tasks, not {len(found)}." | |
| for task_id, mask in found: | |
| task_id = int(task_id) | |
| mask = int(mask, 16) | |
| assert task_id in range(ntasks), f"Verbose reports unknown task {task_id}" | |
| assert mask == expected_masks[task_id], ( | |
| f"Verbose mask for task {task_id} should be " | |
| f"0x{expected_masks[task_id]:x}, got 0x{mask:x}" | |
| ) | |
| def assert_effect(task_data, expected_masks): | |
| """Verify the kernel honored the binding Slurm asked for.""" | |
| expected = dict(enumerate(expected_masks)) | |
| masks_by_task = {data["task_id"]: data["mask"] for data in task_data} | |
| assert ( | |
| masks_by_task == expected | |
| ), f"Unexpected CPU binding: {masks_by_task} != {expected}" | |
| def assert_binding(task_data, verbose, expected_masks, bind_type): | |
| assert_verbose(verbose, expected_masks, bind_type) | |
| assert_effect(task_data, expected_masks) | |
| def assert_binding_order(task_data, verbose, expected_cpus, bind_type): | |
| masks = [1 << cpu for cpu in expected_cpus] | |
| assert_binding(task_data, verbose, masks, bind_type) | |
| def test_cpu_bind_none(allocation): | |
| """Test that --cpu-bind=none removes the binding that would apply by default.""" | |
| # Match ntasks to the allocation's CPU count to make an auto-binding likely. | |
| ntasks = len(allocation.cpus) | |
| # Measure the default binding first, as the basis for judging none. | |
| default_data, default_verbose = bind_tasks( | |
| allocation, "--cpu-bind=verbose", ntasks=ntasks, cpus_per_task=None | |
| ) | |
| assert_no_failed_binding(default_verbose) | |
| default_masks = {data["task_id"]: data["mask"] for data in default_data} | |
| if all(mask == allocation.mask for mask in default_masks.values()): | |
| pytest.skip("This test requires default binding to restrict CPU masks") | |
| for task_id, default_mask in sorted(default_masks.items()): | |
| assert not default_mask & ~allocation.mask, ( | |
| f"Task {task_id} was bound outside the allocation by default: " | |
| f"0x{default_mask:x} is not within 0x{allocation.mask:x}" | |
| ) | |
| task_data, verbose = bind_tasks( | |
| allocation, "--cpu-bind=verbose,none", ntasks=ntasks, cpus_per_task=None | |
| ) | |
| assert_no_failed_binding(verbose) | |
| none_masks = {data["task_id"]: data["mask"] for data in task_data} | |
| assert len(none_masks) == ntasks, f"Should launch {ntasks} tasks: {none_masks}" | |
| # Every task keeps the one mask the step inherited. | |
| inherited = set(none_masks.values()) | |
| assert ( | |
| len(inherited) == 1 | |
| ), f"Unbound tasks should all share one mask, got {none_masks}" | |
| mask = inherited.pop() | |
| assert not allocation.mask & ~mask, ( | |
| f"Unbound tasks should keep every CPU of the allocation: " | |
| f"0x{mask:x} does not cover 0x{allocation.mask:x}" | |
| ) | |
| assert_verbose(verbose, [mask] * ntasks, bind_type="NONE") | |
| @pytest.mark.parametrize("form", ["map", "mask"]) | |
| def test_cpu_bind_single(allocation, form): | |
| """Test that a single-element map/mask_cpu binds every task to that CPU.""" | |
| for cpu_id in sample_ids(allocation.cpus): | |
| list_str = bind_list(form, [cpu_id]) | |
| task_data, verbose = bind_tasks( | |
| allocation, f"--cpu-bind={form}_cpu:{list_str},verbose" | |
| ) | |
| assert_binding_order( | |
| task_data, verbose, [cpu_id] * allocation.task_cnt, form.upper() | |
| ) | |
| @pytest.mark.parametrize("form", ["map", "mask"]) | |
| def test_cpu_bind_reuse(allocation, form): | |
| """Test that a multi-element map/mask_cpu list wraps to its first element.""" | |
| ids = allocation.cpus[:2] | |
| list_str = bind_list(form, ids) | |
| task_data, verbose = bind_tasks( | |
| allocation, | |
| f"--cpu-bind={form}_cpu:{list_str},verbose", | |
| ntasks=len(ids) + 1, | |
| cpus_per_task=None, | |
| ) | |
| assert_binding_order(task_data, verbose, [ids[0], ids[1], ids[0]], form.upper()) | |
| def test_mask_cpu_fat_masks(allocation): | |
| """Test that mask_cpu binds each task to every CPU in its mask.""" | |
| assert len(allocation.cpus) >= 2, "Should discover at least 2 bindable CPUs" | |
| masks = cover_masks(allocation.cpus) | |
| list_str = ",".join(f"0x{mask:x}" for mask in masks) | |
| task_data, verbose = bind_tasks( | |
| allocation, | |
| f"--cpu-bind=mask_cpu:{list_str},verbose", | |
| ntasks=len(masks), | |
| cpus_per_task=None, | |
| ) | |
| assert_binding(task_data, verbose, masks, "MASK") | |
| @pytest.mark.parametrize("form", ["map", "mask"]) | |
| @pytest.mark.parametrize("pattern", ["forward", "reverse", "alternating"]) | |
| def test_cpu_bind_patterns(allocation, form, pattern): | |
| """Test CPU map/mask binding in forward, reverse and alternating order.""" | |
| order = order_ids(allocation.cpus, pattern) | |
| list_str = bind_list(form, order) | |
| task_data, verbose = bind_tasks( | |
| allocation, f"--cpu-bind={form}_cpu:{list_str},verbose", ntasks=len(order) | |
| ) | |
| assert_binding_order(task_data, verbose, order, form.upper()) | |
| @pytest.mark.parametrize("form", ["map", "mask"]) | |
| def test_cpu_bind_repetition(allocation, form): | |
| """Test the '*<count>' repetition syntax documented for map_cpu/mask_cpu.""" | |
| cpus = allocation.cpus[:2] | |
| counts = [len(allocation.cpus) // len(cpus)] * len(cpus) | |
| counts[-1] += len(allocation.cpus) - sum(counts) | |
| expected = [cpu_id for cpu_id, cnt in zip(cpus, counts) for _ in range(cnt)] | |
| list_str = bind_list(form, cpus, counts) | |
| task_data, verbose = bind_tasks( | |
| allocation, | |
| f"--cpu-bind='{form}_cpu:{list_str},verbose'", | |
| ntasks=len(expected), | |
| ) | |
| assert_binding_order(task_data, verbose, expected, form.upper()) | |
| @pytest.mark.parametrize("bind_type", ["sockets", "cores", "threads"]) | |
| def test_cpu_bind_auto_generated(allocation, bind_type): | |
| """Test the cpu-bind types that generate the task binding automatically.""" | |
| group_mask = cpu_group_masks(bind_type, allocation) | |
| if len(set(group_mask.values())) < 2: | |
| pytest.skip(f"This test requires more than one group of {bind_type}") | |
| ntasks = len(allocation.cpus) | |
| task_data, verbose = bind_tasks( | |
| allocation, | |
| f"--cpu-bind={bind_type},verbose", | |
| ntasks=ntasks, | |
| cpus_per_task=1, | |
| ) | |
| assert len(task_data) == ntasks, "Should bind every requested task" | |
| # Which group a rank lands in is not documented, so read the group off | |
| # the CPUs the task holds and expect the rest of that group with them. | |
| # That asserts alignment: a mask is exactly one group, never a part of one | |
| # and never spanning two. The groups do not overlap, so every CPU in a | |
| # mask answers with the same group and the first one is as good as any. | |
| expected_masks = [] | |
| for data in sorted(task_data, key=lambda data: data["task_id"]): | |
| task_id, mask = data["task_id"], data["mask"] | |
| assert mask, f"Task {task_id} should be bound to at least one CPU" | |
| assert ( | |
| not mask & ~allocation.mask | |
| ), f"Task {task_id} bound outside the allocation" | |
| expected_masks.append(group_mask[atf.mask_to_list(mask)[0]]) | |
| # And covering: one task per CPU, so every group should appear once per | |
| # CPU it holds. The masks of two tasks sharing a group are equal, not | |
| # disjoint, which is why this compares multisets. | |
| assert sorted(expected_masks) == sorted(group_mask.values()), ( | |
| f"Tasks should be spread over the {bind_type} the allocation holds: " | |
| f"{[hex(m) for m in sorted(expected_masks)]} != " | |
| f"{[hex(m) for m in sorted(group_mask.values())]}" | |
| ) | |
| # Bind type for an automatic binding is unspecified, ignore it. | |
| assert_binding(task_data, verbose, expected_masks, bind_type=None) | |
| def test_cpu_bind_quiet(allocation): | |
| """Test that quiet binds without reporting.""" | |
| cpu_id = allocation.cpus[0] | |
| cpu_bind = f"--cpu-bind=quiet,map_cpu:{bind_list('map', [cpu_id])}" | |
| task_data, verbose = bind_tasks(allocation, cpu_bind) | |
| assert_effect(task_data, [1 << cpu_id] * allocation.task_cnt) | |
| assert ( | |
| "cpu-bind" not in verbose | |
| ), f"Binding should not be reported without verbose: {verbose}" |