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

2023-03-07 06:00:28

原文 | 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字元類,不如搜尋子串"