Skip to content

perf(spurctld): aggregate assoc_mgr scope usage in a single pass - #797

Draft
nikhilsk wants to merge 6 commits into
ROCm:mainfrom
nikhilsk:perf/assoc-mgr-single-pass
Draft

perf(spurctld): aggregate assoc_mgr scope usage in a single pass#797
nikhilsk wants to merge 6 commits into
ROCm:mainfrom
nikhilsk:perf/assoc-mgr-single-pass

Conversation

@nikhilsk

@nikhilsk nikhilsk commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

What this changes

scontrol show assoc_mgr recomputed each scope's usage statistics by walking the
job table once per statistic, per user. Cost grew as scopes x users x jobs, so on
a cluster with many users under one QOS an operator-facing read did far more work
than the data required. This aggregates each scope in a single pass over its
in-scope jobs, bucketing per user as it goes, so cost is scopes x jobs.

Approach

The subtlety is that the Node TRES dimension counts distinct occupied nodes, not
the sum of per-job node requests, and it falls back to the job's requested count
when placement is missing. Summing that per statistic is easy to get wrong once the
walks are merged, so the node-union logic is extracted into a
RunningTresAccumulator that owns the distinct-node semantics and the
placement-missing fallback. sum_running_tres on the admission path now uses the
same accumulator, so the operator view and the scheduler cannot drift apart.

Each user gets their own accumulator, so two of a user's jobs sharing a node still
count as one node for that user while the group figure unions across everyone.

Testing

cargo clippy --workspace --exclude spur-ffi --all-targets --locked is
warning-free and cargo test --locked passes.

Added scope_usage_single_pass_matches_per_statistic_computation, which builds a
fixture with several users, two jobs sharing a node, an unplaced job, a
pending-only user, and a job in an excluded scope, then compares the new
implementation against the old per-statistic walks kept in the test as a reference.
Literal assertions pin the distinct-node semantics independently of that reference,
so the test still fails if both implementations drift the same way.

Note for reviewers

This is stacked on #746 and opened as a draft against main, so the diff
currently includes that PR's commits. Once #746 merges I will rebase and only the
two commits here will remain.

@codecov-commenter

codecov-commenter commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.41658% with 75 lines in your changes missing coverage. Please review.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #797      +/-   ##
==========================================
+ Coverage   80.10%   80.26%   +0.15%     
==========================================
  Files         184      184              
  Lines       87417    88685    +1268     
==========================================
+ Hits        70024    71176    +1152     
- Misses      17393    17509     +116     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…sage

Operators had no way to see how much of a QOS or association a user holds
against its caps; the figures existed only inside the admission gate, which
recomputes them per candidate job and keeps nothing queryable. Counting squeue
rows was the only recourse, and it cannot show a cap already being exceeded.

Add a GetAssocMgrInfo RPC on the controller, since only the controller sees the
live job table, and aggregate per (user, QOS) and (user, account) using the same
sum_running_tres the gate uses, so a record reads as the scheduler sees it
rather than as a second opinion. The cap comparison lives in spur-core beside
the limits themselves, next to the admission checks it deliberately differs
from: those project a candidate job onto current usage, this reports what
already stands over a cap.

Records come from the queue, not from the accounting definitions, so an unused
QOS is absent — sacctmgr show qos lists definitions. A cold accounting cache
reports LimitsReadable=NO instead of letting an unreadable cap look absent.
Slurm's assoc_mgr prints a cap and its consumption in one Limit(Consumed) field
with N for no limit, dumps every association and QOS its cache holds rather than
only those with jobs, and nests per-user limits inside the record they qualify.
Existing scripts parse that shape, so diverging from it is a cost paid by every
tool that already reads Slurm.

Render caps as Limit(Consumed) per count and per TRES dimension, listing the
union of dimensions capped and in use. Build records from the accounting
definitions as well as the queue, so a cap on a QOS nobody is using stays
visible, which needs enumerate-all accessors on both caches. Restructure a
record as a scope with users nested under it, which also stops a scope's group
figures being repeated once per user.

A QOS caps every user identically, so those caps are stated once on the scope and
remain visible when no one is using it; an association's are per (user, account)
and ride on each user instead. Caps and consumption stay separate fields on the
wire so a machine consumer never has to take a display string apart.
The GetAssocMgrInfo handler took `user` straight from the request as a
filter, and an empty value meant "no filter", so any caller could read every
user's live job counts and TRES holdings. scope_usage narrowed only the
per-user list, never the Grp* lines or the QOS/account names, so even a
filtered call still enumerated the cluster-wide scope inventory.

Scope the read the way get_jobs does: a privileged caller (an admin, or an
unauthenticated one under permissive/disabled, the same treatment
viewer_is_privileged gives) reads whichever user it names, or every user when
it names none; a non-admin authenticated caller is pinned to its own identity.
scope_usage now drops a scope entirely when the filtered user holds no work
and has no defined association there, so a non-admin can neither read another
tenant's usage nor enumerate the QOS/account inventory. A blanket
require_admin would be wrong: permissive mode has identity == None, which the
existing convention deliberately treats as privileged.

Correct the read path while it is in hand: limits_readable is now
!accounting_enabled() || (qos_loaded && assoc_loaded), evaluated per cache, so
an accounting-off cluster reports readable (it has no caps to load) instead of
standing at LimitsReadable=NO forever, and a split cache still reads as
incomplete. The association records also read each (user, account) limit from
the rows association_cache.all() already returned rather than re-locking the
cache once per user.
- Render MaxTRES*= as N when the scope sets no per-user TRES cap, matching its
  count siblings instead of an empty field a parser splitting on = would read
  as missing.
- Reject an unknown <key>= selector (e.g. qos=highprio) rather than taking it
  as a literal username and printing an empty result; users=<name> and a bare
  name still work. The parse moved into a small testable helper.
- Soften the LimitsReadable=NO banner: it prints only when accounting is
  enabled but a cache is cold, where some caps below may be missing rather
  than all, and is suppressed otherwise.
- Dedupe the tres_limit_consumed dimension union with sort+dedup rather than
  O(n^2) Vec::contains. A BTreeSet is not usable here: TresType is not Ord,
  and ordering it would sort by discriminant instead of by name.
- proto: split the drifted comment block so AssocMgrRecord and AssocMgrCaps
  each carry their own, and correct the TRES-rendering and limits_readable
  wording (comments only, no field change).
- accounting: give types() and format() their own doc comments, and give the
  group-caps-only test a per-user cap it is within so an empty result proves
  the group caps were not leaked onto the user.
- docs: narrow the Limit(Consumed) and literal-0 claims (0 is a real count cap
  but an unset TRES dimension), correct the LimitsReadable wording, and note
  that an unprivileged caller is scoped to its own associations.
scope_usage computed grp_running_jobs, grp_submitted_jobs, grp_running_tres
and every per-user figure with its own walk over the whole jobs map, so
assoc_mgr_info cost grew as O(scopes x users x jobs). Fold all figures for a
scope into one pass over its in-scope jobs, keyed by user, and render from the
aggregate — the cost is now O(scopes x jobs).

The node dimension is deduped per aggregate (group union and each user's union
kept separately) via a shared RunningTresAccumulator, which sum_running_tres now
also uses, so the distinct-node rule and the placement-missing fallback the
admission gate relies on are counted identically everywhere.
The excluded-scope check went through a node-count proxy and its message
claimed the user should not appear, when alice legitimately appears via her
in-scope jobs. Assert the user set and her in-scope running count instead,
which is what the case is actually about.
@nikhilsk
nikhilsk force-pushed the perf/assoc-mgr-single-pass branch from 8d6314b to eec6aca Compare September 2, 2026 08:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants