天天看點

《高性能科學與工程計算》——2.5 C++優化

本節書摘來自華章計算機《高性能科學與工程計算》一書中的第2章,第2.5節,作者:(德)georg hager gerhard wellein 更多章節内容可以通路雲栖社群“華章計算機”公衆号檢視。

目前,有大量關于如何編寫高效c++代碼的文獻[c92,c93, c94, c95]。我們的目标不是取代它們。是以我們特意忽略了引用計數、寫時複制、智能指針等關鍵技術。本節以循環代碼為例,根據我們的經驗指出c++程式設計中經常存在的性能錯誤和誤解。

c++程式設計存在着一個根深蒂固的假象:編譯器應該能夠識别進階c++程式包含的所有抽象和代碼混淆。首先,c++是一門支援複雜管理的進階程式設計語言,且自身特征明顯(如運算符重載、面向對象、自動建構/銷毀等)。然而,這些特征絕大多數都不适合編寫高效的低層次代碼。

2.5.1 臨時變量

c++具有一個“隐式”的程式設計風格:自動機制為程式員隐藏了c++程式設計的複雜性。然而,在表達式含有運算符重載鍊時,經常會出現一個問題。例如,假設有一個表示三維向量的類vec3d,該類實作了算術運算符重載以支援更有表現力的編碼:

《高性能科學與工程計算》——2.5 C++優化

這裡我們隻給出了vect3d::operator+和友元函數vect3d::operator*(與一個标量相乘)的實作。其他有用函數都以類似的方式定義。注意這裡隻給出了複制構造函數和指派運算符的函數聲明,這兩個函數都是隐式定義的。因為對于這個類來說,預設的複制和指派操作已經足夠了。

下面代碼段作為一個啟發式的例子,說明了當類被調用時,背後究竟發生了什麼:

《高性能科學與工程計算》——2.5 C++優化

在這個執行個體中,會按順序逐漸發生如下操作:

1)調用構造函數執行個體化a、b、c、d對象(根據參數調用相應的構造函數)。

2)調用operator*(x, b)函數。

3)調用構造函數初始化operator*(double s, const vec3d& v)中的tmp變量(這裡,我們沒有使用預設構造函數,而是選擇了更加高效的接受三個參數的構造函數)。

4)因為在函數operator*(double, const vec3d&)傳回時,tmp變量會被銷毀。是以vect3d的複制構造函數被調用,以建立一個臨時變量存儲tmp結果,并将其作為“加”運算的第一個參數。

5)調用operator*(y, c)。

6)調用構造函數初始化operator*(double s, const vec3d& v)中的tmp變量。

7)因為函數operator*(double, const vec3d&)傳回時,tmp變量會被銷毀。是以vect3d的複制構造函數被調用,以建立一個臨時變量存儲tmp結果,并将其作為“加”運算的第二個參數。

8)第一個臨時對象調用vec3d::operator+(const vec3d&)函數,第二個臨時對象作為其參數。

9)調用預設構造函數,初始化vec3d::operator+函數中的tmp對象。

10)調用vec3d的複制構造函數,完成運算結果的臨時拷貝操作。

11)調用vect3d的指派運算符,臨時拷貝作為其參數。

盡管編譯器可能會使用所謂的傳回值優化消除本地變量tmp[c92],而直接使用隐式臨時變量而不是tmp。然而,完成一個看起來如此簡單的表達式所要執行的代碼數量是如此的複雜(使用調試工具可檢視相關詳細資訊)。對此,一個直接的優化政策是使用複合計算或指派運算符(以犧牲可讀性為代價),如+=:

《高性能科學與工程計算》——2.5 C++優化

https://yqfile.alicdn.com/c6c2b734e5e67cdfe7f8c8b1b70e04f9d62c171e.png" >

這裡仍然需要兩個臨時變量将operator*(double, const vec3d&)函數的結果傳回到主函數。但是它們直接被指派運算符和vec3d::operator+=應用,這樣就不需要第三個臨時變量。該優點在較長操作鍊中展現得更加明顯。

然而,即使處理臨時變量(比如,調用複制構造函數)消耗了大量的計算時間,标準函數剖析檔案(見2.1.1節内容)也不一定能将此清楚顯示。c++編譯器非常擅長函數内聯,由此會引發許多“神奇”的事情:比如一個包含複雜表達式函數的獨立運作時間。在這種情況下,禁用函數内聯功能(雖然一般情況下不支援這麼做)可能會得到更多的資訊。然而這樣會嚴重幹擾剖析結果。

盡管會積極使用内聯功能,編譯器也不太可能生成“最優”代碼。其生成的代碼大緻上是這樣的:

《高性能科學與工程計算》——2.5 C++優化

https://yqfile.alicdn.com/96cbf2743fd9e45b735d26d6fe1174d07d5237ed.png" >

表達式模闆(expression template)[c96,c97]是一種先進的程式設計技術,應該可以解決很多臨時變量引發的性能問題。實際上,通過進階表達式它也會生成這樣的代碼。

應該明确的是,c++内聯功能不是為了生成最優代碼,而是要彌補因語言規範導緻的最嚴重的性能損失。受記憶體帶寬甚至cache帶寬或者算術吞吐量限制的循環代碼,最好用c或者fortran編寫(2.5.3節将進行詳細讨論)。

2.5.2 動态記憶體管理

c++代碼中另一個常見的性能瓶頸是頻繁的記憶體配置設定和釋放。上節讨論的vec3d類,由于沒有涉及動态記憶體,是以不存在大量記憶體配置設定(釋放)的問題。如果我們選擇一個類似于vec3d但所占記憶體空間可變的類,其構造函數和析構函數會分别調用malloc()和free()函數。是以,臨時變量對性能的影響會更加嚴重。而标準庫函數并沒進行最佳性能優化,是以會嚴重損害程式的整體性能。這就是c++程式員竭盡全力試圖減小記憶體配置設定和釋放對性能影響的原因。

上節讨論的是避免臨時變量而采取的其中一個關鍵措施。除此之外,還有另外兩個有效政策:延遲構造和靜态構造。這兩個政策看起來是對立的,但它們都是有用的政策。

1.延遲構造

将c++作為“第二語言”的c程式員一般會在函數的開始就聲明所有變量,而不是需要時才聲明。前者是c語言所需的,隻要使用的是基本資料類型就不存在性能問題。然而,要盡量避免“昂貴”的構造函數如下所述:

《高性能科學與工程計算》——2.5 C++優化

盡管使用變量v的機率可能會非常低(依賴于threshold),但第2行代碼還是無條件對變量v進行了聲明。一個更好的方案是在需要它時再聲明:

《高性能科學與工程計算》——2.5 C++優化

https://yqfile.alicdn.com/22ee2ebe77d57db360c1ed84b47b656472fabdbf.png" >

這樣編寫代碼的另一個好處是:可以直接調用std::vector<>(第3行)的複制構造函數。而不像之前那樣:首先調用構造函數(帶int型參數),然後再調用指派運算符。

2.靜态構造

如果對象的使用非常頻繁,将其構造放在循環或者代碼塊的外面,或者聲明為static變量,其性能可能會比延遲構造要高得多。如上例,如果數組的長度是個常量且threshold值接近1,那麼靜态配置設定可使構造開銷忽略不計(因為隻構造一次)。

《高性能科學與工程計算》——2.5 C++優化

向量對象隻執行個體化一次(第4行),并且沒有後續配置設定開銷。然而,如果向量長度可變,那麼記憶體不得不重新配置設定,進而産生了和正常構造相同的開銷(見習題2.4)。一般情況下,如果指派操作比記憶體配置設定快(平均值),則靜态配置設定性能會更高。

并行程式中存放在共享記憶體的靜态資料要特别關注,詳細内容見6.1.4節。

2.5.3 循環與疊代器

循環(或者循環嵌套)在科學應用程式的運作時中占主導地位。編譯器對這些循環的優化能力是獲得高性能代碼的關鍵。運算符重載可能會對程式設計帶來很多便利,但不利于循環優化。下面的例子中,模闆函數sprod<>()實作了兩個向量的内積。

《高性能科學與工程計算》——2.5 C++優化

在代碼第7行,const t& vector::operator[]被調用了兩次,分别獲得向量a和b的相應分量。stl定義這個操作的方式如下(改編自gnu iso c++庫代碼):

《高性能科學與工程計算》——2.5 C++優化

盡管代碼看起來足夠簡單,可以有效内聯。然而,目前編譯器拒絕為上例中的求和循環進行simd向量優化。一個單一的抽象層(索引運算符的重載)就可以阻止最優循環代碼的生成(我們甚至都沒有提及第3章中列舉的更複雜、更高層次的循環轉換)。然而,當使用疊代器進行數組元素通路時,向量優化将不是問題:

《高性能科學與工程計算》——2.5 C++優化

因為vector::const_iterator是const t*,是以編譯器認為這是正常的c代碼。在c++程式設計中,使用疊代器進行資料通路是一個有效的優化方法。如果有可能,低層次循環代碼甚至應該駐留在單獨的編譯單元上(用c或者fortran編寫),并且疊代器可作為指針參數傳遞過去。保證盡量不幹擾編譯器對進階c++代碼的編譯。

std::vector<>模闆(最常用的容器)是一個特例,因為它的疊代器實作和标準(c)指針一樣。而越複雜的容器則有更複雜的疊代器類,可能不太容易轉換為原始指針。這種情況下,可使用包含多個類vector<>元件的“分段”結構表示資料(矩陣就是一個典型例子)。分段疊代器的使用還可實作快速低級别算法。詳細資訊見[c99, c100]。

繼續閱讀