原文 | Brennan Conroy
翻譯 | 鄭子銘
受到 Stephen Toub 關於 .NET 效能的博文的啟發,我們正在寫一篇類似的文章來強調 6.0 中對 ASP.NET Core 所做的效能改進。
基準設定
我們將在整個示例中使用 BenchmarkDotNet。在 https://github.com/BrennanConroy/BlogPost60Bench 上提供了一個 repo,其中包括本文中使用的大部分基準。
這篇文章中的大多數基準測試結果都是使用以下命令列生成的:
dotnet run -c Release -f net48 --runtimes net48 netcoreapp3.1 net5.0 net6.0
然後從列表中選擇要執行的特定基準。
這告訴 BenchmarkDotNet:
- 在釋出配置中構建所有內容。
- 針對 .NET Framework 4.8 外圍區域構建它。
- 在 .NET Framework 4.8、.NET Core 3.1、.NET 5 和 .NET 6 上執行每個基準測試。
對於某些基準測試,它們僅在 .NET 6 上執行(例如,如果比較同一版本上的兩種編碼方式):
dotnet run -c Release -f net6.0 --runtimes net6.0
而對於其他版本,只執行了其中的一個子集,例如
dotnet run -c Release -f net5.0 --runtimes net5.0 net6.0
我將包括用於執行每個基準測試的命令當他們出現時。
帖子中的大部分結果都是通過在 Windows 上執行上述基準測試生成的,主要是為了將 .NET Framework 4.8 包含在結果集中。但是,除非另有說明,否則所有這些基準測試通常在 Linux 或 macOS 上執行時都顯示出相當的改進。只需確保您已安裝要測量的每個執行時。基準測試是在夜間構建的 .NET 6 RC1 以及最新發布的 .NET 5 和 .NET Core 3.1 下載中執行的。
Span
自從在 .NET 2.1 中新增 Span
PR dotnet/aspnetcore#28855 在新增兩個 PathString 例項時刪除了來自 string.SubString 的 PathString 中的臨時字串分配,而是使用 Span
dotnet run -c Release -f net48 --runtimes net48 net5.0 net6.0 --filter *PathStringBenchmark*
private PathString _first = new PathString("/first/");
private PathString _second = new PathString("/second/");
private PathString _long = new PathString("/longerpathstringtoshowsubstring/");
[Benchmark]
public PathString AddShortString()
{
return _first.Add(_second);
}
[Benchmark]
public PathString AddLongString()
{
return _first.Add(_long);
}
Method | Runtime | Toolchain | Mean | Ratio | Allocated |
---|---|---|---|---|---|
AddShortString | .NET Framework 4.8 | net48 | 23.51 ns | 1.00 | 96 B |
AddShortString | .NET 5.0 | net5.0 | 22.73 ns | 0.97 | 96 B |
AddShortString | .NET 6.0 | net6.0 | 14.92 ns | 0.64 | 56 B |
AddLongString | .NET Framework 4.8 | net48 | 30.89 ns | 1.00 | 201 B |
AddLongString | .NET 5.0 | net5.0 | 25.18 ns | 0.82 | 192 B |
AddLongString | .NET 6.0 | net6.0 | 15.69 ns | 0.51 | 104 B |
dotnet/aspnetcore#34001 引入了一個新的基於 Span 的 API,用於列舉查詢字串,在沒有編碼字元的常見情況下是無分配的,當查詢字串包含編碼字元時,分配量較低。
dotnet run -c Release -f net6.0 --runtimes net6.0 --filter *QueryEnumerableBenchmark*
#if NET6_0_OR_GREATER
public enum QueryEnum
{
Simple = 1,
Encoded,
}
[ParamsAllValues]
public QueryEnum QueryParam { get; set; }
private string SimpleQueryString = "?key1=value1&key2=value2";
private string QueryStringWithEncoding = "?key1=valu%20&key2=value%20";
[Benchmark(Baseline = true)]
public void QueryHelper()
{
var queryString = QueryParam == QueryEnum.Simple ? SimpleQueryString : QueryStringWithEncoding;
foreach (var queryParam in QueryHelpers.ParseQuery(queryString))
{
_ = queryParam.Key;
_ = queryParam.Value;
}
}
[Benchmark]
public void QueryEnumerable()
{
var queryString = QueryParam == QueryEnum.Simple ? SimpleQueryString : QueryStringWithEncoding;
foreach (var queryParam in new QueryStringEnumerable(queryString))
{
_ = queryParam.DecodeName();
_ = queryParam.DecodeValue();
}
}
#endif
Method | QueryParam | Mean | Ratio | Allocated |
---|---|---|---|---|
QueryHelper | Simple | 243.13 ns | 1.00 | 360 B |
QueryEnumerable | Simple | 91.43 ns | 0.38 | – |
QueryHelper | Encoded | 351.25 ns | 1.00 | 432 B |
QueryEnumerable | Encoded | 197.59 ns | 0.56 | 152 B |
重要的是要注意沒有免費的午餐。在新的 QueryStringEnumerable API 案例中,如果您計劃多次列舉查詢字串值,它實際上可能比使用 QueryHelpers.ParseQuery 並儲存已解析查詢字串值的字典更昂貴。
@paulomorgado 的 dotnet/aspnetcore#29448 使用 string.Create 方法,如果您知道字串的最終大小,則該方法允許在建立字串後對其進行初始化。這用於刪除 UriHelper.BuildAbsolute 中的一些臨時字串分配。
dotnet run -c Release -f netcoreapp3.1 --runtimes netcoreapp3.1 net6.0 --filter *UriHelperBenchmark*
#if NETCOREAPP
[Benchmark]
public void BuildAbsolute()
{
_ = UriHelper.BuildAbsolute("https", new HostString("localhost"));
}
#endif
Method | Runtime | Toolchain | Mean | Ratio | Allocated |
---|---|---|---|---|---|
BuildAbsolute | .NET Core 3.1 | netcoreapp3.1 | 92.87 ns | 1.00 | 176 B |
BuildAbsolute | .NET 6.0 | net6.0 | 52.88 ns | 0.57 | 64 B |
PR dotnet/aspnetcore#31267將 ContentDispositionHeaderValue 中的一些解析邏輯轉換為使用基於 Span
dotnet run -c Release -f net48 --runtimes net48 netcoreapp3.1 net5.0 net6.0 --filter *ContentDispositionBenchmark*
[Benchmark]
public void ParseContentDispositionHeader()
{
var contentDisposition = new ContentDispositionHeaderValue("inline");
contentDisposition.FileName = "FileÃName.bat";
}
Method | Runtime | Toolchain | Mean | Ratio | Allocated |
---|---|---|---|---|---|
ContentDispositionHeader | .NET Framework 4.8 | net48 | 654.9 ns | 1.00 | 570 B |
ContentDispositionHeader | .NET Core 3.1 | netcoreapp3.1 | 581.5 ns | 0.89 | 536 B |
ContentDispositionHeader | .NET 5.0 | net5.0 | 519.2 ns | 0.79 | 536 B |
ContentDispositionHeader | .NET 6.0 | net6.0 | 295.4 ns | 0.45 | 312 B |
空閒連線
ASP.NET Core 的主要元件之一是託管伺服器,它帶來了許多需要優化的不同問題。我們將專注於改進 6.0 中的空閒連線,我們在其中進行了許多更改以減少連線等待資料時使用的記憶體量。
我們進行了三種不同型別的更改,一種是減少連線使用的物件的大小,包括 System.IO.Pipelines、SocketConnections 和 SocketSenders。第二種型別的更改是彙集常用訪問的物件,以便我們可以重用舊例項並節省分配。第三種變化是利用所謂的“零位元組讀取”。這是我們嘗試使用零位元組緩衝區從連線中讀取的地方,如果有可用資料,則讀取將返回沒有資料,但我們會知道現在有可用資料,並且可以提供一個緩衝區來立即讀取該資料。這避免了為可能在將來完成的讀取預先分配緩衝區,因此我們可以避免大量分配,直到我們知道資料可用。
dotnet/runtime#49270 將 System.IO.Pipelines 的大小從 ~560 位元組減少到 ~368 位元組,這減少了 34%,每個連線至少有 2 個管道,所以這是一個巨大的勝利。
dotnet/aspnetcore#31308 重構了 Kestrel 的 Socket 層,以避免一些非同步狀態機並減少剩餘狀態機的大小,從而為每個連線節省約 33% 的分配。
dotnet/aspnetcore#30769 刪除了每個連線的 PipeOptions 分配並將分配移至連線工廠,因此我們僅在伺服器的整個生命週期內分配一個,併為每個連線重用相同的選項。來自@benaadams 的 dotnet/aspnetcore#31311 將 WebSocket 請求中眾所周知的標頭值替換為內部字串,這允許在標頭解析期間分配的字串被垃圾收集,從而減少長期 WebSocket 連線的記憶體使用量。 dotnet/aspnetcore#30771 重構了 Kestrel 中的 Sockets 層,首先避免分配 SocketReceiver 物件 + SocketAwaitableEventArgs 並將其組合成一個物件,這節省了幾個位元組並導致每個連線分配的唯一物件更少。該 PR 還彙集了 SocketSender 類,因此您現在平均擁有多個核心 SocketSender,而不是為每個連線建立一個。所以在下面的基準測試中,當我們有 10,000 個連線時,我的機器上只分配了 16 個,而不是 10,000 個,這節省了約 46 MB!
另一個類似大小的更改是 dotnet/runtime#49123,它增加了對 SslStream 中零位元組讀取的支援,因此我們的 10,000 個空閒連線從 SslStream 分配中從 ~46 MB 變為 ~2.3 MB。 dotnet/runtime#49117 在 StreamPipeReader 上新增了對零位元組讀取的支援,然後 Kestrel 在 dotnet/aspnetcore#30863 中使用它開始在 SslStream 中使用零位元組讀取。
所有這些變化的結果是大量減少了空閒連線的記憶體使用量。
以下數字並非來自 BenchmarkDotNet 應用程式,因為它正在測量空閒連線,並且使用客戶端和伺服器應用程式進行設定更容易。
控制檯和 WebApplication 程式碼貼上在以下要點中:https://gist.github.com/BrennanConroy/02e8459d63305b4acaa0a021686f54c7
下面是不同框架上伺服器上 10,000 個空閒安全 WebSocket 連線 (WSS) 佔用的記憶體量。
Framework | Memory |
---|---|
net48 | 665.4 MB |
net5.0 | 603.1 MB |
net6.0 | 160.8 MB |
從 net5.0 到 net6.0,記憶體減少了近 4 倍!
Entity Framework Core
EF Core 在 6.0 中進行了一些重大改進,執行查詢的速度提高了 31%,而 TechEmpower Fortunes 基準測試通過執行時更新、優化基準測試和 EF 改進提高了 70%。
這些改進來自改進物件池、智慧地檢查遙測是否啟用,以及當您知道您的應用程式安全地使用 DbContext 時新增一個選項以選擇退出執行緒安全檢查。
請參閱宣佈 Entity Framework Core 6.0 Preview 4:Performance Edition 部落格文章,其中詳細介紹了許多改進。
Blazor
本地 byte[] 互通
Blazor 現在在執行 JavaScript 互操作時有效地支援位元組陣列。以前,向 JavaScript 傳送和從 JavaScript 傳送的位元組陣列是 Base64 編碼的,因此它們可以序列化為 JSON,這增加了傳輸大小和 CPU 負載。 Base64 編碼現已在 .NET 6 中進行了優化,允許使用者透明地使用 .NET 中的 byte[] 和 JavaScript 中的 Uint8Array。有關將此功能用於 JavaScript 到 .NET 和 .NET 到 JavaScript 的文件。
讓我們看一個快速基準測試,以瞭解 .NET 5 和 .NET 6 中的 byte[] 互操作之間的區別。以下 Razor 程式碼建立一個 22 kB byte[],並將其傳送到 JavaScript 的 receiveAndReturnBytes 函式,該函式立即返回位元組[]。此資料往返重複 10,000 次,並將時間資料列印到螢幕上。此程式碼與 .NET 5 和 .NET 6 相同。
<button @onclick="@RoundtripData">Roundtrip Data</button>
<hr />
@Message
@code {
public string Message { get; set; } = "Press button to benchmark";
private async Task RoundtripData()
{
var bytes = new byte[1024*22];
List<double> timeForInterop = new List<double>();
var testTime = DateTime.Now;
for (var i = 0; i < 10_000; i++)
{
var interopTime = DateTime.Now;
var result = await JSRuntime.InvokeAsync<byte[]>("receiveAndReturnBytes", bytes);
timeForInterop.Add(DateTime.Now.Subtract(interopTime).TotalMilliseconds);
}
Message = $"Round-tripped: {bytes.Length / 1024d} kB 10,000 times and it took on average {timeForInterop.Average():F3}ms, and in total {DateTime.Now.Subtract(testTime).TotalMilliseconds:F1}ms";
}
}
接下來我們看一下receiveAndReturnBytes JavaScript 函式。在 .NET 5 中。我們必須首先將 Base64 編碼的位元組陣列解碼為 Uint8Array,以便它可以在應用程式程式碼中使用。然後我們必須在將資料返回到伺服器之前將其重新編碼為 Base64。
function receiveAndReturnBytes(bytesReceivedBase64Encoded) {
const bytesReceived = base64ToArrayBuffer(bytesReceivedBase64Encoded);
// Use Uint8Array data in application
const bytesToSendBase64Encoded = base64EncodeByteArray(bytesReceived);
if (bytesReceivedBase64Encoded != bytesToSendBase64Encoded) {
throw new Error("Expected input/output to match.")
}
return bytesToSendBase64Encoded;
}
// https://stackoverflow.com/a/21797381
function base64ToArrayBuffer(base64) {
const binaryString = atob(base64);
const length = binaryString.length;
const result = new Uint8Array(length);
for (let i = 0; i < length; i++) {
result[i] = binaryString.charCodeAt(i);
}
return result;
}
function base64EncodeByteArray(data) {
const charBytes = new Array(data.length);
for (var i = 0; i < data.length; i++) {
charBytes[i] = String.fromCharCode(data[i]);
}
const dataBase64Encoded = btoa(charBytes.join(''));
return dataBase64Encoded;
}
編碼/解碼增加了客戶端和伺服器的大量開銷,同時還需要大量的樣板程式碼。那麼這將如何在 .NET 6 中完成呢?好吧,它有點簡單:
function receiveAndReturnBytes(bytesReceived) {
// bytesReceived comes as a Uint8Array ready for use
// and can be used by the application or immediately returned.
return bytesReceived;
}
所以寫起來肯定更容易,但它的表現如何呢?分別在 .NET 5 和 .NET 6 的 blazorserver 模板中執行這些程式碼片段,在 Release 配置下,我們看到 .NET 6 在 byte[] 互操作方面提供了 78% 的效能提升!
—————– | .NET 6 (ms) | .NET 5 (ms) | Improvement |
---|---|---|---|
Total Time | 5273 | 24463 | 78% |
此外,框架內利用了這種位元組陣列互操作支援,以實現 JavaScript 和 .NET 之間的雙向流式互操作。使用者現在可以傳輸任意二進位制資料。有關從 .NET 流式傳輸到 JavaScript 的文件可在此處獲得,JavaScript 到 .NET 文件可在此處獲得。
輸入檔案
使用上面提到的 Blazor Streaming Interop,我們現在支援通過 InputFile 元件上傳大檔案(以前上傳限制為 ~2GB)。由於原生位元組 [] 流而不是通過 Base64 編碼,該元件還具有顯著的速度改進。例如,與 .NET 5 相比,上傳 100 MB 檔案的速度提高了 77%。
.NET 6 (ms) | .NET 5 (ms) | Percentage |
---|---|---|
2591 | 10504 | 75% |
2607 | 11764 | 78% |
2632 | 11821 | 78% |
Average: | 77% |
請注意,流式互操作支援還可以有效下載(大)檔案,有關更多詳細資訊,請參閱文件。
InputFile 元件已升級為通過 dotnet/aspnetcore#33900 使用流式傳輸。
大雜燴
來自@benaadams 的 dotnet/aspnetcore#30320 對我們的 Typescript 庫進行了現代化改造並對其進行了優化,因此網站載入速度更快。 signalr.min.js 檔案從 36.8 kB 壓縮和 132 kB 未壓縮變為 16.1 kB 壓縮和 42.2 kB 未壓縮。 blazor.server.js 檔案壓縮後為 86.7 kB,未壓縮時為 276 kB,壓縮後為 43.9 kB,未壓縮時為 130 kB。
@benaadams 的 dotnet/aspnetcore#31322 在從連線功能集合中獲取常用功能時刪除了一些不必要的強制轉換。這在訪問集合中的常見特徵時提供了約 50% 的改進。不幸的是,實際上不可能在基準測試中看到效能改進,因為它需要一堆內部型別,所以我將在此處包含來自 PR 的數字,如果您有興趣執行它們,PR 包括可以執行的基準反對內部程式碼。
Method | Mean | Op/s | Diff |
---|---|---|---|
Get |
8.507 ns | 117,554,189.6 | +50.0% |
Get |
9.034 ns | 110,689,963.7 | – |
Get |
9.466 ns | 105,636,431.7 | +58.7% |
Get |
10.007 ns | 99,927,927.4 | +50.0% |
Get |
10.564 ns | 94,656,794.2 | +44.7% |
dotnet/aspnetcore#31519 也來自@benaadams,將預設介面方法新增到 IHeaderDictionary 型別,用於通過以標頭名稱命名的屬性訪問公共標頭。訪問標題字典時不再輸入錯誤的常見標題!對於這篇博文來說更有趣的是,此更改允許伺服器實現返回自定義標頭字典,以更優化地實現這些新介面方法。例如,不是在內部字典中查詢需要雜湊鍵並查詢條目的標頭值,而是伺服器可能將標頭值直接儲存在欄位中並可以直接返回該欄位。在某些情況下,在獲取或設定標頭值時,此更改可帶來高達 480% 的改進。再一次,為了正確地對這個更改進行基準測試,以顯示它需要使用內部型別進行設定所需的改進,因此我將包括來自 PR 的數字,並且對於那些有興趣嘗試它的人,PR 包含在內部程式碼上執行的基準。
Method | Branch | Type | Mean | Op/s | Delta |
---|---|---|---|---|---|
GetHeaders | before | Plaintext | 25.793 ns | 38,770,569.6 | – |
GetHeaders | after | Plaintext | 12.775 ns | 78,279,480.0 | +101.9% |
GetHeaders | before | Common | 121.355 ns | 8,240,299.3 | – |
GetHeaders | after | Common | 37.598 ns | 26,597,474.6 | +222.8% |
GetHeaders | before | Unknown | 366.456 ns | 2,728,840.7 | – |
GetHeaders | after | Unknown | 223.472 ns | 4,474,824.0 | +64.0% |
SetHeaders | before | Plaintext | 49.324 ns | 20,273,931.8 | – |
SetHeaders | after | Plaintext | 34.996 ns | 28,574,778.8 | +40.9% |
SetHeaders | before | Common | 635.060 ns | 1,574,654.3 | – |
SetHeaders | after | Common | 108.041 ns | 9,255,723.7 | +487.7% |
SetHeaders | before | Unknown | 1,439.945 ns | 694,470.8 | – |
SetHeaders | after | Unknown | 517.067 ns | 1,933,985.7 | +178.4% |
dotnet/aspnetcore#31466 使用 .NET 6 中引入的新 CancellationTokenSource.TryReset() 方法來重用 CancellationTokenSource,如果連線在沒有被取消的情況下關閉。以下數字是通過對具有 125 個連線的 Kestrel 執行轟炸機收集的,它執行了約 100,000 個請求。
Branch Type | Allocations | Bytes |
---|---|---|
Before | CancellationTokenSource | 98,314 4,719,072 |
After | CancellationTokenSource | 125 6,000 |
dotnet/aspnetcore#31528 和 dotnet/aspnetcore#34075 分別對 HTTPS 握手和 HTTP3 流重用 CancellationTokenSource 進行了類似的更改。
dotnet/aspnetcore#316600 通過為整個流重用分配的 StreamItem 物件而不是為每個流項分配一個物件,改進了 SignalR 中伺服器到客戶端流的效能。並且 dotnet/aspnetcore#31661 將 HubCallerClients 物件儲存在 SignalR 連線上,而不是為每個 Hub 方法呼叫分配它。
@ShreyasJejurkar 的 dotnet/aspnetcore#31506 重構了 WebSocket 握手的內部結構,以避免臨時 List
來自 martincostello 的 dotnet/aspnetcore#31333 將 Http.Sys 轉換為使用 LoggerMessage.Define,這是高效能日誌記錄 API。這避免了不必要的值型別裝箱、日誌格式字串的解析,並且在某些情況下避免了在日誌級別未啟用時分配字串或物件。
dotnet/aspnetcore#31784 新增了一個新的 IApplicationBuilder。使用過載來註冊中介軟體,以避免在執行中介軟體時進行一些不必要的按請求分配。舊程式碼如下所示:
app.Use(async (context, next) =>
{
await next();
});
新程式碼如下所示:
app.Use(async (context, next) =>
{
await next(context);
});
下面的基準測試模擬了中介軟體管道,而沒有設定伺服器來展示改進。使用 int 代替 HttpContext 進行請求,中介軟體返回完成的任務。
dotnet run -c Release -f net6.0 --runtimes net6.0 --filter *UseMiddlewareBenchmark*
static private Func<Func<int, Task>, Func<int, Task>> UseOld(Func<int, Func<Task>, Task> middleware)
{
return next =>
{
return context =>
{
Func<Task> simpleNext = () => next(context);
return middleware(context, simpleNext);
};
};
}
static private Func<Func<int, Task>, Func<int, Task>> UseNew(Func<int, Func<int, Task>, Task> middleware)
{
return next => context => middleware(context, next);
}
Func<int, Task> Middleware = UseOld((c, n) => n())(i => Task.CompletedTask);
Func<int, Task> NewMiddleware = UseNew((c, n) => n(c))(i => Task.CompletedTask);
[Benchmark(Baseline = true)]
public Task Use()
{
return Middleware(10);
}
[Benchmark]
public Task UseNew()
{
return NewMiddleware(10);
}
Method | Mean | Ratio | Allocated |
---|---|---|---|
Use | 15.832 ns | 1.00 | 96 B |
UseNew | 2.592 ns | 0.16 | – |
總結
希望您喜歡閱讀 ASP.NET Core 6.0 中的一些改進!我鼓勵您檢視 .NET 6 部落格文章中的效能改進,它超越了執行時的效能。
原文連結
Performance improvements in ASP.NET Core 6
本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。
歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。
如有任何疑問,請與我聯絡 (MingsonZheng@outlook.com) 。