vatt'ghern jaskier's ballads

研究者把執行緒鎖進單一 socket,跑分結果最高看起來快了 3 倍——而 Linux 預設的 EEVDF 排程器跟專為 Steam Deck 設計的 LAVD,彼此表現卻幾乎一樣。真正的答案,藏在一行被整個學期複製貼上、後來所有人都忘記的 benchmark 設定裡。

選錯 metric,讓 3 倍加速憑空消失

群修「Parallel Computer Architectures」的 UIUC 學生,在上學期的期末專案裡問了一個聽起來很平常的問題:把一組執行緒限制在同一個 socket、甚至同一個 LLC(last-level cache)domain 裡,排程器的選擇還重不重要?他們比的是 Linux 目前預設的 EEVDF,跟 Igalia 的 Changwoo Min 專門為 Steam Deck 設計的 LAVD。直覺很簡單:兩顆核心如果同屬一個 socket、共享同一段 L3,跨核心存取共享記憶體要付出的 cache coherence 成本,理論上應該比隔著兩個 socket 便宜得多。用 numactl 把測試綁死在單一 socket,等於是把這個「理論上」直接兌現成一組數字——問題是,數字兌現的方式,最後跟他們以為的完全不一樣。

EEVDF(Earliest Eligible Virtual Deadline First)是 Linux 目前的預設排程器,決策依據是每個任務的「虛擬截止時間」,理論上對延遲敏感與吞吐量敏感的工作負載都要照顧到。LAVD 則是 Igalia 的 Changwoo Min 專門為 Steam Deck 設計的排程器,出發點是遊戲這種對「掉幀」極度敏感的場景,決策邏輯會更積極地把延遲敏感的任務往前排。兩者的設計哲學不一樣,這也是團隊一開始會好奇「排程器選擇重不重要」的原因:如果連設計哲學差這麼多的兩個排程器,實測起來都差不多,那問題可能根本不在排程器本身。

鎖住一個 socket,最高看到 3 倍差距

「3 倍」這種數字對工程團隊有一種特殊的吸引力,它夠大,大到值得寫進報告的摘要,又不誇張到讓人直覺懷疑哪裡出錯。比起「改善了 12%」這種容易被追問「你怎麼量的」的數字,「高達 3 倍」常常會先被接受,細節留到後面再說。這正是這篇文章危險的地方:如果團隊沒有在報告快寫完前多看一眼 MIPS,這個 3 倍很可能就這樣被寫進最終結論,沒有人會發現它其實是被固定時長偽裝出來的。

用 numactl 把 benchmark 限制在單一 socket之後,團隊看到的第一組結果很戲劇性:「using numactl to restrict thread scheduling to a single socket improved runtime by as much as 3x」——整組測試套件裡最高的一筆,runtime 直接砍掉一大截。奇怪的是另一條線索完全對不上:把 EEVDF 換成 LAVD,兩個排程器彼此的表現卻「approximately the same」。如果排程器之間的差異這麼小,那「鎖在單一 socket」這個動作顯然不是在幫排程器做決策,它在解決一個更底層的東西。

團隊當時的猜測算合理:Linux 的 load balancer 本來就會盡量讓所有 socket 的負載打平,執行緒被自動撒到各個 socket 上;numactl 硬性把這個「打平」關掉,換來的可能就是少走幾段跨 socket 的記憶體路徑。這個猜測後來只對了一半,而且連作者自己都補了一句「I'm not totally sure how accurate that hypothesis ended up being」。

numactl 本身只是一個工具,作用是把行程或執行緒綁定到特定的 NUMA node 或 CPU 集合,強制作業系統的記憶體配置與排程都留在指定範圍內。這跟排程器本身的決策邏輯是兩件事:排程器決定「這個時間片給哪個執行緒」,numactl 決定「這個執行緒只准出現在哪些核心上」。團隊原本以為兩者會互相影響,排程器如果夠聰明,應該自己就能避免跨 socket 的壞決策;但 numactl 用蠻力達成的效果,跟排程器聰不聰明幾乎沒有關係,這也是為什麼 EEVDF 與 LAVD 在沒有 numactl 介入時表現差不多的另一層解讀。

這類實驗對工程師來說不是象牙塔題目。雲端主機的大 instance 動輒兩顆以上的 socket,NUMA-aware 的部署本來就是效能調校的老問題,把 process 綁在特定 NUMA node,或是讓 container runtime 感知拓樸,都是這個問題的變形。如果單一 socket 限制真的能帶來最高 3 倍的差距,那對任何跑在多 socket 機器上的服務來說,這都是值得認真評估的部署選項;但如果這個 3 倍其實是量錯 metric 量出來的幻影,貿然把生產環境的部署策略押在這個數字上,代價就不只是一篇報告寫錯而已。

作者從第一組數字到找出問題,走過的五步

  • 01 numactl 測試:最高看到 3 倍差距

    用 numactl 限制單一 socket 之後,runtime 最高改善「as much as 3x」;同時發現 default 排程器與 LAVD 彼此表現「approximately the same」。

  • 02 檢查 L3 RFO miss:方向對了,但沒說完整個故事

    單一 socket 限制後,read-for-ownership miss 數字確實下降,方向上支持「少走跨 socket 路徑」的理論——但沒解釋所有 benchmark 的行為。

  • 03 perlbench 對上 mediawiki:同一套限制,兩種反應

    perlbench 大幅加速,作者當場寫下「what the hell is going on with perlbench」;mediawiki 卻幾乎沒有任何變化。

  • 04 數週後回頭看 MIPS:一句帶著問號的困惑

    作者自己也說不清楚為什麼會回頭去查 MIPS 資料,看到數字後留下「throughput has gone up significantly but runtime hasn't changed??」。

  • 05 藏了整個學期的設定:固定 10 分鐘

    往回追,發現 mediawiki 的測試指令裡有一行「duration was fixed to 10 minutes」,被複製貼上到所有人都忘記它的存在。

作者從第一組數字到找出問題,走過的五步 01 numactl 測試 最高「3x」 02 RFO miss 下降 方向對了 03 perlbench 狂飆 mediawiki 不動 04 回頭看 MIPS 「no sense」 05 固定 10 分鐘 被埋了一學期 從左到右:每一步都以為離答案更近,直到第五步才發現前四步量的東西根本不對。

假說一:省下的是跨 socket 的 cache coherence

多核心系統要維持 cache 一致性,本質上是每一份被多顆核心共享的資料,都要有「誰目前擁有寫入權」的共識;一顆核心想寫入別人持有的資料,就得先發訊息把所有權要回來,其他核心手上的舊版本同時作廢。這個協商要付出的成本,取決於持有資料的另一顆核心離自己多遠:同一顆核心的兩條 SMT 執行緒幾乎不用付出額外代價;同一個 socket 內的不同核心,協商走的是晶片內部的 L3,成本已經高一截;一旦跨了 socket,協商得經過 socket 之間的 interconnect,這段路徑的延遲通常是同 socket 內的數倍。

要驗證「少走跨 socket 路徑」這個猜測,最直接的做法是去看 cache coherence 本身的成本:L3 read-for-ownership當一顆核心想寫入某段資料,但這段資料的最新版本在另一顆核心(甚至另一個 socket)的 cache 裡,就會觸發一次 RFO——L3 得先把資料的所有權要回來,才能允許寫入。 miss。核心與核心之間隔得越遠——同一顆核心的兩個執行緒、同 socket 不同核心、跨 socket——這趟「要回所有權」的路徑理論上就越長。

要回資料所有權,得走多遠? 同一顆核心(SMT 兩條執行緒) 最短——共享 L1/L2 同一個 socket,不同核心 中等——共用 LLC/L3 跨 socket 最長——得走 interconnect numactl 把執行緒鎖在同一個 socket,等於把最右邊那格直接排除——理論上省下的正是那段最貴的路徑。 單一 socket 限制後,L3 RFO miss 數字確實下降;但這只解釋了「為什麼有些 benchmark 變快」,沒解釋「為什麼有些沒有」。

底線詞可以點開看定義:LLC domain同一顆 CPU 內共享最後一層 cache(通常是 L3)的核心集合;同一個 LLC domain 內的核心,存取彼此寫過的資料不需要跨晶片互連。coherence action維持多核心 cache 一致性所需的訊息交換,例如把某段資料的所有權從一顆核心的 cache 轉移到另一顆。

這也是為什麼理解自己機器的拓樸,對效能除錯是基本功:同一顆晶片上有幾個核心、幾個核心共用同一段 LLC、系統裡有幾個 socket,這些資訊通常一行指令就能看到,但很少有人在排查效能問題時第一步就去查。numactl 這類工具能做的事,某種程度上排程器本來就該做到,差別只在於排程器要在不知道 workload 未來行為的前提下即時決策,人手動限制則是已經看過結果之後才下的判斷,兩者面對的是完全不同難度的問題。

矛盾就藏在這裡:如果「少走 cache coherence 路徑」這個理論是對的,所有 benchmark 的 RFO miss 都往同一個方向動,理論上應該所有 benchmark 的 runtime 也都跟著動。但事實不是這樣——有一個 benchmark 完全不聽話。

perlbench 狂飆,mediawiki 卻文風不動

DCPerf 是 Meta 開源的資料中心效能基準套件,收錄的都是真實服務等級的工作負載,mediawiki 是其中之一,用來模擬維基百科這類重讀取、高並發的網頁服務。這類 benchmark 常見兩種量測設計:一種是固定工作量、量完成要花多久時間;另一種是固定時間窗口、量這段時間裡完成了多少工作。兩種設計都合理,但算 speedup 的方式完全不同,固定工作量的 benchmark,runtime 縮短就是變快;固定時間窗口的 benchmark,runtime 是常數,只有吞吐量或指令數才反映得出變快。分不清自己的 benchmark harness 用的是哪一種,套錯公式,算出來的數字看起來完全合理,卻是錯的。

DCPerf 套件裡的 mediawiki(在雙 socket 至強機器上跑,32 個 client threads)在單一 socket 限制之後,runtime 幾乎沒有任何變化。放在同一張表裡對照,perlbench 這類 benchmark 卻加速得誇張——誇張到作者當下直接寫下「What the hell is going on with perlbench? What is making it speed up so much?」。這句疑問本身沒有在文章裡被徹底解開,作者自己也承認「we didn't end up being able to answer either of these questions」。

同一套 numactl 限制,perlbench 與 mediawiki 完全不同的反應——引號內為作者原文用語,「文章未提及」代表原文沒有對應描述。
benchmark 執行時長設定 numactl 限制後 runtime 事後查 MIPS/throughput
perlbench 文章未提及固定時長 大幅加速——「what the hell is going on with perlbench」 文章未提及
mediawiki
(DCPerf,32 client threads,雙 socket Xeon)
固定 10 分鐘——「duration was fixed to 10 minutes」 幾乎沒有變化 顯著上升——「throughput has gone up significantly」

這張表刻意把「文章未提及」的欄位空著,而不是幫作者把數字補上。perlbench 的固定時長設定、事後的 MIPS 讀數,原文都沒有給出對應的描述,這不代表答案不存在,只代表作者沒有寫,也就沒有辦法核實。把不確定的地方留白,比起替作者腦補一個聽起來合理的數字,更貼近這篇文章原本的誠實程度。

兩個 benchmark 跑在同一台機器、套同一套限制,一個大幅加速,一個毫無反應。如果理論是對的,mediawiki 沒道理是例外——除非量出「沒有反應」的那把尺,本身就有問題。

一個被整個學期複製貼上、後來被所有人忘記的設定

答案不是在測試當下浮現的,而是在報告快寫完的時候。作者形容得很坦白:「And for some reason I have since forgotten, I was curious what the MIPS was for each of these benchmarks.」他回頭去看每個 benchmark 的 MIPS(每秒完成的指令數),而不是 runtime。看到 mediawiki 的數字時,反應是一句帶著雙問號的困惑:「Well that makes… no sense. Throughput has gone up significantly but runtime hasn't changed??」

MIPS(每秒完成的百萬指令數)在效能工程裡是一個古老但常被忽略的指標。它量的不是花了多少時間,而是同樣時間裡做了多少事,對固定時長的 benchmark 而言,這才是唯一還在變動的維度。作者事後拿 MIPS 回頭核對,等於是換了一把尺重新量同一個系統:runtime 這把尺被 10 分鐘的設定鎖死,量出來的永遠是同一個數字;MIPS 這把尺沒有被鎖死,量出來的才是系統真正的效能差異。兩把尺量的是同一場測試,卻講出兩個完全不同的故事,這正是整篇文章的核心問題。

往回追,原因浮出來了:mediawiki 的測試指令裡藏著一行「Duration was fixed to 10 minutes」的設定。這一行被埋得極深——「buried so deeply in our testing infrastructure」,而且在整個專案裡被複製貼上了太多次,深到沒有人再想起它的存在。固定 10 分鐘是什麼意思?意味著不管系統跑得多快、多慢,mediawiki 每一次測試都精準地跑滿 600 秒才停。runtime 這一欄,對這個 benchmark 而言,從一開始就是一個常數,不是一個能拿來算 speedup 的變數。

作者把這個錯誤講得很直接:「Speedup was calculated with the assumption that instructions have been held constant, which was not the case for this benchmark.」speedup 等於基準 runtime 除以限制後 runtime,這個公式只有在「兩次測試跑的指令數量一樣」的前提下才成立。mediawiki 的指令數量從來就不是固定的——固定的是時間;真正在變動的,是這 10 分鐘裡系統擠出了多少指令。runtime 看起來「沒有變化」,不是因為系統真的沒有變快,而是因為這個 metric 在數學上根本不可能量出變快這件事。

speedup 公式本身沒有錯,錯的是套用的前提沒有先被檢查過。任何兩個數字相除得到的比值指標,都內建了一個關於「什麼東西被固定住」的假設,沒有先確認這個假設在自己的實驗裡成立,算出來的比值就只是兩個數字相除,不是真正的加速倍率。這個陷阱在效能工程裡不算罕見,只是通常不會像這次一樣,被作者自己在文章裡完整攤開來檢討。

切換兩種 metric 濾鏡看同一場測試 · 2 種檢視

固定時長檢視——runtime 這一格是常數 多 socket(預設) 10 分鐘 單一 socket(numactl) 10 分鐘 兩者完全一樣——因為時長本來就是鎖死的,runtime 這個 metric 在這裡量不出任何差異。 指令完成量檢視——同一個 10 分鐘窗口裡,做了多少事 多 socket(預設) 基準 單一 socket(numactl) 最高 3x 同一個 10 分鐘窗口,做的工作量差了一大截——這才是真正在變動的數字。

兩個檢視畫的是同一場測試:左邊固定看「花了多久」,右邊固定看「做了多少事」。長條為示意圖,比例取自作者原文「as much as 3x」這個整組測試套件裡的最高倍率,不是 mediawiki 單一 benchmark 的逐筆精確數字——原文沒有公布這個數字。

作者把這個教訓講得很白:「the choice of metrics can very easily disguise results as something they're not. That can both be in the negative direction (this study) but also the positive direction.」這次的方向是負的——真正的加速被藏了起來,讓 mediawiki 看起來像是排程器對它完全沒用。但同一個機制反過來也能把雜訊放大成假訊號,讓一個其實沒什麼差異的改動,看起來像是巨大的進步。文章裡也留了一條沒接上的線:他們後來注意到「we realized that CAS was starting to address point 3」——CAS(cluster-aware scheduling)被順帶一提,但作者沒有說團隊有沒有真的換上去驗證。

切換分頁看作者自己留下的 4 句保留 · 4 個分頁

「I'm not totally sure how accurate that hypothesis ended up being.」

作者對自己最初的 cache coherence 假說,直接承認不確定它有多準——這句沒有被後續的 RFO miss 數字完全撫平。

「We didn't end up being able to answer either of these questions.」

文章明說有些問題最終沒有答案。整篇偵查不是全部真相水落石出,收尾時仍留著沒接上的線。

「What the hell is going on with perlbench? What is making it speed up so much?」

perlbench 大幅加速這件事,作者當下也還沒有解釋——只用來對照 mediawiki 的「沒反應」,機制本身沒有在文章裡被拆開講。

「And for some reason I have since forgotten, I was curious what the MIPS was for each of these benchmarks.」

回頭去查 MIPS 的理由,作者自己也記不清了——這不是一次刻意設計的交叉驗證,比較像是順手一查,結果查出了整篇文章的轉折。

把「差點被自己的數字騙過去」寫成部落格公開,而不是悄悄修正報告就算了,某種程度上比 3 倍這個數字本身更值得記一筆。效能測試出錯很常見,少見的是有人願意把怎麼發現自己錯了的過程,和結論一起攤開來寫。

這篇文章本身也不是正式論文,而是作者在部落格上回顧上學期的期末專案,沒有經過同行審查,數字也沒有逐筆公開,3 倍這個說法對應的是哪一個 benchmark、哪一次量測,讀者無法直接重現。這不代表內容不可信,但確實意味著這篇文章記錄的更多是一次真實的除錯過程,而不是一份可以直接拿去引用的效能報告。

把一個效能數字寫進結論之前,先問這三個問題——順序本身也有意義,第一題答錯,後面兩題都不用問了。
順序 問題
先問 這次跑分,時間是固定的還是工作量是固定的?
再問 這個假設是不是寫在一個被複製貼上很多次、沒人重新審視的設定裡?
最後問 這次「看起來差不多」或「看起來差很多」,有沒有可能只是量錯了東西?

作者自己的結論:「Picking good metrics to communicate your results helps not only present findings better, but also lets your work be resilient to mistakes or oversights made in experimental design.」下次看到「兩個配置跑起來差不多」,先別急著下結論——回頭確認一下,量的到底是「做完一件事花多久」,還是「這段時間裡做了多少事」;固定住的到底是工作量,還是時間本身。