tigerquoll opened a new pull request, #1127:
URL: https://github.com/apache/yunikorn-core/pull/1127
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`.
> **Stacked on #1122** (YUNIKORN-3352, the pending-priority histogram): the
first two commits here
> ARE #1122 — please review only the last three. This PR rebases down to
those three as soon as
> #1122 lands. YUNIKORN-3351 (the backoff getter hoist) is already on master
as 7dc1287.
## What
`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.
`tryAllocate` walks it from the
front, skipping the allocated ones with `if request.IsAllocated() { continue
}` — 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 CPU profile of master that skip 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 behaviour are unchanged — only
membership changes, plus one
placement rule for the way back in (below). (`insert`'s slow path is shared
with the new `reinsert`
since the two are byte-identical, so they cannot diverge.)
## Measured effect
`BenchmarkScheduling`, allocate phase, 10000 pods, alternating A/B, 3
samples, medians. Linux 6.8
aarch64, 6 CPUs, GOMAXPROCS=6. Cumulative is the whole stack against master;
"this PR" is the ratio
against the previous PR in the stack.
| nodes | master (c/s) | this PR (c/s) | cumulative | this PR |
|---|---:|---:|---:|---:|
| 500 | 5,481 | 44,421 | 8.10x | 2.64x |
| 1000 | 5,517 | 42,452 | 7.70x | 2.60x |
| 2000 | 5,396 | 43,086 | 7.99x | 2.62x |
| 5000 | 5,353 | 40,577 | 7.58x | 2.48x |
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:
```
go test ./pkg/scheduler/tests/ -run '^$' -bench 'BenchmarkScheduling'
-benchtime=1x -v
```
## What else reads `sortedRequests`
Pending-only membership changes what every reader sees, so all were checked.
`sortedRequests` is
unexported, has no accessor, and appears in no DAO, REST handler, metric or
proto conversion. Inside
`pkg/scheduler/objects` there are exactly three readers:
| reader | effect |
|---|---|
| `tryAllocate` | the target of this change |
| `tryPlaceholderAllocate` | already skips on `IsAllocated()`, so sees the
same set |
| `getOutstandingRequests` | unchanged here, already skips on
`IsAllocated()`, so sees the same set |
No caller uses `len(sortedRequests)` as a count of an application's asks, so
its changed meaning is
not 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 it
walks, 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 resource
manager as unschedulable for cluster autoscaling.
## Where a returning ask lands
Nothing on master re-inserts into `sortedRequests`: an ask that becomes
pending again (preemption
revert, 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: `createTime` is whole-second
resolution end to end — the
shim sends `pod.CreationTimestamp.Unix()`, the core does `time.Unix(secs,
0)`, and Kubernetes
`creationTimestamp` is second-resolution at source — so every pod of a burst
ties on
`(priority, createTime)`, and `LessThan` reports both directions as
less-than for a tie. Plain
`insert` would place a returning ask by slice contents: behind its tie-peers
when the group sits at
the 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 is `insert` minus the
append 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
`tryAllocate` walks on to the next pending ask, and the application-level
unschedulable-ask backoff
bounds 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 from
`sortedRequests`. The unconditional
insert 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.remove` matches on **pointer identity** rather than
`allocationKey`. On master
`remove` has a single cold call site; this PR puts it on the allocation
path, so "first entry with
this 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 —
`AddAllocationAsk` replacing an ask that
holds a reservation, whose captured `reserve.alloc`
`tryReservedAllocate`'s second loop uses
without re-resolving — is unreachable, because that function's only caller
adds an ask solely when
`GetAllocationAsk` returned nil, and both run on the single
allocation-event goroutine. Identity
matching means the invariant does not depend on that remaining true.
- `deallocateAsk` returns an ask to the pending structures only 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`.
A dedicated commit extends this guard to the pending-resource re-add
(`sa.pending` and queue
pending), 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.requests` is already
empty, so every later cleanup short-circuits — permanently blocking the
`IsZero(pending)` check
that 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
- **Leave allocated asks in and keep skipping them.** That is master, and it
is the cost being removed.
- **Tombstone and compact periodically.** Keeps positions stable, but adds a
compaction policy and
leaves the loop walking tombstones in between.
- **A second list, or an order-maintenance structure.** Position-preserving,
so no re-insert rule
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.
### `remove` is O(n) — has the quadratic just moved?
No, and this was measured. `remove` → `removeAt` → `memmove` is **~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 `IsAllocated` attributed to `tryAllocate`; the rest belongs to
the rescan the previous PR
removes and is not claimed here.) Two reasons: `tryAllocate` allocates the
first ask that fits, so
`remove`'s linear search almost always terminates at or near index 0; and
moving pointers is far
cheaper 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:
`removeAsksInternal` now skips the slice
removal for allocated asks, which left `sortedRequests` at `allocateAsk`
time — 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 the
pending 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
`tryAllocate` iterated with a range loop while paths inside call
`allocateAsk`, which now removes from
that 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-continue
degrades 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-only
invariant — is upgraded from a silent skip to scream-then-repair: it
`DPanic`s, 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.
`DPanic` severity differs by deployment. Core's `Development: true` is
applied only when no logger has
been 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
`insert` are untouched.
The behaviour change is confined to the revert paths, and the tests carry it:
- `TestReinsertHeadOfTieGroup` (sorted_asks_test.go) pins the `reinsert`
contract: a returning ask
heads 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_PartialHeadroom` pins that outstanding-ask
selection follows slice
order; 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.
- The previous PR's property fuzzer is extended to assert `sortedRequests`
membership (pending keys
exactly, no duplicates, nothing allocated) and comparator-validity of the
order against its
independent reference model, across every entry point including rollback
and replace.
- `TestAddAllocationAskReplaceExistingPendingAsk` and
`TestRollbackAllocationAskNotTracked` pin the
two guards above at the application level.
- Full `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).
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]