# sched_ext(5): Integrate Machine Learning into scx_rusty Load balancer ## `scx_rusty` overview `scx_rusty` 身為 `scx` 專案目前收錄的一個排程器,將 CPU 分到不同 domain 當中,在 bpf program 當中實作主要的排程邏輯,而特別的是透過一個使用者層級的 load balancer 來幫助進行負載平衡,定時進行 load balance 運算並將資料透過 BPF map 更新, bpf program 進行 `select_cpu` 或 `enqueue, dispatch` 等行為時可透過讀取 BPF map 當中的資訊而得知是否改進行負載平衡。 ## Userspace part 使用者層級的程式透過 Rust 程式語言實作,主要負責兩個部分,第一個是提供 higher frequency tuning decision 。找出當前負載不沉重的 CPU 並將他們標記起來,使得他們可以從過載的 CPU 當中進行 task pull 。 第二個部分是運行 lower frequency load balancing ,透過比較 domain 之間的 load averages 來決定是否進行 load balancing 。如果 load difference 夠大則會檢查最多 1024 個 task 來判斷哪個 task 應該被移動(這也是我們可以優化之處)。 在使用者層級的操作成本低,而且負載平衡成本也不像過往高,進行的頻率也低,不過 work-conservation 依舊透過 tuning 和 greedy execution 來進行。 *目前 `scx_rusty` 假設所有 domain 之間的距離都一樣,而且 processing power 也都一樣* 。 ### Load Balancer 負載平衡的流程大致如下 1. 利用 `dom_ctx_map_elem` bpf map 當中的 duty cycle buckets 來計算 domain load average ,利用 `scx_utils::LoadCalculator` 來計算 load 並利用 `scx_utils::LoadLedger` 來判斷 load distribution 。 2. 拆分 NumaNode 和 Domain 的 hierarchy ,詳細可見 [scheds/rust/scx_rusty/src/load_balance.rs](https://github.com/sched-ext/scx/blob/main/scheds/rust/scx_rusty/src/load_balance.rs) 3. 從 LB root 開始尋遍每個 NUMA node ,從 push nodes 也就是負載過高的 node 將任務遷移到 pull nodes 當中。此處進行 task migration 的代價會比 node 當中的 domain 進行 task migration 的代價高。因此這種 migration 只有當 imbalance 情況非常嚴重才會發生。 4. 若 NUMA nodes 之間的負載平衡結束後,接下來會針對每個 NUMA nodes 當中的 domain 進行負載平衡,此處 task migration 的成本低。 ## BPF part 負責進行在每個 domain 間進行 round robin ,當一個 task 初次進入系統當中時( `rusty_prep_enable` ),它們會透過 round robin 順序進行其中一個 domain 。 排程最主要在 `rusty_select_cpu` 當中進行,當一個 task 狀態變為 `runnable` 時會被呼叫, `lb_data` bpf map 是用來判斷此 task 是否需要被遷移到新的 domain ,這個 bpf map 的 producer 就是 userspace part 的 load balancer 。若不需要的話 task 排程的 priority order 如下 1. 若 task 被同步喚醒而系統當中有其他 idle CPU ,則放到當前的 core 當中 2. 若前一個 core 為 idle ,則放到它上面 3. 若 task 有被指派到特定的 core 上則放到該 core 上 4. domain 當中任意的 idle CPU 若上述四項都不滿足,該 task 會被放到對應 domain 的 dispatch queue 當中。 `rusty_dispatch` 會嘗試從對應 domain 的 dispatch queue 當中 consume 一個 task 來運行。若沒有找到 task ,則會從其他 dispatch queue 當中進行 greedy load stealing 。 基本上 load balancing 完全交給使用者層級處理, BPF part 負責將 task weight, domain mask 和 current domain 生產進入 `task_data` bpf map 當中,並透過 `lb_data` bpf map 當中的資訊判斷是否進行 load balance 。 ## 整合機器學習進入 Load balancer ### Main idea 在使用者層級的 load balancer 實作當中,有一個函式稱為 `try_find_move_task` ,用來找到 `push_dom` 當中要遷移到 `pull_dom` 當中的 task ,若找到的話會更新對應 `lb_data` bpf map 當中的資料,並且回傳移動的 load 。 該函式當中尋找可以被遷移的 task 的實作當中,若可以適當的加入機器學習模型的 inference 來判斷哪個 task 應該被遷移,或許可以帶來更好的效果。 ### Training 透過 [MLLB](https://github.com/Keitokuch/MLLB) 當中的程式碼為基礎,以 tensorflow 訓練一個模擬 EEVDF/CFS 的 load balancing 機制的模型。也就是模仿 `can_migrate_task()` 的行為。 原本論文當中透過掛載 kprobe 在 `can_migrate_task()` 上蒐集資料的方式可以採納,但拿來訓練的 feature 必須改變,因為在 `scx_rusty` 當中並不具有 `struct lb_env` 這個結構體,用來進行負載平衡的 `lb_data, task_data` 成員須擴充,但無法完全滿足 `struct lb_env` 的所有成員。 當前 `scx_rusty` 完全只依據 task load 來判定是否進行 load balance 和 task migration 。我們依照原論文方法重新蒐集資料,也就是分別在 `stress-ng --cpu 30 -l 100 --timeout 120s`, `stress-ng --cpu 60 -l 100 --timeout 120s`, `stress-ng --cpu 120 -l 100 --timeout 120s` 情況下蒐集系統資料,之後只利用四項 feature 進行訓練,分別是 `cpu_idle, cpu_not_idle, src_load, dst_load` (應該要再加上 NUMA 資訊,但該程式嘗試取得 NUMA 相關資料時會出錯,尚待解決)。 把訓練好的模型檔案搬移至 `scx_rusty` 資料夾底下,準備進行 inference 。 ### Inference Rust 剛好有能整合 tensorflow 的套件,安裝該套件後實作 inference 應能變得很方便。 首先要在 `scx_rusty` 的 Cargo.toml 檔案當中新增套件 ```toml [dependencies] tensorflow = "0.21.0" ``` 之後擴充 `struct task_ctx` 使我們可以從 kernel space 取得要拿來 inference 的資料 (位在 `scheds/rust/scx_rusty/src/bpf/initf.h`) ```diff + /* Machine Learning inference needed data */ + unsigned long src_dom_load; + unsigned long dst_dom_load; + int cpu_idle; + int cpu_not_idle; + int cpu; +}; ``` 另外在 `main.bpf.c` 當中修改 `init` 。 ```diff + .cpu = bpf_get_smp_processor_id(), + .cpu_idle = 1, + .cpu_not_idle = 0, ``` 之後在 `scheds/rust/scx_rusty/src/load_balance.rs` 當中定義 tensorflow 的 struct 和相關 method ```rust extern crate tensorflow; use tensorflow::Graph; use tensorflow::Session; use tensorflow::SessionOptions; use tensorflow::SessionRunArgs; use tensorflow::Tensor; use std::error::Error; struct TensorFlowModel { graph: Graph, session: Session, } impl TensorFlowModel { fn new(model_dir: &str) -> Result<Self, Box<dyn Error>> { let mut graph = Graph::new(); let bundle = tensorflow::SavedModelBundle::load( &SessionOptions::new(), &["serve"], &mut graph, model_dir, )?; let session = bundle.session; Ok(TensorFlowModel { graph, session }) } fn predict(&self, input_data: Vec<f64>) -> Result<bool, Box<dyn Error>> { let input_tensor = Tensor::new(&[input_data.len() as u64]).with_values(&input_data)?; let input_op = self.graph.operation_by_name_required("serving_default_input")?; let output_op = self.graph.operation_by_name_required("StatefulPartitionedCall")?; let mut args = SessionRunArgs::new(); args.add_feed(&input_op, 0, &input_tensor); let output_token = args.request_fetch(&output_op, 0); self.session.run(&mut args)?; let output_tensor: Tensor<f64> = args.fetch(output_token).unwrap(); let output_value = output_tensor[0]; Ok(output_value == 1.0) } } ``` 可以看到 inference 函式就是 `predict` 。 之後實作 inference call back 函式 ```rust fn migrate_inference(&self, cpu: &i32, cpu_idle: &i32, cpu_not_idle: &i32, src_dom_load: &f64, dst_dom_load: &f64) -> bool { let mut input_vec: Vec<f64> = Vec::with_capacity(5 as usize); input_vec.push(f64::from(*cpu)); input_vec.push(f64::from(*cpu_idle)); input_vec.push(f64::from(*cpu_not_idle)); input_vec.push(*src_dom_load); input_vec.push(*dst_dom_load); self.inference_model.predict(input_vec).unwrap() } ``` 呼叫的方式很簡單,在 `try_find_move_task` 的最後檢查挑選出的 task 是否適合遷移 ```diff + let migrate_or_not = self.migrate_inference(&task.cpu, &task.cpu_idle, &task.cpu_not_idle, &push_load, &pull_load); + task.migrated.set(migrate_or_not); ``` 之後進行測試,首先進行同時編譯 linux kernel 與進行 fio 測試情況下 task migration 次數 ```shell $ sudo perf stat -e sched:sched_migrate_task -a sleep 120 Performance counter stats for 'system wide': 6,1749 sched:sched_migrate_task 120.200994178 seconds time elapsed ``` 可以看到遷移次數相對原本 `scx_rusty` 有顯著的下降,再來測試編譯一次 linux kernel 的時間 ```shell $ time sudo make -j $(nproc) ... real 4m23.921s user 0m0.046s sys 0m0.094s ``` 居然還比 `scx_rusty` 編譯的更快! 目前模型的訓練與 inference 都還有很大的改進空間,但已經可以顯示原本 `scx_rusty` 過多的 task migration 是不必要的,不只對於 load imbalance 沒有過多幫助,整體效能也有提升的空間,當然已經比原本的 EEVDF 好上許多。