.NET 5 中的正则引擎性能改进(翻译)

前言

System.Text.RegularExpressions 命名空间已经在 .NET 中使用了多年,一直追溯到 .NET Framework 1.1。它在 .NET 实施自己的数百个位置中使用,而且直接被成千上万个应用程序使用。在全部这些方面,它也是 CPU 消耗的重要来源。html

可是,从性能角度来看,正则表达式在这几年间并无得到太多关注。在 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年前。git

对于 .NET 5(本周发布了 Preview 2),咱们已对 Regex 引擎进行了一些重大改进。在咱们尝试过的许多表达式中,这些更改一般会使吞吐量提升3到6倍,在某些状况下甚至会提升更多。在本文中,我将逐步介绍 .NET 5 中 System.Text.RegularExpressions 进行的许多更改。这些更改对咱们本身的使用产生了可衡量的影响,咱们但愿这些改进将带来可衡量的胜利在您的库和应用中。github

Regex内部知识

要了解所作的某些更改,了解一些Regex内部知识颇有帮助。正则表达式

Regex构造函数完成全部工做,以采用正则表达式模式并准备对其进行匹配输入:算法

  • RegexParser。该模式被送入内部RegexParser类型,该类型理解正则表达式语法并将其解析为节点树。例如,表达式a|bcd被转换为具备两个子节点的“替代” RegexNode,一个子节点表示单个字符a,另外一个子节点表示“多个” bcd。解析器还对树进行优化,将一棵树转换为另外一个等效树,以提供更有效的表示和/或能够更高效地执行该树。express

  • RegexWriter。节点树不是执行匹配的理想表示,所以解析器的输出将馈送到内部RegexWriter类,该类会写出一系列紧凑的操做码,以表示执行匹配的指令。这种类型的名称是“ writer”,由于它“写”出了操做码。其余引擎一般将其称为“编译”,可是 .NET 引擎使用不一样的术语,由于它保留了“编译”术语,用于 MSIL 的可选编译。redux

  • RegexCompiler(可选)。若是未指定RegexOptions.Compiled选项,则内部RegexInterpreter类稍后在匹配时使用RegexWriter输出的操做码来解释/执行执行匹配的指令,而且在Regex构造过程当中不须要任何操做。可是,若是指定了RegexOptions.Compiled,则构造函数将获取先前输出的资产,并将其提供给内部RegexCompiler类。而后,RegexCompiler使用反射发射生成MSIL,该MSIL表示解释程序将要执行的工做,但专门针对此特定表达式。例如,当与模式中的字符“ c”匹配时,解释器将须要从变量中加载比较值,而编译器会将“ c”硬编码为生成的IL中的常量。api

一旦构造了正则表达式,就能够经过IsMatchMatchMatchesReplaceSplit等实例方法将其用于匹配(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,并用解释正则表达式的实现覆盖FindFirstCharGo,这由RegexWriter生成的操做码表示。 RegexCompiler使用DynamicMethods生成两种方法,一种用于FindFirstChar,另外一种用于Go。委托是从这些建立的、从RegexRunner派生的另外一种类型调用。

.NET 5的改进

在本文的其他部分中,咱们将逐步介绍针对 .NET 5 中的 Regex 进行的各类优化。这不是详尽的清单,但它突出了一些最具影响力的更改。

CharInClass

正则表达式支持“字符类”,它们定义了输入字符应该或不该该匹配的字符集,以便将该位置视为匹配字符。字符类用方括号表示。这里有些例子:

  • [abc] 匹配“ a”,“ b”或“ c”。
  • [^\n] 匹配换行符之外的任何字符。 (除非指定了 RegexOptions.Singleline,不然这是您在表达式中使用的确切字符类。)
  • [a-cx-z] 匹配“ a”,“ b”,“ c”,“ x”,“ y”或“ z”。
  • [\d\s\p{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\netcore31\corerun.exe d:\coreclrtest\master\corerun.exe

我获得了如下结果:

Method Toolchain Mean Error StdDev Ratio
IsMatch \master\corerun.exe 102.3 ns 1.33 ns 1.24 ns 0.17
IsMatch \netcore31\corerun.exe 585.7 ns 2.80 ns 2.49 ns 1.00

开发人员可能会写的代码生成器

如前所述,当RegexOptions.Compiled与Regex一块儿使用时,咱们使用反射发射为其生成两种方法,一种实现FindFirstChar,另外一种实现Go。 为了支持回溯,Go最终包含了不少一般不须要的代码。 生成代码的方式一般包括没必要要的字段读取和写入,致使检查JIT没法消除的边界等。 在 .NET 5 中,咱们改进了为许多表达式生成的代码。

考虑表达式@"a\sb",它匹配一个'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++], "\0\0\u0001d"))
        {
            CheckTimeout();
            if (num < runtextend && runtext[num++] == 'b')
            {
                CheckTimeout();
                int num2 = runstack[runstackpos++];

                Capture(0, num2, num);
                runtrack[--runtrackpos] = num2;
                runtrack[--runtrackpos] = 2;
                goto IL_0131;
            }
        }
    }

    while (true)
    {
        base.runtrackpos = runtrackpos;
        base.runstackpos = runstackpos;
        EnsureStorage();
        runtrackpos = base.runtrackpos;
        runstackpos = base.runstackpos;
        runtrack = base.runtrack;
        runstack = base.runstack;

        switch (runtrack[runtrackpos++])
        {
            case 1:
                CheckTimeout();
                runstackpos++;
                continue;

            case 2:
                CheckTimeout();
                runstack[--runstackpos] = runtrack[runtrackpos++];
                Uncapture();
                continue;
        }

        break;
    }

    CheckTimeout();
    num = runtrack[runtrackpos++];
    goto IL_0131;

    IL_0131:
    CheckTimeout();
    runtextpos = num;
}

那里有不少东西,须要斜视和搜索才能将实现的核心看做方法的中间几行。 如今在.NET 5中,相同的表达式致使生成如下代码:

protected override void Go()
{
    string runtext = base.runtext;
    int runtextend = base.runtextend;
    int runtextpos;
    int start = runtextpos = base.runtextpos;
    ReadOnlySpan<char> readOnlySpan = runtext.AsSpan(runtextpos, runtextend - runtextpos);
    if (0u < (uint)readOnlySpan.Length && readOnlySpan[0] == 'a' &&
        1u < (uint)readOnlySpan.Length && char.IsWhiteSpace(readOnlySpan[1]) &&
        2u < (uint)readOnlySpan.Length && readOnlySpan[2] == 'b')
    {
        Capture(0, start, base.runtextpos = runtextpos + 3);
    }
}

若是您像我同样,则能够注视着眼睛看第一个版本,可是若是您看到第二个版本,则能够真正阅读并了解它的功能。 除了易于理解和易于调试以外,它还减小了执行的代码,消除了边界检查,减小了对字段和数组的读写等方面的工做。 最终的结果是它的执行速度也快得多。 (这里还有进一步改进的可能性,例如删除两个长度检查,可能会从新排序一些检查,但总的来讲,它比之前有了很大的改进。)

向量化的基于 Span 的搜索

正则表达式都是关于搜索内容的。 结果,咱们常常发现本身正在运行循环以寻找各类事物。 例如,考虑表达式 hello.*world。 之前,若是要反编译咱们在Go方法中生成的用于匹配.*的代码,则该代码相似于如下内容:

while (--num3 > 0)
{
    if (runtext[num++] == '\n')
    {
        num--;
        break;
    }
}

换句话说,咱们将手动遍历输入文本字符串,逐个字符地查找 \n(请记住,默认状况下,.表示“ \n之外的任何内容”,所以.*表示“匹配全部内容,直到找到\n” )。 可是,.NET早已拥有彻底执行此类搜索的方法,例如IndexOf,而且从最新版本开始,IndexOf是矢量化的,所以它能够同时比较多个字符,而不只仅是单独查看每一个字符。 如今,在.NET 5中,咱们再也不像上面那样生成代码,而是获得以下代码:

num2 = runtext.AsSpan(runtextpos, num).IndexOf('\n');

使用IndexOf而不是生成咱们本身的循环,则意味着对Regex中的此类搜索进行隐式矢量化,而且对此类实现的任何改进也都应归于此。 这也意味着生成的代码更简单。 能够用这样的基准测试来查看其影响:

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("hello.*world", RegexOptions.Compiled);
    
    [Benchmark] public bool IsMatch() => _regex.IsMatch("hello.  this is a test to see if it's able to find something more quickly in the world.");
}

即便输入的字符串不是特别大,也会产生可衡量的影响:

Method Toolchain Mean Error StdDev Ratio
IsMatch \master\corerun.exe 71.03 ns 0.308 ns 0.257 ns 0.47
IsMatch \netcore31\corerun.exe 149.80 ns 0.913 ns 0.809 ns 1.00

IndexOfAny最终仍是.NET 5实现中的重要工具,尤为是对于FindFirstChar的实现。 .NET Regex实现使用的现有优化之一是对能够开始表达式的全部可能字符进行分析。 生成一个字符类,而后FindFirstChar使用该字符类对可能开始匹配的下一个位置生成搜索。 这能够经过查看表达式([ab]cd|ef [g-i])jklm的生成代码的反编译版原本看到。 与该表达式的有效匹配只能以'a''b''e'开头,所以优化器生成一个字符类[abe]FindFirstChar而后使用:

public override bool FindFirstChar()
{
    int num = runtextpos;
    string runtext = base.runtext;
    int num2 = runtextend - num;
    if (num2 > 0)
    {
        int result;
        while (true)
        {
            num2--;
            if (!RegexRunner.CharInClass(runtext[num++], "\0\u0004\0acef"))
            {
                if (num2 <= 0)
                {
                    result = 0;
                    break;
                }
                continue;
            }
            num--;
            result = 1;
            break;
        }
        runtextpos = num;
        return (byte)result != 0;
    }
    return false;
}

这里须要注意的几件事:

  • 正如前面所讨论的,咱们能够看到每一个字符都是经过CharInClass求值的。 咱们能够看到传递给CharInClass的字符串是该类的内部可搜索表示(第一个字符表示没有取反,第二个字符表示有四个用于表示范围的字符,第三个字符表示没有Unicode类别) ,而后接下来的四个字符表明两个范围,分别包含下限和上限。

  • 咱们能够看到咱们分别评估每一个字符,而不是可以一块儿评估多个字符。

  • 咱们只看第一个字符,若是匹配,咱们退出以容许引擎彻底执行Go

在.NET 5 Preview 2中,咱们如今生成此代码:

protected override bool FindFirstChar()
{
    int runtextpos = base.runtextpos;
    int runtextend = base.runtextend;
    if (runtextpos <= runtextend - 7)
    {
        ReadOnlySpan<char> readOnlySpan = runtext.AsSpan(runtextpos, runtextend - runtextpos);
        for (int num = 0; num < readOnlySpan.Length - 2; num++)
        {
            int num2 = readOnlySpan.Slice(num).IndexOfAny('a', 'b', 'e');
            num = num2 + num;
            if (num2 < 0 || readOnlySpan.Length - 2 <= num)
            {
                break;
            }

            int num3 = readOnlySpan[num + 1];
            if ((num3 == 'c') | (num3 == 'f'))
            {
                num3 = readOnlySpan[num + 2];
                if (num3 < 128 && ("\0\0\0\0\0\0ΐ\0"[num3 >> 4] & (1 << (num3 & 0xF))) != 0)
                {
                    base.runtextpos = runtextpos + num;
                    return true;
                }
            }
        }
    }

    base.runtextpos = runtextend;
    return false;
}

这里要注意一些有趣的事情:

  • 如今,咱们使用IndexOfAny搜索三个目标字符。 IndexOfAny是矢量化的,所以它能够利用SIMD指令一次比较多个字符,而且咱们为进一步优化IndexOfAny所作的任何将来改进都将隐式归于此类FindFirstChar实现。

  • 若是IndexOfAny找到匹配项,咱们不仅是当即返回以给Go机会执行。相反,咱们对接下来的几个字符进行快速检查,以增长这其实是匹配项的可能性。在原始表达式中,您能够看到可能与第二个字符匹配的惟一值是'c''f',所以该实现对这些字符进行了快速比较检查。您会看到第三个字符必须与'd'[g-i]匹配,所以该实现将这些字符组合到单个字符类[dg-i]中,而后使用位图对其进行评估。后两个字符检查都突出了咱们如今为字符类发出的改进的代码生成。

咱们能够在这样的测试中看到这种潜在的影响:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System;
using System.Linq;
using System.Text.RegularExpressions;
    
public class Program
{
    static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args);
    
    private static Random s_rand = new Random(42);
    
    private Regex _regex = new Regex("([ab]cd|ef[g-i])jklm", RegexOptions.Compiled);
    private string _input = string.Concat(Enumerable.Range(0, 1000).Select(_ => (char)('a' + s_rand.Next(26))));
    
    [Benchmark] public bool IsMatch() => _regex.IsMatch(_input);
}

在个人机器上会产生如下结果:

Method Toolchain Mean Error StdDev Ratio
IsMatch \master\corerun.exe 1.084 us 0.0068 us 0.0061 us 0.08
IsMatch \netcore31\corerun.exe 14.235 us 0.0620 us 0.0550 us 1.00

先前的代码差别也突出了另外一个有趣的改进,特别是旧代码的int num2 = runtextend-num;`` if(num2> 0)和新代码的if(runtextpos <= runtextend-7)之间的差别。。如前所述,RegexParser将输入模式解析为节点树,而后对其进行分析和优化。 .NET 5包括各类新的分析,有些简单,有些更复杂。较简单的示例之一是解析器如今将对表达式进行快速扫描,以肯定是否必须有最小输入长度才能匹配输入。考虑一下表达式[0-9]{3}-[0-9]{2}-[0-9]{4},该表达式可用于匹配美国的社会保险号(三个ASCII数字,破折号,两个ASCII数字,一个破折号,四个ASCII数字)。咱们能够很容易地看到,此模式的任何有效匹配都至少须要11个字符;若是为咱们提供了10个或更少的输入,或者若是咱们在输入末尾找到10个字符之内却没有找到匹配项,那么咱们可能会当即使匹配项失败而无需进一步进行,由于这是不可能的匹配。

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Diagnosers;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;

public class Program
{
    static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args);

    private readonly Regex _regex = new Regex("[0-9]{3}-[0-9]{2}-[0-9]{4}", RegexOptions.Compiled);
    
    [Benchmark] public bool IsMatch() => _regex.IsMatch("123-45-678");
}
Method Toolchain Mean Error StdDev Ratio
IsMatch \master\corerun.exe 19.39 ns 0.148 ns 0.139 ns 0.04
IsMatch \netcore31\corerun.exe 459.86 ns 1.893 ns 1.771 ns 1.00

回溯消除

.NET Regex实现当前使用回溯引擎。这种实现能够支持基于DFA的引擎没法轻松或有效地支持的各类功能,例如反向引用,而且在内存利用率以及常见状况下的吞吐量方面都很是高效。可是,回溯有一个很大的缺点,那就是可能致使退化的状况,即匹配在输入长度上花费了指数时间。这就是.NET Regex类公开设置超时的功能的缘由,所以失控匹配可能会被异常中断。

.NET文档提供了更多详细信息,但能够这样说,开发人员能够编写正则表达式,而不会受到过多的回溯。一种方法是采用“原子组”,该原子组告诉引擎,一旦组匹配,实现就不得回溯到它,一般在这种回溯不会带来好处的状况下使用。考虑与输入aaaa匹配的示例表达式a+b

  • Go引擎开​​始匹配a+。此操做是贪婪的,所以它匹配第一个a,而后匹配aa,而后匹配aaa,而后匹配aaaa。而后,它会显示在输入的末尾。

  • 没有b匹配,所以引擎回溯1,而a+如今匹配aaa

  • 仍然没有b匹配,所以引擎回溯1,而a+如今匹配aa

  • 仍然没有b匹配,所以引擎回溯1,而a+如今匹配a

  • 仍然没有b能够匹配,而a+至少须要1个a,所以匹配失败。

可是,全部这些回溯都被证实是没必要要的。 a+不能匹配b能够匹配的东西,所以在这里进行大量的回溯是不会有成果的。看到这一点,开发人员能够改用表达式(?>a+)b(?>)是原子组的开始和结束,它表示一旦该组匹配而且引擎通过该组,则它必定不能回溯到该组中。而后,使用咱们以前针对aaaa进行匹配的示例,则将发生这种状况:

  • Go引擎开​​始匹配 a+。此操做是贪婪的,所以它匹配第一个a,而后匹配aa,而后匹配 aaa,而后匹配 aaaa。而后,它会显示在输入的末尾。

  • 没有匹配的b,所以匹配失败。

简短得多,这只是一个简单的示例。所以,开发人员能够本身进行此分析并找到手动插入原子组的位置,可是,实际上,有多少开发人员认为这样作或花费时间呢?

相反,.NET 5如今将正则表达式做为节点树优化阶段的一部分进行分析,在发现原子组不会产生语义差别但能够帮助避免回溯的地方添加原子组。例如:

a+b将变成(?>a+)b1,由于没有任何a+能够“回馈”与b相匹配的内容

\d+\s*将变成(?>\d+)(?>\s*),由于没有任何能够匹配\d的东西也能够匹配\s,而且\s在表达式的末尾。

a*([xyz]|hello)将变为(?>a*)([xyz]|hello),由于在成功匹配中,a能够跟着xyzh,而且没有与任何这些重叠。

这只是.NET 5如今将执行的树重写的一个示例。它将进行其余重写,部分目的是消除回溯。例如,如今它将合并彼此相邻的各类形式的循环。考虑退化的例子a*a*a*a*a*a*a*b。在.NET 5中,如今将其重写为功能上等效的a*b,而后根据前面的讨论将其进一步重写为(?>a*)b。这将潜在的很是昂贵的执行转换为具备线性执行时间的执行。因为咱们正在处理不一样的算法复杂性,所以显示示例基准几乎没有意义,可是不管如何我仍是会这样作,只是为了好玩:

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*a*a*a*a*a*a*b", RegexOptions.Compiled);
    
    [Benchmark] public bool IsMatch() => _regex.IsMatch("aaaaaaaaaaaaaaaaaaaaa");
}
Method Toolchain Mean Error StdDev Ratio
IsMatch \master\corerun.exe 379.2 ns 2.52 ns 2.36 ns 0.000
IsMatch \netcore31\corerun.exe 22,367,426.9 ns 123,981.09 ns 115,971.99 ns 1.000

回溯减小不只限于循环。轮换表示回溯的另外一个来源,由于实现方式的匹配方式与您手动匹配时的方式相似:尝试一个轮换分支并继续进行,若是匹配失败,请返回并尝试下一个分支,依此类推。所以,减小交替产生的回溯也是有用的。

如今执行的此类重写之一与交替前缀分解有关。考虑针对文本什么是表达式(?:this|that)的表达式。引擎将匹配内容,而后尝试与此匹配。它不会匹配,所以它将回溯并尝试与此匹配。可是交替的两个分支都以th开头。若是咱们将其排除在外,而后将表达式重写为th(?:is|at),则如今能够避免回溯。引擎将匹配,而后尝试将th与它匹配,而后失败,仅此而已。

这种优化还最终使更多文本暴露给FindFirstChar使用的现有优化。若是模式的开头有多个固定字符,则FindFirstChar将使用Boyer-Moore实如今输入字符串中查找该文本。暴露给Boyer-Moore算法的模式越大,在快速找到匹配并最小化将致使FindFirstChar退出到Go引擎的误报中所能作的越好。经过从这种交替中拉出文本,在这种状况下,咱们增长了Boyer-Moore可用的文本量。

做为另外一个相关示例,.NET 5如今发现即便开发人员未指定也能够隐式锚定表达式的状况,这也有助于消除回溯。考虑用*hello匹配abcdefghijk。该实现将从位置0开始,并在该位置计算表达式。这样作会将整个字符串abcdefghijk.*匹配,而后从那里回溯以尝试匹配hello,这将没法完成。引擎将使匹配失败,而后咱们将升至下一个位置。而后,引擎将把字符串bcdefghijk的其他部分与.*进行匹配,而后从那里回溯以尝试匹配hello,这将再次失败。等等。在这里观察到的是,经过碰到下一个位置进行的重试一般不会成功,而且表达式能够隐式地锚定为仅在行的开头匹配。而后,FindFirstChar能够跳过可能不匹配的位置,并避免在这些位置尝试进行引擎匹配。

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Diagnosers;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;

public class Program
{
    static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args);

    private readonly Regex _regex = new Regex(@".*text", RegexOptions.Compiled);
    
    [Benchmark] public bool IsMatch() => _regex.IsMatch("This is a test.\nDoes it match this?\nWhat about this text?");
}
Method Toolchain Mean Error StdDev Ratio
IsMatch \master\corerun.exe 644.1 ns 3.63 ns 3.39 ns 0.21
IsMatch \netcore31\corerun.exe 3,024.9 ns 22.66 ns 20.09 ns 1.00

(只是为了清楚起见,许多正则表达式仍将在 .NET 5 中采用回溯,所以开发人员仍然须要谨慎运行不可信的正则表达式。)

Regex.* 静态方法和并发

Regex类同时公开实例方法和静态方法。静态方法主要是为了方便起见,由于它们仍然须要在Regex实例上使用和操做。每次使用这些静态方法之一时,该实现均可以实例化一个新的Regex并经历完整的解析/优化/代码生成例程,可是在某些状况下,这将浪费大量的时间和空间。相反,Regex会保留最近使用的Regex对象的缓存,并按使它们惟一的全部内容(例如,模式,RegexOptions甚至在CurrentCulture下(由于这可能会影响IgnoreCase匹配)。此缓存的大小受到限制,以Regex.CacheSize为上限,所以该实现采用了最近最少使用的(LRU)缓存:当缓存已满而且须要添加另外一个Regex时,实现将丢弃最近最少使用的项。缓存。

实现这种LRU缓存的一种简单方法是使用连接列表:每次访问某项时,它都会从列表中删除并从新添加到最前面。可是,这种方法有一个很大的缺点,尤为是在并发世界中:同步。若是每次读取实际上都是一个突变,则咱们须要确保并发读取(并发突变)不会破坏列表。这样的列表正是.NET早期版本所采用的列表,而且使用了全局锁来保护它。在.NET Core 2.1中,社区成员提交的一项不错的更改经过容许访问最近使用的无锁项在某些状况下对此进行了改进,从而提升了经过静态使用相同Regex的工做负载的吞吐量和可伸缩性。方法反复。可是,对于其余状况,实现仍然锁定在每种用法上。

经过查看诸如Concurrency Visualizer之类的工具,能够看到此锁定的影响,该工具是Visual Studio的扩展,可在其扩展程序库中使用。经过在分析器下运行这样的示例应用程序:

using System.Text.RegularExpressions;
using System.Threading.Tasks;
    
class Program
{
    static void Main()
    {
        Parallel.Invoke(
            () => { while (true) Regex.IsMatch("abc", "^abc$"); },
            () => { while (true) Regex.IsMatch("def", "^def$"); },
            () => { while (true) Regex.IsMatch("ghi", "^ghi$"); },
            () => { while (true) Regex.IsMatch("jkl", "^jkl$"); });
    }
}

咱们能够看到这样的图像:

每行都是一个线程,它是此Parallel.Invoke的一部分。 绿色区域是线程实际执行代码的时间。 黄色区域表示操做系统已抢占该线程的缘由,由于该线程须要内核运行另外一个线程。 红色区域表示线程被阻止等待某物。 在这种状况下,全部红色是由于线程正在等待Regex缓存中的共享全局锁。

在.NET 5中,图片看起来像这样:

注意,没有更多的红色部分。 这是由于缓存已被重写为彻底无锁的读取; 惟一得到锁的时间是将新的Regex添加到缓存中,可是即便发生这种状况,其余线程也能够继续从缓存中读取实例并使用它们。 这意味着,只要为应用程序及其常规使用的Regex静态方法正确调整Regex.CacheSize的大小,此类访问将再也不招致它们过去的延迟。 到今天为止,该值默认为15,可是该属性具备设置器,所以能够对其进行更改以更好地知足应用程序的需求。

静态方法的分配也获得了改进,方法是精确地更改缓存内容,从而避免分配没必要要的包装对象。 咱们能够经过上一个示例的修改版本看到这一点:

using System.Text.RegularExpressions;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        Parallel.Invoke(
            () => { for (int i = 0; i < 10_000; i++) Regex.IsMatch("abc", "^abc$"); },
            () => { for (int i = 0; i < 10_000; i++) Regex.IsMatch("def", "^def$"); },
            () => { for (int i = 0; i < 10_000; i++) Regex.IsMatch("ghi", "^ghi$"); },
            () => { for (int i = 0; i < 10_000; i++) Regex.IsMatch("jkl", "^jkl$"); });
    }
}

使用Visual Studio中的.NET对象分配跟踪工具运行它。 左边是.NET Core 3.1,右边是.NET 5 Preview 2:

特别要注意的是,左侧包含40,000个分配的行,而右侧只有4个。

其余开销减小

咱们已经介绍了.NET 5中对正则表达式进行的一些关键改进,但该列表毫不是完整的。 处处都有一些较小的优化清单,尽管咱们不能在这里列举全部的优化清单,但咱们能够逐步介绍更多。

在某些地方,咱们已经采用了前面讨论过的矢量化形式。 例如,当使用RegexOptions.Compiled且该模式包含一个字符串字符串时,编译器将分别检查每一个字符。 若是查看诸如abcd之类的表达式的反编译代码,就会看到如下内容:

if (4 <= runtextend - runtextpos &&
    runtext[runtextpos] == 'a' &&
    runtext[runtextpos + 1] == 'b' &&
    runtext[runtextpos + 2] == 'c' &&
    runtext[runtextpos + 3] == 'd')

在.NET 5中,当使用DynamicMethod建立编译后的代码时,咱们如今尝试比较Int64值(在64位系统上,或在32位系统上比较Int32),而不是比较单个字符。 这意味着对于上一个示例,咱们如今改成生成与此相似的代码:

if (3u < (uint)readOnlySpan.Length && *(long*)readOnlySpan._pointer == 28147922879250529L)

(我说“相似”,由于咱们没法在C#中表示生成的确切IL,这与使用Unsafe类型的成员更加一致。)咱们这里没必要担忧字节顺序问题,由于生成用于比较的Int64/Int32值的代码与加载用于比较的输入值的同一台计算机(甚至在同一进程中)发生。

另外一个示例是先前在先前生成的代码示例中实际显示的内容,但已被掩盖。在比较@"a\sb"表达式的输出时,您可能以前已经注意到,之前的代码包含对CheckTimeout()的调用,可是新代码没有。此CheckTimeout()函数用于检查咱们的执行时间是否超过了Regex构造时提供给其的超时值所容许的时间。可是,在没有提供超时的状况下使用的默认超时是“无限”,所以“无限”是很是常见的值。因为咱们永远不会超过无限超时,所以当咱们为RegexOptions.Compiled正则表达式编译代码时,咱们会检查超时,若是是无限超时,则跳过生成这些CheckTimeout()调用。

在其余地方也存在相似的优化。例如,默认状况下,Regex执行区分大小写的比较。仅在指定RegexOptions.IgnoreCase的状况下(或者表达式自己包含执行不区分大小写的匹配的指令)才使用不区分大小写的比较,而且仅当使用不区分大小写的比较时,咱们才须要访问CultureInfo.CurrentCulture以肯定如何进行比较。此外,若是指定了RegexOptions.InvariantCulture,则咱们也无需访问CultureInfo.CurrentCulture,由于它将永远不会使用。全部这些意味着,若是咱们证实再也不须要它,则能够避免生成访问CultureInfo.CurrentCulture的代码。最重要的是,咱们能够经过发出对char.ToLowerInvariant而不是char.ToLower(CultureInfo.InvariantCulture)的调用来使RegexOptions.InvariantCulture更快,尤为是由于.NET 5中ToLowerInvariant也获得了改进(还有另外一个示例,其中将Regex更改成使用其余框架功能时,只要咱们改进这些已利用的功能,它就会隐式受益。

另外一个有趣的更改是Regex.ReplaceRegex.Split。这些方法被实现为对Regex.Match的封装,将其功能分层。可是,这意味着每次找到匹配项时,咱们都将退出扫描循环,逐步遍历抽象的各个层次,在匹配项上执行工做,而后调回引擎,以正确的方式进行工做返回到扫描循环,依此类推。最重要的是,每一个匹配项都须要建立一个新的Match对象。如今在.NET 5中,这些方法在内部使用了一个专用的基于回调的循环,这使咱们可以停留在严格的扫描循环中,并一遍又一遍地重用同一个Match对象(若是公开公开,这是不安全的,可是能够做为内部实施细节来完成)。在实现“替换”中使用的内存管理也已调整为专一于跟踪要替换或不替换的输入区域,而不是跟踪每一个单独的字符。这样作的最终结果可能对吞吐量和内存分配都产生至关大的影响,尤为是对于输入量很是长且替换次数不多的输入。

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Linq;
using System.Text.RegularExpressions;
    
[MemoryDiagnoser]
public class Program
{
    static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args);
    
    private Regex _regex = new Regex("a", RegexOptions.Compiled);
    private string _input = string.Concat(Enumerable.Repeat("abcdefghijklmnopqrstuvwxyz", 1_000_000));
    
    [Benchmark] public string Replace() => _regex.Replace(_input, "A");
}
Method Toolchain Mean Error StdDev Ratio Gen 0 Gen 1 Gen 2 Allocated
Replace \master\corerun.exe 93.79 ms 1.120 ms 0.935 ms 0.45 81.59 MB
Replace \netcore31\corerun.exe 209.59 ms 3.654 ms 3.418 ms 1.00 33666.6667 666.6667 666.6667 371.96 MB

看看效果

全部这些结合在一块儿,能够在各类基准上产生明显更好的性能。 为了说明这一点,我在网上搜索了正则表达式基准并进行了几回测试。

mariomka/regex-benchmark的基准测试已经具备C#版本,所以简单地编译和运行这很容易:

using System;
using System.IO;
using System.Text.RegularExpressions;
using System.Diagnostics;

class Benchmark
{
    static void Main(string[] args)
    {
        if (args.Length != 1)
        {
            Console.WriteLine("Usage: benchmark <filename>");
            Environment.Exit(1);
        }

        StreamReader reader = new System.IO.StreamReader(args[0]);
        string data = reader.ReadToEnd();
    
        // Email
        Benchmark.Measure(data, @"[\w\.+-]+@[\w\.-]+\.[\w\.-]+");
    
        // URI
        Benchmark.Measure(data, @"[\w]+://[^/\s?#]+[^\s?#]+(?:\?[^\s#]*)?(?:#[^\s]*)?");
    
        // IP
        Benchmark.Measure(data, @"(?:(?: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])");
    }
    
    static void Measure(string data, string pattern)
    {
        Stopwatch stopwatch = Stopwatch.StartNew();
    
        MatchCollection matches = Regex.Matches(data, pattern, RegexOptions.Compiled);
        int count = matches.Count;
    
        stopwatch.Stop();
    
        Console.WriteLine(stopwatch.Elapsed.TotalMilliseconds.ToString("G", System.Globalization.CultureInfo.InvariantCulture) + " - " + count);
    }
}

在个人机器上,这是使用.NET Core 3.1的控制台输出:

966.9274 - 92
746.3963 - 5301
65.6778 - 5

以及使用.NET 5的控制台输出:

274.3515 - 92
159.3629 - 5301
15.6075 - 5

破折号前的数字是执行时间,破折号后的数字是答案(所以,第二个数字保持不变是一件好事)。 执行时间急剧降低:分别提升了3.5倍,4.6倍和4.2倍!

我还找到了 https://zherczeg.github.io/sljit/regex_perf.html,它具备各类基准,但没有C#版本。 我将其转换为Benchmark.NET测试:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.IO;
using System.Text.RegularExpressions;
    
[MemoryDiagnoser]
public class Program
{
    static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args);
    
    private static string s_input = File.ReadAllText(@"d:\mtent12.txt");
    private Regex _regex;
    
    [GlobalSetup]
    public void Setup() => _regex = new Regex(Pattern, RegexOptions.Compiled);
    
    [Params(
        @"Twain",
        @"(?i)Twain",
        @"[a-z]shing",
        @"Huck[a-zA-Z]+|Saw[a-zA-Z]+",
        @"\b\w+nn\b",
        @"[a-q][^u-z]{13}x",
        @"Tom|Sawyer|Huckleberry|Finn",
        @"(?i)Tom|Sawyer|Huckleberry|Finn",
        @".{0,2}(Tom|Sawyer|Huckleberry|Finn)",
        @".{2,4}(Tom|Sawyer|Huckleberry|Finn)",
        @"Tom.{10,25}river|river.{10,25}Tom",
        @"[a-zA-Z]+ing",
        @"\s[a-zA-Z]{0,12}ing\s",
        @"([A-Za-z]awyer|[A-Za-z]inn)\s"
    )]
    public string Pattern { get; set; }
    
    [Benchmark] public bool IsMatch() => _regex.IsMatch(s_input);
}

并对照该页面提供的大约20MB文本文件输入运行它,获得如下结果:

Method Toolchain Pattern Mean Ratio
IsMatch \master\corerun.exe (?i)T(…)Finn [31] 12,703.08 ns 0.32
IsMatch \netcore31\corerun.exe (?i)T(…)Finn [31] 40,207.12 ns 1.00
IsMatch \master\corerun.exe (?i)Twain 159.81 ns 0.84
IsMatch \netcore31\corerun.exe (?i)Twain 189.49 ns 1.00
IsMatch \master\corerun.exe ([A-Z(…)nn)\s [29] 6,903,345.70 ns 0.10
IsMatch \netcore31\corerun.exe ([A-Z(…)nn)\s [29] 67,388,775.83 ns 1.00
IsMatch \master\corerun.exe .{0,2(…)Finn) [35] 1,311,160.79 ns 0.68
IsMatch \netcore31\corerun.exe .{0,2(…)Finn) [35] 1,942,021.93 ns 1.00
IsMatch \master\corerun.exe .{2,4(…)Finn) [35] 1,202,730.97 ns 0.67
IsMatch \netcore31\corerun.exe .{2,4(…)Finn) [35] 1,790,485.74 ns 1.00
IsMatch \master\corerun.exe Huck[(…)A-Z]+ [26] 282,030.24 ns 0.01
IsMatch \netcore31\corerun.exe Huck[(…)A-Z]+ [26] 19,908,290.62 ns 1.00
IsMatch \master\corerun.exe Tom.{(…)5}Tom [33] 8,817,983.04 ns 0.09
IsMatch \netcore31\corerun.exe Tom.{(…)5}Tom [33] 94,075,640.48 ns 1.00
IsMatch \master\corerun.exe TomS(…)Finn [27] 39,214.62 ns 0.14
IsMatch \netcore31\corerun.exe TomS(…)Finn [27] 281,452.38 ns 1.00
IsMatch \master\corerun.exe Twain 64.44 ns 0.77
IsMatch \netcore31\corerun.exe Twain 83.61 ns 1.00
IsMatch \master\corerun.exe [a-q][^u-z]{13}x 1,695.15 ns 0.09
IsMatch \netcore31\corerun.exe [a-q][^u-z]{13}x 19,412.31 ns 1.00
IsMatch \master\corerun.exe [a-zA-Z]+ing 3,042.12 ns 0.31
IsMatch \netcore31\corerun.exe [a-zA-Z]+ing 9,896.25 ns 1.00
IsMatch \master\corerun.exe [a-z]shing 28,212.30 ns 0.24
IsMatch \netcore31\corerun.exe [a-z]shing 117,954.06 ns 1.00
IsMatch \master\corerun.exe \b\w+nn\b 32,278,974.55 ns 0.21
IsMatch \netcore31\corerun.exe \b\w+nn\b 152,395,335.00 ns 1.00
IsMatch \master\corerun.exe \s[a-(…)ing\s [21] 1,181.86 ns 0.23
IsMatch \netcore31\corerun.exe \s[a-(…)ing\s [21] 5,161.79 ns 1.00

这些比例中的一些很是有趣。

另外一个是“The Computer Language Benchmarks Game”中的“ regex-redux”基准。 在dotnet/performance回购中利用了此实现,所以我运行了该代码:

Method Toolchain options Mean Error StdDev Median Min Max Ratio RatioSD Gen 0 Gen 1 Gen 2 Allocated
RegexRedux_5 \master\corerun.exe Compiled 7.941 ms 0.0661 ms 0.0619 ms 7.965 ms 7.782 ms 8.009 ms 0.30 0.01 2.67 MB
RegexRedux_5 \netcore31\corerun.exe Compiled 26.311 ms 0.5058 ms 0.4731 ms 26.368 ms 25.310 ms 27.198 ms 1.00 0.00 1571.4286 12.19 MB

所以,在此基准上,.NET 5的吞吐量是.NET Core 3.1的3.3倍。

呼吁社区行动

咱们但愿您的反馈和贡献有多种方式。

下载.NET 5 Preview 2并使用正则表达式进行尝试。您看到可衡量的收益了吗?若是是这样,请告诉咱们。若是没有,也请告诉咱们,以便咱们共同努力,为您最有价值的表达方式改善效果。

是否有对您很重要的特定正则表达式?若是是这样,请与咱们分享;咱们很乐意使用来自您的真实正则表达式,您的输入数据以及相应的预期结果来扩展咱们的测试套件,以帮助确保在对咱们进行进一步改进时,不会退回对您而言重要的事情代码库。实际上,咱们欢迎PR到dotnet/runtime来以这种方式扩展测试套件。您能够看到,除了成千上万个综合测试用例以外,Regex测试套件还包含大量示例,这些示例来自文档,教程和实际应用程序。若是您认为应该在此处添加表达式,请提交PR。做为性能改进的一部分,咱们已经更改了不少代码,尽管咱们一直在努力进行验证,可是确定会漏入一些错误。您对本身的重要表达的反馈将有助于您实现这一目标!

与 .NET 5中已经完成的工做同样,咱们还列出了能够探索的其余已知工做的清单,这些工做已编入dotnet/runtime#1349。咱们将在这里欢迎其余建议,更欢迎在此处概述的一些想法的实际原型设计或产品化(经过适当的性能审查,测试等)。一些示例:

  • 改进自动添加原子组的循环。如本文所述,咱们如今自动在多个位置插入原子组,咱们能够检测到它们可能有助于减小回溯,同时保持语义相同。咱们知道,可是,咱们的分析存在一些空白,填补这些空白很是好。例如,该实现如今将a*b+c更改成(?>a*)(?>b+)c,由于它将看到b+不会提供任何能够匹配c的东西,而a*不会给出能够匹配b的任何东西(b+表示必须至少有一个b)。可是,即便后者合适,表达式a*b*c也会转换为a*(?>b*)c而不是(?>a*)(?>b*)c。这里的问题是,咱们目前仅查看序列中的下一个节点,而且b*可能匹配零项,这意味着a*以后的下一个节点多是c,而咱们目前的眼光并不那么远。

  • 改进原子基团自动交替添加的功能。根据对交替的分析,咱们能够作更多的工做来将交替自动升级为原子。例如,给定相似(Bonjour|Hello), .*的表达式,咱们知道,若是Bonjour匹配,则Hello也不可能匹配,所以能够将这种替换设置为原子的。

  • 改善IndexOfAny的向量化。如本文所述,咱们如今尽量使用内置函数,这样对这些表达式的改进也将使Regex受益(除了使用它们的全部其余工做负载)。如今,咱们在某些正则表达式中对IndexOfAny的依赖度很高,以致于它能够表明处理的很大一部分,例如在前面显示的“ regex redux”基准上,约有30%的时间花费在IndexOfAny上。这里有机会改进此功能,从而也改进Regex。这由 dotnet/runtime#25023 单独引入。

  • 制做DFA实现原型。 .NET正则表达式支持的某些方面很难使用基于DFA的正则表达式引擎来完成,可是某些操做应该是能够实现的,而没必要担忧。例如,Regex.IsMatch没必要关心捕获语义(.NET在捕获方面有一些额外的功能,这使其比其余实现更具挑战性),所以,若是该表达式不包含诸如反向引用之类的问题构造,或环顾四周,对于IsMatch,咱们能够探索使用基于DFA的引擎,而且有可能随着时间的推移而获得更普遍的使用。

  • 改善测试。若是您对测试的兴趣超过对实施的兴趣,那么在这里也须要作一些有价值的事情。咱们的代码覆盖率已经很高,可是仍然存在差距。插入这些代码(并可能在该过程当中找到无效代码)将颇有帮助。查找并合并其余通过适当许可的测试套件以提供更多涵盖各类表达式的内容也颇有价值。

谢谢阅读,翻译自 Regex Performance Improvements in .NET 5