每個程式設計師都應當知道的編譯器優化知識

alexxxx發表於2015-03-13

高階程式語言提供的函式、條件語句和迴圈這樣的抽象程式設計構造極大地提高了程式設計效率。然而,這也潛在地使效能顯著下降成為了用高階程式語言寫程式的一大劣勢。在理想條件下,在不以效能為妥協的情況下,你應該寫出易讀並且易維護的程式碼。因此,編譯器嘗試自動優化程式碼以提高其效能,當今的編譯器都深諳其道。編譯器可以轉化迴圈、條件語句和遞迴函式、消除整塊程式碼和利用目標指令集的優勢讓程式碼變得高效而簡潔。所以對程式設計師來說,寫出可讀性高的程式碼要比因為手工優化而使程式碼變得神祕且難以維護更加可貴。事實上,手工優化的程式碼反而可能會讓編譯器難以進行額外和更加有效的優化。

比起手工優化程式碼,你更應該考慮關於設計的各個方面,比如使用更快的演算法,引入執行緒級並行機制和利用框架特性(比如move建構函式)。

這篇文章是關於Visual C++ 編譯器優化的。為了便於應用,我將會討論編譯器採取的最重要的優化技巧和決策。我的目的不是告訴你如何手工優化程式碼,而是向你展示為什麼你可以信賴編譯器來優化你寫出的程式碼。這篇檔案絕不是對Visual C++ 編譯器優化工作的全面考察。但是將會給你展示那些你真正想要了解的優化工作和怎樣與你的編譯器溝通來應用它們。

有一些重要的優化是超出所有現有編譯器能力的——比如,用高效的演算法代替低效的,或者改變資料結構的排列以優化其在記憶體中的佈局。但是這些優化話題超出了本文的範圍。

定義編譯器優化

優化工作涉及到的一個方面,是把一行程式碼轉化成同等效果的另一行程式碼,在這個過程中提升它的一項或多項效能。最重要的兩項效能(指標)是程式碼的執行速度和長度。其他一些特性包括程式碼執行開銷,程式碼編譯所需時間,如果程式碼需要通過即時編譯機制(Just-in-Time (JIT))進行編譯,那麼JIT所需的編譯時間也是指標之一。

編譯器經常會依據它們所使用的技術優化程式碼。雖然並不完美,但是比起花時間手工苦苦推敲一個程式,利用編譯器提供的特有功能和讓編譯器來優化程式碼要高效得多。

這裡有4種方法讓你的編譯器更加高效地優化程式碼:

  1. 書寫可讀、高效的程式碼。不要把Visual C++ 物件導向的特性當作效能的敵人。最新版本的C++可以讓這些開銷保持到最低甚至消除這些開銷。

2.使用編譯器宣告。例如讓編譯器使用比預設情況更快的函式呼叫約定。

3.使用編譯器內建函式(compiler-intrinsic functions)。內在函式是其實現由編譯器自動提供的特殊函式。編譯器對其很熟悉並且會用極其高效的指令序列來代替函式呼叫,以充分利用目標指令集的優勢。當前Microsoft .NET Framework不支援編譯器內建函式,因此其下的語言都不支援。但是Visual C++ 對這一特性有外在支援。注意,雖然使用內建函式能夠提升程式碼效能,但是會降低可讀性和可移植性。

4. 使用效能分析引導優化(profile-guided optimization)。使用這一技術,可以讓編譯器蒐集更多關於程式碼的執行時行為,並且以此來作為優化依據。

本文的目的是通過證明編譯器可以在低效但是可讀性強的程式碼上應用優化(應用第一條方法),從而向你展示為什麼你可以信任編譯器。當然我也會提供一些對效能分析引導優化(profile-guided optimization)的簡短說明,和提到一些可以微調程式碼的編譯器宣告。

編譯器有許多優化技巧,從像常量摺疊這樣簡單的變換,直到像指令重排(instruction scheduling)這樣極其複雜的變換。然而在這篇文章中我只有限地討論了一些最重要的優化——那些可以顯著地提升效能(兩位數的百分數來衡量)和減少程式碼長度的優化:行內函數(function inlining)、COMDAT優化(COMDAT optimizations)和迴圈優化。我將會在下一部分討論前兩個話題,然後展示你如何控制Visual C++實現優化。最後會有.NET Framework優化的簡略說明。通篇我都將會採用Visual Studio 2013來構建程式碼。

連結時程式碼生成

連結時程式碼生成(LTCG)是一項應用在C/C++程式碼上的程式全域性優化(WPO)技術。C/C++編譯器獨立地編譯每個原始檔然後產生出相應的目標檔案。這意味著編譯器只能在單個原始檔上應用優化技術,而無法照顧到整個程式。但是,一些重要的優化卻只能瀏覽全部程式後才能產生。所以你只能在連結時(link time)應用這些優化,而非編譯時(compile time),因為連結器可以完整地看到程式。

當LTGC被開啟時(通過指定編譯器開關/GL),編譯器驅動程式(cl.exe)將只呼叫編譯器前端(c1.dll or c1xx.dll),並把後端呼叫(c2.dll)推遲到連結時間。產出的目標檔案包含通用中間語言(Common Intermediate Language——CIL)程式碼,而不是依賴機器的彙編程式碼。然後,當連結器(link.exe)被呼叫,它就能看到包含C中間語言的目標檔案,並呼叫編譯器後端,依次進行程式全域性優化,生成二進位制目標檔案,再返回連結器把所有目標檔案連結在一起,最後生成可執行檔案。

編譯器前端實際上進行了一些優化,比如無論優化啟用還是禁用,都會進行常量摺疊。但是所有重要的優化工作都是在編譯器後端進行的,並且可以使用編譯器開關控制。

連結時程式碼生成(LTCG)能讓後端積極地執行許多優化(通過指定/GL與/O1或/O2,以及/Gw編譯器開關,和/OPT:REF 與 /OPT:ICF連結器開關)。在本文中,討論僅限於行內函數(function inlining)和COMDAT優化(COMDAT optimizations)。關於完整的連結時程式碼生成優化,請參考相關文件。注意連結器可以在本地目標檔案,本地/託管混合目標檔案,純託管目標檔案,安全託管目標檔案和安全.net模組上執行連結時程式碼生成。

我編寫了一個包含兩個原始檔(source1.c 和 source2.c)和一個標頭檔案(source2.h)的程式。source1.c 和 source2.c分別在Figure 1 and Figure 2中。由於標頭檔案中非常簡單地包含了source2.c中的函式原型, 所以並沒有列出。

Figure 1 The source1.c File

Figure 2 The source2.c File

source1.c檔案包含兩個函式,有一個引數並返回這個引數的平方的square函式,以及程式的main函式。main函式呼叫source2.c中除了isPrime之外的所有函式。source2.c有5個函式。cube返回一個數的三次方;sum函式返回從1到給定數的和;sumOfcubes返回1到給定數的三次方的和;isPrime用於判斷一個數是否是質數;getPrime函式返回第x個質數。我省略掉了容錯處理因為那並非本文的重點。

這些程式碼簡單但是很有用。其中一些函式只進行簡單的運算,一些需要簡單的迴圈。getPrime是當中最複雜的函式,包含一個while迴圈且在迴圈內部呼叫了也包含一個迴圈的isPrime函式。我將會利用這些函式證實被稱作行內函數的優化,和一些其他的優化,其中行內函數這是編譯器最重要的優化之一。

我會在三種不同的配置下生成程式碼並且檢驗結果來驗證程式碼是如何被編譯器轉化的。如果你也照做的話,你需要彙編生成檔案(由編譯器開關/FA[s]生成)來檢驗生成的彙編程式碼以及映像檔案(由連結器開關/MAP生成)來檢驗初始化資料優化是否被執行(如果你指定了/verbose:icf 和 /verbose:ref開關,連結器也可以彙報這一項)。因此你需要確保在接下來的配置中指定了上述開關。我也會使用C編譯器(/TC)以讓生成的程式碼容易檢驗。但是這篇文章中所有我討論的東西對於C++一樣適用。

Debug配置

之所以使用Debug配置,是因為在你開啟了編譯器/Od開關而沒有開啟/GL開關時,所有的後端優化都是禁用的。當在這項配置下構建程式碼時,生成的目標檔案將包含和原始碼完全對應的二進位制程式碼。你可以通過生成的彙編輸出檔案和映像檔案來確認這一點。這項配置相當於Visual Studio中的除錯配置。

編譯時程式碼生成Release配置

這項配置和優化被啟用的配置(通過指定/O1,/O2或/Ox編譯器開關)非常相似,但是不指定/GL編譯器開關。在這項配置下,生成目標檔案將包含優化過的二進位制程式碼。但是沒有整個程式級別的優化。

通過檢視source1.c生成的彙編程式碼檔案,你會看到執行了兩項優化。首先,通過在編譯時的評估計算把square函式的第一次呼叫完全刪去了。這是如何發生的呢?編譯器發現square函式很小,它應該被作為行內函數。將它作為行內函數之後,編譯器發現本地變數n的值是已知的並且在給它賦值和呼叫函式之間沒有發生改變。因此,編譯器總結出執行乘法和用25替代結果是安全的。第二項優化,對於square的第二次呼叫square(m),也被當作行內函數。但是,因為m的值在編譯時是未知的,所以編譯器不能對計算估值,所以事實上程式碼被保留了。

現在我會檢查source2.c的彙編程式碼檔案,這將會更有趣。在函式sumOfCubes內對cube的呼叫被作為行內函數。這會讓編譯器啟用了對迴圈來說意義重大的一些優化(如你在“迴圈優化”部分將看到的)。此外,SSE2指令集被用於在isPrime函式中,當呼叫了sqrt函式時把int轉化為double而在sqrt返回值時又把double轉化為int。並且sqrt只在迴圈開始前呼叫了一次。注意如果/arch編譯器開關沒有被開啟,x86編譯器將會預設使用SSE2。大多數x86處理器以及所有x86-64處理器,都支援SSE2。

連結時程式碼生成Release配置

連結時程式碼生成(LTCG) Relase配置與Visual Studio中的Release配置相同。在這項配置中,優化被啟用並且/GL編譯器開關被開啟。這個開關隱含的指定了使用/O1或者/O2。這告訴編譯器生成通用中間語言(Common Intermediate Language——CIL)目標檔案而不是彙編目標檔案。這樣,連結器像之前所說那樣呼叫編譯器的後端來執行整個程式的優化。現在我將會討論一些程式全域性優化來展示連結時程式碼生成帶來的巨大好處。這項配置所生成的彙編程式碼列表可以在網路上得到。

只要允許函式被內聯(/Ob控制,不論何時,只要需要優化就可以開啟),不論/Gy開關(稍後討論)是否開啟,/GL開關都允許把其他翻譯單元中定義的函式作為行內函數。/LTCG連結器開關是可選的並且只為連結器提供指導。

通過檢視source1.c的彙編程式碼,你會看到除了scanf_s之外的所有函式都被作為了行內函數。因此,編譯器被允許執行函式cube,sum和sunOfCubes的計算。只有isPrime函式沒有被作為行內函數。但是,如果它被我們手動在getPrime中寫為行內函數,編譯器仍然會在main函式中把getPrime作為行內函數。

正如你所見,將函式內聯很重要不僅僅是因為它總是優化函式呼叫,而且它可以允許編譯器進行許多其他優化。將函式內聯通常會以程式碼量增加為代價來提升效能。過度地使用這一優化會導致我們熟知的程式碼膨脹現象。在每一次呼叫函式的地方,編譯器都會分析這樣做的利弊來決定是否將一個函式作為行內函數。

由於內聯的重要性,Visual C++編譯器提供了比對內聯的標準規定控制更多的支援。你可以通過使用auto_inline編譯控制編譯器不將一段範圍內的函式內聯。你可以通過標記為__declspec(noinline)控制編譯器不把特定的函式或方法內聯。你可以用關鍵字inline標記一個函式來給編譯器提示將這個函式作為行內函數(雖然編譯器可能選擇忽略這一標記如果這次內聯帶來的是淨損失)。inline關鍵字從C++的第一個版本——C99,就可以使用了。你可以同時在C或者C++中使用微軟特有的關鍵字_inline,這在你使用不支援inline的老式C版本時是很有用的。並且,你可以使用__forceinline關鍵字(C和C++)來強制編譯器將任何可以內聯的函式內聯。最後但是很重要的一點是,你可以告訴編譯器以確定或者不確定的深度拆開一個遞迴函式,這可以通過使用inline_recursion編譯指令來達成。注意編譯器當下沒有提供任何特性可以讓你在函式呼叫時控制內聯,一切都只能在函式定義時控制。

預設情況下生效的/Ob0開關會完全禁用內聯功能。你應該在除錯程式碼時使用這一開關(它在Visual Studio Debug配置下是自動開啟的)。/Ob1開關讓編譯器只在函式被定義為inline,__inline 或者__forceinline時,才考慮將函式內聯。/Ob2開關在指定了/O[1|2|x]時生效,編譯器將會考慮所有的函式是否可以內聯。在我看來,只有在/Ob1控制內聯時考慮是否使用inline或_inline才是有意義的。

在一些特定的條件下,編譯器是不能將函式內聯的。舉個例子,當虛呼叫一個虛擬函式時,因為編譯器不知道哪個函式將會被呼叫,所以這個函式不能被內聯。另一個例子是當通過指標呼叫一個函式而不是通過函式名時。你應該盡力避免這些條件來使得函式可以被內聯。具體請參考MSDN文件,那裡列出了不能被內聯的完整條件列表。

某些優化,當其作用於整個程式級別時,往往比其作用於區域性時更加有效,函式內聯就是這種型別的優化之一。事實上,大多數優化都在整體級別更加有效。在這一部分餘下的內容中,我將會討論被稱作COMDAT優化的一類特定優化。

預設情況下,當編譯翻譯單元時,所有的程式碼都被儲存到結果目標檔案的一個單獨區塊。連結器在單獨區塊的範疇上進行操作:也就是對這些區塊進行移除、合併或者重新排序。(但是)這種會妨礙連結器進行三項優化工作,而這三項優化工作對顯著減少可執行程式碼量和提升效能又非常重要。第一項是消除未被引用的函式和全域性變數;第二項是合併相同的函式和全域性常量;第三項是重新對函式和全域性變數排序,使得那些在同一路徑上執行的函式和被一起訪問的變數在實體記憶體中離得更近,這會讓程式有更好的區域性性。

為了能讓這些連結器優化生效,你可以通過分別開啟/Gy(函式級別連結)和/Gw(全域性資料優化)來分別讓編譯器對位於在不同區塊的函式和變數進行打包操作。這些區塊被稱為COMDATs。你也可以用__declspec( selectany)標記特定的全域性資料變數來告訴編譯器把這個變數加入COMDAT。然後,通過指定/OPT:REF連結器開關,連結器就會刪去未被引用的函式和全域性變數。你也可以通過指定/OPT:ICF開關,連結器就會合並相同的函式和全域性常數變數。(ICF代表Identical COMDAT Folding。)通過/ORDER連結器開關,你可以讓連結器把COMDAT以特定的順序放入生成映象。注意所有的這些優化都是連結器優化所以不需要/GL開關。如果是要對程式進行除錯,並且目的明確,那麼/OPT:REF和/OPT:ICF開關應當關閉。

你應該儘可能使用連結時程式碼生成(LTCG)。唯一不使用的原因是當你想要分發生成的目標檔案和二進位制檔案時。記得這些檔案包含通用中間語言(CIL)而不是組合語言,通用中間語言只能被生成它的特定版本的編譯器和連結器識別,這將會明顯限制目標檔案的使用,因為開發者必須使用相同版本的編譯器以使用這些檔案。這種情況下,除非你願意為每個版本的編譯器都分發一份目標檔案,否則你應該使用編譯時程式碼生成。除了限制使用,這些目標檔案通常比相應的彙編目標檔案更加龐大。但是記得CIL目標檔案帶來的巨大好處,那就是可以進行程式全域性優化(WPO)。

迴圈優化

Visual C++支援多種迴圈優化,但是我只討論其中的3種:迴圈展開,自動向量化和迴圈不變數程式碼移動。如果你修改了Figure1中的程式碼讓m代替n作為sumOfCubes的引數,編譯器將不能推斷出引數的值,所以必須讓函式可以處理任何引數。生成函式被高度優化並且尺寸很大,所以編譯器不會將它作為行內函數。

用/O1生成彙編程式碼,會在空間尺寸上進行優化。在這種情況下,不會對sumOfCubes函式實行任何優化操作。用/O2生成程式碼針對執行速度進行優化。生成程式碼的長度會很長但是執行效率顯著提高,因為sumOfCubes內部的迴圈被展開並且向量化了。有一個概念很重要,必須理解:如果不把cube函式內聯就不能進行向量化。而且,不進行內聯的話迴圈展開並不會變得高效。Figure3 顯示了生成的彙編程式碼的流程圖。這個流程圖對x86和x86-64架構都適用。

圖3 sumOfCubes流程圖

在Figure3中,綠色的菱形代表開始點,紅色矩形代表結束點。藍色菱形代表在執行時作為sumOfCubes函式中一部分而被執行的條件。如果處理器支援SSE4並且x大於等於8,就會使用SSE4指令同時執行四個乘法指令。同時把同一操作在多個值上執行的過程被稱為向量化。編譯器也會將迴圈展開,就是說迴圈體將會把每次迭代迴圈重複一次。這樣做的最終效果就是八次乘法在每次迭代都會被執行。當x的值小於8時,傳統的指令將會被用於執行餘下的運算。注意到編譯器放出了結合了三個獨立結尾的迴圈結束點而不是一個。這將會減少跳轉次數。

迴圈展開是重複執行迴圈體的過程,展開後的迴圈每次把未展開迴圈內的迴圈體執行不止一次。這樣做的原因是可以通過減少迴圈控制指令的執行頻率來提升效能。也許更重要的是,這樣可以允許編譯器進行許多其他優化工作,比如向量化。迴圈展開的弊端是會增加程式碼量和暫存器的壓力。但是這可能使效能達到兩位百分數級別的提升,當然這是和具體的迴圈體有關的。

不同於x86處理器,所有的x86-64處理器都支援SSE2.不僅如此,你可以在最新的x86-64微處理器架構上(包括Intel和AMD)通過開啟/arch開關來利用AVX/AVX2指令集。開啟/architecture:AVX2也會允許編譯器使用FMA和BMI指令集。

當前的Visual C++編譯器不支援控制迴圈展開。但是你可以通過使用模版結合__ forceinline關鍵字來模仿這一技術。你可以通過使用no_vector選項來禁用對於某個函式的自動向量化。

通過觀察生成的彙編程式碼,如果你有足夠敏銳的眼睛的話你會注意到程式碼還有少許優化空間。但是,編譯器已經做了很多工作了,並且不會再花更多的時間分析程式碼和進行一些無關緊要的優化。

SumOfCubes(原文是someOfCubes,應該是寫錯了——譯者注)不是唯一一個迴圈被展開的函式。如果你修改程式碼讓m作為引數而不是n,編譯器將不能對程式碼進行估計,因此必須放出其程式碼。在這種情況下,迴圈被展開了兩次。

最後我要討論的優化是迴圈不變數程式碼移動(loop-invariant code motion)。考慮如下程式碼:

這裡唯一的改變是增加了一個變數並且在每次迴圈進行自增,然後列印。不難看出這段程式碼可以通過把變數count的自增移出迴圈來優化。也就是說,我可以直接把x的值賦給變數count。這種優化被稱為迴圈不變數程式碼移動(loop-invariant code motion)。迴圈不變數部分清楚的表明這項技術只能用於其程式碼不依賴於任何迴圈之前的表示式的情況。

那麼這裡有一個問題:如果你自己來進行這項優化,生成的程式碼可能在某些情況下會導致效能下降。能發現為什麼嗎?考慮x為非正數的情況。迴圈將不被執行,這意味著未被手動優化的程式碼中count不會被訪問。但是,在我們手動優化過的程式碼中在迴圈外進行了一次不必要的賦值操作,把x賦給了count。更甚者,如果x是負數,count就會擁有錯誤的值。程式設計師和編譯器都容易受到這種陷阱的影響。所幸Visual C++編譯器足夠聰明地在賦值之前加上了迴圈條件,這樣可以對所有x的值都生成效能有所提升的程式碼。

綜上所述,如果你既不是編譯器也不是編譯器優化方面的專家,你應該避免僅僅因為想讓程式碼更快而進行手工修改。管住你的手並且相信編譯器將會優化你的程式碼。

控制優化

除了/O1,/O2,和/Ox編譯開關,你還可以使用控制優化編譯來達到讓某個函式優化的目的,其形式如下:

[optimization-list]可以為空或者一個或多個緊跟的值:g,s,t和y。分別對應編譯器開關/Og,/Os,/Ot和/Oy.

空列表和off引數會讓所有的優化都被關閉,不管之前的編譯器開關是否被開啟。空列表和on引數會讓之前開啟的編譯器開關生效。

/Og開關啟用全域性優化,全域性優化只作用域那些通過表面分析就可以被優化的函式上,而這些函式內部呼叫的其他函式則不會被優化。如果(連結時程式碼生成)LTCG被啟用,/Og允許程式碼全域性優化(WPO)。

當你需要讓不同的函式進行不同的優化時,比如一些進行空間尺寸優化而另一些進行執行速度優化,那麼優化編譯引數就很有用了。但是如果真的想達到那種粒度的控制,你應該考慮效能分析引導優化(PGO),就是通過對執行測量程式碼時的行為資訊進行記錄,然後使用這一紀錄對程式碼進行優化的過程。編譯器使用效能分析來決定怎樣優化程式碼。Visual Studio提供了必要的工具,來將這一技術同時應用於本機程式碼和託管程式碼上。

.NET中的優化

在.NET的編譯模型中沒有連結器。但是有一個原始碼編譯器(C# compiler)和即時編譯器(JIT compiler),原始碼編譯器只進行很小的一部分優化。比如它不會執行函式內聯和迴圈優化。而這些優化是由即時編譯器執行的。在4.5以前的所有.NET Framework JIT都不支援SIMD指令集。但是.NET Framework 4.5.1和之後的版本都裝有支援SIMD的即時編譯器,被稱為RyuJIT。

從優化能力上來講RyuJIT和Visual C++有什麼不同呢?因為RyuJIT是在執行時完成其工作的,所以它可以完成一些Visual C++不能完成的工作。比如在執行時,RyuJIT可能會判定,在這次程式的執行中一個if語句的條件永遠不會為true,所以就可以將它移除。RyuJIT也可以利用他所執行的處理器的能力。比如如果處理器支援SSE4.1,即時編譯器就會只寫出sumOfCubes函式的SSE4.1指令,讓生成打的程式碼更加緊湊。但是它不能花更多的時間來優化程式碼,因為即時編譯所花的時間會影響到程式的效能。另一方面,Visual C++編譯器可以花更多的時間尋找和利用更多恰當的優化機會。微軟新推出了一項稱為.NET Native的全新技術,允許你使用Visual C++編譯器後端對託管程式碼(Managed Code)進行編譯和優化,並形成自包含的獨立可執行程式。當下這項技術只支援Windows Store apps。

在當前控制託管程式碼的能力是很有限的。C#和VB編譯器只允許使用/optimize編譯器開關開啟或者關閉優化功能。為了控制即時編譯優化,你可以在方法上使用System.Runtime.Compiler­Services.MethodImpl屬性和MethodImplOptions中指定的選項。NoOptimization選項可以關閉優化,NoInlining阻止方法被內聯,AggressiveInlining (.NET 4.5)選項推薦(不僅僅是提示)即時編譯器將一個方法內聯。

結語

本文中提到的所有優化功能都會顯著地將你的程式碼效率提升兩位百分數級別,並且Visual C++編譯器支援所有這些優化。重要的是這些技術能夠在應用之後,帶來其他更多的優化。本文絕不敢奢望能夠對Visual C++編譯器的優化工作進行一次綜合全面的討論。但是我希望通過本文可以讓你領會編譯器的精妙。Visual C++可以做比這多得多的事情,所以敬請期待Part2。


作者介紹:Hadi Brais是印度德里理工大學(IITD)的一名博士生,他的主要研究課題是編譯器優化和下一代記憶體技術。他花費了很多時間使用C/C++/C#語言來編寫程式,並對CLR和CRT做深入的研究。他的部落格地址是:hadibrais.wordpress.com , 郵箱為:hadi.b@live.com .

相關文章