fix(scheduler): NodeAffinity PreFilter + per-node gpuFragmentation repack cooldown - #20
Conversation
NodeAffinity was registered as Filter/stable-Filter only, so its Filter never found PreFilter state and recomputed GetRequiredNodeAffinity (label selector parsing with regex validation) for every (task, node) pair in allocate/preempt/reclaim/backfill. Registering it as a PreFilter computes the required affinity once per task per session and lets pods without node affinity or nodeSelector skip the Filter entirely. Matches upstream 03f2a0b for NodeAffinity. Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Sami Yousef <mail@samiyousef.ca>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Sami Yousef <mail@samiyousef.ca>
|
CI note:
Both are job-controller state transitions on pods with no nodeSelector/affinity, for which I can't rerun jobs here ( |
The gpuFragmentation cooldown was pool-wide: any node stamped within cooldownSeconds held the entire pool, and the planner stopped after one drain per pool. With crossPool the pool is the whole cluster, so production repacked at most one node per 30 minutes against ~300 underutilized spot nodes. The cooldown now holds only the stamped node from being a source; other nodes drain independently, and a cooling node can still receive. A pass keeps draining emptiest-first until maxVictims is spent, with a per-pass ledger so drains share idle capacity (no double booking), a drained source never receives, and a destination is never drained in the same pass.
What type of PR is this?
/kind bug
What this PR does / why we need it:
Two independent scheduler fixes from the 2026-09-02/03 delphi-production incident, both in files only our fork touches.
1.
predicates: register NodeAffinity as a PreFilter plugin (7198ebc)PredicatesPlugin.InitPluginregisteredNodeAffinityinfilterPluginsandstableFilterPluginsbut not inprefilterPlugins. The k8sNodeAffinityplugin computesGetRequiredNodeAffinity(pod)(nodeSelector + required affinity terms → label selectors, with regex-validatedlabels.NewRequirement) inPreFilterand caches it in cycle state; whenFilterfinds no state it falls back to recomputing it for every node:So on delphi-production every allocate/preempt/reclaim/backfill session re-parsed each pending task's affinity once per node (≈2,600 pending tasks × 484 nodes × 3 actions). The scheduler CPU profile (pyroscope, 2026-09-02 22:27–22:57) had 30% of samples under
NodeAffinity.Filter→GetRequiredNodeAffinity→labels.NewRequirement→regexp.tryBacktrack, the single largest leaf.nodeAffinityFilter := plugin.(*nodeaffinity.NodeAffinity) filterPlugins[nodeaffinity.Name] = nodeAffinityFilter stableFilterPlugins[nodeaffinity.Name] = nodeAffinityFilter +prefilterPlugins[nodeaffinity.Name] = nodeAffinityFilterWith the registration,
PrePredicaterunsNodeAffinity.PreFilteronce per task per session (every action already callsssn.PrePredicateFnbeforePredicateNodes), the Filter reuses the cached selector, and pods with no nodeSelector/required affinity returnSkipsohandleSkipPrePredicatePlugindrops the Filter for them entirely.TestInitPluginexpectations updated accordingly.This is the NodeAffinity half of upstream volcano-sh/volcano@03f2a0b ("fix(predicates): register more PreFilter plugins"); the VolumeZone half is left out because it is not on delphi's hot path and the fork's
InitPluginhas diverged from the upstream helper it was written against.2.
rescheduling/gpuFragmentation: per-node cooldown, several drains per pass (ccfab4e)The repack cooldown was a pool-wide lock — any node in the pool stamped with
exa.ai/repack-last-evictionwithincooldownSeconds(default 1800) held the whole pool — andplanGpuFragmentationDrainsstopped after one drain per pool. delphi runscrossPool: true, which makes the whole cluster one pool, so production repacked at most one node per 30 minutes (metrics: ~12 passes/h, 1–2 drains/h, 42 stamps total) against ~300Consolidatablespot GPU nodes. That is why the 1-GPU flyte pods onip-10-119-52-166/ip-10-119-57-66were never repacked onto one node.Changes in
planGpuFragmentationDrains/simulateDrain:Semantics now:
maxVictims(8) is spent, so with 1-GPU pods a pass typically drains 3–8 nodes instead of 1. Total evictions per pass are still bounded bymaxVictims; the "don't fall through to a fuller source under budget pressure" rule is unchanged.stampGpuFragmentationNodes), the drained-source node-order penalty, per-PodGroup eviction caps and themaxVictimPriority/opt-out/do-not-disrupt gates are unchanged; comments updated to drop the pool-wide-clock rationale.Tests:
TestPlanCooldownHoldsPool→TestPlanCooldownHoldsStampedSource(+ future-stamp case); newTestPlanCooldownIsPerNodeNotPerPool,TestPlanDrainsSeveralNodesPerPassWithinBudget,TestPlanLedgerPreventsDoubleBookingAcrossDrains,TestPlanDrainedSourceNeverReceivesInSamePass. All existing rescheduling tests pass unchanged (includingTestPlanOneVictimAcrossPools,TestPlanNodeMoveSetIsAtomicUnderVictimBudget,TestNodeOrderPenalizesRecentlyDrainedSource).Expected effect on delphi at the current config (
interval: 5m,maxVictims: 8,cooldownSeconds: 1800): from ≤2 drained nodes/h to roughly 30–90/h while fragmentation persists, without touching the monorepo config.cooldownSecondscan be lowered later if the 30-min anti-thrash window per node turns out to be conservative.Which issue(s) this PR fixes:
Fixes #
Special notes for your reviewer:
Verified locally:
go build ./pkg/scheduler/...,go vet ./pkg/scheduler/plugins/predicates/ ./pkg/scheduler/plugins/rescheduling/,go test ./pkg/scheduler/plugins/... ./pkg/scheduler/framework/... ./pkg/scheduler/actions/...pass;TestAllocateWithPVCinactions/allocateis flaky on this branch and onexa/masteralike (fails ~1 in 3 runs on both, passes on rerun).Fix 1 is the secondary fix for the 2026-09-02 delphi "kubeflow operator hasn't updated the pytorch custom resource" incident; the primary one is running the scheduler at
-v=2(exa-labs/monorepo#138226). Fix 2 is the Volcano half of the spot-node repacking problem; the exa-scale consolidation half (candidate simulation timing out on the ~2,200 unprovisionable pending pods) is handled separately in the monorepo.Not yet built or pushed to ECR; a monorepo
training-stack.tsroll follows once this is merged and anexa-rebase-v1.14.1-<sha>image exists (staging first, as with #19).Does this PR introduce a user-facing change?
Link to Devin session: https://app.devin.ai/sessions/9618e4fbaeb345ba9c161ec18bfdec6c
Open in Devin Desktop: https://app.devin.ai/desktop/session/9618e4fbaeb345ba9c161ec18bfdec6c?variant=devin
Requested by: @pfernandes21