原文 | Stephen Toub
翻译 | 郑子铭
New APIs
在.NET 7中,Regex得到了几个新的方法,所有这些方法都能提高性能。新的API的简单性可能也误导了为实现它们所需的工作量,特别是由于新的API都支持ReadOnlySpan
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
首先,我们使FindFirstChar和Go成为虚拟的,而不是抽象的。分割这些方法的设计在很大程度上是过时的,特别是强制分离了一个处理阶段,即找到匹配的下一个可能的位置,然后是在该位置实际执行匹配的阶段,这与所有的引擎并不一致,比如NonBacktracking使用的引擎(它最初将FindFirstChar作为一个nop实现,并将其所有逻辑放在Go中)。然后我们添加了一个新的虚拟扫描方法,重要的是,它需要一个ReadOnlySpan
[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
好了,现在引擎能够被交给跨度输入并处理它们,很好,我们能用它做什么?好吧,Regex.IsMatch很简单:它不需要进行多次匹配,因此不需要担心如何存储输入的ReadOnlySpan
所以,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
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字符类,不如搜索子串"
标签:Regex,匹配,十二,改进,M00,dotnet,NET,runtime From: https://www.cnblogs.com/MingsonZheng/p/17185933.html