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::Powermode;
39use scx_utils::Topology;
40use scx_utils::get_primary_cpus;
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_LLC_CPUS;
46use crate::bpf_intf::mlfq_consts_MLFQ_MAX_LLCS;
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            && id.is_some()
283            && id != Some(llc_id)
284        {
285            continue;
286        }
287        let Some(size) = std::fs::read_to_string(dir.join("size"))
288            .ok()
289            .and_then(|s| parse_cache_size(&s))
290        else {
291            continue;
292        };
293        if best.is_none_or(|(best_level, _)| level > best_level) {
294            best = Some((level, size));
295        }
296    }
297    best.map(|(_, size)| size)
298}
299
300/// Pick the LLC domain with the strictly-largest cache size.
301///
302/// The Q1 placement bias needs a single unambiguous winner. With fewer
303/// than two domains, or two or more domains tied for the largest size
304/// (including the all-zeros case of a fully failed discovery), there is
305/// no capacity win to exploit and the feature stays off (`None`).
306/// `sizes` is indexed by LLC domain id; only the first `nr_llcs`
307/// entries are considered.
308pub fn pick_largest_llc(sizes: &[u64], nr_llcs: u32) -> Option<u32> {
309    if (nr_llcs as usize) < 2 {
310        return None;
311    }
312    let nr = nr_llcs as usize;
313    let max = sizes[..nr.min(sizes.len())].iter().copied().max()?;
314    let mut argmax = None;
315    let mut tied = false;
316
317    for (i, &s) in sizes[..nr.min(sizes.len())].iter().enumerate() {
318        if s == max {
319            if argmax.is_some() {
320                tied = true;
321            } else {
322                argmax = Some(i as u32);
323            }
324        }
325    }
326
327    if tied {
328        return None;
329    }
330    argmax
331}
332
333/// The two placement plans produced by `init_topology()`.
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct TopologyPlan {
336    pub capacity: CapacityPlan,
337    pub llcs: LlcPlan,
338}
339
340/// Phase 1 (pre-load): discover the topology and write the rodata globals.
341///
342/// Returns the plans for the caller to pass to the populate functions
343/// after the object is loaded.
344pub fn init_topology(skel: &mut crate::bpf_skel::OpenBpfSkel<'_>) -> Result<TopologyPlan> {
345    let topo = match Topology::new() {
346        Ok(topo) => topo,
347        Err(e) => {
348            warn!("CPU topology discovery failed, using uniform-capacity placement: {e}");
349            return Ok(TopologyPlan {
350                capacity: CapacityPlan {
351                    primary_all: true,
352                    primary_cpus: Vec::new(),
353                },
354                llcs: LlcPlan {
355                    nr_llcs: 0,
356                    has_primary: [0; MAX_LLCS],
357                    llc_cpus: Vec::new(),
358                    cpu_llc: [mlfq_consts_MLFQ_MAX_LLCS; MAX_CPUS],
359                },
360            });
361        }
362    };
363
364    let nr_online = topo.all_cpus.len();
365    let primaries: Vec<u32> = match get_primary_cpus(Powermode::Performance) {
366        Ok(cpus) => cpus.into_iter().map(|cpu| cpu as u32).collect(),
367        Err(e) => {
368            warn!("primary CPU discovery failed, using uniform-capacity placement: {e}");
369            Vec::new()
370        }
371    };
372
373    let capacity = plan_primary_mask(&primaries, nr_online);
374
375    let cpu_to_llc: Vec<(u32, u32)> = topo
376        .all_cpus
377        .iter()
378        .map(|(id, cpu)| (*id as u32, cpu.llc_id as u32))
379        .collect();
380    let llcs = plan_llcs(&cpu_to_llc, &capacity.primary_cpus, MAX_LLCS);
381    let mut llcs = llcs;
382    if capacity.primary_all {
383        // Every LLC contains a primary core when all CPUs are primary.
384        llcs.has_primary.fill(1);
385    }
386
387    // SMT sibling preference table: the lowest-id sibling per core.
388    let sibling = plan_sibling_table(
389        &topo
390            .all_cpus
391            .iter()
392            .map(|(id, cpu)| (*id as u32, cpu.core_id as u32))
393            .collect::<Vec<_>>(),
394    );
395
396    // Largest-LLC bias: per-domain cache sizes from a representative
397    // CPU of each domain, then the strictly-largest winner. Every read is
398    // best-effort; a failure leaves that domain's size at 0 and a full
399    // failure ties the zeros into the sentinel (feature off).
400    let mut llc_sizes = vec![0u64; llcs.nr_llcs as usize];
401    for (llc, size) in llc_sizes.iter_mut().enumerate() {
402        if let Some(&cpu) = llcs.llc_cpus[llc].first() {
403            let cache_path = Path::new("/sys/devices/system/cpu").join(format!("cpu{cpu}/cache"));
404            *size = llc_size_bytes(&cache_path).unwrap_or(0);
405        }
406    }
407    let largest = pick_largest_llc(&llc_sizes, llcs.nr_llcs).unwrap_or(mlfq_consts_MLFQ_MAX_LLCS);
408
409    let rodata = skel
410        .maps
411        .rodata_data
412        .as_mut()
413        .context("rodata missing, the BPF object has no .rodata section")?;
414    rodata.mlfq_primary_all = capacity.primary_all;
415    rodata.mlfq_nr_llcs = llcs.nr_llcs;
416    rodata.mlfq_llc_has_primary = llcs.has_primary;
417    rodata.mlfq_cpu_llc = llcs.cpu_llc;
418    rodata.mlfq_smt_on = sibling.smt_on;
419    rodata.mlfq_cpu_sibling = sibling.cpu_sibling;
420    rodata.mlfq_cpu_core = sibling.cpu_core;
421    rodata.mlfq_llc_largest = largest;
422
423    if capacity.primary_all {
424        info!(
425            "Topology: {} online CPUs, uniform capacity, all treated as primary",
426            nr_online
427        );
428    } else {
429        info!(
430            "Topology: {} online CPUs, {} primary (big) cores",
431            nr_online,
432            capacity.primary_cpus.len()
433        );
434    }
435    if llcs.nr_llcs > 0 {
436        info!("Topology: {} LLC cache domains", llcs.nr_llcs);
437    }
438    if sibling.smt_on {
439        info!("Topology: SMT siblings detected, sibling preference enabled");
440    }
441    if largest < llcs.nr_llcs {
442        info!("Topology: LLC {largest} has the strictly-largest cache, Q1 bias enabled");
443    }
444
445    Ok(TopologyPlan { capacity, llcs })
446}
447
448/// Per-CPU static data for the web UI, seeded once at attach.
449///
450/// Runs `Topology::new()` a second time (besides `init_topology()`) and
451/// reports, per online CPU: the maximum operating frequency (`Cpu.max_freq`,
452/// in kHz), the LLC domain id (from the same `plan_llcs` mapping the
453/// placement path uses) and whether the CPU shares its core with a
454/// sibling thread (the `plan_sibling_table` test `sibling[i] != i`, the
455/// same pairing the wakeup-preference path consumes). The dynamic
456/// per-CPU fields are filled by the web-metrics poll from the BPF
457/// per-CPU maps.
458///
459/// Best-effort like the rest of the topology discovery: any failure
460/// yields an empty list and the web UI shows no per-CPU cards rather
461/// than aborting the scheduler.
462pub fn web_cpu_static() -> Vec<crate::stats::PerCpuMetrics> {
463    let topo = match Topology::new() {
464        Ok(topo) => topo,
465        Err(e) => {
466            warn!("CPU topology discovery failed, the web UI reports no per-CPU data: {e}");
467            return Vec::new();
468        }
469    };
470
471    let primaries: Vec<u32> = match get_primary_cpus(Powermode::Performance) {
472        Ok(cpus) => cpus.into_iter().map(|cpu| cpu as u32).collect(),
473        Err(e) => {
474            warn!("primary CPU discovery failed, the web UI reports no per-CPU data: {e}");
475            Vec::new()
476        }
477    };
478
479    let cpu_to_llc: Vec<(u32, u32)> = topo
480        .all_cpus
481        .iter()
482        .map(|(id, cpu)| (*id as u32, cpu.llc_id as u32))
483        .collect();
484    let llcs = plan_llcs(&cpu_to_llc, &primaries, MAX_LLCS);
485
486    // The SMT badge marks the non-primary thread of a core: the lowest
487    // id in the core is the primary, the same anchor convention the
488    // sibling table uses for the wakeup-preference path, and every
489    // other thread of the core is its virtual sibling. The badge is
490    // display-only; the scheduling path reads the sibling table, not
491    // this flag.
492    let cpu_to_core: Vec<(u32, u32)> = topo
493        .all_cpus
494        .iter()
495        .map(|(id, cpu)| (*id as u32, cpu.core_id as u32))
496        .collect();
497    let mut core_min: std::collections::BTreeMap<u32, u32> = std::collections::BTreeMap::new();
498    for &(cpu, core) in &cpu_to_core {
499        core_min
500            .entry(core)
501            .and_modify(|m| *m = (*m).min(cpu))
502            .or_insert(cpu);
503    }
504
505    topo.all_cpus
506        .iter()
507        .map(|(id, cpu)| {
508            // The placement path's MLFQ_MAX_LLCS sentinel (an unmapped
509            // or unknown CPU) is mapped to 0 for display: the UI shows
510            // the LLC id as-is, and "no LLC" is the field's documented
511            // convention, not a sentinel value from the placement side.
512            let llc_id = llcs.cpu_llc.get(*id).copied().unwrap_or(0);
513            let llc_id = if llc_id == mlfq_consts_MLFQ_MAX_LLCS {
514                0
515            } else {
516                llc_id
517            };
518            crate::stats::PerCpuMetrics {
519                id: *id as u32,
520                freq_khz: cpu.max_freq as u64,
521                cur_freq_khz: 0,
522                llc_id,
523                smt: core_min
524                    .get(&(cpu.core_id as u32))
525                    .copied()
526                    .unwrap_or(*id as u32)
527                    != *id as u32,
528                running_queue: 0,
529                running_pid: 0,
530                rt_occupied: false,
531                running_gpu_submit: 0,
532            }
533        })
534        .collect()
535}
536
537/// Read a CPU's current operating frequency from sysfs, in kHz. The
538/// `scaling_cur_freq` file reflects the live frequency of the CPU,
539/// whatever the governor is doing; a missing or unreadable file (no
540/// cpufreq driver) yields 0.
541pub fn current_freq_khz(cpu: u32) -> u64 {
542    std::fs::read_to_string(format!(
543        "/sys/devices/system/cpu/cpu{cpu}/cpufreq/scaling_cur_freq"
544    ))
545    .ok()
546    .and_then(|s| s.trim().parse().ok())
547    .unwrap_or(0)
548}
549
550/// Phase 2 (post-load): write the primary (big-core) membership bitmap.
551///
552/// With uniform capacity the BPF selector short-circuits on
553/// `mlfq_primary_all` and never reads the map, so it is left empty. On
554/// hybrid systems a failure here leaves the map empty: the selector then
555/// treats every CPU as non-primary and falls back to any idle CPU.
556pub fn write_primary_bitmap(
557    skel: &mut crate::bpf_skel::BpfSkel<'_>,
558    plan: &CapacityPlan,
559) -> Result<()> {
560    if plan.primary_all {
561        return Ok(());
562    }
563
564    let bm = build_bitmap(&plan.primary_cpus);
565    write_bitmap_value(&skel.maps.mlfq_primary_bitmap, &bm, 0)
566        .context("failed to write the primary bitmap")
567}
568
569/// Phase 2 (post-load): write the per-LLC membership bitmaps.
570///
571/// One bitmap per LLC domain. On failure the affected domain's bitmap is
572/// left empty, so the selector finds no idle candidate there and
573/// falls through to the global placement path.
574pub fn write_llc_bitmaps(skel: &mut crate::bpf_skel::BpfSkel<'_>, plan: &LlcPlan) -> Result<()> {
575    if plan.nr_llcs == 0 {
576        return Ok(());
577    }
578
579    for llc in 0..plan.nr_llcs as usize {
580        let bm = build_bitmap(&plan.llc_cpus[llc]);
581        write_bitmap_value(&skel.maps.mlfq_llc_bitmaps, &bm, llc as u32)
582            .with_context(|| format!("failed to write the LLC {llc} bitmap"))?;
583    }
584    Ok(())
585}
586
587/// Phase 2 (post-load): write the per-LLC CPU lists into the
588/// `mlfq_llc_cpus` array map.
589///
590/// One list per LLC domain, the Tier-A same-LLC steal window of the
591/// dispatch path. A domain exceeding the `MLFQ_MAX_LLC_CPUS` bound gets
592/// an EMPTY list (nr == 0) instead of failing the whole write: the
593/// dispatch path then skips Tier A for that domain and Tier B's full
594/// rotating window covers it, because Tier B's same-LLC skip is gated
595/// on Tier A having run (tier_a_ran stays false for an empty list). The
596/// window never silently shrinks to a subset of an oversized domain.
597pub fn write_llc_cpu_lists(skel: &mut crate::bpf_skel::BpfSkel<'_>, plan: &LlcPlan) -> Result<()> {
598    if plan.nr_llcs == 0 {
599        return Ok(());
600    }
601
602    for llc in 0..plan.nr_llcs as usize {
603        if plan.llc_cpus[llc].len() > MAX_LLC_CPUS {
604            warn!(
605                "LLC {llc} has {} CPUs, exceeding the supported maximum \
606({MAX_LLC_CPUS}); its Tier-A window is skipped and the full rotating window covers the domain",
607                plan.llc_cpus[llc].len()
608            );
609        }
610        let list = llc_cpu_list_for(&plan.llc_cpus[llc]);
611        write_llc_cpu_list_value(&skel.maps.mlfq_llc_cpus, &list, llc as u32)
612            .with_context(|| format!("failed to write the LLC {llc} CPU list"))?;
613    }
614    Ok(())
615}
616
617/// Word index of @cpu within a CPU bitmap, matching the word layout of
618/// `struct mlfq_bitmap` in `src/bpf/intf.h`.
619fn bitmap_word(cpu: usize) -> usize {
620    cpu >> 6
621}
622
623/// Bit mask of @cpu within its bitmap word.
624fn bitmap_mask(cpu: usize) -> u64 {
625    1u64 << (cpu & 63)
626}
627
628/// Build a CPU-membership bitmap from a CPU list.
629///
630/// Out-of-range CPUs are ignored, matching the BPF-side guards.
631fn build_bitmap(cpus: &[u32]) -> mlfq_bitmap {
632    let mut bm = mlfq_bitmap {
633        words: [0; mlfq_consts_MLFQ_BITMAP_WORDS as usize],
634    };
635    for &cpu in cpus {
636        let cpu = cpu as usize;
637        if cpu >= MAX_CPUS {
638            continue;
639        }
640        bm.words[bitmap_word(cpu)] |= bitmap_mask(cpu);
641    }
642    bm
643}
644
645/// Write @value into the ARRAY map @map at @key (a u32 key).
646fn write_bitmap_value(map: &libbpf_rs::Map, value: &mlfq_bitmap, key: u32) -> Result<()> {
647    let key_bytes = key.to_ne_bytes();
648    let value_bytes = unsafe {
649        std::slice::from_raw_parts(value as *const _ as *const u8, size_of::<mlfq_bitmap>())
650    };
651    map.update(&key_bytes, value_bytes, MapFlags::ANY)?;
652    Ok(())
653}
654
655/// Build the Tier-A CPU list one LLC domain publishes.
656///
657/// Thin wrapper kept for the existing call sites. The actual packing
658/// and oversize handling lives in `build_llc_cpu_list`.
659fn llc_cpu_list_for(cpus: &[u32]) -> mlfq_llc_cpu_list {
660    build_llc_cpu_list(cpus)
661}
662
663/// Pack a CPU list into the `mlfq_llc_cpu_list` map value type.
664///
665/// If the domain has more than `MLFQ_MAX_LLC_CPUS` CPUs the list is
666/// left empty (nr == 0) so the dispatch path skips Tier-A for that
667/// domain and Tier-B's full rotating window covers it instead of
668/// probing a silently truncated subset. CPUs outside `MAX_CPUS` are
669/// skipped, same as the BPF-side guard, and nr counts only the CPUs
670/// actually stored.
671fn build_llc_cpu_list(cpus: &[u32]) -> mlfq_llc_cpu_list {
672    if cpus.len() > MAX_LLC_CPUS {
673        return mlfq_llc_cpu_list {
674            nr: 0,
675            cpus: [0; mlfq_consts_MLFQ_MAX_LLC_CPUS as usize],
676        };
677    }
678    let mut list = mlfq_llc_cpu_list {
679        nr: 0,
680        cpus: [0; mlfq_consts_MLFQ_MAX_LLC_CPUS as usize],
681    };
682    for &cpu in cpus {
683        if cpu as usize >= MAX_CPUS {
684            continue;
685        }
686        list.cpus[list.nr as usize] = cpu;
687        list.nr += 1;
688    }
689    list
690}
691
692/// Write @value into the ARRAY map @map at @key (a u32 key).
693fn write_llc_cpu_list_value(
694    map: &libbpf_rs::Map,
695    value: &mlfq_llc_cpu_list,
696    key: u32,
697) -> Result<()> {
698    let key_bytes = key.to_ne_bytes();
699    let value_bytes = unsafe {
700        std::slice::from_raw_parts(
701            value as *const _ as *const u8,
702            size_of::<mlfq_llc_cpu_list>(),
703        )
704    };
705    map.update(&key_bytes, value_bytes, MapFlags::ANY)?;
706    Ok(())
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712    use crate::bpf_intf::mlfq_consts_MLFQ_LLC_SCAN_MAX;
713
714    #[test]
715    fn empty_primary_list_falls_back_to_uniform() {
716        let plan = plan_primary_mask(&[], 8);
717        assert!(plan.primary_all);
718        assert!(plan.primary_cpus.is_empty());
719    }
720
721    #[test]
722    fn full_primary_list_is_uniform() {
723        // Every online CPU is primary: no asymmetry to exploit.
724        let plan = plan_primary_mask(&[0, 1, 2, 3, 4, 5, 6, 7], 8);
725        assert!(plan.primary_all);
726    }
727
728    #[test]
729    fn subset_of_online_cpus_is_hybrid() {
730        let plan = plan_primary_mask(&[0, 1, 4, 5], 8);
731        assert!(!plan.primary_all);
732        assert_eq!(plan.primary_cpus, vec![0, 1, 4, 5]);
733    }
734
735    #[test]
736    fn hybrid_list_is_sorted_and_deduplicated() {
737        let plan = plan_primary_mask(&[5, 1, 4, 1, 0], 8);
738        assert!(!plan.primary_all);
739        assert_eq!(plan.primary_cpus, vec![0, 1, 4, 5]);
740    }
741
742    #[test]
743    fn max_cpus_matches_bpf_constant() {
744        // The array sizes and the BPF rodata arrays must stay in lock-step.
745        assert_eq!(
746            MAX_CPUS,
747            crate::bpf_intf::mlfq_consts_MLFQ_MAX_CPUS as usize
748        );
749        assert_eq!(
750            MAX_LLCS,
751            crate::bpf_intf::mlfq_consts_MLFQ_MAX_LLCS as usize
752        );
753    }
754
755    #[test]
756    fn llc_grouping_from_cpu_map() {
757        let plan = plan_llcs(&[(0, 0), (1, 0), (2, 1), (3, 1)], &[], MAX_LLCS);
758        assert_eq!(plan.nr_llcs, 2);
759        assert_eq!(plan.llc_cpus, vec![vec![0, 1], vec![2, 3]]);
760        assert_eq!(plan.cpu_llc[0], 0);
761        assert_eq!(plan.cpu_llc[3], 1);
762        // An unknown CPU keeps the sentinel, never the 0 of a valid domain.
763        assert_eq!(plan.cpu_llc[4], mlfq_consts_MLFQ_MAX_LLCS);
764    }
765
766    #[test]
767    fn llc_has_primary_flags_domains_with_big_cores() {
768        let plan = plan_llcs(&[(0, 0), (1, 0), (2, 1), (3, 1)], &[1], MAX_LLCS);
769        assert_eq!(plan.has_primary[0], 1); // LLC 0 contains primary CPU 1
770        assert_eq!(plan.has_primary[1], 0); // LLC 1 has no primary
771    }
772
773    #[test]
774    fn llc_map_without_primaries_has_no_flags() {
775        let plan = plan_llcs(&[(0, 0), (1, 1)], &[], MAX_LLCS);
776        assert_eq!(plan.has_primary, [0; MAX_LLCS]);
777    }
778
779    #[test]
780    fn llc_plan_disables_when_exceeding_cap() {
781        // 33 domains exceed the 32-domain bound: disable LLC awareness.
782        let map: Vec<(u32, u32)> = (0..33).map(|i| (i, i)).collect();
783        let plan = plan_llcs(&map, &[], MAX_LLCS);
784        assert_eq!(plan.nr_llcs, 0);
785        assert!(plan.llc_cpus.is_empty());
786    }
787
788    #[test]
789    fn llc_plan_saturates_on_u32_max_llc_id() {
790        // A llc_id of u32::MAX must not wrap the domain count to zero,
791        // which would pass the cap check and later index out of bounds.
792        let plan = plan_llcs(&[(0, 0), (1, u32::MAX)], &[], MAX_LLCS);
793        assert_eq!(plan.nr_llcs, 0);
794        assert!(plan.llc_cpus.is_empty());
795    }
796
797    #[test]
798    fn llc_plan_disables_on_empty_map() {
799        let plan = plan_llcs(&[], &[], MAX_LLCS);
800        assert_eq!(plan.nr_llcs, 0);
801    }
802
803    #[test]
804    fn llc_plan_skips_out_of_range_cpus() {
805        let plan = plan_llcs(&[(0, 0), (5000, 1)], &[], MAX_LLCS);
806        // The oversized CPU id must not index into the arrays.
807        assert_eq!(plan.nr_llcs, 2);
808        assert_eq!(plan.cpu_llc[0], 0);
809        // An in-range CPU the map does not cover keeps the sentinel.
810        assert_eq!(plan.cpu_llc[1], mlfq_consts_MLFQ_MAX_LLCS);
811        assert!(plan.llc_cpus[1].is_empty());
812    }
813
814    #[test]
815    fn bitmap_word_index_math() {
816        assert_eq!(bitmap_word(0), 0);
817        assert_eq!(bitmap_word(63), 0);
818        assert_eq!(bitmap_word(64), 1);
819        assert_eq!(bitmap_word(127), 1);
820        assert_eq!(bitmap_word(1023), 15);
821    }
822
823    #[test]
824    fn bitmap_mask_math() {
825        assert_eq!(bitmap_mask(0), 1);
826        assert_eq!(bitmap_mask(5), 1 << 5);
827        assert_eq!(bitmap_mask(63), 1u64 << 63);
828    }
829
830    #[test]
831    fn build_bitmap_sets_expected_bits() {
832        let bm = build_bitmap(&[0, 1, 64, 1023]);
833        assert_eq!(bm.words[0], 0b11);
834        assert_eq!(bm.words[1], 1);
835        assert_eq!(bm.words[15], 1u64 << 63);
836        assert_eq!(bm.words[2], 0);
837    }
838
839    #[test]
840    fn build_bitmap_ignores_out_of_range_cpus() {
841        let bm = build_bitmap(&[1024, 5000]);
842        assert_eq!(bm.words, [0; mlfq_consts_MLFQ_BITMAP_WORDS as usize]);
843    }
844
845    #[test]
846    fn bitmap_words_count_matches_bpf_constant() {
847        assert_eq!(
848            mlfq_consts_MLFQ_BITMAP_WORDS,
849            crate::bpf_intf::mlfq_consts_MLFQ_MAX_CPUS.div_ceil(64)
850        );
851    }
852
853    #[test]
854    fn smt_pair_table() {
855        // Two SMT pairs over four CPUs / two cores: each CPU points at
856        // the other member of its core.
857        let plan = plan_sibling_table(&[(0, 0), (1, 0), (2, 1), (3, 1)]);
858        assert!(plan.smt_on);
859        assert_eq!(&plan.cpu_sibling[..4], &[1, 0, 3, 2]);
860    }
861
862    #[test]
863    fn smt_off_with_distinct_cores() {
864        let plan = plan_sibling_table(&[(0, 0), (1, 1), (2, 2), (3, 3)]);
865        assert!(!plan.smt_on);
866        assert_eq!(&plan.cpu_sibling[..4], &[0, 1, 2, 3]);
867    }
868
869    #[test]
870    fn smt_unpaired_cpu_maps_to_self() {
871        let plan = plan_sibling_table(&[(0, 0), (1, 0), (2, 2)]);
872        assert!(plan.smt_on);
873        assert_eq!(plan.cpu_sibling[0], 1);
874        assert_eq!(plan.cpu_sibling[1], 0);
875        // The unpaired core's CPU has no sibling: itself.
876        assert_eq!(plan.cpu_sibling[2], 2);
877    }
878
879    #[test]
880    fn smt_table_is_order_independent() {
881        let plan = plan_sibling_table(&[(1, 0), (3, 1), (0, 0), (2, 1)]);
882        assert_eq!(&plan.cpu_sibling[..4], &[1, 0, 3, 2]);
883    }
884
885    #[test]
886    fn smt_empty_input_stays_off() {
887        let plan = plan_sibling_table(&[]);
888        assert!(!plan.smt_on);
889        assert_eq!(plan.cpu_sibling[0], 0);
890    }
891
892    #[test]
893    fn largest_llc_unique_max_wins() {
894        assert_eq!(pick_largest_llc(&[16, 32, 8], 3), Some(1));
895    }
896
897    #[test]
898    fn largest_llc_tie_is_disabled() {
899        assert_eq!(pick_largest_llc(&[32, 32, 8], 3), None);
900    }
901
902    #[test]
903    fn largest_llc_single_domain_is_disabled() {
904        assert_eq!(pick_largest_llc(&[32], 1), None);
905    }
906
907    #[test]
908    fn largest_llc_zero_domains_is_disabled() {
909        assert_eq!(pick_largest_llc(&[], 0), None);
910    }
911
912    #[test]
913    fn largest_llc_all_zero_sizes_tie_to_disabled() {
914        // Fully failed discovery: every size reads 0 and the zeros tie.
915        assert_eq!(pick_largest_llc(&[0, 0, 0], 3), None);
916    }
917
918    #[test]
919    fn largest_llc_unreadable_domain_reads_zero() {
920        // One domain's size read failed (0): the other is strictly
921        // largest and wins.
922        assert_eq!(pick_largest_llc(&[16, 0], 2), Some(0));
923    }
924
925    #[test]
926    fn parse_cache_size_suffixes() {
927        assert_eq!(parse_cache_size("16384K"), Some(16384 * 1024));
928        assert_eq!(parse_cache_size("32M"), Some(32 * 1024 * 1024));
929        assert_eq!(parse_cache_size("1G"), Some(1024 * 1024 * 1024));
930        assert_eq!(parse_cache_size("4096"), Some(4096));
931        assert_eq!(parse_cache_size("bogus"), None);
932    }
933
934    #[test]
935    fn llc_size_bytes_picks_the_llc_index_by_id() {
936        // Fake sysfs: index0 is the L2 (id 4), index1 the LLC (id 7);
937        // the CPU's topology llc_id selects the LLC entry.
938        let dir = std::env::temp_dir().join(format!("scx_mlfq_cache_id_{}", std::process::id()));
939        std::fs::create_dir_all(dir.join("cpu0/cache/index0")).unwrap();
940        std::fs::create_dir_all(dir.join("cpu0/cache/index1")).unwrap();
941        std::fs::create_dir_all(dir.join("cpu0/topology")).unwrap();
942        std::fs::write(dir.join("cpu0/cache/index0/level"), "2\n").unwrap();
943        std::fs::write(dir.join("cpu0/cache/index0/size"), "512K\n").unwrap();
944        std::fs::write(dir.join("cpu0/cache/index0/id"), "4\n").unwrap();
945        std::fs::write(dir.join("cpu0/cache/index1/level"), "3\n").unwrap();
946        std::fs::write(dir.join("cpu0/cache/index1/size"), "32M\n").unwrap();
947        std::fs::write(dir.join("cpu0/cache/index1/id"), "7\n").unwrap();
948        std::fs::write(dir.join("cpu0/topology/llc_id"), "7\n").unwrap();
949
950        let size = llc_size_bytes(&dir.join("cpu0/cache"));
951        assert_eq!(size, Some(32 * 1024 * 1024));
952        std::fs::remove_dir_all(&dir).unwrap();
953    }
954
955    #[test]
956    fn llc_size_bytes_falls_back_to_deepest_level_without_id() {
957        // No id files and no topology llc_id: the deepest index wins.
958        let dir = std::env::temp_dir().join(format!("scx_mlfq_cache_noid_{}", std::process::id()));
959        std::fs::create_dir_all(dir.join("cpu0/cache/index0")).unwrap();
960        std::fs::create_dir_all(dir.join("cpu0/cache/index1")).unwrap();
961        std::fs::write(dir.join("cpu0/cache/index0/level"), "2\n").unwrap();
962        std::fs::write(dir.join("cpu0/cache/index0/size"), "512K\n").unwrap();
963        std::fs::write(dir.join("cpu0/cache/index1/level"), "3\n").unwrap();
964        std::fs::write(dir.join("cpu0/cache/index1/size"), "32M\n").unwrap();
965
966        let size = llc_size_bytes(&dir.join("cpu0/cache"));
967        assert_eq!(size, Some(32 * 1024 * 1024));
968        std::fs::remove_dir_all(&dir).unwrap();
969    }
970
971    #[test]
972    fn llc_size_bytes_missing_path_is_none() {
973        let dir =
974            std::env::temp_dir().join(format!("scx_mlfq_cache_missing_{}", std::process::id()));
975        assert_eq!(llc_size_bytes(&dir.join("cpu0/cache")), None);
976    }
977
978    #[test]
979    fn build_llc_cpu_list_layout() {
980        let list = build_llc_cpu_list(&[0, 1, 2, 1023]);
981        assert_eq!(list.nr, 4);
982        assert_eq!(&list.cpus[..4], &[0, 1, 2, 1023]);
983        assert_eq!(list.cpus[4], 0);
984    }
985
986    #[test]
987    fn build_llc_cpu_list_skips_out_of_range() {
988        let list = build_llc_cpu_list(&[0, 5000, 1]);
989        assert_eq!(list.nr, 2);
990        assert_eq!(&list.cpus[..2], &[0, 1]);
991    }
992
993    #[test]
994    fn max_llc_cpus_matches_bpf_constant() {
995        assert_eq!(MAX_LLC_CPUS, mlfq_consts_MLFQ_MAX_LLC_CPUS as usize);
996    }
997
998    #[test]
999    fn oversized_llc_domain_publishes_an_empty_list() {
1000        // A domain larger than the Tier-A window gets nr == 0: Tier A
1001        // skips it and Tier B's full rotating window covers the domain.
1002        let over: Vec<u32> = (0..MAX_LLC_CPUS as u32 + 1).collect();
1003        assert_eq!(llc_cpu_list_for(&over).nr, 0);
1004
1005        // A domain that fits the window publishes every CPU, and the
1006        // window width equals the list bound, so the constant modulo
1007        // covers the whole populated list.
1008        let fits: Vec<u32> = (0..MAX_LLC_CPUS as u32).collect();
1009        let list = llc_cpu_list_for(&fits);
1010        assert_eq!(list.nr, MAX_LLC_CPUS as u32);
1011        assert_eq!(MAX_LLC_CPUS, mlfq_consts_MLFQ_LLC_SCAN_MAX as usize);
1012    }
1013}