.NET平臺系列17 .NET5中的ARM64效能

張傳寧發表於2021-06-08

  .NET團隊使.NET 5大大提高了常規效能和ARM64效能。在《.NET5中的效能改進》部落格中可以檢視總體改進情況。在這篇文章中,將描述我們專門針對ARM64進行的效能改進,並展示對我們使用的基準的積極影響。我還將分享一些我們已經確定並計劃在將來的版本中進行效能改進的其他機會。

  雖然我們在RyuJIT中對ARM64的支援已經工作了五年多,但我們所做的大部分工作是確保生成功能正確的ARM64程式碼。我們在評估為ARM64生成的程式碼RyuJIT的效能方面花費的時間很少。作為.NET5的一部分,我們的重點是在這個領域進行調查,找出RyuJIT中任何明顯的問題,這些問題將提高ARM64程式碼質量(CQ)。由於Microsoft VC++團隊已經支援Windows ARM64,因此我們與他們進行了協商,以瞭解他們在進行類似練習時遇到的CQ問題。

  儘管解決CQ問題是至關重要的,但有時它的影響在應用程式中可能並不明顯。因此,我們還希望對.NET庫的效能進行明顯的改進,以使針對ARM64的.NET應用程式受益。下面是我將用來描述我們在.NET 5上改進ARM64效能的工作的概要:

  • .NET庫中特定於ARM64的優化
  • RyuJIT產生的程式碼質量評估和結果
.NET庫中的ARM64硬體內部函式

  在.NET Core 3.0中,我們引入了一項稱為“硬體內在函式”的新功能,該功能可以訪問現代硬體支援的各種向量化和非向量化指令。對於x86 / x64體系結構,.NET開發人員可以使用名稱空間System.Runtime.IntrinsicsSystem.Runtime.Intrinsics.X86下的一組API訪問這些指令。在.NET 5中,我們在System.Runtime.Intrinsics.Arm下為ARM32 / ARM64體系結構新增了大約384個API 。這涉及到實現這些API並使RyuJIT知道它們,以便它能夠發出適當的ARM32/ARM64指令。我們還優化了Vector64Vector128的方法,這些方法提供了建立和操作Vector64<T>和Vector128<T>資料型別的方法,大多數硬體內部API都在這些資料型別上執行。如果有興趣,請參考示例程式碼用法以及此處Vector64Vector128方法的示例您可以在此處檢視“硬體固有”專案的進度

使用ARM64硬體內部函式優化.NET庫程式碼

  在.NET Core 3.1中,我們使用x86 / x64內部函式優化了.NET庫的許多關鍵方法。當在支援x86 / x64內部指令的硬體上執行時,這樣做可以提高此類方法的效能。對於不支援x86 / x64內在函式的硬體(例如ARM機器),. NET將回退到這些方法的較慢實現。dotnet /執行時#33308列出此類.NET庫方法。在.NET 5中,我們還使用ARM64硬體內在函式對這些方法中的大多數進行了優化。因此,如果您的程式碼使用任何這些.NET庫方法,則它們現在將看到在ARM體系結構上執行的速度提高。我們將精力集中在已經使用x86 / x64內在函式進行了優化的方法上,因為這些方法是基於較早的效能分析(我們不想重複/重複)而選擇的,並且我們希望該產品在各個平臺上具有大致相似的行為。展望未來,當我們優化.NET庫方法時,我們期望同時使用x86 / x64和ARM64硬體內在函式作為我們的預設方法。我們仍然必須決定這將如何影響我們接受的PR的政策。

  對於在.NET 5中優化的每種方法,我將向您展示用於驗證改進的低階基準方面的改進。這些基準與現實世界相去甚遠。在後面的文章中,您將看到如何將所有這些有針對性的改進結合在一起,以在更大,更真實的場景中極大地改進ARM64上的.NET。

  • System.Collections

    • System.Collections.BitArray

  • System.Numerics

    • System.Numerics.BitOperations

  • System.SpanHelpers

  • System.Text

我們還在的幾個類別中優化了方法。System.Text

  在.NET中6,我們計劃以優化其餘的方法中所描述的dotnet /執行#41292,方法到地址的dotnet /執行#35033和合並工作,以優化用做本·亞當斯DOTNET /執行#41097System.Text.ASCIIUtilitySystem.BuffersJsonReaderHelper.IndexOfLessThan

  上面提到的所有度量均來自我們在8/6 / 2020、8 / 10/20208/28/2020的Ubuntu計算機上進行的效能實驗室執行。

具有ARM64內部函式的方法的AOT編譯

  在典型情況下,應用程式在執行時使用JIT編譯為機器程式碼。生成的目標機器程式碼非常有效,但缺點是必須在執行期間進行編譯,這可能會在應用程式啟動期間增加一些延遲。如果預先知道目標平臺,則可以為該目標平臺建立準備執行(R2R)本機映像。這就是所謂的提前編譯(AOT)。它的優點是啟動時間更快,因為在執行過程中不需要生成機器程式碼。目標機器程式碼已經以二進位制形式存在,可以直接執行。AOT編譯的程式碼有時可能不太理想,但最終會被最佳程式碼所取代。

  在.NET 5之前,如果一個方法(.NET庫方法或使用者定義方法)呼叫了ARM64硬體內部API(System.Runtime.Intrinsics和System.Runtime.Intrinsics.Arm下的API),那麼這些方法永遠不會在AOT下編譯,並且總是延遲到執行時進行編譯。這對一些在啟動程式碼中使用這些方法的.NET應用程式的啟動時間產生了影響。在.NET5中,我們在dotnet/runtime#38060中解決了這個問題,現在能夠對此類方法進行AOT編譯。

微基準分析
  使用內在函式優化.NET庫是一個簡單的步驟(遵循我們對x86 / x64所做的工作)。一個同等或更重要的專案正在改善JIT為ARM64生成的程式碼的質量。使該練習面向資料很重要。我們選擇了一些我們認為會突出ARM64 CQ潛在問題的基準。我們從維護的微基準開始,其中大約有1300個基準。
  我們比較了每個基準測試的ARM64和x64效能數字。奇偶校驗不是我們的目標,但是,有一個基準進行比較總是很有用的,尤其是用於識別異常值。然後,我們確定效能最差的基準,並確定為什麼會這樣。我們嘗試使用一些分析器,例如WPAPerfView但在這種情況下它們沒有用。那些剖析器會指出給定基準中最熱門的方法。但是,由於MicroBenchmarks是使用最多1〜2種方法的微小基準,因此探查器指出的最熱方法主要是基準方法本身。因此,為了瞭解ARM64 CQ問題,我們決定只檢查為給定基準所產生的彙編程式碼,並將其與x64彙編進行比較。這將有助於我們確定RyuJIT的ARM64程式碼生成器中的基本問題。
  • ARM64中的記憶體屏障

  通過一些基準測試,我們注意到 volatile 類的關鍵方法的熱迴圈中易失性變數的訪問。訪問ARM64的易失性變數非常昂貴,因為它們引入了記憶體屏障指令。通過快取volatile變數並將其儲存在迴圈外部的區域性變數dotnet / runtime#34225dotnet / runtime#36976dotnet / runtime#37081中,可以提高效能,如下所示。所有的測量單位都是納秒。

System.Collections.Concurrent.ConcurrentDictionary

  • ARM記憶體模型

  ARM體系結構具有弱有序的記憶體模型。處理器可以重新排序記憶體訪問指令以提高效能。它可以重新排列指令,以減少處理器訪問記憶體所需的時間。指令的寫入順序不受保證,而是可以根據給定指令的儲存器訪問成本來執行。這種方法不會影響單核計算機,但會對在多核計算機上執行的多執行緒程式產生負面影響。在這種情況下,會有指令告訴處理器不要在給定點重新安排記憶體訪問。限制這種重新排列的這種指令的技術術語稱為“記憶體屏障”。ARM64中的dmb指令充當了一個屏障,阻止處理器將指令移動到柵欄之外。您可以在ARM開發人員文件中閱讀更多關於它的內容。

  在程式碼中指定新增記憶體屏障的一種方法是使用volatile變數使用volatile,可以確保執行時,JIT和處理器不會重新安排對記憶體位置的讀寫,以提高效能。為此,dmb每次對volatile變數進行訪問(讀/寫)時,RyuJIT都會為ARM64發出(資料儲存屏障)指令

  • ARM64和大常量

  在.NET5中,我們對處理使用者程式碼中存在的大常量的方式進行了一些改進。我們開始消除dotnet / runtime# 39096中大常量的冗餘負載,這為我們為所有.NET庫生成的ARM64程式碼的大小提供了大約1%的精確度(準確地說是521K位元組)。

  值得注意的是,有時JIT的改進不會在微基準測試執行中得到體現,但會對整體程式碼質量有所幫助。在這種情況下,RyuJIT團隊報告了.NET庫程式碼大小方面的改進。RyuJIT在更改前後都在整個.NET庫dll上執行,以瞭解優化產生了多大的影響,以及哪些庫比其他庫進行了更多的優化。從預覽版8開始,用於ARM64目標的整個.NET庫的發出程式碼大小為45 MB。1%的改進意味著.NET 5中我們減少了450 KB的程式碼,這是相當可觀的。您可以在此處看到改進的方法的數量。

  ARM64具有指令集體系結構(ISA),具有固定長度的編碼,每條指令的長度恰好為32位。因此,移動指令mov僅具有空間來編碼最多16位無符號常量。要移動更大的常量值,我們需要使用16位塊(逐步移動該值因此,生成了多個指令以構造一個更大的常數,該常數需要儲存在暫存器中。或者,在x64中,單個可以載入更大的常量。movz/movkmovmov。

窺孔分析

  資料驅動工程方法,用於發現其他重要的ARM64程式碼質量增強並對其進行優先順序排序。當用幾個基準檢查為.NET庫生成的ARM64程式碼時,我們意識到有幾種指令模式可以用更好,效能更高的指令代替。在編譯器文獻中,“窺孔優化”是進行此類優化的階段。RyuJIT當前沒有窺視孔優化階段。新增新的編譯器階段是一項艱鉅的任務,並且很容易花費數月的時間才能使其正確完成,同時又不影響其他指標(如JIT吞吐量)。此外,我們不確定程式碼的大小或加快這種優化的速度能為我們帶來多少。因此,我們以一種有趣的方式收集了資料,以發現窺視孔優化中的各種機會並確定其優先順序。我們寫了一個實用工具AnalyzeAsm它將掃描大約1GB的檔案,其中包含.NET庫方法的ARM64反彙編程式碼,並報告我們感興趣的指令模式及其使用的方法的頻率。有了這些資訊,對於我們來說,確定窺視孔優化階段的最小實施非常重要變得更加容易。使用AnalyzeAsm,我們發現了一些窺孔,它們可以使.NET庫的程式碼大小大致提高0.75%。在.NET 5中,我們通過消除dotnet / runtime#38179中的冗餘相反mov指令來優化指令模式,這為我們提供了0.28%程式碼大小的改善。從百分比的角度來看,改進並不大,但是對於整個產品而言,它們卻是有意義的。

  • 用【ldp】替換【ldr】對
  • 用【stp】替換【str】對
  • 用【str xzr】替換【str wzr】對
  • 刪除冗餘的【ldr】和【str】
  • 將【 ldr】替換為【mov】
  • 使用movz / movk載入大常量
  • 呼叫間接和虛擬存根
Techempower基準

  在Techempower基準測試中顯著改善了ARM64效能。以下是針對請求/秒的度量(越高越好)

結論

  在.NET 5中,我們在提高ARM64目標的速度和程式碼大小方面取得了長足的進步。我們不僅在.NET API中公開了ARM64內在函式,而且還在我們的庫程式碼中使用了它們以優化關鍵方法。通過我們的資料驅動工程方法,我們能夠對.NET 5中具有高影響力的工作專案進行優先順序排序。在進行效能調查時,我們還發現了dotnet / runtime#35853中總結的一些機會,我們計劃繼續為.NET工作。 6.我們與Arm Holdings的@TamarChristinaArm建立了良好的合作關係,他們不僅實現了一些ARM64硬體內在函式,而且還提供了寶貴的建議和反饋以提高我們的程式碼質量。我們要感謝多個貢獻者,他們使得能夠釋出在ARM64目標上執行的.NET 5成為可能。

我們鼓勵大家下載適用於ARM64的.NET 5最新版本,並讓我們知道您的反饋。

 


參考文獻:

  • https://devblogs.microsoft.com/dotnet/Arm64-performance-in-net-5/
  • 適用於ARMv8-A的ARM Cortex-A系列程式設計師指南 https://developer.arm.com/documentation/den0024/a/memory-ordering

 

相關文章