原文 | Stephen Toub
翻譯 | 鄭子銘
New APIs
在.NET 7中,Regex得到了幾個新的方法,所有這些方法都能提高效能。新的API的簡單性可能也誤導了為實現它們所需的工作量,特別是由於新的API都支援ReadOnlySpan
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
首先,我們使FindFirstChar和Go成為虛擬的,而不是抽象的。分割這些方法的設計在很大程度上是過時的,特別是強制分離了一個處理階段,即找到匹配的下一個可能的位置,然後是在該位置實際執行匹配的階段,這與所有的引擎並不一致,比如NonBacktracking使用的引擎(它最初將FindFirstChar作為一個nop實現,並將其所有邏輯放在Go中)。然後我們新增了一個新的虛擬掃描方法,重要的是,它需要一個ReadOnlySpan
[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
好了,現在引擎能夠被交給跨度輸入並處理它們,很好,我們能用它做什麼?好吧,Regex.IsMatch很簡單:它不需要進行多次匹配,因此不需要擔心如何儲存輸入的ReadOnlySpan
所以,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
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
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#63428、dotnet/runtime#68400、dotnet/runtime#64254和dotnet/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#68989、dotnet/runtime#63299和dotnet/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)