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