[AMORO-4271] AIP-5 Phase 2: demand-driven scale-up for dynamic allocation groups#4272
Open
j1wonpark wants to merge 10 commits into
Open
[AMORO-4271] AIP-5 Phase 2: demand-driven scale-up for dynamic allocation groups#4272j1wonpark wants to merge 10 commits into
j1wonpark wants to merge 10 commits into
Conversation
…sm config The scaling unit of dynamic allocation is one homogeneous K-thread optimizer instance (the Spark executor model). K is configured by the new dynamic-allocation.executor-parallelism property (default 1). validate() rejects K < 1 and K > max-parallelism: a single K-thread instance already exceeding the cap could never be created, which would leave an enabled group as a silent no-op. Signed-off-by: Jiwon Park <jpark92@outlook.kr>
…ed, thread occupancy) serviceablePlannedCount implements quota-mode-aware demand counting: a proportional quota (targetQuota <= 1) scales with availableCore, so the whole backlog is serviceable; an absolute quota (> 1) is a fixed limit that scaling cannot raise, so only free slots count. occupiesThread counts SCHEDULED as occupying: a thread is busy from assignment (pollTask), not from ack; counting only ACKED would overestimate headroom during the poll-to-ack window. Signed-off-by: Jiwon Park <jpark92@outlook.kr>
Per-group decision state (backlog timer, cadence, exponential ramp) with injected time, so every scenario is deterministic. Ordered checks: - min-parallelism floor: enforced immediately, no timing gate. - Immediate demand (busy + serviceable > effective): exponential ramp (1, 2, 4, 8) clamped to the actual need; the ramp resets when the clamp binds (Spark addExecutors semantics) or when demand clears. - Future demand (pending tables while all threads are busy, including the zero-optimizer cold start where nothing polls and planning never runs): a single probe instance. Pending tables are not quantified demand before planning, so no exponential growth on this signal. Demand must persist for scheduler-backlog-timeout before the first scale-out; later rounds are spaced by sustained-backlog-timeout, so a trickle drained between rounds never accumulates toward a scale-out. The max-parallelism cap always wins. Signed-off-by: Jiwon Park <jpark92@outlook.kr>
… deadline Registration is optimizer-driven (the pod self-registers after boot), so scale-up counts requested-but-unregistered capacity to avoid duplicate scale-outs during the boot window. Entries carry their own boot deadline: a request that never registers (image pull failure, exhausted ResourceQuota, crash loop) is pruned instead of freezing scale-up below real demand forever. Heartbeat expiry cannot cover this window because it only starts after registration. Signed-off-by: Jiwon Park <jpark92@outlook.kr>
…tion groups DRA-enabled groups are taken over from the legacy floor keeper, whose min-parallelism-check cadence (minutes, multiplied by consecutive attempts) would render the DRA backlog timeouts (seconds) physically unreachable. OptimizerScaleKeeper reuses the AbstractKeeper infrastructure (HA leader gating, DelayQueue, lifecycle) and evaluates each group at its own sustained-backlog-timeout; the legacy keeper keeps watching DRA groups only to resume floor duty if DRA is disabled later. Scale-outs are executed in executor-parallelism-thread instance units (computeScaleUp), with requested-but-unregistered capacity counted via PendingRegistrations so a booting pod is not re-requested every round; a synchronous request failure is dropped immediately and retried. Groups are watched on create, startup load, and update (covering enabling DRA on an existing group at runtime); deleted or disabled groups drop out of the watch set on their next evaluation. OptimizingQueue.collectDynamicAllocationLoad() snapshots the demand side: busy threads (SCHEDULED and ACKED), quota-mode-aware serviceable PLANNED tasks, and PENDING tables - the only signal observable on a cold group with zero optimizers. Signed-off-by: Jiwon Park <jpark92@outlook.kr>
…ad of scaling Idle threads while tables wait as PENDING and no PLANNED tasks materialize means the bottleneck is the serialized planning (optimizer.max-planning-parallelism), not thread capacity: scaling out would only add more idle threads. The scale keeper surfaces this as an edge-triggered warning naming the config to raise, and does not scale. A cold group (zero threads) stays the future-demand case. Signed-off-by: Jiwon Park <jpark92@outlook.kr>
The keeper's drop-out path treated every irregularity the same way, which conflates situations that need opposite handling: - A transient group-read failure (e.g. a database hiccup) is not a deletion: there is no periodic re-watch, so dropping the group there would silently disable its dynamic allocation until the next config change. Keep the task alive and retry shortly. - After unwatching a disabled group, re-read it once: an update re-enabling DRA concurrently would have had its watch() call swallowed by the still-present watched-set entry, orphaning the group until its next change. - An enabled group whose queue is momentarily absent (a delete/create racing the config watcher) is transient too; unwatch-plus-rewatch there would spin a delay-0 hot loop of DB reads. - A scale-out that fails after requestResource succeeded has started a real pod; erasing its boot-window entry would re-request a duplicate next round. Only a failure before the request is dropped and retried. - Disabling DRA keeps the boot-window accounting (re-enabling within the window must not re-request the same capacity); deleting the group drops everything immediately, so a same-name group created before the next evaluation does not inherit phantom capacity. - The planning-bound warning now counts registered threads only (a booting pod's phantom capacity is not idle threads) and requires the condition to persist across two consecutive evaluations, since a single snapshot can hold transiently while planning is in flight. Also pins in the integration test that registration clears the boot-window accounting: double-counted capacity would suppress demand scaling. Signed-off-by: Jiwon Park <jpark92@outlook.kr>
…idation and scaling Two floor edge cases: - A floor unreachable in executor-parallelism units (e.g. min=5, max=6, K=4: covering the floor needs 2 instances = 8 threads > max) passed validation and then sat permanently below its floor with no signal - the same silent no-op the validation rules exist to prevent. Reject it up front; the keeper-side cap clamp stays as defense in depth for configs persisted before this rule. - A floor deficit (optimizers died, or a new group) now resets the demand-phase state: previously a demand phase before the deficit left a passed cadence gate and a grown ramp behind, so the first demand after recovery fired immediately and oversized instead of re-proving backlog persistence. Also pins that a fractional absolute quota truncates like the poll gate does ((int) 2.5 = 2 slots), keeping the two accountings aligned. Signed-off-by: Jiwon Park <jpark92@outlook.kr>
…ing lock SchedulingPolicy's table runtime map is a plain HashMap whose canonical accesses all hold tableLock; the dynamic-allocation load snapshot iterated it lock-free from the scale keeper thread, so a concurrent addTable/removeTable could throw ConcurrentModificationException and skip that whole evaluation round. Add a snapshot accessor that copies the runtimes under the lock and use it for the load snapshot. Also covers collectDynamicAllocationLoad with a queue-level test on a real table: the PENDING table is the only demand signal before any poll (the cold-start case), and a polled task occupies its thread from SCHEDULED on. Signed-off-by: Jiwon Park <jpark92@outlook.kr>
…perties Add the dynamic-allocation.* properties to the optimizer group property table, mark the flat min-parallelism row deprecated in favor of the namespaced key, and recommend executor-parallelism 4-8 for Kubernetes groups so per-pod JVM overhead is shared across threads. The scale-down-related properties are documented as landing in a later release. Signed-off-by: Jiwon Park <jpark92@outlook.kr>
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.
Why are the changes needed?
Close #4271.
This is the second implementation phase of AIP-5: Dynamic Resource Allocation for Optimizer (#4191), building on the configuration foundation merged in #4254. It adds demand-driven scale-up for dynamic-allocation-enabled groups; scale-down lands in later phases. Everything remains opt-in: groups without
dynamic-allocation.enabled = trueare unaffected.Why a dedicated keeper. The existing
OptimizerGroupKeeperchecks a group atoptimizer-group.min-parallelism-check-interval × consecutive attempts— minutes apart by design, which is right for its floor duty but makes the DRA backlog timeouts (scheduler-backlog-timeout1min,sustained-backlog-timeout30s) physically unreachable.OptimizerScaleKeeperreuses the sameAbstractKeeperinfrastructure (HA leader gating, delay queue, lifecycle) but each group's scale evaluations run at its ownsustained-backlog-timeout. The legacy keeper skips DRA groups (continuing the implicitisEffectivelyEnabledpattern from the Phase 1 review) while keeping them under watch, so it resumes floor duty if DRA is disabled later.Why a "future demand" signal. Planning is only driven by
pollTask: with zero optimizers nothing polls, planning never runs, and PLANNED tasks never materialize — so a task-based signal alone can never wake a scaled-to-zero group. PENDING tables are the one signal observable without optimizers; they ignite a single probe instance, and once planning materializes tasks, the task-based signal takes over.Brief change log
OptimizerProperties/DynamicAllocationConfig: newdynamic-allocation.executor-parallelism(default1) — the scaling unit is one homogeneous K-thread optimizer instance (the Spark executor model). Validation:1 ≤ executor-parallelism ≤ max-parallelism, and the floor must be reachable in K units (ceil(min/K)×K ≤ max) — otherwise the group would silently sit below its floor forever.OptimizerScaleKeeper(new, inDefaultOptimizingService): owns both the floor and the demand scaling of DRA groups. Groups are watched on create, startup load, and update (covering enabling DRA on an existing group at runtime). Per evaluation round:min-parallelismdeficits are satisfied immediately in K-thread units, resetting any demand-phase timers so a recovery does not fire through a stale gate.busy + serviceable PLANNED > effective threadsscales out with an exponential ramp (1, 2, 4, 8 instances) clamped to the actual need — SparkExecutorAllocationManagersemantics, including resetting the ramp when the clamp binds or demand clears.scheduler-backlog-timeoutbefore the first scale-out; later rounds are spaced bysustained-backlog-timeout, so a trickle drained between rounds never accumulates. Themax-parallelismcap always wins.PendingRegistrations(new): requested-but-unregistered capacity counts toward effective threads so a booting pod is not re-requested every round. Registration is optimizer-driven and heartbeat expiry only starts after it, so entries carry their own boot deadline (3 minutes). A synchronously failed request is dropped and retried; a pod whoserequestResourcesucceeded but whose persistence failed keeps its entry (it will self-register) instead of being re-requested as a duplicate.OptimizingQueue.collectDynamicAllocationLoad()(new): snapshots the demand side — busy threads (SCHEDULED and ACKED: a thread is occupied from assignment, not from ack), quota-mode-aware serviceable PLANNED tasks (a proportional quota≤ 1counts its whole backlog since scaling raises itsceil(quota × availableCore)limit; an absolute quota> 1counts only free slots), and PENDING tables. Table runtimes are copied undertableLockvia a newSchedulingPolicy.snapshotTableRuntimes()rather than iterating the lock-guarded map from another thread.Planning-bound guard: idle registered threads while tables wait as PENDING and nothing is PLANNED means the bottleneck is the serialized planning (
optimizer.max-planning-parallelism), not thread capacity — scaling out would only add idle threads. The keeper warns (once per episode, after the condition persists across two evaluations) naming the config to raise, and does not scale.Docs: the optimizer group property table now documents all
dynamic-allocation.*properties (as promised in the Phase 1 PR for the phase that first exposes runtime behavior), marks the flatmin-parallelismdeprecated, and recommendsexecutor-parallelism4–8 for Kubernetes groups.Known trade-offs
E ≈ planned/(1−q)while the table only occupiesceil(q×E)threads; the excess is reclaimed by idle-timeout when scale-down lands (Phase 4), andmax-parallelismis the operator's bound until then.How was this patch tested?
TestComputeScaleUp(15): floor immediacy and cap clamping, backlog/sustained timing gates, exponential ramp including reset-on-clamp-binding, trickle filtering, cold-start ignition, future-demand single-probe semantics, stale-gate reset after floor recovery.TestDynamicAllocationState(17): quota-mode-aware serviceable counting (including fractional absolute quotas), SCHEDULED-occupies-thread semantics, planning-bound predicate.TestPendingRegistrations(6): boot-deadline expiry per entry, registration/failure removal.TestDynamicAllocationConfig(29): executor-parallelism parsing and validation including the unreachable-floor rejection.TestOptimizerScaleKeeper(4, integration): K-unit floor satisfaction (vs. the legacy single deficit-sized instance), boot-window duplicate prevention (the legacy path re-requests the full deficit every round), failure retry, runtime enablement.TestOptimizingQueue#testCollectDynamicAllocationLoad: the PENDING-table cold-start signal and the SCHEDULED transition on a real table.amoro-commonandamoro-amssuites pass (the AMS suite takes ~34 min); spotless and checkstyle clean.Documentation
docs/admin-guides/managing-optimizers.md; the AIP-5 page will be revised alongside this PR to documentdynamic-allocation.executor-parallelism, which replaces the page's incorrect reference toexecution-parallel)