來源:DevDivChina
blogs.msdn.microsoft.com/c/2018/01/26/visual-studio-2017-效能提升和建議/
隨著C++專案的壯大和最佳化器的日漸複雜,編譯器的編譯時間,或者說是效能,逐漸成為人們關註的焦點。這是我們Visual C++組非常關註的問題,也成為了15.5版本和未來工作的重點。我想花幾分鐘時間來為各位總結一下我們最近為提升效能所做的特殊的更改,並且可以為你提升編譯工程的效能提幾點建議。
這裡需要註意的是,並非所有的更改都可以提升所有場景的效能。把編譯時間降低到一個期望值是一個長遠的事業。最近我們開始把AAA遊戲作為一個基準。未來還需要付出更多的努力。
VS工具集有三個部分需要分別改進。第一就是編譯器前端,也就是c1xx.dll的執行。這是一個將cpp檔案作為輸入並且生成一種不依賴於中間語言的語言的工具,或者IL,即將被輸入到編譯器後端的內容。編譯器後端也就是c2.dll。它從前端讀取IL並從中生成包含真正機器程式碼的obj。最後是聯結器,它將讀取後端編譯器生成的各種obj和lib,並且將他們合併生成一個最終的二進位制檔案。
編譯器前端效能
在許多工程裡,前端編譯時間阻礙了整體效能的提升。幸運的是,透過直接給msbuild或者其他編譯系統加/MP選項(該選項可以使cl.exe同時處理多個檔案),或者甚至可以使用像incredibuild這樣的工具透過分散式機器來加速。想要提升效能的第一步是在編譯工程時進行高效的分佈和並行。
第二步是確保你高效使用了PCH檔案。一個PCH檔案基本上是cl.exe 充分解析了.h檔案之後的記憶體轉儲–解決了每次都需要這樣做的麻煩。你會被它的作用所震驚,頭檔案(比如windows.h或者一些DirectX頭)一旦被完全預處理會變得非常大,並且常常會成為一個後處理源檔案的最主要的部分。PCH檔案會開啟一個新世界。這裡的主要操作是隻將會被頻繁更改的檔案包含進來,這就保證PCH會幫你改進很多效能。
最後一點建議是對#include的使用限制。在PCH檔案之外,包含一個檔案是一個非常昂貴的操作,這就牽扯到了在包含的路徑裡搜尋每一個檔案夾的問題。很多檔案的輸入輸出操作,這是一個每次都要被重覆的傳遞性操作。這就是PCH會起很大作用的原因。在微軟內部,人們有很多有關“只包含你要使用的檔案”的成功案例。/showInclude選項可以讓你認識到包含檔案是多麼的昂貴,並且可以指導你只包含你需要用的東西。
最後,我想讓你瞭解一下/Bt選項。這個選項可以顯示每個檔案的前端(同樣也包括後端和連結時間)編譯時間。它可以幫你查清效能低的原因,讓你知道哪個檔案需要你花時間去最佳化。
以下是我們為提升前端效能所做的更改。
掃清PGO計數
PGO,或者叫配置檔案最佳化,是一種編譯器後端科技,在微軟被廣泛使用。基本原理是你生成了一個特殊檢測版本的產品,透過執行測試用例生成配置檔案,基於已收集的資料進行重新編譯/最佳化。
我們發現在編譯或者最佳化前端二進位制檔案(c1xx.dll)時使用的是舊的配置檔案資料。當我們重新檢測或者重新收集 PGO資料時,會看到10%的效能提升。
這裡我們學到的是,如果在產品中用PGO提升效能,請確保定期收集訓練過的資料。
移除_assume的使用
_assume(0)給編譯器後端傳遞一個訊號, 告訴它一個特定的程式碼路徑(也許是一個預設情況的標簽,等等)無法到達。許多產品會把它包含在一個宏裡,並命名為類似於UNREACHABLE這樣的名字,然後執行,這樣debug版本會宣告這個訊號,ship版本會把這個訊號傳遞給編譯器。編譯器會做一些操作比如移除枝幹或者轉移標的宣告。
這是有道理的,如果在執行時一個_assume(0)宣告實際上是可到達的,結果就是生成錯誤程式碼。這會在很多不同的情況下帶來很多問題(並且一些人抱怨這會引發安全問題)– 所以我們做了一個實驗來看看透過重新定義一個宏來簡單的移除所有的_assume(0)會帶來的影響。如果回歸很小,也許不值得把它放在產品中免得引發其他的問題。
令我們驚訝的是,移除_assume宣告使前端效能提升了1%-2%。這就很容易做決定了。這一現象的根源就是儘管在很多情況下_assume對應最佳化器是一個有效的訊號,但實際上它也許會阻礙其他最佳化(尤其是比較新的最佳化)。在未來的版本中我們將持續對_assume進行改進。
改進winmd檔案載入
在winmd檔案載入問題上我們做了很多更改,旨在提升10%的載入效能(這一項大概佔總編譯時間的1%)。這隻會影響UWP工程。
編譯器後端
編譯器後端包含了最佳化器。這裡有兩個等級的效能問題,“常規”問題(在這裡我們做了大量的工作希望能有1-2%的提升),和“長遠”問題,這裡有一個特殊的方法會導致一些最佳化到達了一個不合理的路徑並且會花30s或者更長時間去編譯 — 但是大部分人沒被影響。我們關心這個並且一直為之改進。
如果你使用/Bt選項並且看到一個異常的檔案花費了非正常的時間做後端編譯,下一步就是在編譯時使用/d2cgsummary選項。Cgsummary(或者叫程式碼生成概要)將會告訴你哪個函式花費的時間長。如果這個函式不在你的關鍵效能路徑中,說明你很幸運,那麼你就可以用以下的方法為該函式關閉最佳化:
#pragma optimize(“”, off)
void foo() {
…
}
#pragma optimize(“”, on)
那麼這個函式就不會被最佳化。和我們保持聯絡,我們可以幫你看看是否能修複這個問題。
除了為編譯時間不正常的方法關閉最佳化之外,我需要提示你,使用 _forceinline時要當心。通常客戶會使用forceinline讓行內器做他們想做的事情,這種情況下,我的建議是盡可能的有針對性的使用。編譯器後端會非常非常重視_forceinline。它會免除所有的行內預算檢查(_forceinline的花費不會對行內預算不利)。這些年我們看了許多案例,以程式碼質量為由隨意使用_forceinline是效能提神的主要阻礙。基本的,不像其他編譯器,我們經常透過前端的IL行內預最佳化的方法。這樣做有時候有利,我們為不同的內容做不同的最佳化,但一個弊端是,很多工作我們將無法恢復。如果你有一個很深的行內樹,那麼這將很快變得無法控制。這就是碰到像是Tensorflow/libsodium這樣的地方編譯時間過長的根源。這是我們未來版本將要著眼改進的地方。
當使用LTCG build時請瞭解一下iLTCG。增量LTCG是一項新科技,使用它我們只需要對LTCG build中更改過的函式(以及該函式所依賴的函式,比如它的行內函式)做程式碼生成。沒有它,即使只做了少量的更改,也要對整個二進位制檔案重新程式碼生成。如果你曾因為LTCG使你陷入內部開發迴圈而放棄使用它,請瞭解一下iLTCG。
最後一點建議,也更加適用於LTCG build(這裡只有link.exe單獨做程式碼生成而不是分佈cl.exe),考慮使用/cgtreads#來適應預設核心擴充套件策略。正如你以下將要看到的,為了更好的衡量我們做了一些更改,但預設的仍舊是使用4核。將來我們會著眼於增加預設核數,或者甚至可以靈活的適用機器的核數。
以下是我們近期為編譯器後端所做的免費的效能提升:
行內讀取快取
一些編譯器,透過將最佳化過的所有行內函式儲存在記憶體中來實現行內。這個時候,行內的執行,就是一個記憶體複製到當前函式的指定位置的問題。
然而在VC++中,我們對行內的操作有一些不同。我們實際上是從磁碟中重讀沒被最佳化的版本。這明顯會慢一些,但是同時可以減少記憶體的使用。在效能提升的問題上,這將是一個阻礙,尤其是對一個大量使用_forceinline的工程來說。
為了平衡記憶體和效能的問題,相對於其他編譯器,我們在記憶體問題上做了小的改動。編譯器後端在一個方法因為行內操作而被讀取了固定的次數之後會快取這個方法。實驗表明,當次數N=100時可以很好的平衡記憶體和效能問題。我們可以給編譯器傳遞引數/d2FuncCache#(或者在LTCG
Build時給聯結器傳遞引數/de:-FuncCache)來實現。0表示不快取,50表示inline執行50次時快取。
型別系統編譯提升
此項適用於LTCG build。LTCG build開始,後端會致力於編譯在所有型別程式中使用多種最佳化的模型,比如虛擬化。這很慢並且記憶體佔用量大。以前,當碰到型別系統的問題時,我們建議人們透過給聯結器傳遞/d2:-noteypeopt來禁用它。最近,我們做了大量更改,為型別系統平衡這一問題。實際上這個更改非常基礎,這牽扯到我們如何執行bitset這一操作。
更好的擴充套件到多核
後端是多執行緒的。但是也有很多束縛:我們自下而上的執行編譯指令 – 意味著一個方法無論被呼叫過幾次都只編譯一次。這就是一個函式如果在被呼叫函式的編譯過程中使用收集到的資訊做更好的最佳化。
這也存在一個限制:以上方法如果大小一定的情況下可以免除,並且可以在不適用自下而上的資訊的情況下直接開始編譯。這樣就使得單執行緒編譯不會遭遇瓶頸,因為它會流失掉最終剩餘的少量的很大的方法,這類方法因為依賴樹很深而無法立即啟動。
我們已經重新評估了大容量函式的限制,並且明顯的降低了這一限制。我們這個更改,不對任意明顯的程式碼質量流失做評價,但是這次效能的提升會大大取決於,這個工程之前究竟多大程度的被大容量的方法所限制。
其他行內提升
我們為行內時符號表的構造和合併做了更改。這一更改提供了一個全面的附加的很小的好處。
更好封鎖粒度的鎖操作
像大部分工程,我們持續不斷的配置和檢查鎖操作的限制。結果是,在少數情況下,我們提升了鎖的封鎖粒度,尤其是IL檔案如何被對映和得到以及符號如果互相對映。
符號表和符號對映的新的資料結構
LTCG時,準確的在模組中對映符號需要做大量的工作。我們用新的資料結構重寫了這部分程式碼並帶來了提升。這個尤其對整體型別有幫助,通常是遊戲產業,這些工程裡圖例的對映操作會非常大。
LTCG多執行緒的其他部分
編譯器後端是多執行緒的只是相對正確的說法。我們只談論後端的程式碼生成部分,而撇開了主要工作不談。
然而,LTCG builds要複雜的多。它也包含一些其他部分。最近我們使其他部分多執行緒運作,代價是放棄了LTCG build 10%速度的提升。未來的版本中這項工作仍舊會持續。
聯結器的改進
如果你使用LTCG(並且你應該使用),你將可能看到聯結器是你編譯系統的一個阻礙。這有些不公平,因為LTCG時聯結器只用c2.dll做程式碼生成 – 所以以上的建議都適用。但是除了程式碼生成,聯結器有它傳統的工作要做,即解析取用和把所有obj生成一個最終的二進位制檔案。
這裡你能做的最重要的事就是使用fastlink。Fastlink實際上是一個新的PDB格式,透過/debug:fastlink選項使用。連結時,這將極大的減少生成PDB所要做的工作。
在你的debug版本,你應該使用/increamental。增量連結允許聯結器只更新被更改的obj而不是rebuild整個二進位制檔案。當你在內部迴圈做了一些更改,需要重新編譯連結測試重覆這些步驟的時候,增量連結會帶來很大的不同。和fastlink類似,我們在這裡做了大量的穩定的提升。假如你之前用過但是發現它不穩定,請重試一次。
以下是近期對編譯器效能提升所做的更改:
新的ICF heuristic
ICF-摺疊,是聯結器的重大阻礙之一。這是任何相同的函式為了節省空間而摺疊在一起的階段,也是這些函式的取用被重定向到單一實體的階段。
這個版本,對ICF做了一點更改。總結一下,透過依賴一個健壯的雜湊函式來做對等代替使用memcmp。這將明顯加快ICF的速度。
回退到64位聯結器
32位聯結器對於大工程來說存在著地址空間的問題。它經常用獲取檔案的方式將檔案進行記憶體對映,假如檔案很大,需要相鄰地址空間的記憶體對映將不可能實現。作為備用方法,聯結器退回到速度較慢的快取I/O路徑,這裡聯結器只讀取它需要的部分檔案。
聯結器知道,與記憶體對映I/O相比,快取I/O程式碼路徑會非常非常的慢。所以我們添加了一個新的邏輯,是的32位聯結器在退回到快取I/O之前將自己重啟為一個64位的行程。
Fastlink 改進
/debug:fastlink 在一定程度上是一個新的特徵,它可以明顯的加速除錯資訊生成,而這一步是所有連結實踐中一個主要的部分。我們建議所有人專攻這個選項,並且在任何可能的情況下使用它。在這個版本,我們加固並加速了這一選項,併在在未來的版本中給這個選項的編寫投入更多的時間和金錢來改進它。如果你最初使用過它,但是因為某一次不好的體驗而不再使用它,請再次嘗試。我們在15.6版本中對這個選項做了更多的提升。
增量連線的回退
我們聽到一個有關增量連結的抱怨,說有時候增量連結會比整體連結更慢,這取決於有多少obj和lib被更改。目前我們非常努力的查明這個狀況並且會直接的求助於整體連結。
結論
以上所列舉的內容並不詳盡,但是很好的總結了過去幾個月VS有關重大效能提升的更改。如果你曾經被VC++編譯連結時間過長所困擾,我建議你再次嘗試15.5的工具集。如果遇到一個工程和其他大小差不多的工程相比編譯時間毫無理由的過長,或者是跟其他工具相比時間過長,我們很願意幫你看一下。
請記得,cl.exe可以加/d2cgsummary,link.exe可以加/d2:-cgsummary,這兩個方法可以幫助你查明在生成程式碼效能上的問題。這包含了以上所討論的行內器讀取快取的問題。當遇到大工程,記得加/Bt,它可以幫你查明每個檔案的前端後端編譯時間和連結時間。/time+可以顯示連結時間,包含了ICF使用的時間。
●編號118,輸入編號直達本文
●輸入m獲取文章目錄
Web開發
更多推薦《18個技術類微信公眾號》
涵蓋:程式人生、演演算法與資料結構、駭客技術與網路安全、大資料技術、前端開發、Java、Python、Web開發、安卓開發、iOS開發、C/C++、.NET、Linux、資料庫、運維等。