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, detect_regime, scaled_regime_knobs, Regime, HIST_BUCKETS};
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 if r1.value > 0.0 {
215 let tighten = Priced {
216 value: 1.0 - 0.4 * r1.value.clamp(0.0, 1.0),
217 confidence: r1.confidence,
218 }
219 .weighted(1.0);
220 k.preempt_thresh_ns = ((k.preempt_thresh_ns as f64) * tighten) as u64;
221 }
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 phi_scale: Option<u64>,
301) -> Result<bool> {
302 // HOTPLUG POLL STATE: the online count as last observed. The poll below
303 // re-derives topology on change AND refreshes the Rust-local tau_ns --
304 // without that refresh, a hotplug that changes tau without flipping the
305 // regime left MWU computing every knob off pre-hotplug tau until a regime
306 // change happened to land (the stale-MWU-tau gap).
307 let mut last_online = CpuTopology::online_cpu_count();
308 let mut prev = PandemoniumStats::default();
309 let mut prev_hist = [[0u64; HIST_BUCKETS]; 3];
310 let mut prev_sleep = [0u64; SLEEP_BUCKETS];
311 let mut regime = Regime::Mixed;
312
313 // CHAOS RAW WINDOWS. idle_pct DRIVES REGIME DETECTION; wakeup_rate
314 // DRIVES THE MWU CHAOS-TRANSITION PATHWAY (BANDT-POMPE IS ORDINAL,
315 // SO ABSOLUTE RATE SCALE DOES NOT MATTER -- THE PATTERN DOES).
316 let mut idle_win: RawWindow<CHAOS_WIN> = RawWindow::new();
317 let mut wake_win: RawWindow<CHAOS_WIN> = RawWindow::new();
318 // PER-CPU RUNQUEUE-DEPTH WINDOWS: THE NODES OF THE LIVE LOAD GRAPH.
319 // One series per CPU, each the interval-mean depth on that CPU, which is
320 // what rq_depth_sum/rq_depth_samples differenced gives. Until v5.18.0
321 // there was exactly ONE series in this whole loop -- a system-wide integer
322 // percentage -- which is why a coupling measure had nothing to couple.
323 let mut depth_win: Vec<RawWindow<CHAOS_WIN>> =
324 (0..nr_cpus as usize).map(|_| RawWindow::new()).collect();
325 let mut prev_percpu: Vec<PandemoniumStats> = Vec::new();
326 let mut prev_bp_h: f64 = 0.0;
327 let chaos_count = chaos::ChaosCounter::new();
328 let mut prev_lambda_above: bool = false;
329 // READ CURRENT tau SNAPSHOT FROM THE BPF-SIDE KNOB MAP. main.rs WROTE IT
330 // ONCE AT TOPOLOGY DETECT; THE ADAPTIVE LOOP RE-READS SO TAU-SCALED REGIME
331 // KNOBS AGREE WITH TAU-SCALED BPF INIT AT FIRST TICK AND EVERY REGIME CHANGE.
332 let mut tau_ns = sched.read_tuning_knobs().topology_tau_ns;
333 let mut pending_regime = regime;
334 let mut regime_hold: u32 = 0;
335 let mut light_ticks: u64 = 0;
336 let mut mixed_ticks: u64 = 0;
337 let mut heavy_ticks: u64 = 0;
338 // STABILITY SCORE IS A WEAK PRE-CHAOS STEADY-STATE PROXY, KEPT FOR
339 // TELEMETRY GATING ONLY -- THE REAL STEADY-STATE GATE
340 // IS `quiesce.frozen` BELOW. DO NOT WIRE stability_score INTO THE
341 // FREEZE DECISION (TWO COMPETING "AM I STEADY" SIGNALS = A BUG).
342 let mut stability_score: u32 = 0;
343 let mut tick_counter: u64 = 0;
344
345 // QUIESCENCE GATE + ADAPTIVE-RARITY RETUNE STATE. The gate latches
346 // a "frozen" flag from HVG-lambda + RQA-DET + MWU convergence and
347 // the loop then skips the expensive MWU retune + knob write. When
348 // not frozen, the retune interval stretches on sub-threshold deltas.
349 let mut quiesce = tuning::QuiescenceState::new();
350 let mut retune_interval: u32 = tuning::RETUNE_INTERVAL_BASE;
351 let mut ticks_since_retune: u32 = 0;
352 let mut frozen_ticks: u64 = 0;
353 // GRAPH SUMMARY ACCUMULATORS. The scheduler writes its own telemetry file
354 // at shutdown rather than relying on a bench to capture stdout: three
355 // separate benches discarded it three different ways (no recording dir, a
356 // path that bypassed the marker writer, and stdout sent to DEVNULL), and
357 // the coupling reading went unread each time. A producer that owns its own
358 // artifact cannot be defeated by a consumer's plumbing.
359 let mut g_tick: u64 = 0;
360 let mut g_edge_ticks: u64 = 0;
361 let mut g_edges_total: u64 = 0;
362 let mut g_mean_sum: f64 = 0.0;
363 let mut g_spread_sum: f64 = 0.0;
364 let mut g_spread_max: f64 = 0.0;
365
366 // APPLY INITIAL REGIME. scaled_regime_knobs RETURNS topology_tau_ns/codel_eq_ns=0;
367 // OVERLAY THE LIVE BPF VALUES SO THE FIRST WRITE DOESN'T CLOBBER WHAT
368 // write_topology_fields() PUT IN THE MAP. Mirrors the regime-change path at line 230.
369 let live = sched.read_tuning_knobs();
370 let mut rk = scaled_regime_knobs(regime, nr_cpus, tau_ns);
371 rk.topology_tau_ns = tau_ns;
372 rk.codel_eq_ns = live.codel_eq_ns;
373 sched.write_tuning_knobs(&rk)?;
374 // SEED THE MWU BASELINE WITH THE LIVE PHI EQUILIBRIUM AT TICK 0. new()
375 // BUILT mwu FROM scaled_regime_knobs (codel_eq_ns=0); set_baseline IS
376 // OTHERWISE ONLY CALLED ON A REGIME CHANGE. WITHOUT THIS SEED THE SOJOURN
377 // FLOOR (tuning.rs) FALLS BACK TO THE DEAD 4ms CONSTANT FOR ANY RUN WHOSE
378 // REGIME NEVER CHANGES (E.G. A STEADY MIXED BENCH), SO THE PHI-COHERENT
379 // FLOOR WOULD NEVER ENGAGE.
380 // COMMIT-ON-CHANGE BASELINE: the last knob set actually written to
381 // the BPF map. Updated here at init, on every regime-change write,
382 // and on every conditional write. MWU-owned fields drive the diff.
383 let mut last_written_knobs = rk;
384
385 while !shutdown.load(Ordering::Relaxed) && !sched.exited() {
386 crate::watchdog::LOOP_HEARTBEAT.fetch_add(1, Ordering::Relaxed);
387 std::thread::sleep(Duration::from_secs(1));
388
389 if CpuTopology::poll_hotplug(sched, nr_cpus as usize, phi_scale, &mut last_online) {
390 tau_ns = sched.read_tuning_knobs().topology_tau_ns;
391 }
392
393 // THE ARRAY, UNCOLLAPSED. `stats` below is the same fold this loop
394 // always consumed; `percpu` is the spatial dimension that used to be
395 // discarded at the boundary. Both come from ONE syscall.
396 let percpu = sched.read_stats_percpu();
397 let stats = Scheduler::fold_stats(&percpu);
398 let cur_hist = sched.read_wake_lat_hist();
399 let cur_sleep = sched.read_sleep_hist();
400
401 // WRAP GUARD: BPF RELOAD, UEI RECOVERY, OR HOTPLUG CAN RESET KERNEL-SIDE
402 // CUMULATIVE COUNTERS WHILE RUST'S PREV STILL HOLDS OLD VALUES. WITHOUT
403 // THIS CHECK, WRAPPING_SUB PRODUCES A GARBAGE POSITIVE DELTA THAT POISONS
404 // P99 AND FEEDS NONSENSE TO MWU. RESET BASELINE AND SKIP THE TICK.
405 let mut wrapped = stats.nr_dispatches < prev.nr_dispatches;
406 if !wrapped {
407 'wrap: for tier in 0..3 {
408 for b in 0..HIST_BUCKETS {
409 if cur_hist[tier][b] < prev_hist[tier][b] {
410 wrapped = true;
411 break 'wrap;
412 }
413 }
414 }
415 }
416 if !wrapped {
417 for i in 0..SLEEP_BUCKETS {
418 if cur_sleep[i] < prev_sleep[i] {
419 wrapped = true;
420 break;
421 }
422 }
423 }
424 if wrapped {
425 log_warn!("WRAP DETECTED: BASELINE RESET, SKIPPING ADAPTIVE UPDATE");
426 prev = stats;
427 prev_hist = cur_hist;
428 prev_sleep = cur_sleep;
429 continue;
430 }
431
432 // COMPUTE DELTAS
433 let delta_d = stats.nr_dispatches.wrapping_sub(prev.nr_dispatches);
434 let delta_idle = stats.nr_idle_hits.wrapping_sub(prev.nr_idle_hits);
435 let delta_shared = stats.nr_shared.wrapping_sub(prev.nr_shared);
436 let delta_preempt = stats.nr_preempt.wrapping_sub(prev.nr_preempt);
437 let delta_keep = stats.nr_keep_running.wrapping_sub(prev.nr_keep_running);
438 let delta_parks = stats.nr_osc_park.wrapping_sub(prev.nr_osc_park);
439 let delta_wake_sum = stats.wake_lat_sum.wrapping_sub(prev.wake_lat_sum);
440 let delta_wake_samples = stats.wake_lat_samples.wrapping_sub(prev.wake_lat_samples);
441 let delta_hard = stats.nr_hard_kicks.wrapping_sub(prev.nr_hard_kicks);
442 let delta_soft = stats.nr_soft_kicks.wrapping_sub(prev.nr_soft_kicks);
443 let delta_steal = stats.nr_steal.wrapping_sub(prev.nr_steal);
444 let delta_enq_wake = stats.nr_enq_wakeup.wrapping_sub(prev.nr_enq_wakeup);
445 let delta_enq_requeue = stats.nr_enq_requeue.wrapping_sub(prev.nr_enq_requeue);
446 let delta_rescue = stats
447 .nr_overflow_rescue
448 .wrapping_sub(prev.nr_overflow_rescue);
449 // CROSS-DOMAIN SCATTER (PATHWAY 6 INPUT). PLACEMENT-SIDE PATHS ONLY:
450 // XDOM_SEL_* + XDOM_ENQ_T1/T2 (INDICES 0..6). THE PHI-CORRECT WORK-
451 // CONSERVATION PATHS XDOM_STEAL (6) AND XDOM_STEP5 (7) ARE EXCLUDED --
452 // PENALIZING THEM WOULD MAKE MWU FIGHT THE BPF'S DELIBERATE REBALANCING.
453 // saturating_sub ABSORBS A COUNTER RESET (BPF RELOAD) AS 0, NO GARBAGE.
454 let wake_avg_us = if delta_wake_samples > 0 {
455 delta_wake_sum / delta_wake_samples / 1000
456 } else {
457 0
458 };
459
460 // PER-PATH LATENCY
461 let d_idle_sum = stats.wake_lat_idle_sum.wrapping_sub(prev.wake_lat_idle_sum);
462 let d_idle_cnt = stats.wake_lat_idle_cnt.wrapping_sub(prev.wake_lat_idle_cnt);
463 let d_kick_sum = stats.wake_lat_kick_sum.wrapping_sub(prev.wake_lat_kick_sum);
464 let d_kick_cnt = stats.wake_lat_kick_cnt.wrapping_sub(prev.wake_lat_kick_cnt);
465 let lat_idle_us = if d_idle_cnt > 0 {
466 d_idle_sum / d_idle_cnt / 1000
467 } else {
468 0
469 };
470 let lat_kick_us = if d_kick_cnt > 0 {
471 d_kick_sum / d_kick_cnt / 1000
472 } else {
473 0
474 };
475 let delta_reenq = stats.nr_reenqueue.wrapping_sub(prev.nr_reenqueue);
476
477 // L2 CACHE AFFINITY DELTAS
478 let dl2_hb = stats.nr_l2_hit_batch.wrapping_sub(prev.nr_l2_hit_batch);
479 let dl2_mb = stats.nr_l2_miss_batch.wrapping_sub(prev.nr_l2_miss_batch);
480 let dl2_hi = stats
481 .nr_l2_hit_interactive
482 .wrapping_sub(prev.nr_l2_hit_interactive);
483 let dl2_mi = stats
484 .nr_l2_miss_interactive
485 .wrapping_sub(prev.nr_l2_miss_interactive);
486 let l2_pct_b = if dl2_hb + dl2_mb > 0 {
487 dl2_hb * 100 / (dl2_hb + dl2_mb)
488 } else {
489 0
490 };
491 let l2_pct_i = if dl2_hi + dl2_mi > 0 {
492 dl2_hi * 100 / (dl2_hi + dl2_mi)
493 } else {
494 0
495 };
496
497 let idle_pct = if delta_d > 0 {
498 delta_idle * 100 / delta_d
499 } else {
500 0
501 };
502
503 // COMPUTE HISTOGRAM DELTAS (cur_hist READ AT TOP FOR WRAP GUARD)
504 let mut delta_hist = [[0u64; HIST_BUCKETS]; 3];
505 for tier in 0..3 {
506 for b in 0..HIST_BUCKETS {
507 delta_hist[tier][b] = cur_hist[tier][b] - prev_hist[tier][b];
508 }
509 }
510
511 // COMPUTE P99 PER TIER
512 let tp99_b_ns = tuning::compute_p99_from_histogram(&delta_hist[0]);
513 let tp99_i_ns = tuning::compute_p99_from_histogram(&delta_hist[1]);
514 let tp99_l_ns = tuning::compute_p99_from_histogram(&delta_hist[2]);
515
516 // AGGREGATE P99
517 let mut agg = [0u64; HIST_BUCKETS];
518 for t in 0..3 {
519 for b in 0..HIST_BUCKETS {
520 agg[b] += delta_hist[t][b];
521 }
522 }
523 let p99_ns = tuning::compute_p99_from_histogram(&agg);
524
525 // SLEEP HISTOGRAM DELTAS (cur_sleep READ AT TOP FOR WRAP GUARD)
526 let mut delta_sleep = [0u64; SLEEP_BUCKETS];
527 for i in 0..SLEEP_BUCKETS {
528 delta_sleep[i] = cur_sleep[i] - prev_sleep[i];
529 }
530 let sleep_total: u64 = delta_sleep.iter().sum();
531 let io_pct = if sleep_total > 0 {
532 (delta_sleep[0] + delta_sleep[1]) * 100 / sleep_total
533 } else {
534 0
535 };
536
537 // CHAOS UPDATE: PUSH RAW SAMPLES INTO WINDOWS BEFORE COMPUTING
538 // ANY DERIVED FEATURES. WAKE WINDOW USES THE PER-SECOND DELTA
539 // (delta_enq_wake) RATHER THAN AN INSTANTANEOUS RATE.
540 idle_win.push(idle_pct as f64);
541 wake_win.push(delta_enq_wake as f64);
542
543 // NODE WEIGHTS: MEAN RUNQUEUE DEPTH PER CPU OVER THIS INTERVAL.
544 // Both fields are monotonic, so the interval mean is the ratio of the
545 // two deltas -- the same shape wake_lat_sum/wake_lat_samples is already
546 // consumed with. A CPU whose sample count did not advance contributes
547 // nothing rather than a zero: no samples is not an empty queue.
548 if prev_percpu.len() == percpu.len() {
549 for (i, w) in depth_win.iter_mut().enumerate() {
550 if i >= percpu.len() {
551 break;
552 }
553 let ds = percpu[i]
554 .rq_depth_samples
555 .wrapping_sub(prev_percpu[i].rq_depth_samples);
556 if ds > 0 {
557 let dd = percpu[i]
558 .rq_depth_sum
559 .wrapping_sub(prev_percpu[i].rq_depth_sum);
560 w.push(dd as f64 / ds as f64);
561 }
562 }
563 }
564 prev_percpu = percpu.clone();
565
566 // CHAOS PRIMITIVES. ONE O(N^2) HVG PASS + ONE O(N^2) RQA PASS
567 // PER WINDOW; BP IS O(N). RQA-DET RUNS ON THE SAME idle_win AS
568 // HVG SO THE QUIESCENCE GATE SEES IDENTICAL SAMPLES. rqa IS
569 // None UNTIL THE WINDOW HAS RQA_MIN_SAMPLES FILLED.
570 let (idle_lambda, _idle_hvg_s) = chaos::hvg_stats(&idle_win);
571 let wake_bp_h = chaos::bandt_pompe_d3(&wake_win);
572
573 // BUILD THE LIVE LOAD GRAPH. Measured and reported before anything
574 // prices against it: if the coupling spread is ~0 the graph is
575 // complete-and-uniform and carries no structure the static topology
576 // does not already have. Nothing downstream consumes it yet -- this
577 // tick is the falsification, not the feature.
578 let graph = LoadGraph::build(&depth_win);
579 let (g_edges, g_mean, g_min, g_max) = graph.edge_summary();
580 // OSCILLATOR POSITION, READ NOT RE-DERIVED. The BPF damped oscillator owns
581 // codel_target_ns; this reads where it currently sits (0.0 floor/tightened,
582 // 1.0 max/relaxed) so the layer can SEE the target it must not fight. The
583 // reader existed with no consumer, which meant the one piece of state that
584 // says whether the BPF has already responded was unobservable from up here.
585 // Reporting only -- the defer gate that would ACT on it is still open.
586 let osc = sched.read_oscillator_state();
587 let osc_pos = osc.position();
588 // The Phi release point the warm-stay and STEP-1 steal actually let a task
589 // leave home at: codel_target plus the nearest-peer hold. Reported beside
590 // the raw position because position() alone cannot say whether the BPF has
591 // genuinely responded -- that comparison is the defer gate, still open.
592 let osc_rel_us = osc.effective_release_ns() / 1000;
593 g_tick += 1;
594 if g_edges > 0 {
595 g_edge_ticks += 1;
596 g_edges_total += g_edges as u64;
597 g_mean_sum += g_mean;
598 let spread = g_max - g_min;
599 g_spread_sum += spread;
600 if spread > g_spread_max {
601 g_spread_max = spread;
602 }
603 }
604 // SPILL-Phi CHAOS->TEMPERATURE BRIDGE: OVERLAID ONTO EVERY KNOB
605 // WRITE BELOW LIKE topology_tau_ns; INERT IN BPF UNTIL THE SPILL
606 // PRICE CONSUMES IT.
607 let bp_delta = wake_bp_h - prev_bp_h;
608 let mean_idle = chaos::mean(&idle_win);
609 let rqa = chaos::rqa_det(&idle_win);
610
611 // CHAOS CROSSING DIAGNOSTIC: BUMP COUNTER ON EITHER GATE FIRING.
612 // chaos_crossing IS ALSO REUSED BELOW AS THE ADAPTIVE-RARITY
613 // "disturbed" SIGNAL -- CAPTURE IT BEFORE prev_lambda_above IS
614 // OVERWRITTEN.
615 let lambda_above = idle_lambda >= chaos::HVG_LAMBDA_CHAOTIC_MIN;
616 let chaos_crossing = (lambda_above && !prev_lambda_above) || bp_delta > 0.10;
617 if chaos_crossing {
618 chaos_count.bump();
619 }
620 prev_lambda_above = lambda_above;
621
622 // DETECT REGIME (CHAOS-DRIVEN + 2-TICK HOLD)
623 let detected = detect_regime(mean_idle, idle_lambda, wake_bp_h);
624
625 let mut regime_changed_this_tick = false;
626 if detected != regime {
627 if detected == pending_regime {
628 regime_hold += 1;
629 } else {
630 pending_regime = detected;
631 regime_hold = 1;
632 }
633 if regime_hold >= 2 {
634 regime = detected;
635 // REFRESH tau IN CASE HOTPLUG/TOPOLOGY CHANGED.
636 // scaled_regime_knobs RETURNS topology_tau_ns/codel_eq_ns=0;
637 // OVERLAY THE LIVE BPF VALUES (BOTH OWNED BY TOPOLOGY LAYER).
638 let live = sched.read_tuning_knobs();
639 tau_ns = live.topology_tau_ns;
640 let mut rk = scaled_regime_knobs(regime, nr_cpus, tau_ns);
641 rk.topology_tau_ns = tau_ns;
642 rk.codel_eq_ns = live.codel_eq_ns;
643 // Regime baseline, spread per CPU by the graph on the same
644 // terms as the retune path below.
645 sched.write_tuning_knobs_percpu(&graph.derive_percpu_knobs(&rk))?;
646 last_written_knobs = rk;
647 regime_changed_this_tick = true;
648 // RESET ONLY THE NEW REGIME'S WEIGHT VECTOR + EDGE STATE
649 // (THE OTHER REGIMES KEEP THEIR LEARNED VECTORS), AND
650 // SNAP THE ADAPTIVE-RARITY INTERVAL BACK TO BASE.
651 retune_interval = tuning::RETUNE_INTERVAL_BASE;
652 ticks_since_retune = 0;
653 }
654 } else {
655 pending_regime = regime;
656 regime_hold = 0;
657 }
658
659 // QUIESCENCE GATE. HVG-lambda in the periodic band + RQA-DET
660 // steady + the active-regime MWU vector converged -> latch
661 // `frozen` and skip the expensive MWU retune + knob write. The
662 // loop still ticks at 1 Hz; the chaos sensors above are the
663 // exit condition for frozen mode. A regime change moves lambda
664 // out of the steady band, so the gate thaws on the same/next
665 // tick -- the two gates compose without conflict.
666 // The freeze gate's third term was MWU convergence. With no learner
667 // there is nothing to converge, so the gate is the chaos band alone.
668 let frozen = quiesce.update(idle_lambda, rqa, true);
669 if frozen {
670 frozen_ticks += 1;
671 }
672
673 // MWU ORCHESTRATOR: UNIFIED KNOB CONTROL
674 // GATED BY !regime_changed_this_tick (a fresh regime already
675 // wrote its baseline) AND !frozen (steady state -- stop the
676 // machinery). When neither gate is set, the adaptive-rarity
677 // counter throttles how often the retune actually fires.
678 if !regime_changed_this_tick && !frozen {
679 ticks_since_retune += 1;
680 if ticks_since_retune >= retune_interval {
681 ticks_since_retune = 0;
682 // KNOBS COME FROM THE REGIME BASELINE, SPREAD BY THE GRAPH.
683 // No expert weights, no loss pathways, no convergence window:
684 // the baseline is a function of regime and tau, and per-CPU
685 // slice pressure is a function of measured queue depth. Both
686 // are computed this tick from this tick's data.
687 let live = sched.read_tuning_knobs();
688 let mut knobs = scaled_regime_knobs(regime, nr_cpus, tau_ns);
689 knobs.topology_tau_ns = live.topology_tau_ns;
690 knobs.codel_eq_ns = live.codel_eq_ns;
691 // COMMIT-ON-CHANGE: only push when a field actually moved. The
692 // BPF side reads the map unsynchronized, so skipping redundant
693 // writes strictly reduces torn-read exposure.
694 let changed = tuning::knobs_differ(&knobs, &last_written_knobs);
695 if changed {
696 sched.write_tuning_knobs_percpu(&graph.derive_percpu_knobs(&knobs))?;
697 last_written_knobs = knobs;
698 }
699 let disturbed = chaos_crossing;
700 retune_interval =
701 tuning::next_retune_interval(retune_interval, !changed, disturbed);
702 }
703 }
704
705 // STABILITY TRACKING
706 let tighten_delta = if chaos_crossing { 1u64 } else { 0u64 };
707 stability_score = tuning::compute_stability_score(
708 stability_score,
709 regime_changed_this_tick,
710 tighten_delta,
711 p99_ns,
712 regime.p99_ceiling(),
713 );
714
715 let p99_us = p99_ns / 1000;
716 let tp99_b = tp99_b_ns / 1000;
717 let tp99_i = tp99_i_ns / 1000;
718 let tp99_l = tp99_l_ns / 1000;
719 let knobs = sched.read_tuning_knobs();
720
721 let sojourn_ms = stats.batch_sojourn_ns / 1_000_000;
722 let sojourn_thresh_ms = knobs.codel_thresh_ns / 1_000_000;
723 let longrun_label = if stats.longrun_mode_active > 0 {
724 " LONGRUN"
725 } else {
726 ""
727 };
728
729 if verbose && tuning::should_print_telemetry(tick_counter, stability_score) {
730 let rqa_disp = rqa.unwrap_or(-1.0);
731 let frozen_disp = if frozen { 1 } else { 0 };
732 println!(
733 "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",
734 delta_d, idle_pct, delta_shared, delta_preempt, delta_keep,
735 delta_hard, delta_soft, delta_enq_wake, delta_enq_requeue,
736 wake_avg_us, p99_us, tp99_b, tp99_i, tp99_l,
737 lat_idle_us, lat_kick_us,
738 io_pct, knobs.slice_ns / 1000, knobs.batch_slice_ns / 1000,
739 delta_reenq, sojourn_ms, sojourn_thresh_ms,
740 delta_rescue,
741 l2_pct_b, l2_pct_i,
742 idle_lambda, wake_bp_h, rqa_disp, chaos_count.load(),
743 frozen_disp, frozen_ticks, retune_interval,
744 graph.n, g_edges, g_mean, g_min, g_max,
745 osc_pos, osc_rel_us,
746 regime.label(), longrun_label,
747 );
748 }
749
750 sched.log.snapshot(
751 delta_d,
752 delta_idle,
753 delta_shared,
754 delta_preempt,
755 delta_keep,
756 delta_parks,
757 wake_avg_us,
758 delta_hard,
759 delta_soft,
760 lat_idle_us,
761 lat_kick_us,
762 delta_steal,
763 );
764
765 match regime {
766 Regime::Light => light_ticks += 1,
767 Regime::Mixed => mixed_ticks += 1,
768 Regime::Heavy => heavy_ticks += 1,
769 }
770
771 tick_counter += 1;
772 prev_hist = cur_hist;
773 prev_sleep = cur_sleep;
774 prev = stats;
775 prev_bp_h = wake_bp_h;
776 }
777
778 // KNOBS SUMMARY: CAPTURED BY TEST HARNESS FOR ARCHIVE
779 let final_knobs = sched.read_tuning_knobs();
780 let final_stats = sched.read_stats();
781 let l2_total_b = final_stats.nr_l2_hit_batch + final_stats.nr_l2_miss_batch;
782 let l2_total_i = final_stats.nr_l2_hit_interactive + final_stats.nr_l2_miss_interactive;
783 let l2_cum_b = if l2_total_b > 0 {
784 final_stats.nr_l2_hit_batch * 100 / l2_total_b
785 } else {
786 0
787 };
788 let l2_cum_i = if l2_total_i > 0 {
789 final_stats.nr_l2_hit_interactive * 100 / l2_total_i
790 } else {
791 0
792 };
793 // CROSS-DOMAIN SCATTER ATTRIBUTION (PER XDOM_* PATH). scatter_pct IS THE
794 // PLACEMENT-SIDE FRACTION (idx 0..6) PATHWAY 6 ACTS ON; THE PER-PATH COUNTS
795 // ARE THE PERMANENT ATTRIBUTION SURFACED TO THE BENCH SUITE EVERY RUN.
796 let x = &final_stats.nr_cross_domain;
797 let x_scatter: u64 = x[0..6].iter().sum();
798 let x_scatter_pct = if final_stats.nr_dispatches > 0 {
799 x_scatter * 100 / final_stats.nr_dispatches
800 } else {
801 0
802 };
803 // osc_park IS THE OSCILLATOR ENVELOPE'S OWN COLLAPSE DETECTOR: ZERO PARKS
804 // AFTER AN IDLE-HEAVY RUN MEANS THE ENVELOPE NEVER WENT QUIET, WHICH IS THE
805 // MINIMUM-ATTENTION-COLLAPSE FAILURE MODE. IT WAS IN THE STATS STRUCT AND ON
806 // THE TERMINAL, BUT NOT ON THIS LINE, SO IT NEVER REACHED THE BENCH ARCHIVE
807 // OR THE PRISM .prom -- THE IDLE POWER QUESTION HAD NO COUNTER BEHIND IT.
808 // THE COUPLING READING, WRITTEN WHERE A BENCH CANNOT LOSE IT.
809 // edge_ticks == 0 is the falsification: no CPU pair ever produced a usable
810 // coupling value, so the live graph has nodes and no edges and there is
811 // nothing for a spectral price to solve over. That is a real answer and it
812 // must survive to disk to count as one.
813 {
814 let dir = std::path::Path::new("/tmp/pandemonium");
815 let _ = std::fs::create_dir_all(dir);
816 let denom = g_edge_ticks.max(1) as f64;
817 let body = format!(
818 "pandemonium_graph_ticks {}\n\
819 pandemonium_graph_edge_ticks {}\n\
820 pandemonium_graph_edges_mean {:.2}\n\
821 pandemonium_graph_coupling_mean {:.4}\n\
822 pandemonium_graph_coupling_spread_mean {:.4}\n\
823 pandemonium_graph_coupling_spread_max {:.4}\n\
824 pandemonium_chaos_frozen_ticks {}\n\
825 pandemonium_chaos_frozen_fraction {:.4}\n",
826 g_tick,
827 g_edge_ticks,
828 g_edges_total as f64 / denom,
829 g_mean_sum / denom,
830 g_spread_sum / denom,
831 g_spread_max,
832 frozen_ticks,
833 frozen_ticks as f64 / g_tick.max(1) as f64
834 );
835 let path = dir.join(format!("graph-{}.prom", std::process::id()));
836 let _ = std::fs::write(&path, body);
837 println!("[GRAPH] {}", path.display());
838 }
839
840 println!(
841 "[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={}",
842 regime.label(), final_knobs.slice_ns, final_knobs.batch_slice_ns,
843 final_knobs.preempt_thresh_ns,
844 0.0f64,
845 light_ticks, mixed_ticks, heavy_ticks, frozen_ticks,
846 l2_cum_b, l2_cum_i,
847 x_scatter_pct, x[0], x[1], x[2], x[3], x[4], x[5], x[6], x[7],
848 final_stats.nr_osc_park,
849 );
850
851 // READ UEI EXIT REASON
852 let should_restart = sched.read_exit_info();
853 Ok(should_restart)
854}
855
856// THE LIVE LOAD GRAPH SEES STRUCTURE THAT THE STATIC TOPOLOGY CANNOT
857//
858// The static chip graph is a constant: two CPUs in the same cache domain are
859// always "close", whatever the workload does. These build a graph from
860// synthetic per-CPU depth series where the COUPLING is known by construction
861// and the wiring is irrelevant, and require the graph to report it. A build
862// that returned a flat matrix would still produce a plausible telemetry line,
863// which is exactly why the discriminating case is asserted rather than eyeballed.
864#[cfg(test)]
865mod load_graph_tests {
866 use super::*;
867
868 fn lcg(s: &mut u64) -> f64 {
869 *s = s
870 .wrapping_mul(6364136223846793005)
871 .wrapping_add(1442695040888963407);
872 ((*s >> 33) as f64 / (1u64 << 31) as f64) - 0.5
873 }
874
875 fn win(vals: &[f64]) -> RawWindow<CHAOS_WIN> {
876 let mut w = RawWindow::new();
877 for v in vals {
878 w.push(*v);
879 }
880 w
881 }
882
883 #[test]
884 fn pair_index_is_a_bijection_over_the_upper_triangle() {
885 for n in 2..12usize {
886 let mut seen = vec![false; n * (n - 1) / 2];
887 for i in 0..n {
888 for j in (i + 1)..n {
889 let k = pair_index(n, i, j);
890 assert!(!seen[k], "n={n} collision at ({i},{j})");
891 seen[k] = true;
892 }
893 }
894 assert!(seen.iter().all(|s| *s), "n={n} left a hole");
895 }
896 }
897
898 #[test]
899 fn coupled_cpus_read_higher_than_independent_ones() {
900 // CPUs 0 and 1 share a driving signal; CPU 2 runs its own. No cache
901 // topology is involved -- this is structure the static graph cannot
902 // express, which is the entire claim.
903 let mut s = 4242u64;
904 let drive: Vec<f64> = (0..CHAOS_WIN).map(|_| 2.0 + lcg(&mut s)).collect();
905 let mut s2 = 99u64;
906 let indep: Vec<f64> = (0..CHAOS_WIN).map(|_| 2.0 + lcg(&mut s2)).collect();
907 let scaled: Vec<f64> = drive.iter().map(|v| 3.0 * v + 1.0).collect();
908
909 let g = LoadGraph::build(&[win(&drive), win(&scaled), win(&indep)]);
910 let c01 = g.edge[pair_index(3, 0, 1)].expect("0-1 must couple");
911 let c02 = g.edge[pair_index(3, 0, 2)].expect("0-2 must compute");
912 assert!(
913 c01 > c02,
914 "coupled pair {c01} must exceed independent pair {c02}"
915 );
916 assert!(c01 > 0.9, "an affine copy should read as slaved, got {c01}");
917 }
918
919 #[test]
920 fn a_flat_machine_produces_no_edges_not_fake_ones() {
921 // Every CPU pinned at the same depth: no dynamics, nothing to couple.
922 // Reporting perfect synchrony here would make an idle box look maximally
923 // structured, which is the most common state a desktop is in.
924 let g = LoadGraph::build(&[win(&[4.0; CHAOS_WIN]), win(&[4.0; CHAOS_WIN])]);
925 assert_eq!(g.edge_summary().0, 0, "a flat machine must yield no edges");
926 }
927
928 #[test]
929 fn edge_summary_spread_is_the_falsification() {
930 // A uniform-coupling machine has ~zero spread and the live graph adds
931 // nothing over the static one. A mixed machine must not.
932 let mut s = 7u64;
933 let a: Vec<f64> = (0..CHAOS_WIN).map(|_| 2.0 + lcg(&mut s)).collect();
934 let b: Vec<f64> = a.iter().map(|v| 2.0 * v).collect();
935 let mut s2 = 8u64;
936 let c: Vec<f64> = (0..CHAOS_WIN).map(|_| 2.0 + lcg(&mut s2)).collect();
937 let g = LoadGraph::build(&[win(&a), win(&b), win(&c)]);
938 let (n, _mean, lo, hi) = g.edge_summary();
939 assert_eq!(n, 3);
940 assert!(
941 hi - lo > 0.3,
942 "mixed machine should show spread, got {}",
943 hi - lo
944 );
945 }
946
947 #[test]
948 fn nodes_absent_where_a_cpu_has_no_series() {
949 // An offline or never-sampled CPU is ABSENT, not depth zero. Zero would
950 // read as a permanently idle CPU and drag every mean toward it.
951 let g = LoadGraph::build(&[win(&[1.0, 2.0, 3.0]), win(&[])]);
952 assert!(g.node_depth[0].is_finite());
953 assert!(
954 g.node_depth[1].is_nan(),
955 "an unsampled CPU must not read as 0"
956 );
957 }
958}
959
960// THE GRAPH ACTUALLY DRIVES THE KNOBS
961//
962// derive_percpu_knobs is the consumer LoadGraph exists for. These pin the two
963// properties that decide whether it is safe to have MWU stop owning the slice:
964// a machine with no structure must come out exactly where it went in, and a
965// machine WITH structure must come out different in the right direction.
966#[cfg(test)]
967mod derive_tests {
968 use super::*;
969 use crate::tuning::TuningKnobs;
970
971 fn base() -> TuningKnobs {
972 let mut k = TuningKnobs::default();
973 k.slice_ns = 1_000_000;
974 k.preempt_thresh_ns = 1_000_000;
975 k.codel_thresh_ns = 5_000_000;
976 k
977 }
978
979 fn win(vals: &[f64]) -> RawWindow<CHAOS_WIN> {
980 let mut w = RawWindow::new();
981 for v in vals {
982 w.push(*v);
983 }
984 w
985 }
986
987 #[test]
988 fn a_uniform_machine_is_a_no_op() {
989 // The safety property. Every CPU at the same depth must derive the base
990 // slice exactly -- otherwise turning this on changes behavior on
991 // machines that have no structure to exploit, which is most of them.
992 let g = LoadGraph::build(&[win(&[3.0; 8]), win(&[3.0; 8]), win(&[3.0; 8])]);
993 let b = base();
994 for k in g.derive_percpu_knobs(&b) {
995 assert_eq!(
996 k.slice_ns, b.slice_ns,
997 "uniform depth must not move the slice"
998 );
999 assert_eq!(k.preempt_thresh_ns, b.preempt_thresh_ns);
1000 }
1001 }
1002
1003 #[test]
1004 fn a_deep_queue_gets_a_shorter_slice_than_a_shallow_one() {
1005 // The whole point: one machine-wide slice is the average of a
1006 // distribution MWU could not see. CPU 0 is buried, CPU 2 is nearly idle.
1007 let g = LoadGraph::build(&[win(&[12.0; 8]), win(&[3.0; 8]), win(&[0.2; 8])]);
1008 let k = g.derive_percpu_knobs(&base());
1009 assert!(
1010 k[0].slice_ns < k[1].slice_ns,
1011 "deep queue {} must slice shorter than mid {}",
1012 k[0].slice_ns,
1013 k[1].slice_ns
1014 );
1015 assert!(
1016 k[1].slice_ns < k[2].slice_ns,
1017 "mid {} must slice shorter than shallow {}",
1018 k[1].slice_ns,
1019 k[2].slice_ns
1020 );
1021 }
1022
1023 #[test]
1024 fn derived_slices_stay_inside_the_clamp() {
1025 // One anomalous tick must not hand a CPU a slice far outside what the
1026 // regime profile intended. 2x either way, no further.
1027 let g = LoadGraph::build(&[win(&[1e9; 8]), win(&[0.0001; 8])]);
1028 let b = base();
1029 for k in g.derive_percpu_knobs(&b) {
1030 assert!(k.slice_ns >= b.slice_ns / 2, "under-clamp: {}", k.slice_ns);
1031 assert!(k.slice_ns <= b.slice_ns * 2, "over-clamp: {}", k.slice_ns);
1032 }
1033 }
1034
1035 #[test]
1036 fn fields_the_graph_does_not_own_are_carried_through() {
1037 let g = LoadGraph::build(&[win(&[9.0; 8]), win(&[1.0; 8])]);
1038 let b = base();
1039 for k in g.derive_percpu_knobs(&b) {
1040 assert_eq!(
1041 k.codel_thresh_ns, b.codel_thresh_ns,
1042 "the graph must not touch knobs it does not own"
1043 );
1044 }
1045 }
1046
1047 #[test]
1048 fn an_unsampled_cpu_keeps_the_base() {
1049 // NaN node -> no derivation. A CPU we have not measured must not be
1050 // handed a slice computed from a number we do not have.
1051 let g = LoadGraph::build(&[win(&[9.0; 8]), win(&[])]);
1052 let b = base();
1053 let k = g.derive_percpu_knobs(&b);
1054 assert_eq!(k[1].slice_ns, b.slice_ns);
1055 }
1056}
1057
1058// EACH SENSOR DERIVES ITS OWN KNOB
1059//
1060// The claim these defend is that the four node attributes are not redundant:
1061// burstiness is SHAPE, lag-1 is DIRECTION, Hurst is DURATION, depth is
1062// MAGNITUDE. If any two moved the same knob the same way, one of them would be
1063// a second name for the other and MWU's single loss signal would have been
1064// adequate after all. Each test isolates one sensor and requires the others to
1065// stay out of the way.
1066#[cfg(test)]
1067mod derivation_tests {
1068 use super::*;
1069 use crate::tuning::TuningKnobs;
1070
1071 fn base() -> TuningKnobs {
1072 let mut k = TuningKnobs::default();
1073 k.slice_ns = 1_000_000;
1074 k.preempt_thresh_ns = 1_000_000;
1075 k.batch_slice_ns = 20_000_000;
1076 k.burst_slice_ns = 1_000_000;
1077 k.codel_thresh_ns = 5_000_000;
1078 k
1079 }
1080
1081 fn win(vals: &[f64]) -> RawWindow<CHAOS_WIN> {
1082 let mut w = RawWindow::new();
1083 for v in vals {
1084 w.push(*v);
1085 }
1086 w
1087 }
1088
1089 fn lcg(s: &mut u64) -> f64 {
1090 *s = s
1091 .wrapping_mul(6364136223846793005)
1092 .wrapping_add(1442695040888963407);
1093 ((*s >> 33) as f64 / (1u64 << 31) as f64) - 0.5
1094 }
1095
1096 // Spiky: long quiet punctuated by clumps. Burstiness positive.
1097 fn bursty() -> Vec<f64> {
1098 let mut v = vec![0.05f64; CHAOS_WIN];
1099 v[3] = 30.0;
1100 v[11] = 40.0;
1101 v
1102 }
1103
1104 // Steady arrivals with tiny jitter. Burstiness negative.
1105 fn paced() -> Vec<f64> {
1106 let mut s = 3u64;
1107 (0..CHAOS_WIN).map(|_| 5.0 + 0.01 * lcg(&mut s)).collect()
1108 }
1109
1110 #[test]
1111 fn bursty_traffic_lengthens_the_batch_ceiling_paced_shortens_it() {
1112 let g = LoadGraph::build(&[win(&bursty()), win(&paced())]);
1113 let b = base();
1114 let k = g.derive_percpu_knobs(&b);
1115 assert!(
1116 k[0].batch_slice_ns > b.batch_slice_ns,
1117 "bursty CPU should widen the batch ceiling: {} vs {}",
1118 k[0].batch_slice_ns,
1119 b.batch_slice_ns
1120 );
1121 assert!(
1122 k[1].batch_slice_ns < b.batch_slice_ns,
1123 "paced CPU should tighten it: {} vs {}",
1124 k[1].batch_slice_ns,
1125 b.batch_slice_ns
1126 );
1127 }
1128
1129 #[test]
1130 fn critical_slowing_tightens_the_preempt_window() {
1131 // A ramp is maximally autocorrelated: r1 -> 1, the approach to
1132 // saturation. Compare against an oscillating CPU at the same mean
1133 // depth, where r1 < 0 and no tightening should apply.
1134 let ramp: Vec<f64> = (0..CHAOS_WIN).map(|i| 4.0 + i as f64 * 0.1).collect();
1135 let osc: Vec<f64> = (0..CHAOS_WIN)
1136 .map(|i| if i % 2 == 0 { 4.7 } else { 4.0 })
1137 .collect();
1138 let g = LoadGraph::build(&[win(&ramp), win(&osc)]);
1139 let k = g.derive_percpu_knobs(&base());
1140 assert!(
1141 k[0].preempt_thresh_ns < k[1].preempt_thresh_ns,
1142 "a CPU that is slowing critically must preempt sooner: {} vs {}",
1143 k[0].preempt_thresh_ns,
1144 k[1].preempt_thresh_ns
1145 );
1146 }
1147
1148 #[test]
1149 fn persistence_rescues_sooner_than_mean_reversion() {
1150 // Random walk: H -> 1, a deep queue will stay deep, rescue sooner.
1151 // Differenced noise: H -> 0, transient, do not scatter the task.
1152 let mut s = 7u64;
1153 let mut acc = 5.0;
1154 let walk: Vec<f64> = (0..CHAOS_WIN)
1155 .map(|_| {
1156 acc += lcg(&mut s);
1157 acc.abs() + 1.0
1158 })
1159 .collect();
1160 let mut s2 = 11u64;
1161 let noise: Vec<f64> = (0..CHAOS_WIN + 1).map(|_| lcg(&mut s2)).collect();
1162 let diff: Vec<f64> = (1..=CHAOS_WIN)
1163 .map(|i| 5.0 + (noise[i] - noise[i - 1]))
1164 .collect();
1165 let g = LoadGraph::build(&[win(&walk), win(&diff)]);
1166 let k = g.derive_percpu_knobs(&base());
1167 assert!(
1168 k[0].codel_thresh_ns < k[1].codel_thresh_ns,
1169 "persistent load should rescue sooner than mean-reverting: {} vs {}",
1170 k[0].codel_thresh_ns,
1171 k[1].codel_thresh_ns
1172 );
1173 }
1174
1175 #[test]
1176 fn the_sensors_move_different_knobs() {
1177 // The non-redundancy claim, asserted directly. Two CPUs with the SAME
1178 // mean depth but different shape must differ on the burst-derived
1179 // knobs and agree on the depth-derived slice.
1180 let mut s = 5u64;
1181 let a: Vec<f64> = {
1182 let mut v = vec![0.2f64; CHAOS_WIN];
1183 v[4] = 20.0;
1184 v
1185 };
1186 let mean_a: f64 = a.iter().sum::<f64>() / a.len() as f64;
1187 let b: Vec<f64> = (0..CHAOS_WIN)
1188 .map(|_| mean_a + 0.001 * lcg(&mut s))
1189 .collect();
1190 let g = LoadGraph::build(&[win(&a), win(&b)]);
1191 let k = g.derive_percpu_knobs(&base());
1192 // Within rounding: the ratio is a float and the two means differ in
1193 // the last bits. What matters is that SHAPE did not leak into the
1194 // depth-derived knob.
1195 let d = k[0].slice_ns.abs_diff(k[1].slice_ns);
1196 assert!(
1197 d * 10_000 < k[0].slice_ns,
1198 "equal mean depth must derive the same slice, got {} vs {}",
1199 k[0].slice_ns,
1200 k[1].slice_ns
1201 );
1202 assert_ne!(
1203 k[0].batch_slice_ns, k[1].batch_slice_ns,
1204 "different traffic shape must derive a different batch ceiling"
1205 );
1206 }
1207
1208 #[test]
1209 fn data_flows_below_the_old_gate() {
1210 // THE POINT OF PRICING. Three samples is below the old burstiness floor
1211 // of four. Under the gate the knob did not move at all -- the reading
1212 // was discarded. Now it lands, weighted by what the window is worth.
1213 let g = LoadGraph::build(&[win(&[0.1, 9.0, 0.1])]);
1214 let b = base();
1215 assert_ne!(
1216 g.derive_percpu_knobs(&b)[0].batch_slice_ns,
1217 b.batch_slice_ns,
1218 "a sub-floor window must still reach the knob"
1219 );
1220 }
1221
1222 #[test]
1223 fn hurst_stays_gated_because_its_floor_is_arithmetic() {
1224 // The one estimator that is NOT priced. Its floor is not statistical
1225 // thinness -- the wavelet fit needs three octaves, and a short window
1226 // yields two, which is a line through two points rather than a
1227 // regression. There is no weakly-known answer, so codel_thresh_ns holds.
1228 let g = LoadGraph::build(&[win(&[2.0, 3.0, 2.5])]);
1229 let b = base();
1230 assert_eq!(
1231 g.derive_percpu_knobs(&b)[0].codel_thresh_ns,
1232 b.codel_thresh_ns
1233 );
1234 }
1235
1236 #[test]
1237 fn a_uniform_machine_derives_uniformly_but_not_necessarily_the_base() {
1238 // THE SAFETY PROPERTY, CORRECTED. With only the depth derivation an
1239 // unstructured machine came out exactly where it went in. With four
1240 // sensors that is no longer true and should not be: a machine with no
1241 // SPATIAL structure can still have TEMPORAL structure -- every CPU
1242 // slowing critically at once is a real state, and deriving from it is
1243 // the entire point.
1244 //
1245 // What must still hold is that identical CPUs get identical knobs.
1246 // Divergence without structure would mean a sensor is reading noise.
1247 let p = paced();
1248 let g = LoadGraph::build(&[win(&p), win(&p), win(&p)]);
1249 let k = g.derive_percpu_knobs(&base());
1250 for w in k.windows(2) {
1251 assert_eq!(
1252 w[0].slice_ns, w[1].slice_ns,
1253 "identical CPUs diverged on slice"
1254 );
1255 assert_eq!(
1256 w[0].preempt_thresh_ns, w[1].preempt_thresh_ns,
1257 "identical CPUs diverged on preempt"
1258 );
1259 assert_eq!(
1260 w[0].batch_slice_ns, w[1].batch_slice_ns,
1261 "identical CPUs diverged on batch ceiling"
1262 );
1263 assert_eq!(
1264 w[0].codel_thresh_ns, w[1].codel_thresh_ns,
1265 "identical CPUs diverged on rescue threshold"
1266 );
1267 }
1268 }
1269}
1270
1271// AFFINITY IS NOT DERIVED FROM COUPLING
1272//
1273// It was, for one release candidate, and the first PRISM pass measured the
1274// cost. These pin the retreat: coupling is still READ, and it no longer moves
1275// the knob.
1276#[cfg(test)]
1277mod affinity_tests {
1278 use super::*;
1279 use crate::tuning::{TuningKnobs, AFFINITY_WEAK};
1280
1281 fn win(vals: &[f64]) -> RawWindow<CHAOS_WIN> {
1282 let mut w = RawWindow::new();
1283 for v in vals {
1284 w.push(*v);
1285 }
1286 w
1287 }
1288 fn lcg(s: &mut u64) -> f64 {
1289 *s = s
1290 .wrapping_mul(6364136223846793005)
1291 .wrapping_add(1442695040888963407);
1292 ((*s >> 33) as f64 / (1u64 << 31) as f64) - 0.5
1293 }
1294
1295 #[test]
1296 fn coupling_no_longer_moves_affinity() {
1297 // Two tightly coupled runqueues -- the IPC ping-pong shape that
1298 // regressed 16x when this derived. The knob must keep the base.
1299 let mut s = 21u64;
1300 let a: Vec<f64> = (0..CHAOS_WIN).map(|_| 4.0 + lcg(&mut s)).collect();
1301 let b: Vec<f64> = a.iter().map(|v| 2.0 * v + 1.0).collect();
1302 let g = LoadGraph::build(&[win(&a), win(&b)]);
1303 assert!(
1304 g.edge_summary().1 > 0.9,
1305 "the pair must still READ as coupled"
1306 );
1307 let mut base = TuningKnobs::default();
1308 base.affinity_mode = AFFINITY_WEAK;
1309 for k in g.derive_percpu_knobs(&base) {
1310 assert_eq!(
1311 k.affinity_mode, AFFINITY_WEAK,
1312 "affinity must carry the base through, not be derived"
1313 );
1314 }
1315 }
1316}