在.NET Core以前的版本中,其實已經在博客中介紹了在該版本中發現的重大性能改進。 從.NET Core 2.0到.NET Core 2.1到.NET Core 3.0的每一篇文章,發現
談論愈來愈多的東西。 然而有趣的是,每次都想知道下一次是否有足夠的意義的改進以保證再發表一篇文章。 .NET 5已經實現了許多性能改進,儘管直到今年秋天才計劃發佈最終版本,而且到那時頗有可能會有更多的改進,可是還要強調一下,如今已提供的改進。 在這篇文章中,重點介紹約250個PR,這些請求爲整個.NET 5的性能提高作出了巨大貢獻。
html
Benchmark.NET如今是衡量.NET代碼性能的規範工具,可輕鬆分析代碼段的吞吐量和分配。 所以,本文中大部分示例都是使用使用該工具編寫的微基準來衡量的。首先建立了一個目錄,而後使用dotnet工具對其進行了擴展:
c++
mkdir Benchmarks cd Benchmarks dotnet new console
生成的Benchmarks.csproj的內容擴展爲以下所示:
git
<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 Framework 4.8,.NET Core 3.1和.NET 5執行基準測試(目前已爲Preview 8安裝了每晚生成的版本)。.csproj還引用Benchmark.NET NuGet軟件包(其最新版本爲12.1版),以便可以使用其功能,而後引用其餘幾個庫和軟件包,特別是爲了支持可以在其上運行測試 .NET Framework 4.8。
而後,將生成的Program.cs文件更新到同一文件夾中,以下所示:
github
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 GO HERE"
的位置。
爲了運行基準測試,而後作:
web
dotnet run -c Release -f net48 --runtimes net48 netcoreapp31 netcoreapp50 --filter ** --join
這告訴Benchmark.NET:
正則表達式
在某些狀況下,針對特定目標的API並不存在,我只是省略了命令行的這一部分。
最後,請注意如下幾點:
數據庫
讓咱們開始吧…
express
對於全部對.NET和性能感興趣的人來講,垃圾收集一般是他們最關心的。在減小分配上花費了大量的精力,不是由於分配行爲自己特別昂貴,而是由於經過垃圾收集器(GC)清理這些分配以後的後續成本。然而,不管減小分配須要作多少工做,絕大多數工做負載都會致使這種狀況發生,所以,重要的是要不斷提升GC可以完成的任務和速度。
這個版本在改進GC方面作了不少工做。例如, dotnet/coreclr#25986 爲GC的「mark」階段實現了一種形式的工做竊取。.NET GC是一個「tracing」收集器,這意味着(在很是高的級別上)當它運行時,它從一組「roots」(已知的固有可訪問的位置,好比靜態字段)開始,從一個對象遍歷到另外一個對象,將每一個對象「mark」爲可訪問;在全部這些遍歷以後,任何沒有標記的對象都是不可訪問的,能夠收集。此標記表明了執行集合所花費的大部分時間,而且此PR經過更好地平衡集合中涉及的每一個線程執行的工做來改進標記性能。當使用「Server GC」運行時,每一個核都有一個線程參與收集,當線程完成分配給它們的標記工做時,它們如今可以從其餘線程「steal」 未完成的工做,以幫助更快地完成整個收集。
另外一個例子是,dotnet/runtime#35896 「ephemeral」段的解壓進行了優化(gen0和gen1被稱爲 「ephemeral」,由於它們是預期只持續很短期的對象)。在段的最後一個活動對象以後,將內存頁返回給操做系統。那麼GC的問題就變成了,這種解解應該在何時發生,以及在任什麼時候候應該解解多少,由於在不久的未來,它可能須要爲額外的分配分配額外的頁面。
或者以dotnet/runtime#32795,爲例,它經過減小在GC靜態掃描中涉及的鎖爭用,提升了在具備較高核心計數的機器上的GC可伸縮性。或者dotnet/runtime#37894,它避免了代價高昂的內存重置(本質上是告訴操做系統相關的內存再也不感興趣),除非GC看到它處於低內存的狀況。或者dotnet/runtime#37159,它(雖然尚未合併,預計將用於.NET5 )構建在@damageboy的工做之上,用於向量化GC中使用的排序。或者 dotnet/coreclr#27729,它減小了GC掛起線程所花費的時間,這對於它得到一個穩定的視圖,從而準確地肯定正在使用的線程是必要的。
這只是改進GC自己所作的部分更改,但最後一點給我帶來了一個特別吸引個人話題,由於它涉及到近年來咱們在.NET中所作的許多工做。在這個版本中,咱們繼續,甚至加快了從C/C++移植coreclr運行時中的本地實現,以取代System.Private.Corelib中的普通c#託管代碼。此舉有大量的好處,包括讓咱們更容易共享一個實現跨多個運行時(如coreclr和mono),甚至對咱們來講更容易進化API表面積,如經過重用相同的邏輯來處理數組和跨越。但讓一些人吃驚的是,這些好處還包括多方面的性能。其中一種方法回溯到使用託管運行時的最初動機:安全性。默認狀況下,用c#編寫的代碼是「safe」,由於運行時確保全部內存訪問都檢查了邊界,只有經過代碼中可見的顯式操做(例如使用unsafe關鍵字,Marshal類,unsafe類等),開發者才能刪除這種驗證。結果,做爲一個開源項目的維護人員,咱們的工做的航運安全系統在很大程度上使當貢獻託管代碼的形式:雖然這樣的代碼能夠固然包含錯誤,可能會經過代碼審查和自動化測試,咱們能夠晚上睡得更好知道這些bug引入安全問題的概率大大下降。這反過來意味着咱們更有可能接受託管代碼的改進,而且速度更快,貢獻者提供的更快,咱們幫助驗證的更快。咱們還發現,當使用c#而不是C時,有更多的貢獻者對探索性能改進感興趣,並且更多的人以更快的速度進行實驗,從而得到更好的性能。
然而,咱們從移植中看到了更直接的性能改進。託管代碼調用運行時所需的開銷相對較小,可是若是調用頻率很高,那麼開銷就會增長。考慮dotnet/coreclr#27700,它將原始類型數組排序的實現從coreclr的本地代碼移到了Corelib的c#中。除了這些代碼以外,它還爲新的公共api提供了對跨度進行排序的支持,它還下降了對較小數組進行排序的成本,由於排序的成本主要來自於從託管代碼的轉換。咱們能夠在一個小的基準測試中看到這一點,它只是使用數組。對包含10個元素的int[], double[]和string[]數組進行排序:
json
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 |
這自己就是此次遷移的一個很好的好處,由於咱們在.NET5中經過dotnet/runtime#37630 添加了System.Half,一個新的原始16位浮點,而且在託管代碼中,這個排序實現的優化幾乎當即應用到它,而之前的本地實現須要大量的額外工做,由於沒有c++標準類型的一半。可是,這裏還有一個更有影響的性能優點,這讓咱們回到我開始討論的地方:GC。
GC的一個有趣指標是「pause time」,這實際上意味着GC必須暫停運行時多長時間才能執行其工做。更長的暫停時間對延遲有直接的影響,而延遲是全部工做負載方式的關鍵指標。正如前面提到的,GC可能須要暫停線程爲了獲得一個一致的世界觀,並確保它能安全地移動對象,可是若是一個線程正在執行C/c++代碼在運行時,GC可能須要等到調用完成以前暫停的線程。所以,咱們在託管代碼而不是本機代碼中作的工做越多,GC暫停時間就越好。咱們能夠使用相同的數組。排序的例子,看看這個。考慮一下這個程序:
小程序
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,每次GCs之間大約有15毫秒。咱們預計這個循環會花費150毫秒多一點的時間。但當我在.NET Core 3.1上運行時,我獲得的秒數是這樣的
6.6419048 5.5663149 5.7430339 6.032052 7.8892468
在這裏,GC很難中斷執行排序的線程,致使GC暫停時間遠遠高於預期。幸運的是,當我在 .NET5 上運行這個時,我獲得了這樣的數字:
0.159311 0.159453 0.1594669 0.1593328 0.1586566
這正是咱們預測的結果。經過移動數組。將實現排序到託管代碼中,這樣運行時就能夠在須要時更容易地掛起實現,咱們使GC可以更好地完成其工做。
固然,這不只限於Array.Sort。 一堆PR進行了這樣的移植,例如dotnet/runtime#32722將stdelemref和ldelemaref JIT helper 移動到C#,dotnet/runtime#32353 將unbox helpers的一部分移動到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的改進來在各類字符串和數組方法中得到額外的收益。
關於這組更改的最後一個想法是,須要注意的另外一件有趣的事情是,在一個版本中所作的微優化是如何基於後來被證實無效的假設的,而且當使用這種微優化時,須要準備並願意適應。在個人.NET Core 3.0博客中,我提到了像dotnet/coreclr#21756這樣的「peanut butter」式的改變,它改變了不少使用數組的調用站點。複製(源,目標,長度),而不是使用數組。複製(source, sourceOffset, destination, destinationOffset, length),由於前者獲取源數組和目標數組的下限的開銷是可測量的。可是經過前面提到的將數組處理代碼移動到c#的一系列更改,更簡單的重載的開銷消失了,使其成爲這些操做更簡單、更快的選擇。這樣,.NET5 PRs dotnet/coreclr#27641和dotnet/corefx#42343切換了全部這些呼叫站點,更多地回到使用更簡單的過載。dotnet/runtime#36304是另外一個取消以前優化的例子,由於更改使它們過期或實際上有害。你老是可以傳遞一個字符到字符串。分裂,如version.Split (' . ')。然而,問題是,這個綁定到Split的惟一重載是Split(params char[] separator),這意味着每次這樣的調用都會致使c#編譯器生成一個char[]分配。爲了解決這個問題,之前的版本添加了緩存,提早分配數組並將它們存儲到靜態中,而後能夠被分割調用使用,以免每一個調用都使用char[]。既然.NET中有一個Split(char separator, StringSplitOptions options = StringSplitOptions. none)重載,咱們就再也不須要數組了。
做爲最後一個示例,我展現了將代碼移出運行時並轉移到託管代碼中如何幫助GC暫停,可是固然還有其餘方式能夠使運行時中剩餘的代碼對此有所幫助。dotnet/runtime#36179經過確保運行時處於代碼爭搶模式下(例如獲取「Watson」存儲桶參數(基本上是一組用於惟一標識此特定異常和調用堆棧以用於報告目的的數據)),從而減小了因爲異常處理而致使的GC暫停。 。暫停。
.NET5 也是即時(JIT)編譯器的一個使人興奮的版本,該版本中包含了各類各樣的改進。與任何編譯器同樣,對JIT的改進能夠產生普遍的影響。一般,單獨的更改對單獨的代碼段的影響很小,可是這樣的更改會被它們應用的地方的數量放大。
能夠向JIT添加的優化的數量幾乎是無限的,若是給JIT無限的時間來運行這種優化,JIT就能夠爲任何給定的場景建立最優代碼。可是JIT的時間並非無限的。JIT的「即時」特性意味着它在應用程序運行時執行編譯:當調用還沒有編譯的方法時,JIT須要按需爲其提供彙編代碼。這意味着在編譯完成以前線程不能向前推動,這反過來意味着JIT須要在應用什麼優化以及如何選擇使用有限的時間預算方面有策略。各類技術用於給JIT更多的時間,好比使用「提早」(AOT)編譯應用程序的一些部分作儘量多的編譯工做前儘量執行應用程序(例如,AOT編譯核心庫都使用一個叫「ReadyToRun」的技術,你可能會聽到稱爲「R2R」甚至「crossgen」,是產生這些圖像的工具),或使用「tiered compilation」,它容許JIT在最初編譯一個應用了從少到少優化的方法,所以速度很是快,只有在它被認爲有價值的時候(即該方法被重複使用的時候),纔會花更多的時間使用更多優化來從新編譯它。然而,更廣泛的狀況是,參與JIT的開發人員只是選擇使用分配的時間預算進行優化,根據開發人員編寫的代碼和他們使用的代碼模式,這些優化被證實是有價值的。這意味着,隨着.NET的發展並得到新的功能、新的語言特性和新的庫特性,JIT也會隨着適合於編寫的較新的代碼風格的優化而發展。
一個很好的例子是@benaadams的dotnet/runtime#32538。 Span 一直滲透到.NET堆棧的全部層,由於從事運行時,核心庫,ASP.NET Core的開發人員以及其餘人在編寫安全有效的代碼(也統一了字符串處理)時認識到了它的強大功能 ,託管數組,本機分配的內存和其餘形式的數據。 相似地,值類型(結構)被愈來愈廣泛地用做經過堆棧分配避免對象分配開銷的一種方式。 可是,對此類類型的嚴重依賴也給運行時帶來了更多麻煩。 coreclr運行時使用「precise」 garbage collector,這意味着GC可以100%準確地跟蹤哪些值引用託管對象,哪些值不引用託管對象; 這樣作有好處,但也有代價(相反,mono運行時使用「conservative」垃圾收集器,這具備一些性能上的好處,但也意味着它能夠解釋堆棧上的任意值,而該值剛好與 被管理對象的地址做爲對該對象的實時引用)。 這樣的代價之一是,JIT須要經過確保在GC注意以前將任何能夠解釋爲對象引用的局部都清零來幫助GC。 不然,GC可能最終會在還沒有設置的本地中看到一個垃圾值,並假定它引用的是有效對象,這時可能會發生「bad things」。 參考當地人越多,須要進行的清理越多。 若是您只清理一些當地人,那可能不會引發注意。 可是隨着數量的增長,清除這些本地對象所花費的時間可能加起來,尤爲是在很是熱的代碼路徑中使用的一種小方法中。 這種狀況在跨度和結構中變得更加廣泛,在這種狀況下,編碼模式一般會致使須要爲零的更多引用(Span 包含引用)。 前面提到的PR經過更新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輸出的guid。這是由於c#編譯器在編譯的示例方法的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。在dotnet/runtime#37541中,.NET5 中的核心庫如今都使用這個屬性來禁用.locals init(在之前的版本中,.locals init在構建核心庫時經過編譯後的一個步驟刪除)。請注意,c#編譯器只容許在不安全的上下文中使用SkipLocalsInit,由於它很容易致使未通過適當驗證的代碼損壞(所以,若是/當您應用它時,請三思)。
除了使零的速度更快,也有改變,以消除零徹底。例如,dotnet/runtime#31960, dotnet/runtime#36918, dotnet/runtime#37786,和dotnet/runtime#38314 都有助於消除零,當JIT能夠證實它是重複的。
這樣的零是託管代碼的一個例子,運行時須要它來保證其模型和上面語言的需求。另外一種此類稅收是邊界檢查。使用託管代碼的最大優點之一是,在默認狀況下,整個類的潛在安全漏洞都變得可有可無。運行時確保數組、字符串和span的索引被檢查,這意味着運行時注入檢查以確保被請求的索引在被索引的數據的範圍內(即greather大於或等於0,小於數據的長度)。這裏有一個簡單的例子:
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的一個方便特性生成的。將[DisassemblyDiagnoser]添加到包含基準測試的類中,它就會吐出被分解的彙編代碼。咱們能夠看到,大會將字符串(經過rcx寄存器)和加載字符串的長度(8個字節存儲到對象,所以,[rcx + 8]),與我通過比較,edx登記,若是與一個無符號的比較(無符號,這樣任何負環繞大於長度)我是長度大於或等於,跳到一個輔助COREINFO_HELP_RNGCHKFAIL拋出一個異常。只有幾條指令,可是某些類型的代碼可能會花費大量的循環索引,所以,當JIT能夠消除儘量多的沒必要要的邊界檢查時,這是頗有幫助的。
JIT已經可以在各類狀況下刪除邊界檢查。例如,當你寫循環:
int[] arr = ...; for (int i = 0; i < arr.Length; i++) Use(arr[i]);
JIT能夠證實我永遠不會超出數組的邊界,所以它能夠省略它將生成的邊界檢查。在.NET5 中,它能夠在更多的地方刪除邊界檢查。例如,考慮這個函數,它將一個整數的字節做爲字符寫入一個span:
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的新字節數組的模式(它也能夠識別sbyte和bool,但因爲字節關係,沒有比字節大的)。由於數組的性質被span徹底隱藏了,C#編譯器經過將字節實際存儲到程序集的數據部分而發出這些字節,而span只是經過將靜態數據和長度的指針包裝起來而建立的:
IL_000c: ldsflda valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=16' '<PrivateImplementationDetails>'::'2125B2C332B1113AAE9BFC5E9F7E3B4C91D828CB942C2DF1EEB02502ECCAE9E9' IL_0011: ldc.i4.s 16 IL_0013: newobj instance void valuetype [System.Runtime]System.ReadOnlySpan'1<uint8>::.ctor(void*, int32)
因爲ldc.i4,這對於本次JIT討論很重要。s16在上面。這就是IL加載16的長度來建立跨度,JIT能夠看到這一點。它知道跨度的長度是16,這意味着若是它能夠證實訪問老是大於或等於0且小於16的值,它就不須要對訪問進行邊界檢查。dotnet/runtime#1644 就是這樣作的,它能夠識別像array[index % const]這樣的模式,並在const小於或等於長度時省略邊界檢查。在前面的TryToHex示例中,JIT能夠看到地圖跨長度16,和它能夠看到全部的索引到完成& 0 xf,意義最終將全部值在範圍內,所以它能夠消除全部的邊界檢查地圖。結合的事實可能已經看到,沒有邊界檢查須要寫進跨度(由於它能夠看到前面長度檢查的方法保護全部索引到跨度),和整個方法是在.NET bounds-check-free 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 |
注意.NET5的運行速度不只比.NET Core 3.1快15%,咱們還能夠看到它的彙編代碼大小小了22%(額外的「Code Size」一欄來自於我在benchmark類中添加了[DisassemblyDiagnoser])。
另外一個很好的邊界檢查移除來自dotnet/runtime#36263中的@nathan-moore。我提到過,JIT已經可以刪除很是常見的從0迭代到數組、字符串或span長度的模式的邊界檢查,可是在此基礎上還有一些比較常見的變化,但之前沒有認識到。例如,考慮這個微基準測試,它調用一個方法來檢測一段整數是否被排序:
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忽略邊界檢查。如今不是了.NET5在個人機器上能夠快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異常(這裏))。但有時指令只用於null檢查,而不是完成其餘必要的功能,並且只要須要的null檢查是因爲某些指令發生的,沒必要要的重複指令能夠被刪除。考慮這段代碼:
private (int i, int j) _value; [Benchmark] public int NullCheck() => _value.j++;
做爲一個可運行的基準測試,它所作的工做太少,沒法用基準測試進行準確的度量.NET,但這是查看生成的彙編代碼的好方法。在.NET Core 3.1中,此方法產生以下assembly:
; 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
cmp [rcx],ecx指令在計算j的地址時執行null檢查,而後mov eax,[rcx]指令執行另外一個null檢查,做爲取消引用j的位置的一部分。所以,第一個null檢查其實是沒必要要的,由於該指令沒有提供任何其餘好處。因此,多虧了像dotnet/runtime#1735和dotnet/runtime#32641這樣的PRs,這樣的重複被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中的數組是「協變」的,這意味着我能夠傳遞一個數組派生類型[]做爲BaseType[],其中派生類型派生自BaseType。這意味着在本例中,arr能夠被構造爲新A[1]或新對象[1]或新B[1]。這段代碼應該在前兩個中運行良好,但若是arr其實是一個B[],試圖存儲一個實例到其中必須失敗;不然,使用數組做爲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
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
if (!typeof(T).IsValueType && array.GetType() != typeof(T[])) ThrowHelper.ThrowArrayTypeMismatchException();
PR dotnet/runtime#32790就是這樣優化數組的.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 |
當查看生成的程序集時,差別的解釋就很明顯了,即便不是徹底精通程序集代碼。如下是[DisassemblyDiagnoser]在.NET Core 3.1上生成的內容:
; 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
下面是.NET5的內容:
; 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。當咱們第一次將本機數組排序實現移動到managed時,咱們無心中致使了浮點值的迴歸,這個迴歸被@nietras 發現,隨後在dotnet/runtime#37941中修復。迴歸是因爲本機實現使用一個特殊的優化,咱們失蹤的管理端口(浮點數組,將全部NaN值數組的開始,後續的比較操做能夠忽略NaN)的可能性,咱們成功了。然而,問題是這個的方式表達並無致使大量的代碼重複:本機實現模板,使用和管理實現使用泛型,但限制與泛型等,內聯 helpers介紹,以免大量的代碼重複致使non-inlineable在每一個比較採用那種方法調用。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); }
最小的方法比較提供的三個值並返回最小值的索引。它是泛型類型上的一個方法,它調用同一類型上的另外一個方法,這個方法反過來調用泛型類型參數實例上的方法。因爲基準使用C做爲泛型類型,並且C是引用類型,因此JIT不會專門爲C專門化此方法的代碼,而是使用它生成的用於全部引用類型的「shared」實現。爲了讓Compare方法隨後調用到CompareTo的正確接口實現,共享泛型實現使用了一個從泛型類型映射到正確目標的字典。在. net的早期版本中,包含那些通用字典查找的方法是不可行的,這意味着這個最小的方法不能內聯它所作的三個比較調用,即便Compare被歸爲methodimploptions .侵略化的內聯。前面提到的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 Throw helpers() { 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
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可以識別這種重複,而不是全部的6個呼叫站點,它將最終合併成一個:
M00_L00: call System.ThrowHelper.ThrowArgumentOutOfRangeException() int 3
全部失敗的檢查都跳到這個共享位置,而不是每一個都有本身的副本
Method | Runtime | Code Size |
---|---|---|
Throw helpers | .NET FW 4.8 | 424 B |
Throw helpers | .NET Core 3.1 | 252 B |
Throw helpers | .NET 5.0 | 222 B |
這些只是.NET 5中對JIT進行的衆多改進中的一部分。還有許多其餘改進。dotnet/runtime#32368致使JIT將數組的長度視爲無符號,這使得JIT可以對在長度上執行的某些數學運算(例如除法)使用更好的指令。 dotnet/runtime#25458 使JIT能夠對某些無符號整數運算使用更快的基於0的比較。 當開發人員實際編寫> = 1時,使用等於!= 0的值。dotnet/runtime#1378容許JIT將「 constantString」 .Length識別爲常量值。 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中還有一些默認禁用的重要更改,目的是得到關於它們的真實反饋,並可以在默認狀況下post-啓用它們。淨5。例如,dotnet/runtime#32969提供了「On Stack Replacement」(OSR)的初始實現。我在前面提到了分層編譯,它使JIT可以首先爲一個方法生成優化最少的代碼,而後當該方法被證實是重要的時,用更多的優化從新編譯該方法。這容許代碼運行得更快,而且只有在運行時才升級有效的方法,從而實現更快的啓動時間。可是,分層編譯依賴於替換實現的能力,下次調用它時,將調用新的實現。可是長時間運行的方法呢?對於包含循環(或者,更具體地說,向後分支)的方法,分層編譯在默認狀況下是禁用的,由於它們可能會運行很長時間,以致於沒法及時使用替換。OSR容許方法在執行代碼時被更新,而它們是「在堆棧上」的;PR中包含的設計文檔中有不少細節(也與分層編譯有關,dotnet/runtime#1457改進了調用計數機制,分層編譯經過這種機制決定哪些方法應該從新編譯以及什麼時候從新編譯)。您能夠經過將COMPlus_TC_QuickJitForLoops和COMPlus_TC_OnStackReplacement環境變量設置爲1來試驗OSR。另外一個例子是,dotnet/runtime#1180 改進了try塊內代碼的生成代碼質量,使JIT可以在寄存器中保存之前不能保存的值。您能夠經過將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,長度檢查也能夠成爲常量)。或dotnet/runtime#1224,它改進了Bmi2的代碼生成。MultiplyNoFlags內在。或者dotnet/runtime#37836,它將轉換位操做。將PopCount轉換爲一個內因,使JIT可以識別什麼時候使用常量參數調用它,並將整個操做替換爲一個預先計算的常量。或dotnet/runtime#37254,它刪除使用const字符串時發出的空檢查。或者來自@damageboy的dotnet/runtime#32000 ,它優化了雙重否認。
在.NET Core 3.0中,超過1000種新的硬件內置方法被添加並被JIT識別,從而使c#代碼可以直接針對指令集,如SSE4和AVX2(docs)。而後,在覈心庫中的一組api中使用了這些工具。可是,intrinsic僅限於x86/x64架構。在.NET 5中,咱們投入了大量的精力來增長數千個組件,特別是針對ARM64,這要感謝衆多貢獻者,特別是來自Arm Holdings的@TamarChristinaArm。與對應的x86/x64同樣,這些內含物在覈心庫功能中獲得了很好的利用。例如,BitOperations.PopCount()方法以前被優化爲使用x86 POPCNT內在的,對於.NET 5, dotnet/runtime#35636 加強了它,使它也可以使用ARM VCNT或等價的ARM64 CNT。相似地,dotnet/runtime#34486修改了位操做。LeadingZeroCount, TrailingZeroCount和Log2利用相應的instrincs。在更高的級別上,來自@Gnbrkm41的dotnet/runtime#33749加強了位數組中的多個方法,以使用ARM64內含物來配合以前添加的對SSE2和AVX2的支持。爲了確保Vector api在ARM64上也能很好地執行,咱們作了不少工做,好比dotnet/runtime#33749和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在不少地方調用了預先存在的 helpers函數,運行時提供這些 helpers,對這些 helpers的改進能夠對程序產生有意義的影響。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 |
基於文本的處理是許多應用程序的基礎,而且在每一個版本中都花費了大量的精力來改進基礎構建塊,其餘全部內容都構建在這些基礎構建塊之上。這些變化從 helpers處理單個字符的微優化一直延伸到整個文本處理庫的大修。
系統。Char在NET 5中獲得了一些不錯的改進。例如,dotnet/coreclr#26848提升了char的性能。經過調整實現來要求更少的指令和更少的分支。改善char。IsWhiteSpace隨後在一系列依賴於它的其餘方法中出現,好比string.IsEmptyOrWhiteSpace和調整:
[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改進了char的性能。ToUpperInvariant和char。經過改進各類方法的內聯性,將調用路徑從公共api簡化到核心功能,並進一步調整實現以確保JIT生成最佳代碼,從而實現owerinvariant。
[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 |
對字符串的操做也有不少改進,好比dotnet/coreclr#26621和dotnet/coreclr#26962,在某些狀況下顯著提升了區域性感知的Linux上的起始和結束操做的性能。
固然,低級處理是很好的,可是如今的應用程序花費了大量的時間來執行高級操做,好比以特定格式編碼數據,好比以前的.NET Core版本是對Encoding.UTF8進行了優化,但在.NET 5中仍有進一步的改進。dotnet/runtime#27268優化它,特別是對於較小的投入,以更好地利用堆棧分配和改進了JIT devirtualization (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」(如今公開表示爲編碼)。Encoding.Latin1經過dotnet/runtime#37550),也很是重要,特別是對於像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中的編碼器。來自@gfoidl的PRs dotnet/corefx#42073和dotnet/runtime#284改進了各類TextEncoder類型。這包括使用SSSE3指令向量化FindFirstCharacterToEncodeUtf8以及JavaScriptEncoder中的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 _);
一種很是特殊但很是常見的解析形式是經過正則表達式。早在4月初,我就分享了一篇關於。net 5中System.Text.RegularExpressions大量性能改進的詳細博客文章。我不打算在這裏重複全部這些內容,可是若是你尚未讀過,我鼓勵你去讀它,由於它表明了圖書館的重大進步。然而,我還在那篇文章中指出,咱們將繼續改進正則表達式,特別是增長了對特殊但常見狀況的更多支持。
其中一個改進是在指定RegexOptions時的換行處理。Multiline,它改變^和$錨點的含義,使其在任何行的開始和結束處匹配,而不只僅是整個輸入字符串的開始和結束處。以前咱們沒有對起始行錨作任何特殊的處理(當Multiline被指定時^),這意味着做爲FindFirstChar操做的一部分(請參閱前面提到的博客文章,瞭解它指的是什麼),咱們不會盡量地跳過它。dotnet/runtime#34566教會FindFirstChar如何使用矢量化的索引向前跳轉到下一個相關位置。這一影響在這個基準中獲得了強調,它處理從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 | Ratio |
---|---|---|---|
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,而不是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中進一步加強)。regex的認可變化,從一個原子環(一個明確的書面或更常見的一個原子的升級到自動的分析表達式),咱們能夠更新掃描循環中的下一個起始位置(再一次,詳見博客)基於循環的結束,而不是開始。對於許多輸入,這能夠大大減小開銷。使用基準測試和來自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。編譯,它使用反射發射在運行時生成IL,反過來須要JIT編譯。根據所使用的表達式,Regex可能會輸出大量IL,而後須要大量的JIT處理才能生成彙編代碼。dotnet/runtime#35352改進了JIT自己來幫助解決這種狀況,修復了regex生成的IL觸發的一些可能的二次執行時代碼路徑。而dotnet/runtime#35321對Regex引擎使用的IL操做進行了調整,使其使用的模式更接近於c#編譯器發出的模式,這一點很重要,由於JIT對這些模式進行了更多的優化。在一些具備數百個複雜正則表達式的實際工做負載上,將它們組合起來能夠將JIT表達式所花的時間減小20%以上。
net 5中關於異步的最大變化之一其實是默認不啓用的,但這是另外一個得到反饋的實驗。net 5中的異步ValueTask池博客更詳細地解釋,但本質上dotnet/coreclr#26310介紹了異步ValueTask能力和異步ValueTask
[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中的異步方法帶來了額外的好處(在 .NET5中的核心庫是用更新的編譯器編譯的)。每一個異步方法都有一個負責生成和完成返回任務的「生成器」,而c#編譯器將生成代碼做爲異步方法的一部分來使用。避免做爲代碼的一部分生成結構副本,這能夠幫助減小開銷,特別是對於async ValueTask方法,其中構建器相對較大(並隨着T的增加而增加)。一樣來自@benaadams的dotnet/roslyn#45262也調整了相同的生成代碼,以更好地發揮前面討論的JIT的零改進。
在特定的api中也有一些改進。dotnet/runtime#35575誕生於一些特定的任務使用Task.ContinueWith,其中延續純粹用於記錄「先行」任務continue from中的異常。一般狀況下,任務不會出錯,而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時,volatile在JIT時基本上就消失了。ARM/ARM64的狀況不是這樣,它的內存模型較弱,而且volatile會致使JIT發出圍欄。dotnet/runtime#36697刪除了每一個排隊到線程池的工做項的幾個volatile訪問,使ARM上的線程池更快。dotnet/runtime#34225將ConcurrentDictionary中的volatile訪問從一個循環中拋出,這反過來提升了ARM上ConcurrentDictionary的一些成員的吞吐量高達30%。而dotnet/runtime#36976則徹底從另外一個ConcurrentDictionary字段中刪除了volatile。
多年來,c#已經得到了大量有價值的特性。這些特性中的許多都是爲了讓開發人員可以更簡潔地編寫代碼,而語言/編譯器負責全部樣板文件,好比c# 9中的記錄。然而,有一些特性更注重性能而不是生產力,這些特性對核心庫來講是一個巨大的恩惠,它們能夠常用它們來提升每一個人的程序的效率。來自@benaadams的dotnet/runtime#27195就是一個很好的例子。PR改進了Dictionary<TKey, TValue>,利用了c# 7中引入的ref返回和ref局部變量。>的實現是由字典中的數組條目支持的,字典有一個核心例程用於在其條目數組中查找鍵的索引;而後在多個函數中使用該例程,如indexer、TryGetValue、ContainsKey等。可是,這種共享是有代價的:經過返回索引並將其留給調用者根據須要從槽中獲取數據,調用者將須要從新索引到數組中,從而致使第二次邊界檢查。有了ref返回,共享例程就能夠把一個ref遞迴給槽,而不是原始索引,這樣調用者就能夠避免第二次邊界檢查,同時也避免複製整個條目。PR還包括對生成的程序集進行一些低級調優、從新組織字段和用於更新這些字段的操做,以便JIT可以更好地調優生成的程序集。
字典<TKey,TValue>的性能進一步提升了幾個PRs。像許多哈希表同樣,Dictionary<TKey,TValue>被劃分爲「bucket」,每一個bucket本質上是一個條目鏈表(存儲在數組中,而不是每一個項都有單獨的節點對象)。對於給定的鍵,一個哈希函數(TKey ' s GetHashCode或提供的IComparer ' s GetHashCode)用於計算提供的鍵的哈希碼,而後該哈希碼肯定地映射到一個bucket;找到bucket以後,實現將遍歷該bucket中的條目鏈,查找目標鍵。該實現試圖保持每一個bucket中的條目數較小,並在必要時進行增加和從新平衡以維護該條件。所以,查找的很大一部分開銷是計算hashcode到bucket的映射。爲了幫助在bucket之間保持良好的分佈,特別是當提供的TKey或比較器使用不太理想的哈希代碼生成器時,字典使用質數的bucket,而bucket映射由hashcode % numBuckets完成。可是在這裏重要的速度,%操做符采用的除法是相對昂貴的。基於Daniel Lemire的工做,dotnet/coreclr#27299(來自@benaadams)和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很是相似於Dictionary<TKey, TValue>。雖然它公開了一組不一樣的操做(沒有雙關的意思),除了只存儲一個鍵而不是一個鍵和一個值以外,它的數據結構基本上是相同的……或者至少過去是同樣的。多年來,考慮到使用Dictionary<TKey,TValue>比HashSet多多少,咱們花費了更多的努力來優化Dictionary<TKey,TValue>的實現,這兩種實現已經漂移了。dotnet/corefx#40106 @JeffreyZhao移植的一些改進詞典散列集,而後dotnet/runtime#37180有效地改寫HashSet
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 |
System.Collections。不可變的版本也有改進。dotnet/runtime#1183是@hnrqbaggio經過添加[MethodImpl(methodimploptions.ancsiveinlining)]到ImmutableArray的GetEnumerator方法來提升對ImmutableArray的GetEnumerator方法的foreach性能。咱們一般很是謹慎灑AggressiveInlining:它能夠使微基準測試看起來很好,由於它最終消除調用相關方法的開銷,但它也能夠大大提升代碼的大小,而後一大堆事情產生負面影響,如致使指令緩存變得不那麼有效了。然而,在這種狀況下,它不只提升了吞吐量,並且實際上還減小了代碼的大小。內聯是一種強大的優化,不只由於它消除了調用的開銷,還由於它向調用者公開了被調用者的內容。JIT一般不作過程間分析,這是因爲JIT用於優化的時間預算有限,可是內聯經過合併調用者和被調用者克服了這一點,在這一點上調用者因素的JIT優化被調用者因素。假設一個方法public static int GetValue() => 42;調用者執行if (GetValue() * 2 > 100){…不少代碼…}。若是GetValue()沒有內聯,那麼比較和「大量代碼」將會被JIT處理,可是若是GetValue()內聯,JIT將會看到這就像(84 > 100){…不少代碼…},則整個塊將被刪除。幸運的是,這樣一個簡單的方法幾乎老是會自動內聯,可是ImmutableArray的GetEnumerator足夠大,JIT沒法自動識別它的好處。在實踐中,當內聯GetEnumerator時,JIT最終可以更好地識別出foreach在遍歷數組,而不是爲Sum生成代碼:
; Program.Sum() push rsi sub rsp,30 xor eax,eax mov [rsp+20],rax mov [rsp+28],rax xor esi,esi cmp [rcx],ecx add rcx,8 lea rdx,[rsp+20] call System.Collections.Immutable.ImmutableArray'1[[System.Int32, System.Private.CoreLib]].GetEnumerator() jmp short M00_L01 M00_L00: cmp [rsp+28],edx jae short M00_L02 mov rax,[rsp+20] mov edx,[rsp+28] movsxd rdx,edx mov eax,[rax+rdx*4+10] add esi,eax M00_L01: mov eax,[rsp+28] inc eax mov [rsp+28],eax mov rdx,[rsp+20] mov edx,[rdx+8] cmp edx,eax jg short M00_L00 mov eax,esi add rsp,30 pop rsi ret M00_L02: call CORINFO_HELP_RNGCHKFAIL int 3 ; Total bytes of code 97
就像在.NET Core 3.1中同樣,在.NET 5中也是如此
; Program.Sum() sub rsp,28 xor eax,eax add rcx,8 mov rdx,[rcx] mov ecx,[rdx+8] mov r8d,0FFFFFFFF jmp short M00_L01 M00_L00: cmp r8d,ecx jae short M00_L02 movsxd r9,r8d mov r9d,[rdx+r9*4+10] add eax,r9d M00_L01: inc r8d cmp ecx,r8d jg short M00_L00 add rsp,28 ret M00_L02: call CORINFO_HELP_RNGCHKFAIL int 3 ; Total bytes of code 59
所以,更小的代碼和更快的執行:
private ImmutableArray<int> _array = ImmutableArray.Create(Enumerable.Range(0, 100_000).ToArray()); [Benchmark] public int Sum() { int sum = 0; foreach (int i in _array) sum += i; return sum; }
Method | Runtime | Mean | Ratio |
---|---|---|---|
Sum | .NET FW 4.8 | 187.60 us | 1.00 |
Sum | .NET Core 3.1 | 187.32 us | 1.00 |
Sum | .NET 5.0 | 46.59 us | 0.25 |
ImmutableList
private ImmutableList<int> _list = ImmutableList.Create(Enumerable.Range(0, 1_000).ToArray()); [Benchmark] public int Sum() { int sum = 0; for (int i = 0; i < 1_000; i++) if (_list.Contains(i)) sum += i; return sum; }
Method | Runtime | Mean | Ratio |
---|---|---|---|
Sum | .NET FW 4.8 | 22.259 ms | 1.00 |
Sum | .NET Core 3.1 | 22.872 ms | 1.03 |
Sum | .NET 5.0 | 2.066 ms | 0.09 |
前面強調的集合改進都是針對通用集合的,即用於開發人員須要存儲的任何數據。但並非全部的集合類型都是這樣的:有些更專門用於特定的數據類型,而這樣的集合在。net 5中也能夠看到性能的改進。位數組就是這樣的一個例子,與幾個PRs這個釋放做出重大改進,以其性能。特別地,來自@Gnbrkm41的dotnet/corefx#41896使用了AVX2和SSE2特性來對BitArray的許多操做進行矢量化(dotnet/runtime#33749隨後也添加了ARM64特性):
private bool[] _array; [GlobalSetup] public void Setup() { var r = new Random(42); _array = Enumerable.Range(0, 1000).Select(_ => r.Next(0, 2) == 0).ToArray(); } [Benchmark] public BitArray Create() => new BitArray(_array);
Method | Runtime | Mean | Ratio |
---|---|---|---|
Create | .NET FW 4.8 | 1,140.91 ns | 1.00 |
Create | .NET Core 3.1 | 861.97 ns | 0.76 |
Create | .NET 5.0 | 49.08 ns | 0.04 |
在.NET Core以前的版本中,系統出現了大量的變更。Linq代碼基,特別是提升性能。這個流程已經放緩了,可是.NET 5仍然能夠看到LINQ的性能改進。
OrderBy有一個值得注意的改進。正如前面所討論的,將coreclr的本地排序實現轉換爲託管代碼有多種動機,其中一個就是可以輕鬆地將其做爲基於spanc的排序方法的一部分進行重用。這樣的api是公開的,而且經過dotnet/runtime#1888,咱們可以在System.Linq中利用基於spane的排序。這特別有好處,由於它支持利用基於Comparison的排序例程,這反過來又支持避免在每一個比較操做上的多層間接。
[GlobalSetup] public void Setup() { var r = new Random(42); _array = Enumerable.Range(0, 1_000).Select(_ => r.Next()).ToArray(); } private int[] _array; [Benchmark] public void Sort() { foreach (int i in _array.OrderBy(i => i)) { } }
Method | Runtime | Mean | Ratio |
---|---|---|---|
Sort | .NET FW 4.8 | 100.78 us | 1.00 |
Sort | .NET Core 3.1 | 101.03 us | 1.00 |
Sort | .NET 5.0 | 85.46 us | 0.85 |
對於一行更改來講,這還不錯。
另外一個改進是來自@timandy的dotnet/corefx#41342。PR可擴充的枚舉。SkipLast到特殊狀況IList以及內部IPartition接口(這是各類操做符相互之間進行優化的方式),以便在能夠廉價肯定源長度時將SkipLast從新表示爲Take操做。
private IEnumerable<int> data = Enumerable.Range(0, 100).ToList(); [Benchmark] public int SkipLast() => data.SkipLast(5).Sum();
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
SkipLast | .NET Core 3.1 | 1,641.0 ns | 1.00 | 248 B |
SkipLast | .NET 5.0 | 684.8 ns | 0.42 | 48 B |
最後一個例子,dotnet/corefx#40377是一個漫長的過程。這是一個有趣的例子。一段時間以來,我看到開發人員認爲Enumerable.Any()比Enumerable.Count() != 0更有效;畢竟,Any()只須要肯定源中是否有東西,而Count()須要肯定源中有多少東西。所以,對於任何合理的集合,any()在最壞狀況下應該是O(1),而Count()在最壞狀況下多是O(N),那麼any()不是老是更好的嗎?甚至有Roslyn分析程序推薦這種轉換。不幸的是,狀況並不老是這樣。在。net 5以前,Any()的實現基本以下:
using (IEnumerator<T> e = source.GetEnumerator) return e.MoveNext();
這意味着在一般狀況下,即便多是O(1)操做,也會致使分配一個枚舉器對象以及兩個接口分派。相比之下,自從. net Framework 3.0中LINQ的初始版本發佈以來,Count()已經優化了特殊狀況下ICollection使用它的Count屬性的代碼路徑,在這種狀況下,它一般是O(1)和分配自由,只有一個接口分派。所以,對於很是常見的狀況(好比源是List),使用Count() != 0實際上比使用Any()更有效。雖然添加接口檢查會帶來一些開銷,但值得添加它以使Any()實現具備可預測性並與Count()保持一致,這樣就能夠更容易地對其進行推理,並使有關其成本的主流觀點變得正確。
現在,網絡是幾乎全部應用程序的關鍵組件,而良好的網絡性能相當重要。所以,.NET的每個版本都在提升網絡性能上投入了大量的精力.NET 5也不例外。
讓咱們先看看一些原語,而後繼續往下看。系統。大多數應用程序都使用Uri來表示url,它的速度要快,這一點很重要。許多PRs已經開始在。.NET 5中使Uri更快。能夠說,Uri最重要的操做是構造一個Uri,而dotnet/runtime#36915使全部Uri的構造速度更快,主要是經過關注開銷和避免沒必要要的開銷:
[Benchmark] public Uri Ctor() => new Uri("https://github.com/dotnet/runtime/pull/36915");
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
Ctor | .NET FW 4.8 | 443.2 ns | 1.00 | 225 B |
Ctor | .NET Core 3.1 | 192.3 ns | 0.43 | 72 B |
Ctor | .NET 5.0 | 129.9 ns | 0.29 | 56 B |
在構造以後,應用程序常常訪問Uri的各類組件,這一點也獲得了改進。特別是,像HttpClient這樣的類型一般有一個重複用於發出請求的Uri。HttpClient實現將訪問Uri。屬性的路徑和查詢,以發送做爲HTTP請求的一部分(例如,GET /dotnet/runtime HTTP/1.1),在過去,這意味着爲每一個請求從新建立Uri的部分字符串。感謝dotnet/runtime#36460,它如今被緩存(就像IdnHost同樣):
private Uri _uri = new Uri("http://github.com/dotnet/runtime"); [Benchmark] public string PathAndQuery() => _uri.PathAndQuery;
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
PathAndQuery | .NET FW 4.8 | 17.936 ns | 1.00 | 56 B |
PathAndQuery | .NET Core 3.1 | 30.891 ns | 1.72 | 56 B |
PathAndQuery | .NET 5.0 | 2.854 ns | 0.16 | – |
除此以外,還有許多代碼與uri交互的方式,其中許多都獲得了改進。例如,dotnet/corefx#41772改進了Uri。EscapeDataString和Uri。EscapeUriString,它根據RFC 3986和RFC 3987對字符串進行轉義。這兩種方法都依賴於使用不安全代碼的共享 helpers,經過char[]來回切換,而且在Unicode處理方面有不少複雜性。這個PR重寫了這個 helpers來利用.NET的新特性,好比span和符文,以使escape操做既安全又快速。對於某些輸入,增益不大,可是對於涉及Unicode的輸入,甚至對於長ASCII輸入,增益就很大了。
[Params(false, true)] public bool ASCII { get; set; } [GlobalSetup] public void Setup() { _input = ASCII ? new string('s', 20_000) : string.Concat(Enumerable.Repeat("\xD83D\xDE00", 10_000)); } private string _input; [Benchmark] public string Escape() => Uri.EscapeDataString(_input);
Method | Runtime | ASCII | Mean | Ratio | Allocated |
---|---|---|---|---|---|
Escape | .NET FW 4.8 | False | 6,162.59 us | 1.00 | 60616272 B |
Escape | .NET Core 3.1 | False | 6,483.85 us | 1.06 | 60612025 B |
Escape | .NET 5.0 | False | 243.09 us | 0.04 | 240045 B |
Escape | .NET FW 4.8 | True | 86.93 us | 1.00 | – |
Escape | .NET Core 3.1 | True | 122.06 us | 1.40 | – |
Escape | .NET 5.0 | True | 14.04 us | 0.16 | – |
爲Uri.UnescapeDataString提供了相應的改進。這一改變包括使用已經向量化的IndexOf而不是手動的基於指針的循環,以肯定須要進行非轉義的字符的第一個位置,而後避免一些沒必要要的代碼,並在可行的狀況下使用堆棧分配而不是堆分配。雖然使全部操做更快,最大的收益是字符串unescape無關,這意味着EscapeDataString操做沒有逃避,只是返回其輸入(這種狀況也隨後幫助進一步dotnet/corefx#41684,使原來的字符串返回時不須要改變):
private string _value = string.Concat(Enumerable.Repeat("abcdefghijklmnopqrstuvwxyz", 20)); [Benchmark] public string Unescape() => Uri.UnescapeDataString(_value);
Method | Runtime | Mean | Ratio |
---|---|---|---|
Unescape | .NET FW 4.8 | 847.44 ns | 1.00 |
Unescape | .NET Core 3.1 | 846.84 ns | 1.00 |
Unescape | .NET 5.0 | 21.84 ns | 0.03 |
dotnet/runtime#36444和dotnet/runtime#32713使比較uri和執行相關操做(好比將它們放入字典)變得更快,尤爲是相對uri。
private Uri[] _uris = Enumerable.Range(0, 1000).Select(i => new Uri($"/some/relative/path?ID={i}", UriKind.Relative)).ToArray(); [Benchmark] public int Sum() { int sum = 0; foreach (Uri uri in _uris) sum += uri.GetHashCode(); return sum; }
Method | Runtime | Mean | Ratio |
---|---|---|---|
Sum | .NET FW 4.8 | 330.25 us | 1.00 |
Sum | .NET Core 3.1 | 47.64 us | 0.14 |
Sum | .NET 5.0 | 18.87 us | 0.06 |
向上移動堆棧,讓咱們看看System.Net.Sockets。自從.NET Core誕生以來,TechEmpower基準就被用做衡量進展的一種方式。之前咱們主要關注「明文」基準,很是低級的一組特定的性能特徵,但對於這個版本,咱們但願專一於改善兩個基準,「JSON序列化」和「財富」(後者涉及數據庫訪問,儘管它的名字,前者的成本主要是因爲網絡速度很是小的JSON載荷有關)。咱們的工做主要集中在Linux上。當我說「咱們的」時,我不只僅是指那些在.NET團隊工做的人;咱們經過一個超越核心團隊的工做小組進行了富有成效的合做,例如紅帽的@tmds和Illyriad Games的@benaadams的偉大想法和貢獻。
在Linux上,socket實現是基於epoll的。爲了實現對許多服務的巨大需求,咱們不能僅僅爲每一個套接字分配一個線程,若是對套接字上的全部操做都使用阻塞I/O,咱們就會這樣作。相反,使用非阻塞I/O,當操做系統尚未準備好來知足一個請求(例如當ReadAsync用於套接字但沒有數據可供閱讀,或使用非同步套接字可是沒有可用空間在內核的發送緩衝區),epoll用於通知套接字實現的套接字狀態的變化,這樣操做能夠再次嘗試。epoll是一種使用一個線程有效地阻塞任何數量套接字的更改等待的方法,所以實現維護了一個專用的線程,等待更改的全部套接字註冊的epoll。該實現維護了多個epoll線程,這些線程的數量一般等於系統中內核數量的一半。當多個套接字都複用到同一個epoll和epoll線程時,實現須要很是當心,不要在響應套接字通知時運行任意的工做;這樣作會發生在epoll線程自己,所以epoll線程將沒法處理進一步的通知,直到該工做完成。更糟糕的是,若是該工做被阻塞,等待與同一epoll關聯的任何套接字上的另外一個通知,系統將死鎖。所以,處理epoll的線程試圖在響應套接字通知時作儘量少的工做,提取足夠的信息將實際處理排隊到線程池中。
事實證實,在這些epoll線程和線程池之間發生了一個有趣的反饋循環。來自epoll線程的工做項排隊的開銷恰好足夠支持多個epoll線程,可是多個epoll線程會致使隊列發生一些爭用,以致於每一個額外的線程所增長的開銷都超過了它的公平份額。最重要的是,排隊的速度只是足夠低,線程池將很難保持它的全部線程飽和的狀況下會發生少許的工做在一個套接字操做(這是JSON序列化基準的狀況);這將反過來致使線程池花費更多的時間來隔離和釋放線程,從而使其變慢,從而建立一個反饋循環。長話短說,不理想的排隊會致使較慢的處理速度和比實際須要更多的epoll線程。這被糾正與兩個PRs, dotnet/runtime#35330和dotnet/runtime#35800。#35330改變了從epoll線程排隊模型,而不是排隊一個工做項/事件(當epoll醒來通知,可能會有多個通知全部的套接字註冊它,和它將提供全部的通知在一批),它將整個批處理隊列的一個工做項。處理它的池線程而後使用一個很是相似於並行的模型。For/ForEach已經工做多年,也就是說,排隊的工做項能夠爲本身保留一個項,而後將本身的副本排隊以幫助處理剩餘的項。這改變了微積分,最合理大小的機器,它實際上成爲有利於減小epoll線程而不是更多(並不是巧合的是,咱們但願有更少的),那麼# 35800 epoll線程的數量變化,一般使用最終只是一個(在機器與更大的核心方面,還會有更多)。咱們還經過經過DOTNET_SYSTEM_NET_SOCKETS_THREAD_COUNT epoll數可配置環境變量,能夠設置爲所需的計算以覆蓋系統的默認值,若是開發人員想要實驗與其餘數量和提供反饋結果給定的工做負載。
做爲一個實驗,從@tmds dotnet/runtime#37974咱們還添加了一個實驗模式(由DOTNET_SYSTEM_NET_SOCKETS_INLINE_COMPLETIONS環境變量設置爲1在Linux上)咱們避免排隊的工做線程池,而不是僅僅運行全部套接字延續(如工做()等待socket.ReadAsync ();工做()😉;在epoll線程上。嗝是我德拉古!若是套接字延續中止,則不會處理與該epoll線程關聯的其餘工做。更糟糕的是,若是延續實際上同步阻塞等待與該epoll關聯的其餘工做,系統將死鎖。可是,在這種模式下,一個精心設計的程序可能會得到更好的性能,由於處理的位置能夠更好,而且能夠避免排隊到線程池的開銷。由於全部套接字工做都在epoll線程上運行,因此默認爲1再也不有意義;默認狀況下,它的線程數等於處理器數。再說一次,這是一個實驗,咱們歡迎你看到任何積極或消極的結果。
這些改進都大規模地集中在Linux上的套接字性能上,這使得它們很難在單機上的微基準測試中進行演示。不過,還有其餘更容易看到的改進dotnet/runtime#32271從套接字刪除了幾個分配。鏈接,插座。爲了支持再也不相關的舊代碼訪問安全(CAS)檢查,對某些狀態進行了沒必要要的複製:CAS檢查在好久之前就被刪除了,可是克隆仍然存在,因此這也只是清理了它們。dotnet/runtime#32275也從SafeSocketHandle的Windows實現中刪除了一個分配。dotnet/runtime#787重構插座。ConnectAsync,以便它能夠共享相同的內部SocketAsyncEventArgs實例,該實例最終被隨後用於執行ReceiveAsync操做,從而避免額外的鏈接分配。dotnet /運行時# 34175利用.NET 5中引入的新的固定對象堆使用pre-pinned緩衝區SocketAsyncEventArgs實現的各部分在Windows上而不是用GCHandle銷(在Linux上不須要把相應的功能,因此它是不習慣)。在dotnet/runtime#37583中,@tmds經過在適當的地方使用堆棧分配,減小了做爲向生I/O SendAsync/ReceivedAsync實現的一部分的分配。
private Socket _listener, _client, _server; private byte[] _buffer = new byte[8]; private List<ArraySegment<byte>> _buffers = new List<ArraySegment<byte>>(); [GlobalSetup] public void Setup() { _listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); _listener.Listen(1); _client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _client.ConnectAsync(_listener.LocalEndPoint).GetAwaiter().GetResult(); _server = _listener.AcceptAsync().GetAwaiter().GetResult(); for (int i = 0; i < _buffer.Length; i++) _buffers.Add(new ArraySegment<byte>(_buffer, i, 1)); } [Benchmark] public async Task SendReceive() { await _client.SendAsync(_buffers, SocketFlags.None); int total = 0; while (total < _buffer.Length) total += await _server.ReceiveAsync(_buffers, SocketFlags.None); }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
SendReceive | .NET Core 3.1 | 5.924 us | 1.00 | 624 B |
SendReceive | .NET 5.0 | 5.230 us | 0.88 | 144 B |
在此之上,咱們來到System.Net.Http。SocketsHttpHandler在兩個方面作了大量改進。第一個是頭的處理,它表明了與類型相關的分配和處理的很大一部分。經過建立HttpHeaders, dotnet/corefx#41640啓動了事情。TryAddWithoutValidation的名稱爲真:因爲SocketsHttpHandler枚舉請求頭並將它們寫入連線的方式,即便開發人員指定了「WithoutValidation」,它最終仍是會對頭執行驗證,PR修復了這個問題。多個PRs,包括dotnet/runtime#35003, dotnet/runtime#34922, dotnet/runtime#32989和dotnet/runtime#34974改進了在SocketHttpHandler的已知標頭列表中的查找(當這些標頭出現時,這有助於避免分配),並加強了該列表以更加全面。dotnet/runtime#34902更新內部各強類型集合類型使用頭少分配集合,和dotnet/runtime#34724作了一些相關的分配頭到手只有當他們實際上訪問(以及特殊狀況的日期和服務器響應標頭以免爲他們分配在最多見的狀況下)。最終的結果是吞吐量獲得了小的改善,但分配獲得了顯著的改善:
private static readonly Socket s_listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); private static readonly HttpClient s_client = new HttpClient(); private static Uri s_uri; [Benchmark] public async Task HttpGet() { var m = new HttpRequestMessage(HttpMethod.Get, s_uri); m.Headers.TryAddWithoutValidation("Authorization", "ANYTHING SOMEKEY"); m.Headers.TryAddWithoutValidation("Referer", "http://someuri.com"); m.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36"); m.Headers.TryAddWithoutValidation("Host", "www.somehost.com"); using (HttpResponseMessage r = await s_client.SendAsync(m, HttpCompletionOption.ResponseHeadersRead)) using (Stream s = await r.Content.ReadAsStreamAsync()) await s.CopyToAsync(Stream.Null); } [GlobalSetup] public void CreateSocketServer() { s_listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); s_listener.Listen(int.MaxValue); var ep = (IPEndPoint)s_listener.LocalEndPoint; s_uri = new Uri($"http://{ep.Address}:{ep.Port}/"); byte[] response = Encoding.UTF8.GetBytes("HTTP/1.1 200 OK\r\nDate: Sun, 05 Jul 2020 12:00:00 GMT \r\nServer: Example\r\nContent-Length: 5\r\n\r\nHello"); byte[] endSequence = new byte[] { (byte)'\r', (byte)'\n', (byte)'\r', (byte)'\n' }; Task.Run(async () => { while (true) { Socket s = await s_listener.AcceptAsync(); _ = Task.Run(() => { using (var ns = new NetworkStream(s, true)) { byte[] buffer = new byte[1024]; int totalRead = 0; while (true) { int read = ns.Read(buffer, totalRead, buffer.Length - totalRead); if (read == 0) return; totalRead += read; if (buffer.AsSpan(0, totalRead).IndexOf(endSequence) == -1) { if (totalRead == buffer.Length) Array.Resize(ref buffer, buffer.Length * 2); continue; } ns.Write(response, 0, response.Length); totalRead = 0; } } }); } }); }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
HttpGet | .NET FW 4.8 | 123.67 us | 1.00 | 98.48 KB |
HttpGet | .NET Core 3.1 | 68.57 us | 0.55 | 6.07 KB |
HttpGet | .NET 5.0 | 66.80 us | 0.54 | 2.86 KB |
其餘一些與主管有關的PRs更爲專業化。例如,dotnet/runtime#34860經過更仔細地考慮方法改進了日期頭的解析。前面的實現使用的是DateTime。一長串可行格式的TryParseExact;這就使實現失去了它的快速路徑,而且致使即便輸入與列表中的第一種格式匹配時,解析它的速度也要慢得多。在今天的日期標題中,絕大多數標題將遵循RFC 1123中列出的格式,也就是「r」。因爲以前版本的改進,DateTime對「r」格式的解析很是快,因此咱們能夠先直接使用TryParseExact對單一格式進行解析,若是它失敗了,就使用TryParseExact對其他格式進行解析。
[Benchmark] public DateTimeOffset? DatePreferred() { var m = new HttpResponseMessage(); m.Headers.TryAddWithoutValidation("Date", "Sun, 06 Nov 1994 08:49:37 GMT"); return m.Headers.Date; }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
DatePreferred | .NET FW 4.8 | 2,177.9 ns | 1.00 | 674 B |
DatePreferred | .NET Core 3.1 | 1,510.8 ns | 0.69 | 544 B |
DatePreferred | .NET 5.0 | 267.2 ns | 0.12 | 520 B |
然而,最大的改進來自於通常的HTTP/2。在.NET Core 3.1中,HTTP/2實現是功能性的,但沒有進行特別的調優,因此在.NET5上作了一些努力,使HTTP/2實現更好,特別是更具備可伸縮性。dotnet/runtime#32406和dotnet/runtime#32624顯著下降分配參與HTTP/2 GET請求經過使用一個自定義CopyToAsync覆蓋在響應流用於HTTP/2響應,被更當心在如何訪問請求頭寫請求的一部分(爲了不迫使lazily-initialized狀態存在的時候沒有必要),和刪除async-related分配。而dotnet/runtime#32557減小了HTTP/2中的分配,經過更好地處理取消和減小與異步操做相關的分配。之上,dotnet/runtime#35694包括一堆HTTP /兩個相關的變化,包括減小鎖的數量涉及(HTTP/2涉及更多的同步比HTTP/1.1 c#實現,由於在HTTP / 2多個請求多路複用到相同的套接字鏈接),減小工做的數量,而持有鎖,一個關鍵的狀況下改變使用的鎖定機制,增長標題的標題優化,以及其餘一些減小管理費用的調整。做爲後續,dotnet/runtime#36246刪除了一些因爲取消和尾部標頭(這在gRPC流量中很常見)而形成的分配。爲了演示這一點,我建立了一個簡單的ASP.NET Core localhost服務器(使用空模板,刪除少許代碼,本例不須要):
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; public class Program { public static void Main(string[] args) => Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(b => b.UseStartup<Startup>()).Build().Run(); } public class Startup { public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.MapGet("/", context => context.Response.WriteAsync("Hello")); endpoints.MapPost("/", context => context.Response.WriteAsync("Hello")); }); } }
而後我使用這個客戶端基準:
private HttpMessageInvoker _client = new HttpMessageInvoker(new SocketsHttpHandler() { UseCookies = false, UseProxy = false, AllowAutoRedirect = false }); private HttpRequestMessage _get = new HttpRequestMessage(HttpMethod.Get, new Uri("https://localhost:5001/")) { Version = HttpVersion.Version20 }; private HttpRequestMessage _post = new HttpRequestMessage(HttpMethod.Post, new Uri("https://localhost:5001/")) { Version = HttpVersion.Version20, Content = new ByteArrayContent(Encoding.UTF8.GetBytes("Hello")) }; [Benchmark] public Task Get() => MakeRequest(_get); [Benchmark] public Task Post() => MakeRequest(_post); private Task MakeRequest(HttpRequestMessage request) => Task.WhenAll(Enumerable.Range(0, 100).Select(async _ => { for (int i = 0; i < 500; i++) { using (HttpResponseMessage r = await _client.SendAsync(request, default)) using (Stream s = await r.Content.ReadAsStreamAsync()) await s.CopyToAsync(Stream.Null); } }));
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
Get | .NET Core 3.1 | 1,267.4 ms | 1.00 | 122.76 MB |
Get | .NET 5.0 | 681.7 ms | 0.54 | 74.01 MB |
Post | .NET Core 3.1 | 1,464.7 ms | 1.00 | 280.51 MB |
Post | .NET 5.0 | 735.6 ms | 0.50 | 132.52 MB |
還要注意的是,對於.NET 5,在這方面還有不少工做要作。dotnet/runtime#38774改變了在HTTP/2實現中處理寫的方式,預計將在已有改進的基礎上帶來實質性的可伸縮性提升,特別是針對基於grpc的工做負載。
其餘網絡組件也有顯著的改進。例如,Dns類型上的XxAsync api是在相應的Begin/EndXx方法上實現的。對於.NET 5中的dotnet/corefx#41061,這是反向的,例如Begin/EndXx方法是在XxAsync方法的基礎上實現的;這使得代碼更簡單、更快,同時對分配也有很好的影響(注意.NET Framework 4.8的結果稍微快一些,由於它實際上沒有使用異步I/O,而只是一個排隊的工做項到執行同步I/O的線程池;這樣會減小一些開銷,但也會減小可伸縮性):
private string _hostname = Dns.GetHostName(); [Benchmark] public Task<IPAddress[]> Lookup() => Dns.GetHostAddressesAsync(_hostname);
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
Lookup | .NET FW 4.8 | 178.6 us | 1.00 | 4146 B |
Lookup | .NET Core 3.1 | 211.5 us | 1.18 | 1664 B |
Lookup | .NET 5.0 | 209.7 us | 1.17 | 984 B |
雖然是一種不多有人(儘管它使用WCF), NegotiateStream也一樣更新dotnet/runtime#36583,與全部XxAsync方法被使用異步/等待,而後在dotnet/runtime#37772複用緩衝區,而不是爲每一個操做建立新的。最終結果是在典型的讀/寫使用中顯著減小分配:
private byte[] _buffer = new byte[1]; private NegotiateStream _client, _server; [GlobalSetup] public void Setup() { using var listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); listener.Listen(1); var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); client.ConnectAsync(listener.LocalEndPoint).GetAwaiter().GetResult(); Socket server = listener.AcceptAsync().GetAwaiter().GetResult(); _client = new NegotiateStream(new NetworkStream(client, true)); _server = new NegotiateStream(new NetworkStream(server, true)); Task.WaitAll( _client.AuthenticateAsClientAsync(), _server.AuthenticateAsServerAsync()); } [Benchmark] public async Task WriteRead() { for (int i = 0; i < 100; i++) { await _client.WriteAsync(_buffer); await _server.ReadAsync(_buffer); } } [Benchmark] public async Task ReadWrite() { for (int i = 0; i < 100; i++) { var r = _server.ReadAsync(_buffer); await _client.WriteAsync(_buffer); await r; } }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
WriteRead | .NET Core 3.1 | 1.510 ms | 1.00 | 61600 B |
WriteRead | .NET 5.0 | 1.294 ms | 0.86 | – |
ReadWrite | .NET Core 3.1 | 3.502 ms | 1.00 | 76224 B |
ReadWrite | .NET 5.0 | 3.301 ms | 0.94 | 226 B |
這個系統有了顯著的改進.NET 5的Json庫,特別是JsonSerializer,可是不少這些改進實際上都被移植回了.NET Core 3.1,並做爲服務修復的一部分發布(參見dotnet/corefx#41771)。即使如此,在.NET 5中也出現了一些不錯的改進。
dotnet/runtime#2259重構了JsonSerializer中的轉換器如何處理集合的模型,致使了可測量的改進,特別是對於更大的集合:
private MemoryStream _stream = new MemoryStream(); private DateTime[] _array = Enumerable.Range(0, 1000).Select(_ => DateTime.UtcNow).ToArray(); [Benchmark] public Task LargeArray() { _stream.Position = 0; return JsonSerializer.SerializeAsync(_stream, _array); }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
LargeArray | .NET FW 4.8 | 262.06 us | 1.00 | 24256 B |
LargeArray | .NET Core 3.1 | 191.34 us | 0.73 | 24184 B |
LargeArray | .NET 5.0 | 69.40 us | 0.26 | 152 B |
但即便是較小的,例如。
private MemoryStream _stream = new MemoryStream(); private JsonSerializerOptions _options = new JsonSerializerOptions(); private Dictionary<string, int> _instance = new Dictionary<string, int>() { { "One", 1 }, { "Two", 2 }, { "Three", 3 }, { "Four", 4 }, { "Five", 5 }, { "Six", 6 }, { "Seven", 7 }, { "Eight", 8 }, { "Nine", 9 }, { "Ten", 10 }, }; [Benchmark] public async Task Dictionary() { _stream.Position = 0; await JsonSerializer.SerializeAsync(_stream, _instance, _options); }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
Dictionary | .NET FW 4.8 | 2,141.7 ns | 1.00 | 209 B |
Dictionary | .NET Core 3.1 | 1,376.6 ns | 0.64 | 208 B |
Dictionary | .NET 5.0 | 726.1 ns | 0.34 | 152 B |
dotnet/runtime#37976還經過添加緩存層來幫助檢索被序列化和反序列化的類型內部使用的元數據,從而幫助提升小型類型的性能。
private MemoryStream _stream = new MemoryStream(); private MyAwesomeType _instance = new MyAwesomeType() { SomeString = "Hello", SomeInt = 42, SomeByte = 1, SomeDouble = 1.234 }; [Benchmark] public Task SimpleType() { _stream.Position = 0; return JsonSerializer.SerializeAsync(_stream, _instance); } public struct MyAwesomeType { public string SomeString { get; set; } public int SomeInt { get; set; } public double SomeDouble { get; set; } public byte SomeByte { get; set; } }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
SimpleType | .NET FW 4.8 | 1,204.3 ns | 1.00 | 265 B |
SimpleType | .NET Core 3.1 | 617.2 ns | 0.51 | 192 B |
SimpleType | .NET 5.0 | 504.2 ns | 0.42 | 192 B |
在.NET Core 3.0以前,.NET Core主要關注服務器的工做負載,而ASP則主要關注服務器的工做負載。NET Core是該平臺上卓越的應用模型。隨着.NET Core 3.0的加入,Windows Forms和Windows Presentation Foundation (WPF)也隨之加入,將. NET Core引入到了桌面應用中。隨着.NET Core 3.2的發佈,Blazor發佈了對瀏覽器應用程序的支持,但它基於mono和mono堆棧中的庫。在.NET 5中,Blazor使用.NET 5 mono運行時和全部其餘應用模型共享的.NET 5庫。這給性能帶來了一個重要的變化:大小。在代碼大小一直是一個重要的問題(和.NET本機應用程序)是很是重要的,一個成功的基於瀏覽器的部署所需的規模確實帶來了最前沿,咱們須要擔憂下載大小在某種程度上咱們尚未過去集中與.NET Core。
協助與應用程序的大小,.NET SDK包含一個連接器,可以清除的未使用部分應用,不只在彙編級,但也在會員級別,作靜態分析來肯定什麼是代碼,不是使用和丟棄的部分不是。這帶來了一組有趣的挑戰:爲了方便或簡化API使用而採用的一些編碼模式,對於連接器來講,很難以容許它扔掉不少東西的方式進行分析。所以,在.NET 5中與性能相關的主要工做之一就是改進庫的可剪裁。
這有兩個方面:
第二種方法有不少例子,因此我將着重介紹其中一些,以展現所使用的各類技術:
在.NET Core 3.0性能後,我講過「花生醬」,許多小的改進,單獨不必定就會有巨大的差異,但處理成本,是整個代碼,不然塗抹和修復這些集體能夠產生可測量的變化。和之前的版本同樣,在.NET 5中也有不少這樣受歡迎的改進。這裏有少數:
這篇文章強調了在.NET 5上運行的大量現有api會變得更好。此外,.NET 5中有許多新的api,其中一些專一於幫助開發人員編寫更快的代碼(更多的關注於讓開發人員用更少的代碼執行相同的操做,或者支持之前不容易完成的新功能)。如下是一些亮點,包括一些api已經被其餘庫內部使用以下降現有api成本的狀況:
Decimal(ReadOnlySpan<int>)
/ Decimal.TryGetBits
/ Decimal.GetBits
(dotnet/runtime#32155):在之前的版本中添加了不少span-based方法有效地與原語交流,decimal並獲得span-based TryFormat和{}嘗試解析方法,但這些新方法在.NET 5使有效地構建一個十進制從跨度以及提取位decimal跨度。您能夠看到,這種支持已經在SQLDecimal、BigInteger和System.Linq和System.Reflection.Metadata
中使用。Unsafe.SkipInit<T>
(dotnet/corefx#41995)。c#編譯器明確的賦值規則要求在各類狀況下爲參數和局部變量賦值。在很是特定的狀況下,這可能須要額外的賦值,而不是實際須要的,在計算每條指令和性能敏感代碼中的內存寫入時,這多是不可取的。該方法有效地使代碼僞裝已寫入參數或本地,而實際上並無這樣作。它被用於對Decimal的各類操做(dotnet/runtime#272377),在IntPtr和UIntPtr的一些新的api (dotnet/runtime#307來自@john-h-k),在Matrix4x4 (dotnet/runtime#36323來自@eanova),在Utf8Parser (dotnet/runtime#33507),和在UTF8Encoding (dotnet/runtime#31904)private Task _incomplete = new TaskCompletionSource<bool>().Task; [Benchmark] public Task OneAlreadyCompleted() => Task.WhenAny(Task.CompletedTask, _incomplete); [Benchmark] public Task AsyncCompletion() { AsyncTaskMethodBuilder atmb = default; Task result = Task.WhenAny(atmb.Task, _incomplete); atmb.SetResult(); return result; }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
OneAlreadyCompleted | .NET FW 4.8 | 125.387 ns | 1.00 | 217 B |
OneAlreadyCompleted | .NET Core 3.1 | 89.040 ns | 0.71 | 200 B |
OneAlreadyCompleted | .NET 5.0 | 8.391 ns | 0.07 | 72 B |
AsyncCompletion | .NET FW 4.8 | 289.042 ns | 1.00 | 257 B |
AsyncCompletion | .NET Core 3.1 | 195.879 ns | 0.68 | 240 B |
AsyncCompletion | .NET 5.0 | 150.523 ns | 0.52 | 160 B |
還有太多System.Runtime.Intrinsics方法甚至開始提到!
c#「Roslyn」編譯器有一個很是有用的擴展點,稱爲「analyzers」或「Roslyn analyzers」。分析器插入到編譯器中,並被授予對編譯器操做的全部源代碼以及編譯器對代碼的解析和建模的徹底讀訪問權,這使得開發人員可以將他們本身的自定義分析插入到編譯中。最重要的是,分析器不只能夠做爲構建的一部分運行,並且能夠在開發人員編寫代碼時在IDE中運行,這使得分析器可以就開發人員如何改進代碼提供建議、警告和錯誤。分析器開發人員還能夠編寫可在IDE中調用的「修復程序」,並將標記的代碼自動替換爲「修復的」替代品。全部這些組件均可以經過NuGet包分發,這使得開發人員很容易使用其餘人編寫的任意分析。
Roslyn分析程序回購包含一組定製分析程序,包括舊FxCop規則的端口。它還包含新的分析程序,對於.NET5, .NET SDK將自動包含大量這些分析程序,包括爲這個發行版編寫的全新分析程序。這些規則中有多個與性能相關,或者至少部分與性能相關。下面是一些例子:
檢測意外分配,做爲距離索引的一部分。c# 8引入了範圍,這使得對集合進行切片變得很容易,例如someCollection[1..3]。這樣的表達式能夠轉換爲使用集合的索引器來獲取一個範圍,例如public MyCollection this[Range r] {get;},或者若是沒有這樣的索引器,則使用Slice(int start, int length)。根據慣例和設計準則,這樣的索引器和切片方法應該返回它們所定義的相同類型,所以,例如,切片一個T[]將產生另外一個T[],而切片一個Span將產生一個Span。可是,這可能會致使隱式強制轉換隱藏意外的分配。例如,T[]能夠隱式轉換爲Span,但這也意味着T[]切片的結果能夠隱式轉換爲Span,這意味着以下代碼Span Span = _array[1..3];將很好地編譯和運行,除了它將致使由_array[1..]產生的數組片的數組分配。3]索引範圍。更有效的編寫方法是Span Span = _array.AsSpan()[1..3]。這個分析器將檢測幾個這樣的狀況,並提供解決方案來消除分配。
[Benchmark(Baseline = true)] public ReadOnlySpan<char> Slice1() { ReadOnlySpan<char> span = "hello world"[1..3]; return span; } [Benchmark] public ReadOnlySpan<char> Slice2() { ReadOnlySpan<char> span = "hello world".AsSpan()[1..3]; return span; }
Method | Mean | Ratio | Allocated |
---|---|---|---|
Slice1 | 8.3337 ns | 1.00 | 32 B |
Slice2 | 0.4332 ns | 0.05 | – |
優先使用流的內存重載。.NET Core 2.1爲流添加了新的重載。ReadAsync和流。分別對Memory和ReadOnlyMemory操做的WriteAsync。這使得這些方法能夠處理來自其餘來源的數據,而不是byte[],而且還能夠進行優化,好比當{ReadOnly}內存是按照指定的方式建立的,它表示已經固定的或不可移動的數據時,能夠避免進行固定。然而,新重載的引入也爲選擇這些方法的返回類型提供了新的機會,咱們分別選擇了ValueTask和ValueTask,而不是Task和Task。這樣作的好處是容許以更同步的方式完成調用來避免分配,甚至以更異步的方式完成調用來避免分配(儘管覆蓋的開發人員須要付出更多的努力)。所以,傾向於使用新的重載而不是舊的重載一般是有益的,這個分析器將檢測舊重載的使用並提供修復程序來自動切換到使用新重載,dotnet/runtime#35941有一些在發現的修復案例的例子。
private NetworkStream _client, _server; private byte[] _buffer = new byte[10]; [GlobalSetup] public void Setup() { using Socket listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); listener.Listen(); client.Connect(listener.LocalEndPoint); _client = new NetworkStream(client); _server = new NetworkStream(listener.Accept()); } [Benchmark(Baseline = true)] public async Task ReadWrite1() { byte[] buffer = _buffer; for (int i = 0; i < 1000; i++) { await _client.WriteAsync(buffer, 0, buffer.Length); await _server.ReadAsync(buffer, 0, buffer.Length); // may not read everything; just for demo purposes } } [Benchmark] public async Task ReadWrite2() { byte[] buffer = _buffer; for (int i = 0; i < 1000; i++) { await _client.WriteAsync(buffer); await _server.ReadAsync(buffer); // may not read everything; just for demo purposes } }
Method | Mean | Ratio | Allocated |
---|---|---|---|
ReadWrite1 | 7.604 ms | 1.00 | 72001 B |
ReadWrite2 | 7.549 ms | 0.99 | – |
最好在StringBuilder上使用類型重載。附加和StringBuilder.Insert有許多重載,不只用於追加字符串或對象,還用於追加各類基本類型,好比Int32。即使如此,仍是常常會看到像stringBuilder.Append(intValue.ToString())這樣的代碼。StringBuilder.Append(Int32)重載的效率更高,不須要分配字符串,所以應該首選重載。這個分析儀帶有一個fixer來檢測這種狀況,並自動切換到使用更合適的過載。
[Benchmark(Baseline = true)] public void Append1() { _builder.Clear(); for (int i = 0; i < 1000; i++) _builder.Append(i.ToString()); } [Benchmark] public void Append2() { _builder.Clear(); for (int i = 0; i < 1000; i++) _builder.Append(i); }
Method | Mean | Ratio | Allocated |
---|---|---|---|
Append1 | 13.546 us | 1.00 | 31680 B |
Append2 | 9.841 us | 0.73 | – |
首選StringBuilder.Append(char),而不是StringBuilder.Append(string)。將單個字符附加到StringBuilder比附加長度爲1的字符串更有效。可是,像private const string Separator = ":"
這樣的代碼仍是很常見的。…;若是const被更改成private const char Separator = ':';
會更好。分析器將標記許多這樣的狀況,並幫助修復它們。在dotnet/runtime中針對分析器修正的一些例子在dotnet/runtime#36097中。
[Benchmark(Baseline = true)] public void Append1() { _builder.Clear(); for (int i = 0; i < 1000; i++) _builder.Append(":"); } [Benchmark] public void Append2() { _builder.Clear(); for (int i = 0; i < 1000; i++) _builder.Append(':'); }
Method | Mean | Ratio |
---|---|---|
Append1 | 2.621 us | 1.00 |
Append2 | 1.968 us | 0.75 |
優先選擇IsEmpty而不是Count。 與前面的LINQ Any() vs Count()類似,某些集合類型同時公開了IsEmpty屬性和Count屬性。 在某些狀況下,例如像ConcurrentQueue 這樣的併發集合,肯定集合中項目數的準確計數比僅肯定集合中是否有任何項目要昂貴得多。 在這種狀況下,若是編寫代碼來執行相似if(collection.Count!= 0)的檢查,則改成使用if(!collection.IsEmpty)會更有效。 該分析儀有助於發現並修復此類狀況。
[Benchmark(Baseline = true)] public bool IsEmpty1() => _queue.Count == 0; [Benchmark] public bool IsEmpty2() => _queue.IsEmpty;
Method | Mean | Ratio |
---|---|---|
IsEmpty1 | 21.621 ns | 1.00 |
IsEmpty2 | 4.041 ns | 0.19 |
首選Environment.ProcessId。 dotnet/runtime#38908 添加了新的靜態屬性Environment.ProcessId,該屬性返回當前進程的ID。 看到之前嘗試使用Process.GetCurrentProcess()。Id執行相同操做的代碼是很常見的。 可是,後者的效率明顯較低,它沒法輕鬆地支持內部緩存,所以在每次調用時分配一個可終結對象並進行系統調用。 這款新的分析儀有助於自動查找和替換此類用法。
[Benchmark(Baseline = true)] public int PGCPI() => Process.GetCurrentProcess().Id; [Benchmark] public int EPI() => Environment.ProcessId;
Method | Mean | Ratio | Allocated |
---|---|---|---|
PGCPI | 67.856 ns | 1.00 | 280 B |
EPI | 3.191 ns | 0.05 | – |
避免循環中的stackalloc。這個分析器並不能很大程度上幫助您使代碼更快,可是當您使用了使代碼更快的解決方案時,它能夠幫助您使代碼正確。具體來講,它標記使用stackalloc從堆棧分配內存,但在循環中使用它的狀況。從堆棧中分配的內存的一部分stackalloc可能不會被釋放,直到方法返回,若是stackalloc是在一個循環中使用,它可能致使比開發人員分配更多的內存,並最終致使堆棧溢出,崩潰的過程。你能夠在dotnet/runtime#34149中看到一些修復的例子。
根據.NET路線圖,.NET 5計劃在2020年11月發佈,這離咱們還有幾個月的時間。雖然這篇文章展現了大量的性能進步已經釋放,我指望咱們將會看到大量的額外性能改進發如今.NET 5,若是沒有其餘緣由比目前PRs等待一羣(除了前面提到的其餘討論),例如dotnet/runtime#34864和dotnet/runtime#32552進一步提升Uri, dotnet/runtime#402 vectorizes string.Compare ,dotnet/runtime#36252改善性能的Dictionary
最後,雖然咱們真的很努力地避免性能退化,可是任何版本都將不可避免地出現一些性能退化,而且咱們將花費時間調查咱們找到的性能退化。這樣的迴歸與一個已知的類特性使得在.NET5: ICU .NET Framework和之前版本的.NET Core 在Windows上使用國家語言支持(NLS) api全球化在Windows上,而net核心在Unix上使用國際Unicode (ICU).NET 5組件切換到使用默認ICU在全部操做系統若是是可用的(Windows 10包括截至2019年5月更新),使更好的行爲一致性操做系統。可是,因爲這兩種技術具備不一樣的性能概要,所以某些操做(特別是識別區域性的字符串操做)在某些狀況下可能會變得更慢。雖然咱們但願減小其中的大部分(這也將有助於提升Linux和macOS上的性能),可是若是保留下來的任何更改均可能對您的應用程序可有可無,那麼若是這些更改對您的特定應用程序產生了負面影響,您能夠選擇繼續使用NLS。
有了.NET 的預覽和每晚的構建版本,我鼓勵您下載最新的版本,並在您的應用程序中試用它們。若是你發現你認爲能夠和應該改進的東西,咱們歡迎你的PRs到dotnet/runtime!
編碼快樂!
因爲文章較長真的是用了很長時間,中間機翻加糾正了一些地方,不過結局仍是好的最後仍是整理完成。但願能對你們有幫助,謝謝!
參考:https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-5/