【译】.NET 7 中的性能改进(十二)

时间:2021-08-13 00:44:13

原文 | Stephen Toub

翻译 | 郑子铭

New APIs

在.NET 7中,Regex得到了几个新的方法,所有这些方法都能提高性能。新的API的简单性可能也误导了为实现它们所需的工作量,特别是由于新的API都支持ReadOnlySpan输入到regex引擎。

dotnet/runtime#65473将Regex带入了基于跨度的.NET时代,克服了Regex自跨度在.NET Core 2.1中引入后的一个重要限制。Regex在历史上一直是基于处理System.String输入的,这一事实贯穿了Regex的设计和实现,包括在.NET Framework中依赖的扩展性模型Regex.CompileToAssembly所暴露的API(CompileToAssembly现在已经被淘汰,在.NET Core中从未发挥作用)。依赖于字符串作为输入的性质的一个微妙之处在于如何将匹配信息返回给调用者。Regex.Match返回一个Match对象,代表输入中的第一个匹配,而这个Match对象暴露了一个NextMatch方法,可以移动到下一个匹配。这意味着Match对象需要存储对输入的引用,这样它就可以作为NextMatch调用的一部分被反馈到匹配引擎。如果这个输入是一个字符串,很好,没有问题。但是如果输入的是一个ReadOnlySpan,这个跨度作为一个引用结构就不能存储在Match类对象上,因为引用结构只能在堆栈而不是堆上。仅仅这一点就使支持跨度成为一个挑战,但问题甚至更加根深蒂固。所有的 regex 引擎都依赖于 RegexRunner,它是一个基类,上面存储了所有必要的状态,以反馈给构成正则表达式实际匹配逻辑的 FindFirstChar 和 Go 方法(这些方法包含执行匹配的所有核心代码,其中 FindFirstChar 是一种优化,用于跳过不可能开始匹配的输入位置,然后 Go 执行实际匹配逻辑)。如果你看一下内部的RegexInterpreter类型,也就是当你构造一个新的Regex(...)而不使用RegexOptions.Compiled或RegexOptions.NonBacktracking标志时得到的引擎,它来源于RegexRunner。同样,当你使用RegexOptions.Compiled时,它把它反射的动态方法交给了一个从RegexRunner派生的类型,RegexOptions.NonBacktracking有一个SymbolicRegexRunnerFactory,产生从RegexRunner派生的类型,以此类推。这里最相关的是,RegexRunner是公共的,因为由Regex.CompileToAssembly类型(以及现在的regex源代码生成器)生成的类型包括从这个RegexRunner派生的类型。因此,那些FindFirstChar和Go方法是抽象的、受保护的、无参数的,因为它们从基类上受保护的成员中获取它们需要的所有状态。这包括要处理的字符串输入。那么,跨度呢?我们当然可以对一个输入的ReadOnlySpan调用ToString()。这在功能上是正确的,但却完全违背了接受跨度的目的,更糟糕的是,这可能会导致消费应用程序的性能比没有API时更差。相反,我们需要一种新的方法和新的API。

首先,我们使FindFirstChar和Go成为虚拟的,而不是抽象的。分割这些方法的设计在很大程度上是过时的,特别是强制分离了一个处理阶段,即找到匹配的下一个可能的位置,然后是在该位置实际执行匹配的阶段,这与所有的引擎并不一致,比如NonBacktracking使用的引擎(它最初将FindFirstChar作为一个nop实现,并将其所有逻辑放在Go中)。然后我们添加了一个新的虚拟扫描方法,重要的是,它需要一个ReadOnlySpan作为参数;这个span不能从基本的RegexRunner中暴露出来,必须被传递进去。然后,我们在Scan方面实现了FindFirstChar和Go,并使它们 "只是工作"。然后,所有的引擎都是以这个跨度来实现的;它们不再需要访问受保护的RegexRunner.runtext、RegexRunner.runtextbeg和RegexRunner.runtextend成员,它们只是被交给跨度,已经切成了输入区域,并进行处理。从性能的角度来看,这样做的一个好处是使JIT能够更好地消除各种开销,特别是围绕边界检查。当逻辑以字符串的形式实现时,除了输入字符串本身之外,引擎还被告知要处理的输入区域的开头和结尾(因为开发者可以调用类似Regex.Match(string input, int beginning, int length)的方法,以便只处理一个子串)。显然,引擎的匹配逻辑比这要复杂得多,但简化一下,想象一下整个引擎只是在输入上的一个循环。有了输入、开头和长度,看起来就像。

[Benchmark]
[Arguments("abc", 0, 3)]
public void Scan(string input, int beginning, int length)
{
    for (int i = beginning; i < length; i++)
    {
        Check(input[i]);
    }
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Check(char c) { }

这将导致JIT产生类似这样的汇编代码。

; Program.Scan(System.String, Int32, Int32)
       sub       rsp,28
       cmp       r8d,r9d
       jge       short M00_L01
       mov       eax,[rdx+8]
M00_L00:
       cmp       r8d,eax
       jae       short M00_L02
       inc       r8d
       cmp       r8d,r9d
       jl        short M00_L00
M00_L01:
       add       rsp,28
       ret
M00_L02:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 36

相比之下,如果我们处理的是一个跨度,它已经考虑了边界的因素,那么我们可以写一个更规范的循环,比如这样。

[Benchmark]
[Arguments("abc")]
public void Scan(ReadOnlySpan<char> input)
{
    for (int i = 0; i < input.Length; i++)
    {
        Check(input[i]);
    }
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Check(char c) { }

而当涉及到编译器时,典范形式的东西确实很好,因为代码的形状越常见,越有可能被大量优化。

; Program.Scan(System.ReadOnlySpan`1<Char>)
       mov       rax,[rdx]
       mov       edx,[rdx+8]
       xor       ecx,ecx
       test      edx,edx
       jle       short M00_L01
M00_L00:
       mov       r8d,ecx
       movsx     r8,word ptr [rax+r8*2]
       inc       ecx
       cmp       ecx,edx
       jl        short M00_L00
M00_L01:
       ret
; Total bytes of code 27

因此,即使不考虑以跨度为单位的操作所带来的其他好处,我们也能从以跨度为单位执行所有的逻辑中立即获得低级别的代码生成好处。虽然上面的例子是编造的(显然匹配逻辑比一个简单的for循环做得更多),但这里有一个真实的例子。当一个regex包含一个/b,作为针对该/b评估输入的一部分,回溯引擎调用一个RegexRunner.IsBoundary辅助方法,该方法检查当前位置的字符是否是一个单词字符,以及它之前的字符是否是一个单词字符(也考虑到了输入的边界)。下面是基于字符串的IsBoundary方法的样子(它使用的runtext是RegexRunner上存储输入的字符串字段的名称)。

[Benchmark]
[Arguments(0, 0, 26)]
public bool IsBoundary(int index, int startpos, int endpos)
{
    return (index > startpos && IsBoundaryWordChar(runtext[index - 1])) !=
           (index < endpos   && IsBoundaryWordChar(runtext[index]));
}

[MethodImpl(MethodImplOptions.NoInlining)]
private bool IsBoundaryWordChar(char c) => false;

这里是跨度版本的样子。

[Benchmark]
[Arguments("abcdefghijklmnopqrstuvwxyz", 0)]
public bool IsBoundary(ReadOnlySpan<char> inputSpan, int index)
{
    int indexM1 = index - 1;
    return ((uint)indexM1 < (uint)inputSpan.Length && IsBoundaryWordChar(inputSpan[indexM1])) !=
            ((uint)index < (uint)inputSpan.Length && IsBoundaryWordChar(inputSpan[index]));
}

[MethodImpl(MethodImplOptions.NoInlining)]
private bool IsBoundaryWordChar(char c) => false;

这里是所产生的结果集

; Program.IsBoundary(Int32, Int32, Int32)
       push      rdi
       push      rsi
       push      rbp
       push      rbx
       sub       rsp,28
       mov       rdi,rcx
       mov       esi,edx
       mov       ebx,r9d
       cmp       esi,r8d
       jle       short M00_L00
       mov       rcx,rdi
       mov       rcx,[rcx+8]
       lea       edx,[rsi-1]
       cmp       edx,[rcx+8]
       jae       short M00_L04
       mov       edx,edx
       movzx     edx,word ptr [rcx+rdx*2+0C]
       mov       rcx,rdi
       call      qword ptr [Program.IsBoundaryWordChar(Char)]
       jmp       short M00_L01
M00_L00:
       xor       eax,eax
M00_L01:
       mov       ebp,eax
       cmp       esi,ebx
       jge       short M00_L02
       mov       rcx,rdi
       mov       rcx,[rcx+8]
       cmp       esi,[rcx+8]
       jae       short M00_L04
       mov       edx,esi
       movzx     edx,word ptr [rcx+rdx*2+0C]
       mov       rcx,rdi
       call      qword ptr [Program.IsBoundaryWordChar(Char)]
       jmp       short M00_L03
M00_L02:
       xor       eax,eax
M00_L03:
       cmp       ebp,eax
       setne     al
       movzx     eax,al
       add       rsp,28
       pop       rbx
       pop       rbp
       pop       rsi
       pop       rdi
       ret
M00_L04:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 117

; Program.IsBoundary(System.ReadOnlySpan`1<Char>, Int32)
       push      r14
       push      rdi
       push      rsi
       push      rbp
       push      rbx
       sub       rsp,20
       mov       rdi,rcx
       mov       esi,r8d
       mov       rbx,[rdx]
       mov       ebp,[rdx+8]
       lea       edx,[rsi-1]
       cmp       edx,ebp
       jae       short M00_L00
       mov       edx,edx
       movzx     edx,word ptr [rbx+rdx*2]
       mov       rcx,rdi
       call      qword ptr [Program.IsBoundaryWordChar(Char)]
       jmp       short M00_L01
M00_L00:
       xor       eax,eax
M00_L01:
       mov       r14d,eax
       cmp       esi,ebp
       jae       short M00_L02
       mov       edx,esi
       movzx     edx,word ptr [rbx+rdx*2]
       mov       rcx,rdi
       call      qword ptr [Program.IsBoundaryWordChar(Char)]
       jmp       short M00_L03
M00_L02:
       xor       eax,eax
M00_L03:
       cmp       r14d,eax
       setne     al
       movzx     eax,al
       add       rsp,20
       pop       rbx
       pop       rbp
       pop       rsi
       pop       rdi
       pop       r14
       ret
; Total bytes of code 94

这里最值得注意的是。

call      CORINFO_HELP_RNGCHKFAIL
int       3

在第一个版本的结尾处有一个在第二个版本结尾处不存在的代码。正如我们前面看到的,当JIT发出代码抛出数组、字符串或跨度的索引超出范围的异常时,生成的程序集就是这个样子。它在最后,因为它被认为是 "冷 "的,很少执行。它存在于第一种情况中,因为JIT无法根据对该函数的局部分析证明runtext[index-1]和runtext[index]的访问将在字符串的范围内(它无法知道或相信startpos、endpos和runtext的边界之间的任何隐含关系)。但是在第二种情况下,JIT可以知道并相信ReadOnlySpan的下限是0,上限(独占)是span的Length,并且通过该方法的构造,它可以证明span的访问总是在边界内。因此,它不需要在方法中发出任何边界检查,而且该方法也没有索引超出范围抛出的提示性签名。你可以在dotnet/runtime#66129dotnet/runtime#66178dotnet/runtime#72728中看到更多利用跨度作为所有引擎核心的例子,所有这些例子都清理了不必要的边界检查,然后总是0和跨度.长度。

好了,现在引擎能够被交给跨度输入并处理它们,很好,我们能用它做什么?好吧,Regex.IsMatch很简单:它不需要进行多次匹配,因此不需要担心如何存储输入的ReadOnlySpan以备下次匹配。同样地,新的Regex.Count提供了一个优化的实现来计算输入中有多少个匹配,它可以绕过使用Match或MatchCollection,因此也可以轻松地在跨度上操作;dotnet/runtime#64289添加了基于字符串的重载,dotnet/runtime#66026添加了基于跨度的重载。我们可以通过向引擎传递额外的信息来进一步优化Count,让它们知道它们实际上需要计算多少信息。例如,我之前指出,NonBacktracking在相对于它需要收集的信息而言,需要做多少工作,是相当有代价的。最便宜的做法是只确定是否有一个匹配,因为它可以在一次向前通过输入的过程中做到这一点。如果它还需要计算实际的起点和终点界限,这就需要再反向通过一些输入。如果它还需要计算捕获信息,这就需要在NFA的基础上再进行一次正向传递(即使其他两次是基于DFA的)。Count需要边界信息,因为它需要知道从哪里开始寻找下一个匹配,但它不需要捕获信息,因为这些捕获信息都不会交还给调用者。dotnet/runtime#68242更新了引擎以接收这些额外的信息,从而使Count等方法变得更有效率。

所以,IsMatch和Count可以与跨度一起工作。但是,我们仍然没有一个方法可以让你真正得到匹配的信息。输入新的EnumerateMatches方法,由dotnet/runtime#67794添加。EnumerateMatches与Match非常相似,只是它不是交回一个Match类实例,而是交回一个Ref结构的枚举器。

public ref struct ValueMatchEnumerator
{
    private readonly Regex _regex;
    private readonly ReadOnlySpan<char> _input;
    private ValueMatch _current;
    private int _startAt;
    private int _prevLen;
    ...
}

作为一个引用结构,枚举器能够存储对输入跨度的引用,因此能够通过匹配进行迭代,这些匹配由 ValueMatch 引用结构表示。值得注意的是,今天 ValueMatch 不提供捕获信息,这也使它能够参与之前提到的对 Count 的优化。即使你有一个输入字符串,EnumerateMatches也是一种对输入的所有匹配进行无分配枚举的方法。不过,在.NET 7中,如果你还需要所有的捕获数据,就没有办法实现这种无分配的枚举。如果需要的话,我们会在未来研究设计这个问题。

TryFindNextPossibleStartingPosition

如前所述,所有引擎的核心是一个Scan(ReadOnlySpan)方法,它接受要匹配的输入文本,将其与基础实例的位置信息结合起来,并在找到下一个匹配的位置或用尽输入而没有找到另一个时退出。对于回溯引擎来说,该方法的实现在逻辑上是这样的。

protected override void Scan(ReadOnlySpan<char> inputSpan)
{
    while (!TryMatchAtCurrentPosition(inputSpan) &&
           base.runtextpos != inputSpan.Length)
    {
        base.runtextpos++;
    }
}

我们试图匹配当前位置的输入,如果我们成功地做到了这一点,我们就退出。然而,如果当前位置不匹配,那么如果有任何剩余的输入,我们就 "撞 "一下位置,重新开始这个过程。在词组引擎术语中,这通常被称为 "bumpalong循环"。然而,如果我们真的在每个输入字符上都运行完整的匹配过程,那就会变得不必要的缓慢。对于许多模式来说,有些东西可以让我们在进行完全匹配时考虑得更周全,快速跳过那些不可能匹配的位置,而只把时间和资源花在真正有机会匹配的位置上。为了将这一概念提升到一流水平,回溯引擎的 "bumpalong循环 "通常更像下面这样(我说 "通常 "是因为在某些情况下,编译的和源码生成的词组能够生成更好的东西)。

protected override void Scan(ReadOnlySpan<char> inputSpan)
{
    while (TryFindNextPossibleStartingPosition(inputSpan) &&
           !TryMatchAtCurrentPosition(inputSpan) &&
           base.runtextpos != inputSpan.Length)
    {
        base.runtextpos++;
    }
}

和之前的FindFirstChar一样,那个TryFindNextPossibleStartingPosition的责任是尽快搜索下一个匹配的地方(或者确定没有其他东西可能匹配,在这种情况下,它将返回false,循环退出)。如同FindFirstChar,而且它被嵌入了多种方式来完成其工作。在.NET 7中,TryFindNextPossibleStartingPosition学会了许多更多和改进的方法来帮助引擎快速。

在.NET 6中,解释器引擎实际上有两种实现TryFindNextPossibleStartingPosition的方法:如果模式以至少两个字符的字符串(可能不区分大小写)开始,则采用Boyer-Moore子串搜索,以及对已知是所有可能开始匹配的字符集的字符类进行线性扫描。对于后一种情况,解释器有八种不同的匹配实现,基于RegexOptions.RightToLeft是否被设置,字符类是否需要不区分大小写的比较,以及字符类是否只包含单个字符或多个字符的组合。其中一些比其他的更优化,例如,从左到右、大小写敏感的单字符搜索将使用IndexOf(char)来搜索下一个位置,这是在.NET 5中添加的优化。然而,每次执行这个操作时,引擎都需要重新计算是哪种情况。dotnet/runtime#60822改进了这一点,引入了TryFindNextPossibleStartingPosition用来寻找下一个机会的策略的内部枚举,为TryFindNextPossibleStartingPosition增加了一个开关,以快速跳到正确的策略,并在构造解释器时预先计算使用哪个策略。这不仅使解释器在比赛时的实现更快,而且使其有效地免费(就比赛时的运行时间开销而言)增加额外的策略。

dotnet/runtime#60888然后添加了第一个额外的策略。该实现已经能够使用IndexOf(char),但是正如之前在这篇文章中提到的,IndexOf(ReadOnlySpan)的实现在很多情况下在.NET 7中得到了很大的改善,以至于除了最角落的情况,它最终都比Boyer-Moore好很多。因此,这个PR使一个新的IndexOf(ReadOnlySpan)策略能够在字符串大小写敏感的情况下被用来搜索前缀字符串。

private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;
private Regex _regex = new Regex(@"\belementary\b", RegexOptions.Compiled);

[Benchmark]
public int Count() => _regex.Matches(s_haystack).Count;
方法 运行时 平均值 比率
Count .NET 6.0 377.32 us 1.00
Count .NET 7.0 55.44 us 0.15

dotnet/runtime#61490然后完全删除了Boyer-Moore。在之前提到的PR中没有这样做,因为缺乏处理大小写不敏感匹配的好方法。然而,这个PR也对ASCII字母进行了特殊处理,以教导优化器如何将ASCII不区分大小写的匹配转化为该字母的两种大小写的集合(不包括少数已知的问题,如i和k,它们都可能受到所采用的文化的影响,并且可能将不区分大小写映射为两个以上的值)。有了足够多的常见情况,与其使用Boyer-Moore来进行不区分大小写的搜索,不如直接使用IndexOfAny(char, char, ...)来搜索起始集,而且IndexOfAny采用的矢量化最终在现实世界中大大超过了老的实现。这个PR比这更进一步,它不只是发现 "起始集",而是能够找到所有可能与模式相匹配的字符类,这些字符类与起始集有一个固定的偏移量;然后让分析器有能力选择预计最不常见的集合,并对其进行搜索,而不是恰好位于起始集的任何东西。PR也走得更远,这在很大程度上是由非反向追踪引擎所激发的。非反向追踪引擎的原型实现在到达起始状态时也使用了IndexOfAny(char, char, ...),因此能够快速跳过那些没有机会将其推到下一个状态的输入文本。我们希望所有的引擎都能共享尽可能多的逻辑,特别是围绕这个速度的提前,所以这个PR将解释器和非反向追踪引擎统一起来,让它们共享完全相同的TryFindNextPossibleStartingPosition例程(非反向追踪引擎只是在其图形遍历循环的适当位置调用)。由于非反向追踪引擎已经在以这种方式使用IndexOfAny,最初不这样做会对我们测量的各种模式产生明显的倒退,这导致我们投资在所有地方使用它。这个PR还在编译引擎中引入了第一个不区分大小写的比较的特殊情况,例如,如果我们发现一个集合是[Ee],而不是发出类似于c == 'E' || c == 'e'的检查,我们会发出类似于(c | 0x20) == 'e' 的检查(前面讨论的那些有趣的ASCII技巧又开始发挥作用了)。

private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;
private Regex _regex = new Regex(@"\belementary\b", RegexOptions.Compiled | RegexOptions.IgnoreCase);

[Benchmark]
public int Count() => _regex.Matches(s_haystack).Count;
方法 运行时 平均值 比率
Count .NET 6.0 499.3 us 1.00
Count .NET 7.0 177.7 us 0.35

以前的PR开始把IgnoreCase模式的文本变成集合,特别是ASCII,例如(?i)a会变成[Aa]。那个PR在知道会有更完整的东西出现的情况下,黑进了对ASCII的支持,正如它在dotnet/runtime#67184中所做的那样。与其硬编码只有ASCII字符映射到的不区分大小写的集合,这个PR本质上是硬编码每个可能的字符的集合。一旦这样做了,我们就不再需要在匹配时知道大小写不敏感的问题,而是可以在有效的匹配集上加倍努力,我们已经需要能够很好地做到这一点。现在,我说它对每个可能的字符都进行了编码;这并不完全正确。如果是真的,那就会占用大量的内存,事实上,大部分的内存都会被浪费掉,因为绝大多数的字符都不参与大小写转换......我们需要处理的字符只有大约2000个。因此,该实现采用了一个三层表方案。第一个表有64个元素,将全部字符分为64个组;在这64个组中,有54个没有参与大小写转换的字符,所以如果我们遇到这些条目,我们可以立即停止搜索。对于剩下的10个在其范围内至少有一个字符参与的条目,第一个表中的字符和值被用来计算第二个表中的索引;在那里,大多数条目都说没有任何字符参与大小写转换。只有当我们在第二张表中得到一个合法条目时,才会给我们一个进入第三张表的索引,在这个位置我们可以找到所有被认为与第一张表大小写相等的字符。

dotnet/runtime#63477(后来又在dotnet/runtime#66572中进行了改进),继续增加了另一种搜索策略,这个策略的灵感来自于nim-regex的字面优化。我们从性能的角度跟踪了大量的词组,以确保我们在常见的情况下没有倒退,并帮助指导投资。其中一个是mariomka/regex-benchmark语言的regex基准的模式集。其中一个是针对URI的:(@"[\w]+://[/\s?#]+[\s?#]+(?:?[\s#]*)?(?:#[\s]*)?" 。这个模式违背了迄今为止所启用的寻找下一个好位置的策略,因为它保证以 "单词字符"(\w)开始,其中包括65,000个可能的字符中的50,000个;我们没有一个好的方法来对这样一个字符类进行矢量搜索。然而,这个模式很有趣,它以一个循环开始,不仅如此,它是一个上界循环,我们的分析将确定它是原子性的,因为保证紧随循环的字符是一个':',它本身不是一个单词字符,因此,没有什么循环可以匹配并放弃作为回溯的一部分,可以匹配':'。这一切使我们有了一种不同的矢量化方法:与其试图搜索\w字符类,不如搜索子串"????/",然后一旦找到它,我们可以通过尽可能多的[\w]进行反向匹配;在这种情况下,唯一的约束是我们需要至少匹配一个。这个PR给所有的引擎都增加了这个策略,用于原子循环后的字词。

private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;
private Regex _regex = new Regex(@"[\w]+://[^/\s?#]+[^\s?#]+(?:\?[^\s#]*)?(?:#[^\s]*)?", RegexOptions.Compiled);

[Benchmark]
public bool IsMatch() => _regex.IsMatch(s_haystack); // Uri's in Sherlock Holmes? "Most unlikely."
方法 运行时 平均值 比率
IsMatch .NET 6.0 4,291.77 us 1.000
IsMatch .NET 7.0 42.40 us 0.010

当然,正如在其他地方谈到的那样,最好的优化不是让某些东西更快,而是让某些东西完全没有必要。这就是dotnet/runtime#64177所做的,特别是在锚点方面。.NET的regex实现早就对带有起始锚点的模式进行了优化:例如,如果模式以开头(并且没有指定RegexOptions.Multiline),模式就会被根植到开头,这意味着它不可能在0以外的任何位置匹配;因此,对于这样一个锚点,TryFindNextPossibleStartingPosition根本就不会进行任何搜索。不过,这里的关键是能够检测到模式是否以这样的锚点开始。在某些情况下,比如abc$,这是很简单的。在其他情况下,比如abc|def,现有的分析很难看透这种交替,从而找到保证的起始^锚。这个PR解决了这个问题。如果分析引擎能够确定任何可能的匹配的最大字符数,并且它有这样一个锚,那么它可以简单地跳到离字符串末端的那个距离,甚至绕过在此之前的任何东西。

private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;
private Regex _regex = new Regex(@"^abc|^def", RegexOptions.Compiled);

[Benchmark]
public bool IsMatch() => _regex.IsMatch(s_haystack); // Why search _all_ the text?!
方法 运行时 平均值 比率
IsMatch .NET 6.0 867,890.56 ns 1.000
IsMatch .NET 7.0 33.55 ns 0.000

dotnet/runtime#67732是另一个与改进锚点处理有关的PR。当一个错误修复或代码简化重构变成一个性能改进时,总是很有趣。这个PR的主要目的是简化一些复杂的代码,这些代码正在计算可能开始匹配的字符集。事实证明,这个复杂的代码隐藏着一个逻辑错误,表现在它错过了一些报告有效起始字符类的机会,其影响是一些本来可以被矢量化的搜索没有被报告。通过简化实现,这个错误被修复了,暴露了更多的性能机会。

到此为止,引擎已经能够使用IndexOf(ReadOnlySpan)来寻找模式开头的子串了。但是有时候最有价值的子串并不在开头,而是在中间的某个地方,甚至是在结尾。只要它与模式的开头有一个固定的偏移量,我们就可以搜索它,然后通过偏移量退到我们真正应该尝试运行匹配的位置。dotnet/runtime#67907正是这样做的。

private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;
private Regex _regex = new Regex(@"looking|feeling", RegexOptions.Compiled);

[Benchmark]
public int Count() => _regex.Matches(s_haystack).Count; // will search for "ing"
方法 运行时 平均值 比率
Count .NET 6.0 444.2 us 1.00
Count .NET 7.0 122.6 us 0.28

循环和回溯 (Loops and Backtracking)

编译和源码生成的引擎中的循环处理已经有了明显的改进,无论是在处理速度方面还是在减少回溯方面。

对于常规的贪婪循环(例如c),有两个方向需要关注:我们能以多快的速度消耗所有与循环相匹配的元素,以及我们能以多快的速度回馈元素,这些元素可能是回溯的一部分,以便表达式的剩余部分能够匹配。而对于懒惰循环,我们主要关注的是回溯,也就是前进方向(因为懒惰循环是作为回溯的一部分进行消耗,而不是作为回溯的一部分进行回馈)。通过PR dotnet/runtime#63428dotnet/runtime#68400dotnet/runtime#64254dotnet/runtime#73910,在编译器和源码生成器中,我们现在充分利用了IndexOf、IndexOfAny、LastIndexOf、LastIndexOfAny、 IndexOfAnyExcept和LastIndexOfAnyExcept的所有变体,以便加速这些搜索。例如,在像.abc这样的模式中,该循环的前进方向需要消耗每个字符,直到下一个换行,我们可以用IndexOf('\n')来优化。然后作为回溯的一部分,我们可以用LastIndexOf("abc")来找到下一个可能与模式剩余部分匹配的可行位置,而不是一次放弃一个字符。又比如,在[^a-c]*def这样的模式中,循环最初会贪婪地消耗除'a'、'b'或'c'以外的所有东西,所以我们可以使用IndexOfAnyExcept('a'、'b'、'c')来找到循环的初始终点。以此类推。这可以产生巨大的性能提升,而且通过源码生成器,还可以使生成的代码更成文,更容易理解。

private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;
private Regex _regex = new Regex(@"^.*elementary.*$", RegexOptions.Compiled | RegexOptions.Multiline);

[Benchmark]
public int Count() => _regex.Matches(s_haystack).Count;
方法 运行时 平均值 比率
Count .NET 6.0 3,369.5 us 1.00
Count .NET 7.0 430.2 us 0.13

dotnet/runtime#63398修复了.NET 5中引入的一个优化问题;该优化很有价值,但只适用于它所要涵盖的一个子集的场景。虽然 TryFindNextPossibleStartingPosition 的主要存在理由是更新 bumpalong 位置,但 TryMatchAtCurrentPosition 也有可能这样做。它这样做的场合之一是当模式开始于一个上不封顶的单字符贪婪循环。由于处理开始时,该循环已经完全消耗了它可能匹配的所有内容,所以随后的扫描循环之旅不需要重新考虑该循环中的任何起始位置;这样做只是重复扫描循环先前迭代中的工作。因此,TryMatchAtCurrentPosition可以将bumpalong位置更新到循环的末端。.NET 5中添加的优化是尽职尽责地做这件事,而且是以完全处理原子循环的方式做的。但是对于贪婪的循环,每次我们回溯的时候,更新的位置都会被更新,这意味着它开始向后退,而它本应该停留在循环的末端。这个PR修复了这一问题,在额外覆盖的情况下产生了显著的节约。

private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;
private Regex _regex = new Regex(@".*stephen", RegexOptions.Compiled);

[Benchmark]
public int Count() => _regex.Matches(s_haystack).Count;
方法 运行时 平均值 比率
Count .NET 6.0 103,962.8 us 1.000
Count .NET 7.0 336.9 us 0.003

dotnet/runtime#68989dotnet/runtime#63299dotnet/runtime#63518正是通过提高模式分析器发现和消除更多不必要的回溯的能力,这一过程被分析器称为 "自动原子性"(自动使循环原子化)。例如,在模式a?b中,我们有一个由 "a "和 "b "组成的懒惰循环,该循环只能匹配 "a",而且 "a "不会与 "b "重叠。所以我们假设输入是 "aaaaaaab"。循环是懒惰的,所以我们一开始就尝试只匹配'b'。它不会匹配,所以我们会回到懒惰循环中,尝试匹配 "ab"。它不匹配,所以我们再回到懒惰循环中,尝试匹配 "aab"。以此类推,直到我们耗尽了所有的 "a",使模式的其余部分有机会匹配输入的其余部分。这正是原子贪婪循环所做的,所以我们可以将模式a?b转化为(?>a*)b,这样处理起来更有效率。事实上,只要看看这个模式的源码生成的实现,我们就能清楚地看到它是如何处理的。

private bool TryMatchAtCurrentPosition(ReadOnlySpan<char> inputSpan)
{
    int pos = base.runtextpos;
    int matchStart = pos;
    ReadOnlySpan<char> slice = inputSpan.Slice(pos);

    // Match 'a' atomically any number of times.
    {
        int iteration = slice.IndexOfAnyExcept('a');
        if (iteration < 0)
        {
            iteration = slice.Length;
        }

        slice = slice.Slice(iteration);
        pos += iteration;
    }

    // Advance the next matching position.
    if (base.runtextpos < pos)
    {
        base.runtextpos = pos;
    }

    // Match 'b'.
    if (slice.IsEmpty || slice[0] != 'b')
    {
        return false; // The input didn't match.
    }

    // The input matched.
    pos++;
    base.runtextpos = pos;
    base.Capture(0, matchStart, pos);
    return true;
}

(注意,这些评论不是我为这篇博文添加的;源码生成器本身就发出了评论代码)。

当一个正则表达式被输入时,它被解析成基于树的形式。前面的PR中讨论的 "自动原子性 "分析是一种分析形式,它围绕这棵树寻找机会,将树的部分内容转化为行为上的等价物,以更有效地执行。例如,dotnet/runtime#63695在树中寻找可以删除的 "空 "和 "无 "节点。一个 "空 "节点是与空字符串相匹配的东西,因此,例如在交替的abc|def||ghi中,该交替的第三个分支是空的。一个 "无 "节点是不能匹配任何东西的东西,例如在串联abc(?!)def中,中间的(?!)是一个围绕空的负查找,它不可能匹配任何东西,因为它是说如果表达式后面有一个空字符串,它就不会匹配,而所有东西都是空的。这些结构往往是其他转换的结果,而不是开发者通常手工编写的东西,就像JIT中的一些优化,你可能会看着它们说:"这到底为什么是一个开发者会写的东西",但无论如何,它最终是一个有价值的优化,因为内联可能将完全合理的代码转化为符合目标模式的东西。因此,例如,如果你确实有abc(?!)def,因为这个连接需要(?!)匹配才能成功,连接本身可以简单地被一个 "无 "所取代。如果你用源码生成器试试,你就可以很容易地看到这一点。

[GeneratedRegex(@"abc(?!)def")]

因为它将产生一个像这样的扫描方法(注释和所有)。

protected override void Scan(ReadOnlySpan<char> inputSpan)
{
    // The pattern never matches anything.
}

dotnet/runtime#59903中引入了另一组转换,特别是围绕交替(除了循环之外,交替是回溯的另一个来源)。这引入了两个主要的优化。首先,它可以将交替写入交替的交替,例如将axy|axz|bxy|bxz转化为ax(?:y|z)|bx(?:y|z),然后进一步简化为ax[yz]|bx[yz]。这可以使回溯引擎更有效地处理交替,因为分支较少,因此潜在的回溯也较少。PR还允许对交替中的分支进行有限的重新排序。一般来说,分支不能被重新排序,因为顺序会影响到到底什么被匹配,什么被捕获,但是如果引擎可以证明对排序没有影响,那么它就可以*地重新排序。顺序不是因素的一个关键地方是,如果交替是原子性的,因为它被包裹在一个原子组中(自动原子性分析在某些情况下会隐含地添加这样的组)。对分支进行重新排序可以实现其他优化,比如之前提到的这个PR中的优化。一旦这些优化启动,如果我们剩下一个原子交替,每个分支都以不同的字母开始,那么就可以在如何降低交替方面实现进一步的优化;这个PR教给源码生成器如何发出switch语句,这将导致更有效和更可读的代码。(检测树中的节点是否是原子性的,以及其他诸如执行捕获或引入回溯的属性,被证明是有价值的,以至于dotnet/runtime#65734为此增加了专门的支持)。

原文链接

Performance Improvements in .NET 7

【译】.NET 7 中的性能改进(十二)

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

欢迎转载、使用、重新发布,但务必保留文章署名 郑子铭 (包含链接: http://www.cnblogs.com/MingsonZheng/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。

如有任何疑问,请与我联系 (MingsonZheng@outlook.com)