feat(scheduler): reconcile mem reservation against real usage
Adds reservation-vs-actual-usage reconciliation for real (gauge-style)
resources, using mem as the concrete case -- documented in the new
resource-accounting.md steering doc.
Policy: charged = max(reserved, actual). A job's rusage[mem=...]
request is an estimate made before it has run, and the real usage the
worker later reports may land above or below it; charging a flat
reservation for the job's whole life either starves other jobs of
memory it never uses (when actual stays below the estimate) or lets
the host be driven into genuine oversubscription (when actual exceeds
it). Available memory for scheduling is then the host's OS-reported
available figure minus, per job, only the UNUSED SLACK of the
reservation (max(0, reserved - used)) -- never the job's actual usage
on top of that, since the OS's own figure already reflects it.
The existing xce_res_account_t{used, reserved, allocated} fields and
sched_update_res_accounts()'s used+reserved charge formula already
implement this exactly, once `used` is set to the OVERAGE beyond the
reservation (max(0, actual - reserved)) rather than left at its
permanent 0.0 default -- nothing previously computed it.
New (resource.c): sched_reconcile_host_resource_usage() sets that
overage on one xce_res_account_t and applies just the DELTA to the
affected worker's resInUse (not a full uncharge/recharge of the whole
job_account, which would needlessly re-touch sibling resources like
"slots" charged in the same account). sched_reconcile_job_resource_usage()
is the wrapper driven from a raw rusage report: maps the worker's real
wire resname ("memory_rss", same worker-resname-vs-scheduler-resource-
name split bug-1735 already established for the controller side) to
the scheduler's "mem" resource, and is a no-op when the job spans more
than one host (the wire report carries no per-host attribution today --
a separate, undocumented-until-now gap, out of this fix's scope) or
never requested rusage[mem=...] in the first place (reserved=0, nothing
to reconcile).
Wired into sched_apply_job_rusage() (schedobj.c), which already
receives every job rusage tick but previously only stored it for
display/plugin hooks with no resource-accounting effect at all.
Compatible with sched_account_jobs()'s existing per-round resInUse
reset+re-derive cycle (bug-1624): the reconciled `used` value is
persisted on the job's own account record, which that re-derivation
already reads from, not just a transient pool-level delta that a
round boundary would wipe.
Added link-satisfying stubs to the three white-box test files that
#include schedobj.c directly (test_job_update.c,
test_picker_config_fields.c, test_picker_wiring.c) -- none of them
exercise real job->accounts, so the new call is unreachable there, but
still needs to link. 10 new regression tests in
test_resource_accounting.c cover the core formula (below/at/above the
reservation, delta-only pool updates, sibling-resource isolation) and
the wrapper's scoping rules (resname mapping, multi-host skip,
no-request no-op).
Full scheduler suite: 55/55 passing, zero regressions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QxaSzHRB7pmYFp4cVnwkA
This commit is contained in:
parent
dc430ae112
commit
87a2a46b94
9 changed files with 691 additions and 1 deletions
148
.kiro/steering/resource-accounting.md
Normal file
148
.kiro/steering/resource-accounting.md
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
# Resource Accounting: Reservation vs. Actual Usage for Real Resources
|
||||
|
||||
## The distinction this document is about
|
||||
|
||||
XCE's schedulable resources split into two kinds, and they must be accounted
|
||||
differently:
|
||||
|
||||
- **Virtual resources** (e.g. `slots`) are pure allocation counts. A slot is
|
||||
either allocated to a job or it isn't — there is no OS ground truth for
|
||||
"how much of a slot is actually being used." Reservation and usage are the
|
||||
same number, always.
|
||||
- **Real resources** (e.g. `mem`) have an OS-measurable actual value that
|
||||
moves independently of what a job asked for. A job's `rusage[mem=4M]`
|
||||
request is an *estimate* of the memory it will need over its lifetime,
|
||||
supplied at submit time, before the job has run — it is not a hard
|
||||
guarantee, and the real usage the worker later reports may come in above
|
||||
or below it.
|
||||
|
||||
This document covers the accounting policy for the second kind, using `mem`
|
||||
as the running example. The same policy applies to any resource that is
|
||||
both `XCE_RESF_DYNAMIC` and `XCE_RESF_CONSUMABLE` (`types.h`) with a
|
||||
worker-reported instantaneous (gauge-style) value — not to `slots`, and not
|
||||
to a cumulative counter like `cpu_time` (a usage metric reported for
|
||||
accounting/billing, not a capacity resource the allocator schedules
|
||||
against).
|
||||
|
||||
## The policy
|
||||
|
||||
1. **Request.** A job may submit `rusage[mem=4M]`. If it doesn't, treat the
|
||||
reservation as 0 — the job made no promise about memory and gets none of
|
||||
the protection described below.
|
||||
|
||||
2. **Admission.** Before dispatching to a candidate host, the scheduler
|
||||
checks the host has at least 4MB of *available* memory (see the
|
||||
available-memory formula below — this is not simply the OS's raw free
|
||||
figure).
|
||||
|
||||
3. **Reservation charge.** Once dispatched, 4MB is charged against the
|
||||
host's in-use memory — this is the reservation, and it holds regardless
|
||||
of what the job actually ends up using, until real usage data says
|
||||
otherwise.
|
||||
|
||||
4. **Reconciliation against real usage.** Once the worker starts reporting
|
||||
the job's actual memory usage (rusage ticks), two cases:
|
||||
- **actual < reserved (4MB):** keep charging the full 4MB reservation.
|
||||
The estimate might have been conservative and the job may still grow
|
||||
into it later in its lifecycle — the unused slack must not be handed
|
||||
to another job on the strength of a snapshot that could be stale a
|
||||
moment later.
|
||||
- **actual >= reserved (4MB):** charge the real usage instead. The
|
||||
estimate was wrong in the other direction; admission control must
|
||||
reflect what the host is *actually* carrying, not a now-obsolete
|
||||
promise, or the host can be driven into genuine oversubscription.
|
||||
|
||||
In short: **charged = max(reserved, actual)**.
|
||||
|
||||
5. **Available memory for scheduling:**
|
||||
|
||||
```
|
||||
available = host_os_reported_available_memory
|
||||
- sum over every job on that host of: max(0, reserved_j - used_j)
|
||||
```
|
||||
|
||||
Only the *unused slack of the reservation* (`reserved - used`, floored at
|
||||
0) is subtracted — never the job's actual usage on top of that. The
|
||||
job's real consumption is already reflected in the OS's own
|
||||
available-memory figure by the time it's read; subtracting it again
|
||||
would double-count it. This is also why case 4's "actual >= reserved"
|
||||
branch contributes nothing extra here: `max(0, reserved - actual)` is
|
||||
already 0 once actual has caught up to or passed the reservation, so the
|
||||
OS figure alone is authoritative once a job is running hot.
|
||||
|
||||
## Existing infrastructure this policy builds on
|
||||
|
||||
This is not a green-field design — the account-level data model already has
|
||||
almost exactly the right shape for it, it's just never been wired up to
|
||||
real usage data:
|
||||
|
||||
- `xce_res_account_t` (`types.h`) already carries three separate fields per
|
||||
job-resource pairing: `reserved`, `allocated` (both set to the job's
|
||||
request at dispatch time, `make_res_account()`, `xcealloc.c`), and `used`.
|
||||
`used` is initialized to `0.0` and — as of this writing — **nothing in the
|
||||
codebase ever updates it again**. It is a fully dormant field.
|
||||
- The resource-pool charge/uncharge path, `sched_update_res_accounts()`
|
||||
(`resource.c`), sums `accounts[i]->used + accounts[i]->reserved` as the
|
||||
amount charged into `w->resInUse`/`core->sharedInUse`.
|
||||
|
||||
Substitute `used = max(0, actual - reserved)` (the *overage* beyond the
|
||||
reservation, not the raw actual value) into that existing sum and it falls
|
||||
out exactly right, with no formula change needed at the charge site:
|
||||
|
||||
```
|
||||
used + reserved = reserved + max(0, actual - reserved) = max(reserved, actual)
|
||||
```
|
||||
|
||||
— precisely step 4's `charged = max(reserved, actual)` above. The field
|
||||
names and the charge formula strongly suggest this reconciliation was the
|
||||
original intent of splitting `used` out from `reserved`/`allocated` in the
|
||||
first place; only the piece that keeps `used` current from real rusage
|
||||
reports was never built.
|
||||
|
||||
### The missing wiring (not yet implemented — scope for a future task)
|
||||
|
||||
Job rusage already reaches the scheduler (`XCE_SCHED_OP_JOB_RUSAGE` ->
|
||||
`sched_apply_job_rusage()`, `schedobj.c`) — but today that function only
|
||||
stores the raw report on `job->rusages` and fires a picker-plugin hook
|
||||
(`plugin_job_rusage_update`); it never touches `job->accounts` or
|
||||
re-charges the resource pool. Implementing this policy means, at that same
|
||||
integration point, for each `XCE_RESF_DYNAMIC | XCE_RESF_CONSUMABLE`
|
||||
resource the report covers:
|
||||
|
||||
1. Look up the job's existing `reserved` value for that resource from
|
||||
`job->accounts`.
|
||||
2. Compute `overage = max(0, actual_usage - reserved)`.
|
||||
3. Update `xce_res_account_t.used = overage`.
|
||||
4. Re-charge the pool: uncharge the account's old total, recharge the new
|
||||
total, so `w->resInUse`/`core->sharedInUse` — and therefore
|
||||
`unsatisfy_()`'s admission check (`total < inuse + uncommitted +
|
||||
wanted`, `resource.c`) — reflects the update immediately, not just at
|
||||
the next full re-account pass (`sched_account_jobs()`).
|
||||
|
||||
This has not been implemented yet. When it is, it should land as its own
|
||||
spec (TDD: a regression test proving the reconciliation formula before the
|
||||
wiring, following this project's testing discipline) rather than a quick
|
||||
patch, since it changes live admission-control behavior for every
|
||||
`rusage[mem=...]`-bearing job in the cluster.
|
||||
|
||||
## Why estimates in either direction are expected, not exceptional
|
||||
|
||||
`rusage[mem=...]` is supplied before the job has run — it is a best-effort
|
||||
prediction of peak lifetime memory, not a measurement. Both directions of
|
||||
error are routine, not edge cases to special-case away:
|
||||
|
||||
- **Over-estimated** (the common conservative-user case): the job never
|
||||
grows into the memory it asked for. Case 4's first branch protects the
|
||||
host from another job being packed into that never-realized headroom —
|
||||
a job that legitimately reserved memory it turns out not to need for
|
||||
most of its run can still burst into it later without being starved by a
|
||||
neighbor that was admitted on the strength of a low-water-mark reading.
|
||||
- **Under-estimated**: the job genuinely needs more than it asked for.
|
||||
Case 4's second branch keeps admission control honest about what the
|
||||
host is really carrying once that happens, rather than continuing to
|
||||
trust a promise the job has already broken.
|
||||
|
||||
Neither case is a failure mode to reject or warn about at the accounting
|
||||
layer — the reservation is advisory input to admission control, not a hard
|
||||
cap enforced against the job itself (cgroup-level enforcement, if desired,
|
||||
is a separate concern from scheduling admission).
|
||||
Loading…
Add table
Add a link
Reference in a new issue