看到 Google 的 Jeff Dean 和 Sanjay Ghemawat 公開了一份 Performance Hints 文件,把他們多年來在 Google 做效能調校的經驗整理成一份系統性的指南。雖然範例以 C++ 為主,但很多原則是跨語言通用的,對寫應用的工程師也很有參考價值。

以下摘一些我覺得對 application developer 特別實用的重點:

1. 別等到最後才想效能

Knuth 那句「premature optimization is the root of all evil」常被斷章取義。完整的原文其實是說: 97% 的時間不用管小效能,但那關鍵的 3% 不該放過。

Jeff 和 Sanjay 特別強調一個反直覺的觀點: 很多人說「先寫簡單的,之後再 profile 來優化」,但如果整個系統開發過程都不管效能,最後你會得到一個「flat profile」— 效能損失分散在各處,沒有明顯的 hotspot,反而不知道從哪裡下手。

更實際的建議是: 寫程式的時候,如果有兩種寫法,效能好的那個不會增加太多複雜度,就直接選效能好的。不需要刻意優化,但也不要刻意忽略。

2. 學會做 Back-of-the-Envelope 估算

在寫程式之前,先粗略估算一下不同方案的效能差距,可以快速排除不可行的選項。文中更新了 Jeff Dean 經典的「Latency Numbers Every Programmer Should Know」:

操作 大約耗時
L1 cache 存取 0.5 ns
L2 cache 存取 3 ns
Mutex lock/unlock (無競爭) 15 ns
主記憶體存取 50 ns
從 SSD 讀 4KB 20 µs
同機房網路來回 50 µs
從記憶體循序讀 1MB 64 µs
從 SSD 讀 1MB 1 ms
磁碟 seek 5 ms
從磁碟循序讀 1MB 10 ms
跨洋網路來回 (加州↔荷蘭) 150 ms

建議你也整理一份自己系統常用操作的延遲數字,例如: 一次 SQL 查詢多久、一次 API call 多久、一次頁面渲染多久。沒有這些數字,就沒辦法做有效的估算。

3. Profile 是 flat 的怎麼辦?

當你已經把明顯的 hotspot 都處理完了,profile 看起來很平,沒有突出的瓶頸。文中給了幾個方向:

  • 累積小改善: 20 個各 1% 的改善加起來就是很可觀的進步
  • 從 flame graph 的上層找迴圈: 看看能不能重構呼叫方式,例如把逐筆處理改成批次處理
  • 退一步看結構性問題: 不要只盯著微觀優化,想想有沒有演算法層面的改進空間
  • 找過度通用的程式碼: 例如用 regex 做的事情其實用簡單的字串前綴比對就夠了
  • 減少記憶體分配次數: 拿一份 allocation profile 看看,每次分配都可能造成 cache miss

4. 提供 Bulk API

這點對寫後端服務的人特別有感。如果你的 API 支援批次操作,可以:

  • 攤提 lock 的開銷 (拿一次鎖處理一批,而不是每筆都拿鎖)
  • 減少跨邊界的呼叫次數 (RPC、函式呼叫等)
  • 利用批次化的演算法優勢

例如文中一個例子: 把 DeleteRef 一次刪一筆,改成 DeleteRefs 一次刪一批,內部只拿一次鎖就搞定所有刪除。這個模式在寫 API 和 SDK 時非常常見也非常有效。

5. 避免不必要的複製和分配

這幾個技巧適用於任何語言:

  • 重複使用暫存物件: 迴圈裡面宣告的變數每次都會重新建立和銷毀。把宣告提到迴圈外面,呼叫 clear() 重複使用,可以省下大量分配
  • 預先 reserve 容器大小: 如果你知道 list 大概會有多少元素,先預留空間避免多次擴容
  • 用 move 取代 copy: 如果資料不需要保留原本的,用移動語意
  • 存指標或索引而非複製: 如果只是暫時需要引用某個物件,不要複製整個物件

6. 避免不必要的工作

這大概是最直覺也最有效的類別:

🔹 Fast path 快速路徑: 大部分情況下走簡單的快速路徑,只有少數例外才走完整邏輯。例如 push_back 大多時候容量是夠的,resize 是少數情況

🔹 延遲計算 (Lazy evaluation): 不要急著算,等真的需要時再算。文中有個例子: 一個 GetSubSharding 呼叫從 43 秒降到 2 秒,只因為把它從「先算再判斷要不要用」改成「先判斷需不需要,需要才算」

🔹 預計算 (Precompute): 反過來,如果某個值會被重複用到,先算好存起來。例如建一個 256 元素的查表陣列,避免每次都呼叫昂貴的計算函式

🔹 把昂貴的計算移出迴圈: 迴圈邊界條件如果每次迭代都重算,提出來算一次就好

7. 選對資料結構

文中花了很大篇幅講資料結構的選擇,核心思想是減少 cache miss:

  • 用陣列代替 map: 如果 key 是小整數或 enum,直接用陣列索引,O(1) 存取
  • 用 bit vector 代替 set: 如果 set 的元素是有限範圍的整數,用位元向量做集合運算 (AND、OR) 比用 hash set 快非常多。文中 Spanner 的例子改完提升了 30%
  • 扁平化巢狀 map: map<a, map<b, c>> 可以改成 map<pair<a,b>, c>,減少分配和 cache 壓力
  • 用 hash table 取代排序後交集: O(N) 打敗 O(N log N)

8. API 設計要留效能空間

幾個值得記住的原則:

  • 模組介面要「深」: 用窄介面包裝大量功能,這樣內部可以自由優化而不影響使用者
  • 別輕易加功能: 每個新功能都是對未來實作的限制。例如 C++ 標準容器保證 iterator 穩定性,導致實作上必須多做很多分配,但大多數使用者其實不需要這個保證
  • Thread-compatible vs Thread-safe: 大多數型別應該是 thread-compatible (外部同步),讓不需要執行緒安全的使用者不用付出額外代價

總結

這份文件最大的價值不在於具體的 C++ 技巧,而在於它提供了一套思考效能問題的框架: 先估算、再量測、選對資料結構和演算法、避免不必要的工作、設計好 API 留有優化空間。

Jeff Dean 和 Sanjay Ghemawat 用幾十年的實戰經驗告訴我們: 效能不是事後才想的事,而是寫程式時就該有的思維習慣。不需要過度優化,但要有意識地做出好的選擇。

原文全文: https://abseil.io/fast/hints.html