效能最佳化的一般策略及方法

Zijian/TENG發表於2023-11-26

效能最佳化的一般策略及方法

在汽車嵌入式開發領域,效能最佳化始終是一個無法迴避的問題:

  • 座艙 HMI 想要實現更流暢的人機互動
  • 通訊中介軟體在給定的 CPU 資源下,追求更高的吞吐量
  • 更一般的場景:嵌入式裝置 CPU 資源告急,需要降低 CPU 使用率...

不同的工程師會從不同的角度給出不同的最佳化建議:

  • 有人關注系統呼叫情況
  • 有人建議從演算法和資料結構入手
  • 有人建議避免遞迴、迴圈巢狀
  • 有人會從儲存器層次結構出發,建議修改程式碼提高快取命中率來提升效能
  • ...

這些都是具體的程式碼調優技術/技巧,或許有效,但不夠系統。本文不討論具體的程式碼調優技術,而是想介紹下具體程式碼最佳化技巧之上,更高層次的最佳化策略。比起程式碼級別的調優,可能效果更好,成本更低。

開始之前,需要強調下:

Premature optimization is the root of all evil. — Donald Knuth

一、效能概述

程式碼調優只是程式碼效能最佳化的方法之一,還有其他效能最佳化的方法,也許效果更好、成本更低、對程式碼的負面影響(降低可讀性/可維護性、引入 bug 等)也更少。

1.1 軟體質量和效能

效能只是眾多軟體質量標準中的一個。比起單純的程式碼執行速度,使用者可能更在意其他方面,比如穩定可靠、簡潔易用等。

效能也不只是程式碼的執行速度,過分追求程式碼的執行速度而忽略其他方面可能會影響整體效能及軟體質量。

1.2 效能和程式碼調優

假如確定了把 Efficiency 作為首要目標,在程式碼調優之前,請優先考慮:

  • 效能需求
  • 程式設計
  • 類和方法設計
  • 作業系統互動
  • 編譯器最佳化
  • 硬體升級
  • 程式碼調優

a. 效能需求

Barry Boehm 講過一個故事:某系統一開始要求亞秒級的響應時間,導致非常複雜的設計,預估成本 1 億美元。後來分析發現,90%的情況下,使用者可以接受 4s 的響應時間。重新修改需求之後,節省了 7000 萬美元。

再舉一個例子,自動駕駛演算法需要週期性獲取某些車輛資料,當前的需求是 10ms 的週期上報。如果將週期改為 20ms 仍然可以滿足需求,那麼不需要任何額外的最佳化,CPU 佔用率便可減少一半。

解決效能問題之前,先確認是否真的必要。

b. 程式設計

軟體架構設計主要如何將程式分解到模組/類。有的設計決定了很難實現高效能,有的設計則容易實現高效能。

在軟體的架構設計中,設定資源佔用的目標很重要:如果每個元件都能達成目標,則整個系統自然也可以。如果某個元件無法達成目標,也可以及早發現,進行設計修改或程式碼最佳化。不僅如此,清晰的目標也更利於執行和實施。

c. 類和方法設計

在程式設計基礎上更近一步,深入到類的內部。在這一層級,我們可以選擇資料結構和演算法,從而影響程式的執行速度和記憶體佔用。

d. 作業系統互動

如果程式中涉及外部檔案、動態記憶體、輸出裝置,通常會和作業系統互動。如果程式效能不好,有可能就是系統呼叫過多導致的。有時系統庫或編譯器會在你意想不到的地方產生系統呼叫。

e. 編譯器

編譯器最佳化比手工最佳化程式碼效果更好,也更安全!某種程度上來說,選擇了正確的編譯器,基本就不需要考慮程式碼級最佳化了。

f. 硬體

有時候升級硬體是解決效能問題成本最低的方案。不僅節省了效能最佳化的人力成本,同時還避免了由於效能最佳化引入的一系列隱性成本。同時,所有其他程式也因為硬體升級而得到效能提升。

g. 程式碼調優(Code Tuning)

“程式碼調優”指的是修改正確的程式碼,使之執行得更快。程式碼調優的前提是程式碼正確:設計良好,易於理解和修改。“調優”指的是小規模修改,一個類,一個函式或者幾行程式碼。“調優”不包括大規模設計修改,以及更高層次的效能最佳化手段。

上面從程式設計到程式碼調優六個層級中,每一個層級都可能產生 10 倍的效能提升,不同層級的組合起來理論上可以有百萬倍的提升。雖然實際不可能在每個層級都取得 10 倍的提升,但是這裡想表達的是,效能最佳化的空間潛力是巨大的。

二、程式碼調優

2.1 二八法則

a. 最佳化哪裡

有研究和報告表明:

  • 20% 的函式佔用了 80% 的程式執行時間
  • <4% 的程式碼甚至能佔用 50% 的執行時間

不是每一行程式碼都要做到最快,真正值得花時間把效能調到極致的程式碼只有很小的一部分!

b. 誰來最佳化

專案中系統整體的 CPU 接近滿負荷,其中 A 負責的模組 CPU 佔用 5%,而 B 負責的模組 CPU 佔用超過 60%。即便 A 再厲害,把自己最佳化沒了,帶來的整體收益也不過 5%,而 B 卻因為有更大的最佳化空間,能輕鬆地地降低 10%的 CPU 佔用。

2.2 常見誤區

很多過時的、傳說中的程式碼最佳化技巧都是無效的,甚至能夠產生負面影響。

誤區 1: 程式碼行數越少,程式越快

很容易找到一個反例:初始化大小為 N 的陣列,直接寫出 N 條賦值語句,其效能是迴圈賦值的 2.5~4 倍!

誤區 2: xxxx 寫法很很可能更快

對於效能而言,沒有所謂的“很可能”,必須實際測量才知道到底是“最佳化”了還是“劣化”了。影響效能的因素很多:處理器架構、程式語言、編譯器、編譯器版本、庫、庫的版本、記憶體大小...“很可能”是非常不負責任的說法,對於特定的環境是最佳化,在另外環境下很能就是劣化。再次強調,必須要實際測量!

此外,為了“效能最佳化”而引入的特殊寫法,反而會影響編譯器的最佳化。

誤區 3: 從一開始就寫要出“快”的程式碼

在程式沒最終完成之前,幾乎不可能識別出真正的效能瓶頸,你所“最佳化”的程式碼中,96%其實不需要最佳化。過分關注執行速度反而會影響軟體質量的其他方面。

Premature optimization is the root of all evil. — Donald Knuth

誤區 4: “快”和“正確”同等重要

如果程式不能正確執行,或者執行結果不正確,即使再快也沒有任何價值。

2.3 什麼時候去調優

Jackson's Rule of Optimization:

Rule 1. Don't do it.

Rule 2 (for expert only). Don't do it yet -- that is, not until you have a perfectly clear and unoptimized solution.

簡言之,非必要,不最佳化。先保證良好的設計,編寫易於理解和修改的整潔程式碼。如果現有的程式碼很糟糕,先清理重構,然後再考慮最佳化。

2.4 編譯器最佳化

現代編譯器最佳化遠比你想象中的更強大。例如編譯器能夠識別並最佳化迴圈巢狀,比手動最佳化更安全,效果也更好。不要自作聰明地用一些幾十年前所謂的特殊“最佳化技巧”,大機率會給編譯器造成困擾,適得其反。

  • 各家的編譯器各有優缺點,選擇最適合專案的編譯器

  • 開啟編譯器的不同最佳化選項,效能可提升為原來的 2 倍甚至更多

程式設計師應該專注於寫整潔程式碼(設計良好,意圖明確清晰,可讀性好,易於維護),最佳化的事情交給編譯器就好啦!

三、導致效能問題的常見原因

3.1 常見效能問題元兇

a. 輸入/輸出操作

不必要的 I/O 操作是最常見的導致效能問題的罪魁禍首。比如頻繁讀寫磁碟上的檔案、透過網路訪問資料庫等。一般來說,記憶體的讀寫效能是磁碟的幾千幾萬倍,如果有記憶體不是很 critical,可以將資料儲存在記憶體中以減少不必要的 IO 操作從而改善效能。

幾年前在一個基於 Qt 的座艙專案中,從 CarPlay 介面返回車機首頁會有短暫的卡頓,導致無法透過 CarPlay 的認證。用 QmlProfiler 分析發現,切換卡頓是由於從磁碟載入背景圖片導致的,將背景圖片快取在記憶體中,可以直接消除圖片載入時間,大幅提升介面切換的流暢度。代價是犧牲了一定的記憶體,這是一個空間換時間的典型例子。

b. 缺頁

有一個經典的例子:

// BAD
for (int col = 0; col < MAX_COLUMNS; ++col) {
  for(int row = 0; row < MAX_ROWS; ++row) {
      table[row][col] = GetDefaultValue();
  }
}

// GOOD
for (int row = 0; row < MAX_ROWS; ++row) {
  for(int col = 0; col < MAX_COLUMNS; ++col) {
      table[row][col] = GetDefaultValue();
  }
}

以上兩種寫法在特定場景下,效能差距可達 1000 倍。背後涉及到二維陣列在記憶體中的儲存方式以及快取命中等知識,CSAPP 的第 5、6 章對此有詳細闡述。

c. 系統呼叫

系統呼叫需要進行上下文切換,儲存程式狀態、恢復核心狀態等一些步驟,開銷相對較大。對磁碟的讀寫操作、對鍵盤、螢幕等外設的操作、記憶體管理函式的呼叫等都屬於系統呼叫。

Linux 系統呼叫可以透過 strace 檢視,qnx 也有 tracelogger 等工具

d. 解釋型語言

一般來說,C/C++/VB/C# 這種編譯型語言的效能好於 Java 的位元組碼,好於 PHP/Pyhon 等解釋型語言。這也是為什麼汽車嵌入式領域還是 C/C++ 天下等主要原因。

e. 錯誤

還有很大很一部分導致效能問題的原因可以歸為錯誤:忘了把除錯程式碼(如儲存 trace 到檔案)關閉,忘記釋放資源/記憶體洩漏、資料庫表設計缺陷(常用表沒有索引)等。

3.2 常見操作的相對開銷

操作 示例 相對耗時(C++)
整數賦值(基準) i = j 1
函式呼叫
普通函式呼叫(無參) foo() 1
普通函式呼叫(單參) foo(i) 1.5
普通函式呼叫(雙參) foo(i,j) 2
類的成員函式呼叫 bar.foo() 2
子類的成員函式呼叫 derivedBar.foo() 2
多型方法呼叫 abstractBar.foo() 2.5
物件解引用
訪問物件成員(一級/二級) i = obj1.obj2.num 1
整數運算
整數賦值/加/減/乘 i = j * k 1
整數除法 i = j / k 5
浮點運算
浮點賦值/加/減/乘 x = y * z 1
浮點除法 x = y / z 4
超越函式
浮點根號 y = sqrt(x) 15
浮點 sin y = sin(x) 25
浮點對數 y = log(x) 25
浮點指數 y = exp(x) 50
陣列操作
一維/二維整數/浮點陣列下標訪問 x = a[3][j] 1

注:上表僅供參考,不同處理器、不同語言、不同編譯器、不同測試環境所得結果可能相差很大!

程式碼調優的方式之一就是用低開銷的操作替代高開銷操作。一般操作(賦值、函式呼叫、算數運算)的開銷基本相同,除法運算開銷較大,超越函式開銷尤其巨大,多型函式的呼叫較普通函式呼叫有一定額外開銷。

四、測量

程式碼執行耗時和程式碼量不成比例,必須經過測量才知道時間花在哪裡。找到問題,最佳化,重新測量。

效能最佳化很多時候是反直覺的(比如程式碼量越少不一定越快),只有測量了才知道是否有效果。

過往的經驗可能不會有太多幫助,針對舊的機器、語言、編譯器的最佳化經驗在現在可能完全不適用,必須要實際測量了才知道!

比如在舊版本的編譯器中,把二維陣列的操作轉為對單個指標操作可以提升效能,而在新的編譯器卻完全沒有效果,因為新版編譯器會自動進行這樣的轉化。而手動修改程式碼只會降低程式碼的可讀性。

測量要準確

  • 用專門的 Profiling 工具或者系統時間
  • 只測量你自己的程式碼部分
  • 必要時需要用 CPU 時鐘 tick 數來替代時間戳以獲得更準確的測量結果

要想準確的測量是一件非常困難的事情。不同的硬體、程式的優先順序、執行緒排程策略、測量時其他的程式的執行、甚至外界環境都可能對測量結果產生影響。我們能做的就是儘可能地控制變數,剔除無關因素影響。

五、迭代

很難只用一個技巧就把效能提升 10 倍,但是可以不斷嘗試,組合不同技巧,最終實現巨大的效能提升。下面是一個透過不斷迭代最佳化,將執行時間從 21 分 40 秒最佳化到 22 秒的例子:

最佳化項 執行時間
初版,直接實現 21:40
bit 轉陣列 7:30
展開最內層 for 迴圈 6:00
去除最終排列 5:24
合併 2 個變數 5:06
合併演算法的前兩步 4:30
在內層迴圈中,使兩個變數共享同一記憶體 3:36
在外層迴圈中,使兩個變數共享同一記憶體 3:09
展開所有迴圈,使用字面量下標 1:36
去除所有函式呼叫,把程式碼寫在一行 0:45
用匯編重寫整個函式 0:22

六、調優一般方法

  1. 程式設計良好,易於理解和修改(前提)
  2. 如果效能不佳:
    a. 儲存當前狀態
    b. 測量,找出時間主要消耗在哪裡
    c. 分析問題:是否因為高層設計、資料結構、演算法導致的,如果是,返回步驟 1
    d. 如果設計、資料結構、演算法沒問題,針對上述步驟中的瓶頸進行程式碼調優
    e. 每進行一項最佳化,立即進行測量
    f. 如果沒有效果,恢復到 a 的狀態。(大多數的調優嘗試幾乎不會對效能產生影響,甚至產生負面影響。程式碼調優的前提是程式碼設計良好,易於理解和修改。Code tuning 通常會對設計、可讀性、可維護性產生負面影響,如果 tuning 改良了設計或者可讀性,那麼不應該叫 tuning,而是屬於步驟 1)
  3. 重複步驟 2

七、總結

  • 效能只是眾多軟體質量指標中的一個,而且一般不是最重要的那個。精心調優之後的程式碼也只能對整體效能產生部分影響,程式架構、詳細設計、資料結構/演算法的選擇、編譯器通常比程式碼本身對效能的影響更大。
  • 準確地測量至關重要
    • 絕大多數程式的大部分時間都耗在少數程式碼上,只有測量了才知道時間花在了哪裡,最佳化重點在哪裡
    • 很多“最佳化技巧”實際上不僅不會提高效能,甚至會降低效能,只有測量了才能知道
    • 測量越接近真實環境越好,模擬的測試環境和程式實際執行環境可能得到完全不同的結果!
  • 通常需要多輪最佳化迭代才能達到預期效能目標
  • 如果想為今後(可能)的效能最佳化提前作準備,最好的準備就是編寫易於理解和修改的整潔程式碼

7.1 檢查清單

  1. 明確需求,是否真的有這麼高的效能要求?
  2. 嘗試提高編譯器最佳化選項?
  3. 考慮升級/更換編譯器?
  4. 考慮過升級/更換硬體?
  5. 程式的 high-level design、類設計是否合理?
  6. 檢查是否有不必要的系統呼叫、I/O 操作?
  7. 考慮用編譯型語言替代解釋型語言?
  8. 程式碼調優是否作為最後手段?

7.2 程式碼調優方法

  1. 調優的前提:程式碼正確,設計良好,易於理解和修改
  2. 測量,找出瓶頸
  3. 每次最佳化後,立即重新測量
  4. 如果沒有效果,撤銷改動
  5. 嘗試多種方法,不斷迭代

八、擴充套件閱讀

  • 《CSAPP》第 5、6 章
  • 《Code Complete》第 25、26 章
  • 《C++ Core Guidelines》Per 章節