We should forget about small efficiencies, say about 97percent of the
time:premature optimization is the root of all evil.
我們應該在97%的時間忘記優化:過早優化是萬惡之源。
做C++,當然不能不關心性能。但是,什麼時候開始關心性能優化?2020全球C++及系統軟體技術大會中《C++性能調優縱橫談》的演講,現場座無虛席,好評連連。下面讓演講者,Boolan首席軟體咨詢師吳詠炜老師為大家揭秘。
國内知名 C++專家。曾任英特爾亞太研發中心資深系統架構師,近 30 年 C/C++系統級軟體開發和架構經驗。專注于 C/C++ 語言(包括 C++98/C++11/14/17/20)、軟體架構、性能優化、設計模式和代碼重用。長期擔任資深技術教練,具有豐富技術咨詢經驗。
引言
先說為什麼要用C++?摩爾定律推動計算機的性能不停提高,腳本語言大行其道。但是計算機的性能畢竟有限,到21世紀初,就不得不通過語言層面以及個人寫代碼的技巧等各方面來提升性能。
但是我們也無法做到100%優化,因為C++開發效率較低,如果想在整個代碼做優化,得不償失。原因我們看下面這個公式。裡面P代表優化的部分所占比例,Sp是對這部分P的性能提升大小。
舉兩個最簡單的資料說明:
①如果優化的部分有一個非常重要的函數,這個函數占到系統開銷的50%,這時,我們把這個部分的性能提升了50%,這種情況下,結果是提升了20%,這已經是一個非常好的成果。
②反之,如果有一個函數性能提升100%,如果在執行過程中隻占了系統開銷的1%(不管它占代碼總量多少),那即使這部分性能提升了100%,最後結果也隻提升了0.5%。
是以,很重要的一件基本的事情,就是要做性能測試。
測不準的問題
性能測試是一件很難的事情,也是一件非常有技巧的事情。以下面的簡單代碼為例,我們看一下memset和手工清零,性能有沒有差異,差異是多少?
下方展示了一個令人驚訝的測試結果
▼
根據示例代碼的測試結果我們可以看出,當優化開到 -O2時,memset居然比手工循環慢了10萬倍。memset在GCC8之下,開到 -O2不會被優化,仍會做memset,但編譯器會完全幹掉對buffer的寫入。這就是常見的陷阱。
那怎麼繞過測試測不準的問題?volatile可以使測試結果相對合理。
然而volatile本身會妨礙優化。我們看下方彙編代碼,80個單位元組的0,去掉volatile,在GCC10下直接做了5次的16位元組0寫入,而且沒有循環。這就是C++編譯器的優化魔法。
在前面的示例代碼裡,兩種方式在優化編譯下的性能,實際上是完全一緻的。
下面列舉了一些編譯器的優化魔法,在沒有同步原語的情況下,編譯器可以(通常為了性能)在(目前線程)結果不變的情況下自由地調整執行順序。比如局部變量可能被全部消除;而全局變量不會被優化沒,但是寫入的順序可能會調整,編譯器覺得怎麼友善怎麼寫入,隻要對外表現行為與程式的設計行為完全一緻。
例如:x = a; y = 2; 可以變為 y = 2; x = a
x=a,是從a裡面讀東西,寫到x,做了記憶體讀操作,再做記憶體寫操作。我們看彙編代碼,會發現會先做從a讀到eax,同時對y寫入讀,然後對x寫入,進而達到最高的并發性。
另外,volatile聲明會禁止編譯器進行相關優化。
對volatile變量的讀,編譯器肯定會生成讀語句;對volatile變量的寫,編譯器肯定會生成寫語句。這是一種很特殊的場景,是以一般用于驅動程式,記憶體映射檔案等,正常情況下volatile需要謹慎使用。特别需要指出的一點,volatile在C++和Java裡面的語義完全不一樣,在C++裡面沒有多線程同步的語義。
以上就是測試可能存在的坑,從防優化的角度我們總結出以下技巧:
性能測試方式
不管是鎖,還是額外函數調用,都會有額外開銷,尤其鎖的性能開銷是有點大的,是以我們需要比clock更好的進行性能測試的方式。通過分析測時長相關的函數,我們可以發現rdtsc是x86 和x64系統上的的首選計時方式。
需要注意,tsc的主屏頻率和CPU參考主頻不一定一緻,需要自己測試,或者從Linux裡面使用dmesg查找tsc的頻率資訊。
以rdtsc為計時方式,我們可實作一個性能分析器profiler,測量出函數調用和虛函數調用的額外開銷(不同的軟硬體會影響測試資料),可以發現開銷是很低的。
我們前面說的測試方式屬于插樁測試。插樁測試的開銷随測試範圍而變,雖然函數調用開銷較低,但依然存在開銷,而且測量出的時鐘周期都可能帶來問題,是以插樁本身可能影響測試結果,但是結果相對較為精确、穩定,适合對單個函數進行性能調優。
另外一種測試方式是采樣測試。采樣測試需要依賴于一個外部的東西,在程式的執行過程中,它會定期中斷程式,然後檢查調用棧,知道程式目前執行到哪裡,最後看百分比的分布,進而知道函數的大概比例。采樣測試比較優勢的地方,是總體開銷可控,而且适合用來尋找程式的熱點。
總結:整體找程式的熱點與問題在哪裡,用采樣測試;已經找到熱點,需要進行精細優化,用插樁測試。
關于采樣測試常用的一些工具。一個是GCC自帶的工具gprof,它是采樣結合了部分插樁,可以很快上手嘗試,但是因為總體效果不太好,是以并不推薦。比較推薦的Google的gperftools.
編譯的時候不需要做特殊處理,用普通的 -g和 -o參數就可以。執行的時候,可以在指令行上指定預加載profiler庫,再指定CPUPROFILE輸出到哪個檔案,然後執行代碼,這樣就可以生成test.prof檔案,最後再用google-pprof工具把test.prof生成輸出檔案,可以是svg、jpg、png之類的格式。
其中,SVG的效果會比較好一些,有層次關系、樹形結構,字型的大小代表了耗時百分比的高低,可以很清晰的看到整體執行的性能,進行分析。
性能優化
1、循環優化
循環會放大代碼中的低效率,是以不必要的反複執行的代碼要提到循環外面,否則會有額外的開銷。以下面這個糟糕代碼為例:
首先,strlen這個函數會被反複調用,其次,strlen是個很糟糕的函數,它的執行時間與你的字元串長度成正比。是以如果給了一個長的字元串,即使不考慮strlen本身的函數調用開銷的問題,也需要考慮是不是應該把這個長度随時随地帶在API裡,而不是調strlen來獲得它的長度。那這種問題如何優化?
長度不變的情況,在for循環的開頭初始化一下,然後後面就是循環寫入。這個優化也是GCC可能自動做的,當GCC能夠判定你肯定沒有在修改這個字元串的時候,它甚至可以幫你直接做到這一點。但是當你把s,一個char*,傳到另外一個函數去,GCC判定不了那個函數背後做了什麼,就無法優化。是以還是需要手工将優化寫出來,這是一種非常基本的優化方式。
如果長度可變的情況,原理是一樣的。可以先把長度儲存下來,然後在長度進行變化的時候,直接調整長度值。但是,如果字元串太長的話,我們仍需要做一些其他的優化操作,但是概念是一樣的,就是盡量避免重複做不必要的操作,這是最基本的優化思路。
2、多線程優化
在某些記憶體管理器裡,每次調用時會有個加解鎖的問題,而加解鎖是絕對的性能殺手。是以,
①能使用 atomic 就不用 mutex;
②如果讀比寫多很多,考慮使用讀寫鎖(shared_mutex)而不是獨占鎖(mutex);
③使用線程本地(thread_local)變量
3、算術表達式優化
下面這個等式,從代碼角度看是否成立?
這個地方的關鍵是是否使用了浮點數類型。浮點數的精度有限,這就意味着一個操作先做還是後做,可能會影響結果,編譯器就會保守處理,不敢輕易做優化。是以除非你開了 -Ofast,告訴編譯器可以不管 IEEE 浮點資料運算規則,在碰到浮點數的時候,要做一下手工處理。
![8db404dd08d445d4b6b550ada21a7a1f[1].png](
https://ucc.alicdn.com/pic/developer-ecology/01ae7a81131c4f8f9293a8be0e343346.png)不需要做的優化
1、移位和乘法,不需要開優化, -O0就會做。
檢視下方騷操作
2、提取公共表達式
3、略去本地變量的初始化
無用的初始化,編譯器會自動消掉。
以上就是吳詠炜老師在2020全球C++及系統軟體技術大會中分享的内容,這裡隻讨論了部分性能優化點,性能調優的手段還有很多,歡迎大家有問題交流讨論。
2021全球C++及系統軟體技術大會将于11月25-26日上海舉辦,吳詠炜老師會再次出席為大家帶來現代C++新特性技能分享,感興趣的小夥伴不要錯過哦!