# sched_ext (2) : 排程器程式碼分析 ## Introduction 在利用 sched_ext 撰寫我們的 custom scheduler 之前,先理解現有排程器如何實作,解析相關工具的使用並探討是否有改進空間。 ## 架構分析 以 `scheds/c/` 底下包含的排程器來說,不外乎是一個 userspace program 搭配一個 bpf program ,而 bpf program 又會透過 libbpf 解析行程 skeleton file ,因此使用者層級程式碼都是 include `skel.h` 標頭檔來和 bpf application 進行互動。以下先以架構最簡單的 `scx_simple` 為例。 ### 使用者層級程式 先看到 main 函式當中,透過 libbpf API `libbpf_set_print` 來設定如何印出 libbpf warning 和 informational messages 。此處是設定為 `libbpf_print_fn` ,而 `libbpf_print_fn` 實作如下 ```c static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args) { if (level == LIBBPF_DEBUG && !verbose) return 0; return vfprintf(stderr, format, args); } ``` 接下來可以看到兩行 `signal` 相關程式碼,目的是將 `SIGINT, SIGTERM` 都導向 `sigint_handler` 函式用以通知程式執行的結束。之後利用定義在 `scheds/include/scx/compat.h` 當中的巨集 `SCX_OPS_OPEN` 來讓 skeleton file 對應的 bpf application 進入 open phase ,可以注意到程式碼當中的 `__skel = __scx_name##__open()` 即是 libbpf 產生的 skeleton file 當中所提供之函式。在程式當中的 `struct scx_simple` 則是 libbpf 的 skeleton file 所建立的 struct 用來對應到 bpf program 當中所有可用的 data fields 。之後的 `SCX_OPS_LOAD, SCX_OPS_ATTACH` 分別對應到 bpf application 的 load phase 和 attach phase 。 值得注意的是在 `SCX_OPS_ATTACH()` 巨集當中使用到的 `bpf_map__attach_struct_ops()` 是 libbpf API ,會回傳一個 `struct bpf_link`。 另外可以注意到程式當中許多 `UEI_*` 的函式或巨集,都是定義在 `scheds/include/scx/user_exit_info.h` 當中, `struct user_exit_info` 是使用者層級程式和 BPF program 共享的資訊,用來傳送 exit status 和其他資訊。 在這個程式的 main loop 當中,利用 `exit_req` 和 `UEI_EXITED(skel, uei)` 來判斷是否離開 main loop ,我認為 `UEI_EXITED` 巨集的實作值得探討,它利用 `__sync_val_compare_and_swap` 來檢查 skeleton file 的 `__uei_name.kind` 的數值。 `__sync_val_compare_and_swap` 是一個 GNU builtin function ,可以參見 [6.49 Built-in functions for atomic memory access](https://gcc.gnu.org/onlinedocs/gcc-4.5.3/gcc/Atomic-Builtins.html) ,此函式將 `__uei_name.kind` 的值和 `-1` 比較,若相同則將 `-1` 寫入 `__uei_name.kind` ,每次都是回傳 `__uei_name.kind` 原本的值。所以結束條件應該是當 `__uei_name.kind` 不為 `0` ,正常情況下都應該是 `0` 。 接著 main loop 當中的操作很單純,不斷的透過 `bpf_map_lookup_elem()` 來查看 BPF program 當中 BPF maps 包含的值是什麼並印出來,所以 BPF program 是如何將原本的 scheduler 替換掉,並和 kernel 互動的? ### BPF program 在每個排程器實作當中我們都可以看到大量的 `BPF_STRUCT_OPS` 巨集,該巨集定義在 `scheds/include/scx/common.bpf.h` 當中,定義如下 ```c #define BPF_STRUCT_OPS(name, args...) \ SEC("struct_ops/"#name) \ BPF_PROG(name, ##args) ``` 其中 `SEC` 巨集來自 libbpf 也就是 `/tools/lib/bpf/bpf_helpers.h` 當中,定義如下 ```c /* * Helper macro to place programs, maps, license in * different sections in elf_bpf file. Section names * are interpreted by libbpf depending on the context (BPF programs, BPF maps, * extern variables, etc). * To allow use of SEC() with externs (e.g., for extern .maps declarations), * make sure __attribute__((unused)) doesn't trigger compilation warning. */ #if __GNUC__ && !__clang__ /* * Pragma macros are broken on GCC * https://gcc.gnu.org/bugzilla/show_bug.cgi?id=55578 * https://gcc.gnu.org/bugzilla/show_bug.cgi?id=90400 */ #define SEC(name) __attribute__((section(name), used)) #else #define SEC(name) \ _Pragma("GCC diagnostic push") \ _Pragma("GCC diagnostic ignored \"-Wignored-attributes\"") \ __attribute__((section(name), used)) \ _Pragma("GCC diagnostic pop") \ #endif ``` 另一個巨集 `BPF_PROG` 定義在 `/tools/lib/bpf/bpf_tracing.h` 底下,定義如下 ```c /* * BPF_PROG is a convenience wrapper for generic tp_btf/fentry/fexit and * similar kinds of BPF programs, that accept input arguments as a single * pointer to untyped u64 array, where each u64 can actually be a typed * pointer or integer of different size. Instead of requring user to write * manual casts and work with array elements by index, BPF_PROG macro * allows user to declare a list of named and typed input arguments in the * same syntax as for normal C function. All the casting is hidden and * performed transparently, while user code can just assume working with * function arguments of specified type and name. * * Original raw context argument is preserved as well as 'ctx' argument. * This is useful when using BPF helpers that expect original context * as one of the parameters (e.g., for bpf_perf_event_output()). */ #define BPF_PROG(name, args...) \ name(unsigned long long *ctx); \ static __always_inline typeof(name(0)) \ ____##name(unsigned long long *ctx, ##args); \ typeof(name(0)) name(unsigned long long *ctx) \ { \ _Pragma("GCC diagnostic push") \ _Pragma("GCC diagnostic ignored \"-Wint-conversion\"") \ return ____##name(___bpf_ctx_cast(args)); \ _Pragma("GCC diagnostic pop") \ } \ static __always_inline typeof(name(0)) \ ____##name(unsigned long long *ctx, ##args) ``` 幫我們根據給定的 `name` 與 `args` 建立一系列函式原型。 我們可以透過 `BPF_STRUCT_OPS` 和 `BPF_STRUCT_OPS_SLEEPABLE` 定義一系列 handlers 並利用 `SCX_OPS_DEFINE` 來將這些 handlers 和對應之 `name` 註冊為 `struct sched_ext_ops`,成為和其他元件互動的介面。 關於每個函式詳細的作用在 `/kernel/scheds/ext.c` 有講解。 在 `/kernel/scheds/ext.c` 當中有對應的 dummy interface 如下 ```c static struct sched_ext_ops __bpf_ops_sched_ext_ops = { .select_cpu = select_cpu_stub, .enqueue = enqueue_stub, .dequeue = dequeue_stub, .dispatch = dispatch_stub, .runnable = runnable_stub, .running = running_stub, .stopping = stopping_stub, .quiescent = quiescent_stub, .yield = yield_stub, .core_sched_before = core_sched_before_stub, .set_weight = set_weight_stub, .set_cpumask = set_cpumask_stub, .update_idle = update_idle_stub, .cpu_acquire = cpu_acquire_stub, .cpu_release = cpu_release_stub, .init_task = init_task_stub, .exit_task = exit_task_stub, .enable = enable_stub, .disable = disable_stub, #ifdef CONFIG_EXT_GROUP_SCHED .cgroup_init = cgroup_init_stub, .cgroup_exit = cgroup_exit_stub, .cgroup_prep_move = cgroup_prep_move_stub, .cgroup_move = cgroup_move_stub, .cgroup_cancel_move = cgroup_cancel_move_stub, .cgroup_set_weight = cgroup_set_weight_stub, #endif .cpu_online = cpu_online_stub, .cpu_offline = cpu_offline_stub, .init = init_stub, .exit = exit_stub, }; static struct bpf_struct_ops bpf_sched_ext_ops = { .verifier_ops = &bpf_scx_verifier_ops, .reg = bpf_scx_reg, .unreg = bpf_scx_unreg, .check_member = bpf_scx_check_member, .init_member = bpf_scx_init_member, .init = bpf_scx_init, .update = bpf_scx_update, .validate = bpf_scx_validate, .name = "sched_ext_ops", .owner = THIS_MODULE, .cfi_stubs = &__bpf_ops_sched_ext_ops }; ``` 並且透過註冊在 `bpf_sched_ext_ops` 的成員當中,作為和其他程式互動的介面,透過 `init_sched_ext_class` 和 `scx_init` 來初始化 `scx` 。其中 `scx_init` 負責將上述在 BPF program 當中定義的 handler 註冊成為 kernel function 。之後 kernel 可以透過呼叫 `ops->*` 相關函式來操作 scheduler 。 :::warning 所以這段程式是怎麼把原先的 scheduler 替換掉的? ::: 先觀察 init 和 exit 函式 ```c s32 BPF_STRUCT_OPS_SLEEPABLE(simple_init) { __COMPAT_scx_bpf_switch_all(); return scx_bpf_create_dsq(SHARED_DSQ, -1); } void BPF_STRUCT_OPS(simple_exit, struct scx_exit_info *ei) { UEI_RECORD(uei, ei); } ``` 在 init function 當中先利用 `__COMPAT_scx_bpf_switch_all()` 將所有 task 都轉為 scx task 讓所有 class 的任務都被 scx scheduler 排程。實作在 `/scheds/include/scx/compat.bpf.h` 當中。現階段被 `SCX_OPS_SWITCH_PARTIAL` 給替代了。 `enum scx_ops_flags` 被定義在 `/scheds/include/vmlinux/vmlinux.h` 當中如下 ```c enum scx_ops_flags { SCX_OPS_KEEP_BUILTIN_IDLE = 1, SCX_OPS_ENQ_LAST = 2, SCX_OPS_ENQ_EXITING = 4, SCX_OPS_SWITCH_PARTIAL = 8, SCX_OPS_CGROUP_KNOB_WEIGHT = 65536, SCX_OPS_ALL_FLAGS = 65551, }; ``` 關於每個 flags 用途的詳細解說可以參見 `/kernel/sched/ext.c` 。其中表示若 `SCX_OPS_SWITCH_PARITIAL` 有被設定,則只有 `SCHED_EXT` class 的任務會被我們撰寫的 BPF scheduler 排程,若沒設定則是全部都排程。如果你使用的是新版的 sched_ext kernel ,則 `__COMPAT_scx_bpf_switch_all()` 不會有任何作用,因為所有排程器預設會把所有任務拿來排程,只有當 `SCX_OPS_SWITCH_PARTIAL` 被設定時才會只排 `SCHED_EXT` 這個 class 的任務。 接下來程式碼呼叫並回傳 `scx_bpf_create_dsq()` 的回傳值,要理解此函式我們就要理解 sched_ext 當中 dispatch queue 的運用,可以參見 [Extensible Scheduler Class : Dispatch Queues](https://github.com/vax-r/sched_ext/blob/sched_ext/Documentation/scheduler/sched-ext.rst#dispatch-queues) 。特別注意 built-int queues 例如 `SCX_DSQ_GLOBAL, SCX_DSQ_LOCAL` 都是 FIFO queues ,若想利用 priority queues (多數時候會使用,因為要考量權重) ,則要自己透過 `scx_bpf_create_dsq()` 配置新的 queue 。 每個 CPU 都會從自己的 local DSQ 抓取任務出來執行,若 local DSQ 為空,則會嘗試從 global DSQ 抓取任務,若沒有拿到可執行的任務,則會呼叫 `ops.dispatch()` 。 #### Scheduling Cycle 1. 當 task 被喚醒,首先會呼叫 `ops.select_cpu()` ,主要有兩個功能,第一個是做 CPU selection optimization hint ,另外則是喚醒被選中的 CPU 。 被選中的 CPU 僅是一個 optimization hint 而非最終結果,最後 task 要被放到哪個 CPU 的 DSQ 上是由 scheduling 的最後一步來決定。若 `ops.select_cpu()` 選中的 CPU 和最終執行該 task 的 CPU 相同,效能上可以有些微提升。 選擇某個 CPU 的 side-effect 是會把它從 idle 狀態喚醒,當然 BPF scheduler 可以利用 `scx_bpf_kick_cpu()` 來喚醒任何 CPU 。 若 task 呼叫 `scx_bpf_dispatch()` ,則在 `ops.select_cpu()` 之後會立即將該 task 分配到某個 DSQ 上,如果是分配到 `SCX_DSQ_LOCAL` ,則該 local dispatch queue 就是 `ops.select_cpu()` 選中的 CPU 之 local dispatch queue 。另外注意到的是,如果 dispatch 是直接由 `ops.select_cpu()` 處理,則 `ops.enqueue()` 就會被跳過。 2. 當 CPU 選好之後,會呼叫 `ops.enqueue()` ,而該函式呼叫會做以下其中一個決定 * 透過呼叫 `scx_bpf_dispatch()` 將 task 立即分配到 `SCX_DSQ_GLOBAL` 或 `SCX_DSQ_LOCAL` 。 * 透過呼叫 `scx_bpf_dispatch()` 將 task 立即分配到一個 custom DSQ 上,而 DSQ ID 一定小於 $2^{63}$ * 將 task 分配到 BPF side 的 queue 上 3. 當 CPU 可以進行排程時,它會先搜索自己的 local DSQ ,若是空的才會搜索 global DSQ ,若還是沒有任務可以運行,則會呼叫 `ops.dispatch()` ,該函式會運用以下兩個 helper function 來產生任務到 local DSQ * `scx_bpf_dispatch()` : 將任務分配至一個 DSQ 上,此處任何 DSQ 都可以。不過目前若 BPF lock 被持有,則無法呼叫 `scx_bpf_dispatch()` ,未來改進項目之一。此函式當中最多可以有 `ops.dispatch_max_batch` 個 pending tasks 。 * `scx_bpf_consume()` : 從指定的 non-local DSQ 當中拿出一個 task 並放入另一個 DSQ ,此函式同樣無法在 BPF lock 被持有時呼叫。 `scx_bpf_consume()` 會在嘗試進行以上操作之前先將 pending dispatch tasks 清空。 4. 如果 `ops.dispatch()` 回傳後, local DSQ 有任務可以執行,則 CPU 會執行第一個,若 DSQ 依舊為空的,則進行以下動作 * 嘗試 consume global DSQ ,若成功就運行該任務 * 若 `ops.dispatch()` 沒有分配任何任務,重複第三步驟 * 若前一個任務是一個 SCX task 並且依舊可運行,則繼續執行它。 * 轉為 idle 理解了 sched_ext 當中的 scheduling cycle 後,我們終於可以來探討 `scx_simple` 的各項 handler function 實作,首先是 `simple_select_cpu` 。 * `simple_select_cpu()` 利用預設的 `scx_bpf_select_cpu_dfl()` 挑選 CPU ,此處尚需探討挑選的方式為何,若挑選到的 CPU 狀態為 idle ,我們透過 `scx_bpf_dispatch()` 將任務直接分配到 `SCX_DSQ_LOCAL` 當中。 依據 scheduling cycle ,若挑選到的 CPU 為 idle 則透過 `scx_bpf_dispatch()` 直接分發任務後,就會跳過 `simple_enqueue()` 。 * `simple_enqueue()` 將任務分配到 `SHARED_DSQ` 當中,此處會判定排程的規則是 FIFO 或透過 vtime 當優先權,可以注意到若採用 vtime 來決定,則會比較 `p->scx.dsq_vtime` 和 `vtime_now - SCX_SLICE_DFL` 的大小。 * `simple_dispatch()` 在 CPU 搜索 local DSQ 和 global DSQ 都找不到可執行的任務時會呼叫此函式,在這個排程器的例子當中是利用 `scx_bpf_consume()` 從 `SHARED_DSQ` 當中取出 task 執行。 * `simple_running()` 若採用 vtime 決定排程順序,則此函式會負責更新 `vtime_now` 的數值,應該探討如何呼叫此函式。 * `simple_stopping()` 更新 task 的 `dsq_vtime` 。應探討如何呼叫此函式。 ### sched-ext 解析 `sched-ext` 撰寫了關於 scheduler extension 的所有基礎建設,包括如何將 scheduler 透過 eBPF 機制替換成使用者撰寫的 BPF scheduler 等等,首先解析 `struct sched_ext_ops` ,這是一個 BPF scheduler 的 operation table 。 * `select_cpu()` 選擇一個 CPU 來執行 task ,此處的決策不一定是最終決策,不過如果最後 task 被放入此處選擇的 CPU 的 runqueue ,我們可以減少一些 overhead 。它作為預設將 idle CPU 喚醒的函式,使用者也可以另外實作自己將 CPU 喚醒的機制。 * `enqueue()` 將 task 放入 BPF scheduler 的 queue 當中。如果在 `select_cpu()` 時沒有利用 `scx_bpf_dispatch()` 直接將 task 分配到選擇到的 CPU 上,則會執行此函式,此時 BPF scheduler 是該 task 的持有者。 * `dequeue()` 將 task 從 BPF scheduler 上移除,通常在更新該 task 的 scheduling properties 時才會特別呼叫,用來將該 task 隔離。 `sched-ext` 的程式當中已經實作了可以追蹤某個 task 是否被 BPF scheduler 持有的機制,用以忽略 BPF scheduler 進行的 spurious dispatch ,所以此函式不一定要自行實作。 * `dispatch()` 通知 BPF scheduler 來分配 task 到 CPU 上,或者 consume DSQ 。當 CPU 的 local DSQ 為空時才會被呼叫, BPF scheduler 應該透過 `scx_bpf_dispatch()` 分配一個或多個任務並利用 `scx_bpf_consume()` 將 user DSQ 當中的任務移動到 loca DSQ 當中。 `prev` 是上一個被 switched out 的任務,並且該任務的 slice 已經被耗盡,若 `prev` 狀態依舊是 runnable ,則它會在 `dispatch()` 函式回傳後被 `enqueue()` ,若要持續執行 `prev` ,則回傳時不該分配或 consume 任何任務。 * `tick()` 每個 CPU 在每 `1/HZ` 秒會呼叫一次,若 `p->scx.slice` 變為 0 則會自動觸發 CPU 的 dispatch cycle 。 * `runnable()` 代表該任務狀態轉變為 runnable ,用來追蹤任務的 execution state transition 。一個任務會先在 CPU 上轉變為 runnable ,並且經過幾個 `running()` 和 `stopping()` ,最後它結束在該 CPU 上的運行後會轉為 `quiescent()` 。 以下三種情況,一個任務會在它要被執行的 CPU 上轉為 runnable * waking up (`SCX_ENQ_WAKEUP`) * being moved from another CPU * being restored after temporarily taken off the queue for an attribute change 和 `enqueue()` 相關但並非成對的,也就是說此函式用來通知任務 `p` 的狀態改變,有可能會接著呼叫 `enqueue()` 也有可能不會。同樣的任務也可以進行 `enqueue()` 但沒有先呼叫 `runnable()` 。 * `running()` 通知某任務狀態變為 running * `stopping()` 通知某任務狀態變為 stopping ,若呼叫後任務狀態並非 runnable ,則下一個會呼叫 `quiescent()` 。 * `quiescent()` 任務狀態轉變為 not runnable 。以下三種情形會讓 CPU 上的任務轉變為 quiescent * sleeping (`SCX_DEQ_SLEEP`) * being moved to another CPU * being temporarily taken off the queue for an attribute change (`SCX_DEQ_SAVE`) 此函式和 `dequeue()` 相關聯但並非成對。 * `yield()` 任務主動讓出 CPU 給另一個 runnable task , BPF scheduler 需要確保另一個可執行的任務在這個 yielding task 之前先被分配。 另外 scheduling 當中十分重視 [SMT](https://en.wikipedia.org/wiki/Simultaneous_multithreading) 是否有被開啟,透過位於 [/include/linux/sched/smt.h](https://elixir.bootlin.com/linux/latest/source/include/linux/sched/smt.h) 當中的函式 `sched_smt_active()` 可以判斷當前系統是否有開啟 SMT 。其中一個在意 SMT 是否開啟的場景即是 idle tracking 。 在 sched_ext 當中 idle tracking 會透過一個結構體稱為 `idle_masks` 來進行,定義如下 ```c static struct { cpumask_var_t cpu; cpumask_var_t smt; } idle_masks CL_ALIGNED_IF_ONSTACK; ``` `cpumask_var_t` 在 [/include/linux/cpumask.h](https://elixir.bootlin.com/linux/latest/source/include/linux/cpumask.h) 當中的定義實際上就是 `struct cpumask` ,但操作上有些細節要注意,可以參見它的註解。 :::warning `struct cpumask` 和 `cpumask_t` 的定義如下 ```c typedef struct cpumask { DECLARE_BITMAP(bits, NR_CPUS); } cpumask_t; ``` 可以看到 `struct cpumask` 實際上就是一個長度為 `NR_CPUS` 的 bitmap ,而 `cpumask_t` 就是 `struct cpumask` ,那為何要多定義一個 `cpumask_var_t` ? smt mask 和 cpu mask 差異在哪? ::: 例如在 `test_and_clear_cpu_idle(int cpu)` 函式當中就會針對 smt 做特別的處理,先取得該 `cpu` 的 smt mask 後,檢查 `idle_masks.smt` 是否和該 smt mask 有重疊的部分,若有則清除那些部分的位元,接著再送入 `cpumask_test_and_clear_cpu()` 。 ### scx 解析 `scx` 專案底下包含了許多排程器,用全 C 語言撰寫的排程器都放在 `scheds/c` 目錄底下,但此處的排程器都是展示功能用,並非能上 production 或重點開發的排程器。主要的排程器是以 Rust 搭配 C 語言撰寫的 BPF program 組合而成,位於 `scheds/rust` 目錄底下,目前較成熟的排程器有 `scx_lavd` 、 `scx_rusty` 、 `scx_rustland` 、 `scx_layered` 。 此專案有個值得注意的部分位於 `rust/scx_utils` 目錄底下,撰寫了包括如何取得電腦目前架構的 topology 等方法,這些工具在 `scx_rusty` 等排程器當中被大量使用。 這裡可以注意的細節是 Linux scheduler 的觀點, CPU 架構階層是 NUMA node -> domain -> core -> CPU , CPU 在這當中實際上是 logical thread 的角色。