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

鄭子銘發表於2023-03-02

原文 | Stephen Toub

翻譯 | 鄭子銘

最後一個有趣的與IndexOf有關的最佳化。字串早就有了IndexOf/IndexOfAny/LastIndexOf/LastIndexOfAny,顯然對於字串來說,這都是關於處理字元。當ReadOnlySpan和Span出現時,MemoryExtensions被新增進來,為spans和朋友提供擴充套件方法,包括這樣的IndexOf/IndexOfAny/LastIndexOf/LastIndexOfAny方法。但是對於spans來說,這不僅僅是char,所以MemoryExtensions增長了它自己的一套實現,基本上與string的實現分開。多年來,MemoryExtensions的實現已經專門化了越來越多的型別,但特別是位元組和char,這樣一來,隨著時間的推移,string的實現大多被委託到與MemoryExtensions使用的相同的實現中而取代。然而,IndexOfAny和LastIndexOfAny一直是統一的保留者,它們各有自己的方向。string.IndexOfAny對於被搜尋的1-5個值確實委託給與MemoryExtensions.IndexOfAny相同的實現,但是對於超過5個值,string.IndexOfAny使用一個 "機率圖",基本上是一個布魯姆過濾器。它建立了一個256位的表,並根據被搜尋的值快速設定該表中的位(本質上是雜湊,但用一個微不足道的雜湊函式)。然後,它對輸入進行迭代,而不是將每個輸入字元與每個目標值進行對照,而是首先在表中查詢輸入字元。如果相應的位沒有被設定,它就知道輸入的字元與任何目標值都不匹配。如果相應的位被設定,那麼它就會繼續將輸入的字元與每個目標值進行比較,它很有可能是其中之一。MemoryExtensions.IndexOfAny對5個以上的值缺乏這樣的過濾器。相反,string.LastIndexOfAny沒有為多個目標值提供任何向量,而MemoryExtensions.LastIndexOfAny則為兩個和三個目標值提供向量。從dotnet/runtime#63817開始,所有這些現在都是統一的,這樣字串和MemoryExtensions都得到了對方的優點。

private readonly char[] s_target = new[] { 'z', 'q' };
const string Sonnet = """
    Shall I compare thee to a summer's day?
    Thou art more lovely and more temperate:
    Rough winds do shake the darling buds of May,
    And summer's lease hath all too short a date;
    Sometime too hot the eye of heaven shines,
    And often is his gold complexion dimm'd;
    And every fair from fair sometime declines,
    By chance or nature's changing course untrimm'd;
    But thy eternal summer shall not fade,
    Nor lose possession of that fair thou ow'st;
    Nor shall death brag thou wander'st in his shade,
    When in eternal lines to time thou grow'st:
    So long as men can breathe or eyes can see,
    So long lives this, and this gives life to thee.
    """;

[Benchmark]
public int LastIndexOfAny() => Sonnet.LastIndexOfAny(s_target);

[Benchmark]
public int CountLines()
{
    int count = 0;
    foreach (ReadOnlySpan<char> _ in Sonnet.AsSpan().EnumerateLines())
    {
        count++;
    }

    return count;
}
方法 執行時 平均值 比率
LastIndexOfAny .NET 6.0 443.29 ns 1.00
LastIndexOfAny .NET 7.0 31.79 ns 0.07
CountLines .NET 6.0 1,689.66 ns 1.00
CountLines .NET 7.0 1,461.64 ns 0.86

同樣的PR也清理了IndexOf系列的使用,特別是在檢查包含性而不是檢查結果的實際索引的使用。IndexOf系列的方法在找到一個元素時返回一個非負值,否則返回-1。這意味著當檢查一個元素是否被找到時,程式碼可以使用>=0或!=-1,而當檢查一個元素是否被找到時,程式碼可以使用< 0或==-1。 事實證明,針對0產生的比較程式碼比針對-1產生的比較要稍微有效一些,這不是JIT可以自己替代的,因為IndexOf方法是內在的,這樣JIT就可以理解返回值的語義。因此,為了一致性和少量的效能提升,所有相關的呼叫站點都被切換為與0而不是與-1比較。

說到呼叫站點,擁有高度最佳化的IndexOf方法的好處之一是在所有可以受益的地方使用它們,消除開放編碼替換的維護影響,同時也收穫了perf的勝利。 dotnet/runtime#63913在StringBuilder.Replace裡面使用IndexOf來加速尋找下一個要替換的字元。

private StringBuilder _builder = new StringBuilder(Sonnet);

[Benchmark]
public void Replace()
{
    _builder.Replace('?', '!');
    _builder.Replace('!', '?');
}
方法 執行時 平均值 比率
Replace .NET 6.0 1,563.69 ns 1.00
Replace .NET 7.0 70.84 ns 0.04

dotnet/runtime#60463來自@nietras在StringReader.ReadLine中使用IndexOfAny來搜尋'\r'和'\n'行結束字元,這導致了一些可觀的吞吐量提升,即使是在方法設計中固有的分配和複製。

[Benchmark]
public void ReadAllLines()
{
    var reader = new StringReader(Sonnet);
    while (reader.ReadLine() != null) ;
}
方法 執行時 平均值 比率
ReadAllLines .NET 6.0 947.8 ns 1.00
ReadAllLines .NET 7.0 385.7 ns 0.41

dotnet/runtime#70176清理了大量的額外用途。

最後,在IndexOf方面,如前所述,多年來在最佳化這些方法方面花費了大量的時間和精力。在以前的版本中,其中一些精力是以直接使用硬體本徵的形式出現的,例如,有一個SSE2程式碼路徑和一個AVX2程式碼路徑以及一個AdvSimd程式碼路徑。現在我們有了Vector128和Vector256,許多這樣的使用可以被簡化(例如,避免SSE2實現和AdvSimd實現之間的重複),同時仍然保持同樣好甚至更好的效能,同時自動支援其他平臺上的向量化,有自己的本徵,如WebAssembly。dotnet/runtime#73481, dotnet/runtime#73556, dotnet/runtime#73368, dotnet/runtime#73364, dotnet/runtime#73064, and dotnet/runtime#73469都在這方面做出了貢獻,在某些情況下產生了有意義的吞吐量的提升。

[Benchmark]
public int IndexOfAny() => Sonnet.AsSpan().IndexOfAny("!.<>");
方法 執行時 平均值 比率
IndexOfAny .NET 6.0 52.29 ns 1.00
IndexOfAny .NET 7.0 40.17 ns 0.77

IndexOf系列只是字串/記憶體擴充套件中的一個,它已經有了很大的改進。另一個是SequenceEquals系列,包括Equals, StartsWith, 和EndsWith。在整個版本中,我最喜歡的一個變化是dotnet/runtime#65288,它正處於這個領域。我們經常看到對StartsWith等方法的呼叫,這些方法有一個恆定的字串引數,例如value.StartsWith("https://"),value.SequenceEquals("Key"),等等。這些方法現在可以被JIT識別,它現在可以自動展開比較,並一次比較多個字元,例如,將四個字元作為一個長字串進行一次讀取,並將該長字串與這四個字元的預期組合進行一次比較。其結果是美麗的。dotnet/runtime#66095使它變得更好,它增加了對OrdinalIgnoreCase的支援。還記得之前討論過的char.IsAsciiLetter和朋友們的那些ASCII位扭動的技巧嗎?JIT現在採用了同樣的技巧作為解卷的一部分,所以如果你做同樣的value.StartsWith("https://"),但改為value.StartsWith("https://", StringComparison.OrdinalIgnoreCase),它將認識到整個比較字串是ASCII,並將在比較常數和從輸入的讀取資料上進行適當的遮蔽,以便以不分大小寫的方式執行比較。

private string _value = "https://dot.net";

[Benchmark]
public bool IsHttps_Ordinal() => _value.StartsWith("https://", StringComparison.Ordinal);

[Benchmark]
public bool IsHttps_OrdinalIgnoreCase() => _value.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
方法 執行時 平均值 比率
IsHttps_Ordinal .NET 6.0 4.5634 ns 1.00
IsHttps_Ordinal .NET 7.0 0.4873 ns 0.11
IsHttps_OrdinalIgnoreCase .NET 6.0 6.5654 ns 1.00
IsHttps_OrdinalIgnoreCase .NET 7.0 0.5577 ns 0.08

有趣的是,從.NET 5開始,由RegexOptions.Compiled生成的程式碼在比較多個字元的序列時將執行類似的unrolling,而當原始碼生成器在.NET 7中被新增時,它也學會了如何做這個。然而,由於位元組數的原因,原始碼生成器在這種最佳化方面存在問題。被比較的常量會受到位元組排序問題的影響,因此原始碼生成器需要發出的程式碼可以處理在小位元組或大位元組機器上的執行。JIT沒有這樣的問題,因為它是在將執行程式碼的同一臺機器上生成程式碼的(在它被用來提前生成程式碼的情況下,整個程式碼已經與特定的架構繫結)。透過將這種最佳化轉移到JIT中,相應的程式碼可以從RegexOptions.Compiled和regex原始碼生成器中刪除,然後利用StartsWith生成更容易閱讀的程式碼,其速度也同樣快(dotnet/runtime#65222dotnet/runtime#66339)。勝利就在身邊。(這隻能在dotnet/runtime#68055之後從RegexOptions.Compiled中移除,它修復了JIT在DynamicMethods中識別這些字串字面的能力,RegexOptions.Compiled使用反射emit來吐出正在編譯的regex的IL。)

dotnet/runtime#63734(由dotnet/runtime#64530進一步改進)增加了另一個非常有趣的基於JIT的最佳化,但要理解它,我們需要理解字串的內部佈局。字串在記憶體中基本上表示為一個int length,後面是許多字元和一個空終止符。實際的System.String類在C#中表示為一個int _stringLength欄位和一個char _firstChar欄位,這樣_firstChar確實與字串的第一個字元一致,如果字串為空,則為空終止符。在System.Private.CoreLib內部,特別是在字串本身的方法中,當需要查詢第一個字元時,程式碼通常會直接引用_firstChar,因為這樣做通常比使用str[0]更快,特別是因為不涉及邊界檢查,而且通常不需要查詢字串的長度。現在,考慮一個類似於字串上的public bool StartsWith(char value)的方法。在.NET 6中,其實現方式是。

return Length != 0 && _firstChar == value;

考慮到我剛才描述的情況,這是有道理的:如果Length是0,那麼字串就不是以指定的字元開始的,如果Length不是0,那麼我們就可以把這個值與_firstChar進行比較。但是,為什麼還需要Length檢查呢?難道我們不能直接返回_firstChar == value;嗎?這將避免額外的比較和分支,而且工作得很好......除非目標字元本身是'\0',在這種情況下,我們可能會在結果中得到誤報。現在說說這個PR。這個PR引入了一個內部的JIT intrinsinc RuntimeHelpers.IsKnownConstant,如果包含的方法被內聯,並且傳遞給IsKnownConstant的引數被認為是一個常量,JIT會將其替換為true。在這種情況下,實現可以依靠其他JIT最佳化來啟動和最佳化方法中的各種程式碼,有效地使開發者能夠編寫兩種不同的實現,一種是當引數是常數時,另一種是不常數。有了這些,PR能夠對StartsWith進行如下最佳化。

public bool StartsWith(char value)
{
    if (RuntimeHelpers.IsKnownConstant(value) && value != '\0')
        return _firstChar == value;

    return Length != 0 && _firstChar == value;
}

如果引數值不是一個常量,那麼IsKnownConstant將被替換為false,整個起始if塊將被刪除,而方法將被完全保留。但是,如果這個方法被內聯,並且值實際上是一個常量,那麼值!='\0'的條件也將在JIT-編譯時被評估。如果值確實是'\0',那麼,整個if塊將被消除,我們也不會更糟。但在常見的情況下,如果值不是空的,整個方法最終會被編譯成空的。

return _firstChar == ConstantValue;

這樣我們就省去了讀取字串的長度、比較和分支的過程。 dotnet/runtime#69038然後對EndsWith採用了類似的技術。

private string _value = "https://dot.net";

[Benchmark]
public bool StartsWith() =>
    _value.StartsWith('a') ||
    _value.StartsWith('b') ||
    _value.StartsWith('c') ||
    _value.StartsWith('d') ||
    _value.StartsWith('e') ||
    _value.StartsWith('f') ||
    _value.StartsWith('g') ||
    _value.StartsWith('i') ||
    _value.StartsWith('j') ||
    _value.StartsWith('k') ||
    _value.StartsWith('l') ||
    _value.StartsWith('m') ||
    _value.StartsWith('n') ||
    _value.StartsWith('o') ||
    _value.StartsWith('p');
方法 執行時 平均值 比率
StartsWith .NET 6.0 8.130 ns 1.00
StartsWith .NET 7.0 1.653 ns 0.20

(另一個使用IsKnownConstant的例子來自dotnet/runtime#64016,它在指定MidpointRounding模式時使用它來改進Math.Round。這方面的呼叫站點幾乎總是明確地將列舉值指定為常量,然後允許JIT將方法的程式碼生成專用於正在使用的特定模式;這反過來又使Arm64上的Math.Round(..., MidpointRounding.AwayFromZero)呼叫降低為一條frinta指令)。

EndsWith在dotnet/runtime#72750中也得到了改進,特別是當StringComparison.OrdinalIgnoreCase被指定時。這個簡單的PR只是切換了用於實現該方法的內部輔助方法,利用了一個足以滿足該方法需求且開銷較低的方法的優勢。

[Benchmark]
[Arguments("System.Private.CoreLib.dll", ".DLL")]
public bool EndsWith(string haystack, string needle) =>
    haystack.EndsWith(needle, StringComparison.OrdinalIgnoreCase);
方法 執行時 平均值 比率
EndsWith .NET 6.0 10.861 ns 1.00
EndsWith .NET 7.0 5.385 ns 0.50

最後,dotnet/runtime#67202dotnet/runtime#73475採用了Vector128和Vector256來代替直接使用硬體本徵,就像之前為各種IndexOf方法展示的那樣,但這裡分別為SequenceEqual和SequenceCompareTo。

在.NET 7中,另一個方法似乎受到了一些關注,那就是MemoryExtensions.Reverse(以及Array.Reverse,因為它共享相同的實現),它可以執行目標跨度的就地反轉。來自@alexcovingtondotnet/runtime#64412透過直接使用AVX2和SSSE3硬體本徵,提供了一個向量化的實現,來自@SwapnilGaikwaddotnet/runtime#72780跟進,為 Arm64增加了一個AdvSimd本徵實現。(最初的向量化變化引入了一個意外的迴歸,但這被dotnet/runtime#70650所修復)。

private char[] text = "Free. Cross-platform. Open source.\r\nA developer platform for building all your apps.".ToCharArray();

[Benchmark]
public void Reverse() => Array.Reverse(text);
方法 執行時 平均值 比率
Reverse .NET 6.0 21.352 ns 1.00
Reverse .NET 7.0 9.536 ns 0.45

String.Split在dotnet/runtime#64899中也看到了來自@yesmey的向量化改進。與之前討論的一些PR一樣,它將現有的SSE2和SSSE3硬體本徵的使用切換到了新的Vector128幫助器上,在改進現有實現的同時也隱含了對Arm64的向量化支援。

轉換各種格式的字串是許多應用程式和服務都會做的事情,無論是從UTF8位元組轉換到字串還是格式化和解析十六進位制值。這類操作在.NET 7中也有不同程度的改進。例如,Base64編碼是一種在只支援文字的媒介上表示任意二進位制資料(想想byte[])的方法,將位元組編碼為64個不同的ASCII字元之一。.NET中的多個API實現了這種編碼。為了在以ReadOnlySpan表示的二進位制資料和同樣以ReadOnlySpan表示的UTF8(實際上是ASCII)編碼資料之間進行轉換,System.Buffers.Text.Base64型別提供EncodeToUtf8和DecodeFromUtf8方法。這些方法在幾個版本前就已經向量化了,但在.NET 7中透過@a74nhdotnet/runtime#70654得到了進一步改進,它將基於SSSE3的實現轉換為使用Vector128(這又隱含地在Arm64上實現了向量化)。然而,為了在以ReadOnlySpan/byte[]和ReadOnlySpan/char[]/string表示的任意二進位制資料之間進行轉換,System.Convert型別暴露了多種方法,例如Convert.ToBase64String,而這些方法在歷史上並沒有被向量化。這種情況在.NET 7中有所改變,dotnet/runtime#71795dotnet/runtime#73320將ToBase64String、ToBase64CharArray和TryToBase64Chars方法向量化。他們這樣做的方式很有意思。他們沒有有效地複製Base64.EncodeToUtf8的向量化實現,而是在EncodeToUtf8之上,呼叫它將輸入的位元組資料編碼成輸出的Span。然後,他們將這些位元組 "拓寬 "為字元(記住,Base64編碼的資料是一組ASCII字元,所以從這些位元組到字元需要在每個元素上新增一個0位元組)。這種拓寬本身可以很容易地以向量的方式完成。這種分層的另一個有趣之處在於,它實際上並不要求對編碼的位元組進行單獨的中間儲存。實現可以完美地計算出將X位元組編碼為Y個Base64字元的結果字元數(有一個公式),實現可以分配該最終空間(例如在ToBase64CharArray的情況下)或確保提供的空間是足夠的(例如在TryToBase64Chars的情況下)。因為我們知道初始編碼需要的位元組數正好是一半,所以我們可以編碼到相同的空間(目標跨度被重新解釋為位元組跨度而不是char跨度),然後 "就地 "擴容:從位元組的末端和char空間的末端走,把位元組複製到目標空間。

方法 執行時 平均值 比率
TryToBase64Chars .NET 6.0 623.25 ns 1.00
TryToBase64Chars .NET 7.0 81.82 ns 0.13

就像加寬可以用來從位元組到字元,縮小可以用來從字元到位元組,特別是如果字元實際上是ASCII,因此有一個0的上位位元組。這種縮小可以被向量化,內部的NarrowUtf16ToAscii工具助手正是這樣做的,作為Encoding.ASCII.GetBytes等方法的一部分使用。雖然這個方法以前是向量化的,但它的主要快速路徑利用了SSE2,因此不適用於Arm64;由於@SwapnilGaikwaddotnet/runtime#70080,該路徑被改變為基於跨平臺的Vector128,使其在支援的平臺上具有相同水平的最佳化。同樣,來自@SwapnilGaikwaddotnet/runtime#71637為GetIndexOfFirstNonAsciiChar內部助手新增了Arm64向量化,該助手被Encoding.UTF8.GetByteCount等方法使用。(同樣,dotnet/runtime#67192將內部HexConverter.EncodeToUtf16方法從使用SSSE3本徵改為使用Vector128,自動提供一個Arm64實現)。

Encoding.UTF8也得到了一些改進。特別是,dotnet/runtime#69910精簡了GetMaxByteCount和GetMaxCharCount的實現,使其小到可以在直接使用Encoding.UTF8時被普遍內聯,這樣JIT就能對呼叫進行虛擬化。

[Benchmark]
public int GetMaxByteCount() => Encoding.UTF8.GetMaxByteCount(Sonnet.Length);
方法 執行時 平均值 比率
GetMaxByteCount .NET 6.0 1.7442 ns 1.00
GetMaxByteCount .NET 7.0 0.4746 ns 0.27

可以說,.NET 7中圍繞UTF8的最大改進是C# 11對UTF8字樣的新支援。UTF8字頭最初在dotnet/roslyn#58991的C#編譯器中實現,隨後在dotnet/roslyn#59390dotnet/roslyn#61532dotnet/roslyn#62044中實現,UTF8字頭使編譯器在編譯時執行UTF8編碼到位元組。開發者不需要寫一個普通的字串,例如 "hello",而是簡單地將新的u8字尾附加到字串字面,例如 "hello "u8。在這一點上,這不再是一個字串。相反,這個表示式的自然型別是一個ReadOnlySpan。如果你寫

public static ReadOnlySpan<byte> Text => "hello"u8;

C#編譯器會編譯,相當於你寫的。

public static ReadOnlySpan<byte> Text =>
    new ReadOnlySpan<byte>(new byte[] { (byte)'h', (byte)'e', (byte)'l', (byte)'l', (byte)'o', (byte)'\0' }, 0, 5);    

換句話說,編譯器在編譯時做了相當於Encoding.UTF8.GetBytes的工作,並對所得位元組進行了硬編碼,節省了在執行時進行編碼的成本。當然,乍一看,這種陣列分配可能看起來效率很低。然而,外表可能是騙人的,在這種情況下就是如此。在幾個版本中,當C#編譯器看到一個位元組[](或sbyte[]或bool[])被初始化為一個恆定的長度和恆定的值,並立即被轉換為或用於構造一個ReadOnlySpan時,它會最佳化掉位元組[]的分配。相反,它將該跨度的資料混合到彙編的資料部分,然後構造一個跨度,直接指向載入的彙編中的資料。這就是上述屬性的實際生成的IL。

IL_0000: ldsflda valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=6' '<PrivateImplementationDetails>'::F3AEFE62965A91903610F0E23CC8A69D5B87CEA6D28E75489B0D2CA02ED7993C
IL_0005: ldc.i4.5
IL_0006: newobj instance void valuetype [System.Runtime]System.ReadOnlySpan`1<uint8>::.ctor(void*, int32)
IL_000b: ret

這意味著我們不僅節省了執行時的編碼成本,而且我們不僅避免了儲存結果資料可能需要的託管分配,我們還受益於JIT能夠看到關於編碼資料的資訊,比如它的長度,從而實現連帶最佳化。透過檢查為一個方法生成的彙編,你可以清楚地看到這一點。

public static int M() => Text.Length;

為之,JIT產生了。

; Program.M()
       mov       eax,5
       ret
; Total bytes of code 6

JIT內聯屬性訪問,看到跨度的長度是5,所以它沒有發出任何陣列分配或跨度構建或任何類似的東西,而是簡單地輸出mov eax, 5來返回跨度的已知長度。

主要由於dotnet/runtime#70568, dotnet/runtime#69995, dotnet/runtime#70894, dotnet/runtime#71417 來自 @am11, dotnet/runtime#71292, dotnet/runtime#70513, and dotnet/runtime#71992, u8現在在整個dotnet/runtime中被使用超過2100次。這幾乎不是一個公平的比較,但下面的基準測試表明,在執行時,u8實際執行的工作是多麼少。

[Benchmark(Baseline = true)]
public ReadOnlySpan<byte> WithEncoding() => Encoding.UTF8.GetBytes("test");

[Benchmark] 
public ReadOnlySpan<byte> Withu8() => "test"u8;
方法 平均值 比率 已分配 分配比率
WithEncoding 17.3347 ns 1.000 32 B 1.00
Withu8 0.0060 ns 0.000 0.00

就像我說的,不公平,但它證明了這一點

編碼當然只是建立字串例項的一種機制。其他機制在.NET 7中也得到了改進。以超級常見的long.ToString為例。以前的版本改進了int.ToString,但32位和64位的演算法之間有足夠的差異,所以long沒有看到所有相同的收益。現在由於dotnet/runtime#68795的出現,64位的格式化程式碼路徑與32位的更加相似,從而使效能更快。

你也可以看到string.Format和StringBuilder.AppendFormat的改進,以及其他在這些之上的輔助工具(如TextWriter.AppendFormat)。 dotnet/runtime#69757檢修了Format內部的核心例程,以避免不必要的邊界檢查,支援預期情況,並普遍清理了實現。然而,它也利用IndexOfAny來搜尋下一個需要填入的插值孔,如果非孔字元與孔的比例很高(例如,長的格式字串有很少的孔),它可以比以前快很多。

private StringBuilder _sb = new StringBuilder();

[Benchmark]
public void AppendFormat()
{
    _sb.Clear();
    _sb.AppendFormat("There is already one outstanding '{0}' call for this WebSocket instance." +
                     "ReceiveAsync and SendAsync can be called simultaneously, but at most one " +
                     "outstanding operation for each of them is allowed at the same time.",
                     "ReceiveAsync");
}
方法 執行時 平均值 比率
AppendFormat .NET 6.0 338.23 ns 1.00
AppendFormat .NET 7.0 49.15 ns 0.15

說到StringBuilder,除了前面提到的對AppendFormat的修改之外,它還看到了額外的改進。一個有趣的變化是dotnet/runtime#64405,它實現了兩個相關的事情。首先是取消了作為格式化操作一部分的釘子。舉例來說,StringBuilder有一個Append(char* value, int valueCount)過載,它將指定的字元數從指定的指標複製到StringBuilder中,其他API也是以這個方法實現的;例如,Append(string? value, int startIndex, int count)方法基本上被實現為。

fixed (char* ptr = value)
{
    Append(ptr + startIndex, count);
}

這個固定的宣告轉化為一個 "釘住指標 (pinning pointer)"。通常情況下,GC可以自由地在堆上移動被管理的物件,它可能這樣做是為了壓縮堆(例如,避免物件之間出現小的、不可用的記憶體碎片)。但是,如果GC可以移動物件,一個正常的本地指標進入該記憶體將是非常不安全和不可靠的,因為在沒有注意到的情況下,被指向的資料可能會移動,你的指標現在可能指向垃圾或其他被轉移到該位置的物件。有兩種方法來處理這個問題。第一種是 "託管指標 (managed pointer)",也被稱為 "引用 "或 "ref",因為這正是你在C#中使用 "ref "關鍵字時得到的東西;它是一個指標,當執行時移動被指向的物件時,它將用正確的值進行更新。第二種是防止被指向的物件被移動,將其 "釘 "在原地。這就是 "固定 "關鍵字的作用,在固定塊的持續時間內固定被引用的物件,在此期間,使用所提供的指標是安全的。值得慶幸的是,在沒有發生GC的情況下,釘住是很便宜的;然而,當GC發生時,被釘住的物件不能被移動,因此,釘住會對應用程式的效能(以及GC本身)產生全面的影響。釘住也會抑制各種最佳化。隨著C#的進步,可以在更多的地方使用ref(例如ref locals、ref returns,以及現在C# 11中的ref fields),以及.NET中所有用於操作ref的新API(例如Unsafe.Add、Unsafe.AreSame),現在可以重寫使用pinning指標的程式碼,轉而使用託管指標,從而避免了pinning帶來的問題。這就是這個PR所做的。與其用Append(char*, int)幫助器來實現所有的Append方法,不如用Append(ref char, int)幫助器來實現它們。因此,舉例來說,之前顯示的Append(string?value, int startIndex, int count)實現,現在變成了類似於

Append(ref Unsafe.Add(ref value.GetRawStringData(), startIndex), count);

其中string.GetRawStringData方法只是公共的string.GetPinnableReference方法的內部版本,返回一個ref,而不是一個只讀的ref。這意味著StringBuilder內部所有的高效能程式碼都可以繼續使用指標來避免邊界檢查等,但現在也不用釘住所有的輸入了。

這個StringBuilder的變化所做的第二件事是統一了對字串輸入的最佳化,也適用於char[]輸入和ReadOnlySpan輸入。具體來說,由於向StringBuilder追加字串例項是很常見的,所以很久以前就有一個特殊的程式碼路徑來最佳化這種輸入,特別是在StringBuilder中已經有足夠的空間來容納整個輸入的情況下,此時可以使用一個有效的複製。有了一個共享的Append(ref char, int)幫助器,這種最佳化可以下移到該幫助器中,這樣它不僅可以幫助字串,而且還可以幫助任何其他呼叫相同幫助器的型別。這方面的效果在一個簡單的微測試中可以看到。

private StringBuilder _sb = new StringBuilder();

[Benchmark]
public void AppendSpan()
{
    _sb.Clear();
    _sb.Append("this".AsSpan());
    _sb.Append("is".AsSpan());
    _sb.Append("a".AsSpan());
    _sb.Append("test".AsSpan());
    _sb.Append(".".AsSpan());
}
方法 執行時 平均值 比率
AppendSpan .NET 6.0 35.98 ns 1.00
AppendSpan .NET 7.0 17.59 ns 0.49

改進堆疊中低層的東西的一個好處是它們有一個倍增效應;它們不僅有助於提高直接依賴改進功能的使用者程式碼的效能,它們還可以幫助提高核心庫中其他程式碼的效能,然後進一步幫助依賴的應用程式和服務。你可以看到這一點,例如,DateTimeOffset.ToString,它依賴於StringBuilder。

private DateTimeOffset _dto = DateTimeOffset.UtcNow;

[Benchmark]
public string DateTimeOffsetToString() => _dto.ToString();
方法 執行時 平均值 比率
DateTimeOffsetToString .NET 6.0 340.4 ns 1.00
DateTimeOffsetToString .NET 7.0 289.4 ns 0.85

隨後,StringBuilder本身被@teo-tsirpanisdotnet/runtime#64922進一步更新,它改進了Insert方法。過去,StringBuilder上的Append(primitive)方法(例如Append(int))會在值上呼叫ToString,然後追加結果字串。隨著ISpanFormattable的出現,作為一個快速路徑,這些方法現在嘗試直接將值格式化到StringBuilder的內部緩衝區,只有當沒有足夠的剩餘空間時,他們才會採取舊的路徑作為後備。當時Insert並沒有以這種方式進行改進,因為它不能只是格式化到構建器末端的空間;插入的位置可以是構建器中的任何地方。這個PR解決了這個問題,它將格式化到一些臨時的堆疊空間中,然後委託給之前討論過的PR中現有的基於Ref的內部幫助器,將得到的字元插入到正確的位置(當堆疊空間對ISpanFormattable.TryFormat來說不夠時,它也會退回到ToString,但這隻發生在難以置信的角落,比如一個浮點值格式化到數百位數)。

private StringBuilder _sb = new StringBuilder();

[Benchmark]
public void Insert()
{
    _sb.Clear();
    _sb.Insert(0, 12345);
}
方法 執行時 平均值 比率 已分配 分配比率
Insert .NET 6.0 30.02 ns 1.00 32 B 1.00
Insert .NET 7.0 25.53 ns 0.85 0.00

對StringBuilder也做了其他小的改進,比如dotnet/runtime#60406刪除了Replace方法中一個小的int[]分配。不過,即使有了這些改進,StringBuilder最快的用途也沒有用;dotnet/runtime#68768刪除了StringBuilder的一堆用途,這些用途用其他的字串建立機制會更好。例如,傳統的DataView型別有一些程式碼將排序規範建立為一個字串。

private static string CreateSortString(PropertyDescriptor property, ListSortDirection direction)
{
    var resultString = new StringBuilder();
    resultString.Append('[');
    resultString.Append(property.Name);
    resultString.Append(']');
    if (ListSortDirection.Descending == direction)
    {
        resultString.Append(" DESC");
    }
    return resultString.ToString();
}

我們在這裡實際上不需要StringBuilder,因為在最壞的情況下,我們只是將三個字串連線起來,而string.Concat有一個專門的過載,用於這個確切的操作,它有可能是這個操作的最佳實現(如果我們找到了更好的方法,這個方法會被改進)。所以我們可以直接使用這個方法。

private static string CreateSortString(PropertyDescriptor property, ListSortDirection direction) =>
    direction == ListSortDirection.Descending ?
        $"[{property.Name}] DESC" :
        $"[{property.Name}]";

注意,我透過一個插值字串來表達連線,但是C#編譯器會將這個插值字串 "降低 "到對string.Concat的呼叫,所以這個IL與我寫的沒有區別。

private static string CreateSortString(PropertyDescriptor property, ListSortDirection direction) =>
    direction == ListSortDirection.Descending ?
        string.Concat("[", property.Name, "] DESC") :
        string.Concat("[", property.Name, "]");

作為一個旁觀者,擴充套件後的string.Concat版本強調了這個方法如果改為寫成:"IL",那麼它的結果可能會少一點。

private static string CreateSortString(PropertyDescriptor property, ListSortDirection direction) =>
    string.Concat("[", property.Name, direction == ListSortDirection.Descending ? "] DESC" : "]"); 

但這並不影響效能,在這裡,清晰度和可維護性比減少幾個位元組更重要。

[Benchmark(Baseline = true)]
[Arguments("SomeProperty", ListSortDirection.Descending)]
public string WithStringBuilder(string name, ListSortDirection direction)
{
    var resultString = new StringBuilder();
    resultString.Append('[');
    resultString.Append(name);
    resultString.Append(']');
    if (ListSortDirection.Descending == direction)
    {
        resultString.Append(" DESC");
    }
    return resultString.ToString();
}

[Benchmark]
[Arguments("SomeProperty", ListSortDirection.Descending)]
public string WithConcat(string name, ListSortDirection direction) =>
    direction == ListSortDirection.Descending?
        $"[{name}] DESC" :
        $"[{name}]";
方法 平均值 比率 已分配 分配比率
WithStringBuilder 68.34 ns 1.00 272 B 1.00
WithConcat 20.78 ns 0.31 64 B 0.24

還有一些地方,StringBuilder仍然適用,但它被用在足夠熱的路徑上,以至於以前的.NET版本看到StringBuilder例項被快取起來。一些核心庫,包括System.Private.CoreLib,有一個內部的StringBuilderCache型別,它在一個[ThreadStatic]中快取了一個StringBuilder例項,這意味著每個執行緒最終都可能有這樣一個例項。這樣做有幾個問題,包括當StringBuilder沒有被使用時,StringBuilder使用的緩衝區不能用於其他任何東西,而且因為這個原因,StringBuilderCache對可以被快取的StringBuilder例項的容量進行了限制;試圖快取超過這個容量的例項會導致它們被丟棄。最好的辦法是使用不受長度限制的快取陣列,並且每個人都可以訪問這些陣列以進行共享。許多核心的.NET庫都有一個內部的ValueStringBuilder型別,這是一個基於Ref結構的型別,可以使用堆疊分配的記憶體開始,然後如果需要的話,可以增長到ArrayPool陣列。而隨著dotnet/runtime#64522dotnet/runtime#69683的出現,許多剩餘的StringBuilderCache的使用已經被取代。我希望我們能在未來完全刪除StringBuilderCache。

原文連結

Performance Improvements in .NET 7

知識共享許可協議

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

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

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

相關文章