【翻译】.NET 5中的性能改进
【翻译】.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
if (!typeof(T).IsValueType && array.GetType() != typeof(T[]))
ThrowHelper.ThrowArrayTypeMismatchException();
PR dotnet/runtime#32790就是这样优化数组的.GetType()!= typeof(T [])检查何时密封T,而dotnet/runtime#1157识别typeof(T).IsValueType模式并将其替换为常量 值(PR dotnet/runtime#1195对于typeof(T1).IsAssignableFrom(typeof(T2))进行了相同的操作)。 这样做的最终结果是极大地改善了微基准,例如:
class A { }
sealed class B : A { }
private B[] _array = new B[42];
[Benchmark]
public int Ctor() => new Span<B>(_array).Length;
我得到的结果如下:
Method | Runtime | Mean | Ratio | Code Size |
---|---|---|---|---|
Ctor | .NET FW 4.8 | 48.8670 ns | 1.00 | 66 B |
Ctor | .NET Core 3.1 | 7.6695 ns | 0.16 | 66 B |
Ctor | .NET 5.0 | 0.4959 ns | 0.01 | 17 B |
当查看生成的程序集时,差异的解释就很明显了,即使不是完全精通程序集代码。以下是[DisassemblyDiagnoser]在.NET Core 3.1上生成的内容:
; Program.Ctor()
push rdi
push rsi
sub rsp,28
mov rsi,[rcx+8]
test rsi,rsi
jne short M00_L00
xor eax,eax
jmp short M00_L01
M00_L00:
mov rcx,rsi
call System.Object.GetType()
mov rdi,rax
mov rcx,7FFE4B2D18AA
call CORINFO_HELP_TYPEHANDLE_TO_RUNTIMETYPE
cmp rdi,rax
jne short M00_L02
mov eax,[rsi+8]
M00_L01:
add rsp,28
pop rsi
pop rdi
ret
M00_L02:
call System.ThrowHelper.ThrowArrayTypeMismatchException()
int 3
; Total bytes of code 66
下面是.NET5的内容:
; Program.Ctor()
mov rax,[rcx+8]
test rax,rax
jne short M00_L00
xor eax,eax
jmp short M00_L01
M00_L00:
mov eax,[rax+8]
M00_L01:
ret
; Total bytes of code 17
另一个例子是,在前面的GC讨论中,我提到了将本地运行时代码移植到c#代码中所带来的一些好处。有一点我之前没有提到,但现在将会提到,那就是它导致了我们对系统进行了其他改进,解决了移植的关键阻滞剂,但也改善了许多其他情况。一个很好的例子是dotnet/runtime#38229。当我们第一次将本机数组排序实现移动到managed时,我们无意中导致了浮点值的回归,这个回归被@nietras 发现,随后在dotnet/runtime#37941中修复。回归是由于本机实现使用一个特殊的优化,我们失踪的管理端口(浮点数组,将所有NaN值数组的开始,后续的比较操作可以忽略NaN)的可能性,我们成功了。然而,问题是这个的方式表达并没有导致大量的代码重复:本机实现模板,使用和管理实现使用泛型,但限制与泛型等,内联 helpers介绍,以避免大量的代码重复导致non-inlineable在每个比较采用那种方法调用。PR dotnet/runtime#38229通过允许JIT在同一类型内嵌共享泛型代码解决了这个问题。考虑一下这个微基准测试:
private C c1 = new C() { Value = 1 }, c2 = new C() { Value = 2 }, c3 = new C() { Value = 3 };
[Benchmark]
public int Compare() => Comparer<C>.Smallest(c1, c2, c3);
class Comparer<T> where T : IComparable<T>
{
public static int Smallest(T t1, T t2, T t3) =>
Compare(t1, t2) <= 0 ?
(Compare(t1, t3) <= 0 ? 0 : 2) :
(Compare(t2, t3) <= 0 ? 1 : 2);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int Compare(T t1, T t2) => t1.CompareTo(t2);
}
class C : IComparable<C>
{
public int Value;
public int CompareTo(C other) => other is null ? 1 : Value.CompareTo(other.Value);
}
最小的方法比较提供的三个值并返回最小值的索引。它是泛型类型上的一个方法,它调用同一类型上的另一个方法,这个方法反过来调用泛型类型参数实例上的方法。由于基准使用C作为泛型类型,而且C是引用类型,所以JIT不会专门为C专门化此方法的代码,而是使用它生成的用于所有引用类型的“shared”实现。为了让Compare方法随后调用到CompareTo的正确接口实现,共享泛型实现使用了一个从泛型类型映射到正确目标的字典。在. net的早期版本中,包含那些通用字典查找的方法是不可行的,这意味着这个最小的方法不能内联它所做的三个比较调用,即使Compare被归为methodimploptions .侵略化的内联。前面提到的PR消除了这个限制,在这个例子中产生了一个非常可测量的加速(并使数组排序回归修复可行):
Method | Runtime | Mean | Ratio |
---|---|---|---|
Compare | .NET FW 4.8 | 8.632 ns | 1.00 |
Compare | .NET Core 3.1 | 9.259 ns | 1.07 |
Compare | .NET 5.0 | 5.282 ns | 0.61 |
这里提到的大多数改进都集中在吞吐量上,JIT产生的代码执行得更快,而更快的代码通常(尽管不总是)更小。从事JIT工作的人们实际上非常关注代码大小,在许多情况下,将其作为判断更改是否有益的主要指标。更小的代码并不总是更快的代码(可以是相同大小的指令,但开销不同),但从高层次上来说,这是一个合理的度量,更小的代码确实有直接的好处,比如对指令缓存的影响更小,需要加载的代码更少,等等。在某些情况下,更改完全集中在减少代码大小上,比如在出现不必要的重复的情况下。考虑一下这个简单的基准:
private int _offset = 0;
[Benchmark]
public int Throw helpers()
{
var arr = new int[10];
var s0 = new Span<int>(arr, _offset, 1);
var s1 = new Span<int>(arr, _offset + 1, 1);
var s2 = new Span<int>(arr, _offset + 2, 1);
var s3 = new Span<int>(arr, _offset + 3, 1);
var s4 = new Span<int>(arr, _offset + 4, 1);
var s5 = new Span<int>(arr, _offset + 5, 1);
return s0[0] + s1[0] + s2[0] + s3[0] + s4[0] + s5[0];
}
Span
M00_L00:
call System.ThrowHelper.ThrowArgumentOutOfRangeException()
int 3
M00_L01:
call System.ThrowHelper.ThrowArgumentOutOfRangeException()
int 3
M00_L02:
call System.ThrowHelper.ThrowArgumentOutOfRangeException()
int 3
M00_L03:
call System.ThrowHelper.ThrowArgumentOutOfRangeException()
int 3
M00_L04:
call System.ThrowHelper.ThrowArgumentOutOfRangeException()
int 3
M00_L05:
call System.ThrowHelper.ThrowArgumentOutOfRangeException()
int 3
在.NET 5中,感谢dotnet/coreclr#27113, JIT能够识别这种重复,而不是所有的6个呼叫站点,它将最终合并成一个:
M00_L00:
call System.ThrowHelper.ThrowArgumentOutOfRangeException()
int 3
所有失败的检查都跳到这个共享位置,而不是每个都有自己的副本
Method | Runtime | Code Size |
---|---|---|
Throw helpers | .NET FW 4.8 | 424 B |
Throw helpers | .NET Core 3.1 | 252 B |
Throw helpers | .NET 5.0 | 222 B |
这些只是.NET 5中对JIT进行的众多改进中的一部分。还有许多其他改进。dotnet/runtime#32368导致JIT将数组的长度视为无符号,这使得JIT能够对在长度上执行的某些数学运算(例如除法)使用更好的指令。 dotnet/runtime#25458 使JIT可以对某些无符号整数运算使用更快的基于0的比较。 当开发人员实际编写> = 1时,使用等于!= 0的值。dotnet/runtime#1378允许JIT将“ constantString” .Length识别为常量值。 dotnet/runtime#26740 通过删除nop填充来减小ReadyToRun图像的大小。 dotnet/runtime#330234使用加法而不是乘法来优化当x为浮点数或双精度数时执行x * 2时生成的指令。dotnet/runtime#27060改进了为Math.FusedMultiplyAdd内部函数生成的代码。 dotnet/runtime#27384通过使用比以前更好的篱笆指令使ARM64上的易失性操作便宜,并且dotnet/runtime#38179在ARM64上执行窥视孔优化以删除大量冗余mov指令。 等等。
JIT中还有一些默认禁用的重要更改,目的是获得关于它们的真实反馈,并能够在默认情况下post-启用它们。净5。例如,dotnet/runtime#32969提供了“On Stack Replacement”(OSR)的初始实现。我在前面提到了分层编译,它使JIT能够首先为一个方法生成优化最少的代码,然后当该方法被证明是重要的时,用更多的优化重新编译该方法。这允许代码运行得更快,并且只有在运行时才升级有效的方法,从而实现更快的启动时间。但是,分层编译依赖于替换实现的能力,下次调用它时,将调用新的实现。但是长时间运行的方法呢?对于包含循环(或者,更具体地说,向后分支)的方法,分层编译在默认情况下是禁用的,因为它们可能会运行很长时间,以至于无法及时使用替换。OSR允许方法在执行代码时被更新,而它们是“在堆栈上”的;PR中包含的设计文档中有很多细节(也与分层编译有关,dotnet/runtime#1457改进了调用计数机制,分层编译通过这种机制决定哪些方法应该重新编译以及何时重新编译)。您可以通过将COMPlus_TC_QuickJitForLoops和COMPlus_TC_OnStackReplacement环境变量设置为1来试验OSR。另一个例子是,dotnet/runtime#1180 改进了try块内代码的生成代码质量,使JIT能够在寄存器中保存以前不能保存的值。您可以通过将COMPlus_EnableEHWriteThr环境变量设置为1来进行试验。
还有一堆等待拉请求JIT尚未合并,但很可能在.NET 5发布(除此之外,我预计还有更多在.NET 5发布之前还没有发布的内容)。例如,dotnet/runtime#32716允许JIT替换一些分支比较,如a == 42 ?3: 2无分支实现,当硬件无法正确预测将采用哪个分支时,可以帮助提高性能。或dotnet/runtime#37226,它允许JIT采用像“hello”[0]这样的模式并将其替换为h;虽然开发人员通常不编写这样的代码,但在涉及内联时,这可以提供帮助,通过将常量字符串传递给内联的方法,并将其索引到常量位置(通常在长度检查之后,由于dotnet/runtime#1378,长度检查也可以成为常量)。或dotnet/runtime#1224,它改进了Bmi2的代码生成。MultiplyNoFlags内在。或者dotnet/runtime#37836,它将转换位操作。将PopCount转换为一个内因,使JIT能够识别何时使用常量参数调用它,并将整个操作替换为一个预先计算的常量。或dotnet/runtime#37254,它删除使用const字符串时发出的空检查。或者来自@damageboy的dotnet/runtime#32000 ,它优化了双重否定。
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 = 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 = 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/