一、前言
C++的性能真的比C語言的要差麼?人們通常所持的C++性能差的觀點是不正确的。确實,在一般情況下,如果把C語言和看起來與C語言相同的C++版本相比,前者通常要快一些。但同時兩種語言在表面上的相似性通常是基于它們的資料處理功能,而不是它們的正确性、健壯性和易維護性。我們的觀點是如果讓C語言程式在上述方面達到C++程式的級别,則速度差别就會消失,甚至可能是C++版本的程式更快。
C++不是天生就較慢或較快,這兩者都是有可能的,關鍵要看怎樣使用它以及想從它那裡得到什麼。這與如何使用C++有關系:運用得當的話,C++不僅可以讓軟體系統具備可接受的性能,甚至還可以獲得出衆的性能。
二、軟體低效的根源
圖檔來自《C++性能優化指南》
● 語言結構
C++在其原型C中增加了新能力和靈活性。這些新增的益處(eg:新特性、新文法)并不是白來的。某些C++語言結構可能會以産生開銷作為代價。
● 系統體系結構
不考慮系統體系結構開發軟體也很容易。然而要達到高性能,就不能無視體系結構的種種問題,因為它們在相當大的程度上影響到性能。
當提到性能時,我們必須記住以下幾點:
● 記憶體不是無限大的。虛拟記憶體系統使得記憶體看起來是無限的,而事實上并非如此。
● 記憶體通路開銷不是均衡的。對緩存、主記憶體和磁盤的通路開銷不在同一個數量級之上。
● 我們的程式沒有專用的CPU,隻能間歇地獲得一個時間片。
● 在一台單處理器的計算機上,并行的線程并不是真正地并行執行,它們是輪詢的。
● 庫
對庫的選擇與使用也會影響性能。為了性能提升,即便庫中已存在某種特殊功能的函數,您還是可以選擇自己去編寫一個版本。
設計庫時人們常常将靈活性和可重用性作為指導思想。一般來說,靈活性和可重用性與性能之間存在一種折中。如果認為某段代碼的性能比其靈活性和可重用性都重要,那麼使用自己的實作來替代庫提供的功能就是合理的。由于不同的程式有其各自特定的需求,是以很難設計出一個能夠對任何人、在任何地點和時間都提供完美實作的庫。
● 編譯器優化
絕大多數編譯器能夠完成許多“消除計算備援”的優化,但是不同編譯器的優化方式和優化效果不一樣,是以不能指望某一個特定的編譯器進行特定優化。為了最大程度地控制程式,您必須自己動手解決編碼問題。
三、性能優化觀點
代碼優化不會導緻項目的業務異常或項目延期,不能因程式設計惡習或逃避做優化代碼分析而不做優化工作。
個人建議:
項目應該提前整理好編碼checklist和編碼規範,包含常用的編碼注意點和編碼建議,這樣可以讓開發人員從一開始編碼就參考着編寫高品質規範的代碼。
需求分析和設計時,也要考慮記憶體占用、性能等各種非功能性需求。
編碼完成,項目進入內建測試階段,進行性能測試與代碼優化和代碼品質加強。
- 應從一開始就寫更加高效的代碼,編寫普通代碼和編寫高效代碼耗時差異是不大的。
- 優化不要過于教條,所有的軟體開發的最佳實踐都可以參考,但是不能因為其他項目中用了哪個算法或某個資料結構,就在新項目中也使用。需要根據實際分析出的問題點進行綜合考慮。
- 軟體開發世界中,存在大量“優化是不重要的,可以通過堆硬體提升性能”的錯誤認知。如今多核處理器的性能不斷強大,但是單個核心的性能增長卻非常緩慢,甚至有時還有所下降。另外還要考慮到不同系統的相容和遷移,是以,唯有優化可以讓程式永遠保持活力。
- 快速疊代的項目,性能優化可能需要結合多個或多次的優化點一起進行改善;
- 及時同步最新版本,可能新版本代碼中這個性能問題已經不存在,也可能存在更優先的性能問題。
- 性能特質傾向于高度的非直覺性,不要期望你每一單優化都有效果。
四、性能優化原則
- 性能優化點要測量,不要猜。需要根據實際分析出的問題點進行綜合考慮,不能僅僅因為猜測,或者大家都覺得那個方式快就一定用哪個方法。
- 并行系統中,核數并非越多越好,到達一定數量後系統整體性能提升有限
- 帕累托法則(二八原則)
80%的執行時間花在大約20%代碼身上;
80%的記憶體被大約20%的代碼使用;
80%的維護成本花在20%的代碼上面;
程式中隻有 20% 的代碼的性能是很重要的。是以,試圖修改程式中的每條語句去改善程式性能沒有必要,也不會有作用。要會去定位性能熱點或性能瓶頸。
五、性能測量工具
把性能問題進行量化,比如XX吞吐量、XX并發量、RTT資料是XX毫秒等。然後選擇合适的性能測試工具進行測試。
性能問題隻有量化後才友善進行對比分析。要不然隻是說“性能慢”沒有一個準确的判斷标準,也沒有一個準确的測試工具,是沒法進行分析和優化驗證的。
圖檔來自網絡
六、不同開發過程的性能優化點
6.1 設計
針對非功能性需求進行性能問題分析與定義,并進行對應的算法與資料結構設計,并對可能出現的性能問題、驗證方法等做好設計。
6.2 實作
①編碼:
1.考慮預先計算( 編譯期程式設計:模闆 、constexpr等)
2.考慮延遲計算( info log, copy-on-write )
3.考慮批量計算
4.盡量減少函數參數
②并發:
1.避免程序/線程間上下文切換
2.縮小臨界資源範圍
3.方法線程數量盡量不多于核數
6.3 編譯
編譯優化是成本收益比最好的優化手段。
1.選擇更好的編譯器及編譯器版本。使用合适的編譯選項。
2.Release考慮用回報式編譯
3.删除沒必要的虛函數與函數指針
4.考慮關閉異常處理。比如try catch
5.考慮關閉RTT監測
6.考慮使用編譯時多态替換運作時多态
七、關鍵優化點介紹
7.1 用好的編譯器并用好編譯器
每種編譯器為 C++ 語句生成的機器碼都有差别。它們所看到的優化機會是不同的,會為相同的源代碼産生不同的可執行檔案。如果打算為代碼做出最後一丁點性能提升,那麼你可以嘗試一下各種不同的編譯器,看看是否有一種編譯器會為你産生更快的可執行檔案。
C++有衆多編譯器,不同的編譯器優化效果不一樣;
每個編譯器的不同版本,優化效果也不一樣;
感興趣的同學可以在一些線上編譯器網站上檢視,比如“https://gcc.godbolt.org/”網站展示的編譯器有ZIG C++、ICX、ICC、GCC、CLANG、NVC++、MSVC、POWER等,就不一一列舉了。大家平時比較常用的可能都是gcc。不管使用哪一種編譯器,在做代碼優化時,最好在官網下載下傳對應的編譯器說明文檔,具體檢視裡面的優化選項細節。
關于如何選擇 C++ 編譯器的一條最重要的建議,是使用支援 C++11 的編譯器。 C++11 實作了右值引用(rvalue reference)和移動語義(move semantics),可以省去許多在以前的C++ 版本中無法避免的複制操作。
要用好編譯器,是否打開了合适的編譯選項。例如,檢查是否打開了編譯器的優化選項,比如-o1 、-o2 、-o3 、去掉-g等。
-O編譯選項說明:
O0選項不進行任何優化,在這種情況下,編譯器盡量的縮短編譯消耗(時間,空間),此時,debug會産出和程式預期的結果。當程式運作被斷點打斷,此時程式内的各種聲明是獨立的,我們可以任意的給變量指派,或者在函數體内把程式計數器指到其他語句,以及從源程式中 精确地擷取你期待的結果.
O1優化會消耗少多的編譯時間,它主要對代碼的分支,常量以及表達式等進行優化。
O2會嘗試更多的寄存器級的優化以及指令級的優化,它會在編譯期間占用更多的記憶體和編譯時間。
O3在O2的基礎上進行更多的優化,例如使用僞寄存器網絡,普通函數的内聯,以及針對循環的更多優化。
Os主要是對代碼大小的優化,我們基本不用做更多的關心。 通常各種優化都會打亂程式的結構,讓調試工作變得無從着手。并且會打亂執行順序,依賴記憶體操作順序的程式需要做相關處理才能確定程式的正确性。
多數情況下,隻要正确地打開了優化選項,你都不用做額外的優化,因為編譯器就可以讓程式的運作速度提高數倍。預設情況下,許多編譯器都不會進行任何優化,因為如果不進行優化,編譯器就可以稍微縮短一點編譯時間。
7.2 使用更好的算法
選擇一個最優算法對性能優化的效果最大。各種優化手段都能改善程式的性能。它們可以壓縮以前看似低效的代碼的執行時間,但是除非你能找到一種更加高效的算法,否則要想實作性能的指數級增長通常是不太可能的。
幾個改善程式性能的重要技巧,其中包括預計算(precomputation,将計算從運作時移動至連結、編譯或是設計時)、 延遲計算(lazy computation,如果通常計算結果不會被使用,那麼将計算推遲至真正需要使用計算結果時)和緩存(caching,節省和複用昂貴的計算)。
7.3 使用更好的庫
C++ 編譯器提供的标準 C++ 模闆庫和運作時庫是可維護的、全面的和非常健壯的。對進行性能優化的開發人員來說,掌握标準 C++ 模闆庫是必需的技能。
有一些開源庫實作了非常重要的功能。它們提供的複雜的實作可能比供應商提供的 C++ 運作時庫更快、更強。開發人員還可以開發适合自己項目的庫,通過放松标準庫中的某些安全性和健壯性限制來換取更快的運作速度。要想隐藏高度優化後的程式的複雜性,函數和類庫是非常合适的地方。
7.4 減少記憶體配置設定和複制
減少對記憶體管理器的調用是一種非常有效的優化手段,以至于開發人員隻要掌握了這一個技巧就可以變為成功的性能優化人員。絕大多數 C++ 語言特性的性能開銷最多隻是幾個指令,但是每次調用記憶體管理器的開銷卻是數千個指令。
對緩存複制函數的一次調用也可能消耗數千個 CPU 周期。是以,很明顯減少複制是一種提高代碼運作速度的優化方式。比如c++11支援的移動語義。
7.5 優化循環處理
單條 C++ 語句的性能開銷通常都很小。但是如果在循環中執行 上萬次這條語句,或是每次程式處理事件時都執行這條語句,那麼這就是個大問題了。絕大多數程式都會有一個或多個主要的事件處理循環和一個或多個處理字元的函數。找出并優化這些循環幾乎總是可以讓性能優化碩果累累。
7.6 使用更好的資料結構
選擇最合适的資料結構對性能有着深刻的影響,因為插入、疊代、排序和檢索元素的算法的運作時開銷取決于資料結構。除此之外,不同的資料結構在使用記憶體管理器的方式上也有所不同。
程式=資料結構+算法
好的資料結構,可以使用更合适的算法,進而減少記憶體占用,提高程式性能。
7.7 提高并發性
任何時候,如果一個程式的處理進度因需要等待某些事件被暫停,而沒有利用這些時間進行其他處理,都是一種浪費。
現代計算機都可以使用多個處理核心來執行指令。如果一項工作被分給幾個處理器執行,那麼它可以更快地執行完畢。伴随并發執行而來的是用于同步并發線程讓它們可以共享資料的工具。但是需要注意資料的線程安全性,比如C++的STL容器都不是線程安全的,如果需要做多線程處理,需要重寫容器或或其他特殊設計。
7.8 優化記憶體管理
記憶體管理器作為 C++ 運作時庫中的一部分,管理着動态記憶體配置設定。合理使用記憶體管理器,避免頻繁開辟和釋放空間,減少記憶體碎片,提高程式運作效率。
處理速度排序:cpu從寄存器讀取最快, 接下來是緩存 , 接下來是記憶體。
假設一個8核的cpu, 每個核都有自己獨立的L1 Cache和L2 Cache, 而L3 Cache是8核共享的。離核心越近, 等級越高, 速度越快, L1 Cache緩存最小, 速度最快。記憶體的資料會先加載到共享的L3 Cache中, 再加載到每個核心獨有的L2 Cache, 最後進入到最快的L1 Cache.
記憶體優化整體政策
- 一起使用的函數存儲在一起。函數的存儲通常按照源碼中的順序來的,如果函數A,B,C是一起調用的,那盡量讓ABC的聲明也是這個順序
- 一起使用的變量存儲在一起。使用結構體、對象來定義變量,并通過局部變量方式來聲明,都是一些較好的選擇。
- 合理使用位元組對齊, 讓一個CacheLine能擷取到更多有效的資料
- 動态記憶體配置設定、STL容器、string等,核心代碼處盡量不用
底層規範-寄存器
寄存器是cpu的組成部分, 是CPU内部用來存放資料的一些小型存儲區域,用來暫時存放參與運算的資料和運算結果。
1.盡量使用棧記憶體,但避免棧溢出
2.盡量使用無符号數
3.盡量避免使用浮點數,考慮用整形轉換
底層規範-緩存
緩存是cpu的一部分, 位于cpu中。在沒有緩存之前, cpu一直都是在記憶體中讀取資料的, 但由于兩者速度差異, cpu每次都要等記憶體的’回信’, 緩存的設計是用來解決cpu與記憶體速度差異問題.
1.順序存取資料
2.避免通路資料時緩存切換
3.避免位元組不對齊對緩存影響
底層規範-記憶體
記憶體又稱主存,也稱記憶體儲器和主存儲器。它用于暫時存放CPU中的運算資料,以及與硬碟等外部存儲器交換的資料。記憶體的運作決定計算機整體運作快慢。
1.盡量使用棧記憶體(L1 Cache,寄存器)
2.盡量避免全局變量/靜态變量
3.避免動态記憶體申請/釋放
4.避免使用STL容器類,或自定義記憶體配置設定器
5.避免使用string
6.避免沒必要的複制、指派( memset,memcpy)
底層規範-記憶體-避免動态記憶體申請/釋放
1.配置設定釋放需要尋找合适大小記憶體塊,會花費更多時間
2.配置設定釋放大小不同記憶體塊,易造成堆空間碎片化,降低緩存效率
3.堆空間碎片化,可能在不确定時間進行gc,使得性能不穩定
4.動态配置設定記憶體容易造成資料未對産,可能影響Cache
5.編譯器較難優化使用指針的代碼
6. 使用者需要確定申請釋放成對,避免記憶體洩漏導緻堆記憶體耗盡
7.使用者需要確定記憶體釋放後不能通路
底層規範-記憶體-vector
1.動态記憶體申請釋放(vector動态擴容)
2.調整大小時,複制所有存儲内容
3.考慮使用reserve避免頻繁申請記憶體
底層規範-記憶體-string
1.動态記憶體申請釋放
2.調整大小時,複制所有存儲内容
3.考慮避免頻繁動态申請
4.考慮使用C風格字元串替換
底層規範-記憶體- C+ +規範-避免沒必要的複制與指派
1.類定義中禁止不期望的複制
2.使用pass-by-reference-to-const 代替pass-by-value
3.在初始化清單中替代在構造函數體内初始化
4.使用複合運算符代替獨身運算符
5.傳回值優化( RVO)
6.考慮使用modern C++的移動語義