Skip to main content

scx_mlfq/
topology.rs

1// SPDX-License-Identifier: GPL-2.0
2//
3// Copyright (c) 2026 Galih Tama <galpt@v.recipes>
4//
5// This software may be used and distributed according to the terms of the GNU
6// General Public License version 2.
7
8//! Hybrid-capacity and cache-domain topology discovery.
9//!
10//! Feeds the BPF CPU-selection path (`select_cpu.bpf.c`) two placement
11//! hints derived from the host topology:
12//!
13//! 1. The primary (big-core) set on asymmetric-capacity systems.
14//! 2. The per-LLC cache domains for LLC-aware wakeup placement.
15//!
16//! Two phases:
17//!
18//! 1. `init_topology()` runs before `scx_ops_load!()`. It discovers the
19//!    topology, computes the plans, and writes the rodata globals (rodata
20//!    is frozen at load).
21//! 2. `write_primary_bitmap()` and `write_llc_bitmaps()` run after
22//!    `scx_ops_load!()`. They write the CPU-membership bitmaps directly
23//!    into the ARRAY maps (`mlfq_primary_bitmap`, `mlfq_llc_bitmaps`) that
24//!    the CPU-selection path reads.
25//!
26//! All discovery is best-effort: a placement hint must never abort the
27//! scheduler, so any failure leaves the bitmaps empty and the scheduler
28//! keeps working on the base behavior (uniform capacity, no LLC
29//! awareness).
30
31use std::mem::size_of;
32use std::path::Path;
33
34use anyhow::{Context, Result};
35use libbpf_rs::MapCore;
36use libbpf_rs::MapFlags;
37use log::{info, warn};
38use scx_utils::get_primary_cpus;
39use scx_utils::Powermode;
40use scx_utils::Topology;
41
42use crate::bpf_intf::mlfq_bitmap;
43use crate::bpf_intf::mlfq_consts_MLFQ_BITMAP_WORDS;
44use crate::bpf_intf::mlfq_consts_MLFQ_MAX_CPUS;
45use crate::bpf_intf::mlfq_consts_MLFQ_MAX_LLCS;
46use crate::bpf_intf::mlfq_consts_MLFQ_MAX_LLC_CPUS;
47use crate::bpf_intf::mlfq_llc_cpu_list;
48
49/// Compile-time CPU bound; must match `MLFQ_MAX_CPUS` in `src/bpf/intf.h`.
50const MAX_CPUS: usize = mlfq_consts_MLFQ_MAX_CPUS as usize;
51
52/// Compile-time LLC bound; must match `MLFQ_MAX_LLCS` in `src/bpf/intf.h`.
53const MAX_LLCS: usize = mlfq_consts_MLFQ_MAX_LLCS as usize;
54
55/// Compile-time per-LLC CPU-list bound; must match `MLFQ_MAX_LLC_CPUS`
56/// in `src/bpf/intf.h`.
57const MAX_LLC_CPUS: usize = mlfq_consts_MLFQ_MAX_LLC_CPUS as usize;
58
59/// Whether SMT is active on the host, read from the kernel's
60/// `/sys/devices/system/cpu/smt/active` interface. The knob is absent on
61/// systems without SMT support; a missing or unreadable knob yields
62/// `None`, so the caller can omit the SMT annotation from the startup
63/// banner rather than guessing.
64pub fn smt_enabled() -> Option<bool> {
65    let active = std::fs::read_to_string("/sys/devices/system/cpu/smt/active").ok()?;
66    active.trim().parse::<u8>().ok().map(|v| v == 1)
67}
68
69/// Capacity-planning decision, separated from sysfs discovery so the pure
70/// logic is unit-testable without touching the host topology.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct CapacityPlan {
73    /// True when every CPU is treated as primary: uniform-capacity system,
74    /// or the primary set could not be determined.
75    pub primary_all: bool,
76    /// CPUs to add to the primary mask (sorted, deduplicated); empty when
77    /// `primary_all` is true.
78    pub primary_cpus: Vec<u32>,
79}
80
81/// Decide the primary set from the discovered big cores.
82///
83/// A primary list covering every online CPU means there is no capacity
84/// asymmetry to exploit; an empty list means discovery produced no usable
85/// data. Both fall back to the uniform-capacity behavior.
86pub fn plan_primary_mask(primary_cpus: &[u32], nr_online: usize) -> CapacityPlan {
87    if primary_cpus.is_empty() || primary_cpus.len() >= nr_online {
88        CapacityPlan {
89            primary_all: true,
90            primary_cpus: Vec::new(),
91        }
92    } else {
93        let mut cpus = primary_cpus.to_vec();
94        cpus.sort_unstable();
95        cpus.dedup();
96        CapacityPlan {
97            primary_all: false,
98            primary_cpus: cpus,
99        }
100    }
101}
102
103/// Cache-domain planning decision, separated from sysfs discovery so the
104/// pure logic is unit-testable.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct LlcPlan {
107    /// Number of LLC domains (0 disables the LLC step entirely).
108    pub nr_llcs: u32,
109    /// Per-LLC: 1 if the domain contains at least one primary (big) core.
110    pub has_primary: [u8; MAX_LLCS],
111    /// Per-LLC: the CPUs of that domain.
112    pub llc_cpus: Vec<Vec<u32>>,
113    /// Per-CPU LLC domain id (MLFQ_MAX_LLCS, the sentinel, when the CPU
114    /// is unknown or out of range; only known online CPUs get a real
115    /// domain id).
116    pub cpu_llc: [u32; MAX_CPUS],
117}
118
119/// Build the LLC plan from a synthetic `(cpu, llc)` map.
120///
121/// LLC ids are expected to be dense (0-based). An empty map, or a map with
122/// more domains than `max_llcs`, disables LLC awareness entirely so the
123/// BPF side falls back to the current placement behavior. Every CPU the
124/// map does not cover (unknown or out of range) keeps the MLFQ_MAX_LLCS
125/// sentinel, never the 0 of a valid domain, so an unmapped CPU can never
126/// be attributed to LLC 0.
127pub fn plan_llcs(cpu_to_llc: &[(u32, u32)], primary_cpus: &[u32], max_llcs: usize) -> LlcPlan {
128    let mut plan = LlcPlan {
129        nr_llcs: 0,
130        has_primary: [0; MAX_LLCS],
131        llc_cpus: Vec::new(),
132        cpu_llc: [mlfq_consts_MLFQ_MAX_LLCS; MAX_CPUS],
133    };
134
135    if cpu_to_llc.is_empty() {
136        return plan;
137    }
138
139    let max_llc = cpu_to_llc.iter().map(|&(_, llc)| llc).max().unwrap();
140    let nr = match max_llc.checked_add(1) {
141        Some(v) => v,
142        None => {
143            warn!("LLC id u32::MAX wraps domain count, disabling LLC-aware placement");
144            return plan;
145        }
146    };
147    if nr as usize > max_llcs {
148        warn!(
149            "{} LLC domains exceed the supported maximum ({}), disabling LLC-aware placement",
150            nr, max_llcs
151        );
152        return plan;
153    }
154
155    plan.nr_llcs = nr;
156    plan.llc_cpus = vec![Vec::new(); nr as usize];
157    for &(cpu, llc) in cpu_to_llc {
158        if cpu as usize >= MAX_CPUS {
159            continue;
160        }
161        plan.cpu_llc[cpu as usize] = llc;
162        plan.llc_cpus[llc as usize].push(cpu);
163    }
164    for (llc, cpus) in plan.llc_cpus.iter().enumerate() {
165        if cpus.iter().any(|cpu| primary_cpus.contains(cpu)) {
166            plan.has_primary[llc] = 1;
167        }
168    }
169
170    plan
171}
172
173/// SMT sibling-planning decision, separated from sysfs discovery so the
174/// pure logic is unit-testable.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct SiblingPlan {
177    /// True when any online CPU has a sibling sharing its physical core.
178    pub smt_on: bool,
179    /// Per-CPU: the lowest-id other CPU sharing the core, or the CPU
180    /// itself when the core is unpaired or the CPU is unknown.
181    pub cpu_sibling: [u32; MAX_CPUS],
182    /// Per-CPU core id (mlfq_cpu_core rodata), MAX_CPUS sentinel when
183    /// unknown. The table groups by core_id (not llc_id) and handles
184    /// >2-way SMT by picking the lowest-id sibling per CPU.
185    pub cpu_core: [u32; MAX_CPUS],
186}
187
188/// Plan the SMT sibling table from a synthetic `(cpu, core)` map.
189///
190/// Each CPU's entry is the lowest-id *other* CPU sharing its physical
191/// core (the `core_id` from `scx_utils::Cpu`). A core with a single CPU
192/// maps that CPU to itself, the "no sibling" sentinel the BPF side
193/// treats as "no preference". `smt_on` is set when any entry is a
194/// non-self sibling. For >2-way SMT only the lowest-id sibling is
195/// reported. This is a preference, not a full pairing, and the input
196/// order does not matter (the plan groups by core first).
197pub fn plan_sibling_table(cpu_to_core: &[(u32, u32)]) -> SiblingPlan {
198    let mut plan = SiblingPlan {
199        smt_on: false,
200        cpu_sibling: core::array::from_fn(|i| i as u32),
201        cpu_core: [mlfq_consts_MLFQ_MAX_CPUS; MAX_CPUS],
202    };
203    let mut cores: std::collections::BTreeMap<u32, Vec<u32>> = std::collections::BTreeMap::new();
204
205    for &(cpu, core) in cpu_to_core {
206        if cpu as usize >= MAX_CPUS {
207            continue;
208        }
209        cores.entry(core).or_default().push(cpu);
210        plan.cpu_core[cpu as usize] = core;
211    }
212
213    for cpus in cores.values_mut() {
214        cpus.sort_unstable();
215        cpus.dedup();
216        if cpus.len() < 2 {
217            continue;
218        }
219        plan.smt_on = true;
220        for &cpu in cpus.iter() {
221            // >2-way SMT: pick lowest-id other CPU per core, not a
222            // full pairing; logical sibling lookup, not word bit.
223            let sib = cpus.iter().copied().find(|&c| c != cpu).unwrap_or(cpu);
224            plan.cpu_sibling[cpu as usize] = sib;
225        }
226    }
227
228    plan
229}
230
231/// Parse a kernel cache size string ("32M", "16384K", plain bytes) into
232/// bytes. The kernel exposes cache sizes in the human-readable form
233/// with a K/M/G suffix; a parse failure yields `None`.
234fn parse_cache_size(size: &str) -> Option<u64> {
235    let s = size.trim();
236    let (num, mult) = if let Some(v) = s.strip_suffix('K') {
237        (v, 1024u64)
238    } else if let Some(v) = s.strip_suffix('M') {
239        (v, 1024u64 * 1024)
240    } else if let Some(v) = s.strip_suffix('G') {
241        (v, 1024u64 * 1024 * 1024)
242    } else {
243        (s, 1u64)
244    };
245    num.trim().parse::<u64>().ok().map(|n| n * mult)
246}
247
248/// Read the LLC cache size of one CPU from sysfs.
249///
250/// @cache_path is `/sys/devices/system/cpu/cpuN/cache`, whose `index*`
251/// directories each describe one cache level with `level`/`type`/`size`
252/// (and, on machines that expose it, `id`) files. The LLC level is the
253/// index whose `id` matches the CPU's kernel `topology/llc_id` (both
254/// describe the same level); on machines whose index entries carry no
255/// `id` file, the deepest (largest `level`) index is used. A per-entry
256/// read failure skips that entry; a failure of the whole discovery
257/// yields `None` and the caller falls back to 0 for the domain.
258fn llc_size_bytes(cache_path: &Path) -> Option<u64> {
259    let cpu_path = cache_path.parent()?;
260    let llc_id = std::fs::read_to_string(cpu_path.join("topology/llc_id"))
261        .ok()
262        .and_then(|s| s.trim().parse::<u64>().ok());
263
264    let mut best: Option<(u64, u64)> = None; // (level, size)
265    for entry in std::fs::read_dir(cache_path).ok()?.flatten() {
266        let name = entry.file_name();
267        let Some(name) = name.to_str() else { continue };
268        if !name.starts_with("index") {
269            continue;
270        }
271        let dir = entry.path();
272        let Some(level) = std::fs::read_to_string(dir.join("level"))
273            .ok()
274            .and_then(|s| s.trim().parse::<u64>().ok())
275        else {
276            continue;
277        };
278        let id = std::fs::read_to_string(dir.join("id"))
279            .ok()
280            .and_then(|s| s.trim().parse::<u64>().ok());
281        if let Some(llc_id) = llc_id {
282            if id.is_some() && id != Some(llc_id) {
283                continue;
284            }
285        }
286        let Some(size) = std::fs::read_to_string(dir.join("size"))
287            .ok()
288            .and_then(|s| parse_cache_size(&s))
289        else {
290            continue;
291        };
292        if best.is_none_or(|(best_level, _)| level > best_level) {
293            best = Some((level, size));
294        }
295    }
296    best.map(|(_, size)| size)
297}
298
299/// Pick the LLC domain with the strictly-largest cache size.
300///
301/// The Q1 placement bias needs a single unambiguous winner. With fewer
302/// than two domains, or two or more domains tied for the largest size
303/// (including the all-zeros case of a fully failed discovery), there is
304/// no capacity win to exploit and the feature stays off (`None`).
305/// `sizes` is indexed by LLC domain id; only the first `nr_llcs`
306/// entries are considered.
307pub fn pick_largest_llc(sizes: &[u64], nr_llcs: u32) -> Option<u32> {
308    if (nr_llcs as usize) < 2 {
309        return None;
310    }
311    let nr = nr_llcs as usize;
312    let max = sizes[..nr.min(sizes.len())].iter().copied().max()?;
313    let mut argmax = None;
314    let mut tied = false;
315
316    for (i, &s) in sizes[..nr.min(sizes.len())].iter().enumerate() {
317        if s == max {
318            if argmax.is_some() {
319                tied = true;
320            } else {
321                argmax = Some(i as u32);
322            }
323        }
324    }
325
326    if tied {
327        return None;
328    }
329    argmax
330}
331
332/// The two placement plans produced by `init_topology()`.
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub struct TopologyPlan {
335    pub capacity: CapacityPlan,
336    pub llcs: LlcPlan,
337}
338
339/// Phase 1 (pre-load): discover the topology and write the rodata globals.
340///
341/// Returns the plans for the caller to pass to the populate functions
342/// after the object is loaded.
343pub fn init_topology(skel: &mut crate::bpf_skel::OpenBpfSkel<'_>) -> Result<TopologyPlan> {
344    let topo = match Topology::new() {
345        Ok(topo) => topo,
346        Err(e) => {
347            warn!("CPU topology discovery failed, using uniform-capacity placement: {e}");
348            return Ok(TopologyPlan {
349                capacity: CapacityPlan {
350                    primary_all: true,
351                    primary_cpus: Vec::new(),
352                },
353                llcs: LlcPlan {
354                    nr_llcs: 0,
355                    has_primary: [0; MAX_LLCS],
356                    llc_cpus: Vec::new(),
357                    cpu_llc: [mlfq_consts_MLFQ_MAX_LLCS; MAX_CPUS],
358                },
359            });
360        }
361    };
362
363    let nr_online = topo.all_cpus.len();
364    let primaries: Vec<u32> = match get_primary_cpus(Powermode::Performance) {
365        Ok(cpus) => cpus.into_iter().map(|cpu| cpu as u32).collect(),
366        Err(e) => {
367            warn!("primary CPU discovery failed, using uniform-capacity placement: {e}");
368            Vec::new()
369        }
370    };
371
372    let capacity = plan_primary_mask(&primaries, nr_online);
373
374    let cpu_to_llc: Vec<(u32, u32)> = topo
375        .all_cpus
376        .iter()
377        .map(|(id, cpu)| (*id as u32, cpu.llc_id as u32))
378        .collect();
379    let llcs = plan_llcs(&cpu_to_llc, &capacity.primary_cpus, MAX_LLCS);
380    let mut llcs = llcs;
381    if capacity.primary_all {
382        // Every LLC contains a primary core when all CPUs are primary.
383        llcs.has_primary.fill(1);
384    }
385
386    // SMT sibling preference table: the lowest-id sibling per core.
387    let sibling = plan_sibling_table(
388        &topo
389            .all_cpus
390            .iter()
391            .map(|(id, cpu)| (*id as u32, cpu.core_id as u32))
392            .collect::<Vec<_>>(),
393    );
394
395    // Largest-LLC bias: per-domain cache sizes from a representative
396    // CPU of each domain, then the strictly-largest winner. Every read is
397    // best-effort; a failure leaves that domain's size at 0 and a full
398    // failure ties the zeros into the sentinel (feature off).
399    let mut llc_sizes = vec![0u64; llcs.nr_llcs as usize];
400    for (llc, size) in llc_sizes.iter_mut().enumerate() {
401        if let Some(&cpu) = llcs.llc_cpus[llc].first() {
402            let cache_path = Path::new("/sys/devices/system/cpu").join(format!("cpu{cpu}/cache"));
403            *size = llc_size_bytes(&cache_path).unwrap_or(0);
404        }
405    }
406    let largest = pick_largest_llc(&llc_sizes, llcs.nr_llcs).unwrap_or(mlfq_consts_MLFQ_MAX_LLCS);
407
408    let rodata = skel
409        .maps
410        .rodata_data
411        .as_mut()
412        .context("rodata missing, the BPF object has no .rodata section")?;
413    rodata.mlfq_primary_all = capacity.primary_all;
414    rodata.mlfq_nr_llcs = llcs.nr_llcs;
415    rodata.mlfq_llc_has_primary = llcs.has_primary;
416    rodata.mlfq_cpu_llc = llcs.cpu_llc;
417    rodata.mlfq_smt_on = sibling.smt_on;
418    rodata.mlfq_cpu_sibling = sibling.cpu_sibling;
419    rodata.mlfq_cpu_core = sibling.cpu_core;
420    rodata.mlfq_llc_largest = largest;
421
422    if capacity.primary_all {
423        info!(
424            "Topology: {} online CPUs, uniform capacity, all treated as primary",
425            nr_online
426        );
427    } else {
428        info!(
429            "Topology: {} online CPUs, {} primary (big) cores",
430            nr_online,
431            capacity.primary_cpus.len()
432        );
433    }
434    if llcs.nr_llcs > 0 {
435        info!("Topology: {} LLC cache domains", llcs.nr_llcs);
436    }
437    if sibling.smt_on {
438        info!("Topology: SMT siblings detected, sibling preference enabled");
439    }
440    if largest < llcs.nr_llcs {
441        info!("Topology: LLC {largest} has the strictly-largest cache, Q1 bias enabled");
442    }
443
444    Ok(TopologyPlan { capacity, llcs })
445}
446
447/// Per-CPU static data for the web UI, seeded once at attach.
448///
449/// Runs `Topology::new()` a second time (besides `init_topology()`) and
450/// reports, per online CPU: the maximum operating frequency (`Cpu.max_freq`,
451/// in kHz), the LLC domain id (from the same `plan_llcs` mapping the
452/// placement path uses) and whether the CPU shares its core with a
453/// sibling thread (the `plan_sibling_table` test `sibling[i] != i`, the
454/// same pairing the wakeup-preference path consumes). The dynamic
455/// per-CPU fields are filled by the web-metrics poll from the BPF
456/// per-CPU maps.
457///
458/// Best-effort like the rest of the topology discovery: any failure
459/// yields an empty list and the web UI shows no per-CPU cards rather
460/// than aborting the scheduler.
461pub fn web_cpu_static() -> Vec<crate::stats::PerCpuMetrics> {
462    let topo = match Topology::new() {
463        Ok(topo) => topo,
464        Err(e) => {
465            warn!("CPU topology discovery failed, the web UI reports no per-CPU data: {e}");
466            return Vec::new();
467        }
468    };
469
470    let primaries: Vec<u32> = match get_primary_cpus(Powermode::Performance) {
471        Ok(cpus) => cpus.into_iter().map(|cpu| cpu as u32).collect(),
472        Err(e) => {
473            warn!("primary CPU discovery failed, the web UI reports no per-CPU data: {e}");
474            Vec::new()
475        }
476    };
477
478    let cpu_to_llc: Vec<(u32, u32)> = topo
479        .all_cpus
480        .iter()
481        .map(|(id, cpu)| (*id as u32, cpu.llc_id as u32))
482        .collect();
483    let llcs = plan_llcs(&cpu_to_llc, &primaries, MAX_LLCS);
484
485    // The SMT badge marks the non-primary thread of a core: the lowest
486    // id in the core is the primary, the same anchor convention the
487    // sibling table uses for the wakeup-preference path, and every
488    // other thread of the core is its virtual sibling. The badge is
489    // display-only; the scheduling path reads the sibling table, not
490    // this flag.
491    let cpu_to_core: Vec<(u32, u32)> = topo
492        .all_cpus
493        .iter()
494        .map(|(id, cpu)| (*id as u32, cpu.core_id as u32))
495        .collect();
496    let mut core_min: std::collections::BTreeMap<u32, u32> = std::collections::BTreeMap::new();
497    for &(cpu, core) in &cpu_to_core {
498        core_min
499            .entry(core)
500            .and_modify(|m| *m = (*m).min(cpu))
501            .or_insert(cpu);
502    }
503
504    topo.all_cpus
505        .iter()
506        .map(|(id, cpu)| {
507            // The placement path's MLFQ_MAX_LLCS sentinel (an unmapped
508            // or unknown CPU) is mapped to 0 for display: the UI shows
509            // the LLC id as-is, and "no LLC" is the field's documented
510            // convention, not a sentinel value from the placement side.
511            let llc_id = llcs.cpu_llc.get(*id).copied().unwrap_or(0);
512            let llc_id = if llc_id == mlfq_consts_MLFQ_MAX_LLCS {
513                0
514            } else {
515                llc_id
516            };
517            crate::stats::PerCpuMetrics {
518                id: *id as u32,
519                freq_khz: cpu.max_freq as u64,
520                cur_freq_khz: 0,
521                llc_id,
522                smt: core_min
523                    .get(&(cpu.core_id as u32))
524                    .copied()
525                    .unwrap_or(*id as u32)
526                    != *id as u32,
527                running_queue: 0,
528                running_pid: 0,
529                rt_occupied: false,
530                running_gpu_submit: 0,
531            }
532        })
533        .collect()
534}
535
536/// Read a CPU's current operating frequency from sysfs, in kHz. The
537/// `scaling_cur_freq` file reflects the live frequency of the CPU,
538/// whatever the governor is doing; a missing or unreadable file (no
539/// cpufreq driver) yields 0.
540pub fn current_freq_khz(cpu: u32) -> u64 {
541    std::fs::read_to_string(format!(
542        "/sys/devices/system/cpu/cpu{cpu}/cpufreq/scaling_cur_freq"
543    ))
544    .ok()
545    .and_then(|s| s.trim().parse().ok())
546    .unwrap_or(0)
547}
548
549/// Phase 2 (post-load): write the primary (big-core) membership bitmap.
550///
551/// With uniform capacity the BPF selector short-circuits on
552/// `mlfq_primary_all` and never reads the map, so it is left empty. On
553/// hybrid systems a failure here leaves the map empty: the selector then
554/// treats every CPU as non-primary and falls back to any idle CPU.
555pub fn write_primary_bitmap(
556    skel: &mut crate::bpf_skel::BpfSkel<'_>,
557    plan: &CapacityPlan,
558) -> Result<()> {
559    if plan.primary_all {
560        return Ok(());
561    }
562
563    let bm = build_bitmap(&plan.primary_cpus);
564    write_bitmap_value(&skel.maps.mlfq_primary_bitmap, &bm, 0)
565        .context("failed to write the primary bitmap")
566}
567
568/// Phase 2 (post-load): write the per-LLC membership bitmaps.
569///
570/// One bitmap per LLC domain. On failure the affected domain's bitmap is
571/// left empty, so the selector finds no idle candidate there and
572/// falls through to the global placement path.
573pub fn write_llc_bitmaps(skel: &mut crate::bpf_skel::BpfSkel<'_>, plan: &LlcPlan) -> Result<()> {
574    if plan.nr_llcs == 0 {
575        return Ok(());
576    }
577
578    for llc in 0..plan.nr_llcs as usize {
579        let bm = build_bitmap(&plan.llc_cpus[llc]);
580        write_bitmap_value(&skel.maps.mlfq_llc_bitmaps, &bm, llc as u32)
581            .with_context(|| format!("failed to write the LLC {llc} bitmap"))?;
582    }
583    Ok(())
584}
585
586/// Phase 2 (post-load): write the per-LLC CPU lists into the
587/// `mlfq_llc_cpus` array map.
588///
589/// One list per LLC domain, the Tier-A same-LLC steal window of the
590/// dispatch path. A domain exceeding the `MLFQ_MAX_LLC_CPUS` bound gets
591/// an EMPTY list (nr == 0) instead of failing the whole write: the
592/// dispatch path then skips Tier A for that domain and Tier B's full
593/// rotating window covers it, because Tier B's same-LLC skip is gated
594/// on Tier A having run (tier_a_ran stays false for an empty list). The
595/// window never silently shrinks to a subset of an oversized domain.
596pub fn write_llc_cpu_lists(skel: &mut crate::bpf_skel::BpfSkel<'_>, plan: &LlcPlan) -> Result<()> {
597    if plan.nr_llcs == 0 {
598        return Ok(());
599    }
600
601    for llc in 0..plan.nr_llcs as usize {
602        if plan.llc_cpus[llc].len() > MAX_LLC_CPUS {
603            warn!(
604                "LLC {llc} has {} CPUs, exceeding the supported maximum \
605({MAX_LLC_CPUS}); its Tier-A window is skipped and the full rotating window covers the domain",
606                plan.llc_cpus[llc].len()
607            );
608        }
609        let list = llc_cpu_list_for(&plan.llc_cpus[llc]);
610        write_llc_cpu_list_value(&skel.maps.mlfq_llc_cpus, &list, llc as u32)
611            .with_context(|| format!("failed to write the LLC {llc} CPU list"))?;
612    }
613    Ok(())
614}
615
616/// Word index of @cpu within a CPU bitmap, matching the word layout of
617/// `struct mlfq_bitmap` in `src/bpf/intf.h`.
618fn bitmap_word(cpu: usize) -> usize {
619    cpu >> 6
620}
621
622/// Bit mask of @cpu within its bitmap word.
623fn bitmap_mask(cpu: usize) -> u64 {
624    1u64 << (cpu & 63)
625}
626
627/// Build a CPU-membership bitmap from a CPU list.
628///
629/// Out-of-range CPUs are ignored, matching the BPF-side guards.
630fn build_bitmap(cpus: &[u32]) -> mlfq_bitmap {
631    let mut bm = mlfq_bitmap {
632        words: [0; mlfq_consts_MLFQ_BITMAP_WORDS as usize],
633    };
634    for &cpu in cpus {
635        let cpu = cpu as usize;
636        if cpu >= MAX_CPUS {
637            continue;
638        }
639        bm.words[bitmap_word(cpu)] |= bitmap_mask(cpu);
640    }
641    bm
642}
643
644/// Write @value into the ARRAY map @map at @key (a u32 key).
645fn write_bitmap_value(map: &libbpf_rs::Map, value: &mlfq_bitmap, key: u32) -> Result<()> {
646    let key_bytes = key.to_ne_bytes();
647    let value_bytes = unsafe {
648        std::slice::from_raw_parts(value as *const _ as *const u8, size_of::<mlfq_bitmap>())
649    };
650    map.update(&key_bytes, value_bytes, MapFlags::ANY)?;
651    Ok(())
652}
653
654/// Build the Tier-A CPU list one LLC domain publishes.
655///
656/// Thin wrapper kept for the existing call sites. The actual packing
657/// and oversize handling lives in `build_llc_cpu_list`.
658fn llc_cpu_list_for(cpus: &[u32]) -> mlfq_llc_cpu_list {
659    build_llc_cpu_list(cpus)
660}
661
662/// Pack a CPU list into the `mlfq_llc_cpu_list` map value type.
663///
664/// If the domain has more than `MLFQ_MAX_LLC_CPUS` CPUs the list is
665/// left empty (nr == 0) so the dispatch path skips Tier-A for that
666/// domain and Tier-B's full rotating window covers it instead of
667/// probing a silently truncated subset. CPUs outside `MAX_CPUS` are
668/// skipped, same as the BPF-side guard, and nr counts only the CPUs
669/// actually stored.
670fn build_llc_cpu_list(cpus: &[u32]) -> mlfq_llc_cpu_list {
671    if cpus.len() > MAX_LLC_CPUS {
672        return mlfq_llc_cpu_list {
673            nr: 0,
674            cpus: [0; mlfq_consts_MLFQ_MAX_LLC_CPUS as usize],
675        };
676    }
677    let mut list = mlfq_llc_cpu_list {
678        nr: 0,
679        cpus: [0; mlfq_consts_MLFQ_MAX_LLC_CPUS as usize],
680    };
681    for &cpu in cpus {
682        if cpu as usize >= MAX_CPUS {
683            continue;
684        }
685        list.cpus[list.nr as usize] = cpu;
686        list.nr += 1;
687    }
688    list
689}
690
691/// Write @value into the ARRAY map @map at @key (a u32 key).
692fn write_llc_cpu_list_value(
693    map: &libbpf_rs::Map,
694    value: &mlfq_llc_cpu_list,
695    key: u32,
696) -> Result<()> {
697    let key_bytes = key.to_ne_bytes();
698    let value_bytes = unsafe {
699        std::slice::from_raw_parts(
700            value as *const _ as *const u8,
701            size_of::<mlfq_llc_cpu_list>(),
702        )
703    };
704    map.update(&key_bytes, value_bytes, MapFlags::ANY)?;
705    Ok(())
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711    use crate::bpf_intf::mlfq_consts_MLFQ_LLC_SCAN_MAX;
712
713    #[test]
714    fn empty_primary_list_falls_back_to_uniform() {
715        let plan = plan_primary_mask(&[], 8);
716        assert!(plan.primary_all);
717        assert!(plan.primary_cpus.is_empty());
718    }
719
720    #[test]
721    fn full_primary_list_is_uniform() {
722        // Every online CPU is primary: no asymmetry to exploit.
723        let plan = plan_primary_mask(&[0, 1, 2, 3, 4, 5, 6, 7], 8);
724        assert!(plan.primary_all);
725    }
726
727    #[test]
728    fn subset_of_online_cpus_is_hybrid() {
729        let plan = plan_primary_mask(&[0, 1, 4, 5], 8);
730        assert!(!plan.primary_all);
731        assert_eq!(plan.primary_cpus, vec![0, 1, 4, 5]);
732    }
733
734    #[test]
735    fn hybrid_list_is_sorted_and_deduplicated() {
736        let plan = plan_primary_mask(&[5, 1, 4, 1, 0], 8);
737        assert!(!plan.primary_all);
738        assert_eq!(plan.primary_cpus, vec![0, 1, 4, 5]);
739    }
740
741    #[test]
742    fn max_cpus_matches_bpf_constant() {
743        // The array sizes and the BPF rodata arrays must stay in lock-step.
744        assert_eq!(
745            MAX_CPUS,
746            crate::bpf_intf::mlfq_consts_MLFQ_MAX_CPUS as usize
747        );
748        assert_eq!(
749            MAX_LLCS,
750            crate::bpf_intf::mlfq_consts_MLFQ_MAX_LLCS as usize
751        );
752    }
753
754    #[test]
755    fn llc_grouping_from_cpu_map() {
756        let plan = plan_llcs(&[(0, 0), (1, 0), (2, 1), (3, 1)], &[], MAX_LLCS);
757        assert_eq!(plan.nr_llcs, 2);
758        assert_eq!(plan.llc_cpus, vec![vec![0, 1], vec![2, 3]]);
759        assert_eq!(plan.cpu_llc[0], 0);
760        assert_eq!(plan.cpu_llc[3], 1);
761        // An unknown CPU keeps the sentinel, never the 0 of a valid domain.
762        assert_eq!(plan.cpu_llc[4], mlfq_consts_MLFQ_MAX_LLCS);
763    }
764
765    #[test]
766    fn llc_has_primary_flags_domains_with_big_cores() {
767        let plan = plan_llcs(&[(0, 0), (1, 0), (2, 1), (3, 1)], &[1], MAX_LLCS);
768        assert_eq!(plan.has_primary[0], 1); // LLC 0 contains primary CPU 1
769        assert_eq!(plan.has_primary[1], 0); // LLC 1 has no primary
770    }
771
772    #[test]
773    fn llc_map_without_primaries_has_no_flags() {
774        let plan = plan_llcs(&[(0, 0), (1, 1)], &[], MAX_LLCS);
775        assert_eq!(plan.has_primary, [0; MAX_LLCS]);
776    }
777
778    #[test]
779    fn llc_plan_disables_when_exceeding_cap() {
780        // 33 domains exceed the 32-domain bound: disable LLC awareness.
781        let map: Vec<(u32, u32)> = (0..33).map(|i| (i, i)).collect();
782        let plan = plan_llcs(&map, &[], MAX_LLCS);
783        assert_eq!(plan.nr_llcs, 0);
784        assert!(plan.llc_cpus.is_empty());
785    }
786
787    #[test]
788    fn llc_plan_saturates_on_u32_max_llc_id() {
789        // A llc_id of u32::MAX must not wrap the domain count to zero,
790        // which would pass the cap check and later index out of bounds.
791        let plan = plan_llcs(&[(0, 0), (1, u32::MAX)], &[], MAX_LLCS);
792        assert_eq!(plan.nr_llcs, 0);
793        assert!(plan.llc_cpus.is_empty());
794    }
795
796    #[test]
797    fn llc_plan_disables_on_empty_map() {
798        let plan = plan_llcs(&[], &[], MAX_LLCS);
799        assert_eq!(plan.nr_llcs, 0);
800    }
801
802    #[test]
803    fn llc_plan_skips_out_of_range_cpus() {
804        let plan = plan_llcs(&[(0, 0), (5000, 1)], &[], MAX_LLCS);
805        // The oversized CPU id must not index into the arrays.
806        assert_eq!(plan.nr_llcs, 2);
807        assert_eq!(plan.cpu_llc[0], 0);
808        // An in-range CPU the map does not cover keeps the sentinel.
809        assert_eq!(plan.cpu_llc[1], mlfq_consts_MLFQ_MAX_LLCS);
810        assert!(plan.llc_cpus[1].is_empty());
811    }
812
813    #[test]
814    fn bitmap_word_index_math() {
815        assert_eq!(bitmap_word(0), 0);
816        assert_eq!(bitmap_word(63), 0);
817        assert_eq!(bitmap_word(64), 1);
818        assert_eq!(bitmap_word(127), 1);
819        assert_eq!(bitmap_word(1023), 15);
820    }
821
822    #[test]
823    fn bitmap_mask_math() {
824        assert_eq!(bitmap_mask(0), 1);
825        assert_eq!(bitmap_mask(5), 1 << 5);
826        assert_eq!(bitmap_mask(63), 1u64 << 63);
827    }
828
829    #[test]
830    fn build_bitmap_sets_expected_bits() {
831        let bm = build_bitmap(&[0, 1, 64, 1023]);
832        assert_eq!(bm.words[0], 0b11);
833        assert_eq!(bm.words[1], 1);
834        assert_eq!(bm.words[15], 1u64 << 63);
835        assert_eq!(bm.words[2], 0);
836    }
837
838    #[test]
839    fn build_bitmap_ignores_out_of_range_cpus() {
840        let bm = build_bitmap(&[1024, 5000]);
841        assert_eq!(bm.words, [0; mlfq_consts_MLFQ_BITMAP_WORDS as usize]);
842    }
843
844    #[test]
845    fn bitmap_words_count_matches_bpf_constant() {
846        assert_eq!(
847            mlfq_consts_MLFQ_BITMAP_WORDS,
848            (crate::bpf_intf::mlfq_consts_MLFQ_MAX_CPUS + 63) / 64
849        );
850    }
851
852    #[test]
853    fn smt_pair_table() {
854        // Two SMT pairs over four CPUs / two cores: each CPU points at
855        // the other member of its core.
856        let plan = plan_sibling_table(&[(0, 0), (1, 0), (2, 1), (3, 1)]);
857        assert!(plan.smt_on);
858        assert_eq!(&plan.cpu_sibling[..4], &[1, 0, 3, 2]);
859    }
860
861    #[test]
862    fn smt_off_with_distinct_cores() {
863        let plan = plan_sibling_table(&[(0, 0), (1, 1), (2, 2), (3, 3)]);
864        assert!(!plan.smt_on);
865        assert_eq!(&plan.cpu_sibling[..4], &[0, 1, 2, 3]);
866    }
867
868    #[test]
869    fn smt_unpaired_cpu_maps_to_self() {
870        let plan = plan_sibling_table(&[(0, 0), (1, 0), (2, 2)]);
871        assert!(plan.smt_on);
872        assert_eq!(plan.cpu_sibling[0], 1);
873        assert_eq!(plan.cpu_sibling[1], 0);
874        // The unpaired core's CPU has no sibling: itself.
875        assert_eq!(plan.cpu_sibling[2], 2);
876    }
877
878    #[test]
879    fn smt_table_is_order_independent() {
880        let plan = plan_sibling_table(&[(1, 0), (3, 1), (0, 0), (2, 1)]);
881        assert_eq!(&plan.cpu_sibling[..4], &[1, 0, 3, 2]);
882    }
883
884    #[test]
885    fn smt_empty_input_stays_off() {
886        let plan = plan_sibling_table(&[]);
887        assert!(!plan.smt_on);
888        assert_eq!(plan.cpu_sibling[0], 0);
889    }
890
891    #[test]
892    fn largest_llc_unique_max_wins() {
893        assert_eq!(pick_largest_llc(&[16, 32, 8], 3), Some(1));
894    }
895
896    #[test]
897    fn largest_llc_tie_is_disabled() {
898        assert_eq!(pick_largest_llc(&[32, 32, 8], 3), None);
899    }
900
901    #[test]
902    fn largest_llc_single_domain_is_disabled() {
903        assert_eq!(pick_largest_llc(&[32], 1), None);
904    }
905
906    #[test]
907    fn largest_llc_zero_domains_is_disabled() {
908        assert_eq!(pick_largest_llc(&[], 0), None);
909    }
910
911    #[test]
912    fn largest_llc_all_zero_sizes_tie_to_disabled() {
913        // Fully failed discovery: every size reads 0 and the zeros tie.
914        assert_eq!(pick_largest_llc(&[0, 0, 0], 3), None);
915    }
916
917    #[test]
918    fn largest_llc_unreadable_domain_reads_zero() {
919        // One domain's size read failed (0): the other is strictly
920        // largest and wins.
921        assert_eq!(pick_largest_llc(&[16, 0], 2), Some(0));
922    }
923
924    #[test]
925    fn parse_cache_size_suffixes() {
926        assert_eq!(parse_cache_size("16384K"), Some(16384 * 1024));
927        assert_eq!(parse_cache_size("32M"), Some(32 * 1024 * 1024));
928        assert_eq!(parse_cache_size("1G"), Some(1024 * 1024 * 1024));
929        assert_eq!(parse_cache_size("4096"), Some(4096));
930        assert_eq!(parse_cache_size("bogus"), None);
931    }
932
933    #[test]
934    fn llc_size_bytes_picks_the_llc_index_by_id() {
935        // Fake sysfs: index0 is the L2 (id 4), index1 the LLC (id 7);
936        // the CPU's topology llc_id selects the LLC entry.
937        let dir = std::env::temp_dir().join(format!("scx_mlfq_cache_id_{}", std::process::id()));
938        std::fs::create_dir_all(dir.join("cpu0/cache/index0")).unwrap();
939        std::fs::create_dir_all(dir.join("cpu0/cache/index1")).unwrap();
940        std::fs::create_dir_all(dir.join("cpu0/topology")).unwrap();
941        std::fs::write(dir.join("cpu0/cache/index0/level"), "2\n").unwrap();
942        std::fs::write(dir.join("cpu0/cache/index0/size"), "512K\n").unwrap();
943        std::fs::write(dir.join("cpu0/cache/index0/id"), "4\n").unwrap();
944        std::fs::write(dir.join("cpu0/cache/index1/level"), "3\n").unwrap();
945        std::fs::write(dir.join("cpu0/cache/index1/size"), "32M\n").unwrap();
946        std::fs::write(dir.join("cpu0/cache/index1/id"), "7\n").unwrap();
947        std::fs::write(dir.join("cpu0/topology/llc_id"), "7\n").unwrap();
948
949        let size = llc_size_bytes(&dir.join("cpu0/cache"));
950        assert_eq!(size, Some(32 * 1024 * 1024));
951        std::fs::remove_dir_all(&dir).unwrap();
952    }
953
954    #[test]
955    fn llc_size_bytes_falls_back_to_deepest_level_without_id() {
956        // No id files and no topology llc_id: the deepest index wins.
957        let dir = std::env::temp_dir().join(format!("scx_mlfq_cache_noid_{}", std::process::id()));
958        std::fs::create_dir_all(dir.join("cpu0/cache/index0")).unwrap();
959        std::fs::create_dir_all(dir.join("cpu0/cache/index1")).unwrap();
960        std::fs::write(dir.join("cpu0/cache/index0/level"), "2\n").unwrap();
961        std::fs::write(dir.join("cpu0/cache/index0/size"), "512K\n").unwrap();
962        std::fs::write(dir.join("cpu0/cache/index1/level"), "3\n").unwrap();
963        std::fs::write(dir.join("cpu0/cache/index1/size"), "32M\n").unwrap();
964
965        let size = llc_size_bytes(&dir.join("cpu0/cache"));
966        assert_eq!(size, Some(32 * 1024 * 1024));
967        std::fs::remove_dir_all(&dir).unwrap();
968    }
969
970    #[test]
971    fn llc_size_bytes_missing_path_is_none() {
972        let dir =
973            std::env::temp_dir().join(format!("scx_mlfq_cache_missing_{}", std::process::id()));
974        assert_eq!(llc_size_bytes(&dir.join("cpu0/cache")), None);
975    }
976
977    #[test]
978    fn build_llc_cpu_list_layout() {
979        let list = build_llc_cpu_list(&[0, 1, 2, 1023]);
980        assert_eq!(list.nr, 4);
981        assert_eq!(&list.cpus[..4], &[0, 1, 2, 1023]);
982        assert_eq!(list.cpus[4], 0);
983    }
984
985    #[test]
986    fn build_llc_cpu_list_skips_out_of_range() {
987        let list = build_llc_cpu_list(&[0, 5000, 1]);
988        assert_eq!(list.nr, 2);
989        assert_eq!(&list.cpus[..2], &[0, 1]);
990    }
991
992    #[test]
993    fn max_llc_cpus_matches_bpf_constant() {
994        assert_eq!(MAX_LLC_CPUS, mlfq_consts_MLFQ_MAX_LLC_CPUS as usize);
995    }
996
997    #[test]
998    fn oversized_llc_domain_publishes_an_empty_list() {
999        // A domain larger than the Tier-A window gets nr == 0: Tier A
1000        // skips it and Tier B's full rotating window covers the domain.
1001        let over: Vec<u32> = (0..MAX_LLC_CPUS as u32 + 1).collect();
1002        assert_eq!(llc_cpu_list_for(&over).nr, 0);
1003
1004        // A domain that fits the window publishes every CPU, and the
1005        // window width equals the list bound, so the constant modulo
1006        // covers the whole populated list.
1007        let fits: Vec<u32> = (0..MAX_LLC_CPUS as u32).collect();
1008        let list = llc_cpu_list_for(&fits);
1009        assert_eq!(list.nr, MAX_LLC_CPUS as u32);
1010        assert_eq!(MAX_LLC_CPUS, mlfq_consts_MLFQ_LLC_SCAN_MAX as usize);
1011    }
1012}