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}