blob: 6cc15f50787b59d61ec1e966af2614cb373401bf [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""test_130_6.py - per-TRES decay of GrpTRESMins usage.
Ticket 50920: TresDecayHalfLife sets how fast a TRES's GrpTRESMins usage
decays, overriding PriorityDecayHalfLife for that TRES only. 0 means it never
decays.
Four accounts accrue usage together and are then sampled twice, before and
after the same stretch of decay. Everything is asserted by comparing the
accounts against each other over that one shared window, never against a
decay curve computed from wall-clock time. That matters: decay lands in one
discrete step per PriorityCalcPeriod at a phase this test cannot see, so any
assertion built on elapsed time is comparing against a value the controller
never had. Comparing accounts that share a window cancels the phase out.
The ordering asserted below, frozen > slow > global > fast, is also what makes
the test fail if the feature is reverted: without it all four accounts decay
at PriorityDecayHalfLife and every fraction is equal.
"""
import pytest
import tres_decay as td
import atf
# 600s+: tox.ini defines the marker for anything over 60s.
pytestmark = pytest.mark.slow
# Several ticks per half-life, so decay arrives in steps small enough that a
# sample landing mid-tick cannot be mistaken for a different rate.
GLOBAL_HALF_LIFE = "00:05:00"
GLOBAL_HALF_LIFE_SEC = 300
FAST_HALF_LIFE_SEC = 60
SLOW_HALF_LIFE_SEC = 3000
PRIORITY_CALC_PERIOD_SEC = 60
# At least three PriorityCalcPeriod ticks while the jobs are still running, so
# the periodic decay pass has several chances to apply the running-job path's
# per-TRES half-life before any job ends, rather than possibly zero. Below
# this, test_running_job_accrual_decays_per_tres can tie on a healthy tree.
JOB_RUN_SEC = 3 * PRIORITY_CALC_PERIOD_SEC
DECAY_WAIT_SEC = 420
# scontrol reports GrpTRESMins usage in whole TRES-minutes, so the jobs have to
# accrue tens of minutes for the slowest half-life here to move the number at
# all. Usage accrues as cpus * seconds / 60, so a wide job reaches the same
# TRES-minutes in a fraction of the wall time; the nodes are Slurm's own
# configuration rather than the host's, so the width costs nothing. One node
# each, so they all run at once and share a window.
JOB_CPUS = 32
# A memory-requesting job, so mem accrues GrpTRESMins usage of its own and a
# mem-only override can be told apart from what cpu is doing on the same record.
JOB_MEM_MB = 1024
VERSION_REASON = "Ticket 50920: TresDecayHalfLife requires Slurm 26.11"
ACCTS = {
"frozen": "cpu=0",
"slow": f"cpu={SLOW_HALF_LIFE_SEC}",
"global": None,
"fast": f"cpu={FAST_HALF_LIFE_SEC}",
# No override on the association: this account's job runs under a QOS that
# carries the half-life, so what is measured is the QOS record.
"nodecay": None,
# Same, for a QOS frozen by a per-TRES half-life rather than by NoDecay.
"qos_frozen": None,
# Only mem is frozen here. cpu is left unset on purpose: the two TRES on
# this one record have to move independently.
"mem_frozen": "mem=0",
}
# NoDecay is a QOS flag and it suppresses the whole record, half-life included.
# The QOS gets the same rate as the "fast" account so the flag is the only
# difference between the two, and the fast account is what says the rate would
# otherwise have moved it.
NODECAY_QOS = "test_qos_130_6_nodecay"
NODECAY_ACCT_KEY = "nodecay"
# A QOS frozen by cpu=0 rather than by NoDecay. Both stop the record decaying
# but by different routes - the flag short-circuits the whole record, while a
# half-life of 0 makes the factor for that one TRES 1.0 - so both are worth
# holding onto.
FROZEN_QOS = "test_qos_130_6_frozen"
FROZEN_QOS_ACCT_KEY = "qos_frozen"
MEM_ACCT_KEY = "mem_frozen"
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version((26, 11), "bin/sacctmgr", reason=VERSION_REASON)
atf.require_version((26, 11), "sbin/slurmdbd", reason=VERSION_REASON)
atf.require_version((26, 11), "sbin/slurmctld", reason=VERSION_REASON)
atf.require_auto_config("sets PriorityDecayHalfLife and PriorityCalcPeriod")
atf.require_config_parameter("PriorityType", "priority/multifactor")
atf.require_config_parameter("PriorityCalcPeriod", 1)
atf.require_config_parameter("PriorityDecayHalfLife", GLOBAL_HALF_LIFE)
atf.require_config_parameter("PriorityUsageResetPeriod", "NONE")
atf.require_config_parameter_includes("AccountingStorageEnforce", "limits")
# mem only accrues GrpTRESMins usage when it is a consumable resource.
atf.require_config_parameter_includes(
"SelectTypeParameters", ["CR_Core_Memory", "CR_Memory"]
)
atf.require_accounting(modify=True)
atf.require_nodes(len(ACCTS), [("CPUs", JOB_CPUS), ("RealMemory", JOB_MEM_MB)])
atf.require_slurm_running()
def _acct_name(key):
return f"test_acct_130_6_{key}"
@pytest.fixture(scope="module")
def accrued_usage():
"""Create the accounts, accrue usage on all of them, then stop.
One accrual window shared by every test in the module: the jobs are what
make this slow, and running them once is what keeps it to a few minutes
rather than a few minutes per test.
"""
user = atf.get_user_name()
for key, value in ACCTS.items():
name = _acct_name(key)
atf.run_command(
f"sacctmgr -i add account {name}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add user {user} account={name}",
user=atf.properties["slurm-user"],
fatal=True,
)
if value:
atf.run_command(
f"sacctmgr -i modify account {name} set TresDecayHalfLife={value}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add qos {NODECAY_QOS} set flags=NoDecay "
f"TresDecayHalfLife=cpu={FAST_HALF_LIFE_SEC}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i modify account {_acct_name(NODECAY_ACCT_KEY)} "
f"set QosLevel={NODECAY_QOS}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add qos {FROZEN_QOS} set TresDecayHalfLife=cpu=0",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i modify account {_acct_name(FROZEN_QOS_ACCT_KEY)} "
f"set QosLevel={FROZEN_QOS}",
user=atf.properties["slurm-user"],
fatal=True,
)
job_ids = []
for key in ACCTS:
# Each QOS is used by exactly one account, so only that QOS record
# accrues usage and nothing else in the module has to know about it.
extra = ""
if key == NODECAY_ACCT_KEY:
extra = f" --qos={NODECAY_QOS}"
elif key == FROZEN_QOS_ACCT_KEY:
extra = f" --qos={FROZEN_QOS}"
elif key == MEM_ACCT_KEY:
# Asking for memory is what makes mem accrue usage to freeze.
extra = f" --mem={JOB_MEM_MB}"
job_ids.append(
atf.submit_job_sbatch(
f"-A {_acct_name(key)} -N1 -n{JOB_CPUS} -t5{extra} "
f"--wrap 'sleep {JOB_RUN_SEC}'",
fatal=True,
)
)
for job_id in job_ids:
atf.wait_for_job_state(job_id, "RUNNING", timeout=120, fatal=True)
# Every account must finish. Without fatal= a stalled node leaves one
# account still accruing, and a frozen account that grew instead of
# holding would satisfy the assertion below for the wrong reason.
for job_id in job_ids:
atf.wait_for_job_state(job_id, "DONE", timeout=JOB_RUN_SEC + 120, fatal=True)
yield
atf.cancel_all_jobs()
# The user first: one of these accounts is its default, and neither the
# user nor the account can be removed while that is true.
atf.run_command(
f"sacctmgr -i remove user {user}",
user=atf.properties["slurm-user"],
)
accts = ",".join(_acct_name(key) for key in ACCTS)
atf.run_command(
f"sacctmgr -i remove account {accts}",
user=atf.properties["slurm-user"],
)
# After the accounts, which still reference them until then.
atf.run_command(
f"sacctmgr -i remove qos {NODECAY_QOS},{FROZEN_QOS}",
user=atf.properties["slurm-user"],
)
@pytest.fixture(scope="module")
def decay_window(accrued_usage):
"""Sample every account before and after one shared stretch of decay.
Returns the GrpTRESMins cpu usage of every account as "before" and
"after", and its RawUsage as "raw_before" and "raw_after".
"""
sampled = {k: td.account_usage(_acct_name(k)) for k in ACCTS}
before = {k: v[0] for k, v in sampled.items()}
raw_before = {k: v[1] for k, v in sampled.items()}
qos_before = td.qos_cpu_usage(NODECAY_QOS)
frozen_qos_before = td.qos_cpu_usage(FROZEN_QOS)
mem_before = td.mem_usage(_acct_name(MEM_ACCT_KEY))
for key, value in before.items():
assert value > 0, (
f"{key} accrued no usage, so nothing below can mean anything; "
f"the jobs did not run long enough or accounting is not recording"
)
assert qos_before > 0, (
f"{NODECAY_QOS} accrued no usage, so it holding below would mean "
f"nothing; the job did not run under the QOS"
)
assert frozen_qos_before > 0, (
f"{FROZEN_QOS} accrued no usage, so it holding below would mean "
f"nothing; the job did not run under the QOS"
)
assert mem_before > 0, (
f"the {MEM_ACCT_KEY} account accrued no mem usage, so it holding below "
f"would mean nothing; the job did not request memory, or mem is not a "
f"consumable resource"
)
# Close the window once the slow account has visibly decayed. It is the
# slowest of the three that must decay, so by then the other two have
# moved further, and usage is reported in whole TRES-minutes, so ending
# any earlier would leave the slow account rounding to where it started.
# fatal= so a window that never opens fails here, naming the cause,
# rather than surfacing as a tie in the ordering assertions below.
for _ in atf.timer(DECAY_WAIT_SEC, 10, quiet=True, fatal=True):
if td.cpu_usage(_acct_name("slow")) < before["slow"]:
break
sampled = {k: td.account_usage(_acct_name(k)) for k in ACCTS}
after = {k: v[0] for k, v in sampled.items()}
raw_after = {k: v[1] for k, v in sampled.items()}
qos_after = td.qos_cpu_usage(NODECAY_QOS)
frozen_qos_after = td.qos_cpu_usage(FROZEN_QOS)
mem_after = td.mem_usage(_acct_name(MEM_ACCT_KEY))
return {
"before": before,
"after": after,
"raw_before": raw_before,
"raw_after": raw_after,
"qos_before": qos_before,
"qos_after": qos_after,
"frozen_qos_before": frozen_qos_before,
"frozen_qos_after": frozen_qos_after,
"mem_before": mem_before,
"mem_after": mem_after,
}
def _fraction(window, key):
"""How much of the accrued usage survived the window."""
return window["after"][key] / window["before"][key]
@pytest.mark.parametrize("key", list(ACCTS))
def test_controller_has_the_half_life(accrued_usage, key):
"""The controller reports what the database was told.
The decay happens in the controller, so a value that reached the
database but not the controller would leave every account below decaying
at PriorityDecayHalfLife with nothing to say so.
"""
got = td.reported_half_life(_acct_name(key))
assert got == (
ACCTS[key] or ""
), f"{key} was set to {ACCTS[key]}, controller reports {got!r}"
def test_frozen_tres_does_not_decay(decay_window):
"""cpu=0 means that TRES's GrpTRESMins usage never decays."""
before = decay_window["before"]["frozen"]
after = decay_window["after"]["frozen"]
assert after == before, (
f"frozen account moved from {before} to {after}; "
f"cpu=0 should hold usage exactly where it is"
)
def test_running_job_accrual_decays_per_tres(decay_window):
"""frozen and fast have separated by the time decay_window opens.
GrpTRESMins usage is folded in as a job runs, not only when it ends, so
_calc_tres_run_decay() has to apply the per-TRES half-life to live
accrual the same way the periodic pass does. JOB_RUN_SEC gives the
periodic pass several ticks to fire while the jobs are still running, so
it has repeated chances to apply that path before decay_window samples
"before". This does not isolate the running-job path by itself: the
separation it measures is whatever that path did while the jobs were up,
plus whatever the periodic pass did in the gap between the jobs ending
and the sample being taken. It is that combination, not the running-job
path alone, that fails if _calc_tres_run_decay() is reverted.
Only the frozen/fast pair is compared: they are ~5x apart, where frozen
and slow differ by ~1.4% per pass and would be sensitive to job start
skew.
"""
frozen = decay_window["before"]["frozen"]
fast = decay_window["before"]["fast"]
assert frozen > fast, (
f"identical jobs accrued {frozen} on cpu=0 and {fast} on "
f"cpu={FAST_HALF_LIFE_SEC}; a running job's usage should be decayed "
f"with that TRES's own half-life as it accrues"
)
def test_unset_tres_follows_the_global(decay_window):
"""An account with no half-life of its own still decays."""
before = decay_window["before"]["global"]
after = decay_window["after"]["global"]
assert after < before, (
f"account with no override held at {after}; it should decay at "
f"PriorityDecayHalfLife"
)
def test_rate_ordering(decay_window):
"""A shorter half-life decays more of the usage over the same window.
This is the assertion that fails if the feature is reverted: without it
every account decays at PriorityDecayHalfLife and all four fractions are
equal, so no strict ordering can hold.
"""
fast = _fraction(decay_window, "fast")
glob = _fraction(decay_window, "global")
slow = _fraction(decay_window, "slow")
frozen = _fraction(decay_window, "frozen")
assert fast < glob, (
f"cpu={FAST_HALF_LIFE_SEC} should decay more than the "
f"{GLOBAL_HALF_LIFE_SEC}s global: kept {fast:.3f} vs {glob:.3f}"
)
assert glob < slow, (
f"the {GLOBAL_HALF_LIFE_SEC}s global should decay more than "
f"cpu={SLOW_HALF_LIFE_SEC}: kept {glob:.3f} vs {slow:.3f}"
)
assert slow < frozen, (
f"cpu={SLOW_HALF_LIFE_SEC} should decay more than cpu=0: "
f"kept {slow:.3f} vs {frozen:.3f}"
)
def test_fairshare_still_decays_on_a_frozen_account(decay_window):
"""Freezing a TRES must not freeze fairshare with it.
This is the whole point of the feature: PriorityDecayHalfLife=0 would
have stopped both.
"""
before = decay_window["raw_before"]["frozen"]
after = decay_window["raw_after"]["frozen"]
assert after < before, (
f"RawUsage held at {after} on the frozen account; only GrpTRESMins "
f"usage should stop decaying"
)
def test_nodecay_qos_overrides_the_half_life(decay_window):
"""NoDecay on a QOS suppresses the half-life the QOS itself carries.
The QOS has the same cpu half-life as the "fast" account, so the fast
account decaying over this same window is what says the rate would have
moved the QOS too. NoDecay is the only difference between them.
"""
before = decay_window["qos_before"]
after = decay_window["qos_after"]
fast_before = decay_window["before"]["fast"]
fast_after = decay_window["after"]["fast"]
assert fast_after < fast_before, (
f"cpu={FAST_HALF_LIFE_SEC} did not decay on the association either, so "
f"the QOS holding says nothing about NoDecay"
)
assert after == before, (
f"NoDecay must suppress the QOS's own cpu={FAST_HALF_LIFE_SEC} "
f"half-life, but usage moved from {before} to {after}"
)
def test_qos_half_life_of_zero_freezes_the_qos(decay_window):
"""cpu=0 on a QOS freezes that QOS's GrpTRESMins usage.
The same value on an association is covered above. This is the QOS route
into the same code, and it is a different route from NoDecay: the flag
short-circuits the whole record, while a half-life of 0 makes the factor
for that one TRES 1.0.
"""
before = decay_window["frozen_qos_before"]
after = decay_window["frozen_qos_after"]
assert after == before, (
f"cpu=0 on a QOS must stop its GrpTRESMins usage decaying, but it "
f"moved from {before} to {after}"
)
def test_freezing_one_tres_leaves_the_other_alone(decay_window):
"""mem=0 freezes mem on a record whose cpu is still on the global rate.
Both TRES live in the same usage_tres_raw array on the same association, so
this is what says the array is walked per TRES rather than as a whole. cpu
decaying here is the control: if it held too, the record stopped decaying
for some other reason than the mem override.
"""
key = MEM_ACCT_KEY
mem_before = decay_window["mem_before"]
mem_after = decay_window["mem_after"]
cpu_before = decay_window["before"][key]
cpu_after = decay_window["after"][key]
assert cpu_after < cpu_before, (
f"cpu is unset on this account and must follow the "
f"{GLOBAL_HALF_LIFE_SEC}s global, but it held at {cpu_after}; the mem "
f"hold below would then say nothing about per-TRES independence"
)
assert mem_after == mem_before, (
f"mem=0 must stop mem decaying, but it moved from {mem_before} to "
f"{mem_after} while cpu went {cpu_before} -> {cpu_after}"
)