JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

微基準測試:別自欺欺人

上一篇你學會了用剖析找出熱點。現在你想讓某個小東西更快、並把它證明出來——於是你寫一個小迴圈、計時、讀出一個數字。那個數字幾乎總是在騙你。本篇要談的是:為什麼微基準測試會自欺欺人,以及讓它說真話的那套紀律。

天真的基準測試,以及它為何說謊

上一篇把測量,別猜測這條原則灌進了腦袋:剖析整個程式、找出時間實際花在哪、把力氣對準那裡。假設火焰圖指向某個小函式——比如一個雜湊、一個剖析器的內層迴圈、一個數學核心。最自然的下一步就是把它孤立出來:寫一個小程式、在迴圈裡呼叫那個函式一百萬次、把迴圈計時、相除,然後宣布「每次呼叫 3.2 奈秒」。這就是一個微基準測試(micro-benchmark),而這個直覺完全正確。麻煩在於:你量的東西越小越緊,測量就有越多種方式悄悄背叛你。

這裡是它背叛你最常見的一種方式,而它幾乎第一次就逮到每個人。你寫好迴圈,開最佳化編譯——`gcc -O2`,任何誠實的效能測量本來就該這樣——而基準測試報出一個快到荒謬、簡直羞辱物理定律的時間。你並不是剛寫出了世界上最快的雜湊。是編譯器注意到了一件事:沒有人用這個函式的結果,所以依死碼消除這一遍,整個呼叫都是無用功,而宛如規則允許最佳化器把它整個刪掉。你那一百萬次迭代的迴圈被編譯成了無物。你量到的是一個空迴圈——甚至根本沒有迴圈。

編譯器並非惡意;它正是在做它的本份。它證明了你的計算沒有可觀察的效果、於是把它移除,這跟讓你真正的程式變快的,是同一份聰明。但在基準測試裡這是災難性的,因為你想要那份工作被做出來、好讓你能替它計時。而死碼消除只是第一招:常數傳播同樣致命。如果你餵給函式一個字面值——`hash("hello")`——編譯器可能在編譯期就把整件事摺疊成一個常數,於是迴圈量到的是「回傳一個它早就知道的數字」的速度。你必須兩者都擊敗,否則你的數字就是虛構。

讓工作活著:消耗點與未知的輸入

兩個陷阱的解藥,都是讓編譯器無法證明那份工作是無用的。你從兩面夾擊。第一,讓輸入在編譯期是未知的,這樣常數傳播就沒東西可摺:從 argv 讀值、從檔案讀、從一個執行期才填好的隨機緩衝區讀——任何最佳化器看不穿的來源。第二,讓輸出是可觀察的,這樣死碼消除就刪不掉那個呼叫:把每一個結果累加進一個跑動的總和,然後對那個總和做一件編譯器必須尊重的事,比如印出它、或透過一個它無法追蹤的指標把它存出去。

// WRONG: result unused -> the whole loop is deleted at -O2
for (size_t i = 0; i < N; i++)
    hash(i);                    // dead code

// RIGHT: input unknown (from argv), output consumed via a sink
unsigned seed = (unsigned)atoi(argv[1]);   // optimizer can't fold
unsigned long acc = 0;
for (size_t i = 0; i < N; i++)
    acc += hash(seed + i);      // every result feeds acc
if (printf("%lu\n", acc) < 0)    // sink: forces the work to exist
    return 1;
C 裡的修法:從 argv 取輸入使其無法被常數摺疊、累加每一個結果、再透過一個真實的副作用(這裡是 printf)消耗那個累加器。在基準測試函式庫裡,一個「禁止最佳化」屏障或一個 volatile 消耗點能做到同樣的事。

一個正經的基準測試框架會給你一個比 printf 更乾淨的工具:一個禁止最佳化的消耗點,有時是像 `benchmark::DoNotOptimize(x)` 這樣的函式、或一小段內聯組合語言屏障,它告訴編譯器「假裝這個值逃逸到了你看不見的程式碼裡」。那會在恰恰正確的點上擋住死碼消除,而你不必去發明一個假的列印。底層的原則跟整級所倚靠的同一個:最佳化器推理的是可觀察行為,所以要量一份工作,你就必須讓那份工作可觀察——不多,也不少。

暖機、穩定態,與雜訊地板

就算工作被保住了,你的第一次計時迭代仍是另一種陷阱。最初那一次呼叫是冷的:函式的程式碼還不在指令快取裡、它的資料不在資料快取裡、分支預測器從沒見過這些分支、會把它們預測錯,而在一個 JIT 編譯或剛分頁進來的二進位上,程式碼甚至可能還沒完全駐留。第一次跑可能比第一百次慢十倍或一百倍。如果你只計時一發、或把冷跑也平均進去,你量到的是啟動、不是穩定態的速度。

所以你要暖機(warmup):先不計時地跑這個基準測試一陣子、直到效能不再進步,等它穩定下來進入穩定態(steady state)之後才開始測量。但你真正在乎哪個狀態,取決於問題本身。如果你在最佳化一個長跑的伺服器迴圈,穩定態恰恰正確。如果你在替某個每次程式只跑一遍的東西計時——啟動剖析、首次請求延遲——那麼冷路徑才是那件事,把它暖掉量到的是一個幻想。在你決定丟掉什麼之前,先決定你真實的工作負載活在哪個狀態。

現在把穩定態的基準測試跑個幾遍,你會看到數字晃動——3.1 ns、3.4 ns、3.0 ns、3.6 ns。那段散布就是雜訊地板(noise floor),它來自一切你控制不了的東西:作業系統排程器把你的執行緒搬到另一個核心(又是冷快取)、頻率調節把時脈拉上拉下、其他行程偷走快取與記憶體頻寬、中斷、甚至熱節流。一個比雜訊地板還小的測得差異不是結果——它是天氣。最大的原罪是:把舊碼跑一遍、新碼跑一遍、看到 3.3 對 3.1,就宣布一個其實純粹是排程器抖動的 6% 勝利。

讀分布,而不是讀平均

正因為雜訊地板是真實的,單一數字永遠不是答案;一個分布(distribution)才是。跑許多次計時迭代、看它整個形狀。最穩健的摘要通常不是平均——一次脫隊的中斷就會產生一個巨大的離群值、把平均拖高、卻對典型的呼叫毫無交代。當你想要對程式碼內在成本最乾淨的估計時,偏好最小值(最快的那一跑就是最不受雜訊干擾的那一跑),並報出中位數與散布來顯示它有多穩定。如果最小值是 3.0 ns 但中位數是 9 ns,那程式碼其實並不快——它只在星辰排成一線時才快。

當你比較兩個版本時,問題不是「A 的數字比 B 的小嗎?」而是「A 與 B 的分布真的分開了嗎?」如果舊碼聚在 3.2 ns 附近、散布 ±0.4,而新碼聚在 3.0 ns 附近、散布相同,那兩團雲重疊得很厲害、你的「勝利」就在雜訊之內。一個真正的改進,是那兩個分布幾乎碰不到彼此。這正是為什麼可重現的基準測試堅持要多跑幾遍、要報出變異數,而不是一個志得意滿的單一數字——這就是「一次測量」與「一個穿著數字外衣的猜測」之間的差別。

保留分布還有一個更深的理由:平均能藏起真正會傷人的那一部分。一個平均 5 ns 但每一千次呼叫就有一次飆到 500 ns 的函式——因為那一次呼叫觸發了一次配置或一場快取失誤風暴——在平均上看起來沒事,在對延遲敏感的系統裡卻是場災難。分布的尾巴常常才是真正的故事,而那正是下一篇「延遲、吞吐量與尾巴」的全部主題。對微基準測試來說,現在要養成的習慣很簡單:在你看過測量的形狀之前,永遠別把它們塌縮成一個數字。

你量到的「微」不是你出貨的「宏」

假設你每件事都做對了——保住了工作、暖了機、跑了分布、擊敗了雜訊——而你的函式在孤立狀態下真的快了 30%。最後、也最令人謙卑的陷阱仍在:一個更快的微基準測試,不保證一個更快的程式。在你那個緊湊的迴圈裡,函式跑時資料在快取裡是熱的、分支被一個重複的輸入訓練得完美、CPU 的指令級平行一遍又一遍地從同一塊記憶體餵它。在真實的程式裡,那個函式在一千件別的事之間只被呼叫一次,帶著冷快取、未受訓練的分支預測器、與被爭用的記憶體。讓微基準測試乾淨的那些條件,正是在正式環境裡不成立的那些。

這直接連回上一篇的成本模型概念。一個微基準測試量的是函式孤立狀態下的成本;你出貨的是它在脈絡中的成本。有時候一個贏了微基準測試的聰明最佳化,在宏觀層面卻輸了——因為它讓程式碼膨脹、把一個更熱的鄰居擠出了指令快取,或因為它用幾條指令換來了一個會把共享快取打到顛簸的記憶體存取模式。微基準測試看不見那些二階效應,因為它的設計本身就移除了產生那些效應的脈絡。

把它釘死:一份你能照做的檢查表

這一切可以蒸餾成一套簡短、可重複的程序。其中沒有任何玄妙的東西;它只是一組習慣,每次都套用,就能讓一個微基準測試保持誠實。頭幾次刻意地走一遍它,很快就會變成反射——就像在這道階梯較早處,檢查 malloc() 與 read() 的回傳值變成反射那樣。

  1. 用你部署時的最佳化等級編譯(通常是 -O2 或 -O3),絕不用 -O0——量你真正出貨的那個程式。
  2. 擊敗死碼消除:把每一個結果消耗進一個累加器、再餵給一個禁止最佳化的消耗點或一個真實的副作用。
  3. 擊敗常數傳播:從一個編譯器看不穿的來源取輸入——argv、一個檔案、一個執行期填好的緩衝區——絕不用字面值。
  4. 如果你真實的工作負載是熱跑的,就暖機到穩定態;如果你出貨的東西只跑一次,就保留冷路徑。
  5. 跑許多次迭代、收集一個分布、報出最小值加上中位數與散布——絕不用一個孤零零的平均。
  6. 讓機器安靜:把執行緒釘在一個核心上、可以的話關掉頻率調節與 turbo、關掉吵雜的鄰居行程,以降低雜訊地板。
  7. 藉由重新剖析整個程式來端到端地確認任何勝利——微基準測試是假說,宏觀的測量才是判決。

照那份清單跑,你的數字就會開始說真話。把它串起來的那條線,跟基準測試的第一原則是同一條:編譯器與 CPU 都在不懈地試圖做比你的基準測試天真地要求的更少的工作,而一次測量的好壞,只取決於你對它們實際做了什麼的交代有多準。手握誠實的微觀數字,你就準備好迎接下一篇了——在那裡我們不再問「平均有多快?」,而開始問那個更難、也更真實的問題:當它慢的時候有多慢、以及為什麼是尾巴、而非平均,才是你的使用者真正感受到的東西。