.NET 7 中的性能改进(十三)

原文 | Stephen Toub

翻译 | 郑子铭

代码生成 (Code generation)

.NET 7的regex实现有不少于四个引擎:解释器(如果你不明确选择其他引擎,你会得到什么),编译器(你用RegexOptions.Compiled得到什么),非回溯引擎(你用RegexOptions.NonBacktracking得到什么),以及源生成器(你用[GeneratedRegex(..)]得到什么)。解释器和非反向追踪引擎不需要任何类型的代码生成;它们都是基于创建内存中的数据结构,表示如何根据模式匹配输入。不过,其他两个都会生成特定于模式的代码;生成的代码试图模仿你可能写的代码,如果你根本不使用Regex,而是直接写代码来执行类似的匹配。源码生成器吐出的是直接编译到你的汇编中的C#,而编译器在运行时通过反射emit吐出IL。这些都是针对模式生成的代码,这意味着有大量的机会可以优化。.

dotnet/runtime#59186提供了源代码生成器的初始实现。这是编译器的直接移植,有效地将IL逐行翻译成C#;结果是C#,类似于你通过ILSpy等反编译器运行生成的IL。一系列的PR接着对源码生成器进行了迭代和调整,但最大的改进来自于对编译器和源码生成器的共同改变。在.NET 5之前,编译器吐出的IL与解释器的工作非常相似。解释器收到了一系列指令,它逐一进行解释,而编译器收到了同样的一系列指令,只是发出了处理每个指令的IL。它有一些提高效率的机会,如循环解卷,但很多价值被留在了桌子上。在.NET 5中,为了支持没有回溯的模式,增加了另一种路径;这种代码路径是基于被解析的节点树,而不是基于一系列的指令,这种更高层次的形式使编译器能够获得更多关于模式的见解,然后可以用来生成更有效的代码。在.NET 7中,对所有regex特性的支持都是在多个PR的过程中逐步加入的,特别是dotnet/runtime#60385用于回溯单字符循环,dotnet/runtime#61698用于回溯单字符懒惰循环,dotnet/runtime#61784用于其他回溯懒惰循环,dotnet/runtime#61906用于其他回溯循环以及回引和条件。在这一点上,唯一缺少的功能是对RegexOptions.RightToLeft和lookbehinds的支持(这是以从右到左的方式实现的),而且我们根据这些功能相对较少的使用情况决定,我们没有必要为了启用它们而保留旧的编译器代码。所以,dotnet/runtime#62318删除了旧的实现。但是,尽管这些功能相对较少,但说一个 "支持所有模式 "的故事比说一个需要特殊调用和异常的故事要容易得多,所以dotnet/runtime#66127和dotnet/runtime#66280添加了完整的lookbehind和RightToLeft支持,这样就不会有回溯了。在这一点上,编译器和源代码生成器现在都支持编译器以前所做的一切,但现在有了更现代化的代码生成。这种代码生成反过来又使之前讨论的许多优化成为可能,例如,它提供了使用LastIndexOf等API作为回溯的一部分的机会,这在以前的方法中几乎是不可能的。

源码生成器发出成语C#的好处之一是它使迭代变得容易。每次你输入一个模式并看到生成器发出的东西,就像被要求对别人的代码进行审查一样,你经常看到一些值得评论的 "新 "东西,或者在这种情况下,改进生成器以解决这个问题。因此,一堆PR的起源是基于审查生成器发出的东西,然后调整生成器以做得更好(由于编译器实际上是和源生成器一起完全重写的,它们保持相同的结构,很容易从一个移植到另一个的改进)。例如,dotnet/runtime#68846和dotnet/runtime#69198调整了一些比较的执行方式,以便向JIT传达足够的信息,从而消除一些后续的边界检查,dotnet/runtime#68490识别了在一些可静态观察的情况下不可能发生的各种条件,并能够消除所有这些代码基因。同样明显的是,有些模式不需要扫描循环的全部表现力,可以使用更紧凑和定制的扫描实现。dotnet/runtime#68560做到了这一点,例如,像hello这样的简单模式根本不会发出一个循环,而会有一个更简单的扫描实现,比如。

protected override void Scan(ReadOnlySpan<char> inputSpan)
{
    if (TryFindNextPossibleStartingPosition(inputSpan))
    {
        // The search in TryFindNextPossibleStartingPosition performed the entire match.
        int start = base.runtextpos;
        int end = base.runtextpos = start + 5;
        base.Capture(0, start, end);
    }
}

例如,dotnet/runtime#63277教源码生成器如何确定是否允许使用不安全的代码,如果允许,它会为核心逻辑发出[SkipLocalsInit];匹配例程可能导致许多locals被发出,而SkipLocalsInit可以使调用函数的成本降低,因为需要更少的归零。然后还有代码生成的地方的问题;我们希望辅助函数(像dotnet/runtime#62620中介绍的IsWordChar辅助函数)可以在多个生成的regex中共享,如果相同的模式/选项/超时组合在同一个程序集的多个地方使用,我们希望能够共享完全相同的regex实现(dotnet/runtime#66747),但这样做会使这个实现细节暴露给同一个程序集的用户代码。为了仍然能够获得这种代码共享的好处,同时避免由此产生的复杂情况,dotnet/runtime#66432,然后dotnet/runtime#71765教源码生成器使用C#11中新的文件本地类型特性(dotnet/roslyn#62375)。

最后一个有趣的代码生成方面是围绕字符类匹配进行的优化。匹配字符类,无论是开发者明确编写的字符类,还是引擎隐含创建的字符类(例如,作为寻找可以开始表达式的所有字符集的一部分),都可能是匹配中比较耗时的一个方面;如果你想象一下必须对输入的每个字符评估这个逻辑,那么作为匹配字符类的一部分,需要执行多少条指令直接关系到执行整个匹配的时间。例如,dotnet/runtime#67365改进了一些在现实世界中常见的情况,比如特别识别[\d\D]、[\s\S]和[\w\W]这样的集合意味着 "匹配任何东西"(就像RegexOptions.Singleline模式中的.一样),在这种情况下,围绕处理 "匹配任何东西 "的现有优化可以启动。

private static readonly string s_haystack = new string('a', 1_000_000);
private Regex _regex = new Regex(@"([\s\S]*)", RegexOptions.Compiled);

[Benchmark]
public Match Match() => _regex.Match(s_haystack);
方法 运行时 平均值 比率
Match .NET 6.0 1,934,393.69 ns 1.000
Match .NET 7.0 91.80 ns 0.000

或者dotnet/runtime#68924,它教源码生成器如何在生成的输出中使用所有新的char ASCII辅助方法,如char.IsAsciiLetterOrDigit,以及一些它还不知道的现有辅助方法;例如这样。

[GeneratedRegex(@"[A-Za-z][A-Z][a-z][0-9][A-Za-z0-9][0-9A-F][0-9a-f][0-9A-Fa-f]\p{Cc}\p{L}[\p{L}\d]\p{Ll}\p{Lu}\p{N}\p{P}\p{Z}\p{S}")]

现在,在源生成器发出的核心匹配逻辑中产生这种情况。

if ((uint)slice.Length < 17 ||
    !char.IsAsciiLetter(slice[0]) || // Match a character in the set [A-Za-z].
    !char.IsAsciiLetterUpper(slice[1]) || // Match a character in the set [A-Z].
    !char.IsAsciiLetterLower(slice[2]) || // Match a character in the set [a-z].
    !char.IsAsciiDigit(slice[3]) || // Match '0' through '9'.
    !char.IsAsciiLetterOrDigit(slice[4]) || // Match a character in the set [0-9A-Za-z].
    !char.IsAsciiHexDigitUpper(slice[5]) || // Match a character in the set [0-9A-F].
    !char.IsAsciiHexDigitLower(slice[6]) || // Match a character in the set [0-9a-f].
    !char.IsAsciiHexDigit(slice[7]) || // Match a character in the set [0-9A-Fa-f].
    !char.IsControl(slice[8]) || // Match a character in the set [\p{Cc}].
    !char.IsLetter(slice[9]) || // Match a character in the set [\p{L}].
    !char.IsLetterOrDigit(slice[10]) || // Match a character in the set [\p{L}\d].
    !char.IsLower(slice[11]) || // Match a character in the set [\p{Ll}].
    !char.IsUpper(slice[12]) || // Match a character in the set [\p{Lu}].
    !char.IsNumber(slice[13]) || // Match a character in the set [\p{N}].
    !char.IsPunctuation(slice[14]) || // Match a character in the set [\p{P}].
    !char.IsSeparator(slice[15]) || // Match a character in the set [\p{Z}].
    !char.IsSymbol(slice[16])) // Match a character in the set [\p{S}].
{
    return false; // The input didn't match.
}

其他影响字符类代码生成的变化包括:dotnet/runtime#72328,它改进了对涉及字符类减法的字符类的处理;来自@teo-tsirpanis的dotnet/runtime#72317,它使生成器可以避免发出位图查找的额外情况。dotnet/runtime#67133,它增加了一个更严格的边界检查,当它确实发出这样一个查找表时;以及 dotnet/runtime#61562,它使引擎内部表示中的字符类得到更好的规范化,从而导致下游的优化更好地识别更多的字符类。

最后,随着所有这些对Regex的改进,大量的PR以各种方式修复了在dotnet/runtime中使用的Rgex。 dotnet/runtime#66142,来自@Clockwork-Muse的dotnet/runtime#66179,以及来自@Clockwork-Muse的dotnet/runtime#62325都将Regex的使用转为使用[GeneratedRegex(..)]。dotnet/runtime#68961以各种方式优化了其他用法。PR用IsMatch(...)替换了几个regex.Matches(...).Success的调用,因为使用IsMatch的开销较少,因为不需要构建Match实例,而且能够避免非回溯引擎中计算精确边界和捕获信息的昂贵阶段。PR还用EnumerateMatches替换了一些Match/Match.MoveNext的使用,以避免需要Match对象的分配。公报还完全删除了至少一个与更便宜的IndexOf一样的铰链用法。 dotnet/runtime#68766还删除了RegexOptions.CultureInvariant的用法。指定CultureInvariant会改变IgnoreCase的行为,即交替使用大小写表;如果没有指定IgnoreCase,也没有内联的大小写敏感选项((?i)),那么指定CultureInvariant就是一个nop。但这有可能是一个昂贵的选择。对于任何注重规模的代码来说,Regex实现的结构方式是尽量使其对小规模用户友好。如果你只做new Regex(pattern),我们真的希望能够静态地确定编译器和非反向追踪的实现是不需要的,这样修剪者就可以删除它而不产生可见的和有意义的负面影响。然而,修剪器的分析还没有复杂到可以准确地看到哪些选项被使用,并且只在使用RegexOptions.Compiled或RegexOptions.NonBacktracking时保留额外的引擎链接;相反,任何使用需要RegexOptions的重载都会导致该代码继续被引用。通过摆脱这些选项,我们增加了应用程序中没有代码使用这个构造函数的机会,这反过来会使这个构造函数、编译器和非回溯实现被裁剪掉。

集合 (Collections)

System.Collections在.NET 7中的投资并没有像以前的版本那样多,尽管许多低级别的改进也对集合产生了涓滴效应。例如,Dictionary<,>的代码在.NET 6和.NET 7之间没有变化,但即便如此,这个基准还是集中在字典的查找上。

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;
}

显示出.NET 6和.NET 7之间的吞吐量有可观的改善。

方法 运行时 平均值 比率 代码大小
Sum .NET 6.0 51.18 us 1.00 431 B
Sum .NET 7.0 43.44 us 0.85 413 B

除此之外,在集合的其他地方也有明确的改进。例如,ImmutableArray。作为提醒,ImmutableArray是一个非常薄的基于结构的包装,围绕着T[],隐藏了T[]的可变性;除非你使用不安全的代码,否则ImmutableArray的长度和浅层内容都不会改变(我说的浅层是指直接存储在该数组中的数据不能被改变,但如果数组中存储有可变参考类型,这些实例本身仍然可能有其数据被改变)。因此,ImmutableArray也有一个相关的 "builder "类型,它确实支持突变:你创建builder,填充它,然后将内容转移到ImmutableArray中,它就永远冻结了。在来自@grbell-ms的dotnet/runtime#70850中,构建器的排序方法被改为使用span,这又避免了IComparer分配和Comparison分配,同时还通过从每个比较中移除几层间接因素来加快排序本身。

private ImmutableArray<int>.Builder _builder = ImmutableArray.CreateBuilder<int>();

[GlobalSetup]
public void Setup()
{
    _builder.AddRange(Enumerable.Range(0, 1_000));
}

[Benchmark]
public void Sort()
{
    _builder.Sort((left, right) => right.CompareTo(left));
    _builder.Sort((left, right) => left.CompareTo(right));
}
方法 运行时 平均值 比率
Sort .NET 6.0 86.28 us 1.00
Sort .NET 7.0 67.17 us 0.78

dotnet/runtime#61196来自@lateapexearlyspeed,它将ImmutableArray带入了基于span的时代,为ImmutableArray添加了大约10个新方法,这些方法与span和ReadOnlySpan互操作。从性能的角度来看,这些方法很有价值,因为它意味着如果你在span中拥有你的数据,你可以将其放入ImmutableArray中,而不会产生除ImmutableArray本身将创建的分配之外的额外分配。来自@RaymondHuy的dotnet/runtime#66550也为不可变集合构建器添加了一堆新方法,为替换元素和添加、插入和删除范围等操作提供了高效的实现。

SortedSet在.NET 7中也有一些改进。例如,SortedSet内部使用红/黑树作为其内部数据结构,它使用Log2操作来确定在给定节点数下树的最大深度。以前,这个操作是作为一个循环实现的。但由于@teo-tsirpanis的dotnet/runtime#58793,该操作现在只需调用BitOperations.Log2,如果支持多个硬件本征(例如Lzcnt.LeadingZeroCount、ArmBase.LeadingZeroCount、X86Base.BitScanReverse),则可通过这些本征实现。来自@johnthcall的dotnet/runtime#56561通过简化处理树中节点的迭代方式,提高了SortedSet的复制性能。

[Params(100)]
public int Count { get; set; }

private static SortedSet<string> _set;

[GlobalSetup]
public void GlobalSetup()
{
    _set = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
    for (int i = 0; i < Count; i++)
    {
        _set.Add(Guid.NewGuid().ToString());
    }
}

[Benchmark]
public SortedSet<string> SortedSetCopy()
{
    return new SortedSet<string>(_set, StringComparer.OrdinalIgnoreCase);
}
方法 运行时 平均值 比率
SortedSetCopy .NET 6.0 2.397 us 1.00
SortedSetCopy .NET 7.0 2.090 us 0.87

最后一个要看的集合的PR:dotnet/runtime#67923。ConditionalWeakTable<TKey, TValue>是一个大多数开发者没有使用过的集合,但是当你需要它时,你就需要它。它主要用于两个目的:将额外的状态与一些对象相关联,以及维护对象的弱集合。从本质上讲,它是一个线程安全的字典,不维护它所存储的任何东西的强引用,但确保与一个键相关的值将保持根基,只要相关的键是根基的。它暴露了许多与 ConcurrentDictionary<,> 相同的 API,但是对于向集合中添加项目,它历来只有一个 Add 方法。这意味着如果消费代码的设计需要尝试将集合作为一个集合,其中重复是很常见的,当尝试添加一个已经存在于集合中的项目时,也会经常遇到异常。现在,在.NET 7中,它有一个TryAdd方法,可以实现这样的使用,而不可能产生这种异常的代价(也不需要添加try/catch块来抵御这些异常)。

语言集成查询 (LINQ)

让我们继续讨论语言集成查询 (Language-Integrated Query )(LINQ)。LINQ是一个几乎每个.NET开发者都会使用的生产力特性。它使复杂的操作能够被简单地表达出来,无论是通过语言集成查询语法还是通过直接使用System.Linq.Enumerable上的方法。然而,这种生产力和表现力是以一定的开销为代价的。在绝大多数情况下,这些成本(如委托和闭包分配、委托调用、在任意枚举对象上使用接口方法与直接访问索引器和长度/计数属性等)不会产生重大影响,但对于真正的热点路径,它们可以而且确实以一种有意义的方式出现。这导致一些人宣布LINQ在他们的代码库中是被广泛禁止的。在我看来,这是一种误导;LINQ是非常有用的,有它的位置。在.NET中,我们使用了LINQ,只是在使用的地方上比较实际和周到,避免在我们已经优化为轻量级和快速的代码路径中使用它,因为预期这些代码路径可能对消费者很重要。因此,虽然LINQ本身的性能可能不如手工滚动的解决方案那么快,但我们仍然非常关心LINQ的实现性能,以便它能在越来越多的地方被使用,并且在使用它的地方尽可能地减少开销。在LINQ的操作之间也有差异;有200多个提供各种功能的重载,其中一些重载根据其预期用途,比其他重载受益于更多的性能调整。

dotnet/runtime#64470是分析各种现实世界代码库中Enumerable.Min和Enumerable.Max使用情况的结果,并看到在数组中使用这些代码是非常普遍的,通常是那些相当大的数组。这个PR更新了Min(IEnumerable)和Max(IEnumerable)的重载,当输入是int[]或long[]时,使用Vector进行矢量处理。这样做的净效果是,对于较大的数组来说,执行时间明显加快,但即使对于短的数组来说,性能仍有提高(因为现在实现能够直接访问数组,而不是通过enumerable,导致更少的分配和接口调度,以及更适用的优化,如内联)。

[Params(4, 1024)]
public int Length { get; set; }

private IEnumerable<int> _source;

[GlobalSetup]
public void Setup() => _source = Enumerable.Range(1, Length).ToArray();

[Benchmark]
public int Min() => _source.Min();

[Benchmark]
public int Max() => _source.Max();
方法 运行时 长度 平均值 比率 已分配 分配比率
Min .NET 6.0 4 26.167 ns 1.00 32 B 1.00
Min .NET 7.0 4 4.788 ns 0.18 0.00
             
Max .NET 6.0 4 25.236 ns 1.00 32 B 1.00
Max .NET 7.0 4 4.234 ns 0.17 0.00
             
Min .NET 6.0 1024 3,987.102 ns 1.00 32 B 1.00
Min .NET 7.0 1024 101.830 ns 0.03 0.00
             
Max .NET 6.0 1024 3,798.069 ns 1.00 32 B 1.00
Max .NET 7.0 1024 100.279 ns 0.03 0.00

然而,PR的一个更有趣的方面是,有一行是为了帮助处理非数组的情况。在性能优化中,特别是在增加 "快速路径 "以更好地处理某些情况时,几乎总是有一个赢家和一个输家:赢家是优化所要帮助的情况,而输家是所有其他的情况,这些情况在确定是否采取改进的路径时受到必要的检查。一个对数组进行特殊处理的优化,通常看起来像。

if (source is int[] array)
{
    ProcessArray(array);
}
else
{
    ProcessEnumerable(source);
}

然而,如果你看一下PR,你会发现if条件实际上是。

if (source.GetType() == typeof(int[]))

怎么会呢?在代码流程中的这一点上,我们知道source不是空的,所以我们不需要额外的空检查。然而,这与真正的影响相比是次要的,那就是对数组协方差的支持。你可能会惊讶地发现,除了int[]之外,还有一些类型可以满足source is int的检查......试着运行Console.WriteLine((object)new uint[42] is int[]);,你会发现它打印出True。(这也是.NET运行时和C#语言在类型系统方面存在分歧的罕见情况。如果你把Console.WriteLine((object)new uint[42] is int[]);改为Console.WriteLine(new uint[42] is int[]);,也就是去掉(object)的转换,你会发现它开始打印出False而不是True。这是因为C#编译器认为uint[]不可能成为int[],因此将检查完全优化为常数false)。因此,作为类型检查的一部分,运行时不得不做更多的工作,而不仅仅是与int[]的已知类型身份进行简单的比较。我们可以通过查看为这两个方法生成的程序集看到这一点(后者假设我们已经对输入进行了空值检查,在这些LINQ方法中是这样的)。

public IEnumerable<object> Inputs { get; } = new[] { new object() };

[Benchmark]
[ArgumentsSource(nameof(Inputs))]
public bool M1(object o) => o is int[];

[Benchmark]
[ArgumentsSource(nameof(Inputs))]
public bool M2(object o) => o.GetType() == typeof(int[]);

这就造成了。

; Program.M1(System.Object)
       sub       rsp,28
       mov       rcx,offset MT_System.Int32[]
       call      qword ptr [System.Runtime.CompilerServices.CastHelpers.IsInstanceOfAny(Void*, System.Object)]
       test      rax,rax
       setne     al
       movzx     eax,al
       add       rsp,28
       ret
; Total bytes of code 34

; Program.M2(System.Object)
       mov       rax,offset MT_System.Int32[]
       cmp       [rdx],rax
       sete      al
       movzx     eax,al
       ret
; Total bytes of code 20

注意前者涉及到对JIT的CastHelpers.IsInstanceOfAny辅助方法的调用,而且它没有被内联。这反过来又影响了性能。

private IEnumerable<int> _source = (int[])(object)new uint[42];

[Benchmark(Baseline = true)]
public bool WithIs() => _source is int[];

[Benchmark]
public bool WithTypeCheck() => _source.GetType() == typeof(int[]);
方法 平均值 比率 代码大小
WithIs 1.9246 ns 1.000 215 B
WithTypeCheck 0.0013 ns 0.001 24 B

当然,这两种操作在语义上并不等同,所以如果这是为需要前者语义的东西,我们就不能使用后者。但是在这个LINQ性能优化的案例中,我们可以选择只优化int[]的情况,放弃int[]实际上是uint[](或者例如DayOfWeek[])这种超级罕见的情况,并将优化IEnumerable输入而不是int[]的性能惩罚降到最低,只用几条快速指令。

这一改进在dotnet/runtime#64624中得到了进一步的发展,它扩大了支持的输入类型和利用的操作。首先,它引入了一个私有助手,用于从某些类型的IEnumerable输入中提取ReadOnlySpan,即今天那些实际上是T[]或List的输入;与之前的PR一样,它使用GetType() == typeof(T[])形式,以避免对其他输入的显著惩罚。这两种类型都能为实际的存储提取ReadOnlySpan,在T[]的情况下是通过转换,在List的情况下是通过.NET 5中引入的CollectionsMarshal.AsSpan方法。一旦我们有了这个跨度,我们就可以做一些有趣的事情。这个PR。

  • 扩展了之前的Min

    (IEnumerable)和Max(IEnumerable)优化,不仅适用于int[]和long[],也适用于List和List。
  • 为Average

    (IEnumerable)和Sum(IEnumerable)使用直接跨距访问,适用于int、long、float、double或decimal,所有数组和列表。
  • 类似地,对Min

    (IEnumerable)和Max(IEnumerable)使用直接的跨度访问,适用于T是浮点数、双数和小数。
  • 对数组和列表的Average

    (IEnumerable)进行矢量化。

这方面的影响在微观基准中是很明显的,比如说

private static float[] CreateRandom()
{
    var r = new Random(42);
    var results = new float[10_000];
    for (int i = 0; i < results.Length; i++)
    {
        results[i] = (float)r.NextDouble();
    }
    return results;
}

private IEnumerable<float> _floats = CreateRandom();

[Benchmark]
public float Sum() => _floats.Sum();

[Benchmark]
public float Average() => _floats.Average();

[Benchmark]
public float Min() => _floats.Min();

[Benchmark]
public float Max() => _floats.Max();
方法 运行时 平均值 比率 已分配 分配比率
Sum .NET 6.0 39.067 us 1.00 32 B 1.00
Sum .NET 7.0 14.349 us 0.37 0.00
           
Average .NET 6.0 41.232 us 1.00 32 B 1.00
Average .NET 7.0 14.378 us 0.35 0.00
           
Min .NET 6.0 45.522 us 1.00 32 B 1.00
Min .NET 7.0 9.668 us 0.21 0.00
           
Max .NET 6.0 41.178 us 1.00 32 B 1.00
Max .NET 7.0 9.210 us 0.22 0.00

之前的LINQ PR是来自于使现有操作更快的例子。但有时性能的提高来自于新的API,这些API在某些情况下可以用来代替以前的API,以进一步提高性能。一个这样的例子来自于@deeprobin在dotnet/runtime#70525中引入的新的API,然后在dotnet/runtime#71564中得到了改进。LINQ中最流行的方法之一是Enumerable.OrderBy(及其逆序OrderByDescending),它可以创建一个输入枚举的排序副本。为此,调用者向OrderBy传递一个Func<TSource,TKey>谓词,OrderBy用它来提取每个项目的比较键。然而,想要以自己为键对项目进行排序是比较常见的;这毕竟是Array.Sort等方法的默认值,在这种情况下,OrderBy的调用者最终会传入一个身份函数,例如OrderBy(x => x)。为了消除这个障碍,.NET 7引入了新的Order和OrderDescending方法,根据Distinct和DistinctBy等对的精神,执行同样的排序操作,只是隐含了一个代表调用者的x => x。但除了性能之外,这样做的一个好处是,实现者知道键将与输入相同,它不再需要为每个项目调用回调以检索其键,也不需要分配一个新的数组来存储这些键。因此,如果你发现自己在使用LINQ,并达到OrderBy(x => x),考虑使用Order(),并获得(主要是分配)的好处。

[Params(1024)]
public int Length { get; set; }

private int[] _arr;

[GlobalSetup]
public void Setup() => _arr = Enumerable.Range(1, Length).Reverse().ToArray();

[Benchmark(Baseline = true)]
public void OrderBy()
{
    foreach (int _ in _arr.OrderBy(x => x)) { }
}

[Benchmark]
public void Order()
{
    foreach (int _ in _arr.Order()) { }
}
方法 长度 平均值 比率 已分配 分配比率
OrderBy 1024 68.74 us 1.00 12.3 KB 1.00
Order 1024 66.24 us 0.96 8.28 KB 0.67

文件输入输出 (File I/O)

.NET 6有一些巨大的文件I/O改进,特别是对FileStream进行了完全重写。虽然.NET 7没有任何单一的变化,但它确实有大量的改进,可衡量的 "移动针",而且是以不同的方式。

性能改进的一种形式也被伪装成可靠性改进,就是提高对取消请求的响应速度。取消的速度越快,系统就能越快地归还正在使用的宝贵资源,等待该操作完成的事情也就能越快地被解禁。在.NET 7中已经有了一些类似的改进。

在某些情况下,它来自于添加了可取消的重载,而这些东西以前根本就不是可取消的。来自@bgrainger的dotnet/runtime#61898就是这种情况,它添加了TextReader.ReadLineAsync和TextReader.ReadToEndAsync的新的可取消重载,这包括这些方法在StreamReader和StringReader上的重载;来自@bgrainger的dotnet/runtime#64301又在TextReader返回的NullStreamReader类型上重载了这些方法(以及其他缺少重载)。 Null和StreamReader.Null(有趣的是,这些被定义为两种不同的类型,这是不必要的,所以这个PR也统一了让两者都使用StreamReader的变体,因为它满足了两者所需的类型)。你可以在dotnet/runtime#66492中看到这一点被很好地利用,它来自@lateapexearlyspeed,它添加了一个新的File.ReadLinesAsync方法。这个方法产生一个文件中的行的IAsyncEnumerable,基于一个围绕新的StreamReader.ReadLineAsync重载的简单循环,因此本身是完全可取消的。

不过,从我的角度来看,更有趣的形式是当一个现有的重载据称是可取消的,但实际上不是。例如,基本的Stream.ReadAsync方法只是包装了Stream.BeginRead/EndRead方法,而这些方法是不可取消的,所以如果一个Stream派生类型没有覆盖ReadAsync,试图取消对其ReadAsync的调用将是非常有效的。它对取消进行了预先检查,如果在调用之前请求取消,它将被立即取消,但在检查之后,提供的CancellationToken将被有效地忽略。随着时间的推移,我们已经试图消除所有剩余的这种情况,但仍有一些零星的情况存在。一个有害的情况是关于管道的。在这次讨论中,有两种相关的管道,匿名的和命名的,它们在.NET中被表示为一对流。AnonymousPipeClientStream/AnonymousPipeServerStream和NamedPipeClientStream/NamedPipeServerStream。另外,在Windows上,操作系统对为同步I/O打开的句柄和为重叠I/O(又称异步I/O)打开的句柄进行了区分,这在.NET API中得到了反映:你可以根据构造时指定的PipeOptions.Asynchronous选项为同步或重叠I/O打开一个命名管道。而且,在Unix上,命名的管道,与它们的命名相反,实际上是在Unix域套接字之上实现的。现在是一些历史。

  • .NET框架4.8:没有取消支持。管道流派生类型甚至没有覆盖ReadAsync或WriteAsync,所以它们得到的只是默认的取消的前期检查,然后标记被忽略。

  • .NET Core 1.0。在Windows上,通过为异步I/O打开一个命名的管道,完全支持取消。该实现将注册CancellationToken,并在取消请求时,对与异步操作相关的NativeOverlapped*使用CancelIoEx。在Unix上,用套接字实现的命名管道,如果管道是用PipeOptions.Asynchronous打开的,实现将通过轮询来模拟取消:而不是简单地发出Socket.ReceiveAsync/Socket.SendAsync(这是不可能的)。 SendAsync(当时不能取消),它将排队一个工作项目到ThreadPool,该工作项目将运行一个轮询循环,用一个小的超时来调用Socket.Poll,检查令牌,然后循环再做,直到Poll显示操作将成功或被请求取消。在Windows和Unix上,除了用Asynchronous打开的命名管道外,在操作被启动后,取消是一个nop。

  • .NET Core 2.1。在Unix上,该实现被改进以避免轮询循环,但它仍然缺乏一个真正可取消的Socket.ReceiveAsync/Socket.SendAsync。相反,此时Socket.ReceiveAsync支持零字节读取,调用者可以将一个零长度的缓冲区传递给ReceiveAsync,并将其作为数据可用的通知,而无需实际消费。然后,Unix的异步命名管道流的实现改变为发出零字节的读取,并将等待该操作的任务和请求取消时将完成的任务的Task.WhenAny。好多了,但离理想还很远。

  • .NET Core 3.0。在Unix上,Socket得到了真正可取消的ReceiveAsync和SendAsync方法,异步命名管道被更新为利用。在这一点上,Windows和Unix的实现在取消方面是一致的;两者都适合于异步命名的管道,而对其他一切都只是摆设。

  • .NET 5:在Unix上,SafeSocketHandle被公开了,它可以为一个任意提供的SafeSocketHandle创建一个Socket,这使得创建的Socket实际上是指一个匿名管道。这使得Unix上的每一个PipeStream都可以用Socket来实现,这使得ReceiveAsync/SendAsync对于匿名和命名的管道都可以完全取消,而不管它们是如何被打开的。

所以到了.NET 5,这个问题在Unix上得到了解决,但在Windows上仍然是个问题。直到现在。在.NET 7中,由于dotnet/runtime#72503(以及随后在dotnet/runtime#72612中的调整),我们已经使其余的操作在Windows上也可以完全取消。目前,Windows不支持匿名管道的重叠I/O,所以对于匿名管道和为同步I/O打开的命名管道,Windows的实现将只是委托给基本的Stream实现,它将向ThreadPool排队一个工作项,以调用同步对应项,只是在另一个线程。取而代之的是,现在的实现会排队等待工作项,但不是仅仅调用同步方法,而是做一些注册取消的前后工作,传入即将执行I/O的线程的ID。如果请求取消,实现就会使用CancelSynchronousIo来中断它。这里有一个竞赛条件,即当线程注册取消时,可以请求取消,这样CancelSynchronousIo就会在操作实际开始前被调用。因此,有一个小的自旋循环,如果在注册发生的时间和实际执行同步I/O的时间之间有取消请求,取消线程将自旋,直到I/O被启动,但这种情况预计会非常罕见。另一边还有一个竞赛条件,即CancelSynchronousIo在I/O已经完成后被请求;为了解决这个竞赛,该实现依赖于CancellationTokenRegistration.Dispose的保证,它承诺相关的回调将永远不会被调用或在Dispose返回时已经完全执行完毕。这个实现不仅完成了拼图,使Windows和Unix的匿名和命名管道上的所有异步读/写操作都可以取消,而且实际上还提高了正常的吞吐量。

private Stream _server;
private Stream _client;
private byte[] _buffer = new byte[1];
private CancellationTokenSource _cts = new CancellationTokenSource();

[Params(false, true)]
public bool Cancelable { get; set; }

[Params(false, true)]
public bool Named { get; set; }

[GlobalSetup]
public void Setup()
{
    if (Named)
    {
        string name = Guid.NewGuid().ToString("N");
        var server = new NamedPipeServerStream(name, PipeDirection.Out);
        var client = new NamedPipeClientStream(".", name, PipeDirection.In);
        Task.WaitAll(server.WaitForConnectionAsync(), client.ConnectAsync());
        _server = server;
        _client = client;
    }
    else
    {
        var server = new AnonymousPipeServerStream(PipeDirection.Out);
        var client = new AnonymousPipeClientStream(PipeDirection.In, server.ClientSafePipeHandle);
        _server = server;
        _client = client;
    }
}

[GlobalCleanup]
public void Cleanup()
{
    _server.Dispose();
    _client.Dispose();
}

[Benchmark(OperationsPerInvoke = 1000)]
public async Task ReadWriteAsync()
{
    CancellationToken ct = Cancelable ? _cts.Token : default;
    for (int i = 0; i < 1000; i++)
    {
        ValueTask<int> read = _client.ReadAsync(_buffer, ct);
        await _server.WriteAsync(_buffer, ct);
        await read;
    }
}

.NET 7 中的性能改进(十三)

原文链接

Performance Improvements in .NET 7