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