前言
System.Text.RegularExpressions
命名空间已经在 .NET 中使用了多年,一直追溯到 .NET Framework 1.1。它在 .NET 实施本身的数百个位置中使用,并且直接被成千上万个应用程序使用。在所有这些方面,它也是 CPU 消耗的重要来源。
但是,从性能角度来看,正则表达式在这几年间并没有获得太多关注。在 2006 年的 .NET Framework 2.0 中更改了其缓存策略。 .NET Core 2.0 在 RegexOptions.Compiled
之后看到了这个实现的到来(在 .NET Core 1.x 中,RegexOptions.Compiled
选项是一个 nop)。 .NET Core 3.0 受益于 Regex 内部的一些内部更新,以在某些情况下利用 Span<T>
提高内存利用率。在此过程中,一些非常受欢迎的社区贡献改进了目标区域,例如 dotnet/corefx#32899,它减少了使用表达式 RegexOptions.Compiled | RegexOptions.IgnoreCase
时对CultureInfo.CurrentCulture
的访问。但除此之外,实施很大程度上还是在15年前。
对于 .NET 5(本周发布了 Preview 2),我们已对 Regex 引擎进行了一些重大改进。在我们尝试过的许多表达式中,这些更改通常会使吞吐量提高3到6倍,在某些情况下甚至会提高更多。在本文中,我将逐步介绍 .NET 5 中 System.Text.RegularExpressions
进行的许多更改。这些更改对我们自己的使用产生了可衡量的影响,我们希望这些改进将带来可衡量的胜利在您的库和应用中。
Regex内部知识
要了解所做的某些更改,了解一些Regex内部知识很有帮助。
Regex构造函数完成所有工作,以采用正则表达式模式并准备对其进行匹配输入:
-
RegexParser
。该模式被送入内部RegexParser
类型,该类型理解正则表达式语法并将其解析为节点树。例如,表达式a|bcd
被转换为具有两个子节点的“替代”RegexNode
,一个子节点表示单个字符a
,另一个子节点表示“多个”bcd
。解析器还对树进行优化,将一棵树转换为另一个等效树,以提供更有效的表示和/或可以更高效地执行该树。 -
RegexWriter
。节点树不是执行匹配的理想表示,因此解析器的输出将馈送到内部RegexWriter
类,该类会写出一系列紧凑的操作码,以表示执行匹配的指令。这种类型的名称是“ writer”,因为它“写”出了操作码。其他引擎通常将其称为“编译”,但是 .NET 引擎使用不同的术语,因为它保留了“编译”术语,用于 MSIL 的可选编译。 -
RegexCompiler
(可选)。如果未指定RegexOptions.Compiled
选项,则内部RegexInterpreter
类稍后在匹配时使用RegexWriter
输出的操作码来解释/执行执行匹配的指令,并且在Regex构造过程中不需要任何操作。但是,如果指定了RegexOptions.Compiled
,则构造函数将获取先前输出的资产,并将其提供给内部RegexCompiler
类。然后,RegexCompiler
使用反射发射生成MSIL,该MSIL表示解释程序将要执行的工作,但专门针对此特定表达式。例如,当与模式中的字符“ c”匹配时,解释器将需要从变量中加载比较值,而编译器会将“ c”硬编码为生成的IL中的常量。
一旦构造了正则表达式,就可以通过IsMatch
,Match
,Matches
,Replace
和Split
等实例方法将其用于匹配(Match
返回Match
对象,该对象公开了NextMatch
方法,该方法可以迭代匹配并延迟计算) 。这些操作最终以“扫描”循环(某些其他引擎将其称为“传输”循环)结束,该循环本质上执行以下操作:
while (FindFirstChar())
{
Go();
if (_match != null)
return _match;
_pos++;
}
return null;
_pos
是我们在输入中所处的当前位置。virtual FindFirstChar
从_pos
开始,并在输入文本中查找正则表达式可能匹配的第一位;这并不是执行完整引擎,而是尽可能高效地进行搜索,以找到值得运行完整引擎的位置。 FindFirstChar
可以最大程度地减少误报,并且找到有效位置的速度越快,表达式的处理速度就越快。如果找不到合适的起点,则可能没有任何匹配,因此我们完成了。如果找到了一个好的起点,它将更新_pos
,然后通过调用virtual Go
来在找到的位置执行引擎。如果Go
找不到匹配项,我们会碰到当前位置并重新开始,但是如果Go
找到匹配项,它将存储匹配信息并返回该数据。显然,执行Go
的速度也越快越好。
所有这些逻辑都在公共RegexRunner
基类中。 RegexInterpreter
派生自RegexRunner
,并用解释正则表达式的实现覆盖FindFirstChar
和Go
,这由RegexWriter
生成的操作码表示。 RegexCompiler
使用DynamicMethods
生成两种方法,一种用于FindFirstChar
,另一种用于Go
。委托是从这些创建的、从RegexRunner
派生的另一种类型调用。
.NET 5的改进
在本文的其余部分中,我们将逐步介绍针对 .NET 5 中的 Regex 进行的各种优化。这不是详尽的清单,但它突出了一些最具影响力的更改。
CharInClass
正则表达式支持“字符类”,它们定义了输入字符应该或不应该匹配的字符集,以便将该位置视为匹配字符。字符类用方括号表示。这里有些例子:
[abc]
匹配“ a”,“ b”或“ c”。[^ ]
匹配换行符以外的任何字符。 (除非指定了RegexOptions.Singleline
,否则这是您在表达式中使用的确切字符类。)[a-cx-z]
匹配“ a”,“ b”,“ c”,“ x”,“ y”或“ z”。[dsp{IsGreek}]
匹配任何Unicode数字,空格或希腊字符。 (与大多数其他正则表达式引擎相比,这是一个有趣的区别。例如,在其他引擎中,默认情况下,d
通常映射到[0-9]
,您可以选择加入,而不是映射到所有Unicode数字,即[p{Nd}]
,而在.NET中,您默认情况下会使用后者,并使用RegexOptions.ECMAScript
选择退出。)
当将包含字符类的模式传递给Regex构造函数时,RegexParser
的工作之一就是将该字符类转换为可以在运行时更轻松地查询的字符。解析器使用内部RegexCharClass
类型来解析字符类,并从本质上提取三件事(还有更多东西,但这对于本次讨论就足够了):
- 模式是否被否定
- 匹配字符范围的排序集
- 匹配字符的Unicode类别的排序集
这是所有实现的详细信息,但是该信息然后保留在字符串中,该字符串可以传递给受保护的 RegexRunner.CharInClass
方法,以确定字符类中是否包含给定的Char。
在.NET 5之前,每一次需要将一个字符与一个字符类进行匹配时,它将调用该CharInClass
方法。然后,CharInClass
对范围进行二进制搜索,以确定指定字符是否存储在一个字符中;如果不存储,则获取目标字符的Unicode类别,并对Unicode类别进行线性搜索,以查看是否匹配。因此,对于^d*$
之类的表达式(断言它在行的开头,然后匹配任意数量的Unicode数字,然后断言在行的末尾),假设输入了1000位数字,这加起来将对CharInClass
进行1000次调用。
在 .NET 5 中,我们现在更加聪明地做到了这一点,尤其是在使用RegexOptions.Compiled
时,通常,只要开发人员非常关心Regex的吞吐量,就可以使用它。一种解决方案是,对于每个字符类,维护一个查找表,该表将输入字符映射到有关该字符是否在类中的是/否决定。虽然我们可以这样做,但是System.Char
是一个16位的值,这意味着每个字符一个位,我们需要为每个字符类使用8K查找表,并且这还要累加起来。取而代之的是,我们首先尝试使用平台中的现有功能或通过简单的数学运算来快速进行匹配,以处理一些常见情况。例如,对于d
,我们现在不生成对RegexRunner.CharInClass(ch, charClassString)
的调用,而是仅生成对 char.IsDigit(ch)
的调用。 IsDigit
已经使用查找表进行了优化,可以内联,并且性能非常好。类似地,对于s
,我们现在生成对char.IsWhitespace(ch)
的调用。对于仅包含几个字符的简单字符类,我们将生成直接比较,例如对于[az]
,我们将生成等价于(ch =='a') | (ch =='z')
。对于仅包含单个范围的简单字符类,我们将通过一次减法和比较来生成检查,例如[a-z]
导致(uint)ch-'a'<= 26
,而 [^ 0-9]
导致 !((uint)c-'0'<= 10)
。我们还将特殊情况下的其他常见规范;例如,如果整个字符类都是一个Unicode类别,我们将仅生成对char.GetUnicodeInfo
(也具有快速查找表)的调用,然后进行比较,例如[p{Lu}]
变为char.GetUnicodeInfo(c)== UnicodeCategory.UppercaseLetter
。
当然,尽管涵盖了许多常见情况,但当然并不能涵盖所有情况。而且,因为我们不想为每个字符类生成8K查找表,并不意味着我们根本无法生成查找表。相反,如果我们没有遇到这些常见情况之一,那么我们确实会生成一个查找表,但仅针对ASCII,它只需要16个字节(128位),并且考虑到正则表达式中的典型输入,这往往是一个很好的折衷方案基于方案。由于我们使用DynamicMethod
生成方法,因此我们不容易将附加数据存储在程序集的静态数据部分中,但是我们可以做的就是利用常量字符串作为数据存储; MSIL具有用于加载常量字符串的操作码,并且反射发射对生成此类指令具有良好的支持。因此,对于每个查找表,我们只需创建所需的8个字符的字符串,用不透明的位图数据填充它,然后在IL中用ldstr
吐出。然后我们可以像对待其他任何位图一样对待它,例如为了确定给定的字符是否匹配,我们生成以下内容:
bool result = ch < 128 ? (lookup[c >> 4] & (1 << (c & 0xF))) != 0 : NonAsciiFallback;
换句话说,我们使用字符的高三位选择查找表字符串中的第0至第7个字符,然后使用低四位作为该位置16位值的索引; 如果是1,则表示匹配,如果不是,则表示没有匹配。 对于大于等于128的字符,我们需要一个回退,根据对字符类进行的一些分析,回退可能是各种各样的事情。 最糟糕的情况是,回退只是对RegexRunner.CharInClass
的调用,否则我们会做得更好。 例如,很常见的是,我们可以从输入模式中得知所有可能的匹配项均小于<128,在这种情况下,我们根本不需要回退,例如 对于字符类[0-9a-fA-F]
(又称十六进制),我们将生成以下内容:
bool result = ch < 128 && (lookup[c >> 4] & (1 << (c & 0xF))) != 0;
相反,我们可以确定127以上的每个字符都将去匹配。 例如,字符类[^aeiou]
(除ASCII小写元音外的所有字符)将产生与以下代码等效的代码:
bool result = ch >= 128 || (lookup[c >> 4] & (1 << (c & 0xF))) != 0;
等等。
以上都是针对RegexOptions.Compiled
,但解释表达式并不会被冷落。 对于解释表达式,我们当前会生成一个类似的查找表,但是我们这样做是很懒惰的,第一次看到给定输入字符时会填充该表,然后针对该字符类针对该字符的所有将来评估存储该答案。 (我们可能会重新研究如何执行此操作,但这是从 .NET 5 Preview 2 开始存在的方式。)
这样做的最终结果可能是频繁评估字符类的表达式的吞吐量显着提高。 例如,这是一个微基准测试,可将ASCII字母和数字与具有62个此类值的输入进行匹配:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;
public class Program
{
static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args);
private Regex _regex = new Regex("[a-zA-Z0-9]*", RegexOptions.Compiled);
[Benchmark] public bool IsMatch() => _regex.IsMatch("abcdefghijklmnopqrstuvwxyz123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ");
}
这是我的项目文件:
<project Sdk="Microsoft.NET.Sdk">
<propertygroup>
<langversion>preview</langversion>
<outputtype>Exe</outputtype>
<targetframeworks>netcoreapp5.0;netcoreapp3.1</targetframeworks>
</propertygroup>
<itemgroup>
<packagereference Include="benchmarkdotnet" Version="0.12.0.1229"></packagereference>
</itemgroup>
</project>
在我的计算机上,我有两个目录,一个包含.NET Core 3.1,一个包含.NET 5的内部版本(此处标记为master,因为它是dotnet/runtime的master分支的内部版本)。 当我执行以上操作针对两个版本运行基准测试:
dotnet run -c Release -f netcoreapp3.1 --filter ** --corerun d:coreclrtest
etcore31corerun.exe d:coreclrtestmastercorerun.exe
我得到了以下结果:
Method | Toolchain | Mean | Error | StdDev | Ratio |
---|---|---|---|---|---|
IsMatch | mastercorerun.exe | 102.3 ns | 1.33 ns | 1.24 ns | 0.17 |
IsMatch | etcore31corerun.exe | 585.7 ns | 2.80 ns | 2.49 ns | 1.00 |
开发人员可能会写的代码生成器
如前所述,当RegexOptions.Compiled
与Regex一起使用时,我们使用反射发射为其生成两种方法,一种实现FindFirstChar
,另一种实现Go
。 为了支持回溯,Go
最终包含了很多通常不需要的代码。 生成代码的方式通常包括不必要的字段读取和写入,导致检查JIT无法消除的边界等。 在 .NET 5 中,我们改进了为许多表达式生成的代码。
考虑表达式@"asb"
,它匹配一个'a'
,任何Unicode空格和一个'b'
。 以前,反编译为Go
发出的IL看起来像这样:
public override void Go()
{
string runtext = base.runtext;
int runtextstart = base.runtextstart;
int runtextbeg = base.runtextbeg;
int runtextend = base.runtextend;
int num = runtextpos;
int[] runtrack = base.runtrack;
int runtrackpos = base.runtrackpos;
int[] runstack = base.runstack;
int runstackpos = base.runstackpos;
CheckTimeout();
runtrack[--runtrackpos] = num;
runtrack[--runtrackpos] = 0;
CheckTimeout();
runstack[--runstackpos] = num;
runtrack[--runtrackpos] = 1;
CheckTimeout();
if (num < runtextend && runtext[num++] == 'a')
{
CheckTimeout();
if (num < runtextend && RegexRunner.CharInClass(runtext[num++], "