【翻譯】.NET 5中的性能改進
在.NET Core之前的版本中,其實已經在博客中介紹了在該版本中發現的重大性能改進, 從.NET Core 2.0到.NET Core 2.1到.NET Core 3.0的每一篇文章,發現
談論越來越多的東西, 然而有趣的是,每次都想知道下一次是否有足夠的意義的改進以保證再發表一篇文章, .NET 5已經實作了許多性能改進,盡管直到今年秋天才計劃發布最終版本,并且到那時很有可能會有更多的改進,但是還要強調一下,現在已提供的改進, 在這篇文章中,重點介紹約250個PR,這些請求為整個.NET 5的性能提升做出了巨大貢獻,
安裝
Benchmark.NET現在是衡量.NET代碼性能的規范工具,可輕松分析代碼段的吞吐量和分配, 因此,本文中大部分示例都是使用使用該工具撰寫的微基準來衡量的,首先創建了一個目錄,然后使用dotnet工具對其進行了擴展:
mkdir Benchmarks
cd Benchmarks
dotnet new console
生成的Benchmarks.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 Framework 4.8,.NET Core 3.1和.NET 5執行基準測驗(目前已為Preview 8安裝了每晚生成的版本),.csproj還參考Benchmark.NET NuGet軟體包(其最新版本為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 GO HERE"的位置,
為了運行基準測驗,然后做:
dotnet run -c Release -f net48 --runtimes net48 netcoreapp31 netcoreapp50 --filter ** --join
這告訴Benchmark.NET:
- 使用.NET Framework 4.8 來建立基準,
- 針對.NET Framework 4.8,.NET Core 3.1和.NET 5分別運行基準測驗,
- 在程式集中包含所有基準測驗(不要過濾掉任何基準測驗),
- 將所有基準測驗的輸出結果合并在一起,并在運行結束時顯示(而不是貫穿整個程序),
在某些情況下,針對特定目標的API并不存在,我只是省略了命令列的這一部分,
最后,請注意以下幾點:
- 從運行時和核心庫的角度來看,它與幾個月前發布的前身相比沒有多少改進, 但是,還進行了一些改進,在某些情況下,目前已經將.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不包括mono運行時,因此不討論對mono所做的改進,也沒有專門針對“Blazor”, 因此,當指的是“runtime”時,指的是coreclr,即使從.NET 5開始,它也包含多個運行時,并且所有這些都已得到改進,
- 大多數示例都在Windows上運行,因為也希望能夠與.NET Framework 4.8進行比較, 但是,除非另有說明,否則所有顯示的示例均適用于Windows,Linux和macOS,
- 需要注意的是: 這里的所有測量資料都是在的臺式機上進行的,測量結果可能會有所不同,微基準測驗對許多因素都非常敏感,包括處理器數量、處理器架構、記憶體和快取速度等等,但是,一般來說,我關注的是性能改進,并包含了通常能夠承受此類差異的示例,
讓我們開始吧…
GC
對于所有對.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[]陣列進行排序:
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暫停, ,暫停,
JIT
.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
該例外源于對Span 的建構式的檢查:
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 = https://www.cnblogs.com/yyfh/p/1 }, c2 = new C() { Value = 2 }, c3 = new C() { Value = 3 };
[Benchmark]
public int Compare() => Comparer.Smallest(c1, c2, c3);
class Comparer where T : IComparable
{
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
{
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 ,它優化了雙重否定,
Intrinsics
在.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,
Runtime helpers
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 |
Text Processing
基于文本的處理是許多應用程式的基礎,并且在每個版本中都花費了大量的精力來改進基礎構建塊,其他所有內容都構建在這些基礎構建塊之上,這些變化從 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 _);
Regular Expressions
一種非常特殊但非常常見的決議形式是通過正則運算式,早在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%以上,
Threading and Async
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,
Collections
多年來,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 |
LINQ
在.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 = https://www.cnblogs.com/yyfh/p/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()保持一致,這樣就可以更容易地對其進行推理,并使有關其成本的主流觀點變得正確,
Networking
如今,網路是幾乎所有應用程式的關鍵組件,而良好的網路性能至關重要,因此,.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 = https://www.cnblogs.com/yyfh/p/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 |
JSON
這個系統有了顯著的改進.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 |
Trimming
在.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中與性能相關的主要作業之一就是改進庫的可剪裁,
這有兩個方面:
- 沒有洗掉太多(正確性),我們需要確保這些庫能夠真正安全地進行裁減,特別是,反射(甚至只反映在公共面積)的聯結器很難找到所有成員,實際上可能被使用,如在應用程式代碼在一個地方使用typeof型別實體,并傳遞到另一個應用程式的一部分,它使用GetMethod檢索MethodInfo對于一個公共方法,型別,并通過MethodInfo到另一個應用程式呼叫它的一部分,地址,聯結器采用啟發式方法來最大程度地減少可以洗掉的API的誤報,可以洗掉,但為了進一步幫助它,一堆屬性添加了在.NET 5,使開發人員能夠使這樣的隱式依賴關系顯式,抑制警告聯結器在它可能認為是不安全的,但實際上不是,轉嫁給消費者,迫使警告說表面的某些部分不適合連接,看到dotnet/runtime#35387,
- 盡可能多地洗掉(性能),我們需要盡量減少代碼片段需要保留的原因,這可以表現為重構實作來改變呼叫模式,也可以表現為使用聯結器可以識別的使用條件來裁剪整段代碼,還可以表現為使用更細粒度的控制來精確地控制需要保留的內容和保留的原因,
第二種方法有很多例子,所以我將著重介紹其中一些,以展示所使用的各種技術:
- 洗掉不必要的代碼,例如dotnet/corefx#41177,在這里,我們發現了許多過時的TraceSource/Switch用法,這些用法僅用于啟用一些僅用于除錯的跟蹤和斷言,但實際上已經沒有人使用了,這導致聯結器看到其中一些型別,甚至在發布版本中也使用過,
- 洗掉曾經有用但不再有用的過時代碼,例如dotnet/coreclr#26750,這種型別曾經對改進ngen (crossgen的前身)很重要,但現在不需要了,或者像在dotnet/coreclr#26603中,有些代碼實際上不再使用,但仍然會導致型別保留下來,
- 洗掉重復的代碼,例如dotnet/corefx#41165, dotnet/corefx#40935,和dotnet/coreclr#26589,一些庫使用它們自己的哈希代碼幫助例程的私有副本,導致每個庫都有自己的IL副本來實作該功能,它們可以被更新為使用共享的HashCode型別,這不僅有助于IL的大小和調整,還有助于避免需要維護的額外代碼,并更好地現代化代碼庫,以利用我們建議其他人也使用的功能,
- 使用不同的api,例如dotnet/corefx#41143,代碼使用擴展幫助器方法,導致引入額外的型別,但是提供的“幫助”實際上幾乎沒有節省代碼,一個可能更好的示例是dotnet/corefx#41142,它從System.Xml實作中洗掉了非通用佇列和堆疊型別的使用,而只是使用通用實作(dotnet/coreclr#26597使用WeakReference做了類似的事情),或者dotnet/corefx#41111,它改變了XML庫中的一些代碼來使用HttpClient而不是WebRequest,這允許洗掉整個System.Net,依賴的請求,或者避免System.Net的dotnet/corefx#41110,Http需要使用System.Text,RegularExpressions:這是不必要的復雜性,可以用少量特定于該用例的代碼替換,另一個例子是dotnet/coreclr#26602,其中一些代碼不必要地使用了string.ToLower(),替換它的使用不僅更有效,而且有助于在默認情況下削減多載,dotnet/coreclr#26601是相似的,
- 重新路由邏輯以避免對大量不需要的代碼進行根路由,例如dotnet/corefx#41075,如果代碼只是使用了新的Regex(字串),那么在內部只是委托給了更長的Regex(字串,RegexOptions)建構式,并且建構式需要能夠使用內部的RegexCompiler來應對RegexOptions,編譯使用,通過調整代碼路徑,使Regex(string)建構式不依賴于Regex(string, RegexOptions)建構式,如果不使用Regex,聯結器洗掉整個RegexCompiler代碼路徑(及其對反射發出的依賴)就變得很簡單,然后更好地利用這一點,確保盡可能使用更短的電話,這是一種相當常見的模式,以避免這種不必要的根源,考慮Environment.GetEnvironmentVariable(字串),它曾經呼喚環境,GetEnvironmentVariable(string, EnvironmentVariableTarget)多載,傳入默認的EnvironmentVariableTarget. process,相反,依賴關系被倒置了:Environment.GetEnvironmentVariable(string)多載只包含處理流程用例的邏輯,較長的多載有if (target == EnvironmentVariableTarget.Process)回傳GetEnvironmentVariable(name);,這樣,僅僅使用簡單多載的最常見情況就不會引入處理其他不太常見目標所需的所有代碼路徑,另一個例子是dotnet/corefx#0944:對于只寫控制臺而不從控制臺讀取的應用程式,它允許更多的控制臺內部鏈接,
- 使用延遲初始化,特別是對于靜態欄位,例如dotnet/runtime#37909,如果使用了型別并呼叫了它的任何靜態方法,則需要保存它的靜態建構式,由靜態建構式初始化的任何欄位也需要保存,如果這些欄位在第一次使用時是延遲初始化的,那么只有在執行延遲初始化的代碼是可訪問的情況下才需要保留這些欄位,
- 使用特性開關,例如dotnet/runtime#38129(進一步受益于dotnet/runtime#38828),在許多情況下,應用程式可能并不需要所有的特性集,比如日志或除錯支持,但從聯結器的角度來看,它看到了正在使用的代碼,因此被迫保留它,然而,聯結器能夠被告知它應該為已知屬性使用的替換值,例如,你可以告訴聯結器,當它看到一個回傳布林值的類,對于某些屬性,它應該將其替換為常量false,這將反過來使它能夠洗掉由該屬性保護的任何代碼,
Peanut Butter
在.NET Core 3.0性能后,我講過“花生醬”,許多小的改進,單獨不一定就會有巨大的差別,但處理成本,是整個代碼,否則涂抹和修復這些集體可以產生可測量的變化,和以前的版本一樣,在.NET 5中也有很多這樣受歡迎的改進,這里有少數:
- 組裝加載更快,由于歷史原因,.NET Core有很多小的實作程式集,而拆分的目的也沒有什么意義,然而,每一個需要加載的附加程式集都會增加開銷,dotnet/runtime#2189和dotnet/runtime#31991合并了一堆小程式集,以減少需要加載的數量,
- 更快的數學,改進了對NaN的檢查,使代碼為double,IsNan和浮動,更小的代碼和更快,來自@john-h-k的dotnet/runtime#35456是一個使用SSE和AMD64 intrinsics可測量地加速數學的好例子,CopySign MathF.CopySign,來自@Marusyk的dotnet/runtime#34452改進了對Matrix3x2和Matrix4x4的散列代碼生成,
- 更快的加密,來自@vcsjones的dotnet/runtime#36881在System.Security的不同位置使用了優化的BinaryPrimitives來代替開放編碼的等效代碼,來自@VladimirKhvostov的dotnet/corefx#39600優化了不受歡迎但仍在使用的加密,CreateFromName方法可以提高10倍以上的速度,
- 更快的互操作,dotnet/runtime#36257通過在Linux上避免特定于Windows的“ExactSpelling”檢查和在Windows上將其設定為true來減少入口點探測(在這里運行時試圖找到用于P/呼叫的確切本機函式),來自@NextTurn的dotnet/runtime#33020使用sizeof(T)而不是Marshal.SizeOf(Type)/Marshal.SizeOf()在一堆地方,因為前者比后者有更少的開銷,而dotnet/runtime#33967、dotnet/runtime#35098和dotnet/runtime#39059通過使用更多blittable型別、使用span和ref本地變數、使用sizeof等降低了幾個庫的互操作和封送處理成本,
- 更快的反射發出,反射發射使開發人員能夠在運行時寫出IL,如果你能夠以一種占用更少空間的方式發射相同的指令,你就可以節省存盤序列所需的托管分配,各種IL操作碼在更常見的情況下有更短的變體,例如,Ldc_I4可以用來作為常量加載任何int值,但是Ldc_I4_S更短,可以用來加載任何sbyte,而Ldc_I4_1更短,用于加載值1,一些庫利用了這一點,并將它們自己的映射表作為它們的emit代碼的一部分,以使用最短的相關操作碼;別人不喜歡,dotnet/runtime#35427只是將這樣一個映射移動到ILGenerator本身中,使我們能夠洗掉dotnet/runtime庫中的所有自定義實作,并在所有這些庫和其他庫中自動獲得映射的好處,
- 更快的I/O,來自@bbartels改進的BinaryWriter.Write(字串)的dotnet/runtime#37705,為各種常見輸入提供了一個快速路徑,而dotnet/runtime#35978改進了在System.IO內部管理關系的方式,通過使用O(1)而不是O(N)查找進行打包,
- 到處都是小的分配,例如,dotnet/runtime#35005洗掉ByteArrayContent中的記憶體流分配,dotnet/runtime#36228洗掉System.Reflection中的List和底層T[]分配,洗掉XmlConverter中的char[]分配,在HttpUtility中洗掉一個char[]分配,在ModuleBuilder中洗掉幾個可能的char[]分配,在dotnet/runtime#32301洗掉一些char[]分配從字串,拆分使用,dotnet/runtime#32422洗掉了一個字符[]分配在AsnFormatter, dotnet/runtime#34551洗掉了幾個字串分配在System.IO,檔案系統,dotnet/corefx#41363洗掉字符[]分配在JsonCamelCaseNamingPolicy, dotnet/coreclr#25631洗掉字串分配從MethodBase.ToString(), dotnet/corefx#41274洗掉一些不必要的字串從CertificatePal,AppendPrivateKeyInfo dotnet/runtime#1155通過跨越從SqlDecimal @Wraith2洗掉臨時陣列,dotnet/coreclr#26584洗掉拳擊以前發生在使用方法像GetHashCode方法在一些元組,dotnet/coreclr#27451洗掉幾個分配反映在自定義屬性,dotnet/coreclr#27013洗掉一些字串分配從串連用常量代替一些輸入,而且dotnet/runtime#34774從string.Normalize中洗掉了一些臨時的char[]分配,
New Performance-focused APIs
這篇文章強調了在.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中使用,- MemoryExtensions.Sort(dotnet/coreclr#27700), 我之前談到過:新的Sort
和Sort<TKey,TValue>擴展方法可對任意范圍的資料進行排序, 這些新的公共方法已經在Array本身(dotnet/coreclr#27703)和System.Linq(dotnet/runtime#1888)中使用, - GC.AllocateArray
和GC,AllocateUninitializedArray (dotnet/runtime#33526),這些新的api就像使用新的T[length],除了有兩個專門的行為:使用未初始化的變數允許GC交還陣列沒有強行清算他們(除非它們包含參考,在這種情況下,必須明確至少),并通過真實bool固定引數回傳從新固定陣列物件堆(POH),從該陣列在記憶體中保證永不動搖,這樣他們可以被傳遞給外部代碼沒有把他們(即不使用固定或GCHandle),StringBuilder獲得支持使用未初始化的特性(dotnet/coreclr#27364)降低成本擴大其內部存盤,一樣新的TranscodingStream (dotnet/runtime#35145),甚至新的支持從隱私增強進口X509證書和集合郵件證書(PEM)檔案(dotnet/runtime#38280),您還可以看到在Windows SocketsAsyncEventArgs (dotnet/runtime#34175)實作中很好地使用了固定支持,其中需要為諸如ReceiveMessageFrom之類的操作分配固定緩沖區, - StringSplitOptions,TrimEntries (dotnet /運行時# 35740),字串,分割多載接受一個StringSplitOptions enum,該enum允許分割可選地從結果陣列中洗掉空條目,新的TrimEntries列舉值在使用或不使用此選項時首先調整結果,無論是否使用RemoveEmptyEntries,這都允許Split避免為一旦被修剪就會變成空的條目分配字串(或者為分配的字串更小),然后與RemoveEmptyEntries一起在這種情況下使結果陣列更小,另外,Split的使用者隨后對每個字串呼叫Trim()是很常見的,因此將修剪作為Split呼叫的一部分可以消除呼叫者額外的字串分配,這在dotnet/運行時中的一些型別和方法中使用,如通過DataTable、HttpListener和SocketsHttpHandler,
- BinaryPrimitives,{嘗試}{讀/寫}{雙/單}{大/小}尾數法(dotnet /運行時# 6864),例如,在,net 5 (dotnet/runtime#34046)中添加的新的簡潔二進制物件表示(CBOR)支持中,您可以看到使用了這些api,
- MailAddress,TryCreate (dotnet/runtime#1052 from @MarcoRossignoli)和PhysicalAddress,{}嘗試決議(dotnet 和PhysicalAddress,{}嘗試決議(dotnet ) /運行時# 1057),新的Try多載支持無例外的決議,而基于跨的多載支持在更大的背景關系中決議地址,而不會導致子字串的分配,
- unsafeSuppressExecutionContextFlow)(來自@MarcoRossignoli的dotnet/runtime#706), 默認情況下,.NET中的異步操作會流動ExecutionContext,這意味著呼叫站點在執行繼續代碼時會隱式“捕獲”當前的ExecutionContext并“還原”它, 這就是AsyncLocal
值如何通過異步操作傳播的方式, 這種流通常很便宜,但是仍然有少量開銷, 由于套接字操作可能對性能至關重要,因此當開發人員知道實體引發的回呼中將不需要背景關系時,可以使用SocketAsyncEventArgs建構式上的此新建構式, 例如,您可以在SocketHttpHandler的內部ConnectHelper(dotnet/runtime#1381)中看到此用法, 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)- SuppressGCTransitionAttribute (dotnet/coreclr#26458),這是一個用于P/invoke的高級屬性,它使運行時能夠阻止它通常會引發的協作-搶占模式轉換,就像它在對運行時本身進行內部“FCalls”時所做的那樣,需要非常小心地使用該屬性(請參閱屬性描述中的詳細注釋),即使如此,你可以看到在Corelib (dotnet/runtime#27473)中的一些方法使用了它,并且JIT有一些懸而未決的變化,這將使它變得更好(dotnet/runtime#39111),
- CollectionsMarshal.AsSpan (dotnet/coreclr# 26867),這個方法為呼叫者提供了對List
的后臺存盤的基于spaner的訪問, - MemoryMarshal.GetArrayDataReference (dotnet/runtime#1036),這個方法回傳對陣列第一個元素的參考(或者如果陣列不是空的,它應該在哪里),沒有執行驗證,因此它既危險又非常快,這個方法在Corelib的很多地方被使用,都是用于非常低級的優化,例如,它被用作前面討論的c# (dotnet/runtime#1068)中實作的cast helper的一部分,以及使用緩沖區的一部分,Memmove在不同的地方(dotnet/runtime#35733),
- SslStreamCertificateContext (dotnet/runtime#38364),當SslStream.AuthenticateAsServer{Async}提供了使用的證書,它試圖構建完整的X509鏈,一個操作可以有不同數量的相關成本,甚至執行I/O,如果需要下載額外的證書資訊,在某些情況下,用于創建任意數量的SslStream實體的相同證書可能會發生這種情況,從而導致重復的開銷,SslStreamCertificateContext作為此類計算結果的一種快取,作業可以在advanced中執行一次,然后傳遞給SslStream以實作任意程度的重用,這有助于避免重復的作業,同時也為呼叫者提供了更多的可預測性和對任何故障的控制,
- HttpClient,發送(dotnet/runtime#34948),對于一些讀者來說,看到這里呼叫的同步API可能會感到奇怪,雖然HttpClient是為異步使用而設計的,但我們發現了開發人員無法利用異步的情況,例如在實作僅同步的介面方法時,或者從需要同步回應的本地操作呼叫時,下載資料的需求無處不在,在這些情況下,強迫開發人員執行“異步之上的同步”(即執行異步操作,然后阻塞等待它完成)的性能和可伸縮性都不如一開始就使用同步操作,因此,.NET 5看到了添加到HttpClient及其支持型別的有限的新同步表面積,dotnet/runtime本身在一些地方使用了這個,例如,在Linux上,當X509Certificates support需要下載一個證書作為構建鏈的一部分時,它通常在一個代碼路徑上,這個代碼路徑需要在回傳到OpenSSL回呼的所有程序中是同步的;以前,這將使用HttpClient,GetByteArrayAsync,然后阻塞等待它完成,但這被證明給一些用戶造成明顯的可伸縮性問題…dotnet/runtime#38502改變它使用新的同步API代替,類似地,舊的HttpWebRequest型別是建立在HttpClient之上的,在以前的.NET Core版本中,它的同步GetResponse()方法實際上是在異步之上進行同步;就像dotnet/runtime#39511一樣,它現在使用同步HttpClient,發送方法,
- HttpContent.ReadAsStream (dotnet/runtime#37494),這在邏輯上是HttpClient的一部分,發送上面提到的努力,但我單獨呼叫它,因為它本身是有用的,現有的ReadAsStreamAsync方法有點奇怪,它最初被公開為異步,只是為了防止自定義HttpContent派生型別需要異步,但是幾乎沒有發現任何覆寫HttpContent的情況,ReadAsStreamAsync不是同步的,HttpClient請求回傳的實作都是同步的,因此,呼叫方最終為回傳的流的Task
包裝器物件付費,而實際上它總是立即可用的,因此,新的ReadAsStream方法在這種情況下可以避免額外的任務 分配,您可以看到在dotnet/runtime中以這種方式在不同的地方使用它,比如ClientWebSocket實作, - 非泛型TaskCompletionSource (dotnet/runtime#37452),由于引入了Task和Task
, TaskCompletionSource 是一種構建任務的方法,呼叫者可以通過它的{Try}Set方法手動完成這些任務,而且由于Task 是從Task派生的,所以單個泛型型別可以同時用于泛型任務 和非泛型任務需求,然而,這并不總是顯而易見的人,導致混亂對非泛型的情況下,正確的解決方案加劇了歧義的型別時使用T只是信口開河的.NET 5添加了一個非泛型TaskCompletionSource,不僅消除了困惑,但是幫助一點性能,因為它避免了任務需要隨身攜帶一個無用的空間T, - Task.WhenAny(Task, Task)(dotnet/runtime#34288和 dotnet/runtime#37488), 以前,可以將任意數量的任務傳遞給Task.WhenAny并通過其多載接受引數Task[] tasks, 但是,在分析此方法的使用時,發現絕大多數呼叫站點始終通過兩項任務, 新的公共多載針對這種情況進行了優化,關于此多載的一件整潔的事情是,僅重新編譯這些呼叫站點將使編譯器系結到新的更快的多載而不是舊的多載,因此無需進行任何代碼更改即可受益于多載,
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方法甚至開始提到!
New Performance-focused Analyzers
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中看到一些修復的例子,
What's Next?
根據.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/
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/1639.html
標籤:.NET Core
