【譯】.NET 7 中的效能改進(十二)

鄭子銘發表於2023-03-06

原文 | 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

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

如有任何疑問,請與我聯絡 (MingsonZheng@outlook.com)

相關文章