天天看點

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

作者:opendotnet

回複“1”擷取開發者路線圖

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

學習分享 丨作者 / 鄭 子 銘

這是DotNet NB 公衆号的第214篇原創文章

原文 | 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() != ) ;
}
           
方法 運作時 平均值 比率
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#65222和dotnet/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#67202和dotnet/runtime#73475采用了Vector128和Vector256來代替直接使用硬體本征,就像之前為各種IndexOf方法展示的那樣,但這裡分别為SequenceEqual和SequenceCompareTo。

在.NET 7中,另一個方法似乎受到了一些關注,那就是MemoryExtensions.Reverse(以及Array.Reverse,因為它共享相同的實作),它可以執行目标跨度的就地反轉。來自@alexcovington的dotnet/runtime#64412通過直接使用AVX2和SSSE3硬體本征,提供了一個矢量化的實作,來自@SwapnilGaikwad的 dotnet/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中通過@a74nh的dotnet/runtime#70654得到了進一步改進,它将基于SSSE3的實作轉換為使用Vector128(這又隐含地在Arm64上實作了矢量化)。然而,為了在以ReadOnlySpan/byte[]和ReadOnlySpan/char[]/string表示的任意二進制資料之間進行轉換,System.Convert類型暴露了多種方法,例如Convert.ToBase64String,而這些方法在曆史上并沒有被矢量化。這種情況在.NET 7中有所改變,dotnet/runtime#71795和dotnet/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;由于@SwapnilGaikwad的dotnet/runtime#70080,該路徑被改變為基于跨平台的Vector128,使其在支援的平台上具有相同水準的優化。同樣,來自@SwapnilGaikwad的dotnet/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#59390、dotnet/roslyn#61532和dotnet/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-tsirpanis的dotnet/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#64522和dotnet/runtime#69683的出現,許多剩餘的StringBuilderCache的使用已經被取代。我希望我們能在未來完全删除StringBuilderCache。

原文連結

Performance Improvements in .NET 7

推薦閱讀:【譯】.NET 7 中的性能改進(九)
【譯】.NET 7 中的性能改進(八)【譯】.NET 7 中的性能改進(七)
【譯】.NET 7 中的性能改進(六)
.NET 8 Preview 1 中新增的 Random 方法
一個.NET開發的小而美的通用業務型背景管理系統
點選下方卡片關注DotNet NB

一起交流學習


        
【譯】.NET 7 中的性能改進(十)
▲ 點選上方卡片關注DotNet NB,一起交流學習 請在公衆号背景 回複 【路線圖】擷取.NET 2021開發者路線圖回複 【原創内容】擷取公衆号原創内容回複 【峰會視訊】擷取.NET Conf開發者大會視訊回複 【個人簡介】擷取作者個人簡介回複 【年終總結】擷取作者年終總結回複 【加群】加入DotNet NB 交流學習群 和我一起,交流學習,分享心得。
【譯】.NET 7 中的性能改進(十)

繼續閱讀