C++性能優(yōu)化大局觀
發(fā)布時間:2024-01-30 13:41:33
C++ 可算是一種聲名在外的編程語言了。這個名聲有好有壞。從好的方面講,C++ 性能非常好,哪個編程語言性能好的話總?cè)滩蛔∫?C++ 來單挑一下。從壞的方面講,它是臭名昭著的復(fù)雜、難學(xué)、難用。
不管說 C++ 是好還是壞,不可否認(rèn)的是,C++ 仍然是一門非常流行且非常具有活力的語言。繼沉寂了十多年后發(fā)布語言標(biāo)準(zhǔn)的第二版——C++11——之后,C++ 以每三年一版的頻度發(fā)布著新的語言標(biāo)準(zhǔn),每一版都在基本保留向后兼容性的同時提供著改進(jìn)和新功能。
雖然在語言領(lǐng)域,也有Rust這樣的新語言在向 C++ 發(fā)起挑戰(zhàn),但是,不可否認(rèn)的是,C++ 仍然是面向性能的領(lǐng)域里的編程語言王者。我甚至不認(rèn)為 C++ 在性能方面次于 C——在極致追求速度時,C++ 可以比 C 更強(qiáng),而 C 相比 C++ 的主要優(yōu)點(diǎn)是更加簡單:不管是學(xué)習(xí)、使用,還是產(chǎn)生的二進(jìn)制代碼的體積上。
今天,我們就來大略討論一下,C++ 是如何做到高性能的。
Bjarne 老爺子認(rèn)為 C++ 最主要的特點(diǎn)在于以下兩方面的關(guān)注:
跟 C 語言一樣,C++ 提供非常底層的數(shù)據(jù)操作能力,為開發(fā)者提供了靈活性。跟“高級”語言一樣,C++ 提供了強(qiáng)大的抽象能力(可以說超越了大部分語言)。而且,相比 C,C++ 要安全得多。在語言誕生的初期就是如此,現(xiàn)在就更不用說了。
C++ 的類型系統(tǒng)比 C 更加嚴(yán)格,因此雖然一直有 C++ 是 C 的超集的說法,這個說法嚴(yán)格來說從來就沒成立過。最近(2023 年)碰到過一個程序崩潰的案例,簡化來講,就是開發(fā)者使用了一個 char 的二維數(shù)組(char names[MAX_NAMES] [MAX_NAME_LEN]),然后把它傳給了一個接收 char** 參數(shù)的函數(shù)……這代碼當(dāng)然是錯的,但 C 編譯器雖然給了個告警,但編譯還是沒有失敗。如果這是 C++ 代碼的話,那編譯器就會直接報告錯誤,不給通過了。
而第二點(diǎn),零開銷抽象,對于 C++ 的性能至關(guān)重要。我們有很多的抽象機(jī)制,同時,使用這些抽象機(jī)制并不會帶來額外的開銷。在某些情況下,使用這些機(jī)制,反而有“負(fù)開銷”—— “使用者”可以非常安全地使用這門語言,即可獲得極高的性能。同時,C++ 還給予 了“定制者”根據(jù)自己的需求來寫出更貼近使用場景的庫的能力,可以進(jìn)一步方便“使用者”。
當(dāng)然,定制對程序員的技能有非常高的要求。初學(xué) C++ 的更需要掌握 C++ 的標(biāo)準(zhǔn)庫的使用——用好標(biāo)準(zhǔn)庫,就能獲得非常不錯的性能。正如高德納大神的名言的完整版:
而 C++ 已經(jīng)提供相當(dāng)多的機(jī)制,可以允許我們很容易地獲取高性能,在很多場景下遠(yuǎn)遠(yuǎn)超過高德納所說的 12%。
舉個例子, C++ 標(biāo)準(zhǔn)庫的sort和 C 標(biāo)準(zhǔn)庫的qsort:在關(guān)閉優(yōu)化時,在某一測試場景下得到了 1:2.5 的性能差異,C++ 似乎要慢不少;但一旦打開 -O2(允許內(nèi)聯(lián))時,兩者的性能差異突變成 3.5:1,C++ 的性能比 C 高出了好幾倍!這就是所謂的“負(fù)開銷”了。C++ 的代碼比 C 的更簡單、更直觀,性能還更高。原因自然就是 C++ 的函數(shù)對象和模板機(jī)制允許編譯器更好地進(jìn)行內(nèi)聯(lián),從而產(chǎn)生更加高性能的代碼。
因此,學(xué)會用好 C++ 的第一步是用好 C++ 的基本機(jī)制和標(biāo)準(zhǔn)庫,了解標(biāo)準(zhǔn)庫的不同機(jī)制的性能開銷,包括時間和空間。
任何情況下學(xué)習(xí) C++,第一需要了解的就是析構(gòu)函數(shù)和 RAII(resource acquisition is initialization)慣用法。對,雖然 C++ 誕生時名字是“帶類的 C”,但類和面向?qū)ο蟛⒉坏韧?,對面向?qū)ο缶幊痰闹С植⒉皇?C++ 的最重要特性。C++ 的自定義類型的最特別之處不在多態(tài),而在對其行為的定制上——最重要的就是對象銷毀時應(yīng)該做些什么。析構(gòu)函數(shù)和析構(gòu)函數(shù)帶來的 RAII 慣用法,是 C++ 里最重要的特性,也是用 C++ 進(jìn)行資源管理的關(guān)鍵。
重載是另外一個非常重要的 C++ 特性。除了你不用在名字上區(qū)分 process_char、process_string、process_int 帶來的方便性外,它對泛型編程也很重要,還對現(xiàn)代 C++ 的一個基本特性“移動語義”非常重要。刨除語法上的細(xì)節(jié),本質(zhì)上來說,移動語義就是讓程序員可以方便地區(qū)分會繼續(xù)使用的對象和以后不再使用的對象,允許對后者使用構(gòu)造函數(shù)和賦值運(yùn)算符的重載來“竊取”其中的資源。對于一個普通的 vector,拷貝的開銷是 O(n) 或更高(如果 vector 成員是容器或其他具有高拷貝開銷的對象),但移動開銷通常(是,只是通常;不過通常你也不會遇到這種例外的特殊情況)是 O(1),常數(shù)復(fù)雜度。這就是我們在 C++ 里高效傳遞對象的一種常見方式了。
C++ 標(biāo)準(zhǔn)庫里最常用的組件恐怕就是 string 和各種容器了。它們都對移動進(jìn)行了優(yōu)化。當(dāng)然,除了這個基本的性能點(diǎn)外,容器都有各自的特殊性能點(diǎn),比如不同情況下的插入性能差異。這些都是需要學(xué)習(xí)的地方。
比如,vector 在尾部插入性能比較好,在中間插入性能比較差。不過,更進(jìn)一步的是,你需要知道,尾部插入性能好的前提條件是元素的類型對移動有很好的實(shí)現(xiàn),并且移動構(gòu)造函數(shù)聲明成了 noexcept!如果你實(shí)現(xiàn)了開銷為 O(1) 的移動構(gòu)造函數(shù),但忘了把它聲明為 noexcept,那仍然是白搭,vector 的尾部插入仍然有性能問題。
又如,list 不管從開頭、結(jié)尾還是中間插入,都具有很高的性能。但是,對于相同元素的 list 和 vector,list 的遍歷性能可能要差一個數(shù)量級。這個原因就不完全是 C++ 的知識點(diǎn)了,而是跟硬件的緩存組織相關(guān)。如果我們關(guān)心性能的話,這些都是需要了解的地方。
前面我們已經(jīng)提到過模板,而 string 和容器也都是模板,行為可以通過模板參數(shù)來進(jìn)行定制,并允許高效的內(nèi)聯(lián)優(yōu)化。模板當(dāng)然是 C++ 里比較復(fù)雜的一個地方,但基本的使用則相當(dāng)簡單:vector 就是一個放 int 的 vector,用起來跟一個普通的類沒有區(qū)別——只是模板創(chuàng)建者的工作簡單了,不需要手工為不同的類型創(chuàng)建不同的類。
用好 C++、在項目中獲得令人滿意的性能 當(dāng)然不止上面這一些。最基本的,我們還需要了解標(biāo)準(zhǔn)庫算法,并合適地使用并發(fā)和并行來充分利用硬件。在本文中我們暫且就不展開了。
當(dāng)我們用熟了 C++ 之后,慢慢地,我們就會不再滿足于 C++ 標(biāo)準(zhǔn)庫這一“制式武器”。我們會尋找適合自己的第三方庫,甚至自己造輪子來滿足項目的特定需求。此時,我們就需要進(jìn)一步了解 C++ 的高級特性。我們需要了解模板的進(jìn)一步細(xì)節(jié),尤其是特化。我們需要了解 SFINAE 和模板元編程。我們需要了解 constexpr 和它帶來更方便的編譯期編程。C++ 的使用者也許可以暫時不關(guān)心這些問題,但定制者,或者說項目里的框架搭建者和工具提供者,必須去了解 C++ 的這些高級特性,為你的項目提供扎實(shí)的基礎(chǔ)。
舉個例子,C++ 的標(biāo)準(zhǔn)庫提供了 list,雙向鏈表。這個庫沒啥問題,但在某些使用場景下,它的時間和空間開銷都不令人滿意,比如我們的對象除了正常的管理,還需要一個額外的 LRU(least recently used)算法來拋棄其中最老的項。你當(dāng)然可以使用 list,但每次插入操作都需要插入一個對象,除了有堆內(nèi)存分配開銷,你還需要考慮在這個 list 里到底存什么。也許用智能指針?情況是不是越搞越復(fù)雜了?
這種情況下,最合理的選擇是使用某種 intrusive_list,侵入式的鏈表,不需要在每次插入或刪除時進(jìn)行內(nèi)存管理。C++ 標(biāo)準(zhǔn)庫沒有提供這個功能。你可以使用 Boost 里提供的容器,或者自己寫一個新的。對于這個例子,Boost 多半就足夠好了。但總可能出現(xiàn)一些現(xiàn)成庫解決不了的問題的,這時候,利用 C++ 的高級特性來自己造輪子就是一件非常自然的事。我們可以做到既有合適的定制,同時用法又跟已有的容器相似,沒有額外的學(xué)習(xí)成本。
或者,也許你希望使用分配器來創(chuàng)建一個容器內(nèi)存池,來提供對內(nèi)存的使用效率。這在 C++ 里也是非常容易完成的,只要你了解合適的定制機(jī)制。根據(jù)洋蔥原則,你可以不管這些定制點(diǎn),直接用 C++,這樣最簡單;也可以把標(biāo)準(zhǔn)庫“切開”,以自己最喜歡的方式來拼接定制使用——當(dāng)然,這種做法確實(shí)跟切洋蔥一樣,很容易就會哭鼻子的。但它確實(shí)能幫助你獲得最高的可能性能。
以上為本次所有分享內(nèi)容