性能优化的一般策略及方法
性能優(yōu)化的一般策略及方法
在汽車嵌入式開發(fā)領(lǐng)域,性能優(yōu)化始終是一個(gè)無法回避的問題:
- 座艙 HMI 想要實(shí)現(xiàn)更流暢的人機(jī)交互
- 通信中間件在給定的 CPU 資源下,追求更高的吞吐量
- 更一般的場(chǎng)景:嵌入式設(shè)備 CPU 資源告急,需要降低 CPU 使用率...
不同的工程師會(huì)從不同的角度給出不同的優(yōu)化建議:
- 有人關(guān)注系統(tǒng)調(diào)用情況
- 有人建議從算法和數(shù)據(jù)結(jié)構(gòu)入手
- 有人建議避免遞歸、循環(huán)嵌套
- 有人會(huì)從存儲(chǔ)器層次結(jié)構(gòu)出發(fā),建議修改代碼提高緩存命中率來提升性能
- ...
這些都是具體的代碼調(diào)優(yōu)技術(shù)/技巧,或許有效,但不夠系統(tǒng)。本文不討論具體的代碼調(diào)優(yōu)技術(shù),而是想介紹下具體代碼優(yōu)化技巧之上,更高層次的優(yōu)化策略。比起代碼級(jí)別的調(diào)優(yōu),可能效果更好,成本更低。
開始之前,需要強(qiáng)調(diào)下:
Premature optimization is the root of all evil. — Donald Knuth
一、性能概述
代碼調(diào)優(yōu)只是代碼性能優(yōu)化的方法之一,還有其他性能優(yōu)化的方法,也許效果更好、成本更低、對(duì)代碼的負(fù)面影響(降低可讀性/可維護(hù)性、引入 bug 等)也更少。
1.1 軟件質(zhì)量和性能
性能只是眾多軟件質(zhì)量標(biāo)準(zhǔn)中的一個(gè)。比起單純的代碼執(zhí)行速度,用戶可能更在意其他方面,比如穩(wěn)定可靠、簡(jiǎn)潔易用等。
性能也不只是代碼的執(zhí)行速度,過分追求代碼的執(zhí)行速度而忽略其他方面可能會(huì)影響整體性能及軟件質(zhì)量。
1.2 性能和代碼調(diào)優(yōu)
假如確定了把 Efficiency 作為首要目標(biāo),在代碼調(diào)優(yōu)之前,請(qǐng)優(yōu)先考慮:
- 性能需求
- 程序設(shè)計(jì)
- 類和方法設(shè)計(jì)
- 操作系統(tǒng)交互
- 編譯器優(yōu)化
- 硬件升級(jí)
- 代碼調(diào)優(yōu)
a. 性能需求
Barry Boehm 講過一個(gè)故事:某系統(tǒng)一開始要求亞秒級(jí)的響應(yīng)時(shí)間,導(dǎo)致非常復(fù)雜的設(shè)計(jì),預(yù)估成本 1 億美元。后來分析發(fā)現(xiàn),90%的情況下,用戶可以接受 4s 的響應(yīng)時(shí)間。重新修改需求之后,節(jié)省了 7000 萬美元。
再舉一個(gè)例子,自動(dòng)駕駛算法需要周期性獲取某些車輛數(shù)據(jù),當(dāng)前的需求是 10ms 的周期上報(bào)。如果將周期改為 20ms 仍然可以滿足需求,那么不需要任何額外的優(yōu)化,CPU 占用率便可減少一半。
解決性能問題之前,先確認(rèn)是否真的必要。
b. 程序設(shè)計(jì)
軟件架構(gòu)設(shè)計(jì)主要如何將程序分解到模塊/類。有的設(shè)計(jì)決定了很難實(shí)現(xiàn)高性能,有的設(shè)計(jì)則容易實(shí)現(xiàn)高性能。
在軟件的架構(gòu)設(shè)計(jì)中,設(shè)定資源占用的目標(biāo)很重要:如果每個(gè)組件都能達(dá)成目標(biāo),則整個(gè)系統(tǒng)自然也可以。如果某個(gè)組件無法達(dá)成目標(biāo),也可以及早發(fā)現(xiàn),進(jìn)行設(shè)計(jì)修改或代碼優(yōu)化。不僅如此,清晰的目標(biāo)也更利于執(zhí)行和實(shí)施。
c. 類和方法設(shè)計(jì)
在程序設(shè)計(jì)基礎(chǔ)上更近一步,深入到類的內(nèi)部。在這一層級(jí),我們可以選擇數(shù)據(jù)結(jié)構(gòu)和算法,從而影響程序的執(zhí)行速度和內(nèi)存占用。
d. 操作系統(tǒng)交互
如果程序中涉及外部文件、動(dòng)態(tài)內(nèi)存、輸出設(shè)備,通常會(huì)和操作系統(tǒng)交互。如果程序性能不好,有可能就是系統(tǒng)調(diào)用過多導(dǎo)致的。有時(shí)系統(tǒng)庫或編譯器會(huì)在你意想不到的地方產(chǎn)生系統(tǒng)調(diào)用。
e. 編譯器
編譯器優(yōu)化比手工優(yōu)化代碼效果更好,也更安全!某種程度上來說,選擇了正確的編譯器,基本就不需要考慮代碼級(jí)優(yōu)化了。
f. 硬件
有時(shí)候升級(jí)硬件是解決性能問題成本最低的方案。不僅節(jié)省了性能優(yōu)化的人力成本,同時(shí)還避免了由于性能優(yōu)化引入的一系列隱性成本。同時(shí),所有其他程序也因?yàn)橛布?jí)而得到性能提升。
g. 代碼調(diào)優(yōu)(Code Tuning)
“代碼調(diào)優(yōu)”指的是修改正確的代碼,使之運(yùn)行得更快。代碼調(diào)優(yōu)的前提是代碼正確:設(shè)計(jì)良好,易于理解和修改。“調(diào)優(yōu)”指的是小規(guī)模修改,一個(gè)類,一個(gè)函數(shù)或者幾行代碼。“調(diào)優(yōu)”不包括大規(guī)模設(shè)計(jì)修改,以及更高層次的性能優(yōu)化手段。
上面從程序設(shè)計(jì)到代碼調(diào)優(yōu)六個(gè)層級(jí)中,每一個(gè)層級(jí)都可能產(chǎn)生 10 倍的性能提升,不同層級(jí)的組合起來理論上可以有百萬倍的提升。雖然實(shí)際不可能在每個(gè)層級(jí)都取得 10 倍的提升,但是這里想表達(dá)的是,性能優(yōu)化的空間潛力是巨大的。
二、代碼調(diào)優(yōu)
2.1 二八法則
a. 優(yōu)化哪里
有研究和報(bào)告表明:
- 20% 的函數(shù)占用了 80% 的程序執(zhí)行時(shí)間
- <4% 的代碼甚至能占用 50% 的執(zhí)行時(shí)間
不是每一行代碼都要做到最快,真正值得花時(shí)間把性能調(diào)到極致的代碼只有很小的一部分!
b. 誰來優(yōu)化
項(xiàng)目中系統(tǒng)整體的 CPU 接近滿負(fù)荷,其中 A 負(fù)責(zé)的模塊 CPU 占用 5%,而 B 負(fù)責(zé)的模塊 CPU 占用超過 60%。即便 A 再厲害,把自己優(yōu)化沒了,帶來的整體收益也不過 5%,而 B 卻因?yàn)橛懈蟮膬?yōu)化空間,能輕松地地降低 10%的 CPU 占用。
2.2 常見誤區(qū)
很多過時(shí)的、傳說中的代碼優(yōu)化技巧都是無效的,甚至能夠產(chǎn)生負(fù)面影響。
誤區(qū) 1: 代碼行數(shù)越少,程序越快
很容易找到一個(gè)反例:初始化大小為 N 的數(shù)組,直接寫出 N 條賦值語句,其性能是循環(huán)賦值的 2.5~4 倍!
誤區(qū) 2: xxxx 寫法很很可能更快
對(duì)于性能而言,沒有所謂的“很可能”,必須實(shí)際測(cè)量才知道到底是“優(yōu)化”了還是“劣化”了。影響性能的因素很多:處理器架構(gòu)、編程語言、編譯器、編譯器版本、庫、庫的版本、內(nèi)存大小...“很可能”是非常不負(fù)責(zé)任的說法,對(duì)于特定的環(huán)境是優(yōu)化,在另外環(huán)境下很能就是劣化。再次強(qiáng)調(diào),必須要實(shí)際測(cè)量!
此外,為了“性能優(yōu)化”而引入的特殊寫法,反而會(huì)影響編譯器的優(yōu)化。
誤區(qū) 3: 從一開始就寫要出“快”的代碼
在程序沒最終完成之前,幾乎不可能識(shí)別出真正的性能瓶頸,你所“優(yōu)化”的代碼中,96%其實(shí)不需要優(yōu)化。過分關(guān)注執(zhí)行速度反而會(huì)影響軟件質(zhì)量的其他方面。
Premature optimization is the root of all evil. — Donald Knuth
誤區(qū) 4: “快”和“正確”同等重要
如果程序不能正確運(yùn)行,或者運(yùn)行結(jié)果不正確,即使再快也沒有任何價(jià)值。
2.3 什么時(shí)候去調(diào)優(yōu)
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.
簡(jiǎn)言之,非必要,不優(yōu)化。先保證良好的設(shè)計(jì),編寫易于理解和修改的整潔代碼。如果現(xiàn)有的代碼很糟糕,先清理重構(gòu),然后再考慮優(yōu)化。
2.4 編譯器優(yōu)化
現(xiàn)代編譯器優(yōu)化遠(yuǎn)比你想象中的更強(qiáng)大。例如編譯器能夠識(shí)別并優(yōu)化循環(huán)嵌套,比手動(dòng)優(yōu)化更安全,效果也更好。不要自作聰明地用一些幾十年前所謂的特殊“優(yōu)化技巧”,大概率會(huì)給編譯器造成困擾,適得其反。
-
各家的編譯器各有優(yōu)缺點(diǎn),選擇最適合項(xiàng)目的編譯器
-
開啟編譯器的不同優(yōu)化選項(xiàng),性能可提升為原來的 2 倍甚至更多
程序員應(yīng)該專注于寫整潔代碼(設(shè)計(jì)良好,意圖明確清晰,可讀性好,易于維護(hù)),優(yōu)化的事情交給編譯器就好啦!
三、導(dǎo)致性能問題的常見原因
3.1 常見性能問題元兇
a. 輸入/輸出操作
不必要的 I/O 操作是最常見的導(dǎo)致性能問題的罪魁禍?zhǔn)住1热珙l繁讀寫磁盤上的文件、通過網(wǎng)絡(luò)訪問數(shù)據(jù)庫等。一般來說,內(nèi)存的讀寫性能是磁盤的幾千幾萬倍,如果有內(nèi)存不是很 critical,可以將數(shù)據(jù)保存在內(nèi)存中以減少不必要的 IO 操作從而改善性能。
幾年前在一個(gè)基于 Qt 的座艙項(xiàng)目中,從 CarPlay 界面返回車機(jī)首頁會(huì)有短暫的卡頓,導(dǎo)致無法通過 CarPlay 的認(rèn)證。用 QmlProfiler 分析發(fā)現(xiàn),切換卡頓是由于從磁盤加載背景圖片導(dǎo)致的,將背景圖片緩存在內(nèi)存中,可以直接消除圖片加載時(shí)間,大幅提升界面切換的流暢度。代價(jià)是犧牲了一定的內(nèi)存,這是一個(gè)空間換時(shí)間的典型例子。
b. 缺頁
有一個(gè)經(jīng)典的例子:
// 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();
}
}
以上兩種寫法在特定場(chǎng)景下,性能差距可達(dá) 1000 倍。背后涉及到二維數(shù)組在內(nèi)存中的存儲(chǔ)方式以及緩存命中等知識(shí),CSAPP 的第 5、6 章對(duì)此有詳細(xì)闡述。
c. 系統(tǒng)調(diào)用
系統(tǒng)調(diào)用需要進(jìn)行上下文切換,保存程序狀態(tài)、恢復(fù)內(nèi)核狀態(tài)等一些步驟,開銷相對(duì)較大。對(duì)磁盤的讀寫操作、對(duì)鍵盤、屏幕等外設(shè)的操作、內(nèi)存管理函數(shù)的調(diào)用等都屬于系統(tǒng)調(diào)用。
Linux 系統(tǒng)調(diào)用可以通過 strace 查看,qnx 也有 tracelogger 等工具
d. 解釋型語言
一般來說,C/C++/VB/C# 這種編譯型語言的性能好于 Java 的字節(jié)碼,好于 PHP/Pyhon 等解釋型語言。這也是為什么汽車嵌入式領(lǐng)域還是 C/C++ 天下等主要原因。
e. 錯(cuò)誤
還有很大很一部分導(dǎo)致性能問題的原因可以歸為錯(cuò)誤:忘了把調(diào)試代碼(如保存 trace 到文件)關(guān)閉,忘記釋放資源/內(nèi)存泄漏、數(shù)據(jù)庫表設(shè)計(jì)缺陷(常用表沒有索引)等。
3.2 常見操作的相對(duì)開銷
| 操作 | 示例 | 相對(duì)耗時(shí)(C++) |
|---|---|---|
| 整數(shù)賦值(基準(zhǔn)) | i = j |
1 |
| 函數(shù)調(diào)用 | ||
| 普通函數(shù)調(diào)用(無參) | foo() |
1 |
| 普通函數(shù)調(diào)用(單參) | foo(i) |
1.5 |
| 普通函數(shù)調(diào)用(雙參) | foo(i,j) |
2 |
| 類的成員函數(shù)調(diào)用 | bar.foo() |
2 |
| 子類的成員函數(shù)調(diào)用 |
derivedBar.foo() |
2 |
| 多態(tài)方法調(diào)用 | abstractBar.foo() |
2.5 |
| 對(duì)象解引用 | ||
| 訪問對(duì)象成員(一級(jí)/二級(jí)) | i = obj1.obj2.num |
1 |
| 整數(shù)運(yùn)算 | ||
| 整數(shù)賦值/加/減/乘 | i = j * k |
1 |
| 整數(shù)除法 | i = j / k |
5 |
| 浮點(diǎn)運(yùn)算 | ||
| 浮點(diǎn)賦值/加/減/乘 | x = y * z |
1 |
| 浮點(diǎn)除法 | x = y / z |
4 |
| 超越函數(shù) | ||
| 浮點(diǎn)根號(hào) | y = sqrt(x) |
15 |
| 浮點(diǎn) sin | y = sin(x) |
25 |
| 浮點(diǎn)對(duì)數(shù) | y = log(x) |
25 |
| 浮點(diǎn)指數(shù) | y = exp(x) |
50 |
| 數(shù)組操作 | ||
| 一維/二維整數(shù)/浮點(diǎn)數(shù)組下標(biāo)訪問 | x = a[3][j] |
1 |
注:上表僅供參考,不同處理器、不同語言、不同編譯器、不同測(cè)試環(huán)境所得結(jié)果可能相差很大!
代碼調(diào)優(yōu)的方式之一就是用低開銷的操作替代高開銷操作。一般操作(賦值、函數(shù)調(diào)用、算數(shù)運(yùn)算)的開銷基本相同,除法運(yùn)算開銷較大,超越函數(shù)開銷尤其巨大,多態(tài)函數(shù)的調(diào)用較普通函數(shù)調(diào)用有一定額外開銷。
四、測(cè)量
代碼執(zhí)行耗時(shí)和代碼量不成比例,必須經(jīng)過測(cè)量才知道時(shí)間花在哪里。找到問題,優(yōu)化,重新測(cè)量。
性能優(yōu)化很多時(shí)候是反直覺的(比如代碼量越少不一定越快),只有測(cè)量了才知道是否有效果。
過往的經(jīng)驗(yàn)可能不會(huì)有太多幫助,針對(duì)舊的機(jī)器、語言、編譯器的優(yōu)化經(jīng)驗(yàn)在現(xiàn)在可能完全不適用,必須要實(shí)際測(cè)量了才知道!
比如在舊版本的編譯器中,把二維數(shù)組的操作轉(zhuǎn)為對(duì)單個(gè)指針操作可以提升性能,而在新的編譯器卻完全沒有效果,因?yàn)樾掳婢幾g器會(huì)自動(dòng)進(jìn)行這樣的轉(zhuǎn)化。而手動(dòng)修改代碼只會(huì)降低代碼的可讀性。
測(cè)量要準(zhǔn)確
- 用專門的 Profiling 工具或者系統(tǒng)時(shí)間
- 只測(cè)量你自己的代碼部分
- 必要時(shí)需要用 CPU 時(shí)鐘 tick 數(shù)來替代時(shí)間戳以獲得更準(zhǔn)確的測(cè)量結(jié)果
要想準(zhǔn)確的測(cè)量是一件非常困難的事情。不同的硬件、進(jìn)程的優(yōu)先級(jí)、線程調(diào)度策略、測(cè)量時(shí)其他的進(jìn)程的運(yùn)行、甚至外界環(huán)境都可能對(duì)測(cè)量結(jié)果產(chǎn)生影響。我們能做的就是盡可能地控制變量,剔除無關(guān)因素影響。
五、迭代
很難只用一個(gè)技巧就把性能提升 10 倍,但是可以不斷嘗試,組合不同技巧,最終實(shí)現(xiàn)巨大的性能提升。下面是一個(gè)通過不斷迭代優(yōu)化,將執(zhí)行時(shí)間從 21 分 40 秒優(yōu)化到 22 秒的例子:
| 優(yōu)化項(xiàng) | 執(zhí)行時(shí)間 |
|---|---|
| 初版,直接實(shí)現(xiàn) | 21:40 |
| bit 轉(zhuǎn)數(shù)組 | 7:30 |
| 展開最內(nèi)層 for 循環(huán) | 6:00 |
| 去除最終排列 | 5:24 |
| 合并 2 個(gè)變量 | 5:06 |
| 合并算法的前兩步 | 4:30 |
| 在內(nèi)層循環(huán)中,使兩個(gè)變量共享同一內(nèi)存 | 3:36 |
| 在外層循環(huán)中,使兩個(gè)變量共享同一內(nèi)存 | 3:09 |
| 展開所有循環(huán),使用字面量下標(biāo) | 1:36 |
| 去除所有函數(shù)調(diào)用,把代碼寫在一行 | 0:45 |
| 用匯編重寫整個(gè)函數(shù) | 0:22 |
六、調(diào)優(yōu)一般方法
- 程序設(shè)計(jì)良好,易于理解和修改(前提)
- 如果性能不佳:
a. 保存當(dāng)前狀態(tài)
b. 測(cè)量,找出時(shí)間主要消耗在哪里
c. 分析問題:是否因?yàn)楦邔釉O(shè)計(jì)、數(shù)據(jù)結(jié)構(gòu)、算法導(dǎo)致的,如果是,返回步驟 1
d. 如果設(shè)計(jì)、數(shù)據(jù)結(jié)構(gòu)、算法沒問題,針對(duì)上述步驟中的瓶頸進(jìn)行代碼調(diào)優(yōu)
e. 每進(jìn)行一項(xiàng)優(yōu)化,立即進(jìn)行測(cè)量
f. 如果沒有效果,恢復(fù)到 a 的狀態(tài)。(大多數(shù)的調(diào)優(yōu)嘗試幾乎不會(huì)對(duì)性能產(chǎn)生影響,甚至產(chǎn)生負(fù)面影響。代碼調(diào)優(yōu)的前提是代碼設(shè)計(jì)良好,易于理解和修改。Code tuning 通常會(huì)對(duì)設(shè)計(jì)、可讀性、可維護(hù)性產(chǎn)生負(fù)面影響,如果 tuning 改良了設(shè)計(jì)或者可讀性,那么不應(yīng)該叫 tuning,而是屬于步驟 1) - 重復(fù)步驟 2
七、總結(jié)
- 性能只是眾多軟件質(zhì)量指標(biāo)中的一個(gè),而且一般不是最重要的那個(gè)。精心調(diào)優(yōu)之后的代碼也只能對(duì)整體性能產(chǎn)生部分影響,程序架構(gòu)、詳細(xì)設(shè)計(jì)、數(shù)據(jù)結(jié)構(gòu)/算法的選擇、編譯器通常比代碼本身對(duì)性能的影響更大。
-
準(zhǔn)確地測(cè)量至關(guān)重要
- 絕大多數(shù)程序的大部分時(shí)間都耗在少數(shù)代碼上,只有測(cè)量了才知道時(shí)間花在了哪里,優(yōu)化重點(diǎn)在哪里
- 很多“優(yōu)化技巧”實(shí)際上不僅不會(huì)提高性能,甚至?xí)档托阅埽挥袦y(cè)量了才能知道
- 測(cè)量越接近真實(shí)環(huán)境越好,模擬的測(cè)試環(huán)境和程序?qū)嶋H運(yùn)行環(huán)境可能得到完全不同的結(jié)果!
- 通常需要多輪優(yōu)化迭代才能達(dá)到預(yù)期性能目標(biāo)
- 如果想為今后(可能)的性能優(yōu)化提前作準(zhǔn)備,最好的準(zhǔn)備就是編寫易于理解和修改的整潔代碼
7.1 檢查清單
- 明確需求,是否真的有這么高的性能要求?
- 嘗試提高編譯器優(yōu)化選項(xiàng)?
- 考慮升級(jí)/更換編譯器?
- 考慮過升級(jí)/更換硬件?
- 程序的 high-level design、類設(shè)計(jì)是否合理?
- 檢查是否有不必要的系統(tǒng)調(diào)用、I/O 操作?
- 考慮用編譯型語言替代解釋型語言?
- 代碼調(diào)優(yōu)是否作為最后手段?
7.2 代碼調(diào)優(yōu)方法
- 調(diào)優(yōu)的前提:代碼正確,設(shè)計(jì)良好,易于理解和修改
- 測(cè)量,找出瓶頸
- 每次優(yōu)化后,立即重新測(cè)量
- 如果沒有效果,撤銷改動(dòng)
- 嘗試多種方法,不斷迭代
八、擴(kuò)展閱讀
- 《CSAPP》第 5、6 章
- 《Code Complete》第 25、26 章
- 《C++ Core Guidelines》Per 章節(jié)
總結(jié)
以上是生活随笔為你收集整理的性能优化的一般策略及方法的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 【从零开始的ROS四轴机械臂控制】(七)
- 下一篇: Django笔记四十三之使用uWSGI部