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

鄭子銘發表於2023-03-07

原文 | Stephen Toub

翻譯 | 鄭子銘

程式碼生成 (Code generation)

.NET 7的regex實現有不少於四個引擎:直譯器(如果你不明確選擇其他引擎,你會得到什麼),編譯器(你用RegexOptions.Compiled得到什麼),非回溯引擎(你用RegexOptions.NonBacktracking得到什麼),以及源生成器(你用[GeneratedRegex(..)]得到什麼)。直譯器和非反向追蹤引擎不需要任何型別的程式碼生成;它們都是基於建立記憶體中的資料結構,表示如何根據模式匹配輸入。不過,其他兩個都會生成特定於模式的程式碼;生成的程式碼試圖模仿你可能寫的程式碼,如果你根本不使用Regex,而是直接寫程式碼來執行類似的匹配。原始碼生成器吐出的是直接編譯到你的彙編中的C#,而編譯器在執行時透過反射emit吐出IL。這些都是針對模式生成的程式碼,這意味著有大量的機會可以最佳化。

dotnet/runtime#59186提供了原始碼生成器的初始實現。這是編譯器的直接移植,有效地將IL逐行翻譯成C#;結果是C#,類似於你透過ILSpy等反編譯器執行生成的IL。一系列的PR接著對原始碼生成器進行了迭代和調整,但最大的改進來自於對編譯器和原始碼生成器的共同改變。在.NET 5之前,編譯器吐出的IL與直譯器的工作非常相似。直譯器收到了一系列指令,它逐一進行解釋,而編譯器收到了同樣的一系列指令,只是發出了處理每個指令的IL。它有一些提高效率的機會,如迴圈解卷,但很多價值被留在了桌子上。在.NET 5中,為了支援沒有回溯的模式,增加了另一種路徑;這種程式碼路徑是基於被解析的節點樹,而不是基於一系列的指令,這種更高層次的形式使編譯器能夠獲得更多關於模式的見解,然後可以用來生成更有效的程式碼。在.NET 7中,對所有regex特性的支援都是在多個PR的過程中逐步加入的,特別是dotnet/runtime#60385用於回溯單字元迴圈,dotnet/runtime#61698用於回溯單字元懶惰迴圈,dotnet/runtime#61784用於其他回溯懶惰迴圈,dotnet/runtime#61906用於其他回溯迴圈以及回引和條件。在這一點上,唯一缺少的功能是對RegexOptions.RightToLeft和lookbehinds的支援(這是以從右到左的方式實現的),而且我們根據這些功能相對較少的使用情況決定,我們沒有必要為了啟用它們而保留舊的編譯器程式碼。所以,dotnet/runtime#62318刪除了舊的實現。但是,儘管這些功能相對較少,但說一個 "支援所有模式 "的故事比說一個需要特殊呼叫和異常的故事要容易得多,所以dotnet/runtime#66127dotnet/runtime#66280新增了完整的lookbehind和RightToLeft支援,這樣就不會有回溯了。在這一點上,編譯器和原始碼生成器現在都支援編譯器以前所做的一切,但現在有了更現代化的程式碼生成。這種程式碼生成反過來又使之前討論的許多最佳化成為可能,例如,它提供了使用LastIndexOf等API作為回溯的一部分的機會,這在以前的方法中幾乎是不可能的。

原始碼生成器發出成語C#的好處之一是它使迭代變得容易。每次你輸入一個模式並看到生成器發出的東西,就像被要求對別人的程式碼進行審查一樣,你經常看到一些值得評論的 "新 "東西,或者在這種情況下,改進生成器以解決這個問題。因此,一堆PR的起源是基於審查生成器發出的東西,然後調整生成器以做得更好(由於編譯器實際上是和源生成器一起完全重寫的,它們保持相同的結構,很容易從一個移植到另一個的改進)。例如,dotnet/runtime#68846dotnet/runtime#69198調整了一些比較的執行方式,以便向JIT傳達足夠的資訊,從而消除一些後續的邊界檢查,dotnet/runtime#68490識別了在一些可靜態觀察的情況下不可能發生的各種條件,並能夠消除所有這些程式碼基因。同樣明顯的是,有些模式不需要掃描迴圈的全部表現力,可以使用更緊湊和定製的掃描實現。dotnet/runtime#68560做到了這一點,例如,像hello這樣的簡單模式根本不會發出一個迴圈,而會有一個更簡單的掃描實現,比如。

protected override void Scan(ReadOnlySpan<char> inputSpan)
{
    if (TryFindNextPossibleStartingPosition(inputSpan))
    {
        // The search in TryFindNextPossibleStartingPosition performed the entire match.
        int start = base.runtextpos;
        int end = base.runtextpos = start + 5;
        base.Capture(0, start, end);
    }
}

例如,dotnet/runtime#63277教原始碼生成器如何確定是否允許使用不安全的程式碼,如果允許,它會為核心邏輯發出[SkipLocalsInit];匹配例程可能導致許多locals被髮出,而SkipLocalsInit可以使呼叫函式的成本降低,因為需要更少的歸零。然後還有程式碼生成的地方的問題;我們希望輔助函式(像dotnet/runtime#62620中介紹的IsWordChar輔助函式)可以在多個生成的regex中共享,如果相同的模式/選項/超時組合在同一個程式集的多個地方使用,我們希望能夠共享完全相同的regex實現(dotnet/runtime#66747),但這樣做會使這個實現細節暴露給同一個程式集的使用者程式碼。為了仍然能夠獲得這種程式碼共享的好處,同時避免由此產生的複雜情況,dotnet/runtime#66432,然後dotnet/runtime#71765教原始碼生成器使用C#11中新的檔案本地型別特性(dotnet/roslyn#62375)。

最後一個有趣的程式碼生成方面是圍繞字元類匹配進行的最佳化。匹配字元類,無論是開發者明確編寫的字元類,還是引擎隱含建立的字元類(例如,作為尋找可以開始表示式的所有字符集的一部分),都可能是匹配中比較耗時的一個方面;如果你想象一下必須對輸入的每個字元評估這個邏輯,那麼作為匹配字元類的一部分,需要執行多少條指令直接關係到執行整個匹配的時間。例如,dotnet/runtime#67365改進了一些在現實世界中常見的情況,比如特別識別[\d\D]、[\s\S]和[\w\W]這樣的集合意味著 "匹配任何東西"(就像RegexOptions.Singleline模式中的.一樣),在這種情況下,圍繞處理 "匹配任何東西 "的現有最佳化可以啟動。

private static readonly string s_haystack = new string('a', 1_000_000);
private Regex _regex = new Regex(@"([\s\S]*)", RegexOptions.Compiled);

[Benchmark]
public Match Match() => _regex.Match(s_haystack);
方法 執行時 平均值 比率
Match .NET 6.0 1,934,393.69 ns 1.000
Match .NET 7.0 91.80 ns 0.000

或者dotnet/runtime#68924,它教原始碼生成器如何在生成的輸出中使用所有新的char ASCII輔助方法,如char.IsAsciiLetterOrDigit,以及一些它還不知道的現有輔助方法;例如這樣。

[GeneratedRegex(@"[A-Za-z][A-Z][a-z][0-9][A-Za-z0-9][0-9A-F][0-9a-f][0-9A-Fa-f]\p{Cc}\p{L}[\p{L}\d]\p{Ll}\p{Lu}\p{N}\p{P}\p{Z}\p{S}")]

現在,在源生成器發出的核心匹配邏輯中產生這種情況。

if ((uint)slice.Length < 17 ||
    !char.IsAsciiLetter(slice[0]) || // Match a character in the set [A-Za-z].
    !char.IsAsciiLetterUpper(slice[1]) || // Match a character in the set [A-Z].
    !char.IsAsciiLetterLower(slice[2]) || // Match a character in the set [a-z].
    !char.IsAsciiDigit(slice[3]) || // Match '0' through '9'.
    !char.IsAsciiLetterOrDigit(slice[4]) || // Match a character in the set [0-9A-Za-z].
    !char.IsAsciiHexDigitUpper(slice[5]) || // Match a character in the set [0-9A-F].
    !char.IsAsciiHexDigitLower(slice[6]) || // Match a character in the set [0-9a-f].
    !char.IsAsciiHexDigit(slice[7]) || // Match a character in the set [0-9A-Fa-f].
    !char.IsControl(slice[8]) || // Match a character in the set [\p{Cc}].
    !char.IsLetter(slice[9]) || // Match a character in the set [\p{L}].
    !char.IsLetterOrDigit(slice[10]) || // Match a character in the set [\p{L}\d].
    !char.IsLower(slice[11]) || // Match a character in the set [\p{Ll}].
    !char.IsUpper(slice[12]) || // Match a character in the set [\p{Lu}].
    !char.IsNumber(slice[13]) || // Match a character in the set [\p{N}].
    !char.IsPunctuation(slice[14]) || // Match a character in the set [\p{P}].
    !char.IsSeparator(slice[15]) || // Match a character in the set [\p{Z}].
    !char.IsSymbol(slice[16])) // Match a character in the set [\p{S}].
{
    return false; // The input didn't match.
}

其他影響字元類程式碼生成的變化包括:dotnet/runtime#72328,它改進了對涉及字元類減法的字元類的處理;來自@teo-tsirpanisdotnet/runtime#72317,它使生成器可以避免發出點陣圖查詢的額外情況。dotnet/runtime#67133,它增加了一個更嚴格的邊界檢查,當它確實發出這樣一個查詢表時;以及 dotnet/runtime#61562,它使引擎內部表示中的字元類得到更好的規範化,從而導致下游的最佳化更好地識別更多的字元類。

最後,隨著所有這些對Regex的改進,大量的PR以各種方式修復了在dotnet/runtime中使用的Rgex。 dotnet/runtime#66142,來自@Clockwork-Musedotnet/runtime#66179,以及來自@Clockwork-Musedotnet/runtime#62325都將Regex的使用轉為使用[GeneratedRegex(..)]。dotnet/runtime#68961以各種方式最佳化了其他用法。PR用IsMatch(...)替換了幾個regex.Matches(...).Success的呼叫,因為使用IsMatch的開銷較少,因為不需要構建Match例項,而且能夠避免非回溯引擎中計算精確邊界和捕獲資訊的昂貴階段。PR還用EnumerateMatches替換了一些Match/Match.MoveNext的使用,以避免需要Match物件的分配。公報還完全刪除了至少一個與更便宜的IndexOf一樣的鉸鏈用法。 dotnet/runtime#68766還刪除了RegexOptions.CultureInvariant的用法。指定CultureInvariant會改變IgnoreCase的行為,即交替使用大小寫表;如果沒有指定IgnoreCase,也沒有內聯的大小寫敏感選項((?i)),那麼指定CultureInvariant就是一個nop。但這有可能是一個昂貴的選擇。對於任何注重規模的程式碼來說,Regex實現的結構方式是儘量使其對小規模使用者友好。如果你只做new Regex(pattern),我們真的希望能夠靜態地確定編譯器和非反向追蹤的實現是不需要的,這樣修剪者就可以刪除它而不產生可見的和有意義的負面影響。然而,修剪器的分析還沒有複雜到可以準確地看到哪些選項被使用,並且只在使用RegexOptions.Compiled或RegexOptions.NonBacktracking時保留額外的引擎連結;相反,任何使用需要RegexOptions的過載都會導致該程式碼繼續被引用。透過擺脫這些選項,我們增加了應用程式中沒有程式碼使用這個建構函式的機會,這反過來會使這個建構函式、編譯器和非回溯實現被裁剪掉。

集合 (Collections)

System.Collections在.NET 7中的投資並沒有像以前的版本那樣多,儘管許多低階別的改進也對集合產生了涓滴效應。例如,Dictionary<,>的程式碼在.NET 6和.NET 7之間沒有變化,但即便如此,這個基準還是集中在字典的查詢上。

private Dictionary<int, int> _dictionary = Enumerable.Range(0, 10_000).ToDictionary(i => i);

[Benchmark]
public int Sum()
{
    Dictionary<int, int> dictionary = _dictionary;
    int sum = 0;

    for (int i = 0; i < 10_000; i++)
    {
        if (dictionary.TryGetValue(i, out int value))
        {
            sum += value;
        }
    }

    return sum;
}

顯示出.NET 6和.NET 7之間的吞吐量有可觀的改善。

方法 執行時 平均值 比率 程式碼大小
Sum .NET 6.0 51.18 us 1.00 431 B
Sum .NET 7.0 43.44 us 0.85 413 B

除此之外,在集合的其他地方也有明確的改進。例如,ImmutableArray。作為提醒,ImmutableArray是一個非常薄的基於結構的包裝,圍繞著T[],隱藏了T[]的可變性;除非你使用不安全的程式碼,否則ImmutableArray的長度和淺層內容都不會改變(我說的淺層是指直接儲存在該陣列中的資料不能被改變,但如果陣列中儲存有可變參考型別,這些例項本身仍然可能有其資料被改變)。因此,ImmutableArray也有一個相關的 "builder "型別,它確實支援突變:你建立builder,填充它,然後將內容轉移到ImmutableArray中,它就永遠凍結了。在來自@grbell-msdotnet/runtime#70850中,構建器的排序方法被改為使用span,這又避免了IComparer分配和Comparison分配,同時還透過從每個比較中移除幾層間接因素來加快排序本身。

private ImmutableArray<int>.Builder _builder = ImmutableArray.CreateBuilder<int>();

[GlobalSetup]
public void Setup()
{
    _builder.AddRange(Enumerable.Range(0, 1_000));
}

[Benchmark]
public void Sort()
{
    _builder.Sort((left, right) => right.CompareTo(left));
    _builder.Sort((left, right) => left.CompareTo(right));
}
方法 執行時 平均值 比率
Sort .NET 6.0 86.28 us 1.00
Sort .NET 7.0 67.17 us 0.78

dotnet/runtime#61196來自@lateapexearlyspeed,它將ImmutableArray帶入了基於span的時代,為ImmutableArray新增了大約10個新方法,這些方法與span和ReadOnlySpan互操作。從效能的角度來看,這些方法很有價值,因為它意味著如果你在span中擁有你的資料,你可以將其放入ImmutableArray中,而不會產生除ImmutableArray本身將建立的分配之外的額外分配。來自@RaymondHuydotnet/runtime#66550也為不可變集合構建器新增了一堆新方法,為替換元素和新增、插入和刪除範圍等操作提供了高效的實現。

SortedSet在.NET 7中也有一些改進。例如,SortedSet內部使用紅/黑樹作為其內部資料結構,它使用Log2操作來確定在給定節點數下樹的最大深度。以前,這個操作是作為一個迴圈實現的。但由於@teo-tsirpanisdotnet/runtime#58793,該操作現在只需呼叫BitOperations.Log2,如果支援多個硬體本徵(例如Lzcnt.LeadingZeroCount、ArmBase.LeadingZeroCount、X86Base.BitScanReverse),則可透過這些本徵實現。來自@johnthcalldotnet/runtime#56561透過簡化處理樹中節點的迭代方式,提高了SortedSet的複製效能。

[Params(100)]
public int Count { get; set; }

private static SortedSet<string> _set;

[GlobalSetup]
public void GlobalSetup()
{
    _set = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
    for (int i = 0; i < Count; i++)
    {
        _set.Add(Guid.NewGuid().ToString());
    }
}

[Benchmark]
public SortedSet<string> SortedSetCopy()
{
    return new SortedSet<string>(_set, StringComparer.OrdinalIgnoreCase);
}
方法 執行時 平均值 比率
SortedSetCopy .NET 6.0 2.397 us 1.00
SortedSetCopy .NET 7.0 2.090 us 0.87

最後一個要看的集合的PR:dotnet/runtime#67923。ConditionalWeakTable<TKey, TValue>是一個大多數開發者沒有使用過的集合,但是當你需要它時,你就需要它。它主要用於兩個目的:將額外的狀態與一些物件相關聯,以及維護物件的弱集合。從本質上講,它是一個執行緒安全的字典,不維護它所儲存的任何東西的強引用,但確保與一個鍵相關的值將保持根基,只要相關的鍵是根基的。它暴露了許多與 ConcurrentDictionary<,> 相同的 API,但是對於向集合中新增專案,它歷來只有一個 Add 方法。這意味著如果消費程式碼的設計需要嘗試將集合作為一個集合,其中重複是很常見的,當嘗試新增一個已經存在於集合中的專案時,也會經常遇到異常。現在,在.NET 7中,它有一個TryAdd方法,可以實現這樣的使用,而不可能產生這種異常的代價(也不需要新增try/catch塊來抵禦這些異常)。

語言整合查詢 (LINQ)

讓我們繼續討論語言整合查詢 (Language-Integrated Query )(LINQ)。LINQ是一個幾乎每個.NET開發者都會使用的生產力特性。它使複雜的操作能夠被簡單地表達出來,無論是透過語言整合查詢語法還是透過直接使用System.Linq.Enumerable上的方法。然而,這種生產力和表現力是以一定的開銷為代價的。在絕大多數情況下,這些成本(如委託和閉包分配、委託呼叫、在任意列舉物件上使用介面方法與直接訪問索引器和長度/計數屬性等)不會產生重大影響,但對於真正的熱點路徑,它們可以而且確實以一種有意義的方式出現。這導致一些人宣佈LINQ在他們的程式碼庫中是被廣泛禁止的。在我看來,這是一種誤導;LINQ是非常有用的,有它的位置。在.NET中,我們使用了LINQ,只是在使用的地方上比較實際和周到,避免在我們已經最佳化為輕量級和快速的程式碼路徑中使用它,因為預期這些程式碼路徑可能對消費者很重要。因此,雖然LINQ本身的效能可能不如手工滾動的解決方案那麼快,但我們仍然非常關心LINQ的實現效能,以便它能在越來越多的地方被使用,並且在使用它的地方儘可能地減少開銷。在LINQ的操作之間也有差異;有200多個提供各種功能的過載,其中一些過載根據其預期用途,比其他過載受益於更多的效能調整。

dotnet/runtime#64470是分析各種現實世界程式碼庫中Enumerable.Min和Enumerable.Max使用情況的結果,並看到在陣列中使用這些程式碼是非常普遍的,通常是那些相當大的陣列。這個PR更新了Min(IEnumerable)和Max(IEnumerable)的過載,當輸入是int[]或long[]時,使用Vector進行向量處理。這樣做的淨效果是,對於較大的陣列來說,執行時間明顯加快,但即使對於短的陣列來說,效能仍有提高(因為現在實現能夠直接訪問陣列,而不是透過enumerable,導致更少的分配和介面排程,以及更適用的最佳化,如內聯)。

[Params(4, 1024)]
public int Length { get; set; }

private IEnumerable<int> _source;

[GlobalSetup]
public void Setup() => _source = Enumerable.Range(1, Length).ToArray();

[Benchmark]
public int Min() => _source.Min();

[Benchmark]
public int Max() => _source.Max();
方法 執行時 長度 平均值 比率 已分配 分配比率
Min .NET 6.0 4 26.167 ns 1.00 32 B 1.00
Min .NET 7.0 4 4.788 ns 0.18 0.00
Max .NET 6.0 4 25.236 ns 1.00 32 B 1.00
Max .NET 7.0 4 4.234 ns 0.17 0.00
Min .NET 6.0 1024 3,987.102 ns 1.00 32 B 1.00
Min .NET 7.0 1024 101.830 ns 0.03 0.00
Max .NET 6.0 1024 3,798.069 ns 1.00 32 B 1.00
Max .NET 7.0 1024 100.279 ns 0.03 0.00

然而,PR的一個更有趣的方面是,有一行是為了幫助處理非陣列的情況。在效能最佳化中,特別是在增加 "快速路徑 "以更好地處理某些情況時,幾乎總是有一個贏家和一個輸家:贏家是最佳化所要幫助的情況,而輸家是所有其他的情況,這些情況在確定是否採取改進的路徑時受到必要的檢查。一個對陣列進行特殊處理的最佳化,通常看起來像。

if (source is int[] array)
{
    ProcessArray(array);
}
else
{
    ProcessEnumerable(source);
}

然而,如果你看一下PR,你會發現if條件實際上是。

if (source.GetType() == typeof(int[]))

怎麼會呢?在程式碼流程中的這一點上,我們知道source不是空的,所以我們不需要額外的空檢查。然而,這與真正的影響相比是次要的,那就是對陣列協方差的支援。你可能會驚訝地發現,除了int[]之外,還有一些型別可以滿足source is int的檢查......試著執行Console.WriteLine((object)new uint[42] is int[]);,你會發現它列印出True。(這也是.NET執行時和C#語言在型別系統方面存在分歧的罕見情況。如果你把Console.WriteLine((object)new uint[42] is int[]);改為Console.WriteLine(new uint[42] is int[]);,也就是去掉(object)的轉換,你會發現它開始列印出False而不是True。這是因為C#編譯器認為uint[]不可能成為int[],因此將檢查完全最佳化為常數false)。因此,作為型別檢查的一部分,執行時不得不做更多的工作,而不僅僅是與int[]的已知型別身份進行簡單的比較。我們可以透過檢視為這兩個方法生成的程式集看到這一點(後者假設我們已經對輸入進行了空值檢查,在這些LINQ方法中是這樣的)。

public IEnumerable<object> Inputs { get; } = new[] { new object() };

[Benchmark]
[ArgumentsSource(nameof(Inputs))]
public bool M1(object o) => o is int[];

[Benchmark]
[ArgumentsSource(nameof(Inputs))]
public bool M2(object o) => o.GetType() == typeof(int[]);

這就造成了。

; Program.M1(System.Object)
       sub       rsp,28
       mov       rcx,offset MT_System.Int32[]
       call      qword ptr [System.Runtime.CompilerServices.CastHelpers.IsInstanceOfAny(Void*, System.Object)]
       test      rax,rax
       setne     al
       movzx     eax,al
       add       rsp,28
       ret
; Total bytes of code 34

; Program.M2(System.Object)
       mov       rax,offset MT_System.Int32[]
       cmp       [rdx],rax
       sete      al
       movzx     eax,al
       ret
; Total bytes of code 20

注意前者涉及到對JIT的CastHelpers.IsInstanceOfAny輔助方法的呼叫,而且它沒有被內聯。這反過來又影響了效能。

private IEnumerable<int> _source = (int[])(object)new uint[42];

[Benchmark(Baseline = true)]
public bool WithIs() => _source is int[];

[Benchmark]
public bool WithTypeCheck() => _source.GetType() == typeof(int[]);
方法 平均值 比率 程式碼大小
WithIs 1.9246 ns 1.000 215 B
WithTypeCheck 0.0013 ns 0.001 24 B

當然,這兩種操作在語義上並不等同,所以如果這是為需要前者語義的東西,我們就不能使用後者。但是在這個LINQ效能最佳化的案例中,我們可以選擇只最佳化int[]的情況,放棄int[]實際上是uint[](或者例如DayOfWeek[])這種超級罕見的情況,並將最佳化IEnumerable輸入而不是int[]的效能懲罰降到最低,只用幾條快速指令。

這一改進在dotnet/runtime#64624中得到了進一步的發展,它擴大了支援的輸入型別和利用的操作。首先,它引入了一個私有助手,用於從某些型別的IEnumerable輸入中提取ReadOnlySpan,即今天那些實際上是T[]或List的輸入;與之前的PR一樣,它使用GetType() == typeof(T[])形式,以避免對其他輸入的顯著懲罰。這兩種型別都能為實際的儲存提取ReadOnlySpan,在T[]的情況下是透過轉換,在List的情況下是透過.NET 5中引入的CollectionsMarshal.AsSpan方法。一旦我們有了這個跨度,我們就可以做一些有趣的事情。這個PR。

  • 擴充套件了之前的Min(IEnumerable)和Max(IEnumerable)最佳化,不僅適用於int[]和long[],也適用於List和List
  • 為Average(IEnumerable)和Sum(IEnumerable)使用直接跨距訪問,適用於int、long、float、double或decimal,所有陣列和列表。
  • 類似地,對Min(IEnumerable)和Max(IEnumerable)使用直接的跨度訪問,適用於T是浮點數、雙數和小數。
  • 對陣列和列表的Average(IEnumerable)進行向量化。

這方面的影響在微觀基準中是很明顯的,比如說

private static float[] CreateRandom()
{
    var r = new Random(42);
    var results = new float[10_000];
    for (int i = 0; i < results.Length; i++)
    {
        results[i] = (float)r.NextDouble();
    }
    return results;
}

private IEnumerable<float> _floats = CreateRandom();

[Benchmark]
public float Sum() => _floats.Sum();

[Benchmark]
public float Average() => _floats.Average();

[Benchmark]
public float Min() => _floats.Min();

[Benchmark]
public float Max() => _floats.Max();
方法 執行時 平均值 比率 已分配 分配比率
Sum .NET 6.0 39.067 us 1.00 32 B 1.00
Sum .NET 7.0 14.349 us 0.37 0.00
Average .NET 6.0 41.232 us 1.00 32 B 1.00
Average .NET 7.0 14.378 us 0.35 0.00
Min .NET 6.0 45.522 us 1.00 32 B 1.00
Min .NET 7.0 9.668 us 0.21 0.00
Max .NET 6.0 41.178 us 1.00 32 B 1.00
Max .NET 7.0 9.210 us 0.22 0.00

之前的LINQ PR是來自於使現有操作更快的例子。但有時效能的提高來自於新的API,這些API在某些情況下可以用來代替以前的API,以進一步提高效能。一個這樣的例子來自於@deeprobindotnet/runtime#70525中引入的新的API,然後在dotnet/runtime#71564中得到了改進。LINQ中最流行的方法之一是Enumerable.OrderBy(及其逆序OrderByDescending),它可以建立一個輸入列舉的排序副本。為此,呼叫者向OrderBy傳遞一個Func<TSource,TKey>謂詞,OrderBy用它來提取每個專案的比較鍵。然而,想要以自己為鍵對專案進行排序是比較常見的;這畢竟是Array.Sort等方法的預設值,在這種情況下,OrderBy的呼叫者最終會傳入一個身份函式,例如OrderBy(x => x)。為了消除這個障礙,.NET 7引入了新的Order和OrderDescending方法,根據Distinct和DistinctBy等對的精神,執行同樣的排序操作,只是隱含了一個代表呼叫者的x => x。但除了效能之外,這樣做的一個好處是,實現者知道鍵將與輸入相同,它不再需要為每個專案呼叫回撥以檢索其鍵,也不需要分配一個新的陣列來儲存這些鍵。因此,如果你發現自己在使用LINQ,並達到OrderBy(x => x),考慮使用Order(),並獲得(主要是分配)的好處。

[Params(1024)]
public int Length { get; set; }

private int[] _arr;

[GlobalSetup]
public void Setup() => _arr = Enumerable.Range(1, Length).Reverse().ToArray();

[Benchmark(Baseline = true)]
public void OrderBy()
{
    foreach (int _ in _arr.OrderBy(x => x)) { }
}

[Benchmark]
public void Order()
{
    foreach (int _ in _arr.Order()) { }
}
方法 長度 平均值 比率 已分配 分配比率
OrderBy 1024 68.74 us 1.00 12.3 KB 1.00
Order 1024 66.24 us 0.96 8.28 KB 0.67

檔案輸入輸出 (File I/O)

.NET 6有一些巨大的檔案I/O改進,特別是對FileStream進行了完全重寫。雖然.NET 7沒有任何單一的變化,但它確實有大量的改進,可衡量的 "移動針",而且是以不同的方式。

效能改進的一種形式也被偽裝成可靠性改進,就是提高對取消請求的響應速度。取消的速度越快,系統就能越快地歸還正在使用的寶貴資源,等待該操作完成的事情也就能越快地被解禁。在.NET 7中已經有了一些類似的改進。

在某些情況下,它來自於新增了可取消的過載,而這些東西以前根本就不是可取消的。來自@bgraingerdotnet/runtime#61898就是這種情況,它新增了TextReader.ReadLineAsync和TextReader.ReadToEndAsync的新的可取消過載,這包括這些方法在StreamReader和StringReader上的過載;來自@bgraingerdotnet/runtime#64301又在TextReader返回的NullStreamReader型別上過載了這些方法(以及其他缺少過載)。 Null和StreamReader.Null(有趣的是,這些被定義為兩種不同的型別,這是不必要的,所以這個PR也統一了讓兩者都使用StreamReader的變體,因為它滿足了兩者所需的型別)。你可以在dotnet/runtime#66492中看到這一點被很好地利用,它來自@lateapexearlyspeed,它新增了一個新的File.ReadLinesAsync方法。這個方法產生一個檔案中的行的IAsyncEnumerable,基於一個圍繞新的StreamReader.ReadLineAsync過載的簡單迴圈,因此本身是完全可取消的。

不過,從我的角度來看,更有趣的形式是當一個現有的過載據稱是可取消的,但實際上不是。例如,基本的Stream.ReadAsync方法只是包裝了Stream.BeginRead/EndRead方法,而這些方法是不可取消的,所以如果一個Stream派生型別沒有覆蓋ReadAsync,試圖取消對其ReadAsync的呼叫將是非常有效的。它對取消進行了預先檢查,如果在呼叫之前請求取消,它將被立即取消,但在檢查之後,提供的CancellationToken將被有效地忽略。隨著時間的推移,我們已經試圖消除所有剩餘的這種情況,但仍有一些零星的情況存在。一個有害的情況是關於管道的。在這次討論中,有兩種相關的管道,匿名的和命名的,它們在.NET中被表示為一對流。AnonymousPipeClientStream/AnonymousPipeServerStream和NamedPipeClientStream/NamedPipeServerStream。另外,在Windows上,作業系統對為同步I/O開啟的控制程式碼和為重疊I/O(又稱非同步I/O)開啟的控制程式碼進行了區分,這在.NET API中得到了反映:你可以根據構造時指定的PipeOptions.Asynchronous選項為同步或重疊I/O開啟一個命名管道。而且,在Unix上,命名的管道,與它們的命名相反,實際上是在Unix域套接字之上實現的。現在是一些歷史。

  • .NET框架4.8:沒有取消支援。管道流派生型別甚至沒有覆蓋ReadAsync或WriteAsync,所以它們得到的只是預設的取消的前期檢查,然後標記被忽略。
  • .NET Core 1.0。在Windows上,透過為非同步I/O開啟一個命名的管道,完全支援取消。該實現將註冊CancellationToken,並在取消請求時,對與非同步操作相關的NativeOverlapped*使用CancelIoEx。在Unix上,用套接字實現的命名管道,如果管道是用PipeOptions.Asynchronous開啟的,實現將透過輪詢來模擬取消:而不是簡單地發出Socket.ReceiveAsync/Socket.SendAsync(這是不可能的)。 SendAsync(當時不能取消),它將排隊一個工作專案到ThreadPool,該工作專案將執行一個輪詢迴圈,用一個小的超時來呼叫Socket.Poll,檢查令牌,然後迴圈再做,直到Poll顯示操作將成功或被請求取消。在Windows和Unix上,除了用Asynchronous開啟的命名管道外,在操作被啟動後,取消是一個nop。
  • .NET Core 2.1。在Unix上,該實現被改進以避免輪詢迴圈,但它仍然缺乏一個真正可取消的Socket.ReceiveAsync/Socket.SendAsync。相反,此時Socket.ReceiveAsync支援零位元組讀取,呼叫者可以將一個零長度的緩衝區傳遞給ReceiveAsync,並將其作為資料可用的通知,而無需實際消費。然後,Unix的非同步命名管道流的實現改變為發出零位元組的讀取,並將等待該操作的任務和請求取消時將完成的任務的Task.WhenAny。好多了,但離理想還很遠。
  • .NET Core 3.0。在Unix上,Socket得到了真正可取消的ReceiveAsync和SendAsync方法,非同步命名管道被更新為利用。在這一點上,Windows和Unix的實現在取消方面是一致的;兩者都適合於非同步命名的管道,而對其他一切都只是擺設。
  • .NET 5:在Unix上,SafeSocketHandle被公開了,它可以為一個任意提供的SafeSocketHandle建立一個Socket,這使得建立的Socket實際上是指一個匿名管道。這使得Unix上的每一個PipeStream都可以用Socket來實現,這使得ReceiveAsync/SendAsync對於匿名和命名的管道都可以完全取消,而不管它們是如何被開啟的。

所以到了.NET 5,這個問題在Unix上得到了解決,但在Windows上仍然是個問題。直到現在。在.NET 7中,由於dotnet/runtime#72503(以及隨後在dotnet/runtime#72612中的調整),我們已經使其餘的操作在Windows上也可以完全取消。目前,Windows不支援匿名管道的重疊I/O,所以對於匿名管道和為同步I/O開啟的命名管道,Windows的實現將只是委託給基本的Stream實現,它將向ThreadPool排隊一個工作項,以呼叫同步對應項,只是在另一個執行緒。取而代之的是,現在的實現會排隊等待工作項,但不是僅僅呼叫同步方法,而是做一些註冊取消的前後工作,傳入即將執行I/O的執行緒的ID。如果請求取消,實現就會使用CancelSynchronousIo來中斷它。這裡有一個競賽條件,即當執行緒註冊取消時,可以請求取消,這樣CancelSynchronousIo就會在操作實際開始前被呼叫。因此,有一個小的自旋迴圈,如果在註冊發生的時間和實際執行同步I/O的時間之間有取消請求,取消執行緒將自旋,直到I/O被啟動,但這種情況預計會非常罕見。另一邊還有一個競賽條件,即CancelSynchronousIo在I/O已經完成後被請求;為了解決這個競賽,該實現依賴於CancellationTokenRegistration.Dispose的保證,它承諾相關的回撥將永遠不會被呼叫或在Dispose返回時已經完全執行完畢。這個實現不僅完成了拼圖,使Windows和Unix的匿名和命名管道上的所有非同步讀/寫操作都可以取消,而且實際上還提高了正常的吞吐量。

private Stream _server;
private Stream _client;
private byte[] _buffer = new byte[1];
private CancellationTokenSource _cts = new CancellationTokenSource();

[Params(false, true)]
public bool Cancelable { get; set; }

[Params(false, true)]
public bool Named { get; set; }

[GlobalSetup]
public void Setup()
{
    if (Named)
    {
        string name = Guid.NewGuid().ToString("N");
        var server = new NamedPipeServerStream(name, PipeDirection.Out);
        var client = new NamedPipeClientStream(".", name, PipeDirection.In);
        Task.WaitAll(server.WaitForConnectionAsync(), client.ConnectAsync());
        _server = server;
        _client = client;
    }
    else
    {
        var server = new AnonymousPipeServerStream(PipeDirection.Out);
        var client = new AnonymousPipeClientStream(PipeDirection.In, server.ClientSafePipeHandle);
        _server = server;
        _client = client;
    }
}

[GlobalCleanup]
public void Cleanup()
{
    _server.Dispose();
    _client.Dispose();
}

[Benchmark(OperationsPerInvoke = 1000)]
public async Task ReadWriteAsync()
{
    CancellationToken ct = Cancelable ? _cts.Token : default;
    for (int i = 0; i < 1000; i++)
    {
        ValueTask<int> read = _client.ReadAsync(_buffer, ct);
        await _server.WriteAsync(_buffer, ct);
        await read;
    }
}
方法 執行時 可取消 已命名 平均值 比率 已分配 分配比率
ReadWriteAsync .NET 6.0 False False 22.08 us 1.00 400 B 1.00
ReadWriteAsync .NET 7.0 False False 12.61 us 0.76 192 B 0.48
ReadWriteAsync .NET 6.0 False True 38.45 us 1.00 400 B 1.00
ReadWriteAsync .NET 7.0 False True 32.16 us 0.84 220 B 0.55
ReadWriteAsync .NET 6.0 True False 27.11 us 1.00 400 B 1.00
ReadWriteAsync .NET 7.0 True False 13.29 us 0.52 193 B 0.48
ReadWriteAsync .NET 6.0 True True 38.57 us 1.00 400 B 1.00
ReadWriteAsync .NET 7.0 True True 33.07 us 0.86 214 B 0.54

原文連結

Performance Improvements in .NET 7

知識共享許可協議

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

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

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

相關文章