Skip to content

scx_pandemoniumv5.19.0: - #3756

Merged
sirlucjan merged 1 commit into
sched-ext:mainfrom
wllclngn:update-pandemonium-5.19.0
Aug 24, 2026
Merged

scx_pandemoniumv5.19.0:#3756
sirlucjan merged 1 commit into
sched-ext:mainfrom
wllclngn:update-pandemonium-5.19.0

Conversation

@wllclngn

Copy link
Copy Markdown
Contributor

This release removes the behavioural classifier. It had not worked since it was written: last_woke_at does two jobs that destroy each other, so every task scored zero and classified BATCH after its first wake, and the four EWMAs behind the score were correct code averaging a corrupted input. No number this project has ever measured was a function of tier. What the classifier still did was decide. Seven sites read its output, and one of them handed a hard preempt kick to every task it could not call BATCH -- which, through the PF_WQ_WORKER floor, means every kworker on the machine. A field capture from an AMD desktop caught the consequence: 1,174,848 hard kicks against 1,174,815 requeues inside one one-second sample, 46 real wakeups in the whole second, held flat at that rate for 32.3 seconds. Flat is the finding. A load spike is ragged; a plateau at a fixed ceiling is a closed loop running at its IPI throughput limit, and the CoDel controller that exists to brake exactly this fired once in that second. The estimator is gone. Everything that replaces it was already in the tree.

THE KICK ASKS ABOUT THE CPU AND NOT ABOUT THE TASK: src/bpf/main.bpf.c

  • TIER 1's kick flag read tctx->tier != TIER_BATCH, a property of the task, to decide a question about a CPU. The seat was picked from an idle scan; whether it is still idle a moment later is the CPU's business and the tier test could not see it. It now reads the target's live state: an idle seat takes SCX_KICK_IDLE, a seat that went busy takes the preempt that actually reaches it. TIER 0's warm-stay took an unconditional preempt and books its kicks by the flag issued rather than by is_wakeup, so a warm-stay requeue no longer reports a hard IPI as a soft kick.
  • A requeue's preempt is rate-limited to one per CPU per live CoDel target. A task re-entering enqueue with ran_since_wake set did not wake, it was evicted, and letting it evict someone in turn is the loop the field capture recorded: every preempt lands a requeue, every requeue issues a preempt. TIER 0 books a requeue soft and TIER 2/3 already gate on is_wakeup, which leaves TIER 1 as the only site able to produce hard kicks and requeues in lockstep, which is exactly what it produced.
  • Removing that preempt outright instead of bounding it stalls the machine. Under --no-adaptive the knob page is all zeros, so the tick preempt never resolved to a band and TOTAL PREEMPT reads 0 on every BPF-only run in the archive of every version -- that kick was quietly the only mechanism dislodging a resident in that mode. Without it: runnable task stall, watchdog at 10.001s, 11.0M dispatch/s at 0.0% idle hit rate. The loop was never the preempt, it was the unbounded rate.

THE TICK PREEMPT HAD NEVER FIRED IN BPF-ONLY MODE: src/bpf/main.bpf.c

  • The band came from knobs->preempt_thresh_ns, which the adaptive layer writes and --no-adaptive leaves at zero. It is codel_target_ns now: BPF-derived, always live, and the same unit every other bound in the gate is priced in. The band exists in both modes for the first time.
  • The tier split inside it was the worst half of the classifier. The enqueue side read tier != TIER_BATCH and kicked hardest for INTERACTIVE while this side read the same field and braked softest for it, at twice the threshold, with LAT_CRITICAL exempt from the sojourn bound altogether. Two controllers on one variable pulling opposite ways. One band now, and the overflow starvation net above it is unconditional.
  • A declared RT policy answers to the starvation bound, lag_cap_ns, rather than the service bound. This is not the classifier returning: SCHED_FIFO/RR is a contract userspace stated, not a character this scheduler guessed, and RT is still forced off a CPU it has camped on. The split is the audio quantum -- codel_target falls inside one PipeWire period and lag_cap does not, so a uniform band preempted an RT thread mid-buffer every time a waiter existed. The old blanket exemption fixed neither half: it kept an RT thread from being kicked off while doing nothing to get it on, and let a camper hold a CPU without limit. p->policy is read directly, so the tick also sheds a per-call task_ctx lookup.
  • Measured on the BPF-only arm of prism --dev fork-thread, thread/g24: wall 78.486s to 21.3s, +367.6% against EEVDF to +27.5%, IPC 0.277 to 0.423. That arm was not conservative, it was inert -- 370 traced migrations because nothing ever moved work.

SERVICE RENDERED REPLACES CHARACTER GUESSED:
src/bpf/main.bpf.c

  • task_ctx carries standing_runs, a saturating count of consecutive runs that consumed a full codel_target_ns, maintained in stopping() beside last_run_ns and cleared by any shorter run. It is a ledger and not an estimator: it records what was rendered and never guesses what the task is. The confirm depth is the memory a single last_run_ns sample lacks, since a hog that blocks once reads as drained, and it needs no threshold constant because the boundary is one live target by construction.
  • task_slice selects on it instead of on tier. The two EWMA branches it replaced (LAT_CRITICAL at avg_runtime1.5, INTERACTIVE at avg_runtime2) sat behind a score that returns BATCH for every task after its first wake, so the selector was a constant and both branches were reachable only through the RT and PF_WQ_WORKER overrides. Everything drew the batch quantum, roughly 5.6ms at 12C. A task that has not stood on a CPU for a full target now draws the adaptive slice.
  • TIER 3 routes to the batch or interactive overflow DSQ on the same ledger. The split itself stays: dispatch STEP 2 already prices the crossover between the two in codel_target_ns with codel_starve_ns as the net above it, so the fairness half was never the classifier's. The burst-spawn carve-out that kept ewma_age < 2 threads out of the batch DSQ becomes structural rather than a special case, since a fresh fork has rendered no service and routes interactive by construction.
  • prism --dev scale, ADAPTIVE: deadline miss 1.6/0.6/0.3/0.2% to 0.3/0.0/0.1/0.1% at 2/4/8/12C. The 0.0-0.4% band has never held at 2C on any release. Long-run work spread 0.14-0.96% against EEVDF's 2.25-6.45%, absolute per-process throughput above EEVDF at every width, wall +1.0 to +4.1%.

PLACEMENT IS A DISTANCE PRICE WITH NOTHING IN FRONT OF IT: src/bpf/main.bpf.c

  • find_idle_l2_sibling is deleted. It searched one L2 group and fell straight into an unordered node-wide pick on a miss, so it had no gradient: either an idle CPU shared the wakee's L2 group or placement fell off a cliff. An L2 group is two CPUs, so under any messaging load the sibling is busy and every wakeup took the cliff. find_idle_by_affinity walks affinity_rank in R_eff order and contains that search as its prefix, then degrades continuously.
  • The three-term gate in front of it goes with it. The tier test was a dead classifier read, and affinity_mode and PF_KTHREAD only carved exceptions out of a wall that no longer exists.
  • phi_warm_target's tier parameter is dropped. It had already been (void)tier.

TWO CONFLICTS RECORDED IN PLACE:
src/bpf/main.bpf.c

  • The warm-stay occupancy bail is a rate limit on the home-pull, which is not what its comment claimed. warm_stay_anchor returns home_cpu, and seating a task there when it last ran elsewhere is itself a migration. Pricing the bail as queue depth instead of gating on it admits far more wakees to the warm seat and fires far more home-pulls: intra_wake 2161 to 57485, total migrations 3037 to 59150 against a flat wakeup count, 1.3% to 24.0% per wakeup. The comment now says so, and says not to re-price it until the home_cpu against last_cpu divergence beneath it is settled.
  • montauk's per-cause counters put 81% of migrations on the wake path, not the steal path (intra_wake 2161 and cross_wake 293 against intra_steal 547 and cross_steal 36). Routing the idle pick through R_eff moved none of it, and the hub signature a home-pull would leave is absent -- modal-CPU share per thread reads 44.8% against EEVDF's 66.8%, lower rather than higher, which is diffuse scatter and not a star.

"The last image was too immediate for any eye to register. It may have been a
 human figure, dreaming of an early evening in each great capital luminous
 enough to tell him he will never die, coming out to wish on a first star."
— Thomas Pynchon, "Gravity's Rainbow."

This release removes the behavioural classifier. It had not worked since it was
written: last_woke_at does two jobs that destroy each other, so every task scored
zero and classified BATCH after its first wake, and the four EWMAs behind the
score were correct code averaging a corrupted input. No number this project has
ever measured was a function of tier. What the classifier still did was decide.
Seven sites read its output, and one of them handed a hard preempt kick to every
task it could not call BATCH -- which, through the PF_WQ_WORKER floor, means
every kworker on the machine. A field capture from an AMD desktop caught the
consequence: 1,174,848 hard kicks against 1,174,815 requeues inside one
one-second sample, 46 real wakeups in the whole second, held flat at that rate
for 32.3 seconds. Flat is the finding. A load spike is ragged; a plateau at a
fixed ceiling is a closed loop running at its IPI throughput limit, and the CoDel
controller that exists to brake exactly this fired once in that second. The
estimator is gone. Everything that replaces it was already in the tree.

THE KICK ASKS ABOUT THE CPU AND NOT ABOUT THE TASK:
src/bpf/main.bpf.c
- TIER 1's kick flag read `tctx->tier != TIER_BATCH`, a property of the task, to
  decide a question about a CPU. The seat was picked from an idle scan; whether
  it is still idle a moment later is the CPU's business and the tier test could
  not see it. It now reads the target's live state: an idle seat takes
  SCX_KICK_IDLE, a seat that went busy takes the preempt that actually reaches
  it. TIER 0's warm-stay took an unconditional preempt and books its kicks by
  the flag issued rather than by is_wakeup, so a warm-stay requeue no longer
  reports a hard IPI as a soft kick.
- A requeue's preempt is rate-limited to one per CPU per live CoDel target. A
  task re-entering enqueue with ran_since_wake set did not wake, it was evicted,
  and letting it evict someone in turn is the loop the field capture recorded:
  every preempt lands a requeue, every requeue issues a preempt. TIER 0 books a
  requeue soft and TIER 2/3 already gate on is_wakeup, which leaves TIER 1 as
  the only site able to produce hard kicks and requeues in lockstep, which is
  exactly what it produced.
- Removing that preempt outright instead of bounding it stalls the machine.
  Under --no-adaptive the knob page is all zeros, so the tick preempt never
  resolved to a band and TOTAL PREEMPT reads 0 on every BPF-only run in the
  archive of every version -- that kick was quietly the only mechanism
  dislodging a resident in that mode. Without it: runnable task stall, watchdog
  at 10.001s, 11.0M dispatch/s at 0.0% idle hit rate. The loop was never the
  preempt, it was the unbounded rate.

THE TICK PREEMPT HAD NEVER FIRED IN BPF-ONLY MODE:
src/bpf/main.bpf.c
- The band came from knobs->preempt_thresh_ns, which the adaptive layer writes
  and --no-adaptive leaves at zero. It is codel_target_ns now: BPF-derived,
  always live, and the same unit every other bound in the gate is priced in. The
  band exists in both modes for the first time.
- The tier split inside it was the worst half of the classifier. The enqueue
  side read tier != TIER_BATCH and kicked hardest for INTERACTIVE while this
  side read the same field and braked softest for it, at twice the threshold,
  with LAT_CRITICAL exempt from the sojourn bound altogether. Two controllers on
  one variable pulling opposite ways. One band now, and the overflow starvation
  net above it is unconditional.
- A declared RT policy answers to the starvation bound, lag_cap_ns, rather than
  the service bound. This is not the classifier returning: SCHED_FIFO/RR is a
  contract userspace stated, not a character this scheduler guessed, and RT is
  still forced off a CPU it has camped on. The split is the audio quantum --
  codel_target falls inside one PipeWire period and lag_cap does not, so a
  uniform band preempted an RT thread mid-buffer every time a waiter existed.
  The old blanket exemption fixed neither half: it kept an RT thread from being
  kicked off while doing nothing to get it on, and let a camper hold a CPU
  without limit. p->policy is read directly, so the tick also sheds a per-call
  task_ctx lookup.
- Measured on the BPF-only arm of prism --dev fork-thread, thread/g24: wall
  78.486s to 21.3s, +367.6% against EEVDF to +27.5%, IPC 0.277 to 0.423. That
  arm was not conservative, it was inert -- 370 traced migrations because
  nothing ever moved work.

SERVICE RENDERED REPLACES CHARACTER GUESSED:
src/bpf/main.bpf.c
- task_ctx carries standing_runs, a saturating count of consecutive runs that
  consumed a full codel_target_ns, maintained in stopping() beside last_run_ns
  and cleared by any shorter run. It is a ledger and not an estimator: it
  records what was rendered and never guesses what the task is. The confirm
  depth is the memory a single last_run_ns sample lacks, since a hog that blocks
  once reads as drained, and it needs no threshold constant because the boundary
  is one live target by construction.
- task_slice selects on it instead of on tier. The two EWMA branches it replaced
  (LAT_CRITICAL at avg_runtime*1.5, INTERACTIVE at avg_runtime*2) sat behind a
  score that returns BATCH for every task after its first wake, so the selector
  was a constant and both branches were reachable only through the RT and
  PF_WQ_WORKER overrides. Everything drew the batch quantum, roughly 5.6ms at
  12C. A task that has not stood on a CPU for a full target now draws the
  adaptive slice.
- TIER 3 routes to the batch or interactive overflow DSQ on the same ledger. The
  split itself stays: dispatch STEP 2 already prices the crossover between the
  two in codel_target_ns with codel_starve_ns as the net above it, so the
  fairness half was never the classifier's. The burst-spawn carve-out that kept
  ewma_age < 2 threads out of the batch DSQ becomes structural rather than a
  special case, since a fresh fork has rendered no service and routes
  interactive by construction.
- prism --dev scale, ADAPTIVE: deadline miss 1.6/0.6/0.3/0.2% to 0.3/0.0/0.1/0.1%
  at 2/4/8/12C. The 0.0-0.4% band has never held at 2C on any release. Long-run
  work spread 0.14-0.96% against EEVDF's 2.25-6.45%, absolute per-process
  throughput above EEVDF at every width, wall +1.0 to +4.1%.

PLACEMENT IS A DISTANCE PRICE WITH NOTHING IN FRONT OF IT:
src/bpf/main.bpf.c
- find_idle_l2_sibling is deleted. It searched one L2 group and fell straight
  into an unordered node-wide pick on a miss, so it had no gradient: either an
  idle CPU shared the wakee's L2 group or placement fell off a cliff. An L2
  group is two CPUs, so under any messaging load the sibling is busy and every
  wakeup took the cliff. find_idle_by_affinity walks affinity_rank in R_eff
  order and contains that search as its prefix, then degrades continuously.
- The three-term gate in front of it goes with it. The tier test was a dead
  classifier read, and affinity_mode and PF_KTHREAD only carved exceptions out
  of a wall that no longer exists.
- phi_warm_target's tier parameter is dropped. It had already been (void)tier.

TWO CONFLICTS RECORDED IN PLACE:
src/bpf/main.bpf.c
- The warm-stay occupancy bail is a rate limit on the home-pull, which is not
  what its comment claimed. warm_stay_anchor returns home_cpu, and seating a
  task there when it last ran elsewhere is itself a migration. Pricing the bail
  as queue depth instead of gating on it admits far more wakees to the warm seat
  and fires far more home-pulls: intra_wake 2161 to 57485, total migrations 3037
  to 59150 against a flat wakeup count, 1.3% to 24.0% per wakeup. The comment
  now says so, and says not to re-price it until the home_cpu against last_cpu
  divergence beneath it is settled.
- montauk's per-cause counters put 81% of migrations on the wake path, not the
  steal path (intra_wake 2161 and cross_wake 293 against intra_steal 547 and
  cross_steal 36). Routing the idle pick through R_eff moved none of it, and the
  hub signature a home-pull would leave is absent -- modal-CPU share per thread
  reads 44.8% against EEVDF's 66.8%, lower rather than higher, which is diffuse
  scatter and not a star.

HARNESS, A BENCH IS NAMED FOR THE BENCH THAT PRODUCED IT:
tests/pandemonium-tests.py
- prism-scale's --ipc, --deadline, --launch, --mixed, --longrun and --burst
  modes wrote prism-scale's filenames. That was not only confusing, it was
  wrong: the archive prom is named {version}-{stamp}.prom, baseline_gate globs
  [0-9]*.prom and prism-golden globs {version}-*.prom, so an IPC-only run
  dropped a file both of them read as that version's full scale run. Each mode
  takes its own name now. The metric family stays pandemonium_scale_* -- one
  schema, and renaming it would orphan every archived run.
- 93 archived IPC runs are renamed out of the version-prom namespace to match,
  selected by report header rather than by guesswork.
@kode54

kode54 commented Aug 21, 2026

Copy link
Copy Markdown

I tested this particular release privately before it was published, and it fixed my AMDGPU related hangs which occurred while the scheduler was enabled almost immediately from boot. Before this release, it would hang for 5-15 seconds at a time every so often while running the desktop, or even while in a TTY.

It was likely related to this: #3687

@kvoqkki

kvoqkki commented Aug 22, 2026

Copy link
Copy Markdown

Hey I tried this version 5.19 and it seems fixed the VirtualBox issue I post days ago. That issue is deleted so I would post a comment here.

Reading kode54's comment above, I feel I might meet the same issue. I'm on an AMD GPU + Intel CPU system, and start VirtualBox VM immediately after booting the system, would lead the VM to be very slow, almost not processing anything.

It seems version 5.19 fixed it. Thank you.

@sirlucjan
sirlucjan added this pull request to the merge queue Aug 24, 2026
Merged via the queue into sched-ext:main with commit bdaa874 Aug 24, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants