[翻譯] .NET 5 效能改善
原文連結:https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-5/
Stephen
2020年7月13日
在.NET Core早期釋出中,我曾寫過關於團隊如何實現了明顯的效能改善。每篇博文,從.NET Core 2.0到.NET Core 2.1再到.NET Core 3.0,我發現我談論的越來越多。說實話我不知道在後面的博文中是否還能提及更多足夠有意義的改善。現在.NET 5已經發布了預覽, 我可以確定地說答案是,有一次,“是的”。.NET 5已經被發現有很多方面的效能提高,儘管如此它還沒有釋出最終版本,
配置
Benchmark.NET現在是測量 .NET 程式碼效能的規範工具,因此分析程式碼段的吞吐量和分配變得簡單。因此,本文中的大多數示例都是使用使用該工具編寫的微基準進行測量的。為了便於在家裡跟進(實際上,現在我們中的許多人),我一開始建立一個目錄,並使用 dotnet 工具來構建它:
mkdir Benchmarks
cd Benchmarks
dotnet new console
我增加了生成的基準.csproj 的內容,如下所示:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <AllowUnsafeBlocks>true</AllowUnsafeBlocks> <ServerGarbageCollection>true</ServerGarbageCollection> <TargetFrameworks>net5.0;netcoreapp3.1;net48</TargetFrameworks> </PropertyGroup> <ItemGroup> <PackageReference Include="benchmarkdotnet" Version="0.12.1" /> </ItemGroup> <ItemGroup Condition=" '$(TargetFramework)' == 'net48' "> <PackageReference Include="System.Memory" Version="4.5.4" /> <PackageReference Include="System.Text.Json" Version="4.7.2" /> <Reference Include="System.Net.Http" /> </ItemGroup> </Project>
這使我能夠針對 .NET 框架 4.8、.NET Core 3.1 和 .NET 5 執行基準測試(我目前為預覽版 8 安裝了夜間生成)。.csproj 還引用Benchmark.NETNuGet 包(最新版本是版本 12.1)以便能夠使用其功能,然後引用其他幾個庫和包,特別是支援能夠在 .NET Framework 4.8 上執行測試。然後,我更新了Program.cs資料夾中生成的檔案,以查詢:
using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Diagnosers; using BenchmarkDotNet.Running; using System; using System.Buffers.Text; using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Security; using System.Net.Sockets; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; [MemoryDiagnoser] public class Program { static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args); // BENCHMARKS GO HERE }
對於每個測試,我複製/貼上每個示例中顯示的基準程式碼,以顯示"//BENCHMARKS 轉到此處"
。
要執行基準測試,我隨後會做:
dotnet run -c Release -f net48 --runtimes net48 netcoreapp31 netcoreapp50 --filter ** --join
這Benchmark.NET:
- 使用 .NET Framework 4.8 表面積構建基準(這是所有三個目標的最低公分母,因此適用於所有這些目標)。
- 針對 .NET 框架 4.8、.NET 核心 3.1 和 .NET 5 的每個基準執行基準。
- 在裝配體中包括所有基準(不要過濾掉任何基準)。
- 將來自所有基準的所有結果的輸出連線在一起,並在執行結束時顯示輸出(而不是在整個過程中穿插)。
在某些情況下,如果特定目標不存在 API,則我只需離開命令列的這一部分。
最後,需要注意一些:
- 我上次的基準帖子是關於 .NET Core 3.0 的。我沒有寫一個有關 .NET Core 3.1 的,因為從執行時和核心庫的角度來看,它比幾個月前釋出的前身相比幾乎沒有改進。但是,還有一些改進,其中,在某些情況下,我們已經將 .NET 5 的回移植改進回 .NET Core 3.1,其中這些更改被認為具有足夠影響力,足以保證被新增到長期支援 (LTS) 版本中。因此,我這裡的所有比較都是針對最新的 .NET Core 3.1 服務版本 (3.1.5), 而不是 .NET Core 3.0。
- 由於比較是關於 .NET 5 和 .NET Core 3.1 的,並且 .NET Core 3.1 不包括單聲道執行時,因此我沒有涵蓋對單聲道以及專門側重於"Blazor"的核心庫改進的改進。因此,當我提到"執行時"時,我指的是 coreclr,儘管截至 .NET 5,其保護傘下有多個執行時,並且所有這些執行時都已得到改進。
- 我的示例大多數是在 Windows 上執行的,因為我想能夠與 .NET Framework 4.8 進行比較。但是,除非另有提及,否則顯示的所有示例都同樣累積到 Windows、Linux 和 macOS。
- 標準警告:此處的所有測量都在我的臺式計算機上,您的里程可能會有所不同。微臺標可以非常敏感於許多因素,包括處理器計數、處理器體系結構、記憶體和快取速度,以及開啟和開啟。但是,一般來說,我專注於效能改進,幷包括通常應承受任何此類差異的示例。
讓我們開始...
GC
對於任何對 .NET 和效能感興趣的人,垃圾回收通常是頭等心。減少分配需要付出很多努力,不是因為分配行為本身特別昂貴,而是因為通過垃圾回收器 (GC) 進行這些分配後清理的後續成本。然而,無論在減少分配方面需要做多少工作,絕大多數工作負載都會產生這些工作量,因此,不斷突破 GC 能夠完成的任務以及速度的邊界非常重要。
此版本在改進 GC 方面付出了很多努力。例如,dotnet/coreclr#25986 為 GC 的"標記"階段實現了一種工作竊取形式。.NET GC 是一個"跟蹤"收集器,這意味著(在非常高的級別)執行時,它從一組"根"(已知可訪問的位置,如靜態欄位)開始,然後從物件遍歷到物件,"標記"每個物件為可訪問物件;在所有這些遍歷之後,任何未標記的物件都無法訪問,可以收集。此標記表示執行集合所花費的很大一部分時間,此 PR 通過更好地平衡集合中涉及的每個執行緒執行的工作來提高標記效能。使用"Server GC"執行時,每個核心的執行緒都涉及集合,當執行緒完成其分配的標記工作部分時,它們現在可以從其他執行緒"竊取"撤消的工作,以幫助更快地完成整個集合。
另一個示例是 dotnet/runtime#35896 優化了"臨時"段上的取消提交(gen0 和 gen1 稱為"臨時",因為它們的物件預計僅持續很短的時間)。取消提交是在段上的最後一個實時物件之後將記憶體頁放回段末尾的作業系統。GC 的問題就變成了,何時應該進行此類取消承諾,以及它應該在任何時間點取消承諾多少,因為它最終可能需要在近期的某種時間點分配額外的頁面以進行額外分配。
或者採取 dotnet/runtime#32795,通過減少 GC 靜態掃描中涉及的鎖爭用,提高了 GC 在具有更高核心計數的計算機上的可伸縮性。或 dotnet/runtime#37894,它避免了代價高昂的記憶體重置(基本上告訴作業系統相關記憶體不再有趣),除非 GC 看到它處於記憶體不足的情況。或 dotnet/runtime#37159,它(雖然尚未合併,預期為 .NET 5)基於 @damageboy 的工作,以向量化 GC 中採用的排序。或 dotnet/coreclr#27729,這減少了 GC 掛起執行緒的時間,這是它獲得穩定檢視以便準確確定正在使用哪些執行緒所必需的。
這只是為改進 GC 本身而所做的部分更改列表,但最後一個要點讓我想到一個特別讓我著迷的話題,因為它說明了我們近年來在 .NET 中所做的許多工作。在此版本中,我們繼續甚至加快了從 C/C++ 在核心clr執行時移植本機實現的過程,而不是系統.private.Corelib 中的普通 C# 託管程式碼。這種移動具有許多好處,包括讓我們更輕鬆地跨多個執行時(如 coreclr 和 mono)共享單個實現,甚至使我們更輕鬆地發展 API 表面積,例如重用相同的邏輯來處理陣列和範圍。但有一件事讓一些人感到意外,就是這種好處還包括效能,在多種方式。這樣一種方式可以追溯到使用託管執行時的原始動機之一:安全。預設情況下,用 C# 編寫的程式碼是"安全的",因為執行時可確保檢查所有記憶體訪問邊界,並且只有程式碼中可見的顯式操作(例如,使用unsafe
關鍵字、Marshal
類、unsafe
類等)才能刪除此類驗證的開發人員。因此,作為開源專案的維護者,當以託管程式碼形式提供捐款時,我們運送安全系統的工作變得容易得多:雖然此類程式碼當然可以包含可能通過程式碼評審和自動測試的 Bug,但我們知道此類 Bug 引入安全問題的機會大大降低,因此我們可以在晚上睡得更好。這反過來又意味著我們更有可能接受對託管程式碼的改進,並且速度更高,貢獻者提供的速度更快,幫助我們進行驗證。我們還發現,當效能改進以 C# 而不是 C 的形式出現時,更多的貢獻者對探索效能改進感興趣。更多的人以更快的速度進行更多的實驗,可以產生更好的效能。
但是,我們從此類移植中看到了更直接的效能改進形式。託管程式碼呼叫執行時所需的開銷相對較小,但當以高頻方式進行此類呼叫時,這種開銷會增加。考慮 dotnet/coreclr#27700,它將基元型別的陣列排序的實現從核心 cclr 中的本機程式碼中移出,並在 Corelib 中移動到 C# 中。除了該程式碼之外,還為新的公共 API 提供排序範圍,這還使得對較小的陣列進行排序的成本更低,因為這樣做的成本是由託管程式碼的過渡所主導的。我們可以用一個小的基準來檢視這一點,該基準只是使用Array.Sort
對 int[]
、double[]
和string[]
陣列進行排序,這些陣列包含 10 個項:
public class DoubleSorting : Sorting<double> { protected override double GetNext() => _random.Next(); }
public class Int32Sorting : Sorting<int> { protected override int GetNext() => _random.Next(); }
public class StringSorting : Sorting<string>{
protected override string GetNext()
{
var dest = new char[_random.Next(1, 5)];
for (int i = 0; i < dest.Length; i++) dest[i] = (char)('a' + _random.Next(26));
return new string(dest);
}}
public abstract class Sorting<T>{
protected Random _random;
private T[] _orig, _array;
[Params(10)]
public int Size { get; set; }
protected abstract T GetNext();
[GlobalSetup]
public void Setup()
{
_random = new Random(42);
_orig = Enumerable.Range(0, Size).Select(_ => GetNext()).ToArray();
_array = (T[])_orig.Clone();
Array.Sort(_array);
}
[Benchmark]
public void Random()
{
_orig.AsSpan().CopyTo(_array);
Array.Sort(_array);
}
}
Type | Runtime | Mean | Ratio |
---|---|---|---|
DoubleSorting | .NET FW 4.8 | 88.88 ns | 1.00 |
DoubleSorting | .NET Core 3.1 | 73.29 ns | 0.83 |
DoubleSorting | .NET 5.0 | 35.83 ns | 0.40 |
Int32Sorting | .NET FW 4.8 | 66.34 ns | 1.00 |
Int32Sorting | .NET Core 3.1 | 48.47 ns | 0.73 |
Int32Sorting | .NET 5.0 | 31.07 ns | 0.47 |
StringSorting | .NET FW 4.8 | 2,193.86 ns | 1.00 |
StringSorting | .NET Core 3.1 | 1,713.11 ns | 0.78 |
StringSorting | .NET 5.0 | 1,400.96 ns | 0.64 |
這本身是移動的一個很好的好處,事實上,在 .NET 5 通過 dotnet/runtime#37630 中,我們還添加了 System.Half
,一個新的 16 位浮點基元,並且正在託管程式碼中,這種排序實現優化幾乎立即應用於它,而以前的本機實現需要大量的額外工作,沒有 C++ 標準型別的half
。但是,這裡可以說是更具影響力的效能優勢,它讓我們回到我開始討論的地方:GC。
GC 的有趣指標之一是"暫停時間",這實際上意味著 GC 必須暫停執行時間以執行其工作的時間。較長的暫停時間對延遲有直接影響,而延遲可能是所有工作負載的關鍵指標。如前面提到,GC 可能需要掛起執行緒,以便獲得一致的世界檢視,並確保它可以安全地移動物件,但如果執行緒當前在執行時執行 C/C++ 程式碼,則 GC 可能需要等到該呼叫完成之後才能掛起執行緒。因此,在託管程式碼而不是本機程式碼中,我們可以做的工作越多,我們對於 GC 暫停時間處理得越好。我們可以使用相同的Array.Sort
示例來檢視此項。考慮此程式:
using System;
using System.Diagnostics;
using System.Threading;
class Program{
public static void Main()
{
new Thread(() =>
{
var a = new int[20];
while (true) Array.Sort(a);
}) { IsBackground = true }.Start();
var sw = new Stopwatch();
while (true)
{
sw.Restart();
for (int i = 0; i < 10; i++)
{
GC.Collect();
Thread.Sleep(15);
}
Console.WriteLine(sw.Elapsed.TotalSeconds);
}
}}
這是旋轉的執行緒,只是坐在一個緊密的迴圈排序的小陣列一遍又一遍,而在主執行緒上,它執行 10 GCs,每個在它們之間有大約 15 毫秒。因此,我們預計該迴圈需要超過 150 毫秒。但是,當我在 .NET Core 3.1 上執行此時,我得到的秒數是這樣的:
6.6419048
5.5663149
5.7430339
6.032052
7.8892468
GC 很難中斷執行排序的執行緒,導致 GC 暫停時間遠遠高於所需的時間。謝天謝地, 當我在 .NET 5 上執行此時, 我得到這樣的數字:
0.159311
0.159453
0.1594669
0.1593328
0.1586566
這正是我們預測應該得到的。通過將Array.Sort實現移動到託管程式碼中,執行時可以更輕鬆地在它想要時掛起實現,我們使 GC 能夠更好地完成其工作。
當然, 這不僅限於 Array.Sort
。一群 RS 執行了此類移植,例如 dotnet/runtime#32722 將 stdelemref
和 ldelmaref
JIT 幫助程式移動到 C#, dotnet/runtime#32353 將unbox
幫助器的部分移動到 C# (並檢測其餘部分與適當的 GC 輪詢位置,讓 GC 在其餘位置中適當掛起),dotnet/coreclr#27603 / dotnet/coreclr#27634 / dotnet/coreclr#27123 / dotnet/coreclr#27776 移動更多陣列實現,如Array.Clear
和Array.Copy
到 C#, dotnet/coreclr#27216 將更多的Buffer
移動到 C#,而 dotnet/coreclr#27792 將 Enum.CompareTo
到 C#。然後,其中一些更改啟用了後續收益,例如 dotnet/runtime#32342 和 dotnet/runtime#35733,它們利用 Buffer.Memmove
中的改進來實現各種string
和 Array
方法的額外增益。
作為這組更改的最後思考,需要注意的另一個有趣的事情是,在一個版本中所做的微優化如何基於後來無效的假設,並且當採用這種微優化時,需要做好準備並願意適應。在我的 .NET Core 3.0 部落格文章中,我叫出"花生醬"更改,如 dotnet/coreclr#21756,它將大量呼叫站點從使用 Array.Copy(source,destination,length)
改為使用 Array.Copy(source,sourceOffset,destination,destinationOffset,length)
,因為前者的開銷獲取源和目標陣列的下限是可測量的。但是,由於上述一組將陣列處理程式碼移動到 C# 的更改,更簡單的過載開銷消失了,使得這些操作的選擇更簡單、更快。因此,對於 .NET 5 RS dotnet/coreclr#27641 和 dotnet/corefx#42343,切換了所有這些呼叫站點,更多切換為使用更簡單的過載。dotnet/runtime#36304 是另一個由於更改而撤消先前優化的示例,這些更改使它們過時或實際上有害。您始終能夠將單個字元傳遞給 String.Split
,例如version.Split('.')
。但是,問題是,這可以繫結到的 Split
的唯一過載是Split(paramschar[]separator)
,這意味著每次此類呼叫都導致 C# 編譯器生成 char[]
分配。為了消除這種情況,以前的版本添加了快取,提前分配了陣列,並將其儲存到靜態中,然後由 Split
呼叫使用靜態字元以避免每次呼叫 char[]
。現在.NET 中存在Split(charseparator,StringSplitOptionsoptions=StringSplitOptions.None)
過載,我們不再需要陣列了。
作為最後一個示例,我演示了將程式碼從執行時移動到託管程式碼如何有助於解決 GC 暫停問題,但執行時中剩餘的程式碼當然還有其他方法可以幫助實現此功能。dotnet/runtime#36179 減少了由於異常處理而暫停的 GC 暫停,通過確保執行時圍繞程式碼(如獲取"Watson"儲存桶引數)處於先發制人模式(preemptive mode)(基本上,一組唯一標識此特定異常的資料和用於報告目的呼叫堆疊)。
JIT
.NET 5 也是實時 (JIT) 編譯器的令人興奮的版本,其各種改進都融入了版本。與任何編譯器一樣,對 JIT 的改進可以產生廣泛的影響。通常,單個更改對單個程式碼段的影響很小,但這些更改隨後會因應用的地點數量而放大。
有幾乎無限的優化數可以新增到 JIT,並且給定無限時間執行此類優化,JIT 可以為任何給定方案建立最佳程式碼。但是 JIT 沒有無限的時間。JIT 的"實時"性質意味著它在應用執行時執行編譯:當呼叫尚未編譯的方法時,JIT 需要按需提供程式集程式碼。這意味著執行緒在編譯完成之前無法取得進展,這反過來又意味著 JIT 需要在應用哪些優化以及選擇如何使用其有限時間預算方面具有戰略性。各種技術都用於給 JIT 更多的時間,例如在應用的某些部分使用"提前"編譯 (AOT) 在應用執行之前儘可能多地執行編譯工作(例如,核心庫都是使用名為"ReadyToRun"的技術編譯的,您可能會聽到稱為"R2R"甚至"交叉"的技術,這是生成這些影象的工具),或者使用"分層編譯",它允許 JIT 最初編譯一個應用了很少到沒有優化的方法,因此這樣做非常快,並且只有在被認為有價值時,即當該方法被顯示為重複使用時,只會花更多的時間重新編譯它。但是,更一般來說,為 JIT 做出貢獻的開發人員只需選擇使用分配的時間預算進行優化,因為開發人員正在編寫程式碼,並且他們正在使用程式碼模式,這些優化被證明是有價值的。這意味著,隨著 .NET 的發展和獲得新功能、新的語言功能和新的庫功能,JIT 也隨著適合編寫新程式碼樣式的優化而發展。
這方面的一個很好的例子是使用來自使用者網路的 dotnet/runtime#32538 @benaadams。Span<T>
一直滲透著 .NET 堆疊的所有層,因為處理執行時、核心庫、ASP.NET Core 和超酷的開發人員在編寫安全高效的程式碼時認識到其功能,這些程式碼也統一了對字串、託管陣列、本機分配的記憶體和其他形式的資料的處理。同樣,值型別(結構)被更普遍地用作一種通過堆疊分配避免物件分配開銷的方法。但是,這種對此類型別的嚴重依賴也會給執行時帶來額外的麻煩。coreclr 執行時使用"精確"垃圾回收器,這意味著GC能夠以 100% 的準確性跟蹤哪些值指的是託管物件,哪些值不引用託管物件;這樣做的好處,但它也有成本(相反,單聲道執行時使用"保守"的垃圾回收器,這有一些效能優勢,但也意味著它可能將堆疊上的任意值解釋為與託管物件的地址相同的任意值,作為對該物件的實時引用)。其中一個成本是,JIT需要幫助的GC,保證任何本地可以解釋為物件引用是零之前,GC注意它;否則,GC 可能會最終看到尚未設定的本地中的垃圾值,並假定它引用一個有效的物件,此時"壞事"可能發生。引用的區域性變數越多,需要做的清理工作也更多。如果你只是清除一些當地人, 它可能不明顯。但是,隨著數量的增加,清除這些區域性變數所花費的時間可能會增加,尤其是在非常熱門的程式碼路徑中使用的小方法中。這種情況在跨距和結構中已變得更加常見,其中編碼模式通常會導致更多引用(Span<T>
包含引用),這些引用需要為零。上述 PR 通過更新執行此零的 prolog 塊的 JIT 生成的程式碼來對此進行更新,以便使用 xmm
暫存器,而不是使用 rep stosd
指令。實際上,它向量化歸零。您可以使用以下基準檢視其影響:
[Benchmark]public int Zeroing(){
ReadOnlySpan<char> s1 = "hello world";
ReadOnlySpan<char> s2 = Nop(s1);
ReadOnlySpan<char> s3 = Nop(s2);
ReadOnlySpan<char> s4 = Nop(s3);
ReadOnlySpan<char> s5 = Nop(s4);
ReadOnlySpan<char> s6 = Nop(s5);
ReadOnlySpan<char> s7 = Nop(s6);
ReadOnlySpan<char> s8 = Nop(s7);
ReadOnlySpan<char> s9 = Nop(s8);
ReadOnlySpan<char> s10 = Nop(s9);
return s1.Length + s2.Length + s3.Length + s4.Length + s5.Length + s6.Length + s7.Length + s8.Length + s9.Length + s10.Length;}
[MethodImpl(MethodImplOptions.NoInlining)]private static ReadOnlySpan<char> Nop(ReadOnlySpan<char> span) => default;
在我的計算機上,我得到的結果如下:
Method | Runtime | Mean | Ratio |
---|---|---|---|
Zeroing | .NET FW 4.8 | 22.85 ns | 1.00 |
Zeroing | .NET Core 3.1 | 18.60 ns | 0.81 |
Zeroing | .NET 5.0 | 15.07 ns | 0.66 |
請注意,在比我提到的更多的情況下,實際上需要這樣的零。特別是,預設情況下,C# 規範要求在執行開發人員的程式碼之前將所有區域性變數初始化為其預設值。您可以通過以下示例檢視此項:
using System;
using System.Runtime.CompilerServices;
using System.Threading;
unsafe class Program{
static void Main()
{
while (true)
{
Example();
Thread.Sleep(1);
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void Example()
{
Guid g;
Console.WriteLine(*&g);
}}
執行它,您應該只看到所有 0
s 輸出的 Guid
s。這是因為 C# 編譯器正在為編譯的Example
方法向 IL 發出 .locals init
標誌,並且 .locals init
告訴 JIT 它需要歸零所有區域性變數,而不僅僅是包含引用的區域性變數。但是,在 .NET 5 中,執行時有一個新屬性(dotnet/runtime#454):
namespace System.Runtime.CompilerServices{
[AttributeUsage(AttributeTargets.Module | AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Constructor | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Event | AttributeTargets.Interface, Inherited = false)]
public sealed class SkipLocalsInitAttribute : Attribute { }
}
C# 編譯器會識別此屬性,並用於告訴編譯器在否則時不要發出 .locals init
。如果我們對上一個示例稍作調整,請將屬性新增到整個模組中:
using System;
using System.Runtime.CompilerServices;
using System.Threading;
[module: SkipLocalsInit]
unsafe class Program{
static void Main()
{
while (true)
{
Example();
Thread.Sleep(1);
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void Example()
{
Guid g;
Console.WriteLine(*&g);
}
}
你現在應該看到不同的結果, 特別是你應該很可能看到非零 Guid
s 。從 dotnet/runtime#37541 起,.NET5 中的核心庫現在都使用此屬性禁用 .locals init
(在以前的版本中,.locals init
在構建核心庫時採用的編譯後步驟中剝離)。請注意,C# 編譯器只允許在unsafe
上下文中使用 SkipLocalsInit
,因為它很容易導致程式碼損壞,而程式碼尚未經過適當驗證才能使用(因此,如果 /應用它,請仔細考慮)。
除了加快零點之外,還進行了完全刪除零的更改。例如,dotnet/runtime#31960、dotnet/runtime#36918、dotnet/runtime#37786 和 dotnet/runtime#38314 都有助於在 JIT 可以證明它是重複時刪除零。
這種零是託管程式碼產生的稅的示例,執行時需要它來保證其模型和上面語言的要求。另一種此類稅項是界限檢查。使用託管程式碼的一大優點是,預設情況下,一類潛在安全漏洞變得無關緊要。執行時可確保對陣列、字串和範圍中的索引進行邊界檢查,這意味著執行時將注入檢查,以確保請求的索引在要索引的資料的範圍內(即大於或等於零,小於資料的長度)。下面是一個簡單的示例:
public static char Get(string s, int i) => s[i];
為了安全起見,執行時需要生成一個檢查,檢查 i
屬於字串 s
的界限,JIT 使用如下程式集執行:
; Program.Get(System.String, Int32)
sub rsp,28
cmp edx,[rcx+8]
jae short M01_L00
movsxd rax,edx
movzx eax,word ptr [rcx+rax*2+0C]
add rsp,28
ret
M01_L00:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 28
此程式集是通過Benchmark.NET的一個方便功能生成的:將[DisassemblyDiagnoser]
新增到包含基準的類中,然後吐出拆解的程式集程式碼。我們可以看到,程式集採用字串(通過 rcx
暫存器傳遞)並將字串的長度(儲存 8 個位元組到物件中,因此 [rcx[8]
),與 edx
暫存器中傳遞的 i
進行比較,如果與無符號比較(未簽名,使任何負值環繞大於長度),i
大於或等於長度,跳至引發異常COREINFO_HELP_RNGCHKFAIL
的幫助器。只是幾個說明,但某些型別的程式碼可能會花費大量的週期索引,因此,當 JIT 可以消除儘可能多的邊界檢查,因為它可以證明是不必要的, 這是有幫助的。
JIT 已能夠在各種情況下刪除邊界檢查。例如,編寫迴圈時:
int[] arr = ...;
for (int i = 0; i < arr.Length; i++)
Use(arr[i]);
JIT 可以證明我永遠不會超出陣列的界限,因此它可以逃避否則會生成邊界檢查。在 .NET 5 中,它可以刪除在更多位置檢查的繫結。例如,請考慮將整數字節作為字元寫入範圍以下函式:
private static bool TryToHex(int value, Span<char> span){
if ((uint)span.Length <= 7)
return false;
ReadOnlySpan<byte> map = new byte[] { (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F' }; ;
span[0] = (char)map[(value >> 28) & 0xF];
span[1] = (char)map[(value >> 24) & 0xF];
span[2] = (char)map[(value >> 20) & 0xF];
span[3] = (char)map[(value >> 16) & 0xF];
span[4] = (char)map[(value >> 12) & 0xF];
span[5] = (char)map[(value >> 8) & 0xF];
span[6] = (char)map[(value >> 4) & 0xF];
span[7] = (char)map[value & 0xF];
return true;}
private char[] _buffer = new char[100];
[Benchmark]
public bool BoundsChecking() => TryToHex(int.MaxValue, _buffer);
首先,在此示例中,值得注意的是我們依賴於 C# 編譯器優化。請注意:
ReadOnlySpan<byte> map = new byte[] { (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F' };
這看起來非常昂貴,就像我們在每次呼叫 TryToHex 時分配一個位元組陣列一樣。事實上,它不是,它實際上比如果我們做了更好:
private static readonly byte[] s_map = new byte[] { (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F' };
...
ReadOnlySpan<byte> map = s_map;
C# 編譯器可識別將新位元組陣列直接分配給 ReadOnlySpan<byte>
(它也識別sbyte
和 bool
,但由於內維性問題,它不超過位元組)。由於陣列性質隨後被範圍完全隱藏,C# 編譯器通過實際將位元組儲存在程式集的資料部分中來發出該資料,並且通過將其環繞到靜態資料和長度的指標周圍來建立 span:
IL_000c: ldsflda valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=16' '<PrivateImplementationDetails>'::'2125B2C332B1113AAE9BFC5E9F7E3B4C91D828CB942C2DF1EEB02502ECCAE9E9'IL_0011: ldc.i4.s 16IL_0013: newobj instance void valuetype [System.Runtime]System.ReadOnlySpan'1<uint8>::.ctor(void*, int32)
這對於此 JIT 討論非常重要,因為上面的 ldc.i4.s 16
。這是 IL 載入長度 16 用於建立跨度,JIT 可以看到這一點。它知道範圍的長度為 16,這意味著如果它可以證明訪問始終為大於或等於 0 和小於 16 的值,則不需要邊界檢查該訪問。dotnet/runtime#1644 正是這樣做的,識別array[index%const]
等模式,並在 const
小於或等於長度時迴避邊界檢查。在上一個 TryToHex
示例中,JIT 可以看到map
範圍的長度為 16,並且可以看到,它的所有索引都使用 & 0xF
完成,這意味著所有值最終都將在範圍內,因此它可以消除map
上的所有邊界檢查。結合這一事實,它已經可以看到不需要在寫入span
(因為它可以看到長度檢查之前的方法中,守衛到span
的所有索引),這整個方法是無邊界檢查在 .NET 5。在我的機器上,此基準生成的結果如下:
Method | Runtime | Mean | Ratio | Code Size |
---|---|---|---|---|
BoundsChecking | .NET FW 4.8 | 14.466 ns | 1.00 | 830 B |
BoundsChecking | .NET Core 3.1 | 4.264 ns | 0.29 | 320 B |
BoundsChecking | .NET 5.0 | 3.641 ns | 0.25 | 249 B |
請注意,.NET 5 的執行速度不僅比 .NET Core 3.1 執行快 15%,我們可以看到其程式集程式碼大小小 22%(額外的"程式碼大小"列來自我向基準類添加了 [DisassemblyDiagnoser]
)。
另一個不錯的邊界檢查刪除來自@nathan-moore
dotnet/runtime#36263。我提到過 JIT 已經能夠刪除邊界,檢查從 0 到陣列、字串或範圍長度的非常常見的模式,但存在一些變化,這些差異也比較常見,但以前無法識別。例如,請考慮此微臺標,它呼叫一個檢測整數跨度是否排序的方法:
private int[] _array = Enumerable.Range(0, 1000).ToArray();
[Benchmark]
public bool IsSorted() => IsSorted(_array);
private static bool IsSorted(ReadOnlySpan<int> span){
for (int i = 0; i < span.Length - 1; i++)
if (span[i] > span[i + 1])
return false;
return true;
}
與識別模式的這種細微變化以前足以防止 JIT 逃避邊界檢查。不再是了.NET 5 在我的計算機上能夠執行此速度快 20%:
Method | Runtime | Mean | Ratio | Code Size |
---|---|---|---|---|
IsSorted | .NET FW 4.8 | 1,083.8 ns | 1.00 | 236 B |
IsSorted | .NET Core 3.1 | 581.2 ns | 0.54 | 136 B |
IsSorted | .NET 5.0 | 463.0 ns | 0.43 | 105 B |
JIT 確保對錯誤類別進行檢查的另一個情況是空檢查。JIT 與執行時協調執行此操作,JIT 確保已設定適當的指令以產生硬體異常,然後執行時將此類故障轉換為 .NET 異常(例如此處)。但有時,指令只對空檢查是必需的,而不是同時完成其他必要的功能,並且只要由於某些指令而發生所需的空檢查,就可以刪除不必要的重複指令。請考慮以下程式碼:
private (int i, int j) _value;
[Benchmark]
public int NullCheck() => _value.j++;
作為可執行的基準,這對於使用Benchmark.NET進行精確測量的工作太少,但它是檢視生成程式集程式碼的一個很好的方法。使用 .NET Core 3.1 時,此方法將產生此程式集:
; Program.NullCheck()
nop dword ptr [rax+rax]
cmp [rcx],ecx
add rcx,8
add rcx,4
mov eax,[rcx]
lea edx,[rax+1]
mov [rcx],edx
ret
; Total bytes of code 23
作為計算 j 地址的一部分,該 cmp [rcx],ecx
指令對this
執行空檢查。然後,mov eax,[rcx]
指令執行另一個空檢查,作為取消引用 j
位置的一部分。因此,第一次空檢查實際上沒有必要,指令不提供任何其他好處。因此,由於像 dotnet/runtime#1735 和 dotnet/runtime#32641 這樣的 PR,JIT 在很多情況下都能夠識別這種重複,對於 .NET 5,我們現在最終得到:
; Program.NullCheck()
add rcx,0C
mov eax,[rcx]
lea edx,[rax+1]
mov [rcx],edx
ret
; Total bytes of code 12
協方差是 JIT 需要注入檢查以確保開發人員不會意外中斷型別或記憶體安全的另一種情況。請考慮以下程式碼:
class A { }
class B { }
object[] arr = ...;
arr[0] = new A();
此程式碼是否有效?視情況而定。.NET 中的陣列是"協方差",這意味著我可以將陣列DerivedType[]
作為 BaseType[]
傳遞,其中DerivedType
派生自 BaseType
。這意味著在此示例中,arr
可以構造為 new A[1]
或newobject[1]
或 new B[1]
。此程式碼應使用前兩個程式碼執行正常,但如果 arr
實際上是 B[]
,則嘗試將 A
例項儲存到其中時必須失敗;如果 arr
實際上是 B[]
,則嘗試將 A
例項儲存到其中時,必須失敗。否則,將陣列用作 B[]
的程式碼可能會嘗試將 B[0]
用作 B
,並且情況可能會很快變得很糟糕。因此,執行時需要通過執行協方差檢查來防止這種情況,這意味著當引用型別例項儲存在陣列中時,執行時需要檢查分配的型別實際上是否與陣列的具體型別相容。使用 dotnet/runtime#189,JIT 現在能夠消除更多的協方差檢查,特別是在陣列的元素型別被密封的情況下,例如string
。因此,這樣的微臺標現在執行得更快:
private string[] _array = new string[1000];
[Benchmark]
public void CovariantChecking(){
string[] array = _array;
for (int i = 0; i < array.Length; i++)
array[i] = "default";
}
Method | Runtime | Mean | Ratio | Code Size |
---|---|---|---|---|
CovariantChecking | .NET FW 4.8 | 2.121 us | 1.00 | 57 B |
CovariantChecking | .NET Core 3.1 | 2.122 us | 1.00 | 57 B |
CovariantChecking | .NET 5.0 | 1.666 us | 0.79 | 52 B |
與此相關的是型別檢查。我前面提到Span<T>
解決了一堆問題,但也引入了新的模式,然後推動系統其他領域的改進;這同樣適合 Span<T>
本身的實現。Span<T>
的建構函式執行協方差檢查,要求 T[]
實際上是 T[]
而不是 U[]
從 T
派生,例如此程式:
using System;
class Program{
static void Main() => new Span<A>(new B[42]);}
class A { }
class B : A { }
將導致異常:
System.ArrayTypeMismatchException: Attempted to access an element as a type incompatible with the array.
該異常源於 Span<T>
的構造中的此檢查:
if (!typeof(T).IsValueType && array.GetType() != typeof(T[]))
ThrowHelper.ThrowArrayTypeMismatchException();
PR dotnet/runtime#32790 優化了這樣一個數組。array.GetType()!=typeof(T[])
檢查 T
何時密封,而 dotnet/runtime#1157 識別typeof(T).IsValueType
模式並將其替換為常量值(PR dotnet/runtime#1195 對 typeof(T1).IsAssignableFrom(typeof(T2))
執行相同的操作。其淨效應是這樣的微臺標的巨大改進:
class A { }
sealed class B : A { }
private B[] _array = new B[42];
[Benchmark]
public int Ctor() => new Span<B>(_array).Length;
我得到的結果, 如:
Method | Runtime | Mean | Ratio | Code Size |
---|---|---|---|---|
Ctor | .NET FW 4.8 | 48.8670 ns | 1.00 | 66 B |
Ctor | .NET Core 3.1 | 7.6695 ns | 0.16 | 66 B |
Ctor | .NET 5.0 | 0.4959 ns | 0.01 | 17 B |
在檢視生成的程式集時,即使不完全精通程式集程式碼,差異的解釋也很明顯。以下是在 .NET Core 3.1 上生成的 [DisassemblyDiagnoser]
顯示的內容:
; Program.Ctor()
push rdi
push rsi
sub rsp,28
mov rsi,[rcx+8]
test rsi,rsi
jne short M00_L00
xor eax,eax
jmp short M00_L01
M00_L00:
mov rcx,rsi
call System.Object.GetType()
mov rdi,rax
mov rcx,7FFE4B2D18AA
call CORINFO_HELP_TYPEHANDLE_TO_RUNTIMETYPE
cmp rdi,rax
jne short M00_L02
mov eax,[rsi+8]
M00_L01:
add rsp,28
pop rsi
pop rdi
ret
M00_L02:
call System.ThrowHelper.ThrowArrayTypeMismatchException()
int 3
; Total bytes of code 66
以下是它為. net 5 顯示的:
; Program.Ctor() \
mov rax,[rcx+8]
test rax,rax
jne short M00_L00
xor eax,eax
jmp short M00_L01
M00_L00:
mov eax,[rax+8]
M00_L01:
ret
; Total bytes of code 17
作為另一個示例,在 GC 討論之前,我舉出我們從移植本機執行時程式碼以管理 C# 程式碼中體驗到的一系列好處。一個當時我沒有提到,但現在將是,它導致我們在系統中作出其他改進,解決金鑰攔截器到這樣的移植,但隨後也用於改善許多其他情況。一個很好的例子是dotnet/runtime#38229。當我們第一次將本機陣列排序實現移動到託管時,我們無意中產生了浮點值的迴歸,@nietras 幫助發現了這一回歸,隨後在 dotnet/runtime#37941 中修復了迴歸。迴歸是由於本機實現使用我們在託管埠中缺少的特殊優化(對於浮點陣列,將所有 NaN 值移動到陣列的開頭,以便後續的比較操作可以忽略 NaN 的可能性),我們成功地將它帶過來。但是,問題在於以一種不會導致大量程式碼重複的方式表達這種情況:本機實現使用模板,而託管實現使用泛型,但使用泛型的內聯限制使得為避免大量程式碼重複而引入的幫助器導致排序中使用的每個比較上出現非內聯方法呼叫。PR dotnet/runtime#38229 通過啟用 JIT 將同一型別內聯共享通用程式碼解決了這一問題。請考慮以下微臺標:
private C c1 = new C() { Value = 1 }, c2 = new C() { Value = 2 }, c3 = new C() { Value = 3 };
[Benchmark]
public int Compare() => Comparer<C>.Smallest(c1, c2, c3);
class Comparer<T> where T : IComparable<T>{
public static int Smallest(T t1, T t2, T t3) =>
Compare(t1, t2) <= 0 ?
(Compare(t1, t3) <= 0 ? 0 : 2) :
(Compare(t2, t3) <= 0 ? 1 : 2);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int Compare(T t1, T t2) => t1.CompareTo(t2);}
class C : IComparable<C>{
public int Value;
public int CompareTo(C other) => other is null ? 1 : Value.CompareTo(other.Value);}
Smallest
方法是比較三個提供的值並返回最小值的索引。它是泛型型別上的方法,它呼叫同一型別的另一個方法,這反過來又對泛型型別引數的例項上的方法進行呼叫。由於基準使用 C
作為泛型型別,並且 C
是引用型別,因此 JIT 不會專門針對 C
為此方法專門處理程式碼,而是使用它生成的用於所有引用型別的"共享"實現。為了使Compare
方法然後呼叫比較的正確介面實現CompareTo
,共享泛型實現使用字典,對映從泛型型別到正確的目標。在 .NET 的早期版本中,包含這些通用字典查詢的方法不是內聯的,這意味著此Smallest
方法無法內聯它為Compare
而呼叫的三個呼叫,即使Compare
被歸為MethodImplOptions.AggressiveInlining
。上述 PR 消除了該限制,導致此示例的可測量加速(使陣列排序迴歸修復可行):
Method | Runtime | Mean | Ratio |
---|---|---|---|
Compare | .NET FW 4.8 | 8.632 ns | 1.00 |
Compare | .NET Core 3.1 | 9.259 ns | 1.07 |
Compare | .NET 5.0 | 5.282 ns | 0.61 |
此處引用的大多數改進都側重於吞吐量,JIT 生成的程式碼執行速度更快,而且更快的程式碼通常(但並非總是)更小。在 JIT 上工作的人員實際上非常關注程式碼大小,在許多情況下,使用它作為更改是否有益的主要指標。較小的程式碼並不總是更快的程式碼(指令可以是相同的大小,但有非常不同的成本配置檔案),但在高級別上,它是一個合理的指標,較小的程式碼確實有直接的好處,如對指令快取的影響較小,載入的程式碼更少等。在某些情況下,更改完全側重於減少程式碼大小,例如在發生不必要的重複的情況下。考慮以下簡單的基準:
private int _offset = 0;
[Benchmark]
public int ThrowHelpers(){
var arr = new int[10];
var s0 = new Span<int>(arr, _offset, 1);
var s1 = new Span<int>(arr, _offset + 1, 1);
var s2 = new Span<int>(arr, _offset + 2, 1);
var s3 = new Span<int>(arr, _offset + 3, 1);
var s4 = new Span<int>(arr, _offset + 4, 1);
var s5 = new Span<int>(arr, _offset + 5, 1);
return s0[0] + s1[0] + s2[0] + s3[0] + s4[0] + s5[0];
}
Span<T>
執行引數驗證,當 T
是值型別時,會導致 ThrowHelper
類上的方法存在兩個呼叫站點, 對輸入陣列進行失敗的 null 檢查,在偏移量和計數範圍不一時丟擲的異常方法(ThrowHelper
包含不可聯絡的方法,如 ThrowArgumentNullException
,它包含實際 throw
並避免每個呼叫站點的關聯程式碼大小;JIT 當前無法"概述",與"內聯"相反,因此在重要的情況下需要手動執行)。在上面的示例中,我們建立六個 span,這意味著對 Span<T>
建構函式的六次呼叫,所有這些呼叫都將內線。JIT 可以看到陣列是非空的,因此它可以從內線程式碼中消除 null 檢查和 ThrowArgumentNullException
,但它不知道偏移量和計數是否在範圍內,因此它需要保留 ThrowHelper.ThrowArgumentOutRangeException
方法的範圍檢查和呼叫站點。在 .NET Core 3.1 中,這會導致為此 ThrowHelpers
方法生成如下所示的程式碼:
M00_L00:
call System.ThrowHelper.ThrowArgumentOutOfRangeException()
int 3
M00_L01:
call System.ThrowHelper.ThrowArgumentOutOfRangeException()
int 3
M00_L02:
call System.ThrowHelper.ThrowArgumentOutOfRangeException()
int 3
M00_L03:
call System.ThrowHelper.ThrowArgumentOutOfRangeException()
int 3
M00_L04:
call System.ThrowHelper.ThrowArgumentOutOfRangeException()
int 3
M00_L05:
call System.ThrowHelper.ThrowArgumentOutOfRangeException()
int 3
在 .NET 5 中,由於 dotnet/coreclr#27113,JIT 能夠識別此重複,而不是所有六個呼叫站點,它最終會將它們合併為一個:
M00_L00:
call System.ThrowHelper.ThrowArgumentOutOfRangeException()
int 3
所有失敗的檢查都跳到此共享位置,而不是每個檢查都有其自己的副本。
Method | Runtime | Code Size |
---|---|---|
ThrowHelpers | .NET FW 4.8 | 424 B |
ThrowHelpers | .NET Core 3.1 | 252 B |
ThrowHelpers | .NET 5.0 | 222 B |
這些只是 .NET 5 中 JIT 中大量改進的一部分。還有更多。dotnet/runtime#32368 使 JIT 將陣列的長度視為未簽名,因此它能夠對對長度執行的一些數學運算(例如除法)使用更好的指令。dotnet/coreclr#25458 使 JIT 能夠對某些未簽名的整數操作使用更快的 0 個比較,例如,當開發人員實際編寫a>=1
時,使用等效的 a!=0
。dotnet/runtime#1378 允許 JIT 識別"恆定字串"。長度作為常量值。dotnet/runtime#26740 通過刪除 nop
填充來減小 ReadyToRun 映像的大小。dotnet/runtime#330234 在 x
是浮點或雙精度時,使用新增而不是乘法來優化執行 x * 2
時生成的指令。。dotnet/runtime#27060 改進了為Math.FusedMultiplyAdd
的程式碼。dotnet/runtime#27384 通過使用比之前使用的更好的圍欄指令使 ARM64 上的易失性操作更便宜,並且 dotnet/runtime#38179 在 ARM64 上執行窺視孔優化,以刪除一堆冗餘 mov
指令。和上和。
JIT中也有一些重大更改,這些更改預設情況下是禁用的,目的是獲取有關它們的實際反饋並預設在.NET 5之後啟用它們。例如,dotnet/runtime#32969提供了“堆疊替換”(OSR)的初始實現。我在前面提到了分層編譯,它使JIT能夠首先為一種方法生成最小優化的程式碼,然後在證明該方法很重要時再對該方法進行重新編譯,並進行更多優化。通過允許程式碼更快地執行並僅在事情執行後才升級有影響力的方法,從而縮短了啟動時間。但是,分層編譯依賴於能夠替換實現,並且下次呼叫該實現時,將呼叫新的實現。但是長期執行的方法呢?預設情況下,對於包含迴圈的方法(或更具體地說,向後分支),將禁用分層編譯,因為它們可能會長時間執行,從而導致無法及時使用替換。 OSR使方法可以在程式碼執行時和“堆疊上”時進行更新; PR中包含的設計文件中有很多重要的細節(也與分層編譯有關,dotnet/runtime#1457改進了呼叫計數機制,通過該機制,分層編譯可決定何時重新編譯哪些方法)。您可以通過將COMPlus_TC_QuickJitForLoops
和COMPlus_TC_OnStackReplacement
環境變數都設定為1
來進行OSR實驗。作為另一個示例,dotnet/runtime#1180改進了try塊內程式碼的生成程式碼質量,使JIT可以將值保留在以前無法儲存的暫存器中。 t。您可以通過將COMPlus_EnableEHWriteThr
環境變數設定為1
來進行試驗。
還有許多尚未合併到JIT的掛起請求,但很可能是在釋出.NET 5之前(除了,我希望還有更多未提交的請求,但是.NET 5將在幾個月後釋出)。例如,dotnet/runtime#32716使JIT能夠替換某些分支比較,例如a==42?3:2
的無分支實現,在硬體無法正確預測將採用哪個分支時,可以幫助提高效能。或dotnet/runtime#37226,它使JIT可以採用"hello"[0]
之類的模式並將其替換為h;儘管通常開發人員不會編寫此類程式碼,但是當涉及到內聯時,這可以提供幫助,將常量字串傳遞給內聯的方法,該方法可以內聯並且索引到一個恆定的位置(通常在長度檢查之後,這要感謝dotnet/runtime#1378,也可以成為const)。或dotnet / runtime#1224,它可以改善Bmi2.MultiplyNoFlags
內部函式的程式碼生成。或dotnet/runtime#37836,它將BitOperations.PopCount
轉換為內在函式,使JIT能夠識別使用常量引數呼叫的時間,並用預先計算的常量替換整個操作。或dotnet/runtime#37254,它刪除使用const字串時發出的空檢查。或@damageboy的dotnet/runtime#32000,它可以優化雙重否定。
本徵
在.NET Core 3.0中,JIT新增並認可了上千種新的硬體內在方法,以使C#程式碼可以直接針對SSE4和AVX2之類的指令集(請參閱文件)。然後,這些功能在核心庫中的大量API中得到了極大的利用。但是,內在函式僅限於x86 / x64體系結構。在.NET 5中,由於有多個貢獻者,尤其是Arm Holdings的@TamarChristinaArm,在新增成千上萬的特定於ARM64上的工作已經投入了大量精力。與它們的x86 / x64版本一樣,這些內在函式已在核心庫功能中得到很好的利用。例如,以前對BitOperations.PopCount()
方法進行了優化,以使用x86 POPCNT固有函式,對於.NET 5,dotnet/runtime#35636對其進行了增強,使其還能夠使用ARM VCNT或ARM64 CNT等效項。類似地,dotnet/runtime#34486修改了BitOperations.LeadingZeroCount
,TrailingZeroCount
和Log2
以利用相應的指令。在更高層次上,@Gnbrkm41的dotnet/runtime#33749擴充套件了BitArray
中的多種方法,以使用ARM64內在函式與先前新增的對SSE2和AVX2的支援一起使用。確保Vector
API在ARM64上也能正常工作的工作量很大,例如dotnet/runtime#37139和dotnet/runtime#36156。
除ARM64之外,還進行了其他工作以向量化更多操作。例如,@Gnbrkm41還提交了dotnet/runtime#31993,該檔案利用x64上的ROUNDPS / ROUNDPD和ARM64上的FRINPT / FRINTM來改進為新Vector.Ceiling
和Vector.Floor
方法生成的程式碼。 BitOperations
(這是一種相對低階的型別,針對大多數操作以最合適的硬體內部函式的1:1包裝器的形式實現),不僅在@saucecontrol的dotnet/runtime#35650中得到了改進,而且在Corelib中的使用也得到了改進更有效率。
最終,JIT進行了大量更改,以更好地一般地處理硬體內在函式和向量化,例如dotnet/runtime#35421,dotnet/runtime#31834,dotnet/runtime#1280,dotnet/runtime#35857,dotnet/runtime#36267和dotnet/runtime#35525。
執行時幫助類
GC和JIT代表了執行時的大部分,但是在執行時中,這些元件之外仍然有相當一部分功能,並且這些功能也得到了類似的改進。
有趣的是,JIT不會為所有內容從頭開始生成程式碼。 在許多地方,JIT都會呼叫預先存在的幫助程式功能,而執行時會提供這些幫助程式,對這些幫助程式的改進可能會對程式產生有意義的影響。 dotnet/runtime#23548是一個很好的例子。 在System.Linq
之類的庫中,我們避免為協變介面新增其他型別檢查,因為與普通介面相比,它們的開銷明顯更高。 dotnet/runtime#23548(隨後在dotnet/runtime#34427中進行了調整)從本質上增加了一個快取,從而分攤了這些轉換的成本,最終使整體速度更快。 從一個簡單的微基準測試就可以看出這一點:
private List<string> _list = new List<string>();
// IReadOnlyCollection<out T> is covariant
[Benchmark] public bool IsIReadOnlyCollection() => IsIReadOnlyCollection(_list);[MethodImpl(MethodImplOptions.NoInlining)] private static bool IsIReadOnlyCollection(object o) => o is IReadOnlyCollection<int>;
Method | Runtime | Mean | Ratio | Code Size |
---|---|---|---|---|
IsIReadOnlyCollection | .NET FW 4.8 | 105.460 ns | 1.00 | 53 B |
IsIReadOnlyCollection | .NET Core 3.1 | 56.252 ns | 0.53 | 59 B |
IsIReadOnlyCollection | .NET 5.0 | 3.383 ns | 0.03 | 45 B |
另一組具有影響力的更改發生在dotnet/runtime#32270中(在dotnet/runtime#31957中具有JIT支援)。 過去,通用方法僅保留了幾個專用的字典槽,可用於快速查詢與通用方法相關聯的型別。 一旦這些插槽用盡,它就會退回到較慢的查詢表中。 不再需要此限制,並且這些更改使快速查詢槽可用於所有通用查詢。
[Benchmark]public void GenericDictionaries(){
for (int i = 0; i < 14; i++)
GenericMethod<string>(i);}
[MethodImpl(MethodImplOptions.NoInlining)]private static object GenericMethod<T>(int level){
switch (level)
{
case 0: return typeof(T);
case 1: return typeof(List<T>);
case 2: return typeof(List<List<T>>);
case 3: return typeof(List<List<List<T>>>);
case 4: return typeof(List<List<List<List<T>>>>);
case 5: return typeof(List<List<List<List<List<T>>>>>);
case 6: return typeof(List<List<List<List<List<List<T>>>>>>);
case 7: return typeof(List<List<List<List<List<List<List<T>>>>>>>);
case 8: return typeof(List<List<List<List<List<List<List<List<T>>>>>>>>);
case 9: return typeof(List<List<List<List<List<List<List<List<List<T>>>>>>>>>);
case 10: return typeof(List<List<List<List<List<List<List<List<List<List<T>>>>>>>>>>);
case 11: return typeof(List<List<List<List<List<List<List<List<List<List<List<T>>>>>>>>>>>);
case 12: return typeof(List<List<List<List<List<List<List<List<List<List<List<List<T>>>>>>>>>>>>);
default: return typeof(List<List<List<List<List<List<List<List<List<List<List<List<List<T>>>>>>>>>>>>>);
}}
Method | Runtime | Mean | Ratio |
---|---|---|---|
GenericDictionaries | .NET FW 4.8 | 104.33 ns | 1.00 |
GenericDictionaries | .NET Core 3.1 | 76.71 ns | 0.74 |
GenericDictionaries | .NET 5.0 | 51.53 ns | 0.49 |
文字處理
基於文字的處理是許多應用程式的頭等大事,每個發行版中都花了很多心血來改進基本的構建基塊,在此基礎上構建所有其他內容。 此類更改從幫助程式的微優化一直擴充套件到整個文字處理庫的全面檢查。
System.Char
在.NET 5中獲得了一些不錯的改進。例如,dotnet/coreclr#26848通過調整實現以減少指令和分支的數量,提高了char.IsWhiteSpace的效能。 然後對char.IsWhiteSpace
的改進體現在其他依賴它的其他方法中,例如string.IsEmptyOrWhiteSpace
和Trim
:
[Benchmark]public int Trim() => " test ".AsSpan().Trim().Length;
|Method|Runtime|Mean|Ratio|Code Size|
|Trim|.NET FW 4.8|21.694 ns|1.00|569 B|
|Trim|.NET Core 3.1|8.079 ns|0.37|377 B|
|Trim|.NET 5.0|6.556 ns|0.30|365 B|
另一個很好的例子,dotnet/runtime#35194通過改善各種方法的可內聯性,簡化了從公共API到核心功能的呼叫路徑並進一步調整實現以確保char.ToUpperInvariant
和char.ToLowerInvariant
的效能。 JIT正在生成最佳程式碼。
[Benchmark][Arguments("It's exciting to see great performance!")]public int ToUpperInvariant(string s){
int sum = 0;
for (int i = 0; i < s.Length; i++)
sum += char.ToUpperInvariant(s[i]);
return sum;}
Method | Runtime | Mean | Ratio | Code Size |
---|---|---|---|---|
ToUpperInvariant | .NET FW 4.8 | 208.34 ns | 1.00 | 171 B |
ToUpperInvariant | .NET Core 3.1 | 166.10 ns | 0.80 | 164 B |
ToUpperInvariant | .NET 5.0 | 69.15 ns | 0.33 | 105 B |
除了單個字元以外,在幾乎每個版本的.NET Core中,我們都在努力提高現有格式API的速度。 此版本沒有什麼不同。 即使以前的版本取得了重大勝利,但這一版本進一步提高了標準。
Int32.ToString()
是一種非常常見的操作,因此務必要快。 @ts2do的dotnet/runtime#32528通過為該方法所採用的金鑰格式化例程添加了可內聯的快速路徑,並簡化了各種公共API用來訪問這些例程的路徑,從而使其速度更快。 其他原始的ToString
操作也得到了改進。 例如,dotnet/runtime#27056簡化了一些程式碼路徑,以減少從公共API到實際將位寫到記憶體的時間。
[Benchmark] public string ToString12345() => 12345.ToString();
[Benchmark] public string ToString123() => ((byte)123).ToString();
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
ToString12345 | .NET FW 4.8 | 45.737 ns | 1.00 | 40 B |
ToString12345 | .NET Core 3.1 | 20.006 ns | 0.44 | 32 B |
ToString12345 | .NET 5.0 | 10.742 ns | 0.23 | 32 B |
ToString123 | .NET FW 4.8 | 42.791 ns | 1.00 | 32 B |
ToString123 | .NET Core 3.1 | 18.014 ns | 0.42 | 32 B |
ToString123 | .NET 5.0 | 7.801 ns | 0.18 | 32 B |
同樣,在以前的版本中,我們對DateTime
和DateTimeOffset
進行了相當大的優化,但這些改進主要集中在我們可以多快地轉換日/月/年/等方面。 資料寫入正確的字元或位元組,然後將其寫入目標位置。 在dotnet/runtime#1944中,@ts2do專注於此之前的步驟,從而優化了對日/月/年/等的提取。 從原始的滴答計數開始計算DateTime{Offset}
儲存的時間。 最終結果非常豐碩,從而使輸出“ o”(“往返日期/時間模式”)之類的格式的速度比以前快了30%(此更改還在程式碼庫的其他位置應用了相同的分解優化 在DateTime
中需要這些元件的位置,但改進最容易在格式化基準中顯示出來):
private byte[] _bytes = new byte[100];private char[] _chars = new char[100];private DateTime _dt = DateTime.Now;
[Benchmark] public bool FormatChars() => _dt.TryFormat(_chars, out _, "o");[Benchmark] public bool FormatBytes() => Utf8Formatter.TryFormat(_dt, _bytes, out _, 'O');
Method | Runtime | Mean | Ratio |
---|---|---|---|
FormatChars | .NET Core 3.1 | 242.4 ns | 1.00 |
FormatChars | .NET 5.0 | 176.4 ns | 0.73 |
FormatBytes | .NET Core 3.1 | 235.6 ns | 1.00 |
FormatBytes | .NET 5.0 | 176.1 ns | 0.75 |
對strings
的操作也進行了許多改進,例如dotnet/coreclr#26621和dotnet/coreclr#26962,在某些情況下,它們顯著提高了可識別文化的Linux上StartsWith
和EndsWith
操作的效能。
當然,低階處理是一件好事,但是如今,應用程式花費大量時間進行高階操作,例如以特定格式(例如UTF8)對資料進行編碼。 以前的.NET Core版本對Encoding.UTF8
進行了優化,但是在.NET 5中,它仍在進一步改進。 dotnet/runtime#27268通過更好地利用堆疊分配和JIT非虛擬化方面的改進(尤其是對於較小的輸入),對其進行了更多的優化(JIT能夠發現實際的具體型別,從而避免了虛擬排程)。 例項)。
[Benchmark]public string Roundtrip(){
byte[] bytes = Encoding.UTF8.GetBytes("this is a test");
return Encoding.UTF8.GetString(bytes);}
|Method|Runtime|Mean|Ratio|Allocated|
|Roundtrip|.NET FW 4.8|113.69 ns|1.00|96 B|
|Roundtrip|.NET Core 3.1|49.76 ns|0.44|96 B|
|Roundtrip|.NET 5.0|36.70 ns|0.32|96 B|
與UTF8一樣重要的是,“ ISO-8859-1”編碼(也稱為“ Latin1”)(現在已通過dotnet/runtime#37550公開為Encoding.Latin1
)也非常重要,特別是對於網路 HTTP等協議。dotnet/runtime#32994對它的實現進行了向量化,這很大程度上是基於先前對Encoding.ASCII
進行的類似優化。 這會帶來非常不錯的效能提升,可以顯著影響諸如HttpClient
之類的客戶端以及諸如Kestrel之類的伺服器中更高級別的使用。
private static readonly Encoding s_latin1 = Encoding.GetEncoding("iso-8859-1");
[Benchmark]public string Roundtrip(){
byte[] bytes = s_latin1.GetBytes("this is a test. this is only a test. did it work?");
return s_latin1.GetString(bytes);}
Method | Runtime | Mean | Allocated |
---|---|---|---|
Roundtrip | .NET FW 4.8 | 221.85 ns | 209 B |
Roundtrip | .NET Core 3.1 | 193.20 ns | 200 B |
Roundtrip | .NET 5.0 | 41.76 ns | 200 B |
編碼的效能改進也擴充套件到System.Text.Encodings.Web
中的編碼器,其中@gfoidl的PR dotnet/corefx#42073和dotnet/runtime#284改進了各種TextEncoder
型別。 其中包括使用SSSE3指令對JavaScriptEncoder.Default
實現中的FindFirstCharacterToEncodeUtf8
和FindFirstCharToEncode
進行向量化處理。
private char[] _dest = new char[1000];
[Benchmark]public void Encode() => JavaScriptEncoder.Default.Encode("This is a test to see how fast we can encode something that does not actually need encoding", _dest, out _, out _);
Method | Runtime | Mean | Ratio |
---|---|---|---|
Encode | .NET Core 3.1 | 102.52 ns | 1.00 |
Encode | .NET 5.0 | 33.39 ns | 0.33 |
正則表示式
一種非常特定但極為常見的解析形式是通過正則表示式。早在4月初,我分享了一篇詳細的部落格文章,內容涉及.NET 5中System.Text.RegularExpressions所進行的許多效能改進。我不會在這裡重新介紹所有內容,但我鼓勵您閱讀尚未閱讀的所有內容,因為它代表了圖書館的重大進步。但是,我在那篇文章中還指出,我們將繼續改進Regex
,特別是對特殊但常見的情況增加了支援。
這樣的改進之一是在指定RegexOptions.Multiline
時在換行符處理中,它更改了^
和$
錨的含義以匹配任何行的開頭和結尾,而不僅僅是整個輸入字串的開頭和結尾。我們以前沒有對行首錨進行任何特殊處理(指定Multiline
時為^
),這意味著作為FindFirstChar
操作的一部分(有關所指內容的背景資訊,請參見前面的部落格文章),我們不會不會像我們以前那樣多跳。 dotnet/runtime#34566教FindFirstChar
如何使用向量化的IndexOf
跳轉到下一個相關位置。在此基準測試中強調了這種影響,該基準測試處理的是從古騰堡計劃(Project Gutenberg)下載的“羅密歐與朱麗葉”的文字:
private readonly string _input = new HttpClient().GetStringAsync("http://www.gutenberg.org/cache/epub/1112/pg1112.txt").Result;private Regex _regex;
[Params(false, true)]public bool Compiled { get; set; }
[GlobalSetup]public void Setup() => _regex = new Regex(@"^.*\blove\b.*$", RegexOptions.Multiline | (Compiled ? RegexOptions.Compiled : RegexOptions.None));
[Benchmark]public int Count() => _regex.Matches(_input).Count;
Method | Runtime | Compiled | Mean | Ratio |
---|---|---|---|---|
Count | .NET FW 4.8 | False | 26.207 ms | 1.00 |
Count | .NET Core 3.1 | False | 21.106 ms | 0.80 |
Count | .NET 5.0 | False | 4.065 ms | 0.16 |
Count | .NET FW 4.8 | True | 16.944 ms | 1.00 |
Count | .NET Core 3.1 | True | 15.287 ms | 0.90 |
Count | .NET 5.0 | True | 2.172 ms | 0.13 |
另一個這樣的改進是在RegexOptions.IgnoreCase
的處理上。 IgnoreCase的實現使用char.ToLower {Invariant}
來獲取要比較的相關字元,但是由於特定於文化的對映而導致開銷。 當可能比要比較的字元小寫的唯一字元是該字元本身時,dotnet/runtime#35185可以避免那些開銷。
private readonly Regex _regex = new Regex("hello.*world", RegexOptions.Compiled | RegexOptions.IgnoreCase);private readonly string _input = "abcdHELLO" + new string('a', 128) + "WORLD123";
[Benchmark] public bool IsMatch() => _regex.IsMatch(_input);
Method | Runtime | Mean | RatioIs |
---|---|---|---|
IsMatch | .NET FW 4.8 | 2,558.1 ns | 1.00 |
IsMatch | .NET Core 3.1 | 789.3 ns | 0.31 |
IsMatch | .NET 5.0 | 129.0 ns | 0.05 |
與該改進相關的是dotnet/runtime#35203,它也用於RegexOptions.IgnoreCase
,減少了實現對CultureInfo.TextInfo
的虛擬呼叫次數,從而快取了TextInfo
而不是其源自的CultureInfo
。
private readonly Regex _regex = new Regex("Hello, \\w+.", RegexOptions.Compiled | RegexOptions.IgnoreCase);private readonly string _input = "This is a test to see how well this does. Hello, world.";
[Benchmark] public bool IsMatch() => _regex.IsMatch(_input);
Method | Runtime | Mean | Ratio |
---|---|---|---|
IsMatch | .NET FW 4.8 | 712.9 ns | 1.00 |
IsMatch | .NET Core 3.1 | 343.5 ns | 0.48 |
IsMatch | .NET 5.0 | 100.9 ns | 0.14 |
不過,我最近最喜歡的優化之一是dotnet/runtime#35824(然後在dotnet/runtime#35936中進行了進一步的增強)。 該更改認識到,對於以原子迴圈(一個顯式編寫的或更經常通過自動分析表示式升級為原子的正則表示式)開頭的正則表示式,我們可以更新掃描迴圈中的下一個起始位置(同樣,請參見部落格) 有關詳細資訊,請參見迴圈結束的位置,而不是迴圈的起始位置。 對於許多輸入,這可以大大減少開銷。 使用來自https://github.com/mariomka/regex-benchmark的基準和資料:
private Regex _email = new Regex(@"[\w\.+-]+@[\w\.-]+\.[\w\.-]+", RegexOptions.Compiled);private Regex _uri = new Regex(@"[\w]+://[^/\s?#]+[^\s?#]+(?:\?[^\s#]*)?(?:#[^\s]*)?", RegexOptions.Compiled);private Regex _ip = new Regex(@"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9])", RegexOptions.Compiled);
private string _input = new HttpClient().GetStringAsync("https://raw.githubusercontent.com/mariomka/regex-benchmark/652d55810691ad88e1c2292a2646d301d3928903/input-text.txt").Result;
[Benchmark] public int Email() => _email.Matches(_input).Count;[Benchmark] public int Uri() => _uri.Matches(_input).Count;[Benchmark] public int IP() => _ip.Matches(_input).Count;
Method | Runtime | Mean | Ratio |
---|---|---|---|
.NET FW 4.8 | 1,036.729 ms | 1.00 | |
.NET Core 3.1 | 930.238 ms | 0.90 | |
.NET 5.0 | 50.911 ms | 0.05 | |
Uri | .NET FW 4.8 | 870.114 ms | 1.00 |
Uri | .NET Core 3.1 | 759.079 ms | 0.87 |
Uri | .NET 5.0 | 50.022 ms | 0.06 |
IP | .NET FW 4.8 | 75.718 ms | 1.00 |
IP | .NET Core 3.1 | 61.818 ms | 0.82 |
IP | .NET 5.0 | 6.837 ms | 0.09 |
最後,並非所有關注點都集中在實際執行正則表示式的原始吞吐量上。開發人員可以通過Regex
獲得最佳吞吐量的方法之一是指定RegexOptions.Compiled
,它在執行時使用Reflection Emit生成IL,而後者又需要進行JIT編譯。根據使用的表示式,Regex
可能會吐出大量的IL,這可能需要大量的JIT處理才能生成彙編程式碼。 dotnet/runtime#35352改進了JIT本身以解決這種情況,修復了正則表示式生成的IL觸發的一些潛在的二次執行時程式碼路徑。然後dotnet/runtime#35321調整了Regex
引擎使用的IL操作,以採用與C#編譯器發出的模式更接近的模式,這很重要,因為JIT更需要優化這些模式以使其優化。在一些具有數百個複雜正則表示式的現實工作負載中,這些工作負載相結合,可以將JIT表示式花費的時間減少多達20%。
執行緒和非同步
實際上,預設情況下未啟用.NET 5中圍繞非同步進行的最大更改之一,但這是獲取反饋的另一項實驗。 部落格文章.NET 5中的Async ValueTask Pooling對此進行了更詳細的解釋,但從本質上說,dotnet/coreclr#26310引入了async ValueTask
和async ValueTask<T>
的功能,以隱式快取和重用建立的物件以表示非同步完成的操作。 ,使此類方法的開銷無需分攤攤銷。 該優化當前處於啟用狀態,這意味著您需要將DOTNET_SYSTEM_THREADING_POOLASYNCVALUETASKS
環境變數設定為1
才能啟用它。 啟用此功能的困難之一是程式碼可能正在做一些事情,而不僅僅是等待SomeValueTaskReturningMethod()
,因為ValueTasks
比Tasks
具有更多關於如何使用它們的約束。 為了解決這個問題,釋出了一個新的UseValueTasksCorrectly分析器,該分析器將標記大多數此類濫用情況。
[Benchmark]public async Task ValueTaskCost(){
for (int i = 0; i < 1_000; i++)
await YieldOnce();}
private static async ValueTask YieldOnce() => await Task.Yield();
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
ValueTaskCost | .NET FW 4.8 | 1,635.6 us | 1.00 | 294010 B |
ValueTaskCost | .NET Core 3.1 | 842.7 us | 0.51 | 120184 B |
ValueTaskCost | .NET 5.0 | 812.3 us | 0.50 | 186 B |
C#編譯器中的某些更改為.NET 5中的非同步方法帶來了更多好處(因為.NET 5中的核心庫是使用較新的編譯器進行編譯的)。每個非同步方法都有一個“生成器”,負責生成並完成返回的任務,而C#編譯器會生成程式碼,並將其作為非同步方法的一部分來使用。 @benaadams的dotnet/roslyn#41253避免了該程式碼的一部分生成的結構副本,這可以幫助減少開銷,尤其是對於構建器相對較大(並隨著T增長而增長)的async ValueTask<T>
方法而言。 @benaadams的dotnet/roslyn#45262也對相同的生成程式碼進行了調整,以便與前面討論的JIT的歸零改進一起更好地發揮作用。
特定的API也有一些改進。 dotnet/runtime#35575源自Task.ContinueWith
的某些特定用法,其中延續僅用於繼續記錄“先行”Task
中的異常。這裡的常見情況是Task
沒有錯,並且此PR在這種情況下的優化效果更好。
const int Iters = 1_000_000;
private AsyncTaskMethodBuilder[] tasks = new AsyncTaskMethodBuilder[Iters];
[IterationSetup]public void Setup(){
Array.Clear(tasks, 0, tasks.Length);
for (int i = 0; i < tasks.Length; i++)
_ = tasks[i].Task;}
[Benchmark(OperationsPerInvoke = Iters)]public void Cancel(){
for (int i = 0; i < tasks.Length; i++)
{
tasks[i].Task.ContinueWith(_ => { }, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
tasks[i].SetResult();
}}
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
Cancel | .NET FW 4.8 | 239.2 ns | 1.00 | 193 B |
Cancel | .NET Core 3.1 | 140.3 ns | 0.59 | 192 B |
Cancel | .NET 5.0 | 106.4 ns | 0.44 | 112 B |
還進行了一些調整以幫助特定的體系結構。 由於x86 / x64體系結構採用了強大的記憶體模型,因此在以x86 / x64為目標時,在JIT時,volatile
本質上會蒸發掉。 ARM / ARM64並非如此,後者的記憶體模型較弱,並且易失性導致JIT發出volatile
結果。 dotnet/runtime#36697刪除了排隊到ThreadPool
的每個工作項的多個易失性訪問,從而使ThreadPool
在ARM上更快。 dotnet/runtime#34225中斷了ConcurrentDictionary
中的易失性訪問,從而反過來將ARM上ConcurrentDictionary
上某些成員的吞吐量提高了30%。 dotnet/runtime#36976完全從另一個ConcurrentDictionary
欄位中刪除了volatile
。
集合
多年來,C#獲得了許多有價值的功能。這些功能中的許多功能集中於開發人員能夠更簡潔地編寫程式碼,而語言/編譯器負責所有樣板,例如C#9中的記錄。但是,一些功能集中於生產力,而更多地關注效能。 ,這些功能對於核心庫是一個極大的福音,這些庫經常可以使用它們來提高每個人的程式的效率。 @benaadams的dotnet/runtime#27195是一個很好的例子。 PR利用C#7中引入的ref return和ref locals改進了Dictionary <TKey,TValue>
。Dictionary <TKey,TValue>
的實現由字典中的條目陣列支援,並且字典具有一個核心例程,用於在其entry陣列中查詢鍵的索引;然後可以從多個函式(例如索引器,TryGetValue
,ContainsKey
等)中使用該例程。但是,這種共享需要付出一定的代價:通過將索引交還給呼叫者以根據需要從該插槽中獲取資料,呼叫者將需要重新索引到陣列中,從而進行第二次邊界檢查。使用ref返回,該共享例程可以將ref傳遞迴插槽而不是原始索引,從而使呼叫者可以避免第二次邊界檢查,同時還可以避免複製整個條目。 PR還包括對生成的程式集的一些低階調整,重組欄位以及用於更新這些欄位的操作,使JIT能夠更好地調整生成的程式集。
通過另外幾個PR,Dictionary <TKey,TValue>
的效能得到了進一步提高。像許多雜湊表一樣,Dictionary <TKey,TValue>
劃分為“儲存桶”,每個儲存桶本質上是一個連結的條目列表(儲存在陣列中,而不是每個專案具有單獨的節點物件)。對於給定的金鑰,使用雜湊函式(TKey
的GetHashCode
或提供的IComparer <T>
的GetHashCode
)來計算提供的金鑰的雜湊碼,然後將該雜湊碼確定性地對映到儲存桶;一旦找到儲存桶,該實現便會遍歷該儲存桶中的條目鏈,以尋找目標金鑰。該實現嘗試使每個儲存桶中的條目數保持較小,並根據需要增加和重新平衡以維持該條件。這樣,查詢成本的很大一部分是計算雜湊碼到儲存桶的對映。為了幫助在儲存桶之間保持良好的分佈,特別是當所提供的TKey
或比較器使用了不理想的雜湊碼生成器時,該詞典使用儲存桶的質數,並且儲存桶對映由hashcode%numBuckets
完成。但是以此處重要的速度,%運算符采用的除法相對昂貴。以Daniel Lemire的工作為基礎,@benaadams的dotnet/coreclr#27299,然後dotnet/runtime#406更改了64位程序中%
的使用,改為使用幾個乘法和移位來達到相同的結果,但速度更快。
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;}
Method | Runtime | Mean | Ratio |
---|---|---|---|
Sum | .NET FW 4.8 | 77.45 us | 1.00 |
Sum | .NET Core 3.1 | 67.35 us | 0.87 |
Sum | .NET 5.0 | 44.10 us | 0.57 |
HashSet<T>
與Dictionary<TKey,TValue>
非常相似。 儘管它公開了一組不同的操作(無雙關),但僅儲存鍵而不是鍵和值,但其資料結構在本質上是相同的……或者至少是以前的樣子。 多年來,鑑於使用的Dictionary <TKey,TValue>
比HashSet <T>
多得多,我們在優化Dictionary <TKey,TValue>
的實現上投入了更多的精力,而這兩種實現已經發生了變化。 @JeffreyZhao的dotnet/corefx#40106將一些改進從字典移植到了雜湊集,然後dotnet/runtime#37180通過將其與字典重新同步來有效重寫了HashSet<T>
的實現(以及將其在字典中的下移位置)。 堆疊,以便可以適當地替換正在用於集合的字典的某些位置)。 最終結果是HashSet<T>
最終會獲得相似的收益(甚至更是如此,因為它是從更糟糕的地方開始的)。
private HashSet<int> _set = Enumerable.Range(0, 10_000).ToHashSet();
[Benchmark]public int Sum(){
HashSet<int> set = _set;
int sum = 0;
for (int i = 0; i < 10_000; i++)
if (set.Contains(i))
sum += i;
return sum;}
Method | Runtime | Mean | Ratio |
---|---|---|---|
Sum | .NET FW 4.8 | 76.29 us | 1.00 |
Sum | .NET Core 3.1 | 79.23 us | 1.04 |
Sum | .NET 5.0 | 42.63 us | 0.56 |
同樣,dotnet/runtime#37081從Dictionary <TKey,TValue>
移植到ConcurrentDictionary <TKey,TValue>
進行了類似的改進。
private ConcurrentDictionary<int, int> _dictionary = new ConcurrentDictionary<int, int>(Enumerable.Range(0, 10_000).Select(i => new KeyValuePair<int, int>(i, i)));
[Benchmark]public int Sum(){
ConcurrentDictionary<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;}
Method | Runtime | Mean | Ratio |
---|---|---|---|
Sum | .NET FW 4.8 | 115.25 us | 1.00 |
Sum | .NET Core 3.1 | 84.30 us | 0.73 |
Sum | .NET 5.0 | 49.52 us | 0.43 |
未完待續......