Skip to main content

scx_eevdf/
main.rs

1// SPDX-License-Identifier: GPL-2.0
2//
3// Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES.
4
5// This software may be used and distributed according to the terms of the
6// GNU General Public License version 2.
7
8mod bpf_skel;
9pub use bpf_skel::*;
10pub mod bpf_intf;
11pub use bpf_intf::*;
12
13mod console;
14mod stats;
15
16use std::collections::BTreeMap;
17use std::ffi::CStr;
18use std::mem::MaybeUninit;
19use std::os::fd::AsFd;
20use std::os::fd::AsRawFd;
21use std::os::fd::FromRawFd;
22use std::os::fd::OwnedFd;
23use std::sync::Arc;
24use std::sync::atomic::AtomicBool;
25use std::sync::atomic::Ordering;
26use std::time::Duration;
27use std::time::Instant;
28
29use anyhow::Context;
30use anyhow::Result;
31use anyhow::bail;
32use clap::Parser;
33use crossbeam::channel::RecvTimeoutError;
34use libbpf_rs::MapCore;
35use libbpf_rs::OpenObject;
36use libbpf_rs::ProgramInput;
37use libbpf_rs::ProgramType;
38use libbpf_rs::skel::Skel;
39use log::debug;
40use log::warn;
41use scx_arena::ArenaLib;
42use scx_stats::prelude::*;
43use scx_utils::NR_CPU_IDS;
44use scx_utils::NR_CPUS_POSSIBLE;
45use scx_utils::SchedDomainSource;
46use scx_utils::ScxExitKind;
47use scx_utils::Topology;
48use scx_utils::UserExitInfo;
49use scx_utils::build_id;
50use scx_utils::compat;
51use scx_utils::libbpf_clap_opts::LibbpfOpts;
52use scx_utils::scx_ops_attach;
53use scx_utils::scx_ops_cid_load;
54use scx_utils::scx_ops_cid_open;
55use scx_utils::try_set_rlimit_infinity;
56use scx_utils::uei_exited;
57use scx_utils::uei_read;
58use stats::Metrics;
59use stats::OpStats;
60
61const SCHEDULER_NAME: &str = "scx_eevdf";
62
63fn kernel_major_minor(release: &str) -> Option<(u32, u32)> {
64    let mut fields = release.split('.');
65    let major = fields.next()?.parse().ok()?;
66    let minor = fields
67        .next()?
68        .chars()
69        .take_while(|c| c.is_ascii_digit())
70        .collect::<String>()
71        .parse()
72        .ok()?;
73
74    Some((major, minor))
75}
76
77fn warn_on_old_kernel() {
78    let mut uts = MaybeUninit::<libc::utsname>::uninit();
79
80    if unsafe { libc::uname(uts.as_mut_ptr()) } != 0 {
81        return;
82    }
83
84    let uts = unsafe { uts.assume_init() };
85    let release = unsafe { CStr::from_ptr(uts.release.as_ptr()) }.to_string_lossy();
86
87    if kernel_major_minor(&release).is_some_and(|version| version < (7, 2)) {
88        warn!(
89            "kernel {release} is older than v7.2; scx_eevdf requires the sched_ext cid/tid support introduced in v7.2 and may fail to load (a kernel with the support backported may still work)"
90        );
91    }
92}
93
94/// Run a SEC("syscall") program with @args as its context.
95///
96/// Despite the name this is not a test run, it's the supported way of invoking
97/// a syscall program from userspace.
98fn run_syscall_prog<T>(prog: &libbpf_rs::ProgramMut<'_>, args: &mut T) -> Result<()> {
99    let input = ProgramInput {
100        context_in: Some(unsafe {
101            std::slice::from_raw_parts_mut(args as *mut T as *mut u8, std::mem::size_of::<T>())
102        }),
103        ..Default::default()
104    };
105
106    let output = prog.test_run(input)?;
107    if output.return_value != 0 {
108        bail!(
109            "{} returned {}",
110            prog.name().to_string_lossy(),
111            output.return_value as i32
112        );
113    }
114
115    Ok(())
116}
117
118#[derive(Debug, clap::Parser)]
119#[command(
120    name = "scx_eevdf",
121    version,
122    disable_version_flag = true,
123    about = "Topology-aware scheduler that preserves task-to-CPU locality, built on EEVDF concepts."
124)]
125struct Opts {
126    /// Exit debug dump buffer length. 0 indicates default.
127    #[clap(long, default_value = "0")]
128    exit_dump_len: u32,
129
130    /// Maximum scheduling slice duration in microseconds.
131    ///
132    /// The default is fair.c's sysctl_sched_base_slice as update_sysctl()
133    /// sets it: the normalized 700 us scaled by 1 + ilog2(min(nr_cpus, 8)),
134    /// 2.8 ms on eight CPUs or more, so the two schedulers issue requests of
135    /// the same size on the same machine. A kernel that runs fair.c at some
136    /// other slice, /sys/kernel/debug/sched/base_slice_ns says which, is
137    /// matched by setting it here. A task that has company on its CPU is asked for the CPU when
138    /// its request runs out, by a timer armed for its deadline, see
139    /// --no-hrtick, so the slice is what it says whatever the kernel's HZ.
140    /// Without the timer a slice is only acted on from the tick, and one of
141    /// exactly a tick buys two: the task is handed the CPU a few microseconds
142    /// after the tick that freed it, so at the next tick it is those few
143    /// microseconds short of its slice and runs a whole further tick.
144    #[clap(short = 's', long)]
145    slice_us: Option<u64>,
146
147    /// Time, in microseconds, that a task stays cache hot on the CPU it last ran on.
148    ///
149    /// A task that stopped running within this long is left alone by the idle CPUs
150    /// looking for work to steal: its own CPU takes it back within a slice, while
151    /// moving it costs its cache. This is the equivalent of task_hot() with
152    /// sysctl_sched_migration_cost in fair.c. 0 makes every queued task stealable
153    /// right away, which spreads the load faster at the cost of cache locality.
154    #[clap(short = 'm', long, default_value = "500")]
155    migration_cost_us: u64,
156
157    /// Failed scans an idle CPU tolerates before it stops honouring cache hotness.
158    ///
159    /// An idle CPU that finds nothing it is allowed to take, while work is queued
160    /// somewhere it could have run, counts the attempt; once it has counted more
161    /// than this many in a row it takes the head of a queue whether or not the
162    /// task is still hot on the CPU it ran on. This is sd->cache_nice_tries
163    /// against sd->nr_balance_failed in can_migrate_task(), and the value applies
164    /// to a scan within the LLC; one more is allowed beyond it. 0 gives up cache
165    /// locality on the first failure, a large value never gives it up and lets a
166    /// CPU stay idle beside a runnable task for as long as the task keeps being
167    /// hot.
168    #[clap(short = 'c', long, default_value = "1", value_parser = clap::value_parser!(u32).range(0..=255))]
169    cache_nice_tries: u32,
170
171    /// Scan for work on an idle CPU whatever the scan costs.
172    ///
173    /// An idle CPU keeps an average of how long it stays idle after a scan and
174    /// the most a scan at each level has cost, and does not start a scan its
175    /// idle time would not pay for, since a CPU its own wakeups keep bringing
176    /// back is about to have work of its own: sched_balance_newidle()'s
177    /// avg_idle against sd->max_newidle_lb_cost. This drops only the cost
178    /// budget; --no-newidle-sampling separately disables success-rate
179    /// sampling.
180    #[clap(long, action = clap::ArgAction::SetTrue)]
181    no_newidle_cost: bool,
182
183    /// Do not sample new-idle scans from their observed success rate.
184    ///
185    /// fair.c's NI_RANDOM and NI_RATE features avoid repeatedly scanning a
186    /// domain that rarely supplies work. Each cid and topology level tracks
187    /// successful pulls and call frequency, then probabilistically admits
188    /// scans in proportion to that estimate. The estimator compensates a
189    /// successful sampled scan by its inverse sampling probability.
190    ///
191    /// Sampling is enabled by default, as both fair.c features are. This
192    /// option makes every new-idle level eligible to scan, subject only to
193    /// the separate avg-idle cost budget controlled by --no-newidle-cost.
194    #[clap(long, action = clap::ArgAction::SetTrue)]
195    no_newidle_sampling: bool,
196
197    /// Do not bound wakeup idle scans by the target LLC's utilization.
198    ///
199    /// fair.c stops looking for an idle CPU once the LLC it is searching is
200    /// busy enough to make the search unlikely to pay, SIS_UTIL: periodic load
201    /// balance leaves a scan budget behind, computed from the LLC's average
202    /// utilization, which falls quadratically and reaches zero at about 85%.
203    /// scx_eevdf does the same, over a window that starts at the target and
204    /// wraps inside the LLC so a bounded scan still reaches every CPU across
205    /// successive wakeups, and gives up where select_idle_cpu() returns -1
206    /// rather than carrying the search on to the node and the machine.
207    ///
208    /// This disables it and scans the whole LLC on every wakeup. Worth trying
209    /// on a machine whose LLC is small: the scan reads the idle bitmap a word
210    /// at a time, so an LLC of 64 CPUs or fewer costs one read whatever the
211    /// budget says, and the bound then only loses idle CPUs it would have
212    /// found.
213    #[clap(long, action = clap::ArgAction::SetTrue)]
214    no_sis_util: bool,
215
216    /// Look past the target LLC for an idle CPU.
217    ///
218    /// By default, match select_idle_sibling(), which is scoped to sd_llc: when
219    /// the LLC has no idle CPU, queue the task on its affine target and leave
220    /// spreading across LLCs to load balance, where the migration cost is
221    /// weighed against the idle time it would use. This instead extends the
222    /// wakeup scan to the node and then the whole machine.
223    ///
224    /// This can keep work off a busy SMT sibling when a whole idle core exists
225    /// in another LLC, at the cost of weaker cache locality and a scan fair.c
226    /// never pays. It has no effect when one LLC spans every CPU.
227    #[clap(long, action = clap::ArgAction::SetTrue)]
228    llc_extend: bool,
229
230    /// Disable NUMA optimizations.
231    #[clap(short = 'n', long, action = clap::ArgAction::SetTrue)]
232    disable_numa: bool,
233
234    /// Disable CPU frequency control.
235    #[clap(short = 'f', long, action = clap::ArgAction::SetTrue)]
236    disable_cpufreq: bool,
237
238    /// Disable SMT.
239    #[clap(short = 'S', long, action = clap::ArgAction::SetTrue)]
240    disable_smt: bool,
241
242    /// Do not schedule the cpu controller's cgroups as groups.
243    ///
244    /// By default, a cgroup competes with its siblings at its cpu.weight and its
245    /// tasks share what it gets, the way fair.c's group scheduling does, with
246    /// cpu.weight meaning the weight per active CPU (fair.c's default
247    /// cgroup_mode, "concur").
248    ///
249    /// This schedules tasks on their nice levels alone and ignores cpu.weight.
250    /// It avoids walking a nested cgroup hierarchy as tasks wake and sleep,
251    /// trading the cpu controller's isolation semantics for some throughput.
252    ///
253    /// Group scheduling needs a kernel built with CONFIG_EXT_GROUP_SCHED. If
254    /// the running kernel lacks it, scx_eevdf falls back to this behavior.
255    #[clap(long, action = clap::ArgAction::SetTrue)]
256    disable_cgroups: bool,
257
258    /// Deprecated compatibility alias; cgroup scheduling is already enabled.
259    #[clap(
260        short = 'g',
261        long,
262        hide = true,
263        action = clap::ArgAction::SetTrue,
264        conflicts_with = "disable_cgroups"
265    )]
266    enable_cgroups: bool,
267
268    /// Ignore cpu.max while scheduling cgroups as groups.
269    ///
270    /// A cgroup is normally held to the bandwidth its cpu.max asks for: it
271    /// runs for at most its quota in every period, and its tasks wait for the
272    /// next one once it is spent. This turns that off and leaves cpu.weight
273    /// and cpu.idle in place, which is what the comparison against a kernel
274    /// with no bandwidth control needs.
275    ///
276    /// Has no further effect with --disable-cgroups.
277    #[clap(long, action = clap::ArgAction::SetTrue)]
278    disable_cpu_max: bool,
279
280    /// Force every CPU to have the same capacity.
281    ///
282    /// By default scx_eevdf uses the kernel-exported cpu_capacity values when the
283    /// kernel has an active SD_ASYM_CPUCAPACITY domain.
284    #[clap(
285        short = 'u',
286        long,
287        action = clap::ArgAction::SetTrue,
288        conflicts_with = "asym_capacity"
289    )]
290    uniform_capacity: bool,
291
292    /// Force asymmetric capacities using the best available hardware estimate.
293    ///
294    /// This uses ACPI CPPC, cpufreq, or cpu_capacity through scx_utils rather
295    /// than following the kernel's selected capacity classes. It can therefore
296    /// expose hybrid x86 capacity differences that fair.c does not use.
297    #[clap(
298        long,
299        action = clap::ArgAction::SetTrue,
300        conflicts_with = "uniform_capacity"
301    )]
302    asym_capacity: bool,
303
304    /// Maximum capacity difference, in percent, within one capacity tier.
305    ///
306    /// Capacities are compared with the fastest CPU in the current tier, so
307    /// small differences such as favored versus ordinary P-cores do not make
308    /// wakeups chase a marginally faster CPU. A larger gap still starts a new
309    /// tier and retains the preference for P-cores over E-cores. 0 restores
310    /// one tier per distinct reported capacity. The default is 0 when following
311    /// the kernel and 5 with --asym-capacity.
312    #[clap(short = 't', long, value_parser = clap::value_parser!(u32).range(0..=50))]
313    capacity_tier_tolerance_pct: Option<u32>,
314
315    /// Disable the kernel's SD_ASYM_PACKING CPU preference.
316    ///
317    /// Placement then follows the capacity tiers selected by the default
318    /// kernel capacity, --uniform-capacity, or --asym-capacity mode.
319    #[clap(long, action = clap::ArgAction::SetTrue)]
320    disable_asym_packing: bool,
321
322    /// Prefer lower-numbered CPUs within each SMT core.
323    ///
324    /// When the kernel exposes no priority between the threads of a core,
325    /// pick the lowest-numbered idle sibling of the selected core, at wakeup
326    /// and for balance destinations. Placement only: cores are not ranked by
327    /// CPU ID and no task is migrated between the threads of one core. A
328    /// determinism aid for comparisons, not a performance policy.
329    #[clap(
330        long,
331        action = clap::ArgAction::SetTrue,
332        conflicts_with = "disable_smt"
333    )]
334    smt_asym_packing: bool,
335
336    /// Let a wakeup leave its LLC to find a whole idle core.
337    ///
338    /// An idle SMT sibling of a busy core is half of a core that is already
339    /// working: placing a task there costs the thread running on the other
340    /// sibling about half its throughput for as long as the two overlap. By
341    /// default the idle scan follows select_idle_sibling() and takes that
342    /// sibling, because fair.c stops at the LLC and leaves the rest to the
343    /// periodic balancer. This makes the scan prefer a whole idle core in
344    /// another LLC instead, trading cache locality for core throughput, and
345    /// only while such a core exists.
346    ///
347    /// It matters on machines whose LLC spans a whole NUMA node: once that
348    /// node is saturated, everything the machine wakes lands on its busy
349    /// cores' siblings while another node's cores sit fully idle. Barrier-
350    /// synchronized workloads pay for it many times over, since every thread
351    /// waits for the halved one. Balancing cannot repair it, as those visits
352    /// are far shorter than any balance interval.
353    #[clap(
354        long,
355        action = clap::ArgAction::SetTrue,
356        conflicts_with = "disable_smt"
357    )]
358    smt_whole_core: bool,
359
360    /// Disable direct dispatch during synchronous wakeups.
361    ///
362    /// Enabling this option can lead to a more uniform load distribution across available cores,
363    /// potentially improving performance in certain scenarios. However, it may come at the cost of
364    /// reduced efficiency for pipe-intensive workloads that benefit from tighter producer-consumer
365    /// coupling.
366    #[clap(short = 'w', long, action = clap::ArgAction::SetTrue)]
367    no_wake_sync: bool,
368
369    /// Do not weigh the waking CPU against the previous one when both are busy.
370    ///
371    /// By default, send the wakee to whichever of its previous CPU and the
372    /// waking CPU would be lighter after the move. Cid load is sampled from
373    /// the tick and task load reuses its execution-utilization estimate, so
374    /// the comparison adds no runnable-state accounting. Disable this on
375    /// systems where the resulting placement performs worse, such as large
376    /// asymmetric-SMT systems.
377    #[clap(long, action = clap::ArgAction::SetTrue)]
378    no_wa_weight: bool,
379
380    /// Periodic busy load balancing runs every domain-weight milliseconds
381    /// times this factor, sd->busy_factor (16 in fair.c).
382    #[clap(long, default_value = "16", value_parser = clap::value_parser!(u32).range(1..=64))]
383    busy_balance_factor: u32,
384
385    /// Do not account for CPU capacity unavailable to sched_ext.
386    ///
387    /// By default, measure higher-class displacement from stopping/running
388    /// events and IRQ/steal time from rq_clock - rq_clock_task. Periodic
389    /// balance normalizes load by the resulting effective capacity, while
390    /// retaining its normal imbalance, affinity, hotness, and EEVDF gates.
391    /// A task displaced by a higher class may be requeued into a less-loaded
392    /// cid's EDQ, where it still competes in EEVDF order. Hardware capacity
393    /// and wakeup placement remain unchanged.
394    #[clap(long, action = clap::ArgAction::SetTrue)]
395    no_capacity_pressure: bool,
396
397    /// Service is charged in rq_clock_task(), the clock update_curr() uses:
398    /// wall time less the interrupt time and the hypervisor steal time the
399    /// CPU spent on something else. This charges plain wall time, the rq
400    /// clock, instead, so a task pays for the interrupts that land on its
401    /// CPU and for the time the host took from its vCPU.
402    #[clap(long, action = clap::ArgAction::SetTrue)]
403    no_task_clock: bool,
404
405    /// Interrupt on the deadlines alone, without asking who is owed service.
406    ///
407    /// The wakeup preemption normally fires only when the woken task is owed
408    /// service and the running one is not, the way pick_eevdf() drops an
409    /// ineligible current task and then picks an eligible waiter. This skips
410    /// both tests and decides on the deadlines alone. For comparing the two
411    /// rules against each other.
412    #[clap(short = 'e', long, action = clap::ArgAction::SetTrue)]
413    no_eligibility: bool,
414
415    /// Take the head of a deadline-ordered queue at dispatch, eligible or not.
416    ///
417    /// The EDQ normally uses its augmented tree to find the earliest-deadline
418    /// task whose vruntime is eligible in logarithmic time. This option takes
419    /// the head instead; --no-eligibility implies it. Wakeup preemption and
420    /// keep-running decisions are head-based either way because they cannot
421    /// observe the current task, queue, and virtual-time frontier atomically.
422    #[clap(long, action = clap::ArgAction::SetTrue)]
423    no_eligible_scan: bool,
424
425    /// Interrupt a running task that is still owed service.
426    ///
427    /// The wakeup preemption normally leaves a running task alone until it has
428    /// had the share its pack owes it, so a woken task with an earlier deadline
429    /// waits for parity rather than for the slice to end. This drops that
430    /// protection: the woken task takes the CPU as soon as it is eligible
431    /// itself. Unlike --no-eligibility the woken task is still asked whether it
432    /// is owed service; only the task already running loses its protection.
433    ///
434    /// This is RUN_TO_PARITY off, in the sense the feature had when EEVDF was
435    /// merged. For comparing the two rules against each other.
436    #[clap(short = 'r', long, action = clap::ArgAction::SetTrue)]
437    no_run_to_parity: bool,
438
439    /// Keep a longer-request running task protected from shorter wakees.
440    ///
441    /// Normally an eligible waking task that asks for a shorter request than
442    /// the task currently running can preempt it despite RUN_TO_PARITY. The
443    /// wakee is put on an available local DSQ as a one-shot short buddy,
444    /// matching the wakeup side of PREEMPT_SHORT in fair.c. This option keeps
445    /// the ordinary protection for comparison.
446    #[clap(long, action = clap::ArgAction::SetTrue)]
447    no_preempt_short: bool,
448
449    /// Do not compensate placement lag for joining a virtual-time pack.
450    ///
451    /// Normally scx_eevdf inflates a task's placement offset before adding its
452    /// weight to the destination pack, so the movement of the weighted-average
453    /// reference does not dilute the requested lag. This disables that
454    /// PLACE_LAG compensation and restores the older behavior where lag can
455    /// evaporate as a task repeatedly sleeps and wakes. For comparing the two
456    /// placement rules against each other.
457    #[clap(long, action = clap::ArgAction::SetTrue)]
458    no_place_lag: bool,
459
460    /// Give a task a new request every time it is placed.
461    ///
462    /// A task that is moved to another CPU, or queued again without having
463    /// slept, normally keeps what is left of the request it was in the middle
464    /// of: its deadline is carried relative to its vruntime and re-based where
465    /// it lands. This grants it a whole new request instead, so it sorts
466    /// behind tasks that were queued after it. A task that slept gets a new
467    /// request either way.
468    ///
469    /// This is PLACE_REL_DEADLINE off. For comparing the two rules against
470    /// each other.
471    #[clap(short = 'R', long, action = clap::ArgAction::SetTrue)]
472    no_place_rel_deadline: bool,
473
474    /// Let a task that blocks over-served carry its whole debt across the sleep.
475    ///
476    /// A task that blocks while it is over-served normally has the debt paid
477    /// off by the pack it left, with the service delivered there while it
478    /// slept, and never more than paid off: it wakes owing at most what it
479    /// owed, and often nothing. This makes it carry the whole debt to its next
480    /// placement instead, however long it slept.
481    ///
482    /// This is DELAY_DEQUEUE and DELAY_ZERO off. For comparing the two rules
483    /// against each other.
484    #[clap(short = 'D', long, action = clap::ArgAction::SetTrue)]
485    no_delay_dequeue: bool,
486    /// Wake a task that blocked over-served through the placement.
487    ///
488    /// A task that blocks while it is over-served is still on the runqueue it
489    /// blocked on as far as fair.c is concerned, and a wakeup that comes
490    /// before its debt is paid requeues it there, ttwu_runnable(), without
491    /// choosing a CPU for it: it runs there once it is picked, or wherever a
492    /// balance moves it. That is what happens here too. This option sends
493    /// such a task through wake_affine() and the idle scan like any other
494    /// wakeup instead, so it gets an idle CPU when there is one. Implied by
495    /// --no-delay-dequeue. For comparing the two rules against each other.
496    #[clap(long, action = clap::ArgAction::SetTrue)]
497    no_delay_requeue: bool,
498
499    /// Let a task blocked on a mutex block instead of donating its turn.
500    ///
501    /// With proxy execution, a task that blocks on a mutex stays runnable as
502    /// a donor, and when it is picked its scheduling context runs the mutex
503    /// owner, which gets the lock released sooner. The donor keeps its place
504    /// in the queue and is charged for what the owner runs on its behalf, as
505    /// fair.c does. This makes such a task block and leave the queue instead.
506    /// Proxy execution is used only on kernels that support it for sched_ext.
507    #[clap(long, action = clap::ArgAction::SetTrue)]
508    no_proxy_exec: bool,
509
510    /// Notice the end of a request at the tick after it, not when it happens.
511    ///
512    /// A request is normally ended on the spot by a timer armed for the
513    /// running task's deadline whenever it has company, the way HRTICK does
514    /// in fair.c. Without it a task holds the CPU until the tick that follows
515    /// the end of its request, up to a whole tick late, and a task waiting
516    /// behind it waits that long. For comparing the two against each other.
517    #[clap(short = 'H', long, action = clap::ArgAction::SetTrue)]
518    no_hrtick: bool,
519    /// Never interrupt a running task for a woken one with an earlier deadline.
520    ///
521    /// Every task then runs until its slice ends or it blocks, and a woken task
522    /// waits for that, up to a full slice. This is the behavior before wakeup
523    /// preemption was added, kept for comparison.
524    #[clap(short = 'p', long, action = clap::ArgAction::SetTrue)]
525    no_wakeup_preempt: bool,
526
527    /// Place and test tasks against a pack reference that is not up to date.
528    ///
529    /// A running task's vruntime is only charged when it stops, so the
530    /// reference of its pack stands still while it runs and everything read
531    /// off it in between is behind by up to a request. It is normally brought
532    /// up to date on the spot, the way update_curr() does before every
533    /// place_entity() and every entity_eligible(). This reads it as stored,
534    /// for comparing the two against each other.
535    #[clap(short = 'U', long, action = clap::ArgAction::SetTrue)]
536    no_vref_update: bool,
537
538    /// Enable stats monitoring with the specified interval.
539    #[clap(long)]
540    stats: Option<f64>,
541
542    /// Run in stats monitoring mode with the specified interval. Scheduler
543    /// is not launched.
544    #[clap(long)]
545    monitor: Option<f64>,
546
547    /// Enable verbose output, including libbpf details.
548    #[clap(short = 'v', long, action = clap::ArgAction::SetTrue)]
549    verbose: bool,
550
551    /// Print scheduler version and exit.
552    #[clap(short = 'V', long, action = clap::ArgAction::SetTrue)]
553    version: bool,
554
555    /// Show descriptions for statistics.
556    #[clap(long)]
557    help_stats: bool,
558
559    #[clap(flatten, next_help_heading = "Libbpf Options")]
560    pub libbpf: LibbpfOpts,
561}
562
563/// Kernel run-time accounting of the sched_ext callbacks, BPF_ENABLE_STATS.
564///
565/// Each callback is its own BPF program and the kernel keeps a run count and
566/// a run time per program once the accounting is switched on. That costs two
567/// clock reads around every callback, so it is on only while a stats client
568/// is polling and goes away by itself once nobody has asked for a while.
569struct OpsProfiler {
570    /// Holding the descriptor keeps the accounting enabled.
571    enabled: Option<OwnedFd>,
572    last_read: Instant,
573    warned: bool,
574}
575
576impl OpsProfiler {
577    /// How long without a stats request before the accounting is dropped.
578    const IDLE: Duration = Duration::from_secs(5);
579
580    fn new() -> Self {
581        Self {
582            enabled: None,
583            last_read: Instant::now(),
584            warned: false,
585        }
586    }
587
588    fn enable(&mut self) {
589        if self.enabled.is_some() {
590            return;
591        }
592        let fd = unsafe {
593            libbpf_rs::libbpf_sys::bpf_enable_stats(libbpf_rs::libbpf_sys::BPF_STATS_RUN_TIME)
594        };
595        if fd < 0 {
596            if !self.warned {
597                warn!(
598                    "BPF run-time stats unavailable: {}",
599                    std::io::Error::last_os_error()
600                );
601                self.warned = true;
602            }
603            return;
604        }
605        debug!("BPF run-time stats enabled");
606        self.enabled = Some(unsafe { OwnedFd::from_raw_fd(fd) });
607    }
608
609    /// Drop the accounting once no client has polled for a while.
610    fn expire(&mut self) {
611        if self.enabled.is_some() && self.last_read.elapsed() > Self::IDLE {
612            debug!("BPF run-time stats disabled");
613            self.enabled = None;
614        }
615    }
616
617    /// Cumulative counts and times of every struct_ops program, keyed by the
618    /// callback name. The kernel truncates program names, so the names come
619    /// from the skeleton.
620    fn sample(&mut self, skel: &BpfSkel<'_>) -> BTreeMap<String, OpStats> {
621        self.last_read = Instant::now();
622        self.enable();
623
624        let mut ops = BTreeMap::new();
625        if self.enabled.is_none() {
626            return ops;
627        }
628        for prog in skel.object().progs() {
629            if prog.prog_type() != ProgramType::StructOps {
630                continue;
631            }
632            let mut info: libbpf_rs::libbpf_sys::bpf_prog_info = unsafe { std::mem::zeroed() };
633            let mut len = std::mem::size_of_val(&info) as u32;
634            let rc = unsafe {
635                libbpf_rs::libbpf_sys::bpf_prog_get_info_by_fd(
636                    prog.as_fd().as_raw_fd(),
637                    &mut info,
638                    &mut len,
639                )
640            };
641            if rc != 0 {
642                continue;
643            }
644            let name = prog.name().to_string_lossy();
645            let name = name.strip_prefix("eevdf_").unwrap_or(&name).to_string();
646            ops.insert(
647                name,
648                OpStats {
649                    calls: info.run_cnt,
650                    ns: info.run_time_ns,
651                    misses: info.recursion_misses,
652                },
653            );
654        }
655        ops
656    }
657}
658
659/// What the arena holds, in bytes, and the size of the map. Kernels from
660/// 7.3 account the allocated pages in the map's memlock; before that the
661/// figure is the one kept at the allocator by eevdf_arena_alloc_pages(),
662/// when @counted says those programs are attached, and None otherwise.
663fn arena_usage(skel: &BpfSkel, counted: bool) -> (Option<u64>, u64) {
664    let map = &skel.maps.arena;
665    let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }.max(1) as u64;
666    let max = map.max_entries() as u64 * page;
667    let fd = map.as_fd().as_raw_fd();
668    let memlock: u64 = std::fs::read_to_string(format!("/proc/self/fdinfo/{fd}"))
669        .ok()
670        .and_then(|info| {
671            info.lines()
672                .find_map(|line| line.strip_prefix("memlock:"))
673                .and_then(|v| v.trim().parse().ok())
674        })
675        .unwrap_or(0);
676    let used = if memlock > 0 {
677        Some(memlock)
678    } else if counted {
679        let bss = skel.maps.bss_data.as_ref().unwrap();
680        Some(
681            bss.arena_pages_allocated
682                .saturating_sub(bss.arena_pages_freed)
683                * page,
684        )
685    } else {
686        None
687    };
688    (used, max)
689}
690
691struct Scheduler<'a> {
692    /// The arena's user-space services. Nothing here needs reclaiming, the
693    /// arena is carved once at start, but the stream watcher turns an arena
694    /// fault in a BPF program, which the kernel would otherwise fix up
695    /// silently by dropping the access, into a report and an abort.
696    _arenalib: ArenaLib,
697    /// The page counters of eevdf_arena_alloc_pages(), attached to the
698    /// kernel's arena allocator for the life of the scheduler.
699    _arena_links: Vec<libbpf_rs::Link>,
700    skel: BpfSkel<'a>,
701    struct_ops: Option<libbpf_rs::Link>,
702    stats_server: StatsServer<(), Metrics>,
703    ops_profiler: OpsProfiler,
704    started: Instant,
705}
706
707/// Return TICK_NSEC, the kernel's timing granularity.
708///
709/// The coarse clocks are updated from the timer interrupt and nowhere else, so
710/// what clock_getres() reports for one is the tick period: 1ms on a HZ=1000
711/// kernel, 4ms on HZ=250. This is the bound EEVDF puts on the lag a task
712/// carries, see entity_lag(), and CONFIG_HZ is not otherwise readable without
713/// a kernel config that may not be mounted.
714fn tick_ns() -> u64 {
715    let mut ts = libc::timespec {
716        tv_sec: 0,
717        tv_nsec: 0,
718    };
719
720    let ret = unsafe { libc::clock_getres(libc::CLOCK_MONOTONIC_COARSE, &mut ts) };
721    if ret == 0 && ts.tv_sec == 0 && ts.tv_nsec > 0 {
722        return ts.tv_nsec as u64;
723    }
724
725    warn!("could not read the tick period, assuming HZ=1000");
726
727    1_000_000
728}
729
730/// The request size fair.c hands out by default on this machine.
731///
732/// sysctl_sched_base_slice is not the 700 us the kernel is compiled with.
733/// update_sysctl() scales that at boot, and again on hotplug, by a factor
734/// taken from the number of online CPUs:
735///
736///	unsigned int cpus = min_t(unsigned int, num_online_cpus(), 8);
737///	case SCHED_TUNABLESCALING_LOG:
738///		factor = 1 + ilog2(cpus);
739///	sysctl_sched_base_slice = factor * normalized_sysctl_sched_base_slice;
740///
741/// so a machine with eight CPUs or more runs a 2.8 ms slice. This is the
742/// upstream rule with its default log scaling; a kernel whose distribution
743/// changed the normalized value or an administrator who tuned the sysctl
744/// runs fair.c at some other slice, and --slice-us is how to match it.
745/// Return the number of cgroups whose `file` holds a value `set` accepts, and
746/// one of them, walking the cgroup v2 hierarchy. A cgroup has the cpu
747/// controller's files only where its parent enables the controller.
748fn cgroups_with(
749    root: &std::path::Path,
750    file: &str,
751    set: impl Fn(&str) -> bool,
752) -> (usize, Option<String>) {
753    let mut stack = vec![(root.to_path_buf(), 0)];
754    let (mut count, mut example, mut visited) = (0, None, 0);
755
756    while let Some((dir, depth)) = stack.pop() {
757        visited += 1;
758        if visited > 100_000 {
759            break;
760        }
761        if let Ok(val) = std::fs::read_to_string(dir.join(file)) {
762            if set(val.trim()) {
763                count += 1;
764                if example.is_none() {
765                    let name = dir.strip_prefix(root).unwrap_or(&dir);
766                    example = Some(format!("/{} ({})", name.display(), val.trim()));
767                }
768            }
769        }
770        if depth >= 64 {
771            continue;
772        }
773        let Ok(entries) = std::fs::read_dir(&dir) else {
774            continue;
775        };
776        for entry in entries.flatten() {
777            if entry.file_type().is_ok_and(|t| t.is_dir()) {
778                stack.push((entry.path(), depth + 1));
779            }
780        }
781    }
782
783    (count, example)
784}
785
786/// Tell whether cgroup scheduling can do what the options ask for: warn when
787/// it was asked for and the kernel or the cgroup setup leaves nothing to
788/// hook into, and when a cpu.weight or a cpu.max somebody set is ignored
789/// because the feature that reads it is off.
790fn check_cgroup_support(requested: bool, kernel_support: bool, cpu_max: bool) {
791    let root = std::path::Path::new("/sys/fs/cgroup");
792    let cpu_controller = std::fs::read_to_string(root.join("cgroup.subtree_control"))
793        .is_ok_and(|ctrl| ctrl.split_whitespace().any(|c| c == "cpu"));
794
795    if cpu_controller && !cpu_max {
796        let (count, example) = cgroups_with(root, "cpu.max", |v| {
797            v.split_whitespace().next().is_some_and(|q| q != "max")
798        });
799        if count > 0 {
800            warn!(
801                "{} cgroup(s) set cpu.max, e.g. {}, which is ignored: cgroup \
802                 bandwidth control is disabled by --disable-cgroups or \
803                 --disable-cpu-max",
804                count,
805                example.unwrap_or_default()
806            );
807        }
808    }
809
810    if requested {
811        if !kernel_support {
812            warn!(
813                "the kernel has no sched_ext cgroup support \
814                 (CONFIG_EXT_GROUP_SCHED), cgroups are not scheduled as groups"
815            );
816        } else if !cpu_controller {
817            warn!(
818                "the cpu controller is not enabled in {}, \
819                 every task is scheduled as part of the root cgroup",
820                root.join("cgroup.subtree_control").display()
821            );
822        }
823        return;
824    }
825
826    if !cpu_controller {
827        return;
828    }
829    const CGROUP_WEIGHT_DFL: u64 = 100;
830    let (count, example) = cgroups_with(root, "cpu.weight", |v| {
831        v.parse::<u64>().is_ok_and(|w| w != CGROUP_WEIGHT_DFL)
832    });
833    if count > 0 {
834        warn!(
835            "{} cgroup(s) set cpu.weight, e.g. {}, which is ignored with \
836             --disable-cgroups",
837            count,
838            example.unwrap_or_default()
839        );
840    }
841}
842
843fn base_slice_ns(nr_cpus: usize) -> u64 {
844    const NORMALIZED_BASE_SLICE_NS: u64 = 700_000;
845    let cpus = nr_cpus.clamp(1, 8) as u64;
846    let factor = 1 + cpus.ilog2() as u64;
847
848    factor * NORMALIZED_BASE_SLICE_NS
849}
850
851/// Assign sorted, descending CPU capacities to tiers.
852///
853/// Each capacity is compared with the fastest CPU in the current tier rather
854/// than with the preceding CPU. This prevents a chain of individually small
855/// differences from merging CPUs whose capacities differ significantly.
856fn capacity_tiers(capacities: &[usize], tolerance_pct: u32) -> Vec<u64> {
857    let Some(&first) = capacities.first() else {
858        return Vec::new();
859    };
860    let keep_pct = 100usize - tolerance_pct as usize;
861    let mut anchor = first;
862    let mut tier = 0u64;
863    let mut tiers = Vec::with_capacity(capacities.len());
864
865    for &capacity in capacities {
866        debug_assert!(capacity <= anchor);
867        if capacity.saturating_mul(100) < anchor.saturating_mul(keep_pct) {
868            tier += 1;
869            anchor = capacity;
870        }
871        tiers.push(tier);
872    }
873
874    tiers
875}
876
877impl<'a> Scheduler<'a> {
878    fn init(opts: &'a Opts, open_object: &'a mut MaybeUninit<OpenObject>) -> Result<Self> {
879        try_set_rlimit_infinity();
880
881        if opts.slice_us == Some(0) {
882            bail!("--slice-us must be greater than 0");
883        }
884
885        // Initialize CPU topology.
886        let topo = Topology::new().unwrap();
887
888        // Check host topology to determine if we need to enable SMT capabilities.
889        let smt_enabled = !opts.disable_smt && topo.smt_enabled;
890
891        // Determine the amount of non-empty NUMA nodes in the system.
892        let nr_nodes = topo
893            .nodes
894            .values()
895            .filter(|node| !node.all_cpus.is_empty())
896            .count();
897        // Automatically disable NUMA optimizations when running on non-NUMA systems.
898        let numa_enabled = !opts.disable_numa && nr_nodes > 1;
899
900        // The startup report, one block in the style of the stats blocks,
901        // printed once everything below has settled.
902        let mut report = console::Report::new();
903        report.field(format!("{} CPUs", topo.all_cpus.len()));
904        report.field(format!(
905            "{} NUMA node{}{}",
906            nr_nodes,
907            if nr_nodes == 1 { "" } else { "s" },
908            if nr_nodes > 1 && !numa_enabled {
909                " (--disable-numa)"
910            } else {
911                ""
912            }
913        ));
914        report.field(if smt_enabled { "SMT on" } else { "SMT off" });
915        report.row("build", build_id::full_version(env!("CARGO_PKG_VERSION")));
916        report.row("kernel", console::kernel_release());
917        report.row(
918            "tick",
919            format!("{} us (HZ={})", tick_ns() / 1000, 1_000_000_000 / tick_ns()),
920        );
921
922        let slice_ns = match opts.slice_us {
923            Some(us) => {
924                let ns = us * 1000;
925                report.row("slice", format!("{us} us (--slice-us)"));
926                ns
927            }
928            None => {
929                let ns = base_slice_ns(topo.all_cpus.len());
930                report.row(
931                    "slice",
932                    format!("{} us (700 us scaled as update_sysctl() does)", ns / 1000),
933                );
934                ns
935            }
936        };
937
938        // Print command line.
939        report.row(
940            "options",
941            std::env::args().skip(1).collect::<Vec<_>>().join(" "),
942        );
943
944        // Initialize BPF connector.
945        let mut skel_builder = BpfSkelBuilder::default();
946        skel_builder.obj_builder.debug(opts.verbose);
947        let open_opts = opts.libbpf.clone().into_bpf_open_opts();
948
949        // The cid form's cgroup callbacks were renamed from cgroup_* to
950        // cpuctl_*. The BPF object defines the ops under both names, as two
951        // struct_ops maps sharing the same programs: use the one the running
952        // kernel matches and leave the other uncreated.
953        let cpuctl_names =
954            compat::struct_has_field("sched_ext_ops_cid", "cpuctl_set_weight").unwrap_or(false);
955        let cgroup_names = !cpuctl_names
956            && compat::struct_has_field("sched_ext_ops_cid", "cgroup_set_weight").unwrap_or(false);
957        let mut skel = if cgroup_names {
958            scx_ops_cid_open!(skel_builder, open_object, eevdf_ops_cgroup, open_opts)
959        } else {
960            scx_ops_cid_open!(skel_builder, open_object, eevdf_ops, open_opts)
961        }
962        .context("opening BPF skeleton (does this kernel support cid-form sched_ext?)")?;
963        if cgroup_names {
964            skel.maps.eevdf_ops.set_autocreate(false)?;
965        } else {
966            skel.maps.eevdf_ops_cgroup.set_autocreate(false)?;
967        }
968        // The arena page counters are loaded only with --stats, and then
969        // attached by hand after load, before the first allocation, not
970        // with the rest at attach.
971        for prog in [
972            &mut skel.progs.eevdf_arena_alloc_pages,
973            &mut skel.progs.eevdf_arena_free_pages,
974        ] {
975            prog.set_autoload(opts.stats.is_some());
976            prog.set_autoattach(false);
977        }
978
979        skel.struct_ops.eevdf_ops_mut().exit_dump_len = opts.exit_dump_len;
980        skel.struct_ops.eevdf_ops_cgroup_mut().exit_dump_len = opts.exit_dump_len;
981
982        // Schedule cgroups as groups by default when the kernel
983        // has cpu controller support for sched_ext to hook into. Detaching
984        // the callbacks from the struct_ops keeps the kernel from delivering
985        // them at all, and lets the scheduler load on a kernel whose
986        // sched_ext_ops_cid has no cgroup members to bind them to.
987        let cgroup_requested = opts.enable_cgroups || !opts.disable_cgroups;
988        let cgroup_enabled = cgroup_requested && (cpuctl_names || cgroup_names);
989
990        // cpu.max arrived after the rest of the cpu controller's callbacks, so
991        // it is probed on its own: a kernel that delivers cpu.weight may still
992        // have no member to bind the bandwidth callback to.
993        let bw_field = if cgroup_names {
994            "cgroup_set_bandwidth"
995        } else {
996            "cpuctl_set_bandwidth"
997        };
998        let bw_support = compat::struct_has_field("sched_ext_ops_cid", bw_field).unwrap_or(false);
999        let cpu_max_enabled = cgroup_enabled && !opts.disable_cpu_max && bw_support;
1000        check_cgroup_support(
1001            cgroup_requested,
1002            cpuctl_names || cgroup_names,
1003            cpu_max_enabled,
1004        );
1005        if !cgroup_enabled {
1006            let ops = skel.struct_ops.eevdf_ops_mut();
1007            ops.cpuctl_init = std::ptr::null_mut();
1008            ops.cpuctl_exit = std::ptr::null_mut();
1009            ops.cpuctl_set_weight = std::ptr::null_mut();
1010            ops.cpuctl_set_idle = std::ptr::null_mut();
1011            ops.cpuctl_move = std::ptr::null_mut();
1012            let ops = skel.struct_ops.eevdf_ops_cgroup_mut();
1013            ops.cgroup_init = std::ptr::null_mut();
1014            ops.cgroup_exit = std::ptr::null_mut();
1015            ops.cgroup_set_weight = std::ptr::null_mut();
1016            ops.cgroup_set_idle = std::ptr::null_mut();
1017            ops.cgroup_move = std::ptr::null_mut();
1018            report.row("cgroup scheduling", "off");
1019        } else {
1020            report.row(
1021                "cgroup scheduling",
1022                format!(
1023                    "on ({}_* callbacks)",
1024                    if cgroup_names { "cgroup" } else { "cpuctl" }
1025                ),
1026            );
1027            if cgroup_requested && !opts.disable_cpu_max && !bw_support {
1028                warn!("the kernel has no ops.{bw_field}(), cpu.max is ignored");
1029            }
1030        }
1031        if !cpu_max_enabled {
1032            skel.struct_ops.eevdf_ops_mut().cpuctl_set_bandwidth = std::ptr::null_mut();
1033            skel.struct_ops.eevdf_ops_cgroup_mut().cgroup_set_bandwidth = std::ptr::null_mut();
1034        }
1035
1036        // Override default BPF scheduling parameters.
1037        let rodata = skel.maps.rodata_data.as_mut().unwrap();
1038        rodata.slice_ns = slice_ns;
1039        rodata.tick_ns = tick_ns();
1040        rodata.migration_cost_ns = opts.migration_cost_us * 1000;
1041        rodata.cache_nice_tries = opts.cache_nice_tries;
1042        rodata.no_newidle_cost = opts.no_newidle_cost;
1043        rodata.newidle_sampling = !opts.no_newidle_sampling;
1044        rodata.sis_util = !opts.no_sis_util;
1045        rodata.llc_extend = opts.llc_extend;
1046        rodata.cpufreq_enabled = !opts.disable_cpufreq;
1047        rodata.cgroup_enabled = cgroup_enabled;
1048        rodata.cpu_max_enabled = cpu_max_enabled;
1049        rodata.numa_enabled = numa_enabled;
1050        rodata.smt_enabled = smt_enabled;
1051        rodata.force_smt_asym_packing = opts.smt_asym_packing;
1052        if opts.smt_asym_packing {
1053            report.row(
1054                "SMT sibling priority",
1055                "lower CPU IDs first (--smt-asym-packing)",
1056            );
1057        }
1058        rodata.smt_whole_core = opts.smt_whole_core && smt_enabled;
1059        if opts.smt_whole_core && smt_enabled {
1060            report.row(
1061                "idle scan",
1062                "a whole idle core wins over a busy core's sibling, across LLCs",
1063            );
1064        }
1065        rodata.no_wake_sync = opts.no_wake_sync;
1066        rodata.wa_weight = !opts.no_wa_weight;
1067        rodata.busy_balance_factor = opts.busy_balance_factor;
1068        rodata.capacity_pressure = !opts.no_capacity_pressure;
1069        if opts.no_capacity_pressure {
1070            report.row(
1071                "RT/IRQ capacity pressure",
1072                "disabled (--no-capacity-pressure)",
1073            );
1074        }
1075        rodata.no_task_clock = opts.no_task_clock;
1076        report.row(
1077            "service clock",
1078            if opts.no_task_clock {
1079                "ktime"
1080            } else {
1081                "rq_clock_task"
1082            },
1083        );
1084        rodata.no_wakeup_preempt = opts.no_wakeup_preempt;
1085        rodata.no_eligibility = opts.no_eligibility;
1086        rodata.no_eligible_scan = opts.no_eligible_scan;
1087        rodata.no_run_to_parity = opts.no_run_to_parity;
1088        rodata.no_preempt_short = opts.no_preempt_short;
1089        rodata.no_place_lag = opts.no_place_lag;
1090        rodata.no_place_rel_deadline = opts.no_place_rel_deadline;
1091        rodata.no_delay_dequeue = opts.no_delay_dequeue;
1092        rodata.no_delay_requeue = opts.no_delay_requeue;
1093        rodata.no_hrtick = opts.no_hrtick;
1094        rodata.no_vref_update = opts.no_vref_update;
1095
1096        // Follow the capacity classes selected by the kernel unless explicitly
1097        // overridden. cpu_capacity is topology_get_cpu_scale(), the same input
1098        // used to construct SD_ASYM_CPUCAPACITY domains. scx_utils deliberately
1099        // has a more aggressive hardware-derived estimate, retained here for
1100        // the --asym-capacity override.
1101        let (mut cpus, capacity_mode, default_tolerance): (Vec<_>, _, u32) = if opts
1102            .uniform_capacity
1103        {
1104            (
1105                topo.all_cpus
1106                    .values()
1107                    .map(|cpu| (cpu.clone(), 1024usize))
1108                    .collect(),
1109                "forced uniform",
1110                0,
1111            )
1112        } else if opts.asym_capacity {
1113            (
1114                topo.all_cpus
1115                    .values()
1116                    .map(|cpu| (cpu.clone(), cpu.cpu_capacity))
1117                    .collect(),
1118                "forced hardware-derived",
1119                5,
1120            )
1121        } else {
1122            let kernel_cpus: Option<Vec<_>> = topo
1123                .all_cpus
1124                .values()
1125                .map(|cpu| Some((cpu.clone(), cpu.kernel_cpu_capacity?)))
1126                .collect();
1127
1128            match kernel_cpus {
1129                Some(cpus) => (cpus, "kernel", 0),
1130                None => {
1131                    warn!(
1132                        "kernel CPU capacity classes are not exported by sysfs; falling back to hardware-derived capacities"
1133                    );
1134                    (
1135                        topo.all_cpus
1136                            .values()
1137                            .map(|cpu| (cpu.clone(), cpu.cpu_capacity))
1138                            .collect(),
1139                        "hardware-derived fallback",
1140                        5,
1141                    )
1142                }
1143            }
1144        };
1145
1146        // Capacity tiers: CPUs sorted by capacity in descending order, with
1147        // close capacities coalesced into a tier, 0 being the fastest.
1148        // Capacities are normalized to 1..1024 so the highest is always 1024.
1149        cpus.sort_by_key(|(_, capacity)| std::cmp::Reverse(*capacity));
1150        let max_cap = cpus
1151            .first()
1152            .map(|(_, capacity)| *capacity)
1153            .unwrap_or(1)
1154            .max(1);
1155        let capacities: Vec<_> = cpus.iter().map(|(_, capacity)| *capacity).collect();
1156        let tolerance = opts
1157            .capacity_tier_tolerance_pct
1158            .unwrap_or(default_tolerance);
1159        let tiers = capacity_tiers(&capacities, tolerance);
1160        let nr_capacity_tiers = tiers.last().copied().unwrap_or(0) + 1;
1161        let has_capacity_tiers = nr_capacity_tiers > 1;
1162        // Keep capacity and SD_ASYM_PACKING tiers independent. fair.c uses
1163        // them in different paths; collapsing them into one ordering makes
1164        // packing priority affect every ordinary idle-CPU search.
1165        let mut cpu_tiers: Vec<(u64, u64, u64, u64, bool, u64, u64, u64)> = Vec::new();
1166        let mut sched_domain_source = None;
1167        for (i, (cpu, capacity)) in cpus.iter().enumerate() {
1168            let normalized = (*capacity * 1024 / max_cap).clamp(1, 1024);
1169            let domains = topo
1170                .sched_domain_info(cpu.id)
1171                .context("discovering scheduler-domain policy")?;
1172            sched_domain_source.get_or_insert(domains.source);
1173            cpu_tiers.push((
1174                cpu.id as u64,
1175                normalized as u64,
1176                tiers[i],
1177                tiers[i],
1178                false,
1179                domains.fork_span as u64,
1180                domains.wake_affine_span as u64,
1181                domains.asym_capacity_span as u64,
1182            ));
1183        }
1184        report.row(
1185            "CPU capacity",
1186            format!(
1187                "{capacity_mode} ({nr_capacity_tiers} tier{}, tolerance {tolerance}%)",
1188                if nr_capacity_tiers == 1 { "" } else { "s" }
1189            ),
1190        );
1191        if has_capacity_tiers {
1192            report.row(
1193                "CPUs by capacity",
1194                format!(
1195                    "{:?}",
1196                    cpus.iter().map(|(cpu, _)| cpu.id).collect::<Vec<_>>()
1197                ),
1198            );
1199        }
1200
1201        // Proxy execution: SCX_OPS_ENQ_BLOCKED hands mutex-blocked donors to
1202        // ops.enqueue() with SCX_ENQ_BLOCKED. The flag reads as zero on a
1203        // kernel without it, which leaves proxy execution off.
1204        let ops_enq_blocked = if opts.no_proxy_exec {
1205            0
1206        } else {
1207            *compat::SCX_OPS_ENQ_BLOCKED
1208        };
1209        let proxy_exec = ops_enq_blocked != 0;
1210        skel.maps.rodata_data.as_mut().unwrap().proxy_exec = proxy_exec;
1211        report.row(
1212            "proxy execution",
1213            if proxy_exec {
1214                "on"
1215            } else if opts.no_proxy_exec {
1216                "off (--no-proxy-exec)"
1217            } else {
1218                "off (not supported by the kernel)"
1219            },
1220        );
1221
1222        // Set scheduler flags.
1223        //
1224        // SCX_OPS_BUILTIN_IDLE_PER_NODE is left out: a cid-form scheduler
1225        // cannot use the built-in idle tracking, this one does its own.
1226        let flags = *compat::SCX_OPS_ENQ_LAST
1227            | *compat::SCX_OPS_ENQ_MIGRATION_DISABLED
1228            | *compat::SCX_OPS_ALLOW_QUEUED_WAKEUP
1229            | *compat::SCX_OPS_ENQ_EXITING
1230            | *compat::SCX_OPS_TID_TO_TASK
1231            | ops_enq_blocked;
1232        skel.struct_ops.eevdf_ops_mut().flags = flags;
1233        skel.struct_ops.eevdf_ops_cgroup_mut().flags = flags;
1234
1235        report.row("scheduler flags", format!("{flags:#x}"));
1236
1237        // One hrtick per cid, over the same cid space the arena is sized
1238        // for below. A map is sized before the program is loaded.
1239        let nr_cpus = (*NR_CPU_IDS).max(*NR_CPUS_POSSIBLE);
1240        skel.maps
1241            .hrticks
1242            .set_max_entries(nr_cpus as u32)
1243            .context("sizing the hrtick map")?;
1244
1245        // Load the BPF program for validation.
1246        let mut skel = if cgroup_names {
1247            scx_ops_cid_load!(skel, eevdf_ops_cgroup, uei)
1248        } else {
1249            scx_ops_cid_load!(skel, eevdf_ops, uei)
1250        }?;
1251
1252        // Count arena pages at the allocator, see eevdf_arena_alloc_pages():
1253        // attached before the first allocation, which is eevdf_arena_init()
1254        // below, so the count is exact. Without --stats the programs are not
1255        // even loaded and the allocator pays nothing.
1256        let mut arena_links = Vec::new();
1257        if opts.stats.is_some() {
1258            skel.maps.bss_data.as_mut().unwrap().arena_map_id = skel.maps.arena.info()?.info.id;
1259            for (prog, name) in [
1260                (&skel.progs.eevdf_arena_alloc_pages, "bpf_arena_alloc_pages"),
1261                (&skel.progs.eevdf_arena_free_pages, "bpf_arena_free_pages"),
1262            ] {
1263                match prog.attach() {
1264                    Ok(link) => arena_links.push(link),
1265                    Err(e) => warn!("arena memory will not be counted: attaching to {name}: {e}"),
1266                }
1267            }
1268        }
1269
1270        // Capacity and asymmetric packing are separate kernel policies.
1271        // Topology reconstructs the portable domain spans, but neither
1272        // SD_ASYM_PACKING nor arch_asym_cpu_priority() has a userspace ABI.
1273        // Keep this narrow BPF query until sched_ext provides one.
1274        let mut priorities = Vec::with_capacity(cpu_tiers.len());
1275        let mut all_asym_packing = !opts.disable_asym_packing;
1276        for (cpu, _, _, _, smt_asym_packing, _, _, _) in &mut cpu_tiers {
1277            let mut args = types::eevdf_cpu_priority_args {
1278                cpu: *cpu,
1279                priority: 0,
1280                asym_packing: 0,
1281                smt_asym_packing: 0,
1282            };
1283            run_syscall_prog(&skel.progs.eevdf_get_cpu_priority, &mut args)
1284                .context("querying CPU asymmetric-packing policy")?;
1285            if !opts.disable_asym_packing {
1286                all_asym_packing &= args.asym_packing != 0;
1287                *smt_asym_packing = args.smt_asym_packing != 0;
1288                priorities.push((*cpu, args.priority));
1289            }
1290        }
1291
1292        let mut domain_spans = cpu_tiers
1293            .iter()
1294            .map(|entry| (entry.5, entry.6, entry.7))
1295            .collect::<Vec<_>>();
1296        domain_spans.sort_unstable();
1297        domain_spans.dedup();
1298        report.row(
1299            "domain spans",
1300            format!(
1301                "(fork, wake-affine, asym-capacity, source={}): {:?}",
1302                match sched_domain_source {
1303                    Some(SchedDomainSource::Schedstat) => "schedstat",
1304                    Some(SchedDomainSource::Topology) | None => "topology",
1305                },
1306                domain_spans
1307            ),
1308        );
1309
1310        let sched_asym_capacity =
1311            !opts.uniform_capacity && cpu_tiers.iter().any(|entry| entry.7 != 0);
1312        let asym_capacity = has_capacity_tiers && (sched_asym_capacity || opts.asym_capacity);
1313        if has_capacity_tiers && !asym_capacity {
1314            report.row(
1315                "CPU capacity placement",
1316                "off (no kernel SD_ASYM_CPUCAPACITY domain)",
1317            );
1318        }
1319
1320        priorities.sort_by_key(|(_, priority)| std::cmp::Reverse(*priority));
1321        let distinct_priorities = priorities.windows(2).any(|pair| pair[0].1 != pair[1].1);
1322        let asym_packing = all_asym_packing && distinct_priorities;
1323        let mut nr_place_tiers = if asym_capacity { nr_capacity_tiers } else { 1 };
1324        if asym_packing {
1325            let mut tier = 0u64;
1326            for i in 0..priorities.len() {
1327                if i > 0 && priorities[i - 1].1 != priorities[i].1 {
1328                    tier += 1;
1329                }
1330                let cpu = priorities[i].0;
1331                let entry = cpu_tiers
1332                    .iter_mut()
1333                    .find(|entry| entry.0 == cpu)
1334                    .expect("priority CPU must be present in topology");
1335                entry.3 = tier;
1336            }
1337            nr_place_tiers = tier + 1;
1338            report.row(
1339                "asymmetric packing",
1340                format!(
1341                    "kernel ({} priority tiers, CPUs {:?})",
1342                    nr_place_tiers,
1343                    priorities.iter().map(|(cpu, _)| cpu).collect::<Vec<_>>()
1344                ),
1345            );
1346        } else {
1347            for (_, _, capacity_tier, place_tier, smt_asym_packing, _, _, _) in &mut cpu_tiers {
1348                *place_tier = if asym_capacity { *capacity_tier } else { 0 };
1349                *smt_asym_packing = false;
1350            }
1351            report.row(
1352                "asymmetric packing",
1353                format!(
1354                    "{}; placement follows {}",
1355                    if opts.disable_asym_packing {
1356                        "disabled"
1357                    } else {
1358                        "off"
1359                    },
1360                    if asym_capacity {
1361                        format!("{capacity_mode} capacity tiers")
1362                    } else {
1363                        "uniform capacity".to_string()
1364                    },
1365                ),
1366            );
1367        }
1368
1369        // Size the arena for the cid space, which is num_possible_cpus()
1370        // wide, and hand over the capacity of each CPU. The cid layout is
1371        // only known once the kernel has built it, at attach, so this is in
1372        // cpu space and ops.init() translates. It has to happen between
1373        // load and attach: the tables must be in place before ops.init().
1374        let mut args = types::eevdf_arena_args {
1375            nr_cpus: nr_cpus as u64,
1376            nr_place_tiers,
1377            nr_capacity_tiers,
1378            asym_capacity: asym_capacity as u64,
1379            sched_asym_capacity: sched_asym_capacity as u64,
1380            force_asym_capacity: opts.asym_capacity as u64,
1381            asym_packing: asym_packing as u64,
1382        };
1383        run_syscall_prog(&skel.progs.eevdf_arena_init, &mut args)
1384            .context("running eevdf_arena_init")?;
1385        for (
1386            cpu,
1387            capacity,
1388            capacity_tier,
1389            place_tier,
1390            smt_asym_packing,
1391            fork_span,
1392            wake_affine_span,
1393            asym_capacity_span,
1394        ) in cpu_tiers
1395        {
1396            let mut args = types::eevdf_cpu_args {
1397                cpu,
1398                capacity,
1399                place_tier,
1400                capacity_tier,
1401                smt_asym_packing: smt_asym_packing as u64,
1402                fork_span,
1403                wake_affine_span,
1404                asym_capacity_span,
1405            };
1406            run_syscall_prog(&skel.progs.eevdf_set_cpu, &mut args)
1407                .context("running eevdf_set_cpu")?;
1408        }
1409
1410        // Watch the BPF streams: an arena fault is reported and fatal rather
1411        // than silently fixed up.
1412        let arenalib =
1413            ArenaLib::start(skel.object_mut()).context("starting arena userspace services")?;
1414
1415        // Attach the scheduler.
1416        let struct_ops = Some(if cgroup_names {
1417            scx_ops_attach!(skel, eevdf_ops_cgroup)
1418        } else {
1419            scx_ops_attach!(skel, eevdf_ops)
1420        }?);
1421        let stats_server = StatsServer::new(stats::server_data()).launch()?;
1422
1423        // Only when the usage is known: counted with --stats, or by the kernel.
1424        let (arena_bytes, arena_max_bytes) = arena_usage(&skel, !arena_links.is_empty());
1425        if let Some(bytes) = arena_bytes {
1426            report.row(
1427                "BPF arena memory",
1428                format!(
1429                    "{} of {} allocated",
1430                    stats::human_bytes(bytes),
1431                    stats::human_bytes(arena_max_bytes)
1432                ),
1433            );
1434        }
1435        report.print();
1436        console::status("scheduler attached and running");
1437
1438        Ok(Self {
1439            _arenalib: arenalib,
1440            _arena_links: arena_links,
1441            skel,
1442            struct_ops,
1443            stats_server,
1444            ops_profiler: OpsProfiler::new(),
1445            started: Instant::now(),
1446        })
1447    }
1448
1449    fn get_metrics(&mut self) -> Metrics {
1450        let bss_data = self.skel.maps.bss_data.as_ref().unwrap();
1451        let (arena_bytes, arena_max_bytes) = arena_usage(&self.skel, !self._arena_links.is_empty());
1452        let mut m = Metrics {
1453            elapsed_ns: self.started.elapsed().as_nanos() as u64,
1454            nr_cpus: *NR_CPU_IDS as u64,
1455            nr_sis_updates: bss_data.nr_sis_updates,
1456            sis_scan_sum: bss_data.sis_scan_sum,
1457            arena_bytes: arena_bytes.unwrap_or(0),
1458            arena_max_bytes,
1459            arena_counted: arena_bytes.is_some() as u64,
1460            arena_active_allocs: bss_data.alloc_stats.active_allocs,
1461            arena_alloc_failures: bss_data.alloc_stats.alloc_nomem,
1462            ..Default::default()
1463        };
1464        m.ops = self.ops_profiler.sample(&self.skel);
1465        m.ops_profiled = self.ops_profiler.enabled.is_some() as u64;
1466        m
1467    }
1468
1469    pub fn exited(&mut self) -> bool {
1470        uei_exited!(&self.skel, uei)
1471    }
1472
1473    fn run(&mut self, shutdown: Arc<AtomicBool>) -> Result<UserExitInfo> {
1474        let (res_ch, req_ch) = self.stats_server.channels();
1475
1476        while !shutdown.load(Ordering::Relaxed) && !self.exited() {
1477            // Update statistics and check for exit condition.
1478            match req_ch.recv_timeout(Duration::from_secs(1)) {
1479                Ok(()) => res_ch.send(self.get_metrics())?,
1480                Err(RecvTimeoutError::Timeout) => {}
1481                Err(e) => Err(e)?,
1482            }
1483            self.ops_profiler.expire();
1484        }
1485
1486        let _ = self.struct_ops.take();
1487
1488        // A graceful unregister is reported in the console style; an error
1489        // exit keeps the standard report, with the debug dump on stderr.
1490        let uei = uei_read!(&self.skel, uei);
1491        if let Some(why) = self.exit_summary() {
1492            console::status(&format!("scheduler stopped: {why}"));
1493            return Ok(uei);
1494        }
1495        uei.report().map(|_| uei)
1496    }
1497
1498    /// A graceful unregister as one line, "reason" or "reason (msg)", read
1499    /// off the BPF-side exit info. None when the scheduler was kicked out
1500    /// by an error, which keeps the standard report and its debug dump.
1501    fn exit_summary(&self) -> Option<String> {
1502        let uei = &self.skel.maps.data_data.as_ref().unwrap().uei;
1503        if uei.kind == ScxExitKind::None as i32 || uei.kind > ScxExitKind::UnregKern as i32 {
1504            return None;
1505        }
1506        // The skeleton types the arrays after the BPF target, where char is
1507        // signed, while c_char is u8 on aarch64: take i8 and cast.
1508        let text = |chars: &[i8]| {
1509            // NUL-terminated by the BPF side, and the array is never full.
1510            unsafe { CStr::from_ptr(chars.as_ptr().cast()) }
1511                .to_string_lossy()
1512                .into_owned()
1513        };
1514        let reason = text(&uei.reason);
1515        let msg = text(&uei.msg);
1516        if reason.is_empty() {
1517            return Some("unknown reason".to_string());
1518        }
1519        Some(if msg.is_empty() {
1520            reason
1521        } else {
1522            format!("{reason} ({msg})")
1523        })
1524    }
1525}
1526
1527fn main() -> Result<()> {
1528    let opts = Opts::parse();
1529
1530    if opts.version {
1531        println!(
1532            "{} {}",
1533            SCHEDULER_NAME,
1534            build_id::full_version(env!("CARGO_PKG_VERSION"))
1535        );
1536        return Ok(());
1537    }
1538
1539    if opts.help_stats {
1540        stats::server_data().describe_meta(&mut std::io::stdout(), None)?;
1541        return Ok(());
1542    }
1543
1544    let loglevel = simplelog::LevelFilter::Info;
1545
1546    let mut lcfg = simplelog::ConfigBuilder::new();
1547    lcfg.set_time_offset_to_local()
1548        .expect("Failed to set local time offset")
1549        .set_time_level(simplelog::LevelFilter::Error)
1550        .set_location_level(simplelog::LevelFilter::Off)
1551        .set_target_level(simplelog::LevelFilter::Off)
1552        .set_thread_level(simplelog::LevelFilter::Off);
1553    simplelog::TermLogger::init(
1554        loglevel,
1555        lcfg.build(),
1556        simplelog::TerminalMode::Stderr,
1557        simplelog::ColorChoice::Auto,
1558    )?;
1559
1560    let shutdown = Arc::new(AtomicBool::new(false));
1561    let shutdown_clone = shutdown.clone();
1562    ctrlc::set_handler(move || {
1563        shutdown_clone.store(true, Ordering::Relaxed);
1564    })
1565    .context("Error setting Ctrl-C handler")?;
1566
1567    // The stats client, in its own thread: alone in --monitor mode, or
1568    // beside the scheduler in --stats mode, where it starts only once the
1569    // scheduler is attached and its stats server is up, so it never has to
1570    // retry a connection.
1571    let spawn_monitor = |intv: f64| {
1572        let shutdown_copy = shutdown.clone();
1573        std::thread::spawn(move || {
1574            match stats::monitor(Duration::from_secs_f64(intv), shutdown_copy) {
1575                Ok(_) => {
1576                    debug!("stats monitor thread finished successfully")
1577                }
1578                Err(error_object) => {
1579                    warn!(
1580                        "stats monitor thread finished because of an error {}",
1581                        error_object
1582                    )
1583                }
1584            }
1585        })
1586    };
1587
1588    if let Some(intv) = opts.monitor {
1589        let _ = spawn_monitor(intv).join();
1590        return Ok(());
1591    }
1592
1593    warn_on_old_kernel();
1594
1595    let mut open_object = MaybeUninit::uninit();
1596    let mut monitor = None;
1597    loop {
1598        let mut sched = Scheduler::init(&opts, &mut open_object)?;
1599        if let (Some(intv), None) = (opts.stats, &monitor) {
1600            monitor = Some(spawn_monitor(intv));
1601        }
1602        if !sched.run(shutdown.clone())?.should_restart() {
1603            break;
1604        }
1605    }
1606
1607    Ok(())
1608}
1609
1610#[cfg(test)]
1611mod tests {
1612    use super::capacity_tiers;
1613    use super::kernel_major_minor;
1614
1615    #[test]
1616    fn parses_kernel_major_minor() {
1617        assert_eq!(kernel_major_minor("7.2.0-rc1"), Some((7, 2)));
1618        assert_eq!(kernel_major_minor("6.18.12-arch1-1"), Some((6, 18)));
1619        assert_eq!(kernel_major_minor("7.2-custom"), Some((7, 2)));
1620        assert_eq!(kernel_major_minor("not-a-version"), None);
1621    }
1622
1623    #[test]
1624    fn capacity_tiers_use_current_tier_anchor() {
1625        assert_eq!(capacity_tiers(&[1024, 980, 940], 5), vec![0, 0, 1]);
1626    }
1627
1628    #[test]
1629    fn capacity_tiers_keep_meaningful_gaps() {
1630        assert_eq!(capacity_tiers(&[1024, 1000, 700, 680], 5), vec![0, 0, 1, 1]);
1631    }
1632
1633    #[test]
1634    fn zero_tolerance_keeps_distinct_capacities() {
1635        assert_eq!(capacity_tiers(&[1024, 1024, 1000], 0), vec![0, 0, 1]);
1636    }
1637}