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::MapCore;
9use libbpf_rs::skel::{OpenSkel, SkelBuilder};
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    // THE anchor -> target EDGE, BOTH OUTCOMES (intf.h nr_stay_cost_held /
64    // nr_stay_move_taken). The cost is the only priced term on the wake path's
65    // own move and nothing incremented when it fired, so "holds everything home"
66    // and "never runs" were indistinguishable from outside. Read as a ratio: the
67    // pair is every decision, the first is the refusals.
68    // REFUSED REQUEUE KICKS, SELF-TARGETED (intf.h nr_kick_declined). The bottom
69    // rung of the kick ladder: the price refused the act, not just its strength,
70    // and only where this CPU is the one that will dispatch. Read beside
71    // nr_soft_kicks -- a refusal is not a soft kick and must not be pooled with
72    // one, or the kick rate reports IPIs that were never issued. Its magnitude is
73    // what says whether the self-only subset is worth keeping.
74    pub nr_kick_declined: u64,
75    pub nr_stay_cost_held: u64,
76    pub nr_stay_move_taken: u64,
77    // PER-CPU RUNNABLE DEPTH (intf.h rq_depth_sum / rq_depth_samples).
78    // Monotonic accumulators sampled at tick rate; difference BOTH across an
79    // interval and divide for the mean depth on that CPU, exactly as
80    // wake_lat_sum/wake_lat_samples are already consumed. Read per-CPU via
81    // read_stats_percpu() -- folding these to a total discards the spatial
82    // dimension that is the entire reason they exist.
83    pub rq_depth_sum: u64,
84    pub rq_depth_samples: u64,
85}
86
87// COMPILE-TIME ABI SAFETY: MUST MATCH STRUCT LAYOUTS IN intf.h
88// 184 (base, after the structurally empty latcrit l2 pair) + 8*8 (nr_cross_domain)
89// + 8 (nr_osc_park) + 8 (nr_spill_kick_preempt) + 8 (nr_steal)
90// + 8 (nr_kick_declined) + 8 (nr_stay_cost_held) + 8 (nr_stay_move_taken)
91// + 8 (rq_depth_sum) + 8 (rq_depth_samples) = 312.
92const _: () = assert!(std::mem::size_of::<PandemoniumStats>() == 312);
93// 88 - 16 (lat_cri_thresh_high/_low, removed with the classifier that read them)
94// - 8 (spill_temp_q16, computed every tick and consumed by nothing).
95const _: () = assert!(std::mem::size_of::<TuningKnobs>() == 64);
96
97// MAX_AFFINITY_CANDIDATES IS DEFINED IN intf.h. THE RUST MIRROR IN
98// bpf_intf.rs MUST KEEP THE SAME VALUE; IF THE TWO SIDES DRIFT, THE
99// BPF MAP STRIDE AND THE RUST WRITER STRIDE DISAGREE AND THE TABLE
100// IS SILENTLY MIS-POPULATED.
101const _: () = assert!(crate::bpf_intf::MAX_AFFINITY_CANDIDATES == crate::bpf_intf::MAX_CPUS >> 3);
102
103// TuningKnobs LIVES IN tuning.rs (ZERO BPF DEPENDENCIES, TESTABLE OFFLINE)
104
105const KNOBS_PIN: &str = "/sys/fs/bpf/pandemonium/tuning_knobs";
106
107pub struct Scheduler<'a> {
108    skel: MainSkel<'a>,
109    _link: libbpf_rs::Link,
110    pub log: EventLog,
111}
112
113impl<'a> Scheduler<'a> {
114    pub fn init(
115        open_object: &'a mut MaybeUninit<libbpf_rs::OpenObject>,
116        nr_cpus_override: Option<u64>,
117    ) -> Result<Self> {
118        // OPEN
119        let builder = MainSkelBuilder::default();
120        let mut open_skel = builder.open(open_object)?;
121
122        // INJECT VERSION SUFFIX INTO OPS NAME FOR scx_loader GUI
123        {
124            let ops = open_skel.struct_ops.pandemonium_ops_mut();
125            let name_field = &mut ops.name;
126            let version_suffix = scx_utils::build_id::ops_version_suffix(env!("CARGO_PKG_VERSION"));
127            let bytes = version_suffix.as_bytes();
128            let mut i = 0;
129            let mut bytes_idx = 0;
130            let mut found_null = false;
131            while i < name_field.len() - 1 {
132                found_null |= name_field[i] == 0;
133                if !found_null {
134                    i += 1;
135                    continue;
136                }
137                if bytes_idx < bytes.len() {
138                    name_field[i] = bytes[bytes_idx] as i8;
139                    bytes_idx += 1;
140                } else {
141                    break;
142                }
143                i += 1;
144            }
145            name_field[i] = 0;
146        }
147
148        // CONFIGURE RODATA (BEFORE LOAD)
149        let rodata = open_skel.maps.rodata_data.as_mut().unwrap();
150
151        let possible = libbpf_rs::num_possible_cpus()? as u64;
152        rodata.nr_cpu_ids = nr_cpus_override.unwrap_or(possible);
153
154        // POPULATE SCX ENUM VALUES
155        rodata.__SCX_DSQ_FLAG_BUILTIN = SCX_DSQ_FLAG_BUILTIN;
156        rodata.__SCX_DSQ_FLAG_LOCAL_ON = SCX_DSQ_FLAG_LOCAL_ON;
157        rodata.__SCX_DSQ_INVALID = SCX_DSQ_FLAG_BUILTIN;
158        rodata.__SCX_DSQ_GLOBAL = SCX_DSQ_FLAG_BUILTIN | 1;
159        rodata.__SCX_DSQ_LOCAL = SCX_DSQ_FLAG_BUILTIN | SCX_DSQ_FLAG_LOCAL_ON;
160        rodata.__SCX_DSQ_LOCAL_ON = SCX_DSQ_FLAG_BUILTIN | SCX_DSQ_FLAG_LOCAL_ON | 1;
161        rodata.__SCX_DSQ_LOCAL_CPU_MASK = 0xFFFFFFFF;
162
163        // POPULATE SCX_KICK_* ENUM VALUES
164        rodata.__SCX_KICK_IDLE = 1;
165        rodata.__SCX_KICK_PREEMPT = 2;
166        rodata.__SCX_KICK_WAIT = 4;
167
168        // LOAD (VALIDATES BPF WITH KERNEL)
169        let mut skel = open_skel.load()?;
170
171        // ATTACH STRUCT_OPS
172        let link = skel.maps.pandemonium_ops.attach_struct_ops()?;
173
174        // PIN MAPS FOR USERSPACE ACCESS (NON-FATAL: bpffs MAY NOT BE MOUNTED)
175        let pin_dir = "/sys/fs/bpf/pandemonium";
176        let bpffs_ok = std::fs::create_dir_all(pin_dir).is_ok();
177        if bpffs_ok {
178            std::fs::remove_file(KNOBS_PIN).ok();
179            skel.maps.tuning_knobs_map.pin(KNOBS_PIN).ok();
180
181            let cache_pin = "/sys/fs/bpf/pandemonium/cache_domain";
182            std::fs::remove_file(cache_pin).ok();
183            skel.maps.cache_domain.pin(cache_pin).ok();
184        } else {
185            log_warn!("BPFFS NOT AVAILABLE: map pinning skipped (scheduler still functional)");
186        }
187
188        Ok(Self {
189            skel,
190            _link: link,
191            log: EventLog::new(),
192        })
193    }
194
195    // PER-CPU STATS, UNCOLLAPSED. The BPF side keeps one PandemoniumStats
196    // per CPU and lookup_percpu hands the whole array across the boundary --
197    // the syscall, the copy and the cache traffic are paid whether or not the
198    // structure survives. It did not: every field was summed into one scalar
199    // set inside the loop that first touched it, which destroyed the spatial
200    // dimension at the boundary and left the adaptive layer steering a machine
201    // it could only see one number of. Keeping the array costs zero additional
202    // bytes; it is the absence of a discard, not a new transfer.
203    //
204    // read_stats() below folds this into the same total it always returned, so
205    // the aggregate is DERIVED from the array rather than computed beside it
206    // and the two cannot drift.
207    pub fn read_stats_percpu(&self) -> Vec<PandemoniumStats> {
208        let key = 0u32.to_ne_bytes();
209        let percpu_vals = match self
210            .skel
211            .maps
212            .stats_map
213            .lookup_percpu(&key, libbpf_rs::MapFlags::ANY)
214        {
215            Ok(Some(v)) => v,
216            _ => return Vec::new(),
217        };
218        let mut out = Vec::with_capacity(percpu_vals.len());
219        for cpu_val in &percpu_vals {
220            if cpu_val.len() >= std::mem::size_of::<PandemoniumStats>() {
221                out.push(unsafe {
222                    std::ptr::read_unaligned(cpu_val.as_ptr() as *const PandemoniumStats)
223                });
224            }
225        }
226        out
227    }
228
229    // THE REDUCE. Separated from the read so the fold is testable without a
230    // live BPF map, and so every not-a-sum field is stated in one place:
231    // batch_sojourn_ns and longrun_mode_active take a MAX across CPUs (a
232    // system's worst sojourn is not the sum of its CPUs' sojourns), and
233    // nr_cross_domain is an 8-element per-path array summed elementwise.
234    // Returning the raw vec without honoring these would silently change
235    // behavior for every existing consumer.
236    pub fn fold_stats(percpu: &[PandemoniumStats]) -> PandemoniumStats {
237        let mut total = PandemoniumStats::default();
238        for stats in percpu {
239            {
240                total.nr_dispatches += stats.nr_dispatches;
241                total.nr_idle_hits += stats.nr_idle_hits;
242                total.nr_shared += stats.nr_shared;
243                total.nr_preempt += stats.nr_preempt;
244                total.wake_lat_sum += stats.wake_lat_sum;
245                total.wake_lat_samples += stats.wake_lat_samples;
246                total.nr_keep_running += stats.nr_keep_running;
247                total.nr_hard_kicks += stats.nr_hard_kicks;
248                total.nr_soft_kicks += stats.nr_soft_kicks;
249                total.nr_enq_wakeup += stats.nr_enq_wakeup;
250                total.nr_enq_requeue += stats.nr_enq_requeue;
251                total.wake_lat_idle_sum += stats.wake_lat_idle_sum;
252                total.wake_lat_idle_cnt += stats.wake_lat_idle_cnt;
253                total.wake_lat_kick_sum += stats.wake_lat_kick_sum;
254                total.wake_lat_kick_cnt += stats.wake_lat_kick_cnt;
255                total.nr_l2_hit_batch += stats.nr_l2_hit_batch;
256                total.nr_l2_miss_batch += stats.nr_l2_miss_batch;
257                total.nr_l2_hit_interactive += stats.nr_l2_hit_interactive;
258                total.nr_l2_miss_interactive += stats.nr_l2_miss_interactive;
259                total.nr_reenqueue += stats.nr_reenqueue;
260                if stats.batch_sojourn_ns > total.batch_sojourn_ns {
261                    total.batch_sojourn_ns = stats.batch_sojourn_ns;
262                }
263                if stats.longrun_mode_active > total.longrun_mode_active {
264                    total.longrun_mode_active = stats.longrun_mode_active;
265                }
266                total.nr_overflow_rescue += stats.nr_overflow_rescue;
267                for i in 0..8 {
268                    total.nr_cross_domain[i] += stats.nr_cross_domain[i];
269                }
270                total.nr_osc_park += stats.nr_osc_park;
271                total.nr_spill_kick_preempt += stats.nr_spill_kick_preempt;
272                total.nr_steal += stats.nr_steal;
273                total.nr_kick_declined += stats.nr_kick_declined;
274                total.nr_stay_cost_held += stats.nr_stay_cost_held;
275                total.nr_stay_move_taken += stats.nr_stay_move_taken;
276                // Folded so the aggregate stays complete, but the SUMMED value
277                // is close to meaningless -- it is the total depth seen across
278                // every CPU. The per-CPU pair is the point; read it from
279                // read_stats_percpu() and never from here.
280                total.rq_depth_sum += stats.rq_depth_sum;
281                total.rq_depth_samples += stats.rq_depth_samples;
282            }
283        }
284
285        total
286    }
287
288    // THE AGGREGATE VIEW, UNCHANGED FOR EVERY EXISTING CALLER. One syscall,
289    // one fold, byte-for-byte the same total this returned before the array
290    // was preserved.
291    pub fn read_stats(&self) -> PandemoniumStats {
292        Self::fold_stats(&self.read_stats_percpu())
293    }
294
295    // FIELDS THAT MUST BE IDENTICAL ON EVERY CPU.
296    //
297    // tuning_knobs_map is per-CPU, which makes divergence expressible -- and
298    // for these three it would be a defect rather than a feature. tau and
299    // codel_eq are topology-owned and drive tau-scaling, which every CPU
300    // re-derives from the same constant; affinity_mode decides how a TASK is
301    // placed, so a task would change placement depending on which CPU last
302    // looked at it.
303    //
304    // Broadcast rather than trusted: the writer overwrites these in every slot
305    // from slot 0, so a caller cannot diverge them by omission.
306    fn broadcast_global_fields(per_cpu: &mut [TuningKnobs]) {
307        let (first, rest) = match per_cpu.split_first_mut() {
308            Some(v) => v,
309            None => return,
310        };
311        for k in rest.iter_mut() {
312            k.topology_tau_ns = first.topology_tau_ns;
313            k.codel_eq_ns = first.codel_eq_ns;
314            k.affinity_mode = first.affinity_mode;
315        }
316    }
317
318    // WRITE ONE KNOB SET TO EVERY CPU -- CALLED BY MONITOR THREAD.
319    // Behaviorally identical to the pre-per-CPU map: every slot holds the same
320    // struct, so no CPU can observe a value another CPU does not.
321    pub fn write_tuning_knobs(&self, knobs: &TuningKnobs) -> Result<()> {
322        let n = libbpf_rs::num_possible_cpus()?;
323        self.write_tuning_knobs_percpu(&vec![*knobs; n])
324    }
325
326    // WRITE PER-CPU KNOBS. The per-CPU fields (slice, preempt window, batch and
327    // burst ceilings, the CoDel rescue threshold) may differ per slot; the six
328    // global fields are broadcast from slot 0 regardless of what the caller put
329    // in the others.
330    pub fn write_tuning_knobs_percpu(&self, per_cpu: &[TuningKnobs]) -> Result<()> {
331        let n = libbpf_rs::num_possible_cpus()?;
332        let mut vals: Vec<TuningKnobs> = (0..n)
333            .map(|i| per_cpu.get(i).copied().unwrap_or_else(|| per_cpu[0]))
334            .collect();
335        Self::broadcast_global_fields(&mut vals);
336        let key = 0u32.to_ne_bytes();
337        let sz = std::mem::size_of::<TuningKnobs>();
338        let bytes: Vec<Vec<u8>> = vals
339            .iter()
340            .map(|k| unsafe {
341                std::slice::from_raw_parts(k as *const TuningKnobs as *const u8, sz).to_vec()
342            })
343            .collect();
344        self.skel
345            .maps
346            .tuning_knobs_map
347            .update_percpu(&key, &bytes, libbpf_rs::MapFlags::ANY)?;
348        Ok(())
349    }
350
351    // WRITE TOPOLOGY-OWNED FIELDS (tau_ns + codel_eq_ns), PRESERVING OTHERS.
352    // CALLED AT TOPOLOGY DETECT AND ON HOTPLUG. READ-MODIFY-WRITE BECAUSE THE
353    // tuning_knobs_map IS A SINGLE-ENTRY STRUCT AND PARTIAL UPDATES AREN'T A
354    // libbpf CONCEPT -- BUT WE NEED A NARROW SETTER SO TOPOLOGY CHANGES DON'T
355    // STOMP ON WHATEVER THE ADAPTIVE LOOP'S LATEST KNOB VALUES ARE.
356    pub fn write_topology_fields(&self, tau_ns: u64, codel_eq_ns: u64) -> Result<()> {
357        let mut knobs = self.read_tuning_knobs();
358        knobs.topology_tau_ns = tau_ns;
359        knobs.codel_eq_ns = codel_eq_ns;
360        self.write_tuning_knobs(&knobs)
361    }
362
363    // READ BPF OSCILLATOR STATE FROM BSS/DATA SECTIONS.
364    //
365    // Unused since MWU was removed, and kept deliberately. MWU read this to
366    // avoid double-correcting: two controllers both adapting on
367    // global_rescue_count would fight. That hazard is GONE -- the BPF
368    // oscillator is now the only thing adapting on rescue count, and the graph
369    // derives from depth, shape and persistence instead. The accessor stays
370    // because the live-R_eff work reads the same maps.
371    // MWU GATES ITS RESCUE-DRIVEN PATHWAYS ON THIS SO IT DOESN'T
372    // DOUBLE-CORRECT WHEN THE BPF DAMPED OSCILLATOR HAS ALREADY MOVED.
373    pub fn read_oscillator_state(&self) -> OscillatorState {
374        let bss = match self.skel.maps.bss_data.as_ref() {
375            Some(b) => b,
376            None => return OscillatorState::default(),
377        };
378        let data = match self.skel.maps.data_data.as_ref() {
379            Some(d) => d,
380            None => return OscillatorState::default(),
381        };
382        OscillatorState {
383            codel_target_ns: bss.codel_target_ns,
384            codel_target_floor_ns: bss.codel_target_floor_ns,
385            codel_target_max_ns: data.codel_target_max_ns,
386            // NEAREST-PEER PHI HOLD WARM-STAY PRICES IN (SLOT 0 = CHEAPEST
387            // PEER, THE VALUE warm_seat_pick READS FOR THE HOME CPU). CPU 0
388            // IS REPRESENTATIVE ON A HOMOGENEOUS TOPOLOGY.
389            home_dist_extra_ns: self.read_reff_value(0, 0) as u64,
390        }
391    }
392
393    // READ PER-CPU TUNING KNOBS. One entry per CPU; the six global fields are
394    // identical across slots by construction (broadcast_global_fields).
395    pub fn read_tuning_knobs_percpu(&self) -> Vec<TuningKnobs> {
396        let key = 0u32.to_ne_bytes();
397        let vals = match self
398            .skel
399            .maps
400            .tuning_knobs_map
401            .lookup_percpu(&key, libbpf_rs::MapFlags::ANY)
402        {
403            Ok(Some(v)) => v,
404            _ => return Vec::new(),
405        };
406        let sz = std::mem::size_of::<TuningKnobs>();
407        vals.iter()
408            .filter(|v| v.len() >= sz)
409            .map(|v| unsafe { std::ptr::read_unaligned(v.as_ptr() as *const TuningKnobs) })
410            .collect()
411    }
412
413    // READ CURRENT TUNING KNOBS -- CPU 0'S SLOT.
414    //
415    // Every existing caller wants either a global field (tau, codel_eq) or the
416    // value it just wrote uniformly, and both are identical on every CPU, so
417    // slot 0 is the same answer this returned before the map was per-CPU. A
418    // caller that needs a PER-CPU knob must use read_tuning_knobs_percpu() --
419    // this one cannot represent divergence and must not be used to look for it.
420    pub fn read_tuning_knobs(&self) -> TuningKnobs {
421        self.read_tuning_knobs_percpu()
422            .first()
423            .copied()
424            .unwrap_or_default()
425    }
426
427    // READ WAKEUP LATENCY HISTOGRAM: 3 TIERS x 12 BUCKETS
428    // SUMS ACROSS ALL CPUs (PERCPU_ARRAY). RETURNS CUMULATIVE COUNTS.
429    pub fn read_wake_lat_hist(&self) -> [[u64; 12]; 3] {
430        let mut result = [[0u64; 12]; 3];
431        for key_idx in 0u32..36 {
432            let key = key_idx.to_ne_bytes();
433            if let Ok(Some(percpu_vals)) = self
434                .skel
435                .maps
436                .wake_lat_hist
437                .lookup_percpu(&key, libbpf_rs::MapFlags::ANY)
438            {
439                let tier = (key_idx / 12) as usize;
440                let bucket = (key_idx % 12) as usize;
441                for cpu_val in &percpu_vals {
442                    if cpu_val.len() >= std::mem::size_of::<u64>() {
443                        let val: u64 =
444                            unsafe { std::ptr::read_unaligned(cpu_val.as_ptr() as *const u64) };
445                        result[tier][bucket] += val;
446                    }
447                }
448            }
449        }
450        result
451    }
452
453    // READ SLEEP DURATION HISTOGRAM: 4 BUCKETS
454    // SUMS ACROSS ALL CPUs (PERCPU_ARRAY). RETURNS CUMULATIVE COUNTS.
455    pub fn read_sleep_hist(&self) -> [u64; 4] {
456        let mut result = [0u64; 4];
457        for key_idx in 0u32..4 {
458            let key = key_idx.to_ne_bytes();
459            if let Ok(Some(percpu_vals)) = self
460                .skel
461                .maps
462                .sleep_hist
463                .lookup_percpu(&key, libbpf_rs::MapFlags::ANY)
464            {
465                for cpu_val in &percpu_vals {
466                    if cpu_val.len() >= std::mem::size_of::<u64>() {
467                        let val: u64 =
468                            unsafe { std::ptr::read_unaligned(cpu_val.as_ptr() as *const u64) };
469                        result[key_idx as usize] += val;
470                    }
471                }
472            }
473        }
474        result
475    }
476
477    // POPULATE CACHE DOMAIN MAP FROM TOPOLOGY DATA AT STARTUP
478    pub fn write_cache_domain(&self, cpu: u32, l2_group: u32) -> Result<()> {
479        let key = cpu.to_ne_bytes();
480        let val = l2_group.to_ne_bytes();
481        self.skel
482            .maps
483            .cache_domain
484            .update(&key, &val, libbpf_rs::MapFlags::ANY)?;
485        Ok(())
486    }
487
488    // POPULATE EMERGENT OVERFLOW-DOMAIN MAP (T3b.2). cpu_domain[cpu] = the
489    // emergent domain id from the T2 tree (the discrete domain map replacement).
490    pub fn write_cpu_domain(&self, cpu: u32, domain: u32) -> Result<()> {
491        let key = cpu.to_ne_bytes();
492        let val = domain.to_ne_bytes();
493        self.skel
494            .maps
495            .cpu_domain
496            .update(&key, &val, libbpf_rs::MapFlags::ANY)?;
497        Ok(())
498    }
499
500    // SET nr_overflow_domains (post-load mutable global). Walked from topology at startup.
501    // Gates how many of the MAX_OVERFLOW_DOMAINS per-domain overflow DSQs are addressed
502    // by dispatch drain loops.
503    pub fn write_nr_overflow_domains(&mut self, nr_overflow_domains: u32) {
504        if let Some(data) = self.skel.maps.data_data.as_mut() {
505            data.nr_overflow_domains = nr_overflow_domains;
506        }
507    }
508
509    // SET affinity_domain_peers (post-load mutable global). The MINIMUM over CPUs of
510    // how many affinity_rank slots stay inside that CPU's own cache domain, floored
511    // at 1 by the caller. Bounds the affinity idle-search FLOOR so the walk cannot be
512    // forced past a domain narrower than the old constant 3.
513    pub fn write_affinity_domain_peers(&mut self, peers: u32) {
514        if let Some(data) = self.skel.maps.data_data.as_mut() {
515            data.affinity_domain_peers = peers;
516        }
517    }
518
519    // POPULATE L2 SIBLINGS MAP ENTRY
520    pub fn write_l2_sibling(&self, group_id: u32, slot: u32, cpu: u32) -> Result<()> {
521        let key = (group_id * 8 + slot).to_ne_bytes();
522        let val = cpu.to_ne_bytes();
523        self.skel
524            .maps
525            .l2_siblings
526            .update(&key, &val, libbpf_rs::MapFlags::ANY)?;
527        Ok(())
528    }
529
530    // POPULATE RESISTANCE AFFINITY RANK MAP
531    // affinity_rank[cpu * MAX_AFFINITY_CANDIDATES + slot] = target_cpu
532    // SORTED BY ASCENDING R_EFF FROM LAPLACIAN PSEUDOINVERSE
533    pub fn write_affinity_rank(&self, cpu: u32, slot: u32, target_cpu: u32) -> Result<()> {
534        // Stride = MAX_AFFINITY_CANDIDATES. Single source of truth is the
535        // C macro in src/bpf/intf.h, mirrored in bpf_intf.rs. The
536        // static_assert above catches drift at compile time.
537        let stride = crate::bpf_intf::MAX_AFFINITY_CANDIDATES;
538        let key = (cpu * stride + slot).to_ne_bytes();
539        let val = target_cpu.to_ne_bytes();
540        self.skel
541            .maps
542            .affinity_rank
543            .update(&key, &val, libbpf_rs::MapFlags::ANY)?;
544        Ok(())
545    }
546
547    // POPULATE R_eff COST ORACLE MAP (PAIRS 1:1 WITH affinity_rank).
548    // reff_value[cpu * MAX_AFFINITY_CANDIDATES + slot] = quantized R_eff TO THAT TARGET.
549    pub fn write_reff_value(&self, cpu: u32, slot: u32, value: u32) -> Result<()> {
550        let stride = crate::bpf_intf::MAX_AFFINITY_CANDIDATES;
551        let key = (cpu * stride + slot).to_ne_bytes();
552        let val = value.to_ne_bytes();
553        self.skel
554            .maps
555            .reff_value
556            .update(&key, &val, libbpf_rs::MapFlags::ANY)?;
557        Ok(())
558    }
559
560    // POPULATE EMERGENT-DOMAIN CROSSING-PRICE MAP (PAIRS 1:1 WITH affinity_rank).
561    // domain_phi[cpu * MAX_AFFINITY_CANDIDATES + slot] = (phi * 1e6) OF THE CUT
562    // SEPARATING cpu FROM THAT RANKED PEER. (u32)-1 = SAME LEAF / UNUSED SLOT.
563    pub fn write_domain_phi(&self, cpu: u32, slot: u32, value: u32) -> Result<()> {
564        let stride = crate::bpf_intf::MAX_AFFINITY_CANDIDATES;
565        let key = (cpu * stride + slot).to_ne_bytes();
566        let val = value.to_ne_bytes();
567        self.skel
568            .maps
569            .domain_phi
570            .update(&key, &val, libbpf_rs::MapFlags::ANY)?;
571        Ok(())
572    }
573
574    // POPULATE THE DIMENSIONLESS R_eff MAP (PAIRS 1:1 WITH affinity_rank).
575    // reff_frac[cpu * MAX_AFFINITY_CANDIDATES + slot] = R_eff(cpu, peer) as a Q16
576    // fraction of the machine's R_eff span. 0 = adjacent, 65536 = most distant
577    // pair. (u32)-1 = unused slot. The wake-path placement price multiplies this
578    // by codel_target_ns; the steal keeps reading reff_value's tau fold.
579    pub fn write_reff_frac(&self, cpu: u32, slot: u32, value: u32) -> Result<()> {
580        let stride = crate::bpf_intf::MAX_AFFINITY_CANDIDATES;
581        let key = (cpu * stride + slot).to_ne_bytes();
582        let val = value.to_ne_bytes();
583        self.skel
584            .maps
585            .reff_frac
586            .update(&key, &val, libbpf_rs::MapFlags::ANY)?;
587        Ok(())
588    }
589
590    // READ ONE reff_value SLOT (ns PHI HOLD TO THAT RANKED PEER). RETURNS 0 ON
591    // MISS OR THE (u32)-1 UNUSED-SLOT SENTINEL. USED BY THE ADAPTIVE LOOP TO
592    // LEARN THE NEAREST-PEER HOLD WARM-STAY PRICES IN (SLOT 0 = CHEAPEST PEER).
593    pub fn read_reff_value(&self, cpu: u32, slot: u32) -> u32 {
594        let stride = crate::bpf_intf::MAX_AFFINITY_CANDIDATES;
595        let key = (cpu * stride + slot).to_ne_bytes();
596        match self
597            .skel
598            .maps
599            .reff_value
600            .lookup(&key, libbpf_rs::MapFlags::ANY)
601        {
602            Ok(Some(bytes)) if bytes.len() >= 4 => {
603                let v = u32::from_ne_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
604                if v == u32::MAX { 0 } else { v }
605            }
606            _ => 0,
607        }
608    }
609
610    // READ UEI EXIT INFO. RETURNS (should_restart).
611    pub fn read_exit_info(&self) -> bool {
612        let data = self.skel.maps.data_data.as_ref().unwrap();
613        let kind = data.uei.kind;
614        let exit_code = data.uei.exit_code;
615
616        if kind != SCX_EXIT_NONE {
617            let reason_bytes: &[u8] =
618                unsafe { std::slice::from_raw_parts(data.uei.reason.as_ptr() as *const u8, 128) };
619            let msg_bytes: &[u8] =
620                unsafe { std::slice::from_raw_parts(data.uei.msg.as_ptr() as *const u8, 1024) };
621
622            let reason = std::str::from_utf8(reason_bytes)
623                .unwrap_or("unknown")
624                .trim_end_matches('\0');
625            let msg = std::str::from_utf8(msg_bytes)
626                .unwrap_or("")
627                .trim_end_matches('\0');
628
629            log_warn!("BPF exit: kind={} code={}", kind, exit_code);
630            if !reason.is_empty() {
631                log_warn!("BPF exit reason: {}", reason);
632            }
633            if !msg.is_empty() {
634                log_warn!("BPF exit msg: {}", msg);
635            }
636        }
637
638        (exit_code as u64 & SCX_ECODE_RST_MASK) != 0
639    }
640
641    pub fn exited(&self) -> bool {
642        self.skel.maps.data_data.as_ref().unwrap().uei.kind != SCX_EXIT_NONE
643    }
644}
645
646impl Drop for Scheduler<'_> {
647    fn drop(&mut self) {
648        let _ = self.skel.maps.tuning_knobs_map.unpin(KNOBS_PIN);
649        let _ = self
650            .skel
651            .maps
652            .cache_domain
653            .unpin("/sys/fs/bpf/pandemonium/cache_domain");
654        let _ = std::fs::remove_dir("/sys/fs/bpf/pandemonium");
655    }
656}