[YUNIKORN-3353] Keep only pending asks in sortedRequests - #1127
Open
tigerquoll wants to merge 6 commits into
Open
[YUNIKORN-3353] Keep only pending asks in sortedRequests#1127tigerquoll wants to merge 6 commits into
tigerquoll wants to merge 6 commits into
Conversation
…istogram
updateAskMaxPriority recomputed an application's highest pending ask priority by
rescanning every one of its asks, and it ran on every allocate, on every
deallocate and on every ask removal whose priority was at or above the current
maximum. Over a scheduling cycle that is quadratic in the number of asks an
application holds.
In a profile of BenchmarkScheduling this rescan, together with the pending-ask
scan addressed by the follow-up commit in this series and the getter hoisted by
the previous one, accounts for 91% of Application.tryAllocate; only 9% of it is
spent deciding where a pod should go.
Replace the rescan with a small histogram of pending asks per priority value,
maintained incrementally: incPendingPriority when an ask becomes pending,
decPendingPriority when it leaves the pending set. askMaxPriority is then O(1) in
the common case, and only a bucket emptying at the current maximum costs a walk -
over the number of distinct priorities, not the number of asks. The queue is
notified only when the value actually changes, where the rescan re-published it
unconditionally.
One guard comes with the incremental structure, because a full rescan tolerated a
state that incremental bookkeeping does not: deallocateAsk only returns an ask to
the histogram when it is still the tracked object for its key.
removeAsksInternal("") wipes sa.requests while sa.allocations survives until the
shim confirms the releases, and a release arriving in that window reaches
RollbackAllocation. The replaced rescan derived the maximum by scanning
sa.requests, so an ask absent from it never influenced the converged result;
without the guard that pre-existing accounting drift would become a permanent
leak in the histogram. AddAllocationAsk's replace branch needs the mirror image:
the displaced ask has to leave the histogram before the replacement is counted,
or one key is counted twice.
No scheduling behaviour changes: the golden decision-trace tests added in
YUNIKORN-3338 reproduce byte-for-byte, goldens not regenerated.
Signed-off-by: Tigerquoll <tigerquoll@outlook.com>
Add a randomized property fuzzer guarding the incremental ask bookkeeping added by the previous commit. It drives an Application through long interleaved sequences of the real entry points - AddAllocationAsk (including replacing an existing pending ask), AllocateAsk, DeallocateAsk, RemoveAllocationAsk, RecoverAllocationAsk, AddAllocation, RollbackAllocation and FSM-to-Failed cleanup - and after every single step re-derives the expected pending-ask histogram and askMaxPriority from an independent reference model, asserting they match the incremental structures. Two of the operations are covered deliberately because they are where a full rescan and incremental bookkeeping diverge: replacing an ask already tracked under the same key, and rolling back an allocation whose ask sa.requests no longer holds. Coverage high-water marks are asserted at the end of each run so a change that turns an operation into a silent no-op fails rather than passing trivially. Signed-off-by: Tigerquoll <tigerquoll@outlook.com>
sortedRequests is ordered by priority, then age. Whether an ask is allocated plays
no part in that ordering, so allocated and pending asks sit side by side in the
list and the allocation loop skips the allocated ones as it walks - a read lock
each. The more asks an application has already had satisfied, the more there are
to skip, so each scheduling cycle costs more than the last. In a profile of
BenchmarkScheduling the skip alone - Allocation.IsAllocated, which takes a read
lock per ask - is 18% of Application.tryAllocate.
Keep the slice pending-only instead: allocateAsk removes the ask, deallocateAsk
puts it back. The container, its comparator and its insert algorithm are
unchanged; only membership changes.
A returning ask is re-inserted at the head of its (priority, createTime)
tie-group via sortedRequests.reinsert, so the next cycle retries it before the
peers it had already been tried ahead of. reinsert skips insert's append fast
path: LessThan reports a tie as true in both directions, so the binary search
stops at the first tie-peer and the returning ask lands in front of its group.
Ties between newly arriving asks keep the existing position-dependent placement;
no canonical order is imposed on them.
Two guards come with pending-only membership:
* AddAllocationAsk's replace branch drops the displaced ask from
sortedRequests. The unconditional insert that follows would otherwise leave
two entries for one key.
* sortedRequests.remove matches on pointer identity rather than on
allocationKey. remove had a single cold call site; it is now on the
allocation path and the invariant depends on it removing the right entry, so
"first entry with this key" is no longer good enough. Every caller passes the
tracked object, so this removes a failure mode rather than changing an
outcome.
deallocateAsk re-inserts under the same "still the tracked ask" guard the
histogram change introduced, so an ask dropped from sa.requests cannot reappear
as a schedulable entry.
removeAsksInternal drops the slice removal for allocated asks: they left
sortedRequests at allocateAsk time, so scanning for them on every release would
only ever be a guaranteed full-slice miss.
tryPlaceholderAllocate iterates a clone because it deallocates and continues, so
it would otherwise mutate the slice it is ranging over.
Signed-off-by: Tigerquoll <tigerquoll@outlook.com>
…tation tryAllocate iterated sa.sortedRequests with a range loop while paths inside the loop body call sa.allocateAsk(), which removes the ask from that same slice. A range loop captures the slice header once, so a mid-loop remove leaves the loop iterating a stale length over a shifted array and the final iteration can read the nil'd tail slot, panicking on the nil deref. That was safe only by convention: every successful allocation path returns instead of continuing. Convert the loop to an index based loop. It re-reads len() and re-indexes the current slice each iteration, so it never reads the nil'd tail slot after a remove and stays correct if an insert reallocates the backing array. A future mutate-then-continue degrades to skipping one ask for the cycle, which self-corrects on the next cycle, instead of crashing the scheduler. Two asserts come with it. The pre-loop length is captured and a DPanic fires if it changes mid-iteration. The IsAllocated skip, now dead by the pending-only invariant, is upgraded from a silent skip to scream-then-repair: it DPanics, drops the ghost entry and ends the cycle. An allocated entry in the slice means the remove-on-allocate pairing broke, and nothing else would ever clean it up - every removal path skips allocated asks precisely because they cannot be in the slice - so the repair turns a permanently re-detected entry into a hard failure in tests and a single logged self-heal in production. Neither assert can fire against the current code. Note that DPanic severity depends on how the logger was initialised. Core sets Development: true only when no logger has been preset, which covers core unit tests and standalone core - there a violation is a hard failure, so green suites are positive evidence the assert never fires. In a Kubernetes deployment the shim presets the logger with Development: false, so the same assert logs at DPANIC level and falls through. Loud in test, non-fatal in production. No scheduling behaviour changes. getOutstandingRequests is read only under RLock. tryPlaceholderAllocate iterates a clone because it deallocates and continues; that clone is added by the first commit in this series, not here. Signed-off-by: Tigerquoll <tigerquoll@outlook.com>
…t untracked asks
deallocateAsk added the ask's resource back to sa.pending and queue pending
unconditionally, even when the identity guard had just rejected the ask as no
longer tracked. On the ghost-rollback window that the guard exists for -
removeAsksInternal("") wipes sa.requests and zeroes sa.pending while
sa.allocations survives until the shim confirms the releases, and a
SCHEDULING_FAILED_ON_RM release in that window reaches RollbackAllocation - the
re-add is a permanent leak: nothing tracks the ask, every later
removeAsksInternal("") short-circuits on the empty sa.requests, so nothing ever
subtracts it again. The leaked pending keeps resources.IsZero(sa.pending) false
forever, which blocks the Completing transition, and inflates queue pending
until the application is removed.
The drift predates this series - it exists on upstream master, where
RollbackAllocation has the same window - but this series is what put the other
three pending-set structures (histogram, sortedRequests, and via them queue
priority) behind the identity guard, so closing the fourth here keeps every
pending-side effect of deallocateAsk behind one decision instead of three
guarded and one not.
The returned delta deliberately stays the ask's resource in the rejected case:
callers (partition rollbackAllocation, tryPlaceholderAllocate's revert) use it
to unwind the node and queue ALLOCATED tracking, which the rolled-back
allocation genuinely occupied regardless of whether the ask is still tracked.
Only the PENDING re-add is conditional.
TestRollbackAllocationAskNotTracked now pins app and queue pending staying zero
across a ghost rollback, alongside the existing histogram/sortedRequests
assertions.
Signed-off-by: Tigerquoll <tigerquoll@outlook.com>
This was referenced Aug 13, 2026
manirajv06
requested review from
craigcondit,
manirajv06,
pbacsko and
wilfred-s
and removed request for
pbacsko
August 14, 2026 07:51
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1127 +/- ##
==========================================
+ Coverage 81.62% 81.70% +0.08%
==========================================
Files 104 104
Lines 14251 14283 +32
==========================================
+ Hits 11632 11670 +38
+ Misses 2330 2322 -8
- Partials 289 291 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of YUNIKORN-3350, which splits one change into three independent levers. This is the third and
last lever, the one held back from the earlier submissions because it carries the contested
decisions: pending-only membership, where a returning ask lands, and identity-based
remove.What
sortedRequestsis ordered by priority, then age. Whether an ask is allocated plays no part in thatordering, so allocated and pending asks sit side by side in the list.
tryAllocatewalks it from thefront, skipping the allocated ones with
if request.IsAllocated() { continue }— a read lock each. Themore asks an application has already had satisfied, the more there are to skip, so each scheduling
cycle costs more than the last. In a CPU profile of master that skip is 18% of
Application.tryAllocate.Keep the slice pending-only instead:
allocateAskremoves the ask,deallocateAskputs it back. Thecontainer, its comparator and its insert behaviour are unchanged — only membership changes, plus one
placement rule for the way back in (below). (
insert's slow path is shared with the newreinsertsince the two are byte-identical, so they cannot diverge.)
Measured effect
BenchmarkScheduling, allocate phase, 10000 pods, alternating A/B, 3 samples, medians. Linux 6.8aarch64, 6 CPUs, GOMAXPROCS=6. Cumulative is the whole stack against master; "this PR" is the ratio
against the previous PR in the stack.
Spread up to 7%. After the change both hot symbols are absent from the profile entirely and node
iteration becomes the dominant term, which is the correct shape.
Scope: mock resource manager, no real bind, so this is core scheduling headroom rather than a
real-cluster pods/second figure — on a real cluster the ceiling remains the Kubernetes API bind rate.
The workload is 2 applications x 5,000 asks, which is favourable to a per-application quadratic fix.
Reproduce with:
What else reads
sortedRequestsPending-only membership changes what every reader sees, so all were checked.
sortedRequestsisunexported, has no accessor, and appears in no DAO, REST handler, metric or proto conversion. Inside
pkg/scheduler/objectsthere are exactly three readers:tryAllocatetryPlaceholderAllocateIsAllocated(), so sees the same setgetOutstandingRequestsIsAllocated(), so sees the same setNo caller uses
len(sortedRequests)as a count of an application's asks, so its changed meaning isnot observed anywhere.
Insertion of new asks is untouched, so for a workload that only adds asks all three readers see the
slice exactly as master builds it. The one order change is the re-insert path (next section), and it
is visible to all three readers, including
getOutstandingRequests— which consumes headroom as itwalks, so which asks land in the outstanding set can differ when headroom runs out inside a tie
group. That set feeds
UpdateContainerSchedulingState, i.e. the pods reported to the resourcemanager as unschedulable for cluster autoscaling.
Where a returning ask lands
Nothing on master re-inserts into
sortedRequests: an ask that becomes pending again (preemptionrevert, placeholder-replacement revert, rollback after a failed bind) has been sitting in its
original position all along. Pending-only membership creates the question of where it goes back in,
and the existing machinery cannot answer it:
createTimeis whole-second resolution end to end — theshim sends
pod.CreationTimestamp.Unix(), the core doestime.Unix(secs, 0), and KubernetescreationTimestampis second-resolution at source — so every pod of a burst ties on(priority, createTime), andLessThanreports both directions as less-than for a tie. Plaininsertwould place a returning ask by slice contents: behind its tie-peers when the group sits atthe tail (the append fast path), in front of them otherwise.
So the placement is made a stated rule instead of a side effect: a returning ask goes to the head
of its
(priority, createTime)tie-group —sortedRequests.reinsert, which isinsertminus theappend fast path. The scheduler was already trying that ask ahead of its tie-peers when it allocated
it; putting it back at the head means it is retried before the others rather than sent to the back
of a group it had already cleared. The comparator is untouched, no tiebreaker is added, and ties
between newly arriving asks keep the existing position-dependent placement — no canonical order is
imposed where nothing needs one.
The trade-off is deliberate: an ask that keeps failing (say, a repeated bind failure) is re-attempted
first each cycle. That costs the group one attempt per cycle, not the cycle: when an attempt fails
tryAllocatewalks on to the next pending ask, and the application-level unschedulable-ask backoffbounds the walk as it always has. If retry-first proves wrong for that path specifically, sending the
returning ask to the back of its group instead is the same binary search with the opposite tie
handling — a scheduling-policy decision that can be made in its own change without touching this
structure.
Three guards
The full rescan tolerated states that incremental membership does not:
AddAllocationAsk's replace branch drops the displaced ask fromsortedRequests. The unconditionalinsert that follows would otherwise leave two entries for one key. The duplicate exists on master,
masked because nothing depended on the slice holding each key once.
sortedRequests.removematches on pointer identity rather thanallocationKey. On masterremovehas a single cold call site; this PR puts it on the allocation path, so "first entry withthis key" is no longer good enough. Every direct call site passes a map- or slice-resolved object,
so no outcome changes today. This is correctness by construction rather than a fix for a live
hazard: the one path that could present a stale object —
AddAllocationAskreplacing an ask thatholds a reservation, whose captured
reserve.alloctryReservedAllocate's second loop useswithout re-resolving — is unreachable, because that function's only caller adds an ask solely when
GetAllocationAskreturned nil, and both run on the single allocation-event goroutine. Identitymatching means the invariant does not depend on that remaining true.
deallocateAskreturns an ask to the pending structures only when it is still the tracked objectfor its key.
removeAsksInternal("")wipessa.requestswhilesa.allocationssurvives until theshim confirms the releases, and a release arriving in that window reaches
RollbackAllocation.A dedicated commit extends this guard to the pending-resource re-add (
sa.pendingand queuepending), closing a leak that predates this series and exists on upstream master: a ghost
rollback re-added resource that nothing could ever subtract again —
sa.requestsis alreadyempty, so every later cleanup short-circuits — permanently blocking the
IsZero(pending)checkthat gates the Completing transition and inflating queue pending until app removal. The returned
delta is unchanged in the rejected case, since callers use it to unwind node/queue allocated
tracking that the rolled-back allocation genuinely occupied; only the pending re-add is
conditional. Pinned by
TestRollbackAllocationAskNotTracked.Why not a different structure
leaves the loop walking tombstones in between.
would be needed — but it trades one ordering invariant for two structures that must agree, and
gives up the binary-search insert the slice already provides. This is the alternative worth
revisiting if the re-insert placement proves contentious.
removeis O(n) — has the quadratic just moved?No, and this was measured.
remove→removeAt→memmoveis ~30ms of the post-change profile,against the ~0.31s of allocated-ask skipping this PR removes on the same workload. (That 0.31s is
the share of
IsAllocatedattributed totryAllocate; the rest belongs to the rescan the previous PRremoves and is not claimed here.) Two reasons:
tryAllocateallocates the first ask that fits, soremove's linear search almost always terminates at or near index 0; and moving pointers is farcheaper per element than the scan it replaces, where each element cost a read lock. The cost also
falls across a run as the pending list shrinks, whereas the scan it replaces grew.
The one place a miss-scan was guaranteed is also closed:
removeAsksInternalnow skips the sliceremoval for allocated asks, which left
sortedRequestsatallocateAsktime — without the skip,every release of a satisfied allocation would scan the whole pending list to find nothing.
Why the histogram is kept
Once the slice is pending-only it is ordered highest priority first, so
sortedRequests[0]is thepending maximum and the previous PR's histogram becomes derivable — the equivalence holds across the
whole property fuzzer. It is kept anyway, because deriving it would make queue-priority accounting
silently contingent on the ask sort policy, and on the pending-only invariant this PR introduces. The
two values answer different questions.
Hardening
tryAllocateiterated with a range loop while paths inside callallocateAsk, which now removes fromthat same slice — a range loop captures the slice header once, so a mid-loop remove could leave the
final iteration reading the nil'd tail slot. That was safe only by convention.
It becomes an index loop that re-reads
len()each iteration, so a future mutate-then-continuedegrades to skipping one ask instead of crashing. Two asserts come with it: the pre-loop length is
checked for mid-iteration mutation, and the
IsAllocated()skip — now dead by the pending-onlyinvariant — is upgraded from a silent skip to scream-then-repair: it
DPanics, drops the ghostentry and ends the cycle. An allocated entry in the slice means the remove-on-allocate pairing
broke, and nothing else would ever clean it up (every removal path skips allocated asks precisely
because they cannot be in the slice), so the repair turns a permanently re-detected entry into a
hard failure in tests and a single logged self-heal in production. Neither assert can fire against
the current code.
DPanicseverity differs by deployment. Core'sDevelopment: trueis applied only when no logger hasbeen preset, so it holds for core unit tests and standalone core, where a violation is a hard failure
and green suites are positive evidence. A Kubernetes deployment presets the logger with
Development: false, so the same asserts log and fall through. Loud in test, non-fatal in production.Behaviour and testing
For workloads that never revert an allocation — the overwhelming majority of scheduling activity —
ordering is byte-for-byte what master produces, because the comparator and
insertare untouched.The behaviour change is confined to the revert paths, and the tests carry it:
TestReinsertHeadOfTieGroup(sorted_asks_test.go) pins thereinsertcontract: a returning askheads its tie-group, whether the group sits at the tail or has lower-priority asks behind it, and
an ask with nothing sorting after it returns to the end, not the front.
TestGetOutstandingRequests_PartialHeadroompins that outstanding-ask selection follows sliceorder; it holds before and after. The pre-existing cases at all three levels size headroom so that
either all or none are selected, so they pass under any ordering.
sortedRequestsmembership (pending keysexactly, no duplicates, nothing allocated) and comparator-validity of the order against its
independent reference model, across every entry point including rollback and replace.
TestAddAllocationAskReplaceExistingPendingAskandTestRollbackAllocationAskNotTrackedpin thetwo guards above at the application level.
pkg/scheduler/...green, including under-race.Concurrency: no new lock acquisition and no new ordering. All slice mutation happens under the
application write lock, where the previous PR's histogram maintenance already lives.
Scope and rollback
No configuration, REST, scheduler-interface or metrics changes. No new public API. No persisted or
serialized state — nothing crosses a process or version boundary, so there is nothing to migrate and
no mixed-version concern during a rolling upgrade. Rollback is a single revert.
Release note: an ask whose allocation is reverted (preemption, placeholder replacement, failed-bind
rollback) is now retried before other pending asks of equal priority and creation time, instead of
holding its original arrival slot. Additionally, rolling back an allocation whose ask the
application no longer tracks no longer permanently inflates application and queue pending resource
(pre-existing leak, also present upstream).