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

先量測,別用猜的:剖析與火焰圖

每一支跑得慢的程式都會誘惑你去猜時間花在哪裡——而那個猜測錯得比你以為的頻繁得多。本篇要建立一個習慣,它把真正的效能工作和民間傳說區分開來:用剖析器找出熱點、從火焰圖讀懂它,然後讓量測、而不是你的直覺,來決定要修什麼。

第一條規則,以及為什麼它是規則

前面幾級你學了一支程式真正怎麼跑——指令穿過管線、位元組穿過快取、系統呼叫跨進核心。那份知識在一個特定的面向上是危險的:它讓你自信地以為知道程式把時間花在哪裡。你不知道。幾乎沒有人知道。效能工程這門學問的核心可以濃縮成一句話,先量測,別用猜的,而它之所以是規則而非建議,是因為那個失敗模式太可靠了。程式設計師可靠地會去最佳化錯的東西——那個感覺昂貴的函式、那個看起來很緊的迴圈——而真正的成本藏在某個無聊的角落。

一個小小的真實故事說明了重點。某個團隊曾花一週手工最佳化一段他們確信是瓶頸的矩陣常式,把內層迴圈削成漂亮的 SIMD。程式快了 3%。當他們終於去剖析時,60% 的牆鐘時間花在 malloc()free() 裡面,在一個沒人看過的迴圈裡被呼叫了數百萬次。矩陣程式碼從來不是問題。這不是一個關於粗心的人的故事;這是猜測的正常結果,因為人對「什麼很慢」的直覺,是從程式碼有多難寫建立起來的,而不是它跑得多頻繁。

觀察一支程式的兩種方式:抽樣 vs 插樁

剖析器(profiler)是告訴你時間實際去哪裡的儀器,而建造它有兩種根本不同的方式。這個分界,抽樣 versus 插樁,是本篇最重要的單一概念,因為每種方法都以不同的方式欺騙你,而你必須知道自己正在讀的是哪一個謊。一個插樁式(instrumenting)剖析器會改寫你的程式——在編譯期或執行期——使每一個函式在進入與離開時都記下一個時間戳。它看見一切、每一次呼叫、精確無誤。代價是記錄本身要花時間,所以量測這個動作本身改變了你所量測的東西:一個被呼叫了十億次的小函式,現在背上了它在正式環境裡從未有過的十億次時間戳寫入。

一個抽樣式(sampling)剖析器以相反的方式運作:它完全不碰你的程式。它反而每秒中斷執行中的行程許多次——比如說 1000 次——而在每一次中斷時,它只是記下CPU 此刻正在哪個函式裡,做法是讀取指令指標(rip)並沿著儲存的返回位址往回走呼叫堆疊。每一次窺看就是一個統計樣本。一個占去 40% 執行時間的函式,依大數法則,會出現在大約 40% 的樣本裡。程式幾乎以全速執行,因為剖析器在兩次取樣之間什麼都不做。這就是為什麼正式環境的剖析器——Linux 的 `perf` 是經典代表——幾乎總是抽樣式的。

把這個取捨在腦中保持成一幅乾淨的圖。插樁給你精確的次數但扭曲時間,而且對它沒插樁的任何東西都是盲的(你無法從使用者空間替核心打時間戳)。抽樣給你近似的、統計性的時間且幾乎沒有扭曲,看得見整個堆疊包括函式庫與核心,但可能漏掉那些罕見卻個別很慢的函式,而且不告訴你任何精確的數字——只有比例。兩者沒有誰「比較好」;它們回答不同的問題。「有多少牆鐘時間住在這個函式底下?」是一個抽樣問題。「這到底被呼叫了幾次?」是一個插樁問題。

讀懂一張火焰圖

一個抽樣剖析器交給你數萬份堆疊快照,而原始狀態下那是一堆無法閱讀的東西。火焰圖(flame graph)就是那個把這堆東西變成一張你能在幾秒內讀懂的圖的視覺化方式,而學會讀它,是本篇實作技能的大半。每一個樣本都是一個堆疊:main 呼叫 parse、parse 呼叫 read_token、依此往下一直到 CPU 被逮到的地方。火焰圖把這些堆疊起來,就像呼叫堆疊本身那樣堆——呼叫者在下、被呼叫者在上——然後把相同的框並排合併成一個寬框。

      |  parse_number  |       <- top frame: where the CPU actually was
  |        parse        |      <- its caller
  |         main          |    <- bottom: root of every stack
  +-----------------------+
   width  =  fraction of samples  =  share of CPU time

  read x-axis as PROPORTION, never as time-order.
  a WIDE box = a hot path.   a TALL stack = deep calls (not slow!).
一張迷你火焰圖。x 軸是樣本所占的比例(所以寬度 = CPU 時間的占比),不是時間的流逝;y 軸是呼叫深度。你要獵的是寬框,特別是靠近頂端的寬框——那才是 CPU 真正待著的地方。

兩個讀圖錯誤幾乎人人都犯,所以現在先認識它們。第一:x 軸不是時間。框是為了合併而按字母排序的,不是按它們執行的時間由左到右排;寬度的意思就是「樣本占比」,沒有別的。第二,而且更重要:寬度重要,高度不重要。一座又高又瘦的塔是一條 CPU 幾乎沒去過的深呼叫鏈——無害。寬框才是時間所在。而最有用的單一技巧,是去找頂端的寬框:一個框之所以寬,是因為大量時間花在它裡面或它底下的某處,但一個框既寬又上面幾乎或完全沒有東西,那就是一個葉子,是 CPU 真正在執行的地方,而不只是路過。那個頭重腳輕的寬框就是你的熱點。

剖析告訴不了你的事(以及計數器能告訴你的事)

假設火焰圖很誠實,而單一個函式占了你 50% 的時間。你現在知道在哪裡,但不知道為什麼。兩個函式可以各自燒掉同樣的 CPU 時間,卻做著天差地別的事:一個正全速碾過算術,另一個幾乎全程停在那裡等一塊它在快取裡找不到的記憶體。火焰圖無法把這兩者分辨開——兩個看起來都只是「熱」。要看見差別,你得從函式層級往下掉到硬體,透過效能計數器(performance counter):CPU 維護的一小組暫存器,會隨真實的微事件跳動——退役的指令數、快取未命中、分支預測錯誤、週期數。

大家從這些數字裡最常引用的頭號指標是每週期指令數(instructions per cycle,IPC):CPU 每個時脈週期實際完成了多少指令。一個現代的超純量(superscalar)核心原則上每週期能退役 4 個以上,所以 IPC 為 3.5 意味著它運轉順暢、餵得飽飽的。一個「熱」函式的 IPC 是 0.4 則是一盞閃爍的紅燈:CPU 大部分時間在,把週期花在無所事事上,因為它需要的資料卡在主記憶體那邊。第二種情況是記憶體受限(memory-bound),而再多更聰明的算術都救不了它——解法是改變資料的擺放方式,那是這一級第 5 篇的主題。同一個 50% 寬的框,依底下計數器怎麼說,需要的是相反的修法。

誠實地剖析:不會騙你的設定

一份剖析的可信度,不會高過產生它的那一次執行,所以幾個設定錯誤就能讓上面每個數字一文不值。要剖析一個發行建置(release build),用你出貨的方式編譯——`gcc -O2` 或 `-O3`,而不是 `-O0`。一個未最佳化的建置有完全不同的熱點:那些最佳化器本會內聯掉的函式仍然存在、內聯從未發生,而你會去「最佳化」一段編譯器本來就要刪掉的程式碼。但這裡有個張力:`-O2` 也會剝掉剖析器替框命名所需的堆疊框與符號名稱。解法是同時帶著最佳化除錯資訊編譯——`gcc -O2 -g`——而且,如果你的程式碼用了預設被省略的框架指標(frame pointer),就加上 `-fno-omit-frame-pointer`,好讓抽樣器能走完堆疊。

第二個誠實要求,是一次有代表性、可重複的執行。一個剖析器盯著一支程式在玩具輸入上跑 50 毫秒,告訴你的是那個玩具、不是那個產品;先暖好快取、用真實的資料、跑得夠久,久到讓熱路徑壓過雜訊。這就是緊接著下一篇所專門講的可重現基準測試的紀律,因為一筆量測能悄悄說謊的方式——一個學會了你重複測試輸入的分支預測器、一顆先衝高時脈又在中途因過熱降頻的 CPU、在背景偷走快取的其他行程——既微妙又繁多。現在,先把這個形狀內化:同樣的建置、同樣的輸入、跑好幾次,並且不信任一個你只見過一次的數字。

這把你帶到這一級其餘部分的什麼位置?帶到一套工作流程,而不是一個花招。用你出貨的方式建置它、在真實資料上跑它、用 `perf` 抽樣它、讀火焰圖找那個頭重腳輕的寬框、用計數器確認為什麼,然後才改程式碼——接著再量一次來證明它。接下來四篇各自磨利這當中的一塊:怎麼替單一個函式做基準測試而不自欺、為什麼平均延遲藏起了使用者真正感受到的尾端延遲Amdahl 定律怎麼替「最佳化一個框」所能換到的好處設下天花板,以及最後,熱路徑上的配置與對快取不友善的擺放,怎麼變成本篇教你去找的那些瓶頸本身。它們底下的技能都是同一個:先量測,別用猜的。