Skip to main content

scx_pandemonium/
adaptive.rs

1// PANDEMONIUM ADAPTIVE CONTROL LOOP
2// SINGLE-THREAD CLOSED-LOOP TUNING SYSTEM
3//
4// ONE THREAD: MONITOR LOOP (1-SECOND CONTROL LOOP)
5//   READS THE PER-CPU STATS ARRAY, UNCOLLAPSED, AND BUILDS THE LIVE LOAD
6//   GRAPH: NODES ARE CPUs WEIGHTED BY RUNQUEUE DEPTH, TRAFFIC SHAPE,
7//   CRITICAL SLOWING AND PERSISTENCE; EDGES ARE CPU PAIRS WEIGHTED BY
8//   COUPLING. EVERY MEASURE IS RECOMPUTED FROM THE RAW WINDOW EACH TICK.
9//
10//   EVERY KNOB IS DERIVED, NONE ARE LEARNED. depth -> slice, depth+lag-1 ->
11//   preempt, burstiness -> batch/burst ceilings, Hurst -> rescue threshold,
12//   Bandt-Pompe H -> spill temperature, R_eff -> CoDel equilibrium. Coupling is
13//   MEASURED and not actuated: deriving affinity from it cost 16x on IPC pipe
14//   p50 in the first PRISM pass and was withdrawn. There is no expert set, no loss pathway and no convergence
15//   window: a derived knob is correct this tick, where a learned one was
16//   correct several convergence windows later if the regime held still.
17//
18// BPF PRODUCES HISTOGRAMS, RUST READS AND REACTS. RUST WRITES KNOBS,
19// BPF READS THEM ON THE VERY NEXT SCHEDULING DECISION.
20
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::time::Duration;
23
24use anyhow::Result;
25
26use crate::chaos::{self, Priced, RawWindow};
27use crate::scheduler::{PandemoniumStats, Scheduler};
28use crate::topology::CpuTopology;
29use crate::tuning::{self, HIST_BUCKETS, Regime, detect_regime, scaled_regime_knobs};
30
31// CHAOS WINDOW SIZE. 16 SAMPLES AT 1HZ = 16-SECOND REGIME MEMORY.
32// SIZED FOR BENCH-SCALE RESPONSIVENESS (16-30s iterations) RATHER THAN
33// MINUTE-SCALE DESKTOP STEADY STATE. HVG IS O(N^2) = 256 COMPARISONS
34// PER TICK; BP D=3 GETS 14 LENGTH-3 PATTERNS OVER 6 BUCKETS -- LIMITED
35// RESOLUTION BUT ENOUGH TO DISTINGUISH PERIODIC FROM RANDOM IN ONE
36// BENCH ITERATION.
37const CHAOS_WIN: usize = 16;
38
39// REGIME THRESHOLDS, PROFILES, AND KNOB COMPUTATION LIVE IN tuning.rs
40// (ZERO BPF DEPENDENCIES, TESTABLE OFFLINE)
41
42// THE LIVE LOAD GRAPH
43//
44// Nodes are CPUs weighted by mean runqueue depth; edges are CPU pairs weighted
45// by Pecora-Carroll coupling between their depth series. The chip's electrical
46// graph is a constant and R_eff already prices against it; this is the graph
47// the WORKLOAD forms, which moves every second and which nothing measured.
48//
49// BPF cannot build this. Relating CPU i to CPU j means reading another CPU's
50// state, which per-CPU maps exist to avoid, and the spectral work that follows
51// needs f64 and unbounded loops. This is the half of the boundary that
52// justifies a userspace loop existing at all.
53//
54// EDGES ARE THE MEASUREMENT THAT DECIDES THE HORIZON. If every pair couples
55// identically the graph is complete-and-uniform, there is no structure to
56// exploit, and pricing against it can only reproduce the static case. That is
57// falsifiable from the summary below and is the point of emitting it before
58// anything consumes it.
59pub struct LoadGraph {
60    // Mean runqueue depth per CPU over the last window. NaN where a CPU has
61    // no usable series yet -- absent, not empty.
62    pub node_depth: Vec<f64>,
63    // TRAFFIC SHAPE per CPU (Kim-Jo finite-size-corrected burstiness, [-1, 1]).
64    // -1 perfectly regular / deadline-paced, 0 Poisson / longrun, +1 maximally
65    // bursty / starvation-shaped. This is the continuous form of the property
66    // the three-valued regime enum was approximating for the whole machine.
67    pub node_burst: Vec<Option<chaos::Priced>>,
68    // CRITICAL SLOWING per CPU (lag-1 autocorrelation, [-1, 1]). Rises BEFORE
69    // saturation rather than at window close, which is the one thing no other
70    // signal in this loop does.
71    pub node_slowing: Vec<Option<chaos::Priced>>,
72    // PERSISTENCE per CPU (Veitch-Abry Hurst, [0, 1]). > 0.5 says this CPU's
73    // load pattern tends to continue, which is precisely the "the same task is
74    // likely to return" premise the warm-stay price already assumes.
75    pub node_persist: Vec<Option<chaos::Priced>>,
76    // Upper-triangular coupling, index pair_index(i, j). None where either
77    // series is too short or flat to couple.
78    pub edge: Vec<Option<f64>>,
79    pub n: usize,
80}
81
82fn pair_index(n: usize, i: usize, j: usize) -> usize {
83    // i < j; row-major over the strict upper triangle.
84    debug_assert!(i < j && j < n);
85    i * n - (i * (i + 1)) / 2 + (j - i - 1)
86}
87
88impl LoadGraph {
89    pub fn build<const N: usize>(depth_win: &[RawWindow<N>]) -> LoadGraph {
90        let n = depth_win.len();
91        let node_depth = depth_win
92            .iter()
93            .map(|w| {
94                if w.is_empty() {
95                    f64::NAN
96                } else {
97                    chaos::mean(w)
98                }
99            })
100            .collect();
101        // NODE ATTRIBUTES. Each answers a different question about the same
102        // series, which is why they derive different knobs: burstiness is
103        // SHAPE, lag-1 is DIRECTION, Hurst is DURATION. A learner searching one
104        // loss signal could not separate them; measured, they do not need to be.
105        // PRICED, NOT GATED. Each answers wherever its arithmetic is defined and
106        // carries how much the window is worth; the derivations below scale their
107        // effect by that confidence. A knob moves a little on thin evidence and
108        // fully on a complete window, instead of not at all and then all at once.
109        let node_burst = depth_win
110            .iter()
111            .map(chaos::kim_jo_burstiness_priced)
112            .collect();
113        let node_slowing = depth_win.iter().map(chaos::lag1_autocorr_priced).collect();
114        let node_persist = depth_win
115            .iter()
116            .map(chaos::veitch_abry_hurst_priced)
117            .collect();
118
119        let mut edge = vec![None; n * (n.saturating_sub(1)) / 2];
120        for i in 0..n {
121            for j in (i + 1)..n {
122                edge[pair_index(n, i, j)] = chaos::pecora_carroll(&depth_win[i], &depth_win[j]);
123            }
124        }
125        LoadGraph {
126            node_depth,
127            node_burst,
128            node_slowing,
129            node_persist,
130            edge,
131            n,
132        }
133    }
134
135    // DERIVE PER-CPU SLICE PRESSURE FROM THE GRAPH.
136    //
137    // This is what the graph is FOR. A CPU carrying a deep queue needs a
138    // shorter slice so the queue drains; a CPU near-empty needs a longer one so
139    // it stops paying context-switch cost for work that is not contending. MWU
140    // could only ever search for one slice for the whole machine, so it was
141    // solving the average of a distribution it could not see -- the average
142    // being wrong for every CPU that is not at the mean.
143    //
144    // DERIVED, NOT LEARNED. There is no loss signal, no convergence window and
145    // no weight vector: depth is measured and the slice follows from it this
146    // tick. codel_eq_ns has worked this way since v5.16.0 (computed from R_eff,
147    // explicitly excluded from MWU); this extends that pattern to the knobs a
148    // learner was guessing at.
149    //
150    // Returns one knob set per CPU, seeded from `base` so every field the graph
151    // does not own is carried through untouched.
152    pub fn derive_percpu_knobs(
153        &self,
154        base: &crate::tuning::TuningKnobs,
155    ) -> Vec<crate::tuning::TuningKnobs> {
156        // Reference depth: the mean over CPUs that actually reported. A CPU at
157        // the reference keeps the base slice exactly, so a uniformly-loaded
158        // machine is bit-identical to the pre-graph behavior.
159        let present: Vec<f64> = self
160            .node_depth
161            .iter()
162            .copied()
163            .filter(|d| d.is_finite())
164            .collect();
165        let refd = if present.is_empty() {
166            0.0
167        } else {
168            present.iter().sum::<f64>() / present.len() as f64
169        };
170        self.node_depth
171            .iter()
172            .enumerate()
173            .map(|(i, d)| {
174                let mut k = *base;
175                // Absent series or a flat machine: carry the base through.
176                if !d.is_finite() || refd <= f64::EPSILON {
177                    return k;
178                }
179                // Deeper than reference -> shorter slice, and inversely.
180                // Clamped to [1/2, 2x] so one anomalous tick cannot hand a CPU
181                // a slice far outside anything the regime profile intended.
182                let ratio = (refd / (d + refd).max(f64::EPSILON) * 2.0).clamp(0.5, 2.0);
183                k.slice_ns = ((base.slice_ns as f64) * ratio) as u64;
184                k.preempt_thresh_ns = ((base.preempt_thresh_ns as f64) * ratio) as u64;
185
186                // BURSTINESS -> BATCH AND BURST CEILINGS.
187                // Bursty traffic (B > 0) arrives in clumps with quiet between,
188                // so a batch task wants a LONGER ceiling: it will not be
189                // contending most of the time, and cutting it short pays
190                // context-switch cost for contention that is not there. Paced
191                // traffic (B < 0) is the opposite -- steady arrivals mean a
192                // long ceiling holds the CPU against work that is genuinely
193                // queued. Scaled +/-50% across the full [-1, 1] range.
194                if let Some(b) = self.node_burst.get(i).and_then(|v| *v) {
195                    // weighted() collapses toward the neutral factor 1.0 as
196                    // confidence falls, so a thin window nudges rather than swings.
197                    let f = Priced {
198                        value: 1.0 + 0.5 * b.value.clamp(-1.0, 1.0),
199                        confidence: b.confidence,
200                    }
201                    .weighted(1.0);
202                    k.batch_slice_ns = ((base.batch_slice_ns as f64) * f) as u64;
203                    k.burst_slice_ns = ((base.burst_slice_ns as f64) * f) as u64;
204                }
205
206                // CRITICAL SLOWING -> PREEMPT WINDOW, AHEAD OF THE BURST.
207                // r1 rising toward 1 means perturbations on this CPU are
208                // persisting instead of damping: it is approaching saturation
209                // and has not got there yet. Tightening the preempt window now
210                // is the only place in this loop that acts on a prediction
211                // rather than on a completed loss. Only the positive half
212                // matters -- an oscillating CPU (r1 < 0) is already recovering.
213                if let Some(r1) = self.node_slowing.get(i).and_then(|v| *v)
214                    && r1.value > 0.0
215                {
216                    let tighten = Priced {
217                        value: 1.0 - 0.4 * r1.value.clamp(0.0, 1.0),
218                        confidence: r1.confidence,
219                    }
220                    .weighted(1.0);
221                    k.preempt_thresh_ns = ((k.preempt_thresh_ns as f64) * tighten) as u64;
222                }
223
224                // PERSISTENCE -> CoDel RESCUE THRESHOLD.
225                // H > 0.5 says this CPU's load pattern tends to continue, so a
226                // queue that is deep now will likely still be deep: rescue
227                // sooner. H < 0.5 is mean-reverting, where a deep queue is
228                // likely transient and rescuing early only scatters a task that
229                // was about to be served anyway. Centered on H = 0.5 so an
230                // uncorrelated CPU keeps the base exactly.
231                if let Some(h) = self.node_persist.get(i).and_then(|v| *v) {
232                    let f = Priced {
233                        value: (1.0f64 - (h.value.clamp(0.0, 1.0) - 0.5)).clamp(0.5, 1.5),
234                        confidence: h.confidence,
235                    }
236                    .weighted(1.0);
237                    k.codel_thresh_ns = ((base.codel_thresh_ns as f64) * f) as u64;
238                }
239
240                // AFFINITY IS NOT DERIVED, AND THE REASON IS MEASURED.
241                //
242                // This briefly read coupling and bound strongly above a
243                // midpoint, on the argument that runqueues moving together mean
244                // tasks sharing data. That argument was reasoning, not
245                // measurement, and the first full PRISM pass on v5.18.0 put a
246                // number against it: baseline_gate reported every IPC primitive
247                // regressed on every arm, worst pipe p50 20 -> 336us at drift
248                // 1.04x, which is 16x with the environment ruled out.
249                //
250                // IPC is precisely a two-task ping-pong with tightly coupled
251                // queue depths, so the derivation fired hardest exactly where
252                // the damage landed. Whether coupling should raise affinity,
253                // lower it, or not touch it is now an open question with
254                // evidence on one side only, so the knob keeps the base value
255                // until something measures the direction. mean_coupling() stays
256                // as telemetry -- the reading is still wanted, the actuation is
257                // not.
258                k
259            })
260            .collect()
261    }
262
263    // Is there structure worth pricing against? Returns (available_edges,
264    // mean, min, max) over the edges that computed. A spread near zero means
265    // the coupling matrix is flat and the live graph adds nothing over the
266    // static one.
267    pub fn edge_summary(&self) -> (usize, f64, f64, f64) {
268        let vals: Vec<f64> = self.edge.iter().filter_map(|e| *e).collect();
269        if vals.is_empty() {
270            return (0, 0.0, 0.0, 0.0);
271        }
272        let sum: f64 = vals.iter().sum();
273        let mut lo = f64::INFINITY;
274        let mut hi = f64::NEG_INFINITY;
275        for v in &vals {
276            if *v < lo {
277                lo = *v;
278            }
279            if *v > hi {
280                hi = *v;
281            }
282        }
283        (vals.len(), sum / vals.len() as f64, lo, hi)
284    }
285}
286
287// SLEEP PATTERN BUCKETS: CLASSIFY IO-WAIT VS IDLE WORKLOADS
288const SLEEP_BUCKETS: usize = 4;
289
290// MONITOR LOOP
291
292// 1-SECOND CONTROL LOOP. READS BPF HISTOGRAMS, COMPUTES P99,
293// DETECTS WORKLOAD REGIME, TIGHTENS/RELAXES KNOBS.
294// RUNS ON THE MAIN THREAD.
295pub fn monitor_loop(
296    sched: &mut Scheduler,
297    shutdown: &'static AtomicBool,
298    verbose: bool,
299    nr_cpus: u64,
300) -> Result<bool> {
301    // HOTPLUG POLL STATE: the online count as last observed. The poll below
302    // re-derives topology on change AND refreshes the Rust-local tau_ns --
303    // without that refresh, a hotplug that changes tau without flipping the
304    // regime left MWU computing every knob off pre-hotplug tau until a regime
305    // change happened to land (the stale-MWU-tau gap).
306    let mut last_online = CpuTopology::online_cpu_count();
307    let mut prev = PandemoniumStats::default();
308    let mut prev_hist = [[0u64; HIST_BUCKETS]; 3];
309    let mut prev_sleep = [0u64; SLEEP_BUCKETS];
310    let mut regime = Regime::Mixed;
311
312    // CHAOS RAW WINDOWS. idle_pct DRIVES REGIME DETECTION; wakeup_rate
313    // DRIVES THE MWU CHAOS-TRANSITION PATHWAY (BANDT-POMPE IS ORDINAL,
314    // SO ABSOLUTE RATE SCALE DOES NOT MATTER -- THE PATTERN DOES).
315    let mut idle_win: RawWindow<CHAOS_WIN> = RawWindow::new();
316    let mut wake_win: RawWindow<CHAOS_WIN> = RawWindow::new();
317    // PER-CPU RUNQUEUE-DEPTH WINDOWS: THE NODES OF THE LIVE LOAD GRAPH.
318    // One series per CPU, each the interval-mean depth on that CPU, which is
319    // what rq_depth_sum/rq_depth_samples differenced gives. Until v5.18.0
320    // there was exactly ONE series in this whole loop -- a system-wide integer
321    // percentage -- which is why a coupling measure had nothing to couple.
322    let mut depth_win: Vec<RawWindow<CHAOS_WIN>> =
323        (0..nr_cpus as usize).map(|_| RawWindow::new()).collect();
324    let mut prev_percpu: Vec<PandemoniumStats> = Vec::new();
325    let mut prev_bp_h: f64 = 0.0;
326    let chaos_count = chaos::ChaosCounter::new();
327    let mut prev_lambda_above: bool = false;
328    // READ CURRENT tau SNAPSHOT FROM THE BPF-SIDE KNOB MAP. main.rs WROTE IT
329    // ONCE AT TOPOLOGY DETECT; THE ADAPTIVE LOOP RE-READS SO TAU-SCALED REGIME
330    // KNOBS AGREE WITH TAU-SCALED BPF INIT AT FIRST TICK AND EVERY REGIME CHANGE.
331    let mut tau_ns = sched.read_tuning_knobs().topology_tau_ns;
332    let mut pending_regime = regime;
333    let mut regime_hold: u32 = 0;
334    let mut light_ticks: u64 = 0;
335    let mut mixed_ticks: u64 = 0;
336    let mut heavy_ticks: u64 = 0;
337    // STABILITY SCORE IS A WEAK PRE-CHAOS STEADY-STATE PROXY, KEPT FOR
338    // TELEMETRY GATING ONLY -- THE REAL STEADY-STATE GATE
339    // IS `quiesce.frozen` BELOW. DO NOT WIRE stability_score INTO THE
340    // FREEZE DECISION (TWO COMPETING "AM I STEADY" SIGNALS = A BUG).
341    let mut stability_score: u32 = 0;
342    let mut tick_counter: u64 = 0;
343
344    // QUIESCENCE GATE + ADAPTIVE-RARITY RETUNE STATE. The gate latches
345    // a "frozen" flag from HVG-lambda + RQA-DET + MWU convergence and
346    // the loop then skips the expensive MWU retune + knob write. When
347    // not frozen, the retune interval stretches on sub-threshold deltas.
348    let mut quiesce = tuning::QuiescenceState::new();
349    let mut retune_interval: u32 = tuning::RETUNE_INTERVAL_BASE;
350    let mut ticks_since_retune: u32 = 0;
351    let mut frozen_ticks: u64 = 0;
352    // GRAPH SUMMARY ACCUMULATORS. The scheduler writes its own telemetry file
353    // at shutdown rather than relying on a bench to capture stdout: three
354    // separate benches discarded it three different ways (no recording dir, a
355    // path that bypassed the marker writer, and stdout sent to DEVNULL), and
356    // the coupling reading went unread each time. A producer that owns its own
357    // artifact cannot be defeated by a consumer's plumbing.
358    let mut g_tick: u64 = 0;
359    let mut g_edge_ticks: u64 = 0;
360    let mut g_edges_total: u64 = 0;
361    let mut g_mean_sum: f64 = 0.0;
362    let mut g_spread_sum: f64 = 0.0;
363    let mut g_spread_max: f64 = 0.0;
364
365    // APPLY INITIAL REGIME. scaled_regime_knobs RETURNS topology_tau_ns/codel_eq_ns=0;
366    // OVERLAY THE LIVE BPF VALUES SO THE FIRST WRITE DOESN'T CLOBBER WHAT
367    // write_topology_fields() PUT IN THE MAP. Mirrors the regime-change path at line 230.
368    let live = sched.read_tuning_knobs();
369    let mut rk = scaled_regime_knobs(regime, nr_cpus, tau_ns);
370    rk.topology_tau_ns = tau_ns;
371    rk.codel_eq_ns = live.codel_eq_ns;
372    sched.write_tuning_knobs(&rk)?;
373    // SEED THE MWU BASELINE WITH THE LIVE PHI EQUILIBRIUM AT TICK 0. new()
374    // BUILT mwu FROM scaled_regime_knobs (codel_eq_ns=0); set_baseline IS
375    // OTHERWISE ONLY CALLED ON A REGIME CHANGE. WITHOUT THIS SEED THE SOJOURN
376    // FLOOR (tuning.rs) FALLS BACK TO THE DEAD 4ms CONSTANT FOR ANY RUN WHOSE
377    // REGIME NEVER CHANGES (E.G. A STEADY MIXED BENCH), SO THE PHI-COHERENT
378    // FLOOR WOULD NEVER ENGAGE.
379    // COMMIT-ON-CHANGE BASELINE: the last knob set actually written to
380    // the BPF map. Updated here at init, on every regime-change write,
381    // and on every conditional write. MWU-owned fields drive the diff.
382    let mut last_written_knobs = rk;
383
384    while !shutdown.load(Ordering::Relaxed) && !sched.exited() {
385        crate::watchdog::LOOP_HEARTBEAT.fetch_add(1, Ordering::Relaxed);
386        std::thread::sleep(Duration::from_secs(1));
387
388        if CpuTopology::poll_hotplug(sched, nr_cpus as usize, &mut last_online) {
389            tau_ns = sched.read_tuning_knobs().topology_tau_ns;
390        }
391
392        // THE ARRAY, UNCOLLAPSED. `stats` below is the same fold this loop
393        // always consumed; `percpu` is the spatial dimension that used to be
394        // discarded at the boundary. Both come from ONE syscall.
395        let percpu = sched.read_stats_percpu();
396        let stats = Scheduler::fold_stats(&percpu);
397        let cur_hist = sched.read_wake_lat_hist();
398        let cur_sleep = sched.read_sleep_hist();
399
400        // WRAP GUARD: BPF RELOAD, UEI RECOVERY, OR HOTPLUG CAN RESET KERNEL-SIDE
401        // CUMULATIVE COUNTERS WHILE RUST'S PREV STILL HOLDS OLD VALUES. WITHOUT
402        // THIS CHECK, WRAPPING_SUB PRODUCES A GARBAGE POSITIVE DELTA THAT POISONS
403        // P99 AND FEEDS NONSENSE TO MWU. RESET BASELINE AND SKIP THE TICK.
404        let mut wrapped = stats.nr_dispatches < prev.nr_dispatches;
405        if !wrapped {
406            'wrap: for tier in 0..3 {
407                for b in 0..HIST_BUCKETS {
408                    if cur_hist[tier][b] < prev_hist[tier][b] {
409                        wrapped = true;
410                        break 'wrap;
411                    }
412                }
413            }
414        }
415        if !wrapped {
416            for i in 0..SLEEP_BUCKETS {
417                if cur_sleep[i] < prev_sleep[i] {
418                    wrapped = true;
419                    break;
420                }
421            }
422        }
423        if wrapped {
424            log_warn!("WRAP DETECTED: BASELINE RESET, SKIPPING ADAPTIVE UPDATE");
425            prev = stats;
426            prev_hist = cur_hist;
427            prev_sleep = cur_sleep;
428            continue;
429        }
430
431        // COMPUTE DELTAS
432        let delta_d = stats.nr_dispatches.wrapping_sub(prev.nr_dispatches);
433        let delta_idle = stats.nr_idle_hits.wrapping_sub(prev.nr_idle_hits);
434        let delta_shared = stats.nr_shared.wrapping_sub(prev.nr_shared);
435        let delta_preempt = stats.nr_preempt.wrapping_sub(prev.nr_preempt);
436        let delta_keep = stats.nr_keep_running.wrapping_sub(prev.nr_keep_running);
437        let delta_parks = stats.nr_osc_park.wrapping_sub(prev.nr_osc_park);
438        let delta_wake_sum = stats.wake_lat_sum.wrapping_sub(prev.wake_lat_sum);
439        let delta_wake_samples = stats.wake_lat_samples.wrapping_sub(prev.wake_lat_samples);
440        let delta_hard = stats.nr_hard_kicks.wrapping_sub(prev.nr_hard_kicks);
441        let delta_soft = stats.nr_soft_kicks.wrapping_sub(prev.nr_soft_kicks);
442        let delta_steal = stats.nr_steal.wrapping_sub(prev.nr_steal);
443        let delta_enq_wake = stats.nr_enq_wakeup.wrapping_sub(prev.nr_enq_wakeup);
444        let delta_enq_requeue = stats.nr_enq_requeue.wrapping_sub(prev.nr_enq_requeue);
445        let delta_rescue = stats
446            .nr_overflow_rescue
447            .wrapping_sub(prev.nr_overflow_rescue);
448        // CROSS-DOMAIN SCATTER (PATHWAY 6 INPUT). PLACEMENT-SIDE PATHS ONLY:
449        // XDOM_SEL_* + XDOM_ENQ_T1/T2 (INDICES 0..6). THE PHI-CORRECT WORK-
450        // CONSERVATION PATHS XDOM_STEAL (6) AND XDOM_STEP5 (7) ARE EXCLUDED --
451        // PENALIZING THEM WOULD MAKE MWU FIGHT THE BPF'S DELIBERATE REBALANCING.
452        // saturating_sub ABSORBS A COUNTER RESET (BPF RELOAD) AS 0, NO GARBAGE.
453        let wake_avg_us = if delta_wake_samples > 0 {
454            delta_wake_sum / delta_wake_samples / 1000
455        } else {
456            0
457        };
458
459        // PER-PATH LATENCY
460        let d_idle_sum = stats.wake_lat_idle_sum.wrapping_sub(prev.wake_lat_idle_sum);
461        let d_idle_cnt = stats.wake_lat_idle_cnt.wrapping_sub(prev.wake_lat_idle_cnt);
462        let d_kick_sum = stats.wake_lat_kick_sum.wrapping_sub(prev.wake_lat_kick_sum);
463        let d_kick_cnt = stats.wake_lat_kick_cnt.wrapping_sub(prev.wake_lat_kick_cnt);
464        let lat_idle_us = if d_idle_cnt > 0 {
465            d_idle_sum / d_idle_cnt / 1000
466        } else {
467            0
468        };
469        let lat_kick_us = if d_kick_cnt > 0 {
470            d_kick_sum / d_kick_cnt / 1000
471        } else {
472            0
473        };
474        let delta_reenq = stats.nr_reenqueue.wrapping_sub(prev.nr_reenqueue);
475
476        // L2 CACHE AFFINITY DELTAS
477        let dl2_hb = stats.nr_l2_hit_batch.wrapping_sub(prev.nr_l2_hit_batch);
478        let dl2_mb = stats.nr_l2_miss_batch.wrapping_sub(prev.nr_l2_miss_batch);
479        let dl2_hi = stats
480            .nr_l2_hit_interactive
481            .wrapping_sub(prev.nr_l2_hit_interactive);
482        let dl2_mi = stats
483            .nr_l2_miss_interactive
484            .wrapping_sub(prev.nr_l2_miss_interactive);
485        let l2_pct_b = if dl2_hb + dl2_mb > 0 {
486            dl2_hb * 100 / (dl2_hb + dl2_mb)
487        } else {
488            0
489        };
490        let l2_pct_i = if dl2_hi + dl2_mi > 0 {
491            dl2_hi * 100 / (dl2_hi + dl2_mi)
492        } else {
493            0
494        };
495
496        let idle_pct = if delta_d > 0 {
497            delta_idle * 100 / delta_d
498        } else {
499            0
500        };
501
502        // COMPUTE HISTOGRAM DELTAS (cur_hist READ AT TOP FOR WRAP GUARD)
503        let mut delta_hist = [[0u64; HIST_BUCKETS]; 3];
504        for tier in 0..3 {
505            for b in 0..HIST_BUCKETS {
506                delta_hist[tier][b] = cur_hist[tier][b] - prev_hist[tier][b];
507            }
508        }
509
510        // COMPUTE P99 PER TIER
511        let tp99_b_ns = tuning::compute_p99_from_histogram(&delta_hist[0]);
512        let tp99_i_ns = tuning::compute_p99_from_histogram(&delta_hist[1]);
513        let tp99_l_ns = tuning::compute_p99_from_histogram(&delta_hist[2]);
514
515        // AGGREGATE P99
516        let mut agg = [0u64; HIST_BUCKETS];
517        for t in 0..3 {
518            for b in 0..HIST_BUCKETS {
519                agg[b] += delta_hist[t][b];
520            }
521        }
522        let p99_ns = tuning::compute_p99_from_histogram(&agg);
523
524        // SLEEP HISTOGRAM DELTAS (cur_sleep READ AT TOP FOR WRAP GUARD)
525        let mut delta_sleep = [0u64; SLEEP_BUCKETS];
526        for i in 0..SLEEP_BUCKETS {
527            delta_sleep[i] = cur_sleep[i] - prev_sleep[i];
528        }
529        let sleep_total: u64 = delta_sleep.iter().sum();
530        let io_pct = if sleep_total > 0 {
531            (delta_sleep[0] + delta_sleep[1]) * 100 / sleep_total
532        } else {
533            0
534        };
535
536        // CHAOS UPDATE: PUSH RAW SAMPLES INTO WINDOWS BEFORE COMPUTING
537        // ANY DERIVED FEATURES. WAKE WINDOW USES THE PER-SECOND DELTA
538        // (delta_enq_wake) RATHER THAN AN INSTANTANEOUS RATE.
539        idle_win.push(idle_pct as f64);
540        wake_win.push(delta_enq_wake as f64);
541
542        // NODE WEIGHTS: MEAN RUNQUEUE DEPTH PER CPU OVER THIS INTERVAL.
543        // Both fields are monotonic, so the interval mean is the ratio of the
544        // two deltas -- the same shape wake_lat_sum/wake_lat_samples is already
545        // consumed with. A CPU whose sample count did not advance contributes
546        // nothing rather than a zero: no samples is not an empty queue.
547        if prev_percpu.len() == percpu.len() {
548            for (i, w) in depth_win.iter_mut().enumerate() {
549                if i >= percpu.len() {
550                    break;
551                }
552                let ds = percpu[i]
553                    .rq_depth_samples
554                    .wrapping_sub(prev_percpu[i].rq_depth_samples);
555                if ds > 0 {
556                    let dd = percpu[i]
557                        .rq_depth_sum
558                        .wrapping_sub(prev_percpu[i].rq_depth_sum);
559                    w.push(dd as f64 / ds as f64);
560                }
561            }
562        }
563        prev_percpu = percpu.clone();
564
565        // CHAOS PRIMITIVES. ONE O(N^2) HVG PASS + ONE O(N^2) RQA PASS
566        // PER WINDOW; BP IS O(N). RQA-DET RUNS ON THE SAME idle_win AS
567        // HVG SO THE QUIESCENCE GATE SEES IDENTICAL SAMPLES. rqa IS
568        // None UNTIL THE WINDOW HAS RQA_MIN_SAMPLES FILLED.
569        let (idle_lambda, _idle_hvg_s) = chaos::hvg_stats(&idle_win);
570        let wake_bp_h = chaos::bandt_pompe_d3(&wake_win);
571
572        // BUILD THE LIVE LOAD GRAPH. Measured and reported before anything
573        // prices against it: if the coupling spread is ~0 the graph is
574        // complete-and-uniform and carries no structure the static topology
575        // does not already have. Nothing downstream consumes it yet -- this
576        // tick is the falsification, not the feature.
577        let graph = LoadGraph::build(&depth_win);
578        let (g_edges, g_mean, g_min, g_max) = graph.edge_summary();
579        // OSCILLATOR POSITION, READ NOT RE-DERIVED. The BPF damped oscillator owns
580        // codel_target_ns; this reads where it currently sits (0.0 floor/tightened,
581        // 1.0 max/relaxed) so the layer can SEE the target it must not fight. The
582        // reader existed with no consumer, which meant the one piece of state that
583        // says whether the BPF has already responded was unobservable from up here.
584        // Reporting only -- the defer gate that would ACT on it is still open.
585        let osc = sched.read_oscillator_state();
586        let osc_pos = osc.position();
587        // The Phi release point the warm-stay and STEP-1 steal actually let a task
588        // leave home at: codel_target plus the nearest-peer hold. Reported beside
589        // the raw position because position() alone cannot say whether the BPF has
590        // genuinely responded -- that comparison is the defer gate, still open.
591        let osc_rel_us = osc.effective_release_ns() / 1000;
592        g_tick += 1;
593        if g_edges > 0 {
594            g_edge_ticks += 1;
595            g_edges_total += g_edges as u64;
596            g_mean_sum += g_mean;
597            let spread = g_max - g_min;
598            g_spread_sum += spread;
599            if spread > g_spread_max {
600                g_spread_max = spread;
601            }
602        }
603        // SPILL-Phi CHAOS->TEMPERATURE BRIDGE: OVERLAID ONTO EVERY KNOB
604        // WRITE BELOW LIKE topology_tau_ns; INERT IN BPF UNTIL THE SPILL
605        // PRICE CONSUMES IT.
606        let bp_delta = wake_bp_h - prev_bp_h;
607        let mean_idle = chaos::mean(&idle_win);
608        let rqa = chaos::rqa_det(&idle_win);
609
610        // CHAOS CROSSING DIAGNOSTIC: BUMP COUNTER ON EITHER GATE FIRING.
611        // chaos_crossing IS ALSO REUSED BELOW AS THE ADAPTIVE-RARITY
612        // "disturbed" SIGNAL -- CAPTURE IT BEFORE prev_lambda_above IS
613        // OVERWRITTEN.
614        let lambda_above = idle_lambda >= chaos::HVG_LAMBDA_CHAOTIC_MIN;
615        let chaos_crossing = (lambda_above && !prev_lambda_above) || bp_delta > 0.10;
616        if chaos_crossing {
617            chaos_count.bump();
618        }
619        prev_lambda_above = lambda_above;
620
621        // DETECT REGIME (CHAOS-DRIVEN + 2-TICK HOLD)
622        let detected = detect_regime(mean_idle, idle_lambda, wake_bp_h);
623
624        let mut regime_changed_this_tick = false;
625        if detected != regime {
626            if detected == pending_regime {
627                regime_hold += 1;
628            } else {
629                pending_regime = detected;
630                regime_hold = 1;
631            }
632            if regime_hold >= 2 {
633                regime = detected;
634                // REFRESH tau IN CASE HOTPLUG/TOPOLOGY CHANGED.
635                // scaled_regime_knobs RETURNS topology_tau_ns/codel_eq_ns=0;
636                // OVERLAY THE LIVE BPF VALUES (BOTH OWNED BY TOPOLOGY LAYER).
637                let live = sched.read_tuning_knobs();
638                tau_ns = live.topology_tau_ns;
639                let mut rk = scaled_regime_knobs(regime, nr_cpus, tau_ns);
640                rk.topology_tau_ns = tau_ns;
641                rk.codel_eq_ns = live.codel_eq_ns;
642                // Regime baseline, spread per CPU by the graph on the same
643                // terms as the retune path below.
644                sched.write_tuning_knobs_percpu(&graph.derive_percpu_knobs(&rk))?;
645                last_written_knobs = rk;
646                regime_changed_this_tick = true;
647                // RESET ONLY THE NEW REGIME'S WEIGHT VECTOR + EDGE STATE
648                // (THE OTHER REGIMES KEEP THEIR LEARNED VECTORS), AND
649                // SNAP THE ADAPTIVE-RARITY INTERVAL BACK TO BASE.
650                retune_interval = tuning::RETUNE_INTERVAL_BASE;
651                ticks_since_retune = 0;
652            }
653        } else {
654            pending_regime = regime;
655            regime_hold = 0;
656        }
657
658        // QUIESCENCE GATE. HVG-lambda in the periodic band + RQA-DET
659        // steady + the active-regime MWU vector converged -> latch
660        // `frozen` and skip the expensive MWU retune + knob write. The
661        // loop still ticks at 1 Hz; the chaos sensors above are the
662        // exit condition for frozen mode. A regime change moves lambda
663        // out of the steady band, so the gate thaws on the same/next
664        // tick -- the two gates compose without conflict.
665        // The freeze gate's third term was MWU convergence. With no learner
666        // there is nothing to converge, so the gate is the chaos band alone.
667        let frozen = quiesce.update(idle_lambda, rqa, true);
668        if frozen {
669            frozen_ticks += 1;
670        }
671
672        // MWU ORCHESTRATOR: UNIFIED KNOB CONTROL
673        // GATED BY !regime_changed_this_tick (a fresh regime already
674        // wrote its baseline) AND !frozen (steady state -- stop the
675        // machinery). When neither gate is set, the adaptive-rarity
676        // counter throttles how often the retune actually fires.
677        if !regime_changed_this_tick && !frozen {
678            ticks_since_retune += 1;
679            if ticks_since_retune >= retune_interval {
680                ticks_since_retune = 0;
681                // KNOBS COME FROM THE REGIME BASELINE, SPREAD BY THE GRAPH.
682                // No expert weights, no loss pathways, no convergence window:
683                // the baseline is a function of regime and tau, and per-CPU
684                // slice pressure is a function of measured queue depth. Both
685                // are computed this tick from this tick's data.
686                let live = sched.read_tuning_knobs();
687                let mut knobs = scaled_regime_knobs(regime, nr_cpus, tau_ns);
688                knobs.topology_tau_ns = live.topology_tau_ns;
689                knobs.codel_eq_ns = live.codel_eq_ns;
690                // COMMIT-ON-CHANGE: only push when a field actually moved. The
691                // BPF side reads the map unsynchronized, so skipping redundant
692                // writes strictly reduces torn-read exposure.
693                let changed = tuning::knobs_differ(&knobs, &last_written_knobs);
694                if changed {
695                    sched.write_tuning_knobs_percpu(&graph.derive_percpu_knobs(&knobs))?;
696                    last_written_knobs = knobs;
697                }
698                let disturbed = chaos_crossing;
699                retune_interval =
700                    tuning::next_retune_interval(retune_interval, !changed, disturbed);
701            }
702        }
703
704        // STABILITY TRACKING
705        let tighten_delta = if chaos_crossing { 1u64 } else { 0u64 };
706        stability_score = tuning::compute_stability_score(
707            stability_score,
708            regime_changed_this_tick,
709            tighten_delta,
710            p99_ns,
711            regime.p99_ceiling(),
712        );
713
714        let p99_us = p99_ns / 1000;
715        let tp99_b = tp99_b_ns / 1000;
716        let tp99_i = tp99_i_ns / 1000;
717        let tp99_l = tp99_l_ns / 1000;
718        let knobs = sched.read_tuning_knobs();
719
720        let sojourn_ms = stats.batch_sojourn_ns / 1_000_000;
721        let sojourn_thresh_ms = knobs.codel_thresh_ns / 1_000_000;
722        let longrun_label = if stats.longrun_mode_active > 0 {
723            " LONGRUN"
724        } else {
725            ""
726        };
727
728        if verbose && tuning::should_print_telemetry(tick_counter, stability_score) {
729            let rqa_disp = rqa.unwrap_or(-1.0);
730            let frozen_disp = if frozen { 1 } else { 0 };
731            println!(
732                "d/s: {:<8} idle: {}% shared: {:<6} preempt: {:<4} keep: {:<4} kick: H={:<4} S={:<4} enq: W={:<4} R={:<4} wake: {}us p99: {}us [B:{} I:{} L:{}] lat_idle: {}us lat_kick: {}us sleep: io={}% slice: {}us batch: {}us reenq: {} sjrn: {}ms/{}ms rescue: {} l2: B={}% I={}% chaos: lam={:.2} H={:.2} det={:.2} x={} frozen: {} (n={}) retune_iv: {} [{}{}] graph: n={} e={} cpl={:.2}/{:.2}/{:.2} osc: {:.2}/{}us",
733                delta_d,
734                idle_pct,
735                delta_shared,
736                delta_preempt,
737                delta_keep,
738                delta_hard,
739                delta_soft,
740                delta_enq_wake,
741                delta_enq_requeue,
742                wake_avg_us,
743                p99_us,
744                tp99_b,
745                tp99_i,
746                tp99_l,
747                lat_idle_us,
748                lat_kick_us,
749                io_pct,
750                knobs.slice_ns / 1000,
751                knobs.batch_slice_ns / 1000,
752                delta_reenq,
753                sojourn_ms,
754                sojourn_thresh_ms,
755                delta_rescue,
756                l2_pct_b,
757                l2_pct_i,
758                idle_lambda,
759                wake_bp_h,
760                rqa_disp,
761                chaos_count.load(),
762                frozen_disp,
763                frozen_ticks,
764                retune_interval,
765                graph.n,
766                g_edges,
767                g_mean,
768                g_min,
769                g_max,
770                osc_pos,
771                osc_rel_us,
772                regime.label(),
773                longrun_label,
774            );
775        }
776
777        sched.log.snapshot(
778            delta_d,
779            delta_idle,
780            delta_shared,
781            delta_preempt,
782            delta_keep,
783            delta_parks,
784            wake_avg_us,
785            delta_hard,
786            delta_soft,
787            lat_idle_us,
788            lat_kick_us,
789            delta_steal,
790        );
791
792        match regime {
793            Regime::Light => light_ticks += 1,
794            Regime::Mixed => mixed_ticks += 1,
795            Regime::Heavy => heavy_ticks += 1,
796        }
797
798        tick_counter += 1;
799        prev_hist = cur_hist;
800        prev_sleep = cur_sleep;
801        prev = stats;
802        prev_bp_h = wake_bp_h;
803    }
804
805    // KNOBS SUMMARY: CAPTURED BY TEST HARNESS FOR ARCHIVE
806    let final_knobs = sched.read_tuning_knobs();
807    let final_stats = sched.read_stats();
808    let l2_total_b = final_stats.nr_l2_hit_batch + final_stats.nr_l2_miss_batch;
809    let l2_total_i = final_stats.nr_l2_hit_interactive + final_stats.nr_l2_miss_interactive;
810    let l2_cum_b = if l2_total_b > 0 {
811        final_stats.nr_l2_hit_batch * 100 / l2_total_b
812    } else {
813        0
814    };
815    let l2_cum_i = if l2_total_i > 0 {
816        final_stats.nr_l2_hit_interactive * 100 / l2_total_i
817    } else {
818        0
819    };
820    // CROSS-DOMAIN SCATTER ATTRIBUTION (PER XDOM_* PATH). scatter_pct IS THE
821    // PLACEMENT-SIDE FRACTION (idx 0..6) PATHWAY 6 ACTS ON; THE PER-PATH COUNTS
822    // ARE THE PERMANENT ATTRIBUTION SURFACED TO THE BENCH SUITE EVERY RUN.
823    let x = &final_stats.nr_cross_domain;
824    let x_scatter: u64 = x[0..6].iter().sum();
825    let x_scatter_pct = if final_stats.nr_dispatches > 0 {
826        x_scatter * 100 / final_stats.nr_dispatches
827    } else {
828        0
829    };
830    // osc_park IS THE OSCILLATOR ENVELOPE'S OWN COLLAPSE DETECTOR: ZERO PARKS
831    // AFTER AN IDLE-HEAVY RUN MEANS THE ENVELOPE NEVER WENT QUIET, WHICH IS THE
832    // MINIMUM-ATTENTION-COLLAPSE FAILURE MODE. IT WAS IN THE STATS STRUCT AND ON
833    // THE TERMINAL, BUT NOT ON THIS LINE, SO IT NEVER REACHED THE BENCH ARCHIVE
834    // OR THE PRISM .prom -- THE IDLE POWER QUESTION HAD NO COUNTER BEHIND IT.
835    // THE COUPLING READING, WRITTEN WHERE A BENCH CANNOT LOSE IT.
836    // edge_ticks == 0 is the falsification: no CPU pair ever produced a usable
837    // coupling value, so the live graph has nodes and no edges and there is
838    // nothing for a spectral price to solve over. That is a real answer and it
839    // must survive to disk to count as one.
840    {
841        let dir = std::path::Path::new("/tmp/pandemonium");
842        let _ = std::fs::create_dir_all(dir);
843        let denom = g_edge_ticks.max(1) as f64;
844        let body = format!(
845            "pandemonium_graph_ticks {}\n\
846             pandemonium_graph_edge_ticks {}\n\
847             pandemonium_graph_edges_mean {:.2}\n\
848             pandemonium_graph_coupling_mean {:.4}\n\
849             pandemonium_graph_coupling_spread_mean {:.4}\n\
850             pandemonium_graph_coupling_spread_max {:.4}\n\
851             pandemonium_chaos_frozen_ticks {}\n\
852             pandemonium_chaos_frozen_fraction {:.4}\n",
853            g_tick,
854            g_edge_ticks,
855            g_edges_total as f64 / denom,
856            g_mean_sum / denom,
857            g_spread_sum / denom,
858            g_spread_max,
859            frozen_ticks,
860            frozen_ticks as f64 / g_tick.max(1) as f64
861        );
862        let path = dir.join(format!("graph-{}.prom", std::process::id()));
863        let _ = std::fs::write(&path, body);
864        println!("[GRAPH] {}", path.display());
865    }
866
867    println!(
868        "[KNOBS] regime={} slice_ns={} batch_ns={} preempt_ns={} mwu={:.3} ticks=L:{}/M:{}/H:{} frozen={} l2_hit=B:{}%/I:{}% cross_domain_scatter_pct={} cross_domain_sel_tight={} cross_domain_sel_sync={} cross_domain_sel_normal={} cross_domain_sel_dfl={} cross_domain_enq_t1={} cross_domain_enq_t2={} cross_domain_steal={} cross_domain_step5={} osc_park={} steal={} spill={} kick_declined={} stay_cost_held={} stay_move_taken={} dispatches={}",
869        regime.label(),
870        final_knobs.slice_ns,
871        final_knobs.batch_slice_ns,
872        final_knobs.preempt_thresh_ns,
873        0.0f64,
874        light_ticks,
875        mixed_ticks,
876        heavy_ticks,
877        frozen_ticks,
878        l2_cum_b,
879        l2_cum_i,
880        x_scatter_pct,
881        x[0],
882        x[1],
883        x[2],
884        x[3],
885        x[4],
886        x[5],
887        x[6],
888        x[7],
889        final_stats.nr_osc_park,
890        final_stats.nr_steal,
891        final_stats.nr_spill_kick_preempt,
892        final_stats.nr_kick_declined,
893        final_stats.nr_stay_cost_held,
894        final_stats.nr_stay_move_taken,
895        final_stats.nr_dispatches,
896    );
897
898    // READ UEI EXIT REASON
899    let should_restart = sched.read_exit_info();
900    Ok(should_restart)
901}