first commit

This commit is contained in:
elwrcl committed 2026-09-02 20:00:01 +03:00
commit b64e40d88d
12 files changed
+3566

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
/target/
/result
/result-*
src/bpf/vmlinux.h
/nixos.qcow2
/Cargo.lock.orig
Generated
+1540
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "scx_soryu"
version = "0.1.0"
edition = "2021"
description = "BMQ policy port for sched_ext"
license = "GPL-2.0-only"
[dependencies]
anyhow = "1.0"
clap = { version = "4", features = ["derive", "env", "wrap_help"] }
ctrlc = { version = "3.4", features = ["termination"] }
libbpf-rs = "=0.26.2"
libc = "0.2"
log = "0.4"
scx_stats = "1.1.3"
scx_stats_derive = "1.1.3"
scx_utils = "1.1.3"
serde = { version = "1", features = ["derive"] }
crossbeam = "0.8"
simplelog = "0.12"
[build-dependencies]
scx_cargo = "1.1.3"
[profile.release]
lto = "thin"
panic = "abort"
+38
View File
@@ -0,0 +1,38 @@
# scx_soryu
a port of the BMQ/LFBMQ scheduling policy onto [sched_ext](https://docs.kernel.org/scheduler/sched-ext.html).
the policy is pure BPF C; userspace is a thin Rust loader and more safer than normal scheduler.
## tested on
| machine | cpu | status |
| --- | --- | --- |
| target, tested | Intel i5-3210m (Ivy Bridge, 2c/4t) | **verified** — bare metal, extended game + build sessions |
| target, untested | Ryzen 5 PRO 4650u (Renoir, 6c/12t) | not yet run |
| target, untested | Ryzen 5 7500F (ZEN4, 6c/12t) | not yet run |
## development
```sh
nix develop # clang, bpftool, rust, libbpf; kernel drift guard
```
run it by hand:
```sh
sudo ./target/release/scx_soryu
```
or as a NixOS service — see [`docs/service.md`](docs/service.md).
test VM (a smoke test, not a benchmark — measure on bare metal):
```sh
nix build .#nixosConfigurations.testvm.config.system.build.vm
./result/bin/run-nixos-vm
```
## kernel
audited series is **7.1.x**. the devShell refuses to open outside it.
`vmlinux.h` is generated from the running kernel's BTF and is not committed.
+7
View File
@@ -0,0 +1,7 @@
fn main() {
scx_cargo::BpfBuilder::new()
.unwrap()
.enable_skel("src/bpf/main.bpf.c", "bpf")
.build()
.unwrap();
}
+99
View File
@@ -0,0 +1,99 @@
# the NixOS service
the scheduler is now also a flake package and a NixOS module. running
`sudo ./target/release/scx_soryu` by hand still works — the service is an
addition, not a replacement.
## adding it to copland
`flake.nix` inputs:
```nix
scx-soryu.url = "github:elwrcl/scx_soryu/bmq";
```
in the configuration:
```nix
imports = [ inputs.scx-soryu.nixosModules.default ];
services.scx-soryu = {
enable = true;
notify.user = "elars"; # desktop notification on ejection
};
```
that's it. the defaults are measured values: `sliceUs = 1000`
([`slice.md`](slice.md)), `starveMs = 128` ([`stage6-starve.md`](stage6-starve.md)),
`timeoutMs = 5000`, `reportSec = 60`.
to turn it off: `services.scx-soryu.enable = false;` or
`systemctl stop scx-soryu`. either one falls back to EEVDF immediately.
## "something happened and nobody noticed"
an ejection is a silent event: the kernel drops the scheduler, every task
falls back to default kernel scheduler, and nothing on the desktop says so. three layers were added to fix that.
**1. the loader says so, loudly.** ejection used to be a bare
`Error: ...` line printed by anyhow — easy to scroll past in a terminal,
unlabeled and unsearchable in the journal. now it's two ERROR-level lines:
```
scx_soryu was ejected by the kernel: EXIT: runnable task stall (...)
all tasks are back on default kernel scheduler; the scheduler is NOT running
```
**2. systemd retries three times, then gives up.** `Restart=on-failure`,
3 starts in 5 minutes. a transient load spike recovers on its own; a
persistent problem doesn't flap forever — it lands in `failed`.
**3. `failed` triggers a desktop notification.** a second unit bound with
`OnFailure=` finds the user's session bus and calls
`notify-send --urgency=critical`. if there's no session, it exits quietly
(no notification, no error either).
either way, the full story is in the journal:
```
journalctl -u scx-soryu -n 200
```
## telemetry noise
the loader prints four lines a second — fine in a terminal, a disaster in
the journal. `--report-sec` was added; the service uses 60. shutdown is
still checked once a second regardless, so `systemctl stop` isn't slowed
down.
to see live status while the service is running:
```
sudo scx_soryu --monitor 3
```
## verified in a VM
`nixosConfigurations.servicevm` — the same test VM, but running the
scheduler as a service instead of by hand. `tools/servicevm.sh`:
| check | result |
| --- | --- |
| comes up at boot | `active`, `sched_ext=enabled` |
| clean `systemctl stop` | `inactive`, `sched_ext=disabled`, **not** `failed` |
| restarts | `active`, `enabled` |
| repeated SIGKILL | 2 restarts, then `failed` — no flapping |
| journal | `Start request repeated too quickly` + `Failed with result 'start-limit-hit'` |
a clean `systemctl stop` mattered: SIGTERM reaches the loader and drops the
struct_ops link, so unload is clean and the unit isn't marked `failed`.
## packaging note
`packages.default` builds inside the sandbox: the BPF object is compiled
against `scx_utils`'s vendored `vmlinux.h`, then CO-RE-relocated against
the running kernel's BTF at load time. so the build doesn't depend on the
running kernel — `/sys/kernel/btf/vmlinux` is only needed at **run** time.
(the `src/bpf/vmlinux.h` sitting in the repo turned out unused — deleting
it, the build still passes the same way. it was already gitignored.)
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1784007870,
"narHash": "sha256-djcLt/JJphyNt4eDY9XTly+/WbCK5lqWq9lSgCmJkkQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18b9261cb3294b6d2a06d03f96872827b8fe2698",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18b9261cb3294b6d2a06d03f96872827b8fe2698",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+331
View File
@@ -0,0 +1,331 @@
{
description = "scx_soryu — BMQ/LFBMQ policy port for sched_ext";
# The test VM's kernel is the same derivation as
# the host's booted kernel. See docs/KERNEL.md.
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/18b9261cb3294b6d2a06d03f96872827b8fe2698";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
kernelPackages = pkgs.linuxPackages_7_1;
kernelVersion = kernelPackages.kernel.modDirVersion;
auditedSeries = "7.1";
vmBase = { pkgs, lib, ... }: {
boot.kernelPackages = kernelPackages;
# SysRq-S (sched_ext reset) and SysRq-D (dump) are gated behind
# SYSRQ_ENABLE_RTNICE (0x100). NixOS's default of 184 omits it, so
# the emergency escape hatch is dead unless we set this.
boot.kernel.sysctl."kernel.sysrq" = 440;
virtualisation = {
cores = 4;
memorySize = 4096;
graphics = false;
# the host: 2 physical cores x 2 threads, one LLC. Without
# this QEMU reports 4 sockets and scx_bpf_select_cpu_dfl()'s SMT
# walk has nothing real to walk.
qemu.options = [ "-cpu host" "-smp 4,sockets=1,cores=2,threads=2" ];
sharedDirectories.soryu = {
source = "/home/elars/Projects/scx_soryu";
target = "/mnt/soryu";
};
};
services.getty.autologinUser = "root";
users.users.root.password = "";
# rt-tests carries both cyclictest and hackbench (M1 harness).
environment.systemPackages = with pkgs; [ stress-ng rt-tests ];
system.stateVersion = "25.05";
};
in
{
devShells.${system}.default = pkgs.mkShell {
name = "scx_soryu";
nativeBuildInputs = with pkgs; [
clang
llvm
bpftools
pkg-config
rustc
cargo
rustfmt
clippy
rust-analyzer
];
buildInputs = with pkgs; [
libbpf
elfutils
zlib
zstd
];
# scx_utils' build script runs bindgen, which dlopens libclang.
LIBCLANG_PATH = "${pkgs.libclang.lib}/lib";
# The nix cc-wrapper injects -fzero-call-used-regs, which clang rejects
# for -target bpf. nixpkgs' own scx derivation disables the same knob.
hardeningDisable = [ "zerocallusedregs" ];
RUSTFLAGS = builtins.concatStringsSep " " [
"-C relocation-model=pic"
"-C link-args=-lelf"
"-C link-args=-lz"
"-C link-args=-lzstd"
];
shellHook = ''
expected="${kernelVersion}"
series="${auditedSeries}"
running="$(uname -r)"
running_series="$(echo "$running" | cut -d. -f1,2)"
if [ "$running_series" != "$series" ]; then
cat >&2 <<MSG
╔══════════════════════════════════════════════════════════════╗
║ KERNEL SERIES MISMATCH — refusing to enter ║
╚══════════════════════════════════════════════════════════════╝
audited series : $series.x
booted : $running
MSG
return 1
fi
if [ "$running" != "$expected" ]; then
cat >&2 <<MSG
warning: patch-level kernel drift
flake VM kernel : $expected
booted : $running
Same $series.x series, so the kfunc audit still holds. The test VM
will run $expected while bare metal runs $running. The BPF object
is compiled against scx_utils' vendored vmlinux.h and relocated
against the booted kernel's BTF at load time (CO-RE), so the build
does not depend on which of the two you are on.
MSG
fi
# Needed to *load*, not to build: libbpf resolves CO-RE relocations
# against this at attach time.
if [ ! -r /sys/kernel/btf/vmlinux ]; then
echo "error: /sys/kernel/btf/vmlinux unreadable; CONFIG_DEBUG_INFO_BTF required" >&2
return 1
fi
echo "scx_soryu devShell — kernel $running, BTF ok"
'';
};
packages.${system}.default = pkgs.rustPlatform.buildRustPackage {
pname = "scx_soryu";
version = "0.1.0";
# target/ is a gigabyte and nixos.qcow2 is the VM's disk; neither
# belongs in the store path, and both would invalidate the hash on
# every build.
src = pkgs.lib.cleanSourceWith {
src = ./.;
filter = path: _type:
!(builtins.elem (baseNameOf path) [ "target" "result" "nixos.qcow2" ]);
};
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = with pkgs; [
clang
llvm
bpftools
pkg-config
rustPlatform.bindgenHook # scx_utils runs bindgen; needs libclang
];
buildInputs = with pkgs; [ libbpf elfutils zlib zstd ];
# Same two workarounds the devShell needs, for the same reasons.
hardeningDisable = [ "zerocallusedregs" ];
RUSTFLAGS = builtins.concatStringsSep " " [
"-C relocation-model=pic"
"-C link-args=-lelf"
"-C link-args=-lz"
"-C link-args=-lzstd"
];
doCheck = false; # the tests that matter need a kernel, not a sandbox
meta = {
description = "BMQ/LFBMQ policy port for sched_ext";
mainProgram = "scx_soryu";
};
};
nixosModules.default = { config, lib, pkgs, ... }:
let
cfg = config.services.scx-soryu;
in
{
options.services.scx-soryu = {
enable = lib.mkEnableOption "the scx_soryu sched_ext scheduler";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
description = "scx_soryu build to run.";
};
sliceUs = lib.mkOption {
type = lib.types.ints.positive;
default = 1000;
description = ''
Timeslice in microseconds. 1000 is the measured knee: see
docs/slice.md. 4194 restores BMQ's own value.
'';
};
starveMs = lib.mkOption {
type = lib.types.ints.unsigned;
default = 128;
description = ''
Anti-starvation deadline. 0 restores BMQ's semantics.
'';
};
timeoutMs = lib.mkOption {
type = lib.types.ints.positive;
default = 5000;
description = "Kernel watchdog threshold for a stalled task.";
};
reportSec = lib.mkOption {
type = lib.types.ints.positive;
default = 60;
description = ''
Seconds between telemetry lines in the journal. The loader's
own default is 1, which is right for a terminal and far too
chatty for a service.
'';
};
extraArgs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "--boost-on-wake" "false" ];
};
notify = {
enable = lib.mkOption {
type = lib.types.bool;
default = cfg.notify.user != null;
defaultText = lib.literalExpression "notify.user != null";
description = ''
Send a desktop notification when the scheduler gives up.
Being ejected is silent: the kernel puts every task back on
default scheduler and nothing on the desktop says so.
Without this the only trace is the journal.
'';
};
user = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "elars";
description = "Desktop user to notify.";
};
};
};
config = lib.mkIf cfg.enable {
assertions = [{
assertion = !cfg.notify.enable || cfg.notify.user != null;
message = "services.scx-soryu.notify.enable needs notify.user.";
}];
systemd.services.scx-soryu = {
description = "scx_soryu sched_ext scheduler";
documentation = [ "https://github.com/elwrcl/scx_soryu" ];
wantedBy = [ "multi-user.target" ];
after = [ "systemd-udev-settle.service" ];
startLimitIntervalSec = 300;
startLimitBurst = 3;
onFailure = lib.mkIf cfg.notify.enable [ "scx-soryu-notify.service" ];
serviceConfig = {
ExecStart = lib.escapeShellArgs ([
(lib.getExe cfg.package)
"--slice-us" (toString cfg.sliceUs)
"--starve-ms" (toString cfg.starveMs)
"--timeout-ms" (toString cfg.timeoutMs)
"--report-sec" (toString cfg.reportSec)
] ++ cfg.extraArgs);
Type = "simple";
Restart = "on-failure";
RestartSec = 5;
KillSignal = "SIGTERM";
TimeoutStopSec = 15;
# Losing the scheduler to the OOM killer would be a strange
# way to lose the scheduler.
OOMScoreAdjust = -500;
};
};
systemd.services.scx-soryu-notify = lib.mkIf cfg.notify.enable {
description = "Report that scx_soryu gave up";
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "scx-soryu-notify" ''
set -u
echo "soryu panic!; the system is running default scheduler" >&2
echo "journalctl -u scx-soryu -n 200 has the exit reason" >&2
uid=$(${pkgs.coreutils}/bin/id -u ${cfg.notify.user}) || exit 0
bus=/run/user/$uid/bus
[ -S "$bus" ] || exit 0
${pkgs.util-linux}/bin/runuser -u ${cfg.notify.user} -- ${pkgs.coreutils}/bin/env "DBUS_SESSION_BUS_ADDRESS=unix:path=$bus" ${pkgs.libnotify}/bin/notify-send --urgency=critical --icon=dialog-error "scx_soryu durdu" "Sistem EEVDF'e döndü. journalctl -u scx-soryu" || true
'';
};
};
};
};
nixosConfigurations.testvm = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
"${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
vmBase
];
};
nixosConfigurations.servicevm = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
"${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
vmBase
self.nixosModules.default
{
services.scx-soryu = {
enable = true;
reportSec = 5;
# No desktop session in here, so nothing to notify.
notify.enable = false;
};
}
];
};
};
}
+751
View File
@@ -0,0 +1,751 @@
/* SPDX-License-Identifier: GPL-2.0 */
/*
* scx_soryu — BMQ policy on sched_ext.
*/
#include <scx/common.bpf.h>
char _license[] SEC("license") = "GPL";
UEI_DEFINE(uei);
/*
* BMQ compresses (nice + boost) by two, so adjacent nice levels share a queue.
* That halves nice resolution and in exchange gives boost twice the leverage
* per step. Keeping the compression is the point of porting BMQ rather than
* inventing a lookalike.
*
* BMQ: 25 + (prio + boost - 128) / 2, where prio = 160 + nice
* 0-based: (32 + nice + boost) / 2
* mainline: (static_prio - 88 + boost) / 2, nice = static_prio - 120
*/
#define BOOST_RANGE 12 /* BMQ MAX_PRIORITY_ADJ */
#define NORMAL_LEVELS 32 /* (12 + 40 + 12) / 2 */
#define LEVELS (NORMAL_LEVELS + 1) /* last level is SCHED_IDLE */
#define IDLE_LEVEL (LEVELS - 1)
#define MAX_CPUS 1024
/*
* BMQ's RESCHED_NS (alt_core.c:94). A remainder below this is treated as spent:
* handing back a sliver just makes the task come straight back, so BMQ refills
* instead. Same threshold, same reason.
*/
#define RESCHED_NS (100 << 10) /* ~102 us */
/* uapi/linux/sched.h; vmlinux.h carries the enum but not these defines. */
#define SCHED_NORMAL 0
#define SCHED_BATCH 3
#define SCHED_IDLE 5
#define DSQ_BASE 0x736f7279UL /* "sory" */
#define dsq_id(lvl) (DSQ_BASE + (lvl))
const volatile u64 slice_ns = 4194304ULL; /* 4 << 20, BMQ sysctl_sched_base_slice */
const volatile u32 nr_cpu_ids = 1; /* filled in by the loader */
/*
* BMQ's sched_task_ttwu() boosts on wakeup in proportion to how long the task
* slept, which is what makes sleeping for 1us worth nothing and closes the
* sleep-gaming hole. Except the line reads
*
* s64 delta = this_rq()->clock_task > p->last_ran;
*
* -- a comparison, not a subtraction. delta is always 0 or 1, the shift makes
* it 0, and the whole function is dead code in every BMQ build shipped.
*
* Fixing it means running behaviour nobody has field-tested; leaving it means
* reproducing a typo. So it is a knob, measured both ways in stage 2.
*/
const volatile bool boost_on_wake = true;
/*
* Anti-starvation, and a deliberate deviation from BMQ.
*
* BMQ has none. bmq.h is forty lines: boost comes from sleeping, deboost from
* burning a slice, and nothing whatsoever happens to a task that is merely
* waiting. Strict priority, no aging, no deadline. On mainline that is
* survivable -- a nice-19 thread stuck behind a compile is just slow, and no
* one dies. Under sched_ext the watchdog calls it a fatal stall and ejects the
* whole scheduler, which is what happened on 1 Sep: helium:disk$2 (nice 19,
* level 25) waited 7.115s behind seven compilers at level 22 and took us down
* with it. See docs/stage6-stall.md.
*
* So: a level that has held tasks this long without being served gets a turn,
* once, ahead of better levels. One task per level per starve_ns, which works
* out to roughly 3% of a CPU for a starving band -- close to what nice 19 is
* worth under CFS anyway (weight 15/1024). Strict priority is preserved
* everywhere else.
*
* 0 disables it and restores BMQ's semantics exactly.
*/
const volatile u64 starve_ns = 128ULL << 20; /* ~134 ms */
u64 lvl_bitmap;
/* Level each CPU is currently running, LEVELS when idle. Used by stage 4. */
static u8 cpu_level[MAX_CPUS];
u64 max_wait_jiffies;
u64 lvl_max_wait[LEVELS];
u64 lvl_last_run[LEVELS];
u32 starve_cursor;
u32 probe_seq;
/*
* How many times each boost value was seen at ops.running. The single most
* useful stage 2 number: it says whether boost is actually moving, and in which
* direction the workload pushes it. Index is boost + BOOST_RANGE.
*/
u64 boost_hist[2 * BOOST_RANGE + 1];
#define LAT_CLASSES 3 /* 0 boosted, 1 neutral, 2 deboosted */
#define LAT_BUCKETS 32 /* log2(ns): bucket 10 is ~1us, 30 is ~1s */
u64 lat_hist[LAT_CLASSES * LAT_BUCKETS];
struct task_ctx
{
s32 boost; /* [-BOOST_RANGE, +BOOST_RANGE], negative = better */
u64 sleep_at; /* scx_bpf_now() when the task went to sleep */
u64 run_at; /* scx_bpf_now() when it last started running */
u64 wake_at; /* scx_bpf_now() at ops.runnable, for lat_hist */
u64 slice_given; /* what p->scx.slice held at ops.running */
u64 slice_left;
};
struct
{
__uint(type, BPF_MAP_TYPE_TASK_STORAGE);
__uint(map_flags, BPF_F_NO_PREALLOC);
__type(key, int);
__type(value, struct task_ctx);
} task_ctxs SEC(".maps");
enum soryu_stat_idx
{
SORYU_STAT_ENQ_SHARED,
SORYU_STAT_ENQ_LOCAL,
SORYU_STAT_ENQ_REENQ,
SORYU_STAT_ENQ_PINNED,
SORYU_STAT_DISPATCH,
SORYU_STAT_INSERT_FAIL,
SORYU_STAT_CPU_RELEASE,
SORYU_STAT_BOOST_SLEEP,
SORYU_STAT_BOOST_WAKE,
SORYU_STAT_DEBOOST,
SORYU_STAT_SLICE_CARRY,
SORYU_STAT_FORK_SPLIT,
SORYU_STAT_PREEMPT_KICK,
SORYU_STAT_STARVE_SERVE,
SORYU_STAT_PREEMPT_NONE,
SORYU_STAT_PREEMPT_IDLE,
SORYU_NR_STATS,
};
struct
{
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u64));
__uint(max_entries, SORYU_NR_STATS);
} stats SEC(".maps");
static void stat_inc(enum soryu_stat_idx idx)
{
u32 key = idx;
u64 *cnt = bpf_map_lookup_elem(&stats, &key);
if (cnt)
(*cnt)++;
}
static struct task_ctx *lookup_task_ctx(struct task_struct *p)
{
return bpf_task_storage_get(&task_ctxs, p, 0, 0);
}
/*
* BMQ boost_task(): the floor depends on policy. SCHED_NORMAL may go all the
* way to -12; SCHED_BATCH may only climb back to 0, so a batch task can recover
* from a deboost but never outrank a neutral one; anything else (SCHED_IDLE)
* never boosts at all.
*/
static void boost_task(struct task_struct *p, struct task_ctx *tctx, u32 n)
{
s32 limit, want;
switch (p->policy)
{
case SCHED_NORMAL:
limit = -BOOST_RANGE;
break;
case SCHED_BATCH:
limit = 0;
break;
default:
return;
}
want = (s32)tctx->boost - (s32)n;
tctx->boost = want < limit ? limit : want;
}
/* BMQ deboost_task(): NORMAL and BATCH both decay, SCHED_IDLE does not. */
static void deboost_task(struct task_struct *p, struct task_ctx *tctx)
{
if ((p->policy == SCHED_NORMAL || p->policy == SCHED_BATCH) &&
tctx->boost < BOOST_RANGE)
tctx->boost++;
}
static u32 task_level(struct task_struct *p, struct task_ctx *tctx)
{
s32 v;
if (p->policy == SCHED_IDLE)
return IDLE_LEVEL;
/*
* static_prio in [100, 139], boost in [-12, 12], so v lands in [0, 63]
* and never goes negative — no clamp needed, and >> 1 == / 2.
*
* static_prio rather than p->scx.weight on purpose: weight is a
* logarithmic thing derived from nice, and the level math wants nice
* itself. ops.set_weight is deliberately not implemented.
*/
v = (s32)p->static_prio - 88 + (tctx ? tctx->boost : 0);
return (u32)v >> 1;
}
static u64 task_slice(struct task_ctx *tctx)
{
if (tctx && tctx->slice_left >= RESCHED_NS)
return tctx->slice_left;
return slice_ns;
}
/*
* Kick the CPU running the worst thing this task could displace.
*
* BMQ does this on every wakeup: if the woken task outranks what some CPU is
* running, send a resched IPI. With a shared queue there is no single "target
* CPU", so we track what level each CPU is running (cpu_level[]) and search.
*
* Two deliberate differences from LFBMQ (alt_core.c:4225):
*
* - It kicks *every* eligible CPU; we kick only the worst one. Broadcasting
* costs an IPI per CPU per wakeup, and stage 1 measured the shared queue
* taking 82% of wakeups under game load -- that is a lot of wakeups to
* multiply. One kick is enough: the preempted CPU re-enters dispatch and
* pulls the lowest level from the bitmap anyway.
*
* - It respects p->cpus_ptr and so do we. The spec originally dropped this;
* kicking a CPU the task cannot run on wastes the IPI and hands the slot to
* somebody else.
*/
static void try_preempt(struct task_struct *p, u32 lvl, u64 enq_flags)
{
const struct cpumask *allowed = p->cpus_ptr;
s32 worst_cpu = -1;
u32 worst = lvl + 1; /* never IPI an equal or better level */
u32 nr = scx_bpf_nr_cpu_ids();
u32 cpu;
if (!allowed)
return;
/*
* Wakeups only. BMQ checks for preemption in wakeup_srq_task()
* (alt_core.c:4211) -- on the wakeup path, not on every enqueue.
*
* Running it on every enqueue closes a loop: the task we preempt is
* re-enqueued, that re-enqueue runs the check again, which preempts
* somebody else, and so on. Measured at 3.2M kicks in 32s (~100k
* IPIs/sec) on four spinners plus one 100Hz waker -- three orders of
* magnitude more than the wakeups that caused it.
*/
if (!(enq_flags & SCX_ENQ_WAKEUP))
return;
bpf_for(cpu, 0, nr)
{
u32 cl;
if (cpu >= MAX_CPUS)
break;
if (!bpf_cpumask_test_cpu(cpu, allowed))
continue;
cl = cpu_level[cpu];
if (cl > worst)
{
worst = cl;
worst_cpu = cpu;
}
}
if (worst_cpu >= 0)
{
scx_bpf_kick_cpu(worst_cpu, SCX_KICK_PREEMPT);
/*
* Two very different events were hiding behind one counter.
*
* cpu_level is set to LEVELS when a CPU stops running an scx
* task, so an idle CPU always looks "worse" than any level and
* always wins the search. Most of what preempt_kick counted was
* therefore *waking an idle CPU*, not preempting anybody --
* measured in the VM at 11:1 against real preemption with eight
* sleepers. Necessary work, but it says nothing about
* preemption pressure, and the two must not share a number.
*/
if (worst >= LEVELS)
stat_inc(SORYU_STAT_PREEMPT_IDLE);
else
stat_inc(SORYU_STAT_PREEMPT_KICK);
return;
}
/*
* Nobody worse to kick, so this wakeup waits for a slice to end on its
* own -- up to slice_ns.
*
* This is the measurement behind the slice question. When the boost
* range saturates, every task sits at the same level, `worst` never
* exceeds `lvl`, and preemption switches itself off: the scheduler
* silently degrades to FIFO-with-a-4ms-quantum exactly in the workload
* (gaming, 97% at the floor) where responsiveness is the whole point.
* If this counter dwarfs preempt_kick there, the tail is a slice-length
* problem and no amount of boost tuning will touch it.
*/
stat_inc(SORYU_STAT_PREEMPT_NONE);
}
s32 BPF_STRUCT_OPS(soryu_select_cpu, struct task_struct *p, s32 prev_cpu,
u64 wake_flags)
{
struct task_ctx *tctx;
bool is_idle = false;
s32 cpu;
cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags, &is_idle);
if (!is_idle)
return cpu;
/*
* Hot path: an idle CPU was found, so skip the shared queue entirely.
* ops.enqueue is not called for tasks inserted from here.
*/
tctx = lookup_task_ctx(p);
if (scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, task_slice(tctx), 0))
stat_inc(SORYU_STAT_ENQ_LOCAL);
else
stat_inc(SORYU_STAT_INSERT_FAIL);
return cpu;
}
void BPF_STRUCT_OPS(soryu_enqueue, struct task_struct *p, u64 enq_flags)
{
struct task_ctx *tctx = lookup_task_ctx(p);
u32 lvl = task_level(p, tctx);
/*
* Tasks bounced back by ops.cpu_release did not stop through any fault
* of their own; stage 2 must not read this as a scheduling signal.
*/
if (enq_flags & SCX_ENQ_REENQ)
stat_inc(SORYU_STAT_ENQ_REENQ);
/*
* Single-CPU tasks gain nothing from a shared queue, and they make every
* other CPU pay: consume_dispatch_q() walks the whole DSQ list under
* dsq->lock skipping tasks it cannot migrate, so a pile of pinned tasks
* turns each remote dispatch into a futile scan. Measured at 64 pinned
* tasks: 6x slower than EEVDF before this, and it is exactly the
* live-lock the kernel comments about. Per-CPU kthreads are in this
* class too, so it is not an exotic case.
*
* Restricted-but-multi-CPU masks still go to the shared queue; they are
* rarer and the fix for them is per-CPU DSQs, which is a bigger change.
*/
if (p->nr_cpus_allowed == 1)
{
s32 cpu = scx_bpf_task_cpu(p);
if (scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL_ON | cpu,
task_slice(tctx), enq_flags))
{
stat_inc(SORYU_STAT_ENQ_PINNED);
/* Only one CPU can ever run it, so the search above is
* pointless -- just check that one. Wakeups only, same
* reason as try_preempt(). */
if ((enq_flags & SCX_ENQ_WAKEUP) &&
cpu >= 0 && cpu < MAX_CPUS &&
cpu_level[cpu] > lvl)
{
scx_bpf_kick_cpu(cpu, SCX_KICK_PREEMPT);
stat_inc(SORYU_STAT_PREEMPT_KICK);
}
}
else
{
stat_inc(SORYU_STAT_INSERT_FAIL);
}
return;
}
if (!scx_bpf_dsq_insert(p, dsq_id(lvl), task_slice(tctx), enq_flags))
{
stat_inc(SORYU_STAT_INSERT_FAIL);
return;
}
stat_inc(SORYU_STAT_ENQ_SHARED);
/* Insert first, then advertise: a bit set late is a missed dispatch
* round, a bit set early is a dispatch that finds nothing. */
__sync_fetch_and_or(&lvl_bitmap, 1ULL << lvl);
try_preempt(p, lvl, enq_flags);
}
static void sample_head_wait(u32 lvl)
{
struct task_struct *p;
u64 now = bpf_jiffies64();
if (lvl >= LEVELS)
return;
bpf_for_each(scx_dsq, p, dsq_id(lvl), 0)
{
u64 runnable_at = p->scx.runnable_at;
if (runnable_at)
{
u64 waited = time_delta(now, runnable_at);
if (waited > READ_ONCE(max_wait_jiffies))
WRITE_ONCE(max_wait_jiffies, waited);
if (waited > READ_ONCE(lvl_max_wait[lvl]))
WRITE_ONCE(lvl_max_wait[lvl], waited);
}
break;
}
}
void BPF_STRUCT_OPS(soryu_dispatch, s32 cpu, struct task_struct *prev)
{
u64 mask = READ_ONCE(lvl_bitmap);
u64 now = scx_bpf_now();
if (starve_ns)
{
u32 probe = READ_ONCE(starve_cursor);
probe = probe < LEVELS - 1 ? probe + 1 : 0;
WRITE_ONCE(starve_cursor, probe);
/*
* Telemetry, once every 1024 dispatches -- tens of times a
* second, which is three orders of magnitude finer than a
* multi-second watchdog needs, and cheap enough to ignore.
* (1024 mod 33 = 1, so consecutive samples walk every level.)
*/
if (!(probe_seq++ & 1023) && (mask & (1ULL << probe)))
sample_head_wait(probe);
if ((mask & (1ULL << probe)) &&
now - READ_ONCE(lvl_last_run[probe]) > starve_ns)
{
if (scx_bpf_dsq_move_to_local(dsq_id(probe), 0))
{
WRITE_ONCE(lvl_last_run[probe], now);
stat_inc(SORYU_STAT_DISPATCH);
stat_inc(SORYU_STAT_STARVE_SERVE);
return;
}
/*
* Empty, or nothing here can run on this CPU. Either
* way it is not starving -- it was offered a turn and
* had nothing to take. Stamping it keeps a level that
* has been idle for hours (the bitmap is set-only, so
* its bit stays lit forever) from claiming a free pass
* on every probe.
*/
WRITE_ONCE(lvl_last_run[probe], now);
}
}
bpf_repeat(LEVELS)
{
u32 lvl;
u64 bit;
if (!mask)
break;
lvl = __builtin_ctzll(mask);
bit = 1ULL << lvl;
if (scx_bpf_dsq_move_to_local(dsq_id(lvl), 0))
{
WRITE_ONCE(lvl_last_run[lvl], now);
stat_inc(SORYU_STAT_DISPATCH);
return;
}
mask &= ~bit;
}
}
void BPF_STRUCT_OPS(soryu_runnable, struct task_struct *p, u64 enq_flags)
{
struct task_ctx *tctx;
u64 now, slept, steps;
if (enq_flags & SCX_ENQ_REENQ)
return;
tctx = lookup_task_ctx(p);
if (!tctx)
return;
now = scx_bpf_now();
tctx->wake_at = now;
if (!boost_on_wake || !tctx->sleep_at || !slice_ns)
return;
slept = now > tctx->sleep_at ? now - tctx->sleep_at : 0;
tctx->sleep_at = 0;
steps = slept / slice_ns;
if (!steps)
return;
if (steps > 2 * BOOST_RANGE)
steps = 2 * BOOST_RANGE;
boost_task(p, tctx, (u32)steps);
stat_inc(SORYU_STAT_BOOST_WAKE);
}
void BPF_STRUCT_OPS(soryu_running, struct task_struct *p)
{
struct task_ctx *tctx = lookup_task_ctx(p);
s32 cpu = scx_bpf_task_cpu(p);
u64 runnable_at, waited;
if (cpu >= 0 && cpu < MAX_CPUS)
cpu_level[cpu] = task_level(p, tctx);
if (tctx)
{
u32 idx = (u32)(tctx->boost + BOOST_RANGE);
if (idx < 2 * BOOST_RANGE + 1)
boost_hist[idx]++;
tctx->run_at = scx_bpf_now();
tctx->slice_given = p->scx.slice;
if (tctx->wake_at)
{
u64 lat = tctx->run_at > tctx->wake_at
? tctx->run_at - tctx->wake_at
: 0;
u32 cls = tctx->boost < 0 ? 0 : (tctx->boost ? 2 : 1);
u32 b = lat ? 63 - __builtin_clzll(lat) : 0;
u32 slot;
if (b >= LAT_BUCKETS)
b = LAT_BUCKETS - 1;
slot = cls * LAT_BUCKETS + b;
if (slot < LAT_CLASSES * LAT_BUCKETS)
lat_hist[slot]++;
tctx->wake_at = 0;
}
}
runnable_at = p->scx.runnable_at;
if (runnable_at)
{
u32 lvl = task_level(p, tctx);
waited = time_delta(bpf_jiffies64(), runnable_at);
if (waited > READ_ONCE(max_wait_jiffies))
WRITE_ONCE(max_wait_jiffies, waited);
if (lvl < LEVELS && waited > READ_ONCE(lvl_max_wait[lvl]))
WRITE_ONCE(lvl_max_wait[lvl], waited);
}
}
void BPF_STRUCT_OPS(soryu_stopping, struct task_struct *p, bool runnable)
{
struct task_ctx *tctx = lookup_task_ctx(p);
s32 cpu = scx_bpf_task_cpu(p);
u64 now, ran, left;
if (cpu >= 0 && cpu < MAX_CPUS)
cpu_level[cpu] = LEVELS;
if (!tctx)
return;
if (!runnable)
{
boost_task(p, tctx, 1);
tctx->sleep_at = scx_bpf_now();
tctx->slice_left = 0; /* sleeping earns a fresh slice */
stat_inc(SORYU_STAT_BOOST_SLEEP);
return;
}
now = scx_bpf_now();
ran = now > tctx->run_at ? now - tctx->run_at : 0;
left = tctx->slice_given > ran ? tctx->slice_given - ran : 0;
if (left < RESCHED_NS)
{
deboost_task(p, tctx);
tctx->slice_left = 0;
stat_inc(SORYU_STAT_DEBOOST);
}
else
{
tctx->slice_left = left;
stat_inc(SORYU_STAT_SLICE_CARRY);
}
}
void BPF_STRUCT_OPS(soryu_cpu_release, s32 cpu, struct scx_cpu_release_args *args)
{
scx_bpf_reenqueue_local();
if (cpu >= 0 && cpu < MAX_CPUS)
cpu_level[cpu] = LEVELS;
stat_inc(SORYU_STAT_CPU_RELEASE);
}
s32 BPF_STRUCT_OPS(soryu_init_task, struct task_struct *p,
struct scx_init_task_args *args)
{
struct task_ctx *tctx;
tctx = bpf_task_storage_get(&task_ctxs, p, 0,
BPF_LOCAL_STORAGE_GET_F_CREATE);
if (!tctx)
return -ENOMEM;
tctx->boost = 0;
tctx->sleep_at = 0;
tctx->run_at = 0;
tctx->wake_at = 0;
tctx->slice_given = 0;
tctx->slice_left = 0;
/*
* BMQ (alt_core.c:2874, sched_cgroup_fork):
*
* rq->curr->time_slice /= 2;
* p->time_slice = rq->curr->time_slice;
*
* with the comment "the total amount of pending timeslices in the
* system doesn't change". Without it every fork mints a fresh full
* slice out of nothing, so anything that forks in a loop -- a build,
* most obviously -- gains CPU share against everything else simply by
* forking. Splitting closes that.
*
*/
if (args->fork)
{
struct task_struct *parent = bpf_get_current_task_btf();
struct task_ctx *pctx;
u64 half;
if (parent)
{
half = parent->scx.slice / 2;
scx_bpf_task_set_slice(parent, half);
pctx = bpf_task_storage_get(&task_ctxs, parent, 0, 0);
if (pctx)
pctx->slice_left = half;
tctx->slice_left = half >= RESCHED_NS ? half : 0;
stat_inc(SORYU_STAT_FORK_SPLIT);
}
}
return 0;
}
void BPF_STRUCT_OPS(soryu_dump, struct scx_dump_ctx *dctx)
{
u64 mask = READ_ONCE(lvl_bitmap);
u32 lvl;
scx_bpf_dump_header();
scx_bpf_dump("soryu: bitmap=0x%llx max_wait=%llu jiffies\n",
mask, READ_ONCE(max_wait_jiffies));
bpf_for(lvl, 0, LEVELS)
{
s32 nr = scx_bpf_dsq_nr_queued(dsq_id(lvl));
/* [BIT CLEAR!] is an invariant violation: a level holding tasks
* whose bit is unset can never be dispatched. Must never fire
* now that bits are set-only. */
if (nr > 0)
scx_bpf_dump(" lvl %2u: %d queued%s\n", lvl, nr,
(mask & (1ULL << lvl)) ? "" : " [BIT CLEAR!]");
}
}
s32 BPF_STRUCT_OPS_SLEEPABLE(soryu_init)
{
u32 lvl;
s32 ret;
bpf_for(lvl, 0, LEVELS)
{
ret = scx_bpf_create_dsq(dsq_id(lvl), -1);
if (ret)
return ret;
}
bpf_for(lvl, 0, MAX_CPUS)
cpu_level[lvl] = LEVELS;
return 0;
}
void BPF_STRUCT_OPS(soryu_exit, struct scx_exit_info *ei)
{
UEI_RECORD(uei, ei);
}
SCX_OPS_DEFINE(soryu_ops,
.select_cpu = (void *)soryu_select_cpu,
.enqueue = (void *)soryu_enqueue,
.dispatch = (void *)soryu_dispatch,
.runnable = (void *)soryu_runnable,
.running = (void *)soryu_running,
.stopping = (void *)soryu_stopping,
.cpu_release = (void *)soryu_cpu_release,
.init_task = (void *)soryu_init_task,
.dump = (void *)soryu_dump,
.init = (void *)soryu_init,
.exit = (void *)soryu_exit,
.flags = SCX_OPS_KEEP_BUILTIN_IDLE,
.timeout_ms = 5000,
.name = "soryu");
+7
View File
@@ -0,0 +1,7 @@
// SPDX-License-Identifier: GPL-2.0
//
// libbpf-cargo generates the skeleton into OUT_DIR; this module just pulls it
// in. Kept separate so the generated code's lint profile stays isolated.
#![allow(dead_code)]
#![allow(clippy::all)]
include!(concat!(env!("OUT_DIR"), "/bpf_skel.rs"));
+502
View File
@@ -0,0 +1,502 @@
// SPDX-License-Identifier: GPL-2.0
//
// scx_soryu loader.
//
// runs in the foreground and holds no state of its own: dropping
// the struct_ops link is what unloads the scheduler, so Ctrl-C is the emergency
// exit during development.
mod bpf_skel;
mod stats;
use bpf_skel::*;
use stats::Metrics;
use std::mem::MaybeUninit;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use clap::Parser;
use crossbeam::channel::RecvTimeoutError;
use scx_stats::prelude::*;
use libbpf_rs::OpenObject;
use libbpf_rs::MapCore;
use log::error;
use log::info;
use log::warn;
use scx_utils::scx_ops_attach;
use scx_utils::scx_ops_load;
use scx_utils::scx_ops_open;
use scx_utils::uei_exited;
use scx_utils::uei_report;
use scx_utils::UserExitInfo;
const SCHEDULER_NAME: &str = "scx_soryu";
/// mirrors `enum stat_idx` in main.bpf.c.
const STAT_NAMES: &[&str] = &[
"enq_shared",
"enq_local",
"enq_reenq",
"enq_pinned",
"dispatch",
"insert_fail",
"cpu_release",
"boost_sleep",
"boost_wake",
"deboost",
"slice_carry",
"fork_split",
"preempt_kick",
"starve_serve",
"preempt_none",
"preempt_idle",
];
/// mirrors BOOST_RANGE in main.bpf.c.
const BOOST_RANGE: i32 = 12;
/// mirrors LAT_CLASSES / LAT_BUCKETS in main.bpf.c.
const LAT_CLASSES: usize = 3;
const LAT_BUCKETS: usize = 32;
const LAT_CLASS_NAMES: [&str; LAT_CLASSES] = ["boosted", "neutral", "deboosted"];
#[derive(Parser, Debug, Clone)]
#[command(about = "BMQ policy port for sched_ext", version)]
struct Opts {
#[arg(long, default_value_t = 5000, env = "SORYU_TIMEOUT_MS")]
timeout_ms: u32,
#[arg(long, default_value_t = true, env = "SORYU_BOOST_ON_WAKE",
action = clap::ArgAction::Set)]
boost_on_wake: bool,
#[arg(long, default_value_t = 1000, env = "SORYU_SLICE_US")]
slice_us: u64,
#[arg(long, default_value_t = 128, env = "SORYU_STARVE_MS")]
starve_ms: u64,
#[arg(long, default_value_t = 1, env = "SORYU_REPORT_SEC")]
report_sec: u64,
#[arg(long, env = "SORYU_LOG")]
log: Option<String>,
/// Print libbpf's output, including the verifier log on a load failure.
#[arg(long, default_value_t = false, env = "SORYU_DEBUG")]
debug: bool,
/// Bytes of scheduler state captured when the kernel ejects us.
#[arg(long, default_value_t = 65536)]
exit_dump_len: u32,
/// Attach to a running scx_soryu and print its telemetry every N seconds.
/// Starts no scheduler of its own.
#[arg(long)]
monitor: Option<f64>,
}
fn percentiles(buckets: &[u64]) -> Option<(u64, u64, u64)> {
let total: u64 = buckets.iter().sum();
if total == 0 {
return None;
}
let at = |frac: f64| -> u64 {
let target = (total as f64 * frac) as u64;
let mut seen = 0u64;
for (i, &c) in buckets.iter().enumerate() {
seen += c;
if seen >= target {
return 1u64 << i;
}
}
1u64 << (buckets.len() - 1)
};
Some((at(0.50), at(0.99), at(0.999)))
}
fn fmt_ns(ns: u64) -> String {
if ns >= 1_000_000_000 {
format!("{:.1}s", ns as f64 / 1e9)
} else if ns >= 1_000_000 {
format!("{}ms", ns / 1_000_000)
} else if ns >= 1_000 {
format!("{}us", ns / 1_000)
} else {
format!("{ns}ns")
}
}
struct Scheduler<'a> {
opts: Opts,
skel: BpfSkel<'a>,
struct_ops: Option<libbpf_rs::Link>,
stats_server: StatsServer<(), Metrics>,
prev_waits: Vec<u64>,
}
impl<'a> Scheduler<'a> {
fn init(opts: Opts, open_object: &'a mut MaybeUninit<OpenObject>) -> Result<Self> {
let mut skel_builder = BpfSkelBuilder::default();
// SORYU_DEBUG=1 turns on libbpf's output, which is the only way to see
// the verifier's rejection log when a program fails to load.
skel_builder.obj_builder.debug(opts.debug);
let open_opts = scx_utils::libbpf_clap_opts::LibbpfOpts::default().into_bpf_open_opts();
let mut skel = scx_ops_open!(skel_builder, open_object, soryu_ops, open_opts)?;
// Dump the scheduler's own state when we get ejected; ops.dump fills it.
skel.struct_ops.soryu_ops_mut().exit_dump_len = opts.exit_dump_len;
skel.struct_ops.soryu_ops_mut().timeout_ms = opts.timeout_ms;
let rodata = skel
.maps
.rodata_data
.as_mut()
.context("no rodata section in skeleton")?;
rodata.nr_cpu_ids = *scx_utils::NR_CPU_IDS as u32;
// SORYU_BOOST_ON_WAKE=0 reproduces BMQ as shipped (its wake boost is
// dead code); the default reproduces what BMQ meant to do.
rodata.boost_on_wake = opts.boost_on_wake;
rodata.starve_ns = opts.starve_ms * 1_000_000;
rodata.slice_ns = opts.slice_us * 1_000;
let mut skel = scx_ops_load!(skel, soryu_ops, uei)?;
let struct_ops = Some(scx_ops_attach!(skel, soryu_ops)?);
let rd = skel.maps.rodata_data.as_ref().unwrap();
info!(
"{} attached: {} CPUs, slice {} us, boost_on_wake={}, \
starve {} ms, timeout {} ms",
SCHEDULER_NAME,
*scx_utils::NR_CPU_IDS,
rd.slice_ns / 1000,
rd.boost_on_wake,
rd.starve_ns / 1_000_000,
opts.timeout_ms
);
let stats_server = StatsServer::new(stats::server_data()).launch()?;
Ok(Self {
opts,
skel,
struct_ops,
stats_server,
prev_waits: Vec::new(),
})
}
fn exited(&mut self) -> bool {
uei_exited!(&self.skel, uei)
}
/// Per-CPU counters summed across CPUs.
fn read_stats(&self) -> Result<Vec<u64>> {
let nr_cpus = *scx_utils::NR_CPU_IDS;
let mut out = vec![0u64; STAT_NAMES.len()];
for (idx, slot) in out.iter_mut().enumerate() {
let key = (idx as u32).to_ne_bytes();
let per_cpu = self
.skel
.maps
.stats
.lookup_percpu(&key, libbpf_rs::MapFlags::ANY)?
.unwrap_or_default();
*slot = per_cpu
.iter()
.take(nr_cpus)
.map(|v| u64::from_ne_bytes(v[..8].try_into().unwrap_or([0; 8])))
.sum();
}
Ok(out)
}
/// One snapshot for the stats server.
fn metrics(&self) -> Metrics {
let c = self.read_stats().unwrap_or_default();
let g = |i: usize| c.get(i).copied().unwrap_or(0);
let mut m = Metrics {
enq_shared: g(0),
enq_local: g(1),
enq_reenq: g(2),
enq_pinned: g(3),
insert_fail: g(5),
cpu_release: g(6),
boost_sleep: g(7),
boost_wake: g(8),
deboost: g(9),
slice_carry: g(10),
fork_split: g(11),
preempt_kick: g(12),
starve_serve: g(13),
preempt_none: g(14),
preempt_idle: g(15),
..Default::default()
};
if let Some(bss) = self.skel.maps.bss_data.as_ref() {
m.max_wait_j = bss.max_wait_jiffies;
m.lvl_bitmap = bss.lvl_bitmap;
let hist = &bss.boost_hist;
let total: u64 = hist.iter().sum();
if total > 0 {
let weighted: i64 = hist
.iter()
.enumerate()
.map(|(i, &n)| (i as i64 - BOOST_RANGE as i64) * n as i64)
.sum();
m.boost_mean_x100 = weighted * 100 / total as i64;
m.boost_floor_pct = hist[0] * 100 / total;
m.boost_ceil_pct = hist[hist.len() - 1] * 100 / total;
}
// Class 0 is "boosted" -- the interactive tail, and the only class
// with enough samples to mean anything (hogs barely ever wake).
let boosted = &bss.lat_hist[0..LAT_BUCKETS];
m.lat_boosted_n = boosted.iter().sum();
if let Some((p50, p99, p999)) = percentiles(boosted) {
m.lat_boosted_p50 = p50;
m.lat_boosted_p99 = p99;
m.lat_boosted_p999 = p999;
}
}
m
}
fn report(&mut self) {
let stats = match self.read_stats() {
Ok(s) => s,
Err(e) => {
warn!("stat read failed: {e}");
return;
}
};
let bss = self.skel.maps.bss_data.as_ref();
let (bitmap, max_wait) = bss
.map(|b| (b.lvl_bitmap, b.max_wait_jiffies))
.unwrap_or((0, 0));
let raw: Vec<u64> = self
.skel
.maps
.bss_data
.as_ref()
.map(|b| b.lvl_max_wait.to_vec())
.unwrap_or_default();
let waits: Vec<String> = raw
.iter()
.enumerate()
.filter(|(_, &w)| w > 0)
.map(|(lvl, &w)| {
let rising = self.prev_waits.get(lvl).map(|&p| w > p).unwrap_or(false);
let mark = if rising { "*" } else { "" };
match lvl {
32 => format!("idle:{w}j{mark}"),
10 => format!("l10*n0boosted:{w}j{mark}"),
16 => format!("l16*n0neutral:{w}j{mark}"),
22 => format!("l22*n0hog:{w}j{mark}"),
_ => format!("l{lvl}:{w}j{mark}"),
}
})
.collect();
if !raw.is_empty() {
self.prev_waits = raw;
}
let line = STAT_NAMES
.iter()
.zip(stats.iter())
.map(|(n, v)| format!("{n}={v}"))
.collect::<Vec<_>>()
.join(" ");
info!("{line} bitmap=0x{bitmap:x} max_wait={max_wait}j");
if !waits.is_empty() {
info!(" per-level worst wait: {}", waits.join(" "));
}
if let Some(bss) = self.skel.maps.bss_data.as_ref() {
let hist = &bss.boost_hist;
let total: u64 = hist.iter().sum();
if total > 0 {
let occupied: Vec<String> = hist
.iter()
.enumerate()
.filter(|(_, &c)| c > 0)
.map(|(i, &c)| {
let boost = i as i32 - BOOST_RANGE;
format!("{boost:+}:{:.0}%", 100.0 * c as f64 / total as f64)
})
.collect();
let weighted: i64 = hist
.iter()
.enumerate()
.map(|(i, &c)| (i as i64 - BOOST_RANGE as i64) * c as i64)
.sum();
let mean = weighted as f64 / total as f64;
info!(" boost mean={mean:+.2} dist: {}", occupied.join(" "));
}
let lines: Vec<String> = (0..LAT_CLASSES)
.filter_map(|c| {
let slice = &bss.lat_hist[c * LAT_BUCKETS..(c + 1) * LAT_BUCKETS];
percentiles(slice).map(|(p50, p99, p999)| {
format!(
"{}: p50={} p99={} p99.9={} n={}",
LAT_CLASS_NAMES[c],
fmt_ns(p50),
fmt_ns(p99),
fmt_ns(p999),
slice.iter().sum::<u64>()
)
})
})
.collect();
for l in lines {
info!(" wake->run {l}");
}
let boosted = &bss.lat_hist[0..LAT_BUCKETS];
let n: u64 = boosted.iter().sum();
if n > 0 {
let above = |bucket: usize| -> f64 {
100.0 * boosted[bucket..].iter().sum::<u64>() as f64 / n as f64
};
info!(
" wake->run boosted tail: >{} {:.3}% >{} {:.3}% >{} {:.4}% >{} {:.4}%",
fmt_ns(1 << 20),
above(20),
fmt_ns(1 << 22),
above(22),
fmt_ns(1 << 24),
above(24),
fmt_ns(1 << 26),
above(26),
);
}
}
}
fn run(&mut self, shutdown: Arc<AtomicBool>) -> Result<UserExitInfo> {
let (res_ch, req_ch) = self.stats_server.channels();
let every = self.opts.report_sec.max(1);
let mut ticks = 0u64;
while !shutdown.load(Ordering::Relaxed) && !self.exited() {
match req_ch.recv_timeout(Duration::from_secs(1)) {
Ok(()) => {
let m = self.metrics();
res_ch.send(m)?;
}
Err(RecvTimeoutError::Timeout) => {
ticks += 1;
if ticks % every == 0 {
self.report();
}
}
Err(e) => Err(e)?,
}
}
// Dropping the link is what detaches the scheduler.
let _ = self.struct_ops.take();
uei_report!(&self.skel, uei)
}
}
impl Drop for Scheduler<'_> {
fn drop(&mut self) {
info!("{SCHEDULER_NAME} unloaded, tasks returned to default kernel scheduler");
}
}
fn main() -> Result<()> {
let opts = Opts::parse();
let cfg = simplelog::ConfigBuilder::new()
.set_time_offset_to_local()
.expect("failed to set local time offset")
.build();
// SORYU_LOG=<path> writes the same output to a file as well.
let mut sinks: Vec<Box<dyn simplelog::SharedLogger>> = vec![simplelog::TermLogger::new(
simplelog::LevelFilter::Info,
cfg.clone(),
simplelog::TerminalMode::Stderr,
simplelog::ColorChoice::Auto,
)];
if let Some(path) = opts.log.clone() {
let file = std::fs::File::create(&path)
.with_context(|| format!("cannot open SORYU_LOG file {path:?}"))?;
sinks.push(simplelog::WriteLogger::new(
simplelog::LevelFilter::Info,
cfg,
file,
));
}
simplelog::CombinedLogger::init(sinks)?;
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_handler = shutdown.clone();
ctrlc::set_handler(move || {
shutdown_handler.store(true, Ordering::Relaxed);
})?;
if let Some(intv) = opts.monitor {
return stats::monitor(Duration::from_secs_f64(intv), shutdown);
}
info!(
"pid {} — Ctrl-C to unload, or from another terminal: sudo kill -INT {}",
std::process::id(),
std::process::id()
);
let mut open_object = MaybeUninit::uninit();
loop {
if shutdown.load(Ordering::Relaxed) {
break;
}
let mut sched = Scheduler::init(opts.clone(), &mut open_object)?;
let uei = match sched.run(shutdown.clone()) {
Ok(uei) => uei,
Err(e) => {
error!("{SCHEDULER_NAME} was ejected by the kernel: {e:#}");
error!("all tasks are back on default kernel scheduler; the soryu is NOT running");
return Err(e);
}
};
if !uei.should_restart() {
break;
}
if shutdown.load(Ordering::Relaxed) {
break;
}
info!("hotplug detected, reloading");
}
info!("{SCHEDULER_NAME} exiting");
Ok(())
}
+197
View File
@@ -0,0 +1,197 @@
// SPDX-License-Identifier: GPL-2.0
//
// Live telemetry, readable from a second terminal with `scx_soryu --monitor`
// while the scheduler runs.
use std::io::Write;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use scx_stats::prelude::*;
use scx_stats_derive::stat_doc;
use scx_stats_derive::Stats;
use serde::Deserialize;
use serde::Serialize;
#[stat_doc]
#[derive(Clone, Debug, Default, Serialize, Deserialize, Stats)]
#[stat(top)]
pub struct Metrics {
#[stat(desc = "Wakeups that found an idle CPU and skipped the shared queue")]
pub enq_local: u64,
#[stat(desc = "Enqueues onto a shared level DSQ")]
pub enq_shared: u64,
#[stat(desc = "Enqueues of single-CPU tasks, sent straight to that CPU")]
pub enq_pinned: u64,
#[stat(desc = "Tasks bounced back by ops.cpu_release")]
pub enq_reenq: u64,
#[stat(desc = "DSQ inserts that failed")]
pub insert_fail: u64,
#[stat(desc = "CPUs taken by a higher sched class (PipeWire RT, i915)")]
pub cpu_release: u64,
#[stat(desc = "Boosts from going to sleep")]
pub boost_sleep: u64,
#[stat(desc = "Boosts from waking after sleeping at least one slice")]
pub boost_wake: u64,
#[stat(desc = "Deboosts from burning a whole slice")]
pub deboost: u64,
#[stat(desc = "Preemptions that banked an unused slice remainder")]
pub slice_carry: u64,
#[stat(desc = "Forks that split the parent's remaining slice")]
pub fork_split: u64,
#[stat(desc = "Preemption IPIs sent")]
pub preempt_kick: u64,
#[stat(desc = "Dispatches that jumped priority to unstarve a level")]
pub starve_serve: u64,
#[stat(desc = "Wakeups with nobody worse to preempt -- they wait out a slice")]
pub preempt_none: u64,
#[stat(desc = "Wakeups that kicked an idle CPU rather than preempting anyone")]
pub preempt_idle: u64,
#[stat(desc = "Mean boost, x100 (negative is more interactive)")]
pub boost_mean_x100: i64,
#[stat(desc = "Percent of scheduling events at the -12 boost floor")]
pub boost_floor_pct: u64,
#[stat(desc = "Percent at the +12 ceiling")]
pub boost_ceil_pct: u64,
#[stat(desc = "Interactive wakeup->run p50, ns")]
pub lat_boosted_p50: u64,
#[stat(desc = "Interactive wakeup->run p99, ns -- the number that matters")]
pub lat_boosted_p99: u64,
#[stat(desc = "Interactive wakeup->run p99.9, ns")]
pub lat_boosted_p999: u64,
#[stat(desc = "Interactive wakeup samples")]
pub lat_boosted_n: u64,
#[stat(desc = "Worst wait by any runnable task since load, jiffies")]
pub max_wait_j: u64,
#[stat(desc = "Levels currently holding tasks")]
pub lvl_bitmap: u64,
}
impl Metrics {
fn fmt_ns(ns: u64) -> String {
if ns >= 1_000_000_000 {
format!("{:.1}s", ns as f64 / 1e9)
} else if ns >= 1_000_000 {
format!("{}ms", ns / 1_000_000)
} else if ns >= 1_000 {
format!("{}us", ns / 1_000)
} else {
format!("{ns}ns")
}
}
pub fn format<W: Write>(&self, w: &mut W) -> Result<()> {
writeln!(
w,
"wake->run interactive p50 {:>7} p99 {:>7} p99.9 {:>7} (n={})",
Self::fmt_ns(self.lat_boosted_p50),
Self::fmt_ns(self.lat_boosted_p99),
Self::fmt_ns(self.lat_boosted_p999),
self.lat_boosted_n,
)?;
writeln!(
w,
"boost mean {:+.2} floor {}% ceiling {}% max_wait {}j bitmap 0x{:x}",
self.boost_mean_x100 as f64 / 100.0,
self.boost_floor_pct,
self.boost_ceil_pct,
self.max_wait_j,
self.lvl_bitmap,
)?;
writeln!(
w,
"enqueue local {} shared {} pinned {} reenq {} fail {}",
self.enq_local,
self.enq_shared,
self.enq_pinned,
self.enq_reenq,
self.insert_fail,
)?;
writeln!(
w,
"signal boost_sleep {} boost_wake {} deboost {} carry {} fork_split {}",
self.boost_sleep,
self.boost_wake,
self.deboost,
self.slice_carry,
self.fork_split,
)?;
writeln!(
w,
"preempt kick {} idle {} none {} cpu_release {} starve_serve {}",
self.preempt_kick,
self.preempt_idle,
self.preempt_none,
self.cpu_release,
self.starve_serve,
)?;
Ok(())
}
fn delta(&self, rhs: &Self) -> Self {
Self {
enq_local: self.enq_local - rhs.enq_local,
enq_shared: self.enq_shared - rhs.enq_shared,
enq_pinned: self.enq_pinned - rhs.enq_pinned,
enq_reenq: self.enq_reenq - rhs.enq_reenq,
insert_fail: self.insert_fail - rhs.insert_fail,
cpu_release: self.cpu_release - rhs.cpu_release,
boost_sleep: self.boost_sleep - rhs.boost_sleep,
boost_wake: self.boost_wake - rhs.boost_wake,
deboost: self.deboost - rhs.deboost,
slice_carry: self.slice_carry - rhs.slice_carry,
fork_split: self.fork_split - rhs.fork_split,
preempt_kick: self.preempt_kick - rhs.preempt_kick,
starve_serve: self.starve_serve - rhs.starve_serve,
preempt_none: self.preempt_none - rhs.preempt_none,
preempt_idle: self.preempt_idle - rhs.preempt_idle,
lat_boosted_n: self.lat_boosted_n - rhs.lat_boosted_n,
..self.clone()
}
}
}
pub fn server_data() -> StatsServerData<(), Metrics> {
let open: Box<dyn StatsOpener<(), Metrics>> = Box::new(move |(req_ch, res_ch)| {
req_ch.send(())?;
let mut prev = res_ch.recv()?;
let read: Box<dyn StatsReader<(), Metrics>> = Box::new(move |_args, (req_ch, res_ch)| {
req_ch.send(())?;
let cur = res_ch.recv()?;
let delta = cur.delta(&prev);
prev = cur;
delta.to_json()
});
Ok(read)
});
StatsServerData::new()
.add_meta(Metrics::meta())
.add_ops("top", StatsOps { open, close: None })
}
pub fn monitor(intv: Duration, shutdown: Arc<AtomicBool>) -> Result<()> {
scx_utils::monitor_stats::<Metrics>(
&[],
intv,
|| shutdown.load(Ordering::Relaxed),
|metrics| metrics.format(&mut std::io::stdout()),
)
.with_context(|| {
format!(
"cannot reach the {} stats socket \
(/var/run/scx/root/stats); is it running, and did you use sudo?",
crate::SCHEDULER_NAME
)
})
}