blob: d6843277ace03268ce8b3e4265b7a88e33c45487 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test Mode 3 HRES preemption between jobs that share no nodes."""
import pytest
import atf
pytestmark = pytest.mark.slow
# MODE_3 hierarchy where each node has plenty of the "power" resource but the
# root layer only has enough for 100 units in total. Jobs landing on different
# nodes still collide at the root, so a higher-priority job must preempt
# lower-priority ones to run -- even though they share no nodes.
resources_yaml = """
- resource: power
mode: MODE_3
layers:
- layer_name: "leaf1"
parent_name: "root"
nodes:
- "node1"
count: 100
- layer_name: "leaf2"
parent_name: "root"
nodes:
- "node2"
count: 100
- layer_name: "leaf3"
parent_name: "root"
nodes:
- "node3"
count: 100
- layer_name: "leaf4"
parent_name: "root"
nodes:
- "node4"
count: 100
- layer_name: "root"
count: 100
"""
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_auto_config("modifies preemption and partition configuration")
atf.require_version(
(26, 11),
component="sbin/slurmctld",
reason="Mode 3 HRES preemption was added in 26.11",
)
atf.require_version(
(25, 11),
component="bin/sbatch",
reason="--resources was added in 25.11",
)
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_config_parameter("PreemptType", "preempt/partition_prio")
atf.require_config_parameter("PreemptMode", "CANCEL")
# Keep completed job records so the preempted job stays queryable
atf.require_config_parameter("MinJobAge", 3600)
atf.require_config_parameter_includes("SchedulerParameters", ("bf_interval", 1))
atf.require_config_parameter_includes("SchedulerParameters", ("sched_interval", 1))
# Pin the preemptee ordering so test_mode3_prune_unneeded_preemptee can
# predict which candidates are removed.
atf.require_config_parameter_includes("PreemptParameters", "strict_order")
atf.require_config_parameter_includes("PreemptParameters", ("reorder_count", 0))
atf.require_nodes(4, [("CPUs", 2), ("RealMemory", 512)])
atf.require_config_parameter("DefMemPerNode", "128")
# With preempt/partition_prio the preemptee's partition PreemptMode wins
# over the cluster one, so each mode gets its own partition instead of
# reconfiguring between cases.
atf.require_config_parameter(
"PartitionName",
{
"lowcancel": {
"Nodes": "ALL",
"PriorityTier": "1",
"PreemptMode": "CANCEL",
},
"lowrequeue": {
"Nodes": "ALL",
"PriorityTier": "1",
"PreemptMode": "REQUEUE",
},
"lowcancel2": {
"Nodes": "ALL",
"PriorityTier": "2",
"PreemptMode": "CANCEL",
},
"lowcancel3": {
"Nodes": "ALL",
"PriorityTier": "3",
"PreemptMode": "CANCEL",
},
"highprio": {"Nodes": "ALL", "PriorityTier": "4"},
},
)
atf.require_config_file("resources.yaml", resources_yaml)
atf.require_slurm_running()
@pytest.mark.parametrize(
"partition,preempted_state",
[
("lowcancel", "PREEMPTED"),
("lowrequeue", "PENDING"),
],
)
def test_mode3_preempt(partition, preempted_state):
"""Verify MODE_3 HRES preemption without node overlap.
A low-priority job on node1 consumes the shared root-layer "power"
budget. A higher-priority job requesting the same resource on node2
cannot start until the low-priority job is preempted, even though the
two jobs share no nodes.
"""
low_id = atf.submit_job_sbatch(
f"-p {partition} -w node1 --resources=power:100 --mem=1 -o /dev/null"
' --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
low_id, "RUNNING"
), f"Low-priority job ({low_id}) did not start"
high_id = atf.submit_job_sbatch(
"-p highprio -w node2 --resources=power:100 --mem=1 -o /dev/null"
' --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
low_id, preempted_state
), f"Low-priority job ({low_id}) was not preempted ({preempted_state})"
assert atf.wait_for_job_state(
high_id, "RUNNING"
), f"High-priority job ({high_id}) did not start after preemption"
atf.cancel_jobs([low_id, high_id], fatal=True)
def test_mode3_prune_unneeded_preemptee():
"""Verify a preemptee MODE_3 does not need is left running.
The root layer holds 100 units of "power", spread over three
lower-priority jobs: 20 on node1, 40 on node2 and 40 on node3. A
high-priority job wants 80 on node4, so freeing the two 40-unit jobs
is enough and the 20-unit job never has to die.
Candidates are tried in preemptee order, which is 20, 40, 40 here. The
first pass has to remove all three before the job fits, and the reorder
pass only moves the last one to the front, so the 20-unit job is still
in the list that gets built. Removing it again is the job of the second
pass over the preemptee list.
"""
keep_id = atf.submit_job_sbatch(
"-p lowcancel -w node1 --resources=power:20 --mem=1 -o /dev/null"
' --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
keep_id, "RUNNING"
), f"Job ({keep_id}) holding 20 units did not start"
victim_ids = []
for partition, node in (("lowcancel2", "node2"), ("lowcancel3", "node3")):
job_id = atf.submit_job_sbatch(
f"-p {partition} -w {node} --resources=power:40 --mem=1 -o /dev/null"
' --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), f"Job ({job_id}) holding 40 units did not start"
victim_ids.append(job_id)
high_id = atf.submit_job_sbatch(
"-p highprio -w node4 --resources=power:80 --mem=1 -o /dev/null"
' --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
high_id, "RUNNING"
), f"High-priority job ({high_id}) did not start after preemption"
for job_id in victim_ids:
assert atf.wait_for_job_state(
job_id, "PREEMPTED"
), f"Job ({job_id}) holding 40 units was not preempted"
assert (
atf.get_job_parameter(keep_id, "JobState") == "RUNNING"
), f"Job ({keep_id}) holding 20 units was preempted but was not needed"
atf.cancel_jobs([keep_id, high_id] + victim_ids, fatal=True)