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}