【譯】ASP.NET Core 6 中的效能改進

MingsonZheng發表於2022-03-10

原文 | 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 以來的每個版本,我們都轉換了更多程式碼以在內部和作為公共 API 的一部分使用跨度以提高效能。本次釋出也不例外。

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 並儲存已解析查詢字串值的字典更昂貴。

@paulomorgadodotnet/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 的 API,以避免在常見情況下出現臨時字串和臨時 byte[]。

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 分配並將分配移至連線工廠,因此我們僅在伺服器的整個生命週期內分配一個,併為每個連線重用相同的選項。來自@benaadamsdotnet/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 使用流式傳輸。

大雜燴

來自@benaadamsdotnet/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。

@benaadamsdotnet/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#31528dotnet/aspnetcore#34075 分別對 HTTPS 握手和 HTTP3 流重用 CancellationTokenSource 進行了類似的更改。

dotnet/aspnetcore#316600 通過為整個流重用分配的 StreamItem 物件而不是為每個流項分配一個物件,改進了 SignalR 中伺服器到客戶端流的效能。並且 dotnet/aspnetcore#31661 將 HubCallerClients 物件儲存在 SignalR 連線上,而不是為每個 Hub 方法呼叫分配它。

@ShreyasJejurkardotnet/aspnetcore#31506 重構了 WebSocket 握手的內部結構,以避免臨時 List 分配。 @gfoidl 中的 dotnet/aspnetcore#32829 重構了 QueryCollection 以減少分配並向量化一些程式碼。 @benaadamsdotnet/aspnetcore#32234 刪除了 HttpRequestHeaders 列舉中未使用的欄位,該欄位通過不再為每個列舉的標頭分配欄位來提高效能。

來自 martincostellodotnet/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) 。

相關文章