# 編譯器優化 - 借助按本機配置優化來簡化代碼
作者?[Hadi Brais](https://msdn.microsoft.com/zh-cn/magazine/mt149362?author=Hadi+Brais)?| September 2015
編譯器經常會做出不明智的優化決策,這些決策無法真正地提升代碼性能,更糟糕的是,執行這些策略可能會降低代碼性能。前兩篇文章中介紹的優化對提升應用程序性能而言至關重要。
本文介紹了按配置優化 (PGO),這一重要技術可以幫助編譯器后端更有效地優化代碼。實驗結果顯示,性能提升從 5% 提高到 35%。此外,如果您使用謹慎,此技術絕不會降低您的代碼性能。
本文是在前兩篇文章的基礎上編寫而成:([msdn.microsoft.com/magazine/dn904673](https://msdn.microsoft.com/magazine/dn904673)?和[msdn.microsoft.com/magazine/dn973015](https://msdn.microsoft.com/magazine/dn973015))。如果您是初次接觸 PGO 概念,我建議您先閱讀 Visual C++ 團隊博客文章 ([bit.ly/1fJn1DI](http://bit.ly/1fJn1DI))。
## PGO 簡介
編譯器執行的最重要優化任務之一就是函數內聯。默認情況下,只要調用方規模沒有過度增長,Visual C++ 編譯器就會內聯函數。雖然許多函數調用都會進行擴展,但只有在調用經常發生時這才會有用。否則,只會增加代碼大小,同時浪費指令和統一緩存占用的空間,并會擴大應用工作集。編譯器如何知道調用是否經常發生? 這最終取決于傳遞給函數的自變量。
大多數優化缺少做出明智決策所需的可靠啟發。我遇到過許多寄存器分配不佳的情況,結果就是性能大幅下降。在編譯代碼時,您能做的就是希望全部優化帶來的所有性能提升和下降在相互抵消影響后,仍可以形成積極效果。情況幾乎總是這樣,但可能會生成過大的可執行文件。
最好能杜絕此類性能下降。如果您可以告知編譯器代碼在運行時的具體行為,即可更好地優化代碼。我們將在運行時記錄程序行為的相關信息的過程稱為配置處理,將生成的信息稱為配置文件。您可以向編譯器提供一個或多個配置文件,以便其按配置進行優化。這就是 PGO 技術必須完成的任務。
您可以對本機代碼和托管代碼使用此項技術。不過,工具是不同的,因此我在這篇文章中將僅介紹本機 PGO,并將托管 PGO 留到另一篇文章中再做介紹。本部分的剩余篇幅討論了如何將 PGO 應用于應用。
PGO 是一項非常不錯的技術。但如同其他所有技術一樣,PGO 也存在不足之處。此技術不僅耗時(具體視應用大小而定),而且還費力。幸運的是,Microsoft 提供了工具,可大大降低將 PGO 應用于應用所需的時間(如下文所述)。將 PGO 應用于應用的過程分為以下 3 個階段:檢測版本、定型和 PGO 版本。
## 檢測版本
對正在運行的程序進行配置處理的方法有多種。Visual C++ 編譯器使用的是靜態二進制文件檢測,這會生成最準確的配置文件,但耗時比較長。借助檢測,編譯器可以在代碼的所有函數中的相關位置上插入少量機器指令,如圖 1?所示。這些指令會記錄代碼的相關部分的執行時間,并將此信息添加到生成的配置文件中。
?
圖 1:按配置優化應用的檢測版本
要生成應用的檢測版本,您需要執行多個步驟。首先,您需要使用 /GL 開關編譯所有源代碼文件,以啟用全程序優化 (WPO)。檢測程序時需要使用 WPO(從技術上講,并不一定要使用,但它有助于提高生成的配置文件的實用性)。只有已使用 /GL 進行過編譯的文件才會得到檢測。
為了讓下一階段盡可能順利進行,請避免使用任何可能會生成額外代碼的編譯器開關。例如,禁用函數內聯 (/Ob0)。此外,還請禁用安全檢查 (/GS-) 和刪除運行時檢查(無 /RTC)。也就是說,您不得使用 Visual Studio 的默認發布和調試模式。對于未使用 /GL 進行編譯的文件,對其進行速度優化 (/O2)。對于檢測代碼,請至少指定 /Og。
接下來,使用 /LTCG:PGI 開關將生成的對象文件與所需的靜態庫相關聯。鏈接器需要執行 3 項任務。它會指示編譯器后端檢測代碼,并生成 PGO 數據庫 (PGD) 文件。此文件用于在第 3 階段中存儲所有配置文件。此時,PGD 文件不包含任何配置文件,僅包含對象文件的標識信息,用于在 PGD 文件獲得使用時檢測這些文件是否發生了變化。默認情況下,PGD 文件以可執行文件的名稱為文件名。您還可以使用可選的 /PGD 鏈接器開關指定 PGD 文件名。第 3 個任務是關聯 pgort.lib 導入庫。輸出可執行文件依賴于 PGO 運行時 DLL pgortXXX.dll,其中 XXX 表示 Visual Studio 版本。
此階段最終會生成包含大量檢測代碼的可執行文件(EXE 或 DLL),以及將在第 3 階段中填充和使用的空 PGD 文件。您只能檢測靜態庫,前提是此庫與要檢測的項目相關聯。此外,同一版本的編譯器必須生成所有 CIL OBJ 文件;否則,鏈接器會出錯。
## 配置處理探測器
在進入下一階段之前,我想討論一下編譯器插入以進行配置處理的代碼,以便您能夠估計程序附加開銷,并了解在運行時收集的信息。
為了記錄配置文件,編譯器在使用 /GL 編譯的每個函數中都插入了大量探測器。探測器是一小串指令序列(2 到 4 個指令),包含大量推送指令和一個最終向探測器處理程序發出的調用指令。必要時,探測器由兩個函數調用進行封裝,以保存和還原所有 XMM 寄存器。探測器分為以下 3 種類型:
* 計數探測器: 這是最常見的探測器類型。此類探測器在代碼塊每執行一次時都會使計數器遞增一次計數,從而統計代碼塊執行次數。就其大小和速度而言,此類探測器的成本是最低的。每個計數器在 x64 上為 8 字節,在 x86 上為 4 字節。
* 條目探測器: 編譯器在每個函數的開頭位置插入條目探測器。此類探測器的用途是告知與其位于同一函數的其他探測器使用與此函數相關的計數器。此為必需探測器,因為探測器跨函數共享探測器處理程序。主函數的條目探測器負責初始化 PGO 運行時。條目探測器也是計數探測器。這是速度最慢的探測器。
* 值探測器: 此類探測器在所有虛擬函數調用和 switch 語句之前插入,用于記錄值的直方圖。值探測器也是計數探測器,因為它會統計每個值的出現次數。這是最大的探測器。
如果函數僅有一個基本塊(包含一個進入和退出的指令序列),則任何探測器都不會對其進行檢測。事實上,盡管存在 /Ob0 開關,此函數也可能會進行內聯。除了值探測器外,每個 switch 語句也都會讓編譯器創建具有描述用途的常量 COMDAT 部分。此部分的大小計算方式約為,用案例數量乘以控制開關的變量大小。
每個探測器最終都會調用探測器處理程序。主函數的條目探測器會創建一系列探測器處理程序指針(在 x64 上為 8 字節,在 x86 上為 4 字節),其中每個條目均指向不同的探測器處理程序。在大多數情況下,探測器處理程序只有幾個。探測器在每個函數中的插入位置如下所示:
* 條目探測器在函數的開頭位置插入
* 計數探測器在以調用或 ret 指令為結尾的每個基本塊中插入
* 值探測器在所有 switch 語句之前插入
* 值探測器在所有虛擬函數調用之前插入
因此,檢測程序的內存開銷大小由探測器的數量、所有 switch 語句中的案例數量、switch 語句數量和虛擬函數調用次數而定。
在某個時間點,所有探測器處理程序都會讓計數器遞增一次計數,以記錄相應代碼塊的一次執行。編譯器使用 ADD 指令來使 4 字節的計數器遞增一次計數;在 x64 上,編譯器使用 ADC 指令讓高達 4 字節的計數器執行進位加法。這些指令為非線程安全。也就是說,默認情況下所有探測器均為非線程安全。如果多個線程可能同時執行至少一個函數,則結果就不可靠。在這種情況下,您可以使用 /pogosafemode 鏈接器開關。這樣,編譯器就可以為這些指令添加前綴 LOCK,讓所有探測器均變成線程安全。當然,這也會減緩它們的速度。遺憾的是,您無法有選擇性地應用此功能。
如果您的應用程序由 PGO 輸出是 EXE 或 DLL 文件的多個項目組成,則您必須對每個項目重復執行此流程。
## 定型階段
在第 1 階段后,您會獲得可執行文件的檢測版本和 PGD 文件。第 2 階段是定型。在此階段中,可執行文件會生成一個或多個配置文件,以存儲在單獨的 PGO 計數 (PGC) 文件中。您將在第 3 階段中使用這些文件優化代碼。
這是最重要的階段,因為配置文件的準確性對整個過程的成功至關重要。配置文件必須反映程序的常見使用方案才有用。編譯器優化程序的前提是已執行的方案是常見的。如果情況并非如此,那么您程序的實際效果可能會更差。通過常見的使用方案生成的配置文件可有助于編譯器了解用于優化速度的熱路徑和用于優化大小的冷路徑,如圖 2?所示。
?
圖 2:創建 PGO 應用的定型階段
這一階段的復雜性取決于使用方案的數量和程序的性質。如果程序不需要用戶輸入任何內容,則定型很簡單。如果有許多種使用方案,那么依序生成每個方案的配置文件可能不是最快速的方法。
在復雜的定型方案中(如圖 2?所示),pgosweep.exe 是一項命令行工具,可方便您控制配置文件的內容,此配置文件在運行時由 PGO 運行時進行維護。您可以生成多個程序實例,同時應用使用方案。
假設您有兩個實例在進程 X 和 Y 中運行。當一個方案即將在進程 X 上啟動時,調用 pgosweep 并向其傳遞進程 ID 和 /onlyzero 開關。這會讓 PGO 運行時僅針對此進程清除內存中的配置文件的部分內容。無需指定進程 ID,即可清除整個 PGC 配置文件。然后,方案可以啟動。您可以通過類似的方式在進程 Y 上啟動第二個使用方案。
僅當所有正在運行的程序實例終止時,PGC 文件才會生成。不過,如果程序的啟動時間較長,且您不想對每個方案都運行此程序,則可以強制運行時生成配置文件,并清除內存中的配置文件,為同時運行的其他方案做好準備。為此,請運行 pgosweep.exe,并傳遞進程 ID、可執行文件名和 PGC 文件名。
默認情況下,PGC 文件的生成目錄與可執行文件相同。您可以使用 VCPROFILE_PATH 環境變量更改此目錄,但必須在運行首個程序實例之前進行設置。
我已經介紹過檢測代碼的數據和指令開銷。在大多數情況下,此開銷是可以調節的。默認情況下,PGO 運行時的內存占用不會超過特定的閾值。如果需要更多內存,則會出錯。在這種情況下,您可以使用 VCPROFILE_ALLOC_SCALE 環境變量來提高此閾值。
## PGO 版本
在您已執行所有常見的使用方案后,您便會獲得一組 PGC 文件,這些文件可用于生成程序的優化版本。您可以放棄不想使用的任何 PGC 文件。
生成 PGO 版本的第 1 步是,將所有 PGC 文件與 pgomgr.exe 命令行工具合并。您還可以使用此工具來編輯 PGD 文件。若要將兩個 PGC 文件合并到您在第 1 階段生成的 PGD 文件中,請運行 pgomgr,然后傳遞 /merge 開關和 PGD 文件名。這會合并當前目錄中名稱與指定 PGD 文件的名稱(后跟 !# 和數字)匹配的所有 PGC 文件。編譯器和鏈接器可以使用生成的 PGD 文件來優化代碼。
您可以使用 pgomgr 工具捕獲更常見或更重要的使用方案。為此,請傳遞相應的 PGC 文件名和 /merge:n 開關,其中 n 是某正整數,表示 PGD 文件中包含的 PGC 文件的副本數量。默認情況下,n 是 1。這種多重性會令特定的配置文件偏好優化。
第 2 步是運行鏈接器,傳遞第 1 階段中的同一組對象文件。此時,使用 /LTCG:PGO 開關。鏈接器會在當前目錄中查找與可執行文件同名的 PGD 文件。這可確保自 PGD 文件在第 1 階段中生成后,CIL OBJ 文件沒有發生變化,并將它傳遞給編譯器以使用和優化代碼。有關此流程的信息,請參見圖 3。您可以使用 /PGD 鏈接器開關明確指定 PGD 文件。請不要忘記為這一階段啟用函數內聯。
?
圖 3:第 3 階段中的 PGO 版本
大多數編譯器和鏈接器優化都會變成按配置優化。此階段最終會生成在大小和速度方面均高度優化的可執行文件。我們建議您此時衡量性能提升幅度。
## 維護基本代碼
如果您使用 /LTCG:PGI 開關對傳遞給鏈接器的任意輸入文件進行任何更改,則在指定 /LTCG:PGO 的情況下,鏈接器會拒絕使用 PGD 文件。這是因為此類更改會極大地影響 PGD 文件的實用性。
一種選擇是重復執行上述 3 個階段,生成另一個兼容的 PGD 文件。不過,如果更改量非常小(如添加少量函數、略微增加或降低函數調用頻次,或添加不常用的功能),則重復整個流程也不實際。在這種情況下,您可以使用 /LTCG:PGU 開關,而不是 /LTCG:PGO 開關。這會指示鏈接器跳過 PGD 文件的兼容性檢查。
這些細微更改會隨著時間的推移進行累積。您最終會需要再次檢測應用。通過在 PGO 生成代碼時檢查編譯器輸出,您可以確定何時需要這樣做。您可以從中了解 PGD 覆蓋的基本代碼量。如果配置文件的覆蓋率下降至 80% 以下(如圖 4?中所示),那么我們建議您再次檢測代碼。不過,此百分比主要取決于應用程序的性質。
## PGO 的實際使用場景
PGO 指導編譯器和鏈接器優化。我將使用 NBody 模擬器來展示它的一些優勢。您可以從[bit.ly/1gpEaCY](http://bit.ly/1gpEaCY)?下載此應用程序。此外,您還需要從?[bit.ly/1LQnKge](http://bit.ly/1LQnKge)?下載和安裝 DirectX SDK,以編譯此應用程序。
首先,我將在發布模式下編譯此應用程序,將其與 PGO 版本進行比較。若要生成應用的 PGO 版本,您可以使用 Visual Studio“生成”菜單的“按配置優化”菜單項。
您還應使用 /FA[c] 編譯器開關來啟用匯編程序輸出(對于此演示,請勿使用 /FA[c]s)。對于此簡單應用,定型一次檢測應用就足以生成一個 PGC 文件,并將它用于優化此應用。這樣一來,您會獲得兩個可執行文件:一個是盲優化文件,另一個是 PGO 文件。請確保您可以訪問最終 PGD 文件,因為稍后您將需要使用此文件。
現在,如果您依次運行兩個可執行文件,并比較實現的 GFLOP 計數,則會注意到它們的性能類似。顯然,將 PGO 應用于此應用是在浪費時間。經過進一步調查,我們發現此應用的大小已從 531 KB(對于盲優化的應用)下降到 472 KB(對于基于 PGO 的應用),或減少了 11%。因此,將 PGO 應用于此應用后,大小是降低了,但性能仍保持不變。為什么會這樣呢?
請考慮 DXUT/Core/DXUT.CPP 文件中第 200 行的函數 DXUTParseCommandLine。通過查看生成的發布版本程序集代碼,您會發現二進制代碼的大小約為 2700 字節。另一方面,PGO 版本中的二進制代碼大小未超過 1650 字節。您可以從檢查以下循環的條件的程序集指令中,發現導致此差異出現的原因:
~~~
for( int iArg = iArgStart; iArg < nNumArgs; iArg++ ) { ... }
~~~
盲優化版本生成了以下代碼:
~~~
0x044 jge block1
; Fall-through code executed when iArg < nNumArgs
; Lots of code in between
0x362 block1:
; iArg >= nNumArgs
; Lots of other code
~~~
另一方面,PGO 版本生成了以下代碼:
~~~
0x043 jl?? block1
; taken 0(0%), not-taken 1(100%)
block2:
; Fall-through code executed when iArg >= nNumArgs
0x05f ret? 0
; Scenario dead code below
0x000 block1:
; Lots of other code executed when iArg < nNumArgs
~~~
許多用戶更愿意在 GUI 中指定參數,而不是在命令行中傳遞參數。因此,如配置文件信息所示,這里的常見方案是從不循環訪問的循環。如果沒有配置文件,編譯器就無法知曉這一點。所以,它繼續工作,并在循環內主動地優化代碼。它會擴展許多函數,生成大量毫無意義的代碼。在 PGO 版本中,您為編譯器提供了一個配置文件,提示循環從未執行。編譯器從中了解到,內聯從循環主體中調用的任何函數毫無意義。
您會從程序集代碼片段中發現另一個有趣的區別。在盲優化的可執行文件中,很少執行的分支位于條件指令的貫穿路徑中。幾乎總是執行的分支位于距條件指令 800 字節的位置處。這不僅會導致處理器分支預測程序失敗,還會導致指令緩存失誤。
PGO 版本通過交換分支位置,避免了以上兩種問題。事實上,它是將很少執行的分支移到可執行文件中的單獨部分內,因此提升了工作集局部性。我們將這種優化稱為死代碼分離。沒有配置文件,就無法執行。不經常調用的函數(如二進制代碼中的細微差異)會導致性能出現巨大差異。
當生成 PGO 代碼時,編譯器會顯示為了優化所有檢測函數的速度已編譯了多少函數。編譯器還會在 Visual Studio 輸出窗口中顯示此信息。通常,為優化速度而編譯的函數所占的百分比不得超過 10%(視作主動內聯),其余函數可進行編譯以優化大小(視作部分內聯或不內聯)。
請考慮更有趣一點的函數 DXUTStaticWndProc(在同一文件中進行定義)。函數控制結構如下所示:
~~~
if (condition1) { /* Lots of code */ }
if (condition2) { /* Lots of code */ }
if (condition3) { /* Lots of code */ }
switch (variable) { /* Many cases with lots of code in each */ }
if-else statement
return
~~~
盲優化的代碼按照源代碼中的相同順序生成各個代碼塊。不過,已借助各塊的執行頻次和執行時間,對 PGO 版本中的代碼進行了巧妙地重新排列。前兩個條件很少執行,因此,若要提高緩存和內存利用率,相應的代碼塊現在位于單獨的部分中。此外,被識別為貫穿熱路徑的函數(如 DXUTIsWindowed)現在是內聯的:
~~~
if (condition1) { goto dead-code-section }
if (condition2) { goto dead-code-section }
if (condition3) { /* Lots of code */ }
{/* Frequently executed cases pulled outside the switch statement */}
if-else statement
return
switch(variable) { /* The rest of cases */ }
~~~
大多數優化都受益于可靠的配置文件,另一些也變得可以執行。如果 PGO 沒有大大提升性能,則一定會減少生成的可執行文件的大小及其在內存系統上的開銷。
## PGO 數據庫
PGD 配置文件的優勢遠遠超過了指導編譯器優化。雖然您可以使用 pgomgr.exe 合并多個 PGC 文件,但還可以將它用于其他用途。它提供了 3 個開關,可便于您查看 PGD 文件的內容,從而就已執行的方案全面了解代碼行為。第 1 個開關 /summary 指示工具生成 PGD 文件內容的文本摘要。第 2 個開關 /detail 與第 1 個開關合作,指示工具生成詳細的配置文件文本說明。最后一個開關 /unique 指示工具取消修飾函數名稱(尤其適用于 C++ 基本代碼)。
## 編程控制
還有最后一項功能值得一提。pgobootrun.h 頭文件聲明一個 PgoAutoSweep 函數。您可以調用此函數,以編程方式生成 PGC 文件,并清除內存中的配置文件來為下一個 PGC 文件做好準備。此函數采用一個類型為 char* 的自變量,指代 PGC 文件名。您必須關聯 pgobootrun.lib 靜態庫,才能使用此函數。目前,這是與 PGO 相關的唯一編程支持。
## 總結
PGO 是一項優化技術,可在需要解決在大小和速度之間進行取舍的問題時,引用可靠的配置文件,幫助編譯器和鏈接器做出更明智的優化決策。通過項目的“生成”菜單或上下文菜單,Visual Studio 提供了對此技術的可視化訪問。
不過,您可以通過 PGO 插件使用一組更豐富的功能,下載地址為?[bit.ly/1Ntg4Be](http://bit.ly/1Ntg4Be)。[bit.ly/1RLjPDi](http://bit.ly/1RLjPDi)?中也進行了詳細記錄。回想一下圖 4?中的覆蓋率閾值,實現它的最簡單方法是使用本文檔中所述的插件。不過,如果您希望使用命令行工具,則可以參閱?[bit.ly/1QYT5nO](http://bit.ly/1QYT5nO)?中的文章,參考大量示例。如果您有本機基本代碼,我們建議您立即試用此技術。試用時,您可以隨時告知我,此操作對您應用程序的大小和速度產生了哪些影響。

圖 4:PGO 基本代碼維護周期
### 更多資源
有關按配置優化數據庫的詳細信息,請參閱 Hadi Brais 的博客文章 ([bit.ly/1KBcffQ](http://bit.ly/1KBcffQ))。
* * *
Hadi Brais?*獲得了德里印度理工學院 (IITD) 的博士學位,主要研究下一代內存技術的編譯器優化。他將大部分精力用于編寫 C/C++/C# 代碼上,并深入研究了運行時和編譯器框架。他的博客網址是[hadibrais.wordpress.com](http://hadibrais.wordpress.com/)。您可以通過?[hadi.b@live.com](mailto:hadi.b@live.com)?與他聯系。*
- 介紹
- 云連接移動應用 - 借助身份驗證和離線支持構建 Xamarin 應用
- 崛起 - 自由 Internet 廣播
- Microsoft Azure - 云中的容錯問題和解決方法
- 最前沿 - 適合常見應用程序的事件源
- Azure 深入了解 - 跨云平臺創建統一的 Heroku 式工作流
- 借助 C++ 進行 Windows 開發 - Windows 運行時中的高級類型
- 編譯器優化 - 借助按本機配置優化來簡化代碼
- 數據點 - 再探 JavaScript 數據綁定(現在包含 Aurelia)
- 云安全 - 借助 Azure 密鑰保管庫保護敏感信息的安全
- 測試運行 - 借助人工尖峰神經元進行計算
- 開發運營 - 在 Microsoft 堆棧上啟用開發運營
- 孜孜不倦的程序員 - 如何成為 MEAN: Node.js
- 新型應用 - 提升新型應用的易用性的做法
- 別讓我打開話匣子 - Darwin 的照相機
- 編輯寄語 - 汽車 Internet 發生故障