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

原文 | 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/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可發佈。

如有任何疑問,請與我聯繫 ([email protected])

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章