.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

相關文章
相關標籤/搜索