Thursday, May 1, 2025

Revamping my Linux kernel scheduler in Rust

Recap

Some time ago, I built a kernel scheduler in Rust, called scx_rustland. It runs entirely in user space, powered by sched_ext, a technology in the Linux kernel that lets you write CPU schedulers as BPF programs.

The original goal of my project was to create a proof-of-concept, a working example to demonstrate the potential of user-space scheduling. The ideas was to use sched_ext and BPF to capture scheduling events in the kernel and pass them to a Rust program in user space, which makes decisions and dispatches tasks back to the kernel.

Surprisingly, even that early prototype outperformed the default Linux scheduler, but only in a very specific scenario: testing the responsiveness of a videogame under heavy system load. (You can see this in action in this video, where I play Terraria at 60fps while compiling the kernel in the background.)

Since then, the project has evolved significantly. One key improvement was switching to BPF ringbuffers for the communication between BPF and user space. This allowed lockless, syscall-free message passing through shared memory between kernel and user space, eliminating bottlenecks and making the scheduler more viable beyond just demos or niche cases.

Eventually, part of this work grew into a framework: scx_rustland_core, a Rust crate for building fully functional Linux schedulers, designed to abstract away all the boilerplate and offering an easy scheduling development playground.

The original Rust scheduler (scx_rustland) was then completely rewritten on top of this new framework.

Tackling the overhead of user-space scheduling

One of the weaknesses of user-space scheduling is the overhead of the BPF-to-user roundtrip. For user responsiveness focused workloads, usually this isn’t a huge issue. But for more throughput-oriented workloads, the “bubbles” in the scheduling pipeline, caused by synchronizing BPF and the user-space scheduler, can be significant.

To address this, the scx_rustland_core scheduling pipeline has been re-architected and improved.

Now when a task releases a CPU, we check if there are other tasks that want to run and we always “append” the user-space scheduler itself at the end of the queue. In this way tasks can pile in and the scheduler can process them in “bursts” making its prioritization logic more effective. If there’s no pending action to be processed, the CPU can go idle, but right before entering the actual idle state, we opportunistically check for pending tasks again and immediately wakeup the user-space scheduler if needed.

This tighter scheduling loop reduced latency and maximized CPU utilization, as we can see in following trace, collected during a parallel kernel build and visualised using Perfetto:

In the trace, you can see how the older rustland version introduced ~60us gaps between tasks across all the CPUs. The new version virtually eliminates those bubbles (there are only rare ~5us gaps just before the user-space scheduler itself runs).

Another improvement was handling task wakeups directly in BPF: now, when a task wakes up and idle CPUs are available, the BPF code picks a nearby CPU (preferably the one it previously ran on) and dispatches it immediately, bypassing the user-space roundtrip entirely. This fast path improves both latency and throughput performance.

The runqueue design of scx_rustland_core has also been improved a bit, even if scx_rustland is still using a global runqueue, which works well for small-to-medium systems and allows perfect load balancing. However, on large systems with multiple cores, this can become a bottleneck due to contention (I’m planning to improve this in the future introducing per-NUMA or per-LLC runqueues and provide a proper API, so that other schedulers based on scx_rustland_core can use such topology-aware runqueues).

Benchmarking the Improvements

To evaluate the new improvements, I ran a subset of benchmarks from the Phoronix Test Suite, ranging from throughput-oriented workloads, such as kernel builds and LLM inference to more latency-oriented workloads, such as schbench, nginx and PostgreSQL.

Test system

Old vs new scx_rustland

Notable results: nginx: +77% in requests/sec, PostgreSQL: +26% in transactions/sec, schbench (99.9th latency) going from 9ms -> 3.4ms. These significant gains highlight the effect of a more efficient scheduling pipeline and better BPF/user-space synchronization.

scx_rustland vs EEVDF

Some notable results: schbench tail latency improved by a massive +75.5%; interestingly, scx_rustland outperformed EEVDF in nginx throughput by a notable +17.5% (likely thanks to its strong prioritization of short-lived, latency-sensitive tasks); PostgreSQL and FFmpeg latency metrics also saw small but consistent gains (this was expected due to the particular scheduling policy implemented by scx_rustland).

Conclusion

With the new design and optimizations in scx_rustland_core, user-space scheduling feels closer to being a viable option for many real-world use cases. In multiple benchmarks, scx_rustland even outperformed the in-kernel EEVDF scheduler. That’s quite impressive and definitely gives off some microkernel vibes.

But let’s be clear: moving the kernel scheduler to a user-space Rust program doesn’t magically make everything better. There’s always some overhead (even if it’s minimal now), and most of the performance gains come from the specific scheduling policy implemented by the scheduler.

Where user-space scheduling really shines is in specialization and integration. With sched_ext, we now have the flexibility to build task schedulers tailored to specific workloads and load/unload them at runtime. And with scx_rustland_core, we can also easily integrate other user-space components, libraries, and services directly into our scheduling decisions.

For example, an exciting experiment for future work could be integrating AI into the scheduler: letting a model predict optimal time slices, deadlines, or CPU affinity based on certain observed task behavior. That could unlock new ways to optimize systems in ways traditional schedulers can’t.

I think this is an interesting are to explore in the future.

Friday, February 14, 2025

Ubuntu 25.04 is now sched_ext ready

Ubuntu 25.04 Plucky Puffin now ships a linux-generic kernel based on 6.12, which means that sched_ext is supported out of the box! 🎉

This allows Ubuntu users to easily run multiple pluggable BPF schedulers at runtime, without needing to recompile a custom kernel.

Check your kernel version first

Before diving in, make sure you are actually running a 6.12 (or newer) kernel:

$ uname -r

If you see something like this:

6.12.0-15-generic

You’re good to go!

Examples

You can install and run scx_bpfland, a scheduler designed to improve system responsiveness:

$ cargo install scx_bpfland
$ sudo ~/.cargo/bin/scx_bpfland

Then simply press CTRL+c to stop the program and restore the default kernel scheduler.

If you’re looking for a scheduler optimized for gaming, you can try scx_lavd:

$ cargo install scx_lavd
$ sudo ~/.cargo/bin/scx_lavd

For audio, multimedia, or soft real-time workloads, scx_flash might be a better fit:

$ cargo install scx_flash
$ sudo ~/.cargo/bin/scx_flash

Alternatively, you can try scx_rusty, a hybrid scheduler with a user-space load-balancer written in Rust:

$ cargo install scx_rusty
$ sudo ~/.cargo/bin/scx_rusty

And if you’re feeling brave, you can try scx_rustland, a scheduler fully implemented in Rust that runs as a regular user-space process:

$ cargo install scx_rustland
$ sudo ~/.cargo/bin/scx_rustland

That’s it! You’re now running a sched_ext scheduler on your Ubuntu system.

What’s next?

Apart from always improving and optimizing these schedulers, we are also focusing at better integrating them into the major Linux distributions, through proper packaging, etc.

In the future, it would be nice to have application-driven schedulers, where apps can directly request a specific scheduler, that is automatically loaded by a system daemon. For example, imagine Steam automatically loading scx_lavd, Chrome requesting scx_bpfland, or a web server reverting to the default kernel scheduler, all of this dynamically, adapting to the particular workload's needs.

Saturday, January 18, 2025

Accelerating micro-VM boot time with sched_ext

Overview

Booting short-lived virtual machines (VMs) can be a highly CPU-intensive workload. In scenarios like serverless computing, where booting and stopping a short-lived micro-VM is a common operation, improving the single start/stop time can provide significant benefits.

This article shows the results of experiments using sched_ext to accelerate the start/stop of a micro-VM, by changing the scheduling policy on the hypervisor.

Booting a VM

When booting a micro-VM, multiple tasks are spawned and distributed across all the available CPUs (depending on how many vCPUs are assigned to the guest). The workload is typically a high bursty CPU activity, that occurs in short spikes, followed by periods of lower usage.

Unlike long-running workloads, which benefit from keeping tasks on the same CPU to maximize cache locality, the boot process doesn’t exhibit significant working set reuse. As a result, a scheduling policy that is too conservative with migrations, where tasks tend to stay on the same CPU, may be less effective in this scenario.

Maximize core utilization

In this scenario the most effective strategy is to maximize core utilization, without worrying too much about cache locality.

Therefore, a more effective scheduling policy would be to allow tasks to migrate to idle CPUs as soon as their current CPU becomes busy, while still keeping track of their “original” CPU and attempting to return them there, preserving, in this way, cache locality for the tasks that may still require it.

Moreover, using a single shared runqueue helps maximize core utilization, making the scheduler more work-conserving.

Using sched_ext to design a custom scheduling policy

sched_ext is a technology in the Linux kernel that allows to implement scheduling policies as BPF program, that can be loaded at runtime, without rebooting the kernel.

This helps designing and testing specialized scheduling policies, providing rapid experimentation with quick turnarounds between tests, all while ensuring safety, since BPF can’t crash the kernel.

The scheduling ideas described above have been implemented in a modified version of scx_bpfland.

Test case

The test case uses virtme-ng: a tool commonly used for automating kernel testing in CI/CD environments.

The test consists in running a simple "vng -r -- uname -r", which boots a micro-VM using the current host's kernel, executes the uname -r command and then exits. This test aims to simulate a very short-lived VM that runs a basic command before terminating.

Results and Observations

The modified bpfland scheduler delivered the following results:

 +------------------------+------------------+----------+---------+
 | Scheduler              | Boot Time (avg)  |   stdev  | speedup |
 +------------------------+------------------+----------+---------+
 | EEVDF (default Linux)  |      1.437s      |   0.064  |    -    |
 | bpfland                |      1.295s      |   0.013  |  1.11   |
 +------------------------+------------------+----------+---------+

The speedup achieved by bpfland is approximately 1.11x, meaning that bpfland reduced the micro-VM boot time by about 11% compared to the default Linux scheduler. This improvement is notable not only in terms of a faster average boot time, but also in its consistency across multiple runs, with bpfland showing also a lower standard deviation.

By examining the following scheduling traces (visualized in Perfetto), we can observe that in the bpfland case (second diagram), tasks tend to migrate more, maximizing core utilization. In contrast, the first diagram shows that tasks tend to remain a bit more on their CPU, before migrating.

Conclusion and future ideas

The goal of this experiment was to showcase the flexibility of sched_ext and highlight the benefits of rapid experimentation. By analyzing a specific workload, it was possible to quickly conceptualize, design, and test a custom scheduler, specifically crafted to enhance performance for a particular use case, all within a very short time frame.

In a (not too) far future, we may even have an automated way (possibly driven by an AI?) to analyze scheduling traces, generate customized scheduling policies, test them and select the most suitable one for the current system workload. However, we’re not quite there yet…

Sunday, September 8, 2024

AI-generated Linux kernel schedulers in Rust

Overview

Many kernel hackers and OS enthusiasts have long dreamed of designing and running a custom Linux scheduler. However, this has traditionally been highly inaccessible, achievable only by a handful of core kernel developers with years of deep expertise.

What if we could leverage Rust, generative Artificial Intelligence (AI) and Large Language Models (LLMs) to create an AI that can translate high-level scheduling concepts directly into functional kernel code?

State of the art

Using an AI to write functional Linux kernel code can be a bit tricky. There are experiments to use LLMs to review kernel patches, see for example Testing AI-enhanced reviews for Linux patches. However, in terms of generating fully functional code, examples have been limited to producing and fixing simple “hello world” kernel modules or similar.

We are still far from being able to automatically generate a fully functional Linux scheduler, primarily due to the vast amount of knowledge and concepts required, which are scattered throughout the kernel’s source code, an already highly complex system to comprehend.

Rust + sched_ext

Recently I’ve been working at improving the usability of scx_rustland_core: a Rust framework based on sched_ext that enables the implementation of custom Linux kernel schedulers in Rust, which run as regular user-space processes, and use BPF to channel scheduling events and actions between the kernel and user space.

This framework offers high-level Rust abstractions for the underlying BPF and sched_ext subsystems, enabling developers to concentrate on scheduling concepts, without worrying about the low-level kernel implementation details.

This can make scheduling development much more accessible, as you can simply create a regular Rust project (like any other user-space Rust application) and implement the scheduling policy using the high-level Rust APIs.

Add ChatGPT to the equation

To validate the usability of this framework I decided to use ChatGPT and see if the AI was able to produce working schedulers and/or improve them using the high-level Rust API.

For this experiment, I have used the ChatGPT-4o LLM, giving as input a simple FIFO scheduler implemented on top of scx_rustland_core with well-documented code, in particular with a very detailed description of how to use the scheduling framework API. Then the prompt includes a request to modify the code based on the requirements specified by command line (that are simply appended to the prompt).

The new source code is then generated, written to a file (replacing the original implementation), recompiled and executed.

All the source code of this experiment is available here: scx_rust_scheduler.

Demo

This demo video shows a simple implementation of this idea.

A Python script sends the initial FIFO scheduler code along with additional requirements to the AI, requesting it to generate a new scheduler that meets the specified criteria.

The AI then produces the updated code, which overwrites the original FIFO scheduler. This new code is compiled and executed, enabling the process to be repeated for multiple iterations by specifying further high-level requirements.

Result

As shown in the video above, the experiment shows that the AI enhanced the initial FIFO scheduler based on high-level guidance from the user. This improvement reduced the total execution time for a specific multi-threaded message-passing workload from ~5.3 seconds with the initial FIFO scheduler to ~4.9 seconds with the final optimized scheduling policy.

Benchmark:

$ sudo perf bench -f simple sched messaging -t -g 24 -l 2000

However, keep in mind that this was just a basic example and represents a single, specific workload. Moreover, if the scheduling policy’s requirements become too complex or intricate, the AI will likely to introduce syntax errors or logical mistakes in the generated code.

This could be improved by better documenting the initial code in a way that’s more comprehensible, but still, the improvements seen over the multiple iterations in the demo were largely driven by the human guidance more than the AI, that was acting more like a translator.

Nevertheless, it is quite impressive that instructions could be given at such a high level of abstraction, similar to explaining concepts to a class of students, and real, functional code capable of replacing the current Linux kernel scheduler was produced and executed in real-time, all within just a bunch of seconds.

Conclusion

The goal of this experiment was to demonstrate the ease of use of scx_rustland_core and showcase the potential that the sched_ext technology can offer.

While LLMs aren’t poised to replace human kernel developers anytime soon (at least not yet), they could still serve as valuable tools to lower the entry barrier for kernel development, especially for those passionate about it.

Though this was just a funny experiment, it could provide a great academic playground for students to test and explore simple scheduling concepts with ease.

Saturday, August 10, 2024

Re-implementing my Linux Rust scheduler in eBPF

Overview

The main bottleneck of scx_rustland, a Linux scheduler written in Rust, is the communication between kernel and user space.

Even if the communication itself has been improved a lot using ring buffers (BPF_MAP_TYPE_RINGBUF / BPF_MAP_TYPE_USER_RINGBUF) the multiple level of queues is inevitably leading to the scheduler operating in a less work-conserving way.

This has the double effect of making the vruntime-based scheduling policy more effective (being able to accumulate more tasks and prioritizing those that have more strict latency requirements), but the queuing of tasks also has the downside of not using the CPUs at their full capacity, potentially causing regressions in terms of throughput.

To reduce this “bufferbloat” effect I have decided to re-implement scx_rustland fully in BPF, getting rid of the user-space overhead, and name this new scheduler scx_bpfland.

Implementation

scx_bpfland uses the same logic as scx_rustland for classifying interactive and regular tasks. It identifies interactive tasks based on the average number of voluntary context switches per second, classifying them as interactive when this average surpasses a moving threshold. Additionally, tasks that are explicitly awakened are immediately marked as interactive and remain so unless their average voluntary context switches falls below the threshold.

There are per-CPU queues that are used to directly dispatch tasks to idle CPUs. If all CPUs are busy, tasks are either dispatched to a global priority queue or a global regular queue (depending if a task has been classified “interactive” or “regular”).

Tasks are then consumed from the per-CPU queues first, then from the priority queue and lastly from the regular queue. This means that regular tasks could be potentially starved by interactive tasks, so to prevent indefinite starvation scx_bpfland has a “starvation time threshold” parameter (configurable by command line) that forces the scheduler to consume at least one task from the regular queue when the threshold is exceeded.

Having a separate queue for the tasks classified as “interactive” that is consumed before the global regular queue allows to immediately process “interactive” events in an interrupt-like fashion.

Lastly, scx_bpfland assigns a variable time slice to tasks, as a function of the amount of tasks waiting in the priority and shared queues: the more tasks a waiting the shorter the time slice is. This ensures that over a max time slice period all the queued tasks will likely get a chance to run (depending on their priority and their “interactiveness”).

Testing

The logic of the scheduler is quite simple, but it proves to be quite effective in practice. Of course it does not perform well in every possible scenario, but the whole purpose of the scheduler is to be extremely specialized to prioritize latency-sensitive workloads over CPU-intensive workloads.

For this reason scx_bpfland should not be considered a replacement for the default Linux scheduler (EEVDF), that still remains the best choice in general.

However, in some special cases, where latency matters, scx_bpfland can deliver an improved and consistent level of responsiveness.

This run of a selection of tests from the Phoronix Test Suite mostly aimed at measuring latency and response time shows some of the benefits of the aggressive prioritizaton of interactive tasks performed by scx_bpfland.

Results

Both PostgreSQL and Hackbench show a significant boost compared to the default scheduler (up to 39% for read/write latency with PostgreSQL), since their workload is purely latency bound.

FFMpeg also shows some improvements (9% with live-streaming and 7% with the upload profile), mostly due to the fact that the workload is not purely encoding, but there’s also message passing involved (in a producer-consumer fashion).

nginx shows an improvement of 8.4%, also due to the fact that the benchmark is mostly stressing short-lived connections (measuring connection response time).

Apache, instead, shows a 9% performance regression with scx_bpfland, compared to EEVDF.

Both the nginx and the Apache benchmarks rely on wrk, an HTTP benchmarking tool with a multithreaded design and scalable event notification systems via epoll and kqueue.

To better understand what is happening from a scheduler’s perspective, we can look at the distribution of the runqueue latency (the time a task spends waiting in the scheduler’s queue) of the clients (wrk) in both scenarios during a 30s run period:

[nginx - EEVDF]

@usecs:
[1]                   21 |                                                    |
[2, 4)              1590 |@@@@                                                |
[4, 8)              6090 |@@@@@@@@@@@@@@@@@                                   |
[8, 16)            18371 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[16, 32)            7527 |@@@@@@@@@@@@@@@@@@@@@                               |
[32, 64)            8514 |@@@@@@@@@@@@@@@@@@@@@@@@                            |
[64, 128)           1915 |@@@@@                                               |
[128, 256)          1695 |@@@@                                                |
[256, 512)          2029 |@@@@@                                               |
[512, 1K)           2148 |@@@@@@                                              |
[1K, 2K)            2234 |@@@@@@                                              |
[2K, 4K)            2114 |@@@@@                                               |
[4K, 8K)            1729 |@@@@                                                |
[8K, 16K)           1274 |@@@                                                 |
[16K, 32K)           740 |@@                                                  |
[32K, 64K)           251 |                                                    |
[64K, 128K)           31 |                                                    |
[128K, 256K)           8 |                                                    |
[256K, 512K)           1 |                                                    |
[512K, 1M)             0 |                                                    |
[1M, 2M)               0 |                                                    |
[2M, 4M)               0 |                                                    |
[4M, 8M)               0 |                                                    |
[8M, 16M)              1 |                                                    |
[16M, 32M)             1 |                                                    |

Total samples: 58,284

[nginx - scx_bpfland]

@usecs:
[2, 4)              5552 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@           |
[4, 8)              6944 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[8, 16)             5813 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@         |
[16, 32)            4092 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@                      |
[32, 64)            2433 |@@@@@@@@@@@@@@@@@@                                  |
[64, 128)           1840 |@@@@@@@@@@@@@                                       |
[128, 256)          1867 |@@@@@@@@@@@@@                                       |
[256, 512)          2499 |@@@@@@@@@@@@@@@@@@                                  |
[512, 1K)           3104 |@@@@@@@@@@@@@@@@@@@@@@@                             |
[1K, 2K)            1902 |@@@@@@@@@@@@@@                                      |
[2K, 4K)             936 |@@@@@@@                                             |
[4K, 8K)             432 |@@@                                                 |
[8K, 16K)            143 |@                                                   |
[16K, 32K)           104 |                                                    |
[32K, 64K)            63 |                                                    |
[64K, 128K)           37 |                                                    |
[128K, 256K)          37 |                                                    |
[256K, 512K)          14 |                                                    |
[512K, 1M)            22 |                                                    |
[1M, 2M)               8 |                                                    |
[2M, 4M)               1 |                                                    |
[4M, 8M)               2 |                                                    |
[8M, 16M)              2 |                                                    |
[16M, 32M)             1 |                                                    |
[32M, 64M)             1 |                                                    |

Total samples: 37,849

[Apache - EEVDF]

@usecs:
[1]                22688 |@@@@@@                                              |
[2, 4)            178176 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[4, 8)             78276 |@@@@@@@@@@@@@@@@@@@@@@                              |
[8, 16)            96198 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@                        |
[16, 32)           24042 |@@@@@@@                                             |
[32, 64)            6795 |@                                                   |
[64, 128)           5518 |@                                                   |
[128, 256)          6581 |@                                                   |
[256, 512)          7415 |@@                                                  |
[512, 1K)           8184 |@@                                                  |
[1K, 2K)            8709 |@@                                                  |
[2K, 4K)            9001 |@@                                                  |
[4K, 8K)            5881 |@                                                   |
[8K, 16K)           2933 |                                                    |
[16K, 32K)          1255 |                                                    |
[32K, 64K)           364 |                                                    |
[64K, 128K)           72 |                                                    |
[128K, 256K)         111 |                                                    |
[256K, 512K)          50 |                                                    |
[512K, 1M)             8 |                                                    |
[1M, 2M)               0 |                                                    |
[2M, 4M)               1 |                                                    |

Total samples: 462,258

[Apache - scx_bpfland]

@usecs:
[2, 4)              1153 |@                                                   |
[4, 8)              6057 |@@@@@@@@@                                           |
[8, 16)             5591 |@@@@@@@@                                            |
[16, 32)            4188 |@@@@@@                                              |
[32, 64)            5526 |@@@@@@@@                                            |
[64, 128)          12015 |@@@@@@@@@@@@@@@@@@                                  |
[128, 256)         25987 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@            |
[256, 512)         33505 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[512, 1K)           9405 |@@@@@@@@@@@@@@                                      |
[1K, 2K)             575 |                                                    |
[2K, 4K)             585 |                                                    |
[4K, 8K)             838 |@                                                   |
[8K, 16K)            900 |@                                                   |
[16K, 32K)          1166 |@                                                   |
[32K, 64K)          1061 |@                                                   |
[64K, 128K)          423 |                                                    |
[128K, 256K)          61 |                                                    |
[256K, 512K)          25 |                                                    |
[512K, 1M)            13 |                                                    |
[1M, 2M)               5 |                                                    |
[2M, 4M)               3 |                                                    |
[4M, 8M)               1 |                                                    |
[8M, 16M)              2 |                                                    |

Total samples: 109,085

First of all let’s take a look at the amount of samples (that are collected at every task switch via bpftrace). As we can see the amount of task switches in case of Apache is much higher than nginx (around 10x).

Considering that the client is the same (wrk) the difference is clearly in the server, in fact Apache is handling responses in a blocking way, while nginx is handling responses in a non-blocking way.

This is also confirmed by the output of top, where we can see that nginx is using much more CPU than the client (wrk), while in the Apache case it’s the opposite.

So, in the Apache scenario both the clients and the server are classified as “interactive” by scx_bpfland, due to their blocking nature, while in the nginx scenario the clients (wrk) are classified as interactive and the server (nginx) is classified as CPU intensive, so the clients are more prioritized. This helps to improve the overall performance of the producer/consumer pipeline, giving a better score in the benchmark when `scx_bpfland` is used.

This is also confirmed looking at the distribution of the clients’ runqueue latency: with nginx tasks are spending less time in the scheduler’s queue with scx_bpfland, while in the Apache scenario it’s the opposite.

Clearly, the runqueue latency is merely a metric and doesn’t always accurately represent actual performance: tasks might spend more time in the scheduler’s queues yet still achieve improved overall performance, for instance, if the producer/consumer pipeline is better optimized. However, in this case, it seems to accurately explain the performance gaps between the two schedulers.

So, how can we improve scx_bpfland to work better also in the Apache scenario? The performance regression can be mitigated by not being too aggressive at scaling down the variable time slice of the tasks in function of the waiters.

By adjusting this logic we can improve also this particular scenario, potentially regressing others, but that’s the benefit of sched_ext: allowing to quickly implement and conduct experiments and implement specialized scheduler policies.

The regression with Apache shows that scx_bpfland is not a better scheduler in general, but it highlights the fact that it’s possible to leverage sched_ext to quickly implement and test specialized schedulers.

Conclusion

In conclusion, prototyping new schedulers in user-space using Rust and then re-implementing them in BPF can be an effective workflow for designing new specialized schedulers.

The rapid edit/compile/test cycle provided by technologies like sched_ext is invaluable for quickly iterating on these prototypes, allowing developers to achieve meaningful results in a much shorter timeframe.

scx_bpfland is a practical example of this approach, demonstrating how initial development in a flexible Rust user-space environment can be effectively transitioned to BPF for enhanced performance.

This experiment not only highlights the powerful combination of sched_ext and eBPF in enabling efficient and adaptable scheduler development but also suggests that sched_ext could be a fundamental step toward pluggable modular scheduling in Linux.

Saturday, May 25, 2024

Extend your battery life with scx_rustland

Overview

The CPU scheduler can play a significant role to save energy in the system. Typically we talk about Energy Aware Scheduling (EAS) when scehduling decisions can impact on the energy consumed by the CPUs. EAS relies on an Energy Model (EM) of the CPUs to select the most energy efficient CPU for each task, with a minimal impact on throughput.

Effective energy-saving techniques can also be applied by maximizing the idle time of the CPUs. Modern CPUs’ power consumption is heavily influenced by how long they remain idle, sometimes yielding more significant energy savings than Dynamic Voltage and Frequency Scaling (DVFS) techniques (e.g., the cpufreq governor).

Forcing CPUs to stay idle

The scheduler can force specific CPUs to stay idle by not assigning tasks to them. However, it is essential to find a good balance between energy savings and performance. For example, a drastic solution could be scheduling all tasks on a single CPU while keeping others idle. This can save energy in emergency situations, such as when a battery-powered device is nearly out of power, but it can severely degrades the overall system performance.

Energy saving with scx_rustland

scx_rustland uses a very simple, yet effective, strategy to save energy: when a CPU enters an idle state, it attempts to keep it idle if other CPUs are active, even if there are tasks queued for scheduling.

Since scx_rustland uses a vruntime-based policy, latency-sensitive tasks are likely placed at the top of the queue. Thus, active CPUs can quickly dispatch these tasks, maintaining system responsiveness. CPU-intensive tasks, on the other hand, will spend more time in the scheduler queue, waiting for an active CPU.

This approach reduces overall system throughput by intentionally introducing bubbles in the scheduling, but it helps save power without compromising system responsiveness.

Of course the overall throughput in the system is strongly reduced (CPUs are explicitly under-utilized), but the “CPU throttling” is mostly affecting background CPU-intensive tasks.

For this reason, this strategy is disabled by default and it can be enabled starting the scheduler with the --low-power option.

Implementation

The implementation of this strategy is also very simple, technically just a one-liner.

All the logic is implemented in the rustland_update_idle() callback, that is executed when a CPU changes its idle state:

/*
 * A CPU is about to change its idle state.
 */
void BPF_STRUCT_OPS(rustland_update_idle, s32 cpu, bool idle)
{
    /*
     * Don't do anything if we exit from and idle state, a CPU owner will
     * be assigned in .running().
     */
    if (!idle)
        return;
    /*
     * A CPU is now available, notify the user-space scheduler that tasks
     * can be dispatched.
     */
    if (usersched_has_pending_tasks()) {
        set_usersched_needed();
        /*
         * Wake up the idle CPU, so that it can immediately accept
         * dispatched tasks.
         */
        if (!low_power || !nr_running)
            scx_bpf_kick_cpu(cpu, 0);
    }
}

In low-power mode the key part is:

...
    if (usersched_has_pending_tasks()) {
...
        /*
         * Wake up the idle CPU, so that it can immediately accept
         * dispatched tasks.
         */
        if (!low_power || !nr_running)
            scx_bpf_kick_cpu(cpu, 0);
    }
...

The variable nr_running keeps track of the active CPUs and scx_bpf_kick_cpu() is used, in this context, to immediately wake up a CPU when it enters an idle state.

In general immediately waking up the CPU at this point would be totally reasonable if there are still tasks that are waiting to be scheduled (see the usersched_has_pending_tasks() check a few lines above).

However, in a “low power” scenario we can avoid to immediately wake up the CPU, if other CPUs are active (nr_running != 0), in order to maximize the idle state effectiveness and save power at the cost of throttling CPU-intensive tasks even more.

Result

The benefits of the low-power mode can be illustrated with the following test case:

  • play a video game (Terraria) while recompiling the kernel
  • measure game performance (fps) and core power consumption (W)
  • compare the result of normal mode vs low-power mode

Results:

                      Game performance | Power consumption |
         ------------+-----------------+-------------------+
         normal mode |          60 fps |               6W  |
      low-power mode |          60 fps |               3W  |

As we can see from these results, the game performance were pretty much unaffected by the low-power mode, while the CPU consumption is cut in half.

Real-world tests showed around 20-30% increase in laptop battery life using scx_rustland in low-power mode with typical workloads like reading emails, web browsing, listening to music, and compiling code.

Conclusion

This experiment highlights the ease and effectiveness of using sched_ext and scx_rustland for kernel scheduling development.

The ability to quickly edit-compile-run kernel scheduling changes represents a significant improvement over traditional methods that require kernel recompilation and rebooting, with potential for catastrophic results in case of bugs.

The simplicity of the code change also demonstrates how easy it can be to implement and test theories aimed at improving performance, responsiveness, or energy savings. Moreover, operating in user-space (remember that scx_rustland performs 100% of the scheduling decisions in user-space) can be particularly advantageous for debugging and profiling.

Future development

While the described technique for energy saving is simple and effective, there is room for improvement.

For instance, incorporating topology awareness could make the low-power mode less aggressive in keeping CPUs idle. Avoiding idle states for CPUs in the same core as an active CPU, for example, could minimize unnecessary throttling of CPU-intensive tasks while maintaining, potentially, the same level of energy savings.

Sunday, April 14, 2024

Getting started with sched-ext development

The purpose of this article is to support those interested in deepening their engagement in scheduling development using sched-ext.

We are currently working to better integrate all of this in the major Linux distributions, but for now setting up a development environment requires a few manual steps.

In this post, I’ll describe my personal workflow for conducting experiments with sched-ext without the need to install a custom kernel.

To test the sched-ext schedulers, we will use virtme-ng, a tool that allows to quickly build and test custom kernels without having to deploy them on a bare metal system or a dedicated virtual machine.

Install virtme-ng

virtme-ng is available in pretty much all the major Linux distributions, so you should be able to install it via the package manager of your distro.

Alternatively, you can either recompile, or even run directly, from source or install it via pip:

 $ pip install --break-system-packages virtme-ng

Dependencies

Install the following dependencies to be able to compile a sched-ext kernel and the user-space scx tools and schedulers:

 $ sudo apt -y install \
   bison build-essential busybox-static cargo clang-17 cmake coreutils cpio \
   elfutils file flex gcc gcc-multilib git iproute2 jq kbd kmod libcap-dev \
   libelf-dev libssl-dev libunwind-dev libvirt-clients libzstd-dev llvm-17 \
   linux-headers-generic linux-tools-common linux-tools-generic \
   make meson ninja-build pahole pkg-config python3-dev python3-pip \
   python3-requests qemu-kvm rsync rustc udev zstd

These is the list of required packages if you are using Debian/Ubuntu, other distributions may have similar or equivalent package names.

Preparing a sched-ext kernel

The development kernel of sched-ext is based on the latest bpf-next branch.

The main reason is that we may want to start exploring the new eBPF features as soon as possible, being sched-ext strictly connected to eBPF.

To prepare a minimal sched-ext kernel that can run inside a virtme-ng session do the following:

 $ git clone https://github.com/sched-ext/sched_ext.git linux
 $ cd linux
 $ vng -v --build --config .github/workflows/sched-ext.config
 $ make headers
 $ cd -

Build the user-space scx tools and schedulers

All the sched-ext schedulers and tools are provided in a separate scx git repository.

To compile all the schedulers with the required tools and libraries you can run the following command:

 $ git clone https://github.com/sched-ext/scx.git
 $ cd scx
 $ meson setup build -Dkernel_headers=../linux/usr/include
 $ meson compile -C build --njobs=1

If you want to recompile only a specific scheduler (i.e., scx_rustland) you can specify just the scheduler name that you want compile via the meson compile command:

 $ meson compile -C build scx_rustland

Test the scheduler inside virtme-ng

First of all let’s define a bash helper that will help us to quickly spawn a tmux session with a running sched-ext scheduler and a shell.

Add the following to your ~/.bashrc:

# Helper to test scx scheduler
scx() {
    sudo tmux new-session \; split-window -v \; send-keys -t 0 "$*" Enter
}

Now we can run the kernel that we have just recompiled using virtme-ng:

 $ vng -vr ../linux

Then, inside virtme-ng, we can use the following command to run the scx_rustland scheduler together with a shell session, that can be used to run some tests or benchmarks:

 $ scx ./build/scheds/rust/scx_rustland/debug/scx_rustland

Modify a scheduler: scx_rustland

Now, let’s try to modify the scx_rustland scheduler as following:

diff --git a/scheds/rust/scx_rustland/src/main.rs b/scheds/rust/scx_rustland/src/main.rs
index 33cf43b..59891ad 100644
--- a/scheds/rust/scx_rustland/src/main.rs
+++ b/scheds/rust/scx_rustland/src/main.rs
@@ -506,7 +506,7 @@ impl<'a> Scheduler<'a> {
                     // available.
                     let mut dispatched_task = DispatchedTask::new(&task.qtask);
                     if !self.builtin_idle {
-                        dispatched_task.set_cpu(NO_CPU);
+                        dispatched_task.set_cpu(0);
                     }
 
                     // Send task to the BPF dispatcher.

This change will force the scx_rustland scheduler to dispatch all tasks on CPU #0.

This small change is obviously provided mostly for academic purposes, but even a little change like this can have practical benefits in certain scenarios.

Result

For example, let’s test this scheduler on a real system (my laptop) and compare the power consumption of the default Linux scheduler vs the modified scx_rustland.

We can start scx_rustland with the -u option to make sure that all the tasks will be scheduled by the user-space component that we have just modified:

 $ scx ./build/scheds/rust/scx_rustland/debug/scx_rustland -u

Then we can start a CPU-intensive stress test using stress-ng:

 $ stress-ng -c 8 --timeout 30

When measuring the average power consumption over a 30-second period using turbostat, the results show a notable difference between two schedulers:

                          Power usage
 ------------------------------------
 Default Linux scheduler |       7.5W
 Modified scx_rustland   |       3.6W
 ------------------------------------

This disparity can obviously be attributed to the different behavior of the schedulers. With the modified scx_rustland scheduler, all tasks are directed to CPU #0, leaving the other CPUs largely idle. Consequently, the kernel can place these idle CPUs into a low-power state. In contrast, the default Linux scheduler evenly distributes the workload across all available CPUs, leading to higher power consumption.

Although this modification massively impacts on performance, it demonstrates the effectiveness of operating at the kernel scheduler level and how easy it is via sched-ext. This experience highlights the tangible benefits of fine-tuning kernel scheduler settings in real-world scenarios using sched-ext.

The reason is that all the tasks are dispatched on CPU #0 with the modified scx_rustland, so the other CPUs are basically idle and the kernel can put them in a low-power state, while the default Linux scheduler tries to distribute the workload equally across all the available CPUs.

Despite the substantial impact on performance caused by this change, this example demonstrates the effectiveness to operate at the kernel scheduler level and the ease of implementing (and testing) such modifications using sched-ext.

And, speaking from personal experience, I have actually used this change while travelling when my laptop was running out of power. With this simple change I was able to almost double the battery life of my laptop and complete my work during the trip. :)

Conclusion

In this post I have shared my personal workflow to quickly run experiments with sched-ext without the need to install a custom kernel, highlighting the ease of conducting such experiments.

I hope this information can help all the potential developers interested in improving their understanding of sched-ext through experimentation.

Saturday, March 2, 2024

Writing a scheduler for Linux in Rust that runs in user-space (part 2)

In the first part of this series we covered the basic implementation details of scx_rustland: a fully-functional Linux scheduler written in Rust that runs in user space.

If having a Linux scheduler that run in user-space wasn’t enough, we can push the concept even further and consider the possibility to evolve this project into a generic framework that allows to implement any scheduling policy in user-space, using Rust.

The primary advantage of such a framework would lie in significantly lowering the bar of scheduling development. Using this framework, developers could just focus solely on crafting the scheduling policy, without delving into complex kernel internal details. This would make scheduling development and testing really accessible to a broader audience.

Moreover, as already mentioned in part 1, operating in user-space provides access to a plethora of tools, libraries, debuggers, etc., that really help to make the development environment much more “comfortable”.

Now, the question arises: how can we realize all of this?

Implementation

For those who have followed the previous post, you are already aware that scx_rustland is made of two main components: an eBPF part, responsible for implementing the low-level interface to sched-ext/eBPF, using libbpf-rs, and the Rust code operating in user-space.

Between these two layers there is actually an additional layer: a Rust module (bpf.rs) that implements the low-level communication between the Rust code and the eBPF code.

What if we could further abstract this module and relocate both the eBPF code and bpf.rs to a separate standalone crate?

By doing so, we could simply import this crate into our project and use the generic scheduling API to implement a fully functional Linux scheduler.

This is precisely what I’ve recently been focused on: developing a new Rust crate named scx_rustland_core, which is integrated into the scx tools. Its purpose is to accomplish exactly this abstraction.

A first version of this crate has been merged already in the scx repository.

API

The main challenge of this project is to figure out the best API to achieve both implicitly and efficiency, and this is probably going to be a long process (so, the API described below is subject to probable alterations in the near future).

The scx_rustland_core crate provides a BpfScheduler struct that represents the “connector” to the eBPF code.

BpfScheduler provides the following public methods:

   pub fn dequeue_task(&mut self) -> Result<Option<QueuedTask>, libbpf_rs::Error>

   pub fn dispatch_task(&mut self, task: &DispatchedTask) -> Result<(), libbpf_rs::Error>

The former can be used to receive a task queued to the scheduler, the latter can be used to send a task to the dispatcher.

Between the functions dequeue_task() and dispatch_task(), the scheduler can decide to store tasks within internal data structures, determine their order of execution, on which CPU run them, and for how long.

Enqueued tasks and dispatched tasks are represented as following:

pub struct QueuedTask {
    pub pid: i32,              // pid that uniquely identifies a task
    pub cpu: i32,              // CPU where the task is running (-1 = exiting)
    pub cpumask_cnt: u64,      // cpumask generation counter
    pub sum_exec_runtime: u64, // Total cpu time
    pub nvcsw: u64,            // Voluntary context switches
    pub weight: u64,           // Task static priority
}

pub struct DispatchedTask {
    pub pid: i32,         // pid that uniquely identifies a task
    pub cpu: i32,         // target CPU selected by the scheduler
    pub cpumask_cnt: u64, // cpumask generation counter
    pub payload: u64,     // task payload (used for debugging)
}

To assign a specific CPU to task the scheduler can change the attribute cpu within the DispatchedTask struct. If the special value NO_CPU is specified, the dispatcher will execute the task on the first CPU available.

Moreover, to decide the amount of time that each task can run on the assigned CPU, a global time slice is used: there is a default global time slice and a global effective time slice, that can be adjusted dynamically by the scheduler using the following methods:

  pub fn set_effective_slice_us(&mut self, slice_us: u64)
  pub fn get_effective_slice_us(&mut self) -> u64

TODO: as a future improvement I’m planning to add also a local time slice to the DispatchedTask struct. This will give the possibility to set a different time slice to each task, and override the global effective time slice on a per-task basis.

Last, but not least, an additional method is provided to notify the eBPF component if the user-space scheduler has still some pending work to complete:

    pub fn update_tasks(&mut self, nr_queued: Option<u64>, nr_scheduled: Option<u64>) {

nr_queued is a counter that represents the amount of queued tasks that still need to be processed by the user-space scheduler, nr_scheduled represents the amount of tasks that have been currently queued into the scheduler and still need to be dipsatched.

For example, it is possible to notify the eBPF dipatcher that the scheduler doesn’t have any pending work using this method as following:

  .update_tasks(Some(0), Some(0));

scx_rustland refactoring

scx_rustland has been rewritten on top of scx_rustland_core and the scheduler code is a lot more compact:

 $ git diff --stat origin/scx-user~9..origin/scx-user scheds/rust/scx_rustland/
 ...
  9 files changed, 40 insertions(+), 1592 deletions(-)

This is purely a code refactoring, performance-wise scx_rustland can still achieve the same performance as before

[ I can still play AAA games, such as Baldur’s Gate 3, CS2, etc., while recompiling the kernel in the background and achieve a higher fps than the default Linux scheduler. ]

And if the scheduler isn’t ideal for a particular workload we can simply switch to a different scx scheduler or move back to the default Linux scheduler, at run-time and with zero downtime.

Example

As a practical example, to demonstrate how to use scx_rustland_core, I’ve added a new Rust scheduler to the scx schedulers, called scx_rlfifo.

The scheduler is a plain FIFO scheduler (which may not be very thrilling), but its simplicity facilitates its use as a template for implementing more complex scheduling policies.

The entire code is compact enough that can fit in this blog post:

// Copyright (c) Andrea Righi <andrea.righi@canonical.com>

// This software may be used and distributed according to the terms of the
// GNU General Public License version 2.
mod bpf_skel;
pub use bpf_skel::*;
pub mod bpf_intf;

mod bpf;
use bpf::*;

use scx_utils::Topology;

use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::sync::Arc;

use std::time::{Duration, SystemTime};

use anyhow::Result;

struct Scheduler<'a> {
    bpf: BpfScheduler<'a>,
}

impl<'a> Scheduler<'a> {
    fn init() -> Result<Self> {
        let topo = Topology::new().expect("Failed to build host topology");
        let bpf = BpfScheduler::init(5000, topo.nr_cpus() as i32, false, false, false)?;
        Ok(Self { bpf })
    }

    fn now() -> u64 {
        SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)
            .unwrap()
            .as_secs()
    }

    fn dispatch_tasks(&mut self) {
        loop {
            // Get queued taks and dispatch them in order (FIFO).
            match self.bpf.dequeue_task() {
                Ok(Some(task)) => {
                    // task.cpu < 0 is used to to notify an exiting task, in this
                    // case we can simply ignore the task.
                    if task.cpu >= 0 {
                        let _ = self.bpf.dispatch_task(&DispatchedTask {
                            pid: task.pid,
                            cpu: task.cpu,
                            cpumask_cnt: task.cpumask_cnt,
                            payload: 0,
                        });
                    }
                    // Give the task a chance to run and prevent overflowing the dispatch queue.
                    std::thread::yield_now();
                }
                Ok(None) => {
                    // Notify the BPF component that all tasks have been scheduled and dispatched.
                    self.bpf.update_tasks(Some(0), Some(0));

                    // All queued tasks have been dipatched, add a short sleep to reduce
                    // scheduler's CPU consuption.
                    std::thread::sleep(Duration::from_millis(1));
                    break;
                }
                Err(_) => {
                    break;
                }
            }
        }
    }

    fn print_stats(&mut self) {
        let nr_user_dispatches = *self.bpf.nr_user_dispatches_mut();
        let nr_kernel_dispatches = *self.bpf.nr_kernel_dispatches_mut();
        let nr_cancel_dispatches = *self.bpf.nr_cancel_dispatches_mut();
        let nr_bounce_dispatches = *self.bpf.nr_bounce_dispatches_mut();
        let nr_failed_dispatches = *self.bpf.nr_failed_dispatches_mut();
        let nr_sched_congested = *self.bpf.nr_sched_congested_mut();

        println!(
            "user={} kernel={} cancel={} bounce={} fail={} cong={}",
            nr_user_dispatches, nr_kernel_dispatches,
            nr_cancel_dispatches, nr_bounce_dispatches,
            nr_failed_dispatches, nr_sched_congested,
        );
    }

    fn run(&mut self, shutdown: Arc<AtomicBool>) -> Result<()> {
        let mut prev_ts = Self::now();

        while !shutdown.load(Ordering::Relaxed) && !self.bpf.exited() {
            self.dispatch_tasks();

            let curr_ts = Self::now();
            if curr_ts > prev_ts {
                self.print_stats();
                prev_ts = curr_ts;
            }
        }

        self.bpf.shutdown_and_report()
    }
}

fn main() -> Result<()> {
    let mut sched = Scheduler::init()?;
    let shutdown = Arc::new(AtomicBool::new(false));
    let shutdown_clone = shutdown.clone();

    ctrlc::set_handler(move || {
        shutdown_clone.store(true, Ordering::Relaxed);
    })?;

    sched.run(shutdown)
}

Conclusion

Evolving scx_rustland into a generic scheduling framework in Rust can help to make scheduling development accessible to a broader audience.

In particular, it has the potential to promote a stronger connection between academia and real-world kernel development, by helping researchers to experiment with and test new scheduling theories within a real kernel environment, but in a safe way.

Rust, eBPF, and all the debugging tools available in user-space can significantly mitigate the risks of bugs, compared with the traditional kernel development process. And even in presence of bugs (such as deadlock, or starvation), their impact is significantly less critical: the worst-case scenario may involve a brief freeze lasting for 5 seconds, subsequently, the sched-ext watchdog will intervene, restoring the default Linux scheduler.

In conclusion, sched-ext really seems to have the potential to bring a lot of new concepts and different approaches to Linux scheduling, making projects like this a tangible reality.

Hopefully, in a future not too far, we will see this feature available in the upstream kernel, making technologies like scx_rustland_core really available to everyone.

Monday, February 19, 2024

Writing a scheduler for Linux in Rust that runs in user-space

Overview

I’ve decided to start a series of blog posts to cover some details about scx_rustland, my little Linux scheduler written in Rust that runs in user-space.

This project started for fun over the Christmas break, mostly because I wanted to learn more about sched-ext and I also needed some motivation to keep practicing Rust (that I’m still learning).

In this series of articles I would like to focus at some implementation details to better explains how this scheduler works.

A scheduler is kernel component that needs to:

  • determine the order of executions of all the tasks that want to run (ranking)

  • determine where each task needs to run (target CPU)

  • determine for how long a task can run (time slice)

The main goal of this project is to prove that we can channel these operations into a regular user-space program and still have a system that can perform as good as using a in-kernel scheduler.

Pros and cons of a user-space scheduler

The most noticeable benefits of a user-space scheduler are the following:

  • Availability of a large pool of languages (e.g., Rust), libraries, debugging and profiling tools.

  • Lower down the barrier of CPU scheduling experimentation: implementing and testing a particular scheduling policy can be done inside a regular user-space process, it doesn’t require rebooting into a new kernel, in case of bugs the user-space scheduler will just crash and sched-ext will transparently restore the default Linux scheduler.

Downside of a user-space scheduler:

  • Overhead: even if the scheduler itself is not really a CPU-intensive workload (unless the system is massively overloaded), the communication between kernel and user-space is going to add some overhead, so the goal is to try to reduce this overhead as much as possible

  • Protection: the user-space task that implements the scheduling policy needs some special protection, if it’s blocked (e.g., due to a page fault), tasks can’t be scheduled. So, in order to avoid deadlocks the user-space scheduler should never be blocked indefinitely, waiting for some actions performed by another task (a page fault is a good example of such “forbidden” behavior).

How does it work?

First of all this scheduler has nothing to do with Rust-for-Linux (the kernel subsystem that allows to write kernel modules in Rust).

In fact the Rust part of the scheduler runs 100% in user-space and all the scheduling decisions are also done in user-space.

The connection with the kernel happens thanks to eBPF and sched-ext: together they allow to channel all the scheduling events to a user-space program, which then communicates the tasks to be executed back to the kernel via eBPF as well.

eBPF component

sched-ext is a Linux kernel feature (not upstream yet - hopefully it’ll be upstream soon) that allows to implement a scheduler in eBPF.

Basically all you have to do is to implement some callbacks and put them in a struct sched_ext_ops.

scx_rustland implements the following callbacks in the eBPF component:

/*
 * Scheduling class declaration.
 */
SEC(".struct_ops.link")
struct sched_ext_ops rustland = {
        .select_cpu             = (void *)rustland_select_cpu,
        .enqueue                = (void *)rustland_enqueue,
        .dispatch               = (void *)rustland_dispatch,
        .running                = (void *)rustland_running,
        .stopping               = (void *)rustland_stopping,
        .update_idle            = (void *)rustland_update_idle,
        .set_cpumask            = (void *)rustland_set_cpumask,
        .cpu_release            = (void *)rustland_cpu_release,
        .init_task              = (void *)rustland_init_task,
        .exit_task              = (void *)rustland_exit_task,
        .init                   = (void *)rustland_init,
        .exit                   = (void *)rustland_exit,
        .flags                  = SCX_OPS_ENQ_LAST | SCX_OPS_KEEP_BUILTIN_IDLE,
        .timeout_ms             = 5000,
        .name                   = "rustland",
};

The workflow is the following:

  • .select_cpu() implements the logic to assign a target CPU to a task that wants to run, typically you have to decide if you want to keep the task on the same CPU or if it needs to be migrated to a different one (for example if the current CPU is busy); if we can find an idle CPU at this stage there’s no reason to call the scheduler, the task can be immediately dispatched here.
s32 BPF_STRUCT_OPS(rustland_select_cpu, struct task_struct *p, s32 prev_cpu,
                   u64 wake_flags)
{
        bool is_idle = false;
        s32 cpu;

        cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags, &is_idle);
        if (is_idle) {
                /*
                 * Using SCX_DSQ_LOCAL ensures that the task will be executed
                 * directly on the CPU returned by this function.
                 */
                dispatch_task(p, SCX_DSQ_LOCAL, 0, 0);
                __sync_fetch_and_add(&nr_kernel_dispatches, 1);
        }

        return cpu;
}

If we can’t find an idle CPU this step will just return the previously used CPU, that can be used as a hint for the user-space scheduler (keeping tasks on the same CPU has multiple benefits, such as reusing hot caches and avoid any kind migration overhead). However this decision is not the final one, the user-space scheduler can decide to move the task to a different CPU if needed.

NOTE: bypassing the user-space scheduler when we can find an idle CPU can strongly improve the responsiveness of certain “low latency” workloads, such as gaming for example.

  • Once a tentative CPU has been determined for the task we enter the .enqueue() callback, usually here you may decide to store the task in a queue, tree, or any other data structure to determine the proper order of execution of the different tasks that require to run.

    In rustland the .enqueue() callback is used to store tasks into a BPF map BPF_MAP_TYPE_QUEUE queued, this represents the first connection with the user-space counter-part. Items in this queue are managed in a producer/consumer way: the BPF part is the producer, user-space is the consumer.

void BPF_STRUCT_OPS(rustland_enqueue, struct task_struct *p, u64 enq_flags)
{
...
        /*
         * Add tasks to the @queued list, they will be processed by the
         * user-space scheduler.
         *
         * If @queued list is full (user-space scheduler is congested) tasks
         * will be dispatched directly from the kernel (re-using their
         * previously used CPU in this case).
         */
        get_task_info(&task, p, false);
        dbg_msg("enqueue: pid=%d (%s)", p->pid, p->comm);
        if (bpf_map_push_elem(&queued, &task, 0)) {
                sched_congested(p);
                dispatch_task(p, SHARED_DSQ, 0, enq_flags);
                __sync_fetch_and_add(&nr_kernel_dispatches, 1);
                return;
        }
        __sync_fetch_and_add(&nr_queued, 1);
}

At this point it’s up to the user-space counter-part to determine the proper order of execution of tasks and where they need to run, the user-space has the option to maintain the CPU assignment determined by the built-in idle selection logic or pick another CPU.

  • Once the order of execution is determined tasks are then stored to another BPF_MAP_TYPE_QUEUE dispatched, again in a producer/consumer way, but this time the producer is the user-space part and the consumer is the BPF part.

  • Then the workflow goes back to the BPF part. The dispatch path operates using multiple per-CPU dispatch queues (DSQ) and a global dispatch queue.

    The per-CPU DSQs are used to dispatch tasks on specific CPUs, while the global DSQ is used to dispatch tasks on the first CPU that becomes available (usually when the user-space doesn’t specify any preference to run the task on a particular CPU).

    When a CPU becomes ready to dispatch tasks, the .dispatch() callback is called, if there are tasks in the dispatched queue they will be bounced to the target CPU’s queue (DSQ), or to the global dispatch queue, based on the user-space scheduler’s decision.

    void BPF_STRUCT_OPS(rustland_dispatch, s32 cpu, struct task_struct *prev)
    {
         /*
          * Check if the user-space scheduler needs to run, and in that case try
          * to dispatch it immediately.
          */
         dispatch_user_scheduler();
    
         /*
          * Consume all tasks from the @dispatched list and immediately try to
          * dispatch them on their target CPU selected by the user-space
          * scheduler (at this point the proper ordering has been already
          * determined by the scheduler).
          */
         bpf_repeat(MAX_ENQUEUED_TASKS) {
                 struct task_struct *p;
                 struct dispatched_task_ctx task;
    
                 /*
                  * Pop first task from the dispatched queue, stop if dispatch
                  * queue is empty.
                  */
                 if (bpf_map_pop_elem(&dispatched, &task))
                         break;
    
                 /* Ignore entry if the task doesn't exist anymore */
                 p = bpf_task_from_pid(task.pid);
                 if (!p)
                         continue;
                 /*
                  * Check whether the user-space scheduler assigned a different
                  * CPU to the task and migrate (if possible).
                  *
                  * If no CPU has been specified (task.cpu < 0), then dispatch
                  * the task to the shared DSQ and rely on the built-in idle CPU
                  * selection.
                  */
                 dbg_msg("usersched: pid=%d cpu=%d cpumask_cnt=%llu payload=%llu",
                         task.pid, task.cpu, task.cpumask_cnt, task.payload);
                 if (task.cpu < 0)
                         dispatch_task(p, SHARED_DSQ, 0, 0);
                 else
                         dispatch_task(p, cpu_to_dsq(task.cpu), task.cpumask_cnt, 0);
                 bpf_task_release(p);
                 __sync_fetch_and_add(&nr_user_dispatches, 1);
         }
    
         /* Consume all tasks enqueued in the current CPU's DSQ first */
         bpf_repeat(MAX_ENQUEUED_TASKS) {
                 if (!scx_bpf_consume(cpu_to_dsq(cpu)))
                         break;
         }
    
         /* Consume all tasks enqueued in the shared DSQ */
         bpf_repeat(MAX_ENQUEUED_TASKS) {
                 if (!scx_bpf_consume(SHARED_DSQ))
                         break;
         }
    }
  • The .running() and .stopping() callback are called respectively when a task starts its execution on a CPU and it releases the CPU; rustland uses this information to keep track of the CPUs that are idle or busy, sharing this information to the user-space counter-part (via the cpu_mapcpu_map BPF map array).

/*
 * Task @p starts on its selected CPU (update CPU ownership map).
 */
void BPF_STRUCT_OPS(rustland_running, struct task_struct *p)
{
        s32 cpu = scx_bpf_task_cpu(p);

        dbg_msg("start: pid=%d (%s) cpu=%ld", p->pid, p->comm, cpu);
        /*
         * Mark the CPU as busy by setting the pid as owner (ignoring the
         * user-space scheduler).
         */
        if (!is_usersched_task(p))
                set_cpu_owner(cpu, p->pid);
}

/*
 * Task @p stops running on its associated CPU (update CPU ownership map).
 */
void BPF_STRUCT_OPS(rustland_stopping, struct task_struct *p, bool runnable)
{
        s32 cpu = scx_bpf_task_cpu(p);

        dbg_msg("stop: pid=%d (%s) cpu=%ld", p->pid, p->comm, cpu);
        /*
         * Mark the CPU as idle by setting the owner to 0.
         */
        if (!is_usersched_task(p)) {
                set_cpu_owner(scx_bpf_task_cpu(p), 0);
                /*
                 * Kick the user-space scheduler immediately when a task
                 * releases a CPU and speculate on the fact that most of the
                 * time there is another task ready to run.
                 */
                set_usersched_needed();
        }
}
  • Both the .stopping() and .update_idle() callbacks are used as checkpoints to wake-up the user-space scheduler (being the scheduler a regular user-space task, it needs to implement a logic to schedule itself).
void BPF_STRUCT_OPS(rustland_update_idle, s32 cpu, bool idle)
{
        /*
         * Don't do anything if we exit from and idle state, a CPU owner will
         * be assigned in .running().
         */
        if (!idle)
                return;
        /*
         * A CPU is now available, notify the user-space scheduler that tasks
         * can be dispatched.
         */
        if (usersched_has_pending_tasks()) {
                set_usersched_needed();
                /*
                 * Wake up the idle CPU, so that it can immediately accept
                 * dispatched tasks.
                 */
                scx_bpf_kick_cpu(cpu, 0);
        }
}

There is also a periodic heartbeat timer that kicks the user-space scheduler to prevent triggering the sched-ext watchdog when the system is almost idle (since in this condition we won’t hit any of the wake-up point).

static int usersched_timer_fn(void *map, int *key, struct bpf_timer *timer)
{
        int err = 0;

        /* Kick the scheduler */
        set_usersched_needed();

        /* Re-arm the timer */
        err = bpf_timer_start(timer, NSEC_PER_SEC, 0);
        if (err)
                scx_bpf_error("Failed to arm stats timer");

        return 0;
}
  • lastly the .set_cpumask() is used to detect when a task changes its affinity; the scheduler will try to honor the affinity looking at the cpumask (we check the validity of the cpumask using a generation number, that is incremented every time the .set_cpumask() callback is executed).
void BPF_STRUCT_OPS(rustland_set_cpumask, struct task_struct *p,
                    const struct cpumask *cpumask)
{
        struct task_ctx *tctx;

        tctx = lookup_task_ctx(p);
        if (!tctx)
                return;
        tctx->cpumask_cnt++;
}

User-space component (Rust)

The user-space part is fully implemented in Rust as a regular user-space program. The address space is shared with the eBPF part, so some variables can be accessed and modified directly, while the communication of tasks happen using the bpf() syscall, accessing the queued and dispatched maps.

NOTE: we could make this part more efficient by using eBPF ring buffers, this would allow direct access to the maps without using a syscall (there’s an ongoing work on this - patches are welcome if you want to contribute).

The user-space part is made of four components:

  • eBPF abstraction layer: this part implements some Rust abstractions to hide the internal eBPF details, so that the scheduler itself can be implemented in a more abstracted and understandable way, focusing only at the details of the implemented scheduling policy.

  • A custom memory allocator RustLandAllocator: as mentioned in the pros and cons section, if the user-space scheduler is blocked on a page fault, no other task can be scheduled, but we may need to schedule some kernel threads to resolve the page fault, hence the deadlock. To prevent this condition, the user-space scheduler locks all the memory, via mlockall(), and it uses a custom memory allocator that operates on a pre-allocated memory area. Quite tricky, but this allows to prevent page faults in the user-space scheduler task.

  • A CPU topology abstraction: simple library to detect the current system CPU topology (this part will be improved in the future and moved to a more generic place, so that other schedulers may benefit from it).

  • The scheduling policy itself, implemented in a totally abstracted way: the scheduler uses a simple vruntime-based policy (similar to CFS) with a little trick to detect interactive tasks and boost their priority a little more (the trick is to look at the number of voluntary context switches to determine if a task is interactive or not: a task that releases the CPU without using its full assigned time slice is likely to be interactive).

    All tasks are stored in a BTreeSet ordered by their weighted vruntime and dispatched on the CPUs selected by the sched-ext built-in idle selection logic (unless their assigned CPU becomes busy and in this case the task will be dispatched on the first CPU that becomes available).

    For the time slice assigned to each task the scheduler uses a variable time slice approach: it starts with a fixed time slice (20ms), that is scaled down based on the amount of tasks waiting to be scheduled (the more the system becomes overloaded, the shorter the assigned time slice becomes; this can help to reduce the average wait time, making the system more responsive when it is overloaded).

Conclusion

That’s all for now, the goal of this post (probably the first one of multiple series) is to give an idea how the scheduler works.

The scheduler is still under development, but some early results are very promising.

NOTE: keep in mind that in this video the scheduler was still in an early stage, since then it has been improved a lot in terms of stability, robustness and performance.

In the next post I will cover more technical details, mentioning some open issues and plans for future development and improvements.

I’m also planning to run more benchmarks with this scheduler (using the Phoronix test suite) and share some results, so stay tuned!

References

Thursday, July 20, 2023

Implement your own kernel CPU scheduler in Ubuntu with sched-ext

What is sched-ext?

sched-ext is a new scheduling class introduced in the Linux kernel that provides a mechanism to implement scheduling policies as BPF (Berkeley Packet Filter) programs [1]. Such programs can also be connected to user-space counterparts to defer scheduling decisions to regular user-space processes.

State of the art

The idea of "pluggable" schedulers is not new, it was initially proposed in 2004 [2], but at that time it was strongly rejected, to prioritize the creation of a single generic scheduler (one to rule them all), that ended up being the “completely fair scheduler” (CFS). However, with BPF and the sched-ext scheduling class, we now have the possibility to easily and quickly implement and test scheduling policies, making the “pluggable” approach an effective tool for easy experimentation.

What is the main benefit of sched-ext?

The ability to implement custom scheduling policies via BPF greatly lowers the difficulty of testing new scheduling ideas (much easier than changing CFS or replacing it with a different scheduler). With this feature researchers or developers can test their own scheduler in a safe way, without even needing to reboot the system.

How to use sched-ext in Ubuntu?

Unfortunately sched-ext is not yet available in the upstream Linux kernel, at the moment it is only available as a patch set in the Linux kernel mailing list [3] (and it is unlikely to be applied upstream in the near future, because there are still some concerns and potential issues that need to be addressed). However, it is possible to use an experimental version of the Ubuntu linux-unstable kernel [4] [5] that includes the sched-ext patch set (keep in mind that this kernel is very experimental, do not use it in production!).

How to implement a custom scheduler?

The following example implements a “toy” CPU scheduler that passes all the scheduling “enqueue” events to a user-space task that processes them in a FIFO way and sends the “dispatch” events back to the kernel. First of all let’s implement the BPF program:

/* SPDX-License-Identifier: GPL-2.0 */
/*
 * Copyright 2023 Canonical Ltd.
 */

#include "scx_common.bpf.h"
#include "scx_toy.h"

char _license[] SEC("license") = "GPL";

/*
 * This contains the PID of the scheduler task itself (initialized in
 * scx_toy.c).
 */
const volatile s32 usersched_pid;

/* Set when the user-space scheduler needs to run */
static bool usersched_needed;

/* Notify the user-space counterpart when the BPF program exits */
struct user_exit_info uei;

/* Enqueues statistics */
u64 nr_failed_enqueues, nr_kernel_enqueues, nr_user_enqueues;

/*
 * BPF map to store enqueue events.
 *
 * The producer of this map is this BPF program, the consumer is the user-space
 * scheduler task.
 */
struct {
        __uint(type, BPF_MAP_TYPE_QUEUE);
        __uint(max_entries, MAX_TASKS);
        __type(value, struct scx_toy_enqueued_task);
} enqueued SEC(".maps");

/*
 * BPF map to store dispatch events.
 *
 * The producer of this map is the user-space scheduler task, the consumer is
 * this BPF program.
 */
struct {
        __uint(type, BPF_MAP_TYPE_QUEUE);
        __uint(max_entries, MAX_TASKS);
        __type(value, s32);
} dispatched SEC(".maps");

/* Return true if the target task "p" is a kernel thread */
static inline bool is_kthread(const struct task_struct *p)
{
	return !!(p->flags & PF_KTHREAD);
}

/* Return true if the target task "p" is the user-space scheduler task */
static bool is_usersched_task(const struct task_struct *p)
{
	return p->pid == usersched_pid;
}

/*
 * Dispatch user-space scheduler directly.
 */
static void dispatch_user_scheduler(void)
{
        struct task_struct *p;

        if (!usersched_needed)
                return;
        p = bpf_task_from_pid(usersched_pid);
        if (!p)
                return;
        usersched_needed = false;
        scx_bpf_dispatch(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, 0);
        bpf_task_release(p);
}

void BPF_STRUCT_OPS(toy_enqueue, struct task_struct *p, u64 enq_flags)
{
	struct scx_toy_enqueued_task task = {
		.pid = p->pid,
	};

        /*
         * User-space scheduler will be dispatched only when needed from
         * toy_dispatch(), so we can skip it here.
         */
        if (is_usersched_task(p))
            return;

	if (is_kthread(p)) {
		/*
		 * We want to dispatch kernel threads and the scheduler task
		 * directly here for efficiency reasons, rather than passing
		 * the events to the user-space scheduler counterpart.
		 */
		__sync_fetch_and_add(&nr_kernel_enqueues, 1);
		scx_bpf_dispatch(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
		return;
	}
	if (bpf_map_push_elem(&enqueued, &task, 0)) {
		/*
		 * We couldn't push the task to the "enqueued" map, dispatch
		 * the event here and register the failure in the failure
		 * counter.
		 */
		__sync_fetch_and_add(&nr_failed_enqueues, 1);
		scx_bpf_dispatch(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
	} else {
		/*
		 * Enqueue event will be processed and task will be dispatched
		 * in user-space by the scheduler task.
		 */
		__sync_fetch_and_add(&nr_user_enqueues, 1);
	}
}

void BPF_STRUCT_OPS(toy_dispatch, s32 cpu, struct task_struct *prev)
{
	struct task_struct *p;
	s32 pid;

        dispatch_user_scheduler();

	/*
	 * Get a dispatch event from user-space and dispatch the corresponding
	 * task.
	 */
	if (bpf_map_pop_elem(&dispatched, &pid))
		return;

	p = bpf_task_from_pid(pid);
	if (!p)
		return;

	scx_bpf_dispatch(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, 0);
	bpf_task_release(p);
}

s32 BPF_STRUCT_OPS(toy_init)
{
	/* Apply the "toy" scheduling class for all the tasks in the system */
	scx_bpf_switch_all();
	return 0;
}

void BPF_STRUCT_OPS(toy_exit, struct scx_exit_info *ei)
{
	/* Notify user-space counterpart that the BPF program terminated */
	uei_record(&uei, ei);
}

SEC(".struct_ops.link")
struct sched_ext_ops toy_ops = {
	.enqueue		= (void *)toy_enqueue,
	.dispatch		= (void *)toy_dispatch,
	.init			= (void *)toy_init,
	.exit			= (void *)toy_exit,
	.name			= "toy",
};

Then we can implement the user-space counterpart, that will intercept the “enqueue” events and will dispatch them back to the kernel:

/* SPDX-License-Identifier: GPL-2.0 */
/*
 * Copyright 2023 Canonical Ltd.
 */

#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
#include <sched.h>
#include <signal.h>
#include <assert.h>
#include <libgen.h>
#include <pthread.h>
#include <bpf/bpf.h>
#include <sys/mman.h>
#include <sys/queue.h>
#include <sys/syscall.h>
#include "user_exit_info.h"
#include "scx_toy.skel.h"
#include "scx_toy.h"

const char help_fmt[] =
"A toy sched_ext scheduler.\n"
"\n"
"See the top-level comment in .bpf.c for more details.\n"
"\n"
"Usage: %s\n"
"\n"
"  -h            Display this help and exit\n";

static volatile int exit_req;

/*
 * Descriptors used to communicate enqueue and dispatch event with the BPF
 * program.
 */
static int enqueued_fd, dispatched_fd;

static struct scx_toy *skel;

static void sigint_handler(int dummy)
{
	exit_req = 1;
}

/* Thread that periodically prints enqueue statistics */
static void *run_stats_printer(void *arg)
{
	while (!exit_req) {
		__u64 nr_failed_enqueues, nr_kernel_enqueues, nr_user_enqueues, total;

		nr_failed_enqueues = skel->bss->nr_failed_enqueues;
		nr_kernel_enqueues = skel->bss->nr_kernel_enqueues;
		nr_user_enqueues = skel->bss->nr_user_enqueues;
		total = nr_failed_enqueues + nr_kernel_enqueues + nr_user_enqueues;

		printf("\e[1;1H\e[2J");
		printf("o-----------------------o\n");
		printf("| BPF SCHED ENQUEUES    |\n");
		printf("|-----------------------|\n");
		printf("|  kern:     %10llu |\n", nr_kernel_enqueues);
		printf("|  user:     %10llu |\n", nr_user_enqueues);
		printf("|  failed:   %10llu |\n", nr_failed_enqueues);
		printf("|  -------------------- |\n");
		printf("|  total:    %10llu |\n", total);
		printf("o-----------------------o\n\n");
		sleep(1);
	}

	return NULL;
}

static int spawn_stats_thread(void)
{
	pthread_t stats_printer;

	return pthread_create(&stats_printer, NULL, run_stats_printer, NULL);
}

/* Send a dispatch event to the BPF program */
static int dispatch_task(s32 pid)
{
	int err;

	err = bpf_map_update_elem(dispatched_fd, NULL, &pid, 0);
	if (err) {
		fprintf(stderr, "Failed to dispatch task %d\n", pid);
		exit_req = 1;
	}

	return err;
}

/* Receive all the enqueue events from the BPF program */
static void drain_enqueued_map(void)
{
	struct scx_toy_enqueued_task task;

	while (!bpf_map_lookup_and_delete_elem(enqueued_fd, NULL, &task))
		dispatch_task(task.pid);
}

/*
 * Scheduler main loop: get enqueue events from the BPF program, process them
 * (no-op) and send dispatch events to the BPF program.
 */
static void sched_main_loop(void)
{
	while (!exit_req && !uei_exited(&skel->bss->uei)) {
		drain_enqueued_map();
		sched_yield();
	}
}

int main(int argc, char **argv)
{
	struct bpf_link *link;
	u32 opt;
	int err;

	signal(SIGINT, sigint_handler);
	signal(SIGTERM, sigint_handler);

	libbpf_set_strict_mode(LIBBPF_STRICT_ALL);

	skel = scx_toy__open();
	assert(skel);

	skel->rodata->usersched_pid = getpid();
	assert(skel->rodata->usersched_pid > 0);

	while ((opt = getopt(argc, argv, "h")) != -1) {
		switch (opt) {
		default:
			fprintf(stderr, help_fmt, basename(argv[0]));
			return opt != 'h';
		}
	}

	/*
 	 * It's not always safe to allocate in a user space scheduler, as an
 	 * enqueued task could hold a lock that we require in order to be able
 	 * to allocate.
 	 */
	err = mlockall(MCL_CURRENT | MCL_FUTURE);
	if (err) {
		fprintf(stderr, "Failed to prefault and lock address space: %s\n",
			strerror(err));
		return err;
	}

	assert(!scx_toy__load(skel));

	/* Initialize file descriptors to communicate with the BPF program */
	enqueued_fd = bpf_map__fd(skel->maps.enqueued);
	dispatched_fd = bpf_map__fd(skel->maps.dispatched);
	assert(enqueued_fd > 0);
	assert(dispatched_fd > 0);

	/* Start the thread to periodically print enqueue statistics */
	err = spawn_stats_thread();
	if (err) {
		fprintf(stderr, "Failed to spawn stats thread: %s\n", strerror(err));
		goto destroy_skel;
	}

	/* Register BPF program */
	link = bpf_map__attach_struct_ops(skel->maps.toy_ops);
	assert(link);

	/* Call the scheduler main loop */
	sched_main_loop();

	/* Unregister the BPF program and exit */
	bpf_link__destroy(link);
	uei_print(&skel->bss->uei);
	scx_toy__destroy(skel);

	return 0;

destroy_skel:
	scx_toy__destroy(skel);
	exit_req = 1;
	return err;
}

To test the “toy” scheduler install the latest kernel from `ppa:arighi/sched-ext` with all the required build dependencies, following the steps documented at [5]). Then you can load this “toy” scheduling class and replace the default CPU scheduler in Linux simply by running the following command:

$ sudo ./scx_toy

All the events that are affecting kernel threads or the scheduler task itself will be processed in kernel-space (for efficiency reasons), while the rest of the user-space tasks will be processed by the scheduler task (`scx_toy`). The program will output some statistics of the “enqueue” and “dispatch” events done in user-space and kernel-space. To unregister the “toy” scheduler and restore the default CFS scheduler we can simply press CTRL+c, that will also stop the scheduler task:

o-----------------------o
| BPF SCHED ENQUEUES    |
|-----------------------|
|  kern:          19028 |
|  user:          15875 |
|  failed:            0 |
|  -------------------- |
|  total:         34903 |
o-----------------------o

^CEXIT: BPF scheduler unregistered

Credits

The sched-ext patch set has been written by Tejun Heo, David Vernet, Josh Don and Barret Rhoden (with multiple contributions from the kernel community).

See also

  1. The extensible scheduler class
  2. Schedulers, pluggable and realtime
  3. [PATCHSET v5] sched: Implement BPF extensible scheduler class
  4. Latest sched-ext enabled Ubuntu kernel (git repository)
  5. Ubuntu sched-ext experimental (ppa)
  6. sched_ext: a BPF-extensible scheduler class (Part 1)