Skip to main content

scx_pandemonium/
scheduler.rs

1// PANDEMONIUM SCHEDULER
2// WRAPS THE BPF SKELETON: OPEN, CONFIGURE, LOAD, ATTACH, SHUTDOWN
3// MONITORING AND ADAPTIVE CONTROL LIVE IN adaptive.rs
4
5use std::mem::MaybeUninit;
6
7use anyhow::Result;
8use libbpf_rs::skel::{OpenSkel, SkelBuilder};
9use libbpf_rs::MapCore;
10
11use crate::bpf_skel::*;
12use crate::tuning::{OscillatorState, TuningKnobs};
13use scx_pandemonium::event::EventLog;
14
15// SCX EXIT CODES (FROM KERNEL)
16const SCX_EXIT_NONE: i32 = 0;
17const SCX_ECODE_RST_MASK: u64 = 1 << 16;
18
19// SCX DSQ FLAGS (STABLE KERNEL ABI -- sched_ext/sched.h)
20const SCX_DSQ_FLAG_BUILTIN: u64 = 1u64 << 63;
21const SCX_DSQ_FLAG_LOCAL_ON: u64 = 1u64 << 62;
22
23// MATCHES struct pandemonium_stats IN BPF (intf.h)
24#[repr(C)]
25#[derive(Default, Clone, Copy)]
26pub struct PandemoniumStats {
27    pub nr_dispatches: u64,
28    pub nr_idle_hits: u64,
29    pub nr_shared: u64,
30    pub nr_preempt: u64,
31    pub wake_lat_sum: u64,
32    pub wake_lat_samples: u64,
33    pub nr_keep_running: u64,
34    pub nr_hard_kicks: u64,
35    pub nr_soft_kicks: u64,
36    pub nr_enq_wakeup: u64,
37    pub nr_enq_requeue: u64,
38    pub wake_lat_idle_sum: u64,
39    pub wake_lat_idle_cnt: u64,
40    pub wake_lat_kick_sum: u64,
41    pub wake_lat_kick_cnt: u64,
42    pub nr_l2_hit_batch: u64,
43    pub nr_l2_miss_batch: u64,
44    pub nr_l2_hit_interactive: u64,
45    pub nr_l2_miss_interactive: u64,
46    pub nr_reenqueue: u64,
47    pub batch_sojourn_ns: u64,
48    pub longrun_mode_active: u64,
49    pub nr_overflow_rescue: u64,
50    // CROSS-DOMAIN SCATTER ATTRIBUTION (PER XDOM_* PATH) -- MATCHES nr_cross_domain[8] IN
51    // intf.h. PLACEMENT-SIDE PATHS FEED THE MWU SCATTER LOSS PATHWAY.
52    pub nr_cross_domain: [u64; 8],
53    // OSCILLATOR ENVELOPE PARK ENTRIES (CPU-0 TICK WRITER; intf.h nr_osc_park)
54    pub nr_osc_park: u64,
55    // SPILL-KICK PREEMPTS (select_cpu seat redirected off the idle pick onto a
56    // busy spill CPU; intf.h nr_spill_kick_preempt). Confirms the tick-floor fix.
57    pub nr_spill_kick_preempt: u64,
58    // TOTAL STEALS (intf.h nr_steal). Every successful STEP 1 peer move_to_local,
59    // same-domain included -- nr_cross_domain[XDOM_STEAL] counts only the cross-
60    // domain half, which on a two-domain box is the minority. Every steal is a
61    // migration by definition, so this is the dispatch side's share of the count.
62    pub nr_steal: u64,
63    // PER-CPU RUNNABLE DEPTH (intf.h rq_depth_sum / rq_depth_samples).
64    // Monotonic accumulators sampled at tick rate; difference BOTH across an
65    // interval and divide for the mean depth on that CPU, exactly as
66    // wake_lat_sum/wake_lat_samples are already consumed. Read per-CPU via
67    // read_stats_percpu() -- folding these to a total discards the spatial
68    // dimension that is the entire reason they exist.
69    pub rq_depth_sum: u64,
70    pub rq_depth_samples: u64,
71}
72
73// COMPILE-TIME ABI SAFETY: MUST MATCH STRUCT LAYOUTS IN intf.h
74// 184 (base, after the structurally empty latcrit l2 pair) + 8*8 (nr_cross_domain)
75// + 8 (nr_osc_park) + 8 (nr_spill_kick_preempt) + 8 (nr_steal) + 8 (rq_depth_sum)
76// + 8 (rq_depth_samples) = 288.
77const _: () = assert!(std::mem::size_of::<PandemoniumStats>() == 288);
78// 88 - 16 (lat_cri_thresh_high/_low, removed with the classifier that read them)
79// - 8 (spill_temp_q16, computed every tick and consumed by nothing).
80const _: () = assert!(std::mem::size_of::<TuningKnobs>() == 64);
81
82// MAX_AFFINITY_CANDIDATES IS DEFINED IN intf.h. THE RUST MIRROR IN
83// bpf_intf.rs MUST KEEP THE SAME VALUE; IF THE TWO SIDES DRIFT, THE
84// BPF MAP STRIDE AND THE RUST WRITER STRIDE DISAGREE AND THE TABLE
85// IS SILENTLY MIS-POPULATED.
86const _: () = assert!(crate::bpf_intf::MAX_AFFINITY_CANDIDATES == crate::bpf_intf::MAX_CPUS >> 3);
87
88// TuningKnobs LIVES IN tuning.rs (ZERO BPF DEPENDENCIES, TESTABLE OFFLINE)
89
90const KNOBS_PIN: &str = "/sys/fs/bpf/pandemonium/tuning_knobs";
91
92pub struct Scheduler<'a> {
93    skel: MainSkel<'a>,
94    _link: libbpf_rs::Link,
95    pub log: EventLog,
96}
97
98impl<'a> Scheduler<'a> {
99    pub fn init(
100        open_object: &'a mut MaybeUninit<libbpf_rs::OpenObject>,
101        nr_cpus_override: Option<u64>,
102    ) -> Result<Self> {
103        // OPEN
104        let builder = MainSkelBuilder::default();
105        let mut open_skel = builder.open(open_object)?;
106
107        // INJECT VERSION SUFFIX INTO OPS NAME FOR scx_loader GUI
108        {
109            let ops = open_skel.struct_ops.pandemonium_ops_mut();
110            let name_field = &mut ops.name;
111            let version_suffix = scx_utils::build_id::ops_version_suffix(env!("CARGO_PKG_VERSION"));
112            let bytes = version_suffix.as_bytes();
113            let mut i = 0;
114            let mut bytes_idx = 0;
115            let mut found_null = false;
116            while i < name_field.len() - 1 {
117                found_null |= name_field[i] == 0;
118                if !found_null {
119                    i += 1;
120                    continue;
121                }
122                if bytes_idx < bytes.len() {
123                    name_field[i] = bytes[bytes_idx] as i8;
124                    bytes_idx += 1;
125                } else {
126                    break;
127                }
128                i += 1;
129            }
130            name_field[i] = 0;
131        }
132
133        // CONFIGURE RODATA (BEFORE LOAD)
134        let rodata = open_skel.maps.rodata_data.as_mut().unwrap();
135
136        let possible = libbpf_rs::num_possible_cpus()? as u64;
137        rodata.nr_cpu_ids = nr_cpus_override.unwrap_or(possible);
138
139        // POPULATE SCX ENUM VALUES
140        rodata.__SCX_DSQ_FLAG_BUILTIN = SCX_DSQ_FLAG_BUILTIN;
141        rodata.__SCX_DSQ_FLAG_LOCAL_ON = SCX_DSQ_FLAG_LOCAL_ON;
142        rodata.__SCX_DSQ_INVALID = SCX_DSQ_FLAG_BUILTIN;
143        rodata.__SCX_DSQ_GLOBAL = SCX_DSQ_FLAG_BUILTIN | 1;
144        rodata.__SCX_DSQ_LOCAL = SCX_DSQ_FLAG_BUILTIN | SCX_DSQ_FLAG_LOCAL_ON;
145        rodata.__SCX_DSQ_LOCAL_ON = SCX_DSQ_FLAG_BUILTIN | SCX_DSQ_FLAG_LOCAL_ON | 1;
146        rodata.__SCX_DSQ_LOCAL_CPU_MASK = 0xFFFFFFFF;
147
148        // POPULATE SCX_KICK_* ENUM VALUES
149        rodata.__SCX_KICK_IDLE = 1;
150        rodata.__SCX_KICK_PREEMPT = 2;
151        rodata.__SCX_KICK_WAIT = 4;
152
153        // LOAD (VALIDATES BPF WITH KERNEL)
154        let mut skel = open_skel.load()?;
155
156        // ATTACH STRUCT_OPS
157        let link = skel.maps.pandemonium_ops.attach_struct_ops()?;
158
159        // PIN MAPS FOR USERSPACE ACCESS (NON-FATAL: bpffs MAY NOT BE MOUNTED)
160        let pin_dir = "/sys/fs/bpf/pandemonium";
161        let bpffs_ok = std::fs::create_dir_all(pin_dir).is_ok();
162        if bpffs_ok {
163            std::fs::remove_file(KNOBS_PIN).ok();
164            skel.maps.tuning_knobs_map.pin(KNOBS_PIN).ok();
165
166            let cache_pin = "/sys/fs/bpf/pandemonium/cache_domain";
167            std::fs::remove_file(cache_pin).ok();
168            skel.maps.cache_domain.pin(cache_pin).ok();
169        } else {
170            log_warn!("BPFFS NOT AVAILABLE: map pinning skipped (scheduler still functional)");
171        }
172
173        Ok(Self {
174            skel,
175            _link: link,
176            log: EventLog::new(),
177        })
178    }
179
180    // PER-CPU STATS, UNCOLLAPSED. The BPF side keeps one PandemoniumStats
181    // per CPU and lookup_percpu hands the whole array across the boundary --
182    // the syscall, the copy and the cache traffic are paid whether or not the
183    // structure survives. It did not: every field was summed into one scalar
184    // set inside the loop that first touched it, which destroyed the spatial
185    // dimension at the boundary and left the adaptive layer steering a machine
186    // it could only see one number of. Keeping the array costs zero additional
187    // bytes; it is the absence of a discard, not a new transfer.
188    //
189    // read_stats() below folds this into the same total it always returned, so
190    // the aggregate is DERIVED from the array rather than computed beside it
191    // and the two cannot drift.
192    pub fn read_stats_percpu(&self) -> Vec<PandemoniumStats> {
193        let key = 0u32.to_ne_bytes();
194        let percpu_vals = match self
195            .skel
196            .maps
197            .stats_map
198            .lookup_percpu(&key, libbpf_rs::MapFlags::ANY)
199        {
200            Ok(Some(v)) => v,
201            _ => return Vec::new(),
202        };
203        let mut out = Vec::with_capacity(percpu_vals.len());
204        for cpu_val in &percpu_vals {
205            if cpu_val.len() >= std::mem::size_of::<PandemoniumStats>() {
206                out.push(unsafe {
207                    std::ptr::read_unaligned(cpu_val.as_ptr() as *const PandemoniumStats)
208                });
209            }
210        }
211        out
212    }
213
214    // THE REDUCE. Separated from the read so the fold is testable without a
215    // live BPF map, and so every not-a-sum field is stated in one place:
216    // batch_sojourn_ns and longrun_mode_active take a MAX across CPUs (a
217    // system's worst sojourn is not the sum of its CPUs' sojourns), and
218    // nr_cross_domain is an 8-element per-path array summed elementwise.
219    // Returning the raw vec without honoring these would silently change
220    // behavior for every existing consumer.
221    pub fn fold_stats(percpu: &[PandemoniumStats]) -> PandemoniumStats {
222        let mut total = PandemoniumStats::default();
223        for stats in percpu {
224            {
225                total.nr_dispatches += stats.nr_dispatches;
226                total.nr_idle_hits += stats.nr_idle_hits;
227                total.nr_shared += stats.nr_shared;
228                total.nr_preempt += stats.nr_preempt;
229                total.wake_lat_sum += stats.wake_lat_sum;
230                total.wake_lat_samples += stats.wake_lat_samples;
231                total.nr_keep_running += stats.nr_keep_running;
232                total.nr_hard_kicks += stats.nr_hard_kicks;
233                total.nr_soft_kicks += stats.nr_soft_kicks;
234                total.nr_enq_wakeup += stats.nr_enq_wakeup;
235                total.nr_enq_requeue += stats.nr_enq_requeue;
236                total.wake_lat_idle_sum += stats.wake_lat_idle_sum;
237                total.wake_lat_idle_cnt += stats.wake_lat_idle_cnt;
238                total.wake_lat_kick_sum += stats.wake_lat_kick_sum;
239                total.wake_lat_kick_cnt += stats.wake_lat_kick_cnt;
240                total.nr_l2_hit_batch += stats.nr_l2_hit_batch;
241                total.nr_l2_miss_batch += stats.nr_l2_miss_batch;
242                total.nr_l2_hit_interactive += stats.nr_l2_hit_interactive;
243                total.nr_l2_miss_interactive += stats.nr_l2_miss_interactive;
244                total.nr_reenqueue += stats.nr_reenqueue;
245                if stats.batch_sojourn_ns > total.batch_sojourn_ns {
246                    total.batch_sojourn_ns = stats.batch_sojourn_ns;
247                }
248                if stats.longrun_mode_active > total.longrun_mode_active {
249                    total.longrun_mode_active = stats.longrun_mode_active;
250                }
251                total.nr_overflow_rescue += stats.nr_overflow_rescue;
252                for i in 0..8 {
253                    total.nr_cross_domain[i] += stats.nr_cross_domain[i];
254                }
255                total.nr_osc_park += stats.nr_osc_park;
256                total.nr_spill_kick_preempt += stats.nr_spill_kick_preempt;
257                total.nr_steal += stats.nr_steal;
258                // Folded so the aggregate stays complete, but the SUMMED value
259                // is close to meaningless -- it is the total depth seen across
260                // every CPU. The per-CPU pair is the point; read it from
261                // read_stats_percpu() and never from here.
262                total.rq_depth_sum += stats.rq_depth_sum;
263                total.rq_depth_samples += stats.rq_depth_samples;
264            }
265        }
266
267        total
268    }
269
270    // THE AGGREGATE VIEW, UNCHANGED FOR EVERY EXISTING CALLER. One syscall,
271    // one fold, byte-for-byte the same total this returned before the array
272    // was preserved.
273    pub fn read_stats(&self) -> PandemoniumStats {
274        Self::fold_stats(&self.read_stats_percpu())
275    }
276
277    // FIELDS THAT MUST BE IDENTICAL ON EVERY CPU.
278    //
279    // tuning_knobs_map is per-CPU, which makes divergence expressible -- and
280    // for these three it would be a defect rather than a feature. tau and
281    // codel_eq are topology-owned and drive tau-scaling, which every CPU
282    // re-derives from the same constant; affinity_mode decides how a TASK is
283    // placed, so a task would change placement depending on which CPU last
284    // looked at it.
285    //
286    // Broadcast rather than trusted: the writer overwrites these in every slot
287    // from slot 0, so a caller cannot diverge them by omission.
288    fn broadcast_global_fields(per_cpu: &mut [TuningKnobs]) {
289        let (first, rest) = match per_cpu.split_first_mut() {
290            Some(v) => v,
291            None => return,
292        };
293        for k in rest.iter_mut() {
294            k.topology_tau_ns = first.topology_tau_ns;
295            k.codel_eq_ns = first.codel_eq_ns;
296            k.affinity_mode = first.affinity_mode;
297        }
298    }
299
300    // WRITE ONE KNOB SET TO EVERY CPU -- CALLED BY MONITOR THREAD.
301    // Behaviorally identical to the pre-per-CPU map: every slot holds the same
302    // struct, so no CPU can observe a value another CPU does not.
303    pub fn write_tuning_knobs(&self, knobs: &TuningKnobs) -> Result<()> {
304        let n = libbpf_rs::num_possible_cpus()?;
305        self.write_tuning_knobs_percpu(&vec![*knobs; n])
306    }
307
308    // WRITE PER-CPU KNOBS. The per-CPU fields (slice, preempt window, batch and
309    // burst ceilings, the CoDel rescue threshold) may differ per slot; the six
310    // global fields are broadcast from slot 0 regardless of what the caller put
311    // in the others.
312    pub fn write_tuning_knobs_percpu(&self, per_cpu: &[TuningKnobs]) -> Result<()> {
313        let n = libbpf_rs::num_possible_cpus()?;
314        let mut vals: Vec<TuningKnobs> = (0..n)
315            .map(|i| per_cpu.get(i).copied().unwrap_or_else(|| per_cpu[0]))
316            .collect();
317        Self::broadcast_global_fields(&mut vals);
318        let key = 0u32.to_ne_bytes();
319        let sz = std::mem::size_of::<TuningKnobs>();
320        let bytes: Vec<Vec<u8>> = vals
321            .iter()
322            .map(|k| unsafe {
323                std::slice::from_raw_parts(k as *const TuningKnobs as *const u8, sz).to_vec()
324            })
325            .collect();
326        self.skel
327            .maps
328            .tuning_knobs_map
329            .update_percpu(&key, &bytes, libbpf_rs::MapFlags::ANY)?;
330        Ok(())
331    }
332
333    // WRITE TOPOLOGY-OWNED FIELDS (tau_ns + codel_eq_ns), PRESERVING OTHERS.
334    // CALLED AT TOPOLOGY DETECT AND ON HOTPLUG. READ-MODIFY-WRITE BECAUSE THE
335    // tuning_knobs_map IS A SINGLE-ENTRY STRUCT AND PARTIAL UPDATES AREN'T A
336    // libbpf CONCEPT -- BUT WE NEED A NARROW SETTER SO TOPOLOGY CHANGES DON'T
337    // STOMP ON WHATEVER THE ADAPTIVE LOOP'S LATEST KNOB VALUES ARE.
338    pub fn write_topology_fields(&self, tau_ns: u64, codel_eq_ns: u64) -> Result<()> {
339        let mut knobs = self.read_tuning_knobs();
340        knobs.topology_tau_ns = tau_ns;
341        knobs.codel_eq_ns = codel_eq_ns;
342        self.write_tuning_knobs(&knobs)
343    }
344
345    // READ BPF OSCILLATOR STATE FROM BSS/DATA SECTIONS.
346    //
347    // Unused since MWU was removed, and kept deliberately. MWU read this to
348    // avoid double-correcting: two controllers both adapting on
349    // global_rescue_count would fight. That hazard is GONE -- the BPF
350    // oscillator is now the only thing adapting on rescue count, and the graph
351    // derives from depth, shape and persistence instead. The accessor stays
352    // because the live-R_eff work reads the same maps.
353    // MWU GATES ITS RESCUE-DRIVEN PATHWAYS ON THIS SO IT DOESN'T
354    // DOUBLE-CORRECT WHEN THE BPF DAMPED OSCILLATOR HAS ALREADY MOVED.
355    pub fn read_oscillator_state(&self) -> OscillatorState {
356        let bss = match self.skel.maps.bss_data.as_ref() {
357            Some(b) => b,
358            None => return OscillatorState::default(),
359        };
360        let data = match self.skel.maps.data_data.as_ref() {
361            Some(d) => d,
362            None => return OscillatorState::default(),
363        };
364        OscillatorState {
365            codel_target_ns: bss.codel_target_ns,
366            codel_target_floor_ns: bss.codel_target_floor_ns,
367            codel_target_max_ns: data.codel_target_max_ns,
368            // NEAREST-PEER PHI HOLD WARM-STAY PRICES IN (SLOT 0 = CHEAPEST
369            // PEER, THE VALUE warm_stay_anchor READS FOR THE HOME CPU). CPU 0
370            // IS REPRESENTATIVE ON A HOMOGENEOUS TOPOLOGY.
371            home_dist_extra_ns: self.read_reff_value(0, 0) as u64,
372        }
373    }
374
375    // READ PER-CPU TUNING KNOBS. One entry per CPU; the six global fields are
376    // identical across slots by construction (broadcast_global_fields).
377    pub fn read_tuning_knobs_percpu(&self) -> Vec<TuningKnobs> {
378        let key = 0u32.to_ne_bytes();
379        let vals = match self
380            .skel
381            .maps
382            .tuning_knobs_map
383            .lookup_percpu(&key, libbpf_rs::MapFlags::ANY)
384        {
385            Ok(Some(v)) => v,
386            _ => return Vec::new(),
387        };
388        let sz = std::mem::size_of::<TuningKnobs>();
389        vals.iter()
390            .filter(|v| v.len() >= sz)
391            .map(|v| unsafe { std::ptr::read_unaligned(v.as_ptr() as *const TuningKnobs) })
392            .collect()
393    }
394
395    // READ CURRENT TUNING KNOBS -- CPU 0'S SLOT.
396    //
397    // Every existing caller wants either a global field (tau, codel_eq) or the
398    // value it just wrote uniformly, and both are identical on every CPU, so
399    // slot 0 is the same answer this returned before the map was per-CPU. A
400    // caller that needs a PER-CPU knob must use read_tuning_knobs_percpu() --
401    // this one cannot represent divergence and must not be used to look for it.
402    pub fn read_tuning_knobs(&self) -> TuningKnobs {
403        self.read_tuning_knobs_percpu()
404            .first()
405            .copied()
406            .unwrap_or_default()
407    }
408
409    // READ WAKEUP LATENCY HISTOGRAM: 3 TIERS x 12 BUCKETS
410    // SUMS ACROSS ALL CPUs (PERCPU_ARRAY). RETURNS CUMULATIVE COUNTS.
411    pub fn read_wake_lat_hist(&self) -> [[u64; 12]; 3] {
412        let mut result = [[0u64; 12]; 3];
413        for key_idx in 0u32..36 {
414            let key = key_idx.to_ne_bytes();
415            if let Ok(Some(percpu_vals)) = self
416                .skel
417                .maps
418                .wake_lat_hist
419                .lookup_percpu(&key, libbpf_rs::MapFlags::ANY)
420            {
421                let tier = (key_idx / 12) as usize;
422                let bucket = (key_idx % 12) as usize;
423                for cpu_val in &percpu_vals {
424                    if cpu_val.len() >= std::mem::size_of::<u64>() {
425                        let val: u64 =
426                            unsafe { std::ptr::read_unaligned(cpu_val.as_ptr() as *const u64) };
427                        result[tier][bucket] += val;
428                    }
429                }
430            }
431        }
432        result
433    }
434
435    // READ SLEEP DURATION HISTOGRAM: 4 BUCKETS
436    // SUMS ACROSS ALL CPUs (PERCPU_ARRAY). RETURNS CUMULATIVE COUNTS.
437    pub fn read_sleep_hist(&self) -> [u64; 4] {
438        let mut result = [0u64; 4];
439        for key_idx in 0u32..4 {
440            let key = key_idx.to_ne_bytes();
441            if let Ok(Some(percpu_vals)) = self
442                .skel
443                .maps
444                .sleep_hist
445                .lookup_percpu(&key, libbpf_rs::MapFlags::ANY)
446            {
447                for cpu_val in &percpu_vals {
448                    if cpu_val.len() >= std::mem::size_of::<u64>() {
449                        let val: u64 =
450                            unsafe { std::ptr::read_unaligned(cpu_val.as_ptr() as *const u64) };
451                        result[key_idx as usize] += val;
452                    }
453                }
454            }
455        }
456        result
457    }
458
459    // POPULATE CACHE DOMAIN MAP FROM TOPOLOGY DATA AT STARTUP
460    pub fn write_cache_domain(&self, cpu: u32, l2_group: u32) -> Result<()> {
461        let key = cpu.to_ne_bytes();
462        let val = l2_group.to_ne_bytes();
463        self.skel
464            .maps
465            .cache_domain
466            .update(&key, &val, libbpf_rs::MapFlags::ANY)?;
467        Ok(())
468    }
469
470    // POPULATE EMERGENT OVERFLOW-DOMAIN MAP (T3b.2). cpu_domain[cpu] = the
471    // emergent domain id from the T2 tree (the discrete domain map replacement).
472    pub fn write_cpu_domain(&self, cpu: u32, domain: u32) -> Result<()> {
473        let key = cpu.to_ne_bytes();
474        let val = domain.to_ne_bytes();
475        self.skel
476            .maps
477            .cpu_domain
478            .update(&key, &val, libbpf_rs::MapFlags::ANY)?;
479        Ok(())
480    }
481
482    // SET nr_overflow_domains (post-load mutable global). Walked from topology at startup.
483    // Gates how many of the MAX_OVERFLOW_DOMAINS per-domain overflow DSQs are addressed
484    // by dispatch drain loops.
485    pub fn write_nr_overflow_domains(&mut self, nr_overflow_domains: u32) {
486        if let Some(data) = self.skel.maps.data_data.as_mut() {
487            data.nr_overflow_domains = nr_overflow_domains;
488        }
489    }
490
491    // POPULATE L2 SIBLINGS MAP ENTRY
492    pub fn write_l2_sibling(&self, group_id: u32, slot: u32, cpu: u32) -> Result<()> {
493        let key = (group_id * 8 + slot).to_ne_bytes();
494        let val = cpu.to_ne_bytes();
495        self.skel
496            .maps
497            .l2_siblings
498            .update(&key, &val, libbpf_rs::MapFlags::ANY)?;
499        Ok(())
500    }
501
502    // POPULATE RESISTANCE AFFINITY RANK MAP
503    // affinity_rank[cpu * MAX_AFFINITY_CANDIDATES + slot] = target_cpu
504    // SORTED BY ASCENDING R_EFF FROM LAPLACIAN PSEUDOINVERSE
505    pub fn write_affinity_rank(&self, cpu: u32, slot: u32, target_cpu: u32) -> Result<()> {
506        // Stride = MAX_AFFINITY_CANDIDATES. Single source of truth is the
507        // C macro in src/bpf/intf.h, mirrored in bpf_intf.rs. The
508        // static_assert above catches drift at compile time.
509        let stride = crate::bpf_intf::MAX_AFFINITY_CANDIDATES;
510        let key = (cpu * stride + slot).to_ne_bytes();
511        let val = target_cpu.to_ne_bytes();
512        self.skel
513            .maps
514            .affinity_rank
515            .update(&key, &val, libbpf_rs::MapFlags::ANY)?;
516        Ok(())
517    }
518
519    // POPULATE R_eff COST ORACLE MAP (PAIRS 1:1 WITH affinity_rank).
520    // reff_value[cpu * MAX_AFFINITY_CANDIDATES + slot] = quantized R_eff TO THAT TARGET.
521    pub fn write_reff_value(&self, cpu: u32, slot: u32, value: u32) -> Result<()> {
522        let stride = crate::bpf_intf::MAX_AFFINITY_CANDIDATES;
523        let key = (cpu * stride + slot).to_ne_bytes();
524        let val = value.to_ne_bytes();
525        self.skel
526            .maps
527            .reff_value
528            .update(&key, &val, libbpf_rs::MapFlags::ANY)?;
529        Ok(())
530    }
531
532    // WRITE ONE spill_depth SLOT: THE PRE-FOLDED PHI PLACEMENT THRESHOLD (DSQ
533    // DEPTH) FOR THE PEER AT affinity_rank[cpu][slot]. THE SPILL HELPER READS IT
534    // AS THE PER-PEER DEPTH CAP -- THE PLACEMENT MIRROR OF reff_value's STEAL
535    // DELAY.
536    pub fn write_spill_depth(&self, cpu: u32, slot: u32, value: u32) -> Result<()> {
537        let stride = crate::bpf_intf::MAX_AFFINITY_CANDIDATES;
538        let key = (cpu * stride + slot).to_ne_bytes();
539        let val = value.to_ne_bytes();
540        self.skel
541            .maps
542            .spill_depth
543            .update(&key, &val, libbpf_rs::MapFlags::ANY)?;
544        Ok(())
545    }
546
547    // POPULATE EMERGENT-DOMAIN CROSSING-PRICE MAP (PAIRS 1:1 WITH affinity_rank).
548    // domain_phi[cpu * MAX_AFFINITY_CANDIDATES + slot] = (phi * 1e6) OF THE CUT
549    // SEPARATING cpu FROM THAT RANKED PEER. (u32)-1 = SAME LEAF / UNUSED SLOT.
550    pub fn write_domain_phi(&self, cpu: u32, slot: u32, value: u32) -> Result<()> {
551        let stride = crate::bpf_intf::MAX_AFFINITY_CANDIDATES;
552        let key = (cpu * stride + slot).to_ne_bytes();
553        let val = value.to_ne_bytes();
554        self.skel
555            .maps
556            .domain_phi
557            .update(&key, &val, libbpf_rs::MapFlags::ANY)?;
558        Ok(())
559    }
560
561    // READ ONE reff_value SLOT (ns PHI HOLD TO THAT RANKED PEER). RETURNS 0 ON
562    // MISS OR THE (u32)-1 UNUSED-SLOT SENTINEL. USED BY THE ADAPTIVE LOOP TO
563    // LEARN THE NEAREST-PEER HOLD WARM-STAY PRICES IN (SLOT 0 = CHEAPEST PEER).
564    pub fn read_reff_value(&self, cpu: u32, slot: u32) -> u32 {
565        let stride = crate::bpf_intf::MAX_AFFINITY_CANDIDATES;
566        let key = (cpu * stride + slot).to_ne_bytes();
567        match self
568            .skel
569            .maps
570            .reff_value
571            .lookup(&key, libbpf_rs::MapFlags::ANY)
572        {
573            Ok(Some(bytes)) if bytes.len() >= 4 => {
574                let v = u32::from_ne_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
575                if v == u32::MAX {
576                    0
577                } else {
578                    v
579                }
580            }
581            _ => 0,
582        }
583    }
584
585    // READ UEI EXIT INFO. RETURNS (should_restart).
586    pub fn read_exit_info(&self) -> bool {
587        let data = self.skel.maps.data_data.as_ref().unwrap();
588        let kind = data.uei.kind;
589        let exit_code = data.uei.exit_code;
590
591        if kind != SCX_EXIT_NONE {
592            let reason_bytes: &[u8] =
593                unsafe { std::slice::from_raw_parts(data.uei.reason.as_ptr() as *const u8, 128) };
594            let msg_bytes: &[u8] =
595                unsafe { std::slice::from_raw_parts(data.uei.msg.as_ptr() as *const u8, 1024) };
596
597            let reason = std::str::from_utf8(reason_bytes)
598                .unwrap_or("unknown")
599                .trim_end_matches('\0');
600            let msg = std::str::from_utf8(msg_bytes)
601                .unwrap_or("")
602                .trim_end_matches('\0');
603
604            log_warn!("BPF exit: kind={} code={}", kind, exit_code);
605            if !reason.is_empty() {
606                log_warn!("BPF exit reason: {}", reason);
607            }
608            if !msg.is_empty() {
609                log_warn!("BPF exit msg: {}", msg);
610            }
611        }
612
613        (exit_code as u64 & SCX_ECODE_RST_MASK) != 0
614    }
615
616    pub fn exited(&self) -> bool {
617        self.skel.maps.data_data.as_ref().unwrap().uei.kind != SCX_EXIT_NONE
618    }
619}
620
621impl Drop for Scheduler<'_> {
622    fn drop(&mut self) {
623        let _ = self.skel.maps.tuning_knobs_map.unpin(KNOBS_PIN);
624        let _ = self
625            .skel
626            .maps
627            .cache_domain
628            .unpin("/sys/fs/bpf/pandemonium/cache_domain");
629        let _ = std::fs::remove_dir("/sys/fs/bpf/pandemonium");
630    }
631}
632
633// THE FOLD'S NOT-A-SUM FIELDS
634//
635// read_stats() returns a total derived from the per-CPU array. Three fields
636// do not fold by addition, and getting any of them wrong changes numbers that
637// every consumer already trusts, silently and in the safe-looking direction:
638// a summed sojourn reads HIGHER than reality, a summed longrun flag reads as
639// a count rather than a boolean, and an elementwise array folded as a scalar
640// loses per-path attribution entirely. This pins all three against the shape
641// the code had before the array was preserved.
642#[cfg(test)]
643mod fold_tests {
644    use super::*;
645
646    fn cpu(dispatches: u64, sojourn: u64, longrun: u64, xdom: [u64; 8]) -> PandemoniumStats {
647        let mut s = PandemoniumStats::default();
648        s.nr_dispatches = dispatches;
649        s.batch_sojourn_ns = sojourn;
650        s.longrun_mode_active = longrun;
651        s.nr_cross_domain = xdom;
652        s
653    }
654
655    #[test]
656    fn counters_sum_across_cpus() {
657        let cpus = [
658            cpu(10, 0, 0, [0; 8]),
659            cpu(7, 0, 0, [0; 8]),
660            cpu(3, 0, 0, [0; 8]),
661        ];
662        assert_eq!(Scheduler::fold_stats(&cpus).nr_dispatches, 20);
663    }
664
665    #[test]
666    fn sojourn_takes_the_max_not_the_sum() {
667        // A system's worst batch sojourn is the worst any CPU saw, never the
668        // sum of what all of them saw. Summing here would report 900ms where
669        // the machine's actual worst wait was 500.
670        let cpus = [
671            cpu(0, 100, 0, [0; 8]),
672            cpu(0, 500, 0, [0; 8]),
673            cpu(0, 300, 0, [0; 8]),
674        ];
675        assert_eq!(Scheduler::fold_stats(&cpus).batch_sojourn_ns, 500);
676    }
677
678    #[test]
679    fn longrun_flag_takes_the_max_not_the_sum() {
680        // It is a mode flag. Summed across 20 CPUs it becomes a count and any
681        // consumer testing `> 0` still passes, which is exactly why this would
682        // survive review unnoticed.
683        let cpus = [
684            cpu(0, 0, 1, [0; 8]),
685            cpu(0, 0, 1, [0; 8]),
686            cpu(0, 0, 0, [0; 8]),
687        ];
688        assert_eq!(Scheduler::fold_stats(&cpus).longrun_mode_active, 1);
689    }
690
691    #[test]
692    fn cross_domain_folds_elementwise_per_path() {
693        let mut a = [0u64; 8];
694        let mut b = [0u64; 8];
695        a[0] = 5;
696        a[3] = 2;
697        b[0] = 1;
698        b[7] = 9;
699        let got = Scheduler::fold_stats(&[cpu(0, 0, 0, a), cpu(0, 0, 0, b)]).nr_cross_domain;
700        assert_eq!(got[0], 6, "path 0 must sum across CPUs");
701        assert_eq!(got[3], 2);
702        assert_eq!(got[7], 9);
703        assert_eq!(got[1], 0, "an untouched path stays zero");
704    }
705
706    #[test]
707    fn empty_array_folds_to_default() {
708        // A failed lookup returns an empty vec; the fold of nothing must be
709        // the same zeroed struct the old early-return produced.
710        let got = Scheduler::fold_stats(&[]);
711        assert_eq!(got.nr_dispatches, 0);
712        assert_eq!(got.batch_sojourn_ns, 0);
713    }
714}
715
716// GLOBAL-FIELD COHERENCE ACROSS THE PER-CPU KNOB MAP
717//
718// Making the knob map per-CPU makes divergence EXPRESSIBLE, and for six of the
719// eleven fields divergence is a defect rather than a feature: tau and codel_eq
720// are topology-owned and every CPU re-derives its tau-scaled statics from them,
721// while affinity_mode and the two lat_cri thresholds decide how a TASK is
722// classified, so a task would change class depending on which CPU last looked
723// at it. These pin the broadcast so a caller cannot diverge them by omission.
724#[cfg(test)]
725mod knob_broadcast_tests {
726    use super::*;
727
728    fn knobs(slice: u64, tau: u64) -> TuningKnobs {
729        let mut k = TuningKnobs::default();
730        k.slice_ns = slice;
731        k.topology_tau_ns = tau;
732        k
733    }
734
735    #[test]
736    fn global_fields_are_broadcast_from_slot_zero() {
737        let mut v = vec![knobs(100, 7_000), knobs(200, 9_999), knobs(300, 1)];
738        Scheduler::broadcast_global_fields(&mut v);
739        for (i, k) in v.iter().enumerate() {
740            assert_eq!(k.topology_tau_ns, 7_000, "slot {i} diverged on tau");
741        }
742    }
743
744    #[test]
745    fn per_cpu_fields_survive_the_broadcast() {
746        // The whole point: slices may differ per CPU. A broadcast that
747        // flattened them would silently restore the global-only behavior this
748        // change exists to remove.
749        let mut v = vec![knobs(100, 7_000), knobs(200, 0), knobs(300, 0)];
750        Scheduler::broadcast_global_fields(&mut v);
751        assert_eq!(v[0].slice_ns, 100);
752        assert_eq!(v[1].slice_ns, 200);
753        assert_eq!(v[2].slice_ns, 300);
754    }
755
756    #[test]
757    fn uniform_input_stays_uniform() {
758        // The acceptance criterion for the whole change: identical values in
759        // every slot must be indistinguishable from the pre-per-CPU map.
760        let mut v = vec![knobs(100, 7_000); 8];
761        let before = v.clone();
762        Scheduler::broadcast_global_fields(&mut v);
763        for (a, b) in before.iter().zip(v.iter()) {
764            assert_eq!(a.slice_ns, b.slice_ns);
765            assert_eq!(a.topology_tau_ns, b.topology_tau_ns);
766        }
767    }
768
769    #[test]
770    fn empty_and_single_slot_are_no_ops() {
771        let mut none: Vec<TuningKnobs> = Vec::new();
772        Scheduler::broadcast_global_fields(&mut none);
773        let mut one = vec![knobs(100, 7_000)];
774        Scheduler::broadcast_global_fields(&mut one);
775        assert_eq!(one[0].slice_ns, 100);
776    }
777}