ASP.NET Core 6的性能改进指标变化说明

180 阅读13分钟

受到Stephen Toub关于.NET性能的博文的启发,我们也写了一篇类似的文章,来强调6.0版本中对ASP.NET Core的性能改进。

基准测试设置

我们将使用BenchmarkDotNet来完成大部分的例子。在github.com/BrennanConr…,我们提供了一个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<T> ,每个版本我们都转换了更多的代码,以便在内部和作为公共API的一部分使用跨度来提高性能。这个版本也不例外。

PRdotnet/aspnetcore#28855在添加两个PathString 实例时,删除了PathString 中来自string.SubString 的临时字符串分配,而是使用Span<char> 的临时字符串。在下面的基准测试中,我们使用一个短字符串和一个长字符串来显示避免临时字符串的性能差异:

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);
}
方法运行时间工具链平均值比率已分配
添加短字符串.NET框架4.8net4823.51 ns1.0096 B
添加短字符串.NET 5.0淘宝网22.73 ns0.9796 B
添加短字符串.NET 6.0net6.014.92 ns0.6456 B
添加长字符串.NET框架4.8net4830.89 ns1.00201 B
添加长字符串.NET 5.0淘宝网25.18 ns0.82192 B
添加长字符串.NET 6.0net6.015.69 ns0.51104 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
方法查询参数平均值比率已分配
查询助手简单243.13 ns1.00360 B
查询可数简单91.43 ns0.38-
查询助手编码的351.25 ns1.00432 B
编码编码的197.59 ns0.56152 B

值得注意的是,天下没有没有免费的午餐。在新的QueryStringEnumerable API案例中,如果你打算多次枚举查询字符串值,它实际上可能比使用QueryHelpers.ParseQuery ,并存储解析的查询字符串值的字典更昂贵。

如果你知道字符串的最终大小,允许在它创建后初始化它。这被用来删除UriHelper.BuildAbsolute.NET中的一些临时字符串分配。

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
方法运行时间工具链平均值比率分配的
构建绝对值.NET核心3.1netcoreapp3.192.87 ns1.00176 B
构建Absolute.NET 6.0net6.052.88 ns0.5764 B

PRdotnet/aspnetcore#31267ContentDispositionHeaderValue 中的一些解析逻辑转换为使用基于Span<T> 的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";
}
方法运行时间工具链平均值比率分配的
内容处置头(ContentDispositionHeader.NET框架4.8net48654.9 ns1.00570 B
内容处置头(ContentDispositionHeader.NET核心3.1netcoreapp3.1581.5 ns0.89536 B
内容处置头(ContentDispositionHeader.NET 5.0net5.0519.2 ns0.79536 B
内容处置头(ContentDispositionHeader.NET 6.0net6.0295.4 ns0.45312 B

闲置连接

ASP.NET核心的主要组成部分之一是托管服务器,它带来了一系列不同的问题需要优化。我们将重点讨论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 分配,并将分配转移到连接工厂,因此我们只为服务器的整个生命周期分配一个,并为每个连接重复使用相同的选项。dotnet/aspnetcore#31311来自@benaadams,用内部字符串替换WebSocket请求中已知的头值,允许在头解析期间分配的字符串被垃圾回收,减少长期使用的WebSocket连接的内存使用。dotnet/aspnetcore#30771重构了Kestrel中的Sockets层,首先避免分配SocketReceiver对象+SocketAwaitableEventArgs,并将其合并为一个对象,这节省了几个字节,并导致每个连接分配的独特对象减少。这个PR也汇集了SocketSender类,所以你现在不再为每个连接创建一个SocketSender,而是平均拥有若干个核心。因此,在下面的基准中,当我们有10,000个连接时,在我的机器上只分配了16个,而不是10,000个,这就节省了~46MB!

另一个类似规模的变化是dotnet/runtime#49123,它增加了对SslStream 中零字节读取的支持,所以我们的10,000个空闲连接从46 MB变成了2.3 MB,来自SslStream 的分配。dotnet/runtime#49117增加了对StreamPipeReader 中零字节读取的支持,然后由kestrel在dotnet/aspnetcore#30863中使用,开始使用SslStream 的零字节读取。

所有这些变化的结果是大量减少了空闲连接的内存使用。

下面的数字不是来自BenchmarkDotNet应用程序,因为它测量的是空闲连接,用客户端和服务器应用程序来设置比较容易。

控制台和WebApplication的代码粘贴在以下gist中:gist.github.com/BrennanConr…

以下是不同框架下10,000个空闲的安全WebSocket连接(WSS)在服务器上占用的内存量:

框架内存
net48665.4 MB
net5.0603.1 MB
net6.0160.8 MB

从net5.0到net6.0,内存几乎减少了4倍!

实体框架核心

EF Core在6.0中做了一些大规模的改进,它的查询执行速度提高了31%,TechEmpower Fortunes基准测试在Runtime更新、优化基准和EF改进下提高了70%。

这些改进来自于改进对象池,智能地检查是否启用遥测功能,以及当你知道你的应用程序安全地使用DbContext时,增加一个选项来选择不进行线程安全检查。

请参阅宣布Entity Framework Core 6.0 Preview 4: Performance Edition博文,其中详细介绍了许多改进。

浏览器

本地byte[] 互通

在执行JavaScript互操作时,Blazor现在对字节数组有了有效的支持。此前,发送和接收JavaScript的字节数组是经过Base64编码的,这样它们就可以被序列化为JSON,这增加了传输量和CPU负荷。在.NET 6中,Base64编码现在已经被优化了,允许用户在.NET中透明地使用byte[] ,在JavaScript中透明地使用Uint8Array 。关于使用这个功能的文档,可以用于JavaScript到.NET.NET到JavaScript

让我们来看看一个快速的基准测试,看看.NET 5和.NET 6中的byte[] 互通的区别。下面的Razor代码创建了一个22 kB的byte[] ,并将其发送给一个JavaScriptreceiveAndReturnBytes 函数,该函数立即返回byte[] 。这个数据的往返重复了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;
}

所以它肯定更容易编写,但它的性能如何呢?blazorserver 在.NET 5和.NET 6的模板中分别运行这些片段,在Release 配置下,我们看到.NET 6在byte[] 互操作中提供了78%的性能改进。

------.NET 6 (ms).NET 5 (毫秒)改进
总时间52732446378%

此外,这种字节数组互操作支持在框架内被利用来实现JavaScript和.NET之间的双向流互操作。用户现在能够传输任意的二进制数据。关于从.NET流向JavaScript的文档在这里,而JavaScript到.NET的文档在这里

输入文件

利用上面提到的Blazor流媒体互操作,我们现在支持通过InputFile组件上传大文件(之前的上传限制在~2GB)。这个组件还具有显著的速度改进,因为它采用了本地字节[]流,而不是通过Base64编码。例如,与.NET 5相比,一个100MB的文件的上传速度提高了77%。

.NET 6 (毫秒).NET 5 (毫秒)百分比
25911050475%
26071176478%
26321182178%
平均数77%

请注意,流媒体互操作支持也能实现(大)文件的有效下载,更多细节,请看文档

InputFile 组件已经升级,以便通过dotnet/aspnetcore#33900 利用流媒体。

其他

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未压缩。

dotnet/aspnetcore#31322——在从连接特征集合中获取常见特征时,删除了一些不必要的投射。这使得从集合中访问普通特征时有了~50%的改进。不幸的是,在一个基准中看到性能的改善是不可能的,因为它需要大量的内部类型,所以我将在这里包括来自PR的数字,如果你有兴趣运行它们,PR包括可以针对内部代码运行的基准。

方法平均值操作/秒差异
Get*8.507 ns117,554,189.6+50.0%
Get*9.034 ns110,689,963.7-
Get*9.466 ns105,636,431.7+58.7%
Get*10.007 ns99,927,927.4+50.0%
Get*10.564 ns94,656,794.2+44.7%

dotnet/aspnetcore#31519——它为IHeaderDictionary 类型添加了默认的接口方法,以便通过以头名称命名的属性访问普通头。在访问头文件字典时,再也不会误输入普通头文件了。对这篇博文来说,更有趣的是,这一变化允许服务器实现返回一个自定义的头信息字典,以更优化的方式实现这些新的接口方法。例如,服务器可能会直接将头的值存储在一个字段中,并可以直接返回该字段,而不是查询内部字典中的头的值,这需要对键进行散列并查找一个条目。这一变化导致在某些情况下,在获取或设置头值时,有高达480%的改进。为了再一次正确地对这一变化进行基准测试以显示其改进,它需要使用内部类型进行设置,所以我将包括来自PR的数字,PR包含在内部代码上运行的基准。

方法分支类型平均值操作/秒误差
GetHeadersbefore纯文本25.793 ns38,770,569.6-
GetHeadersafter普通文本12.775 ns78,279,480.0+101.9%
GetHeadersbefore普通121.355 ns8,240,299.3-
GetHeadersafter普通37.598 ns26,597,474.6+222.8%
GetHeadersbefore陌生366.456 ns2,728,840.7-
GetHeadersafter未知223.472 ns4,474,824.0+64.0%
SetHeadersbefore普通文本49.324 ns20,273,931.8-
SetHeadersafter普通文本34.996 ns28,574,778.8+40.9%
SetHeadersbefore普通635.060 ns1,574,654.3-
SetHeadersafter普通108.041 ns9,255,723.7+487.7%
SetHeadersbefore未知1,439.945 ns694,470.8-
SetHeadersafter不详517.067 ns1,933,985.7+178.4%

dotnet/aspnetcore#31466使用了.NET 6中引入的新的CancellationTokenSource.TryReset()方法,以便在连接关闭而未被取消的情况下重新使用CancellationTokenSource的。下面的数字是通过对Kestrel运行bombardier收集的,有125个连接,它运行了约10万个请求。

分支类型分配量字节数
beforeCancellationTokenSource98,3144,719,072
afterCancellationTokenSource1256,000

dotnet/aspnetcore#31528dotnet/aspnetcore#34075分别为HTTPS握手和HTTP3流重用CancellationTokenSource's做了类似的修改。

dotnet/aspnetcore#31660改进了SignalR中服务器到客户端流的性能,为整个流重新使用分配的StreamItem 对象,而不是为每个流项分配一个。dotnet/aspnetcore#31661HubCallerClients 对象存储在SignalR连接上,而不是为每个Hub方法调用分配对象。

dotnet/aspnetcore#31506来自@ShreyasJejurkar重构了WebSocket握手的内部结构,以避免临时的List<T>dotnet/aspnetcore#32829来自@gfoidl重构了QueryCollection ,以减少分配和向量一些代码。dotnet/aspnetcore#32234来自@benaadams删除了HttpRequestHeaders 枚举中一个未使用的字段,通过不再为每个枚举的头分配该字段提高perf。

dotnet/aspnetcore#31333来自martincostello,他将Http.Sys转换为使用 LoggerMessage.Define这是高性能的日志API。这避免了不必要的值类型的装箱,解析日志格式字符串,在某些情况下,避免了在日志级别未启用时对字符串或对象的分配。

dotnet/aspnetcore#31784为注册中间件增加了一个新的IApplicationBuilder.Use 重载,在运行中间件时避免了一些不必要的每请求分配。 旧代码:

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);
}
方法平均值比率分配
Use15.832 ns1.0096 B
UseNew2.592 ns0.16-

总结

我希望你喜欢阅读ASP.NET Core 6.0中的一些改进!我鼓励你看一下.NET 6中的性能改进博文,其中介绍了运行时的性能。我鼓励你看一下.NET 6中的性能改进博文,该博文介绍了运行时的性能。