【譯】.NET 7 中的效能改進(七)

鄭子銘發表於2023-02-27

原文 | Stephen Toub

翻譯 | 鄭子銘

Arm64

在.NET 7中,大量的努力用於使Arm64的程式碼生成與x64的程式碼生成一樣好或更好。我已經討論了一些與架構無關的PR,還有一些是專門針對Arm的,但還有很多。我們來列舉其中的一些。

定址模式 (Addressing modes)

"定址模式 "是指如何指定指令的運算元的術語。它可以是實際的值,也可以是應該載入一個值的地址,還可以是包含該值的暫存器,等等。Arm支援 "縮放 "定址模式,通常用於對陣列進行索引,其中每個元素的大小被提供,指令按指定的比例 "縮放 "所提供的偏移量。dotnet/runtime#60808使JIT能夠利用這種定址模式。更普遍的是,dotnet/runtime#70749使JIT在訪問託管陣列的元素時使用定址模式。dotnet/runtime#66902改進了元素型別為位元組時的定址模式。dotnet/runtime#65468改進了用於浮點的定址模式。dotnet/runtime#67490實現了SIMD向量的定址模式,特別是對於具有未縮放索引的負載。

更好的指令選擇 (Better instruction selection)

dotnet/runtime#61037教JIT如何識別整數的(a * b) + c模式,並將其摺疊成一條madd或msub指令,而dotnet/runtime#66621對a - (b * c)和msub也有同樣的作用。dotnet/runtime#61045使JIT能夠識別某些常數位移操作(在程式碼中顯式或隱式的各種形式的託管陣列訪問)併發出sbfiz/ubfiz指令。dotnet/runtime#70599dotnet/runtime#66407dotnet/runtime#65535都處理了各種形式的最佳化a % b。來自@SeanWoodotnet/runtime#61847刪除了一個不必要的movi,它是設定一個取消引用的指標到一個常量值的一部分。來自@SingleAccretiondotnet/runtime#57926使計算一個64位結果作為兩個32位整數的乘法可以用smull/umull完成。而dotnet/runtime#61549用uxtw/sxtw/lsl將帶符號擴充套件或零擴充套件的加法摺疊成一條加法指令,而dotnet/runtime#62630在ldr指令後丟棄多餘的零擴充套件。

向量化 (Vectorization)

dotnet/runtime#64864增加了新的AdvSimd.LoadPairVector64/AdvSimd.LoadPairVector128硬體本徵。

歸零 (Zeroing)

很多操作都需要將狀態設定為零,比如將一個方法中的所有引用區域性初始化為零,作為該方法序幕的一部分(這樣GC就不會看到並試圖跟蹤垃圾引用)。雖然這種功能以前是向量的,但dotnet/runtime#63422使其能夠在Arm上使用128位寬度的向量指令來實現。而dotnet/runtime#64481改變了用於清零的指令序列,以避免不必要的清零,釋放額外的暫存器,並使CPU能夠識別各種指令序列並更好地最佳化。

記憶體模型 (Memory Model)

dotnet/runtime#62895使儲存障礙儘可能地被使用,而不是完全障礙,並對易失性變數使用單向障礙。dotnet/runtime#67384使易失性讀/寫可以用ldapr指令實現,而dotnet/runtime#64354使用更便宜的指令序列來處理易失性間接操作。還有dotnet/runtime#70600,它能使LSE Atomics用於Interlocked操作;dotnet/runtime#71512,它能在Unix機器上使用atomics指令;以及dotnet/runtime#70921,它能實現同樣的功能,但在Windows上。

JIT助手 (JIT helpers)

雖然在邏輯上是執行時的一部分,但JIT實際上與執行時的其他部分是隔離的,只透過一個介面與之互動,使JIT與VM(虛擬機器)的其他部分進行通訊。那麼,有大量的VM功能是JIT賴以獲得良好效能的。

dotnet/runtime#65738重寫了各種 "存根 (stubs)",使之更有效率。存根是一些微小的程式碼,用於執行一些檢查,然後將執行重定向到其他地方。例如,當一個介面的排程呼叫站點預計只用於該介面的單一實現時,JIT可能會採用一個 "排程存根",將物件的型別與它所快取的單一型別進行比較,如果它們相等,就跳轉到正確的目標。當一個PR包含了執行時所針對的每個架構的大量彙編程式碼時,你就知道你已經進入了執行時的最核心區域。它得到了回報;在我們的自動化效能測試套件中,有一個來自.NET周圍的虛擬小組審查效能改進和迴歸,並將這些歸因於可能是原因的PR(這大部分是自動化的,但需要一些人為的監督)。當一個PR被合併幾天後,效能資訊已經穩定下來,你會看到像這個PR上的大量評論,這總是很好的。

對於任何熟悉泛型並對效能感興趣的人來說,你可能聽說過這樣的說法:泛型虛擬方法是相對昂貴的。相對而言,它們的確很貴。例如,在.NET 6上,這段程式碼。

private Example _example = new Example();

[Benchmark(Baseline = true)] public void GenericNonVirtual() => _example.GenericNonVirtual<Example>();
[Benchmark] public void GenericVirtual() => _example.GenericVirtual<Example>();

class Example
{
    [MethodImpl(MethodImplOptions.NoInlining)]
    public void GenericNonVirtual<T>() { }

    [MethodImpl(MethodImplOptions.NoInlining)]
    public virtual void GenericVirtual<T>() { }
}

結果是。

方法 平均值 比率
GenericNonVirtual 0.4866 ns 1.00
GenericVirtual 6.4552 ns 13.28

dotnet/runtime#65926稍微緩解了一下痛苦。一些成本來自於在執行時的雜湊表中查詢一些快取資訊,就像許多對映實現一樣,這涉及到計算一個雜湊程式碼並使用mod操作來對映到正確的桶。dotnet/runtime周圍的其他雜湊表實現,包括Dictionary<,>、HashSet<,>和ConcurrentDictionary<,>以前都切換到 "fastmod "實現;這個PR對這個EEHashtable也是如此,它被用作CORINFO_GENERIC_HANDLE JIT輔助函式採用的一部分。

方法 執行時 平均值 比率
GenericVirtual .NET 6.0 6.475 ns 1.00
GenericVirtual .NET 7.0 6.119 ns 0.95

改善的程度還不足以讓我們開始推薦人們使用它們,但5%的改善可以讓我們擺脫一點刺痛。

Grab Bag

要涵蓋進入JIT的每一個效能變化幾乎是不可能的,我也不打算嘗試。但是,還有這麼多的效能變化,我不能把它們都置之不理,所以這裡還有一些快報。

dotnet/runtime#58727來自@benjamin-hodgson。給出一個表示式,如(byte)x | (byte)y,可以變形為(byte)(x | y),這可以最佳化一些mov。

private int _x, _y;

[Benchmark]
public int Test() => (byte)_x | (byte)_y;
; *** .NET 6 ***
; Program.Test(Int32, Int32)
       movzx     eax,dl
       movzx     edx,r8b
       or        eax,edx
       ret
; Total bytes of code 10

; *** .NET 7 ***
; Program.Test(Int32, Int32)
       or        edx,r8d
       movzx     eax,dl
       ret
; Total bytes of code 7

dotnet/runtime#67182。在支援BMI2的機器上,可以用shlx、sarx和shrx指令進行64位移位。

[Benchmark]
[Arguments(123, 1)]
public ulong Shift(ulong x, int y) => x << y;
; *** .NET 6 ***
; Program.Shift(UInt64, Int32)
       mov       ecx,r8d
       mov       rax,rdx
       shl       rax,cl
       ret
; Total bytes of code 10

; *** .NET 7 ***
; Program.Shift(UInt64, Int32)
       shlx      rax,rdx,r8
       ret
; Total bytes of code 6

dotnet/runtime#69003來自@SkiFoD。模式~x + 1可以被改變為二元互補的否定。

[Benchmark]
[Arguments(42)]
public int Neg(int i) => ~i + 1;
; *** .NET 6 ***
; Program.Neg(Int32)
       mov       eax,edx
       not       eax
       inc       eax
       ret
; Total bytes of code 7

; *** .NET 7 ***
; Program.Neg(Int32)
       mov       eax,edx
       neg       eax
       ret
; Total bytes of code 5

dotnet/runtime#61412來自@SkiFoD。一個表示式X & 1 == 1來測試一個數字的底層位是否被設定,可以改為更便宜的X & 1(在C#中如果沒有後面的 != 0實際上是無法表達的)。

[Benchmark]
[Arguments(42)]
public bool BitSet(int x) => (x & 1) == 1;
; *** .NET 6 ***
; Program.BitSet(Int32)
       test      dl,1
       setne     al
       movzx     eax,al
       ret
; Total bytes of code 10

; *** .NET 7 ***
; Program.BitSet(Int32)
       mov       eax,edx
       and       eax,1
       ret
; Total bytes of code 6

dotnet/runtime#63545來自@Wraith2。表示式x & (x - 1)可以被降低到blsr指令。

[Benchmark]
[Arguments(42)]
public int ResetLowestSetBit(int x) => x & (x - 1);
; *** .NET 6 ***
; Program.ResetLowestSetBit(Int32)
       lea       eax,[rdx+0FFFF]
       and       eax,edx
       ret
; Total bytes of code 6

; *** .NET 7 ***
; Program.ResetLowestSetBit(Int32)
       blsr      eax,edx
       ret
; Total bytes of code 6

dotnet/runtime#62394。/和%由一個向量的.Count組成,並沒有認識到Count可以是無符號的,但這樣做會導致更好的程式碼基因。

[Benchmark]
[Arguments(42u)]
public long DivideByVectorCount(uint i) => i / Vector<byte>.Count;
; *** .NET 6 ***
; Program.DivideByVectorCount(UInt32)
       mov       eax,edx
       mov       rdx,rax
       sar       rdx,3F
       and       rdx,1F
       add       rax,rdx
       sar       rax,5
       ret
; Total bytes of code 21

; *** .NET 7 ***
; Program.DivideByVectorCount(UInt32)
       mov       eax,edx
       shr       rax,5
       ret
; Total bytes of code 7

dotnet/runtime#60787. .NET 6中的迴圈對齊為JIT處理迴圈對齊的原因和方式提供了一個非常好的探索。這個PR進一步擴充套件了這一點,試圖將發出的對齊指令 "隱藏 "在可能已經存在的無條件jmp後面,以儘量減少處理器必須獲取和解碼nops的影響。

GC

"Regions "是垃圾收集器 (garbage collector)(GC)的一項功能,已經進行了很多年了。從dotnet/runtime#64688開始,它在.NET 7的64位程式中被預設啟用,但與其他多年的功能一樣,大量的PR使它成為現實。在3萬英尺的水平上,"區域 "取代了目前在GC堆上管理記憶體的 "段 "的方法;而不是有幾個巨大的記憶體段(例如,每個1GB),通常與一個世代1:1相關聯,GC代替維護許多,許多較小的區域(例如,每個4MB)作為自己的實體。這使得GC在操作上更加靈活,比如從一代到另一代的記憶體區域的重新使用。關於區域的更多資訊,來自GC主要開發者的博文Put a DPAD on that GC!仍然是最佳資源。

Native AOT (ahead-of-time)

對許多人來說,軟體方面的 "效能 "一詞是指吞吐量。一個東西的執行速度有多快?它每秒鐘能處理多少資料?它每秒能處理多少個請求?等等。但是,效能還有許多其他方面的問題。它需要消耗多少記憶體?它啟動和到達做一些有用的事情的速度有多快?它在磁碟上消耗多少空間?它要花多長時間來下載?然後還有相關的擔憂。為了實現這些目標,需要哪些依賴性?為了實現這些目標,它需要執行什麼樣的操作,而這些操作在目標環境中是否都被允許?如果你對這段話有任何共鳴,你就是現在在.NET 7中提供的本地AOT支援的目標受眾。

長期以來,.NET一直支援AOT程式碼的生成。例如,.NET Framework以ngen的形式支援,而.NET Core以crossgen的形式支援。這兩種解決方案都涉及到一個標準的.NET可執行檔案,它的一些IL已經被編譯為彙編程式碼,但並不是所有的方法都會有彙編程式碼生成,各種事情會使生成的彙編程式碼失效,沒有任何本地彙編程式碼的外部.NET彙編可以被載入,等等,在所有這些情況下,執行時繼續使用JIT編譯器。本地AOT是不同的。它是CoreRT的進化,而CoreRT本身就是.NET Native的進化,它完全不需要JIT。釋出構建的二進位制檔案是一個完全獨立的可執行檔案,採用目標平臺的特定檔案格式(如Windows上的COFF,Linux上的ELF,macOS上的Mach-O),除了該平臺的標準檔案(如libc)外,沒有其他外部依賴。而且它完全是原生的:看不到IL,沒有JIT,什麼都沒有。所有需要的程式碼都被編譯和/或連結到可執行檔案中,包括用於標準.NET應用程式和服務的相同的GC,以及提供執行緒和類似服務的最小執行時間。所有這些都帶來了巨大的好處:超快的啟動時間、小型和完全自包含的部署,以及在JIT編譯器不允許的地方執行的能力(例如,因為可寫的記憶體頁隨後不能執行)。它也帶來了一些限制:沒有JIT意味著不能動態載入任意程式集(如Assembly.LoadFile)和不能反射發射(如DynamicMethod),所有的東西都被編譯和連結到應用程式中,意味著使用(或可能使用)的功能越多,你的部署就越大,等等。即使有這些限制,對於某類應用來說,Native AOT是一個令人激動和歡迎的.NET 7的補充。

在建立Native AOT棧的過程中,有太多的PR需要提及。部分原因是它已經工作了多年(作為已歸檔的dotnet/corert專案的一部分,然後作為dotnet/runtimelab/feature/NativeAOT的一部分),部分原因是自從程式碼最初從dotnet/runtimelab帶到dotnet/runtime#62563dotnet/runtime#62611時,僅在dotnet/runtime就有超過100個PR用於將Native AOT提升到可交付狀態。在這種情況下,再加上沒有以前的版本可以比較它的效能,與其關注逐個PR的改進,不如看看如何使用它和它帶來的好處。

今天,Native AOT專注於控制檯應用,所以我們來建立一個控制檯應用。

dotnet new console -o nativeaotexample

現在我們有了nativeaotexample目錄,其中包含nativeaotexample.csproj和 "hello, world" Program.cs。為了能夠用Native AOT釋出應用程式,編輯.csproj,在現有的...中包含這個。

<PublishAot>true</PublishAot>

然後......實際上,就是這樣。我們的應用程式現在已經完全配置好了,可以針對Native AOT。剩下的就是釋出了。由於我目前是在我的Windows x64機器上寫這篇文章,我將以它為目標。

dotnet publish -r win-x64 -c Release

我現在在輸出的釋出目錄中有我生成的可執行檔案。

    Directory: C:\nativeaotexample\bin\Release\net7.0\win-x64\publish

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a---           8/27/2022  6:18 PM        3648512 nativeaotexample.exe
-a---           8/27/2022  6:18 PM       14290944 nativeaotexample.pdb

這個約3.5MB的.exe是可執行檔案,旁邊的.pdb是除錯資訊,實際上不需要和應用程式一起部署。我現在可以把nativeaotexample.exe複製到任何64位的Windows機器上,無論該機器上是否安裝了.NET,我的應用程式都可以執行。現在,如果你真正關心的是大小,而3.5MB對你來說太大,你可以開始做更多的權衡。你可以將一些開關傳遞給本地AOT編譯器(ILC)和修剪器,這些開關會影響哪些程式碼被包含在結果影像中。讓我把轉盤調高一點。

    <PublishAot>true</PublishAot>

    <InvariantGlobalization>true</InvariantGlobalization>
    <UseSystemResourceKeys>true</UseSystemResourceKeys>

    <IlcOptimizationPreference>Size</IlcOptimizationPreference>
    <IlcGenerateStackTraceData>false</IlcGenerateStackTraceData>

    <DebuggerSupport>false</DebuggerSupport>
    <EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
    <EventSourceSupport>false</EventSourceSupport>
    <HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
    <MetadataUpdaterSupport>false</MetadataUpdaterSupport>

我重新發布,現在我有了。

    Directory: C:\nativeaotexample\bin\Release\net7.0\win-x64\publish

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a---           8/27/2022  6:19 PM        2061824 nativeaotexample.exe
-a---           8/27/2022  6:19 PM       14290944 nativeaotexample.pdb

所以2M而不是3.5MB。當然,為了這個顯著的減少,我已經放棄了一些東西。

  • 將InvariantGlobalization設定為 "true "意味著我現在不尊重文化資訊,而是在大多數全球化操作中使用一組不變的資料。
  • 將UseSystemResourceKeys設定為 "true "意味著將剝離漂亮的異常資訊。
  • 將IlcGenerateStackTraceData設定為false意味著如果我需要除錯一個異常,我將得到相當差的堆疊跟蹤。
  • 將DebuggerSupport設定為false......祝你除錯順利。
  • ...你會明白的。

對於習慣於.NET的開發者來說,Native AOT的一個潛在的令人費解的方面是,正如它在罐子上所說的,它確實是原生的。在釋出應用程式後,沒有IL參與,甚至沒有JIT可以處理它。這使得.NET 7中的一些其他投資更有價值,例如,在原始碼生成器中的所有投資都在發生。以前依靠反射發射獲得良好效能的程式碼將需要另一種方案。我們可以看到,比如說Regex。在歷史上,為了獲得Regex的最佳吞吐量,建議使用RegexOptions.Compiled,它在執行時使用反射emit來生成一個指定模式的最佳化實現。但如果你看一下Regex建構函式的實現,你會發現這個小插曲。

if (RuntimeFeature.IsDynamicCodeCompiled)
{
    factory = Compile(pattern, tree, options, matchTimeout != InfiniteMatchTimeout);
}

在JIT中,IsDynamicCodeCompiled是真的。但在Native AOT中,它是假的。因此,在Native AOT和Regex中,指定RegexOptions.Compiled和不指定RegexOptions.Compiled沒有區別,需要另一種機制來獲得RegexOptions.Compiled所承諾的吞吐量優勢。進入[GeneratedRegex(...)],它與.NET 7 SDK中的新regex源生成器一起,將C#程式碼排放到使用它的程式集中。該C#程式碼取代了在執行時發生的反射發射,因此能夠與Native AOT成功合作。

private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;

private Regex _interpreter = new Regex(@"^.*elementary.*$", RegexOptions.Multiline);

private Regex _compiled = new Regex(@"^.*elementary.*$", RegexOptions.Compiled | RegexOptions.Multiline);

[GeneratedRegex(@"^.*elementary.*$", RegexOptions.Multiline)]
private partial Regex SG();

[Benchmark(Baseline = true)] public int Interpreter() => _interpreter.Count(s_haystack);

[Benchmark] public int Compiled() => _compiled.Count(s_haystack);

[Benchmark] public int SourceGenerator() => SG().Count(s_haystack);
方法 平均值 比率
Interpreter 9,036.7 us 1.00
Compiled 9,064.8 us 1.00
SourceGenerator 426.1 us 0.05

所以,是的,有一些與Native AOT相關的限制,但也有解決這些限制的方法。而且,這些限制實際上可以帶來更多的好處。考慮一下dotnet/runtime#64497。還記得我們在動態PGO中談到的 "受保護的去虛擬化 (guarded devirtualization)"嗎?在這種情況下,JIT可以透過檢測來確定在特定的呼叫地點最可能使用的型別並對其進行特殊處理。在Native AOT中,程式的全部內容在編譯時就已經知道了,不支援Assembly.LoadFrom之類的東西。這意味著在編譯時,編譯器可以進行整個程式分析,以確定哪些型別實現了哪些介面。如果一個給定的介面只有一個實現它的單一型別,那麼透過該介面的每一個呼叫站點都可以無條件地去虛擬化,而不需要任何型別檢查的防護。

這是一個真正令人興奮的空間,我們希望看到它在未來的版本中蓬勃發展。

原文連結

Performance Improvements in .NET 7

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

如有任何疑問,請與我聯絡 (MingsonZheng@outlook.com)

相關文章