vatt'ghern jaskier's ballads

Meta 把廣告服務的執行緒排程邏輯,從寫死在核心裡的 CFS 與 EEVDF,搬進一個開機後才載入、隨時可以整支換掉的 BPF 程式——初次上線,廣告服務 p99 延遲降了 28%;之後完全沒再動核心版本,只重新啟動一個 user-space 行程換一版策略,又再降了 60%。

Meta 把廣告服務的排程邏輯,搬進一支可插拔的 BPF 排程器

sched_ext 是一個上游、以 BPF 撰寫的可插拔排程框架——原文的講法是「the upstream, BPF-based extensible scheduling framework」,2026 年隨 Linux v6.12 正式進入主線核心。它的設計不是 Meta 關起門來自己搞的私有 patch,而是「by partnering with the authors of Google's ghOSt to design a scheduler suitable for upstream Linux integration」,換句話說,從一開始的目標就是要讓排程邏輯乾淨到可以被上游核心社群接受,而不是一份注定要跟每一次核心升級打架的內部修補程式。Meta 把這套框架接上廣告服務,寫了一份看得懂「哪個執行緒正跑在延遲關鍵路徑上」的專屬策略,取代掉原本只認 CFS 與 EEVDF 這兩個通用排程器的核心排程邏輯。

這篇東西值得工程師細看的地方,不在於 Meta 換了哪一顆排程器,而在於排程策略第一次被拆成一塊可以獨立迭代的軟體,不必再綁死在核心版本上。任何自己維運延遲敏感服務、又懷疑通用排程器在某些 workload 上不夠好的團隊,理論上都能在 sched_ext 這個框架裡找到同一條路徑:不必等下一次核心升級排上日程,也不必自己維護一份跟每個核心版本打架的私有 patch。下面先拆開這套框架本身怎麼運作,再看 Meta 廣告服務具體怎麼用它。

sched_ext 怎麼掛進核心

通用排程器是編譯進核心的一段程式碼——CFS 也好,後來的 EEVDF 也好,邏輯寫死在核心原始碼裡,要換邏輯,就得改核心、重新編譯、重新開機。sched_ext 把這段邏輯整個抽出來,變成一支獨立的 BPF 程式,核心本身只保留呼叫介面:文中的說法是核心「calls into the BPF scheduler through a set of event-driven callbacks to handle common scheduling events」,具名的四個事件是「Thread wake-up, Enqueue, Dispatch, and Idle transitions」。核心不再自己決定「這個執行緒該排到哪」,它只負責在對的時間點,把決定權交給外面那支 BPF 程式。

這個切法呼應核心開發社群長期以來對「排程器該不該長出策略分支」這個問題的態度——CFS 本身就是為了取代更早期、規則更複雜的排程器而生,過程中核心維護者一直抗拒把太多特定場景的判斷寫進通用排程器,因為那會讓每個人都得為別人的 workload 付出維護成本。sched_ext 提供的答案不是「說服所有人接受你的判斷」,而是「讓你的判斷完全不用進核心」——這也是為什麼原文特別強調這套框架的目標是「suitable for upstream Linux integration」,而不是自己維護一份永遠追不上核心版本的 fork。

真正決定規則的 BPF 程式,又是被一個 user-space 行程載入進去的——「The policy is packaged as a user-space binary that loads the BPF program」。這帶來的直接後果是部署速度:換一版策略,「we can simply restart the scheduler process to unload the old policy and load the new one, without rebuilding or reinstalling the kernel」。三層責任因此徹底分開:核心負責在對的時間點呼叫、BPF callback 負責回答「這次排程要不要介入、怎麼介入」、user-space 行程負責裝載與替換策略本身。下面這張圖點開任一塊,看各自的責任邊界劃在哪裡、彼此又不知道對方的什麼。

從訊號的角度看,這四個 callback 其實對應著排程器可以取得的四種資訊:wake-up 告訴它「誰醒了」,enqueue 告訴它「誰在排隊」,dispatch 是它唯一能真正指定「誰跑在哪顆 CPU」的時刻,idle transition 則告訴它「哪裡有空位」。策略邏輯能用的訊號,全部從這四個時間點的呼叫參數裡取得——沒有額外的旁路通道,也不需要修改應用程式碼去主動回報自己的優先權,這也是為什麼同一套框架能套用在完全不同的 workload 上。

點任一元件看責任邊界 · 4 個元件

sched_ext 的四個元件——點選任一塊看責任邊界 排程核心 kernel dispatch loop 1 BPF callback wake·enqueue·dispatch·idle 2 策略行程 user-space binary 3 CPU 軟分區 p99 優先·L3 locality 4 核心呼叫 callback,callback 交出決定,策略行程裝載規則,規則落成 CPU 分區

click a component above

排程核心 · 責任邊界

核心只在四個 callback 時間點呼叫出去,把排程決定交給外面。

不清楚:哪個執行緒屬於哪個 workload——判斷在 BPF callback 與策略行程裡,核心不看。

BPF callback 集合 · 責任邊界

在四個時間點被核心呼叫進來,負責回答「這次排程要不要介入」。

不清楚:策略邏輯本身的規則——那是策略行程載入的內容,callback 只是介面。

策略行程(user-space binary)· 責任邊界

載入 BPF 程式的 user-space 行程。換策略只需重新啟動它去卸下舊的、載入新的,不需重編或重灌核心。

不清楚:核心版本細節——介面維持穩定,行程可獨立於核心版本被替換。

CPU 軟分區決策 · 責任邊界

依照策略把 CPU 軟分成兩池:一池給延遲關鍵請求路徑的執行緒,一池給不那麼敏感的背景工作;能改善 p99 的工作先跑,其餘讓路,且傾向讓相關工作留在同一組 CPU,改善 L3 局部性、減少 DRAM 存取。

不清楚的事:個別執行緒實際在算什麼業務邏輯——分區只看它屬於哪一池,不看內容。

把責任拆成三層,也解釋了為什麼這套設計可以喊出「適合上游整合」。核心那一側改動的範圍其實很小,只是在既有的排程時機多開幾個呼叫點,原本的 CFS、EEVDF 邏輯完全可以繼續留著當預設值,兩者不衝突;真正會變動、會出錯、需要反覆調整的部分,全部關在 BPF 程式跟 user-space 行程裡,離核心原始碼很遠,出了問題也不會拖累其他不相關的核心子系統。這種切法對任何想寫自己排程策略的團隊都有意義:不需要說服核心維護者接受一份客製化的排程演算法本身,只需要說服他們接受一組穩定、通用的呼叫介面,演算法留在自己的行程裡反覆迭代、出錯了也只影響掛載這套策略的那一組機器。

這也是為什麼原文特別點名感謝 Ads Capacity Efficiency and Kernel team——這不是一個單純的核心工程專案,同時牽涉容量規劃(怎麼決定該分多少 CPU 給哪一池)與核心維運(怎麼確保 BPF 程式在機隊規模下穩定運作)。把排程策略跟容量效率綁在同一個團隊底下,某種程度上也反映了這套系統真正發揮效益的地方:不是單一機器上的排程優化,而是整個機隊的資源分配問題。

四個事件驅動的 callback,各自何時介入

把一次排程動作攤開來看,一個執行緒從醒來到真正跑在某顆 CPU 上,會依序撞上這四個 callback。wake-up 是執行緒從睡眠或阻塞狀態醒來的瞬間,這一刻它還沒被排進任何佇列;enqueue 把它放進等待佇列,這是 BPF 策略第一次有機會表態「這個工作重不重要」;dispatch 才是真正把執行緒指派給某顆實體 CPU 執行的時刻——這一步也是 Meta 廣告策略動手腳最重的地方:「Work that improves the p99 request latency is scheduled first, and everything else takes a back seat」;idle transition 則發生在某顆 CPU 沒事做的時候,排程器要決定要不要把別顆 CPU 排隊中的工作搬過來執行。

四個 callback 不是四段互相獨立的程式,而是同一次排程決策沿著時間軸展開的四個切片——核心在每個切片上都會暫停、把決定權交給 BPF 程式,再拿著答案繼續往下走。這也是為什麼「換一版策略」可以只靠重新啟動 user-space 行程就生效:四個 callback 的介面本身是穩定的,變的只是每個 callback 裡面的決策邏輯。下面這個時間軸可以拖著看,每個時間點排程器實際在回答哪個問題。

把這四個 callback 放在同一張時間軸上看,也能看出 sched_ext 跟傳統核心 patch 最大的差異:傳統做法要嘛整個排程器換掉,要嘛完全不動;sched_ext 允許你只在某幾個時間點插手,其他時間點沿用預設行為。對 Meta 廣告服務這種只需要在 dispatch 階段做出「誰的 p99 比較重要」判斷的場景,不需要重寫喚醒或睡眠邏輯,這種粒度的控制本身就是一種成本節省。

拖曳把手看排程決策沿時間軸的四個切片 · 4 個 callback

wake-up
執行緒從睡眠或阻塞中醒來,還沒被排進任何佇列。

值得多看一眼的是,這四個 callback 沒有一個是「效能優化」本身,它們只是決策的介面點——真正產生延遲改善的,是塞進 dispatch 那一步的判斷邏輯(誰的 p99 比較重要)跟塞進 enqueue、idle transition 裡的分區規則。把介面跟邏輯分開,正是 sched_ext 能同時滿足「核心穩定」與「策略常常要改」這兩個互相矛盾的需求的原因:介面很少變,邏輯天天可能變,兩者被拆到不同的程式碼庫、不同的發布節奏裡。

callback 收到什麼訊號、能做什麼決定 callback 收到的訊號 能做的決定 wake-up 執行緒剛醒來,還沒排隊 尚未介入,交給下一步 enqueue enqueue 執行緒進了等待佇列 BPF 策略第一次表態這工作重不重要 dispatch 要指定執行的 CPU 是哪一顆 p99 優先權在這一步生效 idle 某顆 CPU 出現空位 決定要不要把別顆 CPU 排隊中的工作搬過來

user-space policy 行程:換演算法不必重開機

把策略邏輯搬到 user-space,換來的直接好處是部署速度。傳統上要換排程邏輯,得改核心原始碼、重新編譯、在機隊上滾動重開機——這條路徑動輒以天甚至週計,而且重開機本身對線上服務就是一次風險。sched_ext 把這整段時間壓縮成「重新啟動一個行程」:卸下舊的 BPF 程式、載入新的,核心版本完全不用動,機器也不用重開。

這個決定也有它的代價。通用排程器的好處是背後有整個核心開發社群多年打磨、踩過無數坑的邏輯撐著;換成專屬策略,等於把「這段邏輯正不正確」的責任收回到 Meta 自己頭上——sched_ext 本身只保證呼叫介面穩定,策略寫得好不好、會不會有邊界案例排錯,是 user-space 那支行程要自己扛的事。但對一個會被線上流量直接放大的效能問題,這筆帳顯然划算:後面會看到,Meta 光靠幾次 user-space 端的策略更新,完全沒再碰過一次核心版本,就又拿到一輪很不小的延遲改善。

這種部署模式也悄悄改變了「測試一版新策略」的成本結構。核心變更走的路徑通常是先在小規模機器上驗證、再逐步擴大範圍,任何一步出錯都要走回頭路重新編譯、重新開機;user-space 行程走的路徑是程式照樣要測、要驗證,但每一輪迭代的物理成本只剩下「重新啟動一個行程」,不必再疊加核心編譯與機隊重開機這兩段最貴的時間。策略邏輯因此可以按照一般應用程式的節奏去改、去上線、去還原,而不必套用核心變更那套更保守、更慢的流程——這也是為什麼下一節會看到,同一套 sched_ext 骨架上光靠策略迭代就能再擠出一輪改善。

這也解釋了為什麼一份看起來只是「換個排程器」的變更,會被寫成一篇工程部落格文章,而不是一行 changelog。多數效能優化的成本集中在寫程式那一刻;這裡的成本結構不同——寫完策略只是起點,往後每一次核心版本升級、每一次流量型態改變,都要有人持續盯著這支 user-space 行程的行為是否還正確。把排程邏輯留在自己手上,換來的迭代速度是真的,但也把一份原本不需要團隊操心的長期維運責任,換成了團隊自己的。

兩條部署路徑,步驟數不一樣 核心版本變更 改核心原始碼 重新編譯核心 機隊滾動重開機 任何一步出錯都要走回頭路 user-space 策略更新 改策略邏輯 重新啟動 user-space 行程 完成,核心版本沒有動過 不必重建或重灌核心

軟分區與 p99 優先

廣告服務專屬的排程策略,說穿了是把 CPU 分成兩個池子——「soft-partitions CPUs into two pools, one for threads on the latency-critical request path and one for less latency-sensitive work」。哪一池優先跑,判準很直接:「Work that improves the p99 request latency is scheduled first, and everything else takes a back seat」。通用排程器不做這個區分——CFS、EEVDF「balance threads across CPUs with no understanding of the workload」,意思是同一顆 CPU 上,延遲關鍵的請求處理執行緒,跟背景批次工作,隨時可能被排在一起輪流跑。

這個分法帶來一個不是排程目標本身、卻是排程結果的附帶好處:「This approach tends to keep related work on the same CPUs over time, improving last-level cache (L3) locality and reducing costly DRAM access」——用「tends to」這個詞,原文自己也沒把它講成保證,是傾向。道理不難想:CPU 每次排程器換手,如果換上的是完全無關的工作,L3 快取裡原本為請求路徑暖好的資料,就有可能被沖掉;下一次請求路徑的執行緒醒來,得重新從 DRAM 把資料抓回來,這段存取本身就比從 L3 抓貴上一個數量級。把相關工作固定留在同一組 CPU,等於讓快取裡的內容盡量跟正在跑的工作對得上。下面這張對照圖,左邊是通用排程器不分類的擺法,右邊是軟分區之後的擺法,拖動分隔線看兩種擺法差在哪裡。

拖曳分隔線比較 CFS/EEVDF 與 sched_ext 的 CPU 配置 · 6 顆 CPU

延遲關鍵 背景工作 CPU0 CPU1 CPU2 CFS/EEVDF:不分類 延遲關鍵池 背景池 CPU3 CPU4 CPU5 sched_ext:軟分區

這張對照圖刻意只畫出「哪些執行緒被排在同一顆 CPU」,沒有畫排程器怎麼決定要放誰進去——那部分屬於前面兩節講的 dispatch callback 與策略行程。軟分區本身不是一次畫定就不再變動的靜態擺法,而是排程器每次面對新的 wake-up、enqueue、dispatch 決策時都會參考的分類依據,久而久之才會呈現出「相關工作留在同一組 CPU」這種統計上的傾向,而不是每個瞬間都精準分開——這也對應前面提到的用詞:原文說的是「tends to」,不是「always」。

這也是為什麼軟分區帶來的效益不只反映在延遲數字上,還反映在機隊的用電量上——快取未命中換來的 DRAM 存取,消耗的不只是時間,也是實際的電力與匯流排頻寬。當同一批工作反覆被排到不理解彼此關聯的 CPU 上,浪費的資源會隨機隊規模線性放大;後面會看到,Meta 公佈的機隊省電數字,某種程度上就是這種浪費在機隊尺度上被收回來的量。

從 CFS/EEVDF 的落差到量到的數字

會走到這一步,起點是一次具體的回歸,不是效能優化的選配題。Meta 原本「have been using the general-purpose schedulers typically integrated in the Linux kernel (CFS and EEVDF) that balance threads across CPUs with no understanding of the workload」——這是基準狀態,排程器不知道、也不在乎誰是誰。問題出在 v6.6 引入的新版排程器:「the new Earliest Eligible Virtual Deadline First (EEVDF) scheduler introduced in Linux kernel v6.6 was causing a latency regression which reduced the number of ads ranked in response」。延遲變差不是一個抽象的百分點,它直接砍掉了一次回應裡能排進結果的廣告數量——這是業務指標,不是內部監控儀表板上的雜訊。

「Earliest Eligible Virtual Deadline First」這個名字本身透露了 EEVDF 想解決的問題:在多個「已經有資格跑」的執行緒裡,挑一個虛擬期限(virtual deadline)最早到期的先執行,藉此在公平性與延遲之間找折衷。但不管是 CFS 還是 EEVDF,判斷依據都停在執行時間統計這個層次——排程器不知道某個執行緒屬於哪個服務、在不在使用者能感知的關鍵路徑上,這正是「no understanding of the workload」這句話的字面意思。sched_ext 不是要否定這套公平性模型,而是在它之外另開一條路,讓有能力辨識 workload 語意的團隊,把這層資訊也餵進排程決策裡。

換上 sched_ext 之後,數字分兩波量到。第一波是把核心版本一起換掉——從 kernel 6.4 配 CFS,換成 kernel 6.9 配 sched_ext:廣告檢索路徑的服務 p99 延遲降了 28%,weighted-ads-ranked 這個業務指標長了 1.1%,機隊層級省下 3.28 百萬瓦的電力。第二波完全沒再動核心版本,純粹靠 user-space policy 行程的幾次策略更新:p99 延遲又額外降了 60%,關鍵路徑上的 timeout 錯誤減少 18%。所有數字的量測方法是同一套:「Measured by backtest after the rollout reached global scale and stabilized (~3 weeks), and validated via the Ads Delivery launch-candidate review plus group and company holdout backtests」——不是上線當下的即時讀數,是全球流量都吃到、穩定約三週之後回頭做的 backtest,再拿發布候選審查跟 holdout backtest 交叉驗證過。

數字取自 Meta 工程部落格,backtest 於上線穩定約 3 週後量測並交叉驗證。
階段指標數值
初次上線(kernel 6.4+CFS → kernel 6.9+sched_ext)廣告檢索路徑 p99 延遲−28%
初次上線weighted-ads-ranked+1.1%
初次上線機隊省電3.28 MW
後續 user-space 策略更新(核心版本未變)p99 延遲(額外)−60%
後續 user-space 策略更新關鍵路徑 timeout 錯誤−18%
兩波改善是相乘,不是相加 基準 100% 換框架後 72%(−28%) 策略迭代後 約 29%(再 −60%) 29% 為本站換算(72%×40%),原文只公布兩個百分比本身,未給複合後的總相對值

backtest 加上 holdout 交叉驗證的量測方式,也值得工程師留意。這代表這些數字不是上線瞬間的峰值讀數,而是把一段時間內的真實流量重播、跟沒有套用新排程器的對照組比較出來的差異——這種方法論本身能過濾掉單日流量波動造成的雜訊,但也代表數字反映的是穩定狀態下的平均效益,而不是任何單一請求的改善幅度。

把兩波數字放在一起看,會發現它們其實在驗證兩件不同的事。第一波的 28%,量的是「從通用排程器換成 workload-aware 排程」這個決定本身值多少;第二波的 60%,完全沒有換核心版本,量的是「同一套 sched_ext 骨架上,policy 邏輯可以被反覆調整」這件事本身值多少——這正好對照前面說的部署速度優勢:如果每次調整策略都要重新編譯核心、滾動重開機,不會有這麼多次迭代的空間去擠出這一輪改善。負責這個專案的是 Meta 內部的 Ads Capacity Efficiency and Kernel team。

這兩波數字合起來也回答了一個容易被忽略的問題:一次性換框架的紅利跟長期迭代的紅利,哪個比較大。從絕對數字看,第二波的 60% 比第一波的 28% 還高,而且完全沒有再承擔一次換核心版本的風險——這意味著,對 Meta 而言,把資源投在維護一支 user-space 策略行程、持續調整分區規則,長期報酬可能比一次性換完框架就停手更划算。這是從公開數字推出的合理解讀,原文本身沒有直接下這個結論。

對想嘗試同樣路徑的團隊,這篇文章畫出的檢查清單很具體:先確認自己的核心版本是否已經支援 sched_ext(v6.12 起),有沒有一段可以重播驗證的流量,再決定要不要投入寫一份專屬策略。少了 backtest 與 holdout 這一層驗證能力,貿然換上自訂排程器等於在賭一個沒有安全網的效能優化。

What this enables:把排程策略從核心裡搬出來,換成一個可以隨時重新啟動、隨時換版本的 user-space 行程,拿掉的不是效能,而是「改一次排程邏輯就要重編核心」這道門檻。Meta 廣告服務的兩波數字合起來說明這件事的份量:一次是換框架時量到的 28% p99 改善,一次是完全沒碰核心版本、純靠策略迭代量到的額外 60%——當排程器可以看懂 workload、又能用重新啟動一個行程的速度去調整,延遲問題就從「等下一次核心升級排上日程」,變成「這週就能試下一版策略」。這對任何自己維護延遲敏感服務的團隊都是一個可以直接抄的判斷依據:先問排程器懂不懂你的 workload,再問改一次策略要付出多少部署成本。