.NET 5 中的正則引擎效能改進(翻譯)
阿新 • • 發佈:2020-04-04
## 前言
`System.Text.RegularExpressions` 名稱空間已經在 .NET 中使用了多年,一直追溯到 .NET Framework 1.1。它在 .NET 實施本身的數百個位置中使用,並且直接被成千上萬個應用程式使用。在所有這些方面,它也是 CPU 消耗的重要來源。
但是,從效能角度來看,正則表示式在這幾年間並沒有獲得太多關注。在 2006 年的 .NET Framework 2.0 中更改了其快取策略。 .NET Core 2.0 在 `RegexOptions.Compiled` 之後看到了這個實現的到來(在 .NET Core 1.x 中,`RegexOptions.Compiled` 選項是一個 nop)。 .NET Core 3.0 受益於 Regex 內部的一些內部更新,以在某些情況下利用 `Span` 提高記憶體利用率。在此過程中,一些非常受歡迎的社群貢獻改進了目標區域,例如 [dotnet/corefx#32899](https://github.com/dotnet/corefx/pull/32899),它減少了使用表示式 `RegexOptions.Compiled | RegexOptions.IgnoreCase` 時對`CultureInfo.CurrentCulture` 的訪問。但除此之外,實施很大程度上還是在15年前。
對於 .NET 5(本週釋出了 [Preview 2](https://devblogs.microsoft.com/dotnet/announcing-net-5-0-preview-2/)),我們已對 Regex 引擎進行了一些重大改進。在我們嘗試過的許多表達式中,這些更改通常會使吞吐量提高3到6倍,在某些情況下甚至會提高更多。在本文中,我將逐步介紹 .NET 5 中 `System.Text.RegularExpressions` 進行的許多更改。這些更改對我們自己的使用產生了可衡量的影響,我們希望這些改進將帶來可衡量的勝利在您的庫和應用中。
## Regex內部知識
要了解所做的某些更改,瞭解一些Regex內部知識很有幫助。
Regex建構函式完成所有工作,以採用正則表示式模式並準備對其進行匹配輸入:
- `RegexParser`。該模式被送入內部`RegexParser`型別,該型別理解正則表示式語法並將其解析為節點樹。例如,表示式`a|bcd`被轉換為具有兩個子節點的“替代” `RegexNode`,一個子節點表示單個字元`a`,另一個子節點表示“多個” `bcd`。解析器還對樹進行優化,將一棵樹轉換為另一個等效樹,以提供更有效的表示和/或可以更高效地執行該樹。
- `RegexWriter`。節點樹不是執行匹配的理想表示,因此解析器的輸出將饋送到內部`RegexWriter`類,該類會寫出一系列緊湊的操作碼,以表示執行匹配的指令。這種型別的名稱是“ writer”,因為它“寫”出了操作碼。其他引擎通常將其稱為“編譯”,但是 .NET 引擎使用不同的術語,因為它保留了“編譯”術語,用於 MSIL 的可選編譯。
- `RegexCompiler`(可選)。如果未指定`RegexOptions.Compiled`選項,則內部`RegexInterpreter`類稍後在匹配時使用`RegexWriter`輸出的操作碼來解釋/執行執行匹配的指令,並且在Regex構造過程中不需要任何操作。但是,如果指定了`RegexOptions.Compiled`,則建構函式將獲取先前輸出的資產,並將其提供給內部`RegexCompiler`類。然後,`RegexCompiler`使用反射發射生成MSIL,該MSIL表示解釋程式將要執行的工作,但專門針對此特定表示式。例如,當與模式中的字元“ c”匹配時,直譯器將需要從變數中載入比較值,而編譯器會將“ c”硬編碼為生成的IL中的常量。
一旦構造了正則表示式,就可以通過`IsMatch`,`Match`,`Matches`,`Replace`和`Split`等例項方法將其用於匹配(`Match`返回`Match`物件,該物件公開了`NextMatch`方法,該方法可以迭代匹配並延遲計算) 。這些操作最終以“掃描”迴圈(某些其他引擎將其稱為“傳輸”迴圈)結束,該迴圈本質上執行以下操作:
```csharp
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`,並用解釋正則表示式的實現覆蓋`FindFirstChar`和`Go`,這由`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`](https://docs.microsoft.com/dotnet/api/system.text.regularexpressions.regexrunner.charinclass?view=netcore-3.1) 方法,以確定字元類中是否包含給定的Char。
![](https://devblogs.microsoft.com/dotnet/wp-content/uploads/sites/10/2020/03/regex-ctor-char-class.png)
在.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`吐出。然後我們可以像對待其他任何點陣圖一樣對待它,例如為了確定給定的字元是否匹配,我們生成以下內容:
```csharp
bool result = ch < 128 ? (lookup[c >> 4] & (1 << (c & 0xF))) != 0 : NonAsciiFallback;
```
換句話說,我們使用字元的高三位選擇查詢表字符串中的第0至第7個字元,然後使用低四位作為該位置16位值的索引; 如果是1,則表示匹配,如果不是,則表示沒有匹配。 對於大於等於128的字元,我們需要一個回退,根據對字元類進行的一些分析,回退可能是各種各樣的事情。 最糟糕的情況是,回退只是對`RegexRunner.CharInClass`的呼叫,否則我們會做得更好。 例如,很常見的是,我們可以從輸入模式中得知所有可能的匹配項均小於<128,在這種情況下,我們根本不需要回退,例如 對於字元類`[0-9a-fA-F]`(又稱十六進位制),我們將生成以下內容:
```csharp
bool result = ch < 128 && (lookup[c >> 4] & (1 << (c & 0xF))) != 0;
```
相反,我們可以確定127以上的每個字元都將去匹配。 例如,字元類`[^aeiou]`(除ASCII小寫母音外的所有字元)將產生與以下程式碼等效的程式碼:
```csharp
bool result = ch >= 128 || (lookup[c >> 4] & (1 << (c & 0xF))) != 0;
```
等等。
以上都是針對`RegexOptions.Compiled`,但解釋表示式並不會被冷落。 對於解釋表示式,我們當前會生成一個類似的查詢表,但是我們這樣做是很懶惰的,第一次看到給定輸入字元時會填充該表,然後針對該字元類針對該字元的所有將來評估儲存該答案。 (我們可能會重新研究如何執行此操作,但這是從 .NET 5 Preview 2 開始存在的方式。)
這樣做的最終結果可能是頻繁評估字元類的表示式的吞吐量顯著提高。 例如,這是一個微基準測試,可將ASCII字母和數字與具有62個此類值的輸入進行匹配:
```csharp
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");
}
```
這是我的專案檔案:
```xml
preview
Exe
netcoreapp5.0;netcoreapp3.1
```
在我的計算機上,我有兩個目錄,一個包含.NET Core 3.1,一個包含.NET 5的內部版本(此處標記為master,因為它是[dotnet/runtime](https://github.com/dotnet/runtime)的master分支的內部版本)。 當我執行以上操作針對兩個版本執行基準測試:
```bash
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看起來像這樣:
```csharp
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中,相同的表示式導致生成以下程式碼:
```csharp
protected override void Go()
{
string runtext = base.runtext;
int runtextend = base.runtextend;
int runtextpos;
int start = runtextpos = base.runtextpos;
ReadOnlySpan 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`方法中生成的用於匹配`.*`的程式碼,則該程式碼類似於以下內容:
```csharp
while (--num3 > 0)
{
if (runtext[num++] == '\n')
{
num--;
break;
}
}
```
換句話說,我們將手動遍歷輸入文字字串,逐個字元地查詢 `\n`(請記住,預設情況下,`.`表示“ `\n`以外的任何內容”,因此`.*`表示“匹配所有內容,直到找到`\n`” )。 但是,.NET早已擁有完全執行此類搜尋的方法,例如`IndexOf`,並且從最新版本開始,`IndexOf`是向量化的,因此它可以同時比較多個字元,而不僅僅是單獨檢視每個字元。 現在,在.NET 5中,我們不再像上面那樣生成程式碼,而是得到如下程式碼:
```csharp
num2 = runtext.AsSpan(runtextpos, num).IndexOf('\n');
```
使用`IndexOf`而不是生成我們自己的迴圈,則意味著對Regex中的此類搜尋進行隱式向量化,並且對此類實現的任何改進也都應歸於此。 這也意味著生成的程式碼更簡單。 可以用這樣的基準測試來檢視其影響:
```csharp
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`然後使用:
```csharp
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中,我們現在生成此程式碼:
```csharp
protected override bool FindFirstChar()
{
int runtextpos = base.runtextpos;
int runtextend = base.runtextend;
if (runtextpos <= runtextend - 7)
{
ReadOnlySpan 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]`中,然後使用點陣圖對其進行評估。後兩個字元檢查都突出了我們現在為字元類發出的改進的程式碼生成。
我們可以在這樣的測試中看到這種潛在的影響:
```csharp
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個字元以內卻沒有找到匹配項,那麼我們可能會立即使匹配項失敗而無需進一步進行,因為這是不可能的匹配。
```csharp
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的引擎](https://docs.microsoft.com/dotnet/standard/base-types/details-of-regular-expression-behavior#benefits-of-the-nfa-engine)無法輕鬆或有效地支援的各種功能,例如反向引用,並且在記憶體利用率以及常見情況下的吞吐量方面都非常高效。但是,回溯有一個很大的缺點,那就是可能導致退化的情況,即匹配在輸入長度上花費了指數時間。這就是.NET Regex類公開設定[超時](https://docs.microsoft.com/dotnet/api/system.text.regularexpressions.regex.-ctor?view=netcore-3.1#System_Text_RegularExpressions_Regex__ctor_System_String_System_Text_RegularExpressions_RegexOptions_System_TimeSpan_)的功能的原因,因此失控匹配可能會被異常中斷。
[.NET文件](https://docs.microsoft.com/dotnet/standard/base-types/backtracking-in-regular-expressions)提供了更多詳細資訊,但可以這樣說,開發人員可以編寫正則表示式,而不會受到過多的回溯。一種方法是採用“原子組”,該原子組告訴引擎,一旦組匹配,實現就不得回溯到它,通常在這種回溯不會帶來好處的情況下使用。考慮與輸入`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`可以跟著`x`,`y`,`z`或`h`,並且沒有與任何這些重疊。
這只是.NET 5現在將執行的樹重寫的一個示例。它將進行其他重寫,部分目的是消除回溯。例如,現在它將合併彼此相鄰的各種形式的迴圈。考慮退化的例子`a*a*a*a*a*a*a*b`。在.NET 5中,現在將其重寫為功能上等效的`a*b`,然後根據前面的討論將其進一步重寫為`(?>a*)b`。這將潛在的非常昂貴的執行轉換為具有線性執行時間的執行。由於我們正在處理不同的演算法複雜性,因此顯示示例基準幾乎沒有意義,但是無論如何我還是會這樣做,只是為了好玩:
```csharp
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`可以跳過可能不匹配的位置,並避免在這些位置嘗試進行引擎匹配。
```csharp
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中,社群成員提交的一項[不錯的更改](https://github.com/dotnet/corefx/pull/27278)通過允許訪問最近使用的無鎖項在某些情況下對此進行了改進,從而提高了通過靜態使用相同Regex的工作負載的吞吐量和可伸縮性。方法反覆。但是,對於其他情況,實現仍然鎖定在每種用法上。
通過檢視諸如Concurrency Visualizer之類的工具,可以看到此鎖定的影響,該工具是Visual Studio的擴充套件,可在其擴充套件程式庫中使用。通過在分析器下執行這樣的示例應用程式:
```csharp
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$"); });
}
}
```
我們可以看到這樣的影象:
![](https://devblogs.microsoft.com/dotnet/wp-content/uploads/sites/10/2020/03/regex-static-contention-before.png)
每行都是一個執行緒,它是此`Parallel.Invoke`的一部分。 綠色區域是執行緒實際執行程式碼的時間。 黃色區域表示作業系統已搶佔該執行緒的原因,因為該執行緒需要核心執行另一個執行緒。 紅色區域表示執行緒被阻止等待某物。 在這種情況下,所有紅色是因為執行緒正在等待Regex快取中的共享全域性鎖。
在.NET 5中,圖片看起來像這樣:
![](https://devblogs.microsoft.com/dotnet/wp-content/uploads/sites/10/2020/03/regex-static-contention-after.png)
注意,沒有更多的紅色部分。 這是因為快取已被重寫為完全無鎖的讀取; 唯一獲得鎖的時間是將新的Regex新增到快取中,但是即使發生這種情況,其他執行緒也可以繼續從快取中讀取例項並使用它們。 這意味著,只要為應用程式及其常規使用的Regex靜態方法正確調整`Regex.CacheSize`的大小,此類訪問將不再招致它們過去的延遲。 到今天為止,該值預設為15,但是該屬性具有設定器,因此可以對其進行更改以更好地滿足應用程式的需求。
靜態方法的分配也得到了改進,方法是精確地更改快取內容,從而避免分配不必要的包裝物件。 我們可以通過上一個示例的修改版本看到這一點:
```csharp
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物件分配跟蹤工具](https://docs.microsoft.com/visualstudio/profiling/dotnet-alloc-tool)執行它。 左邊是.NET Core 3.1,右邊是.NET 5 Preview 2:
![](https://devblogs.microsoft.com/dotnet/wp-content/uploads/sites/10/2020/03/regex-static-alloc.png)
特別要注意的是,左側包含40,000個分配的行,而右側只有4個。
### 其他開銷減少
我們已經介紹了.NET 5中對正則表示式進行的一些關鍵改進,但該列表絕不是完整的。 到處都有一些較小的優化清單,儘管我們不能在這裡列舉所有的優化清單,但我們可以逐步介紹更多。
在某些地方,我們已經採用了前面討論過的向量化形式。 例如,當使用`RegexOptions.Compiled`且該模式包含一個字串字串時,編譯器將分別檢查每個字元。 如果檢視諸如`abcd`之類的表示式的反編譯程式碼,就會看到以下內容:
```csharp
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`),而不是比較單個字元。 這意味著對於上一個示例,我們現在改為生成與此類似的程式碼:
```csharp
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.Replace`和`Regex.Split`。這些方法被實現為對`Regex.Match`的封裝,將其功能分層。但是,這意味著每次找到匹配項時,我們都將退出掃描迴圈,逐步遍歷抽象的各個層次,在匹配項上執行工作,然後調回引擎,以正確的方式進行工作返回到掃描迴圈,依此類推。最重要的是,每個匹配項都需要建立一個新的`Match`物件。現在在.NET 5中,這些方法在內部使用了一個專用的基於回撥的迴圈,這使我們能夠停留在嚴格的掃描迴圈中,並一遍又一遍地重用同一個`Match`物件(如果公開公開,這是不安全的,但是可以作為內部實施細節來完成)。在實現“替換”中使用的記憶體管理也已調整為專注於跟蹤要替換或不替換的輸入區域,而不是跟蹤每個單獨的字元。這樣做的最終結果可能對吞吐量和記憶體分配都產生相當大的影響,尤其是對於輸入量非常長且替換次數很少的輸入。
```csharp
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](https://github.com/mariomka/regex-benchmark/blob/969eca41b4302c8c58d0bd547c36b5964f0b18fb/csharp/Benchmark.cs)的基準測試已經具有C#版本,因此簡單地編譯和執行這很容易:
```csharp
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 ");
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的控制檯輸出:
```bash
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測試:
```csharp
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”](https://github.com/dotnet/performance/blob/5034fb017953fdecdf3a3a115202d2a71c831941/src/benchmarks/micro/runtime/BenchmarksGame/regex-redux-5.cs)基準。 在[dotnet/performance](https://github.com/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](https://dotnet.microsoft.com/download/dotnet-core/5.0)並使用正則表示式進行嘗試。您看到可衡量的收益了嗎?如果是這樣,請告訴我們。如果沒有,也請告訴我們,以便我們共同努力,為您最有價值的表達方式改善效果。
是否有對您很重要的特定正則表示式?如果是這樣,請與我們分享;我們很樂意使用來自您的真實正則表示式,您的輸入資料以及相應的預期結果來擴充套件我們的測試套件,以幫助確保在對我們進行進一步改進時,不會退回對您而言重要的事情程式碼庫。實際上,我們歡迎PR到[dotnet/runtime](https://github.com/dotnet/runtime)來以這種方式擴充套件測試套件。您可以看到,除了成千上萬個綜合測試用例之外,Regex測試套件還包含[大量示例](https://github.com/dotnet/runtime/blob/820cc140f145dd669378fe5252f34f3c4a3cb8b4/src/libraries/System.Text.RegularExpressions/tests/Regex.KnownPattern.Tests.cs),這些示例來自文件,教程和實際應用程式。如果您認為應該在此處新增表示式,請提交PR。作為效能改進的一部分,我們已經更改了很多程式碼,儘管我們一直在努力進行驗證,但是肯定會漏入一些錯誤。您對自己的重要表達的反饋將有助於您實現這一目標!
與 .NET 5中已經完成的工作一樣,我們還列出了可以探索的其他已知工作的清單,這些工作已編入[dotnet/runtime#1349](https://github.com/dotnet/runtime/issues/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](https://github.com/dotnet/runtime/issues/25023) 單獨引入。
- 製作DFA實現原型。 .NET正則表示式支援的某些方面很難使用基於DFA的正則表示式引擎來完成,但是某些操作應該是可以實現的,而不必擔心。例如,`Regex.IsMatch`不必關心捕獲語義(.NET在捕獲方面有一些額外的功能,這使其比其他實現更具挑戰性),因此,如果該表示式不包含諸如反向引用之類的問題構造,或環顧四周,對於`IsMatch`,我們可以探索使用基於DFA的引擎,並且有可能隨著時間的推移而得到更廣泛的使用。
- 改善測試。如果您對測試的興趣超過對實施的興趣,那麼在這裡也需要做一些有價值的事情。我們的程式碼覆蓋率已經很高,但是仍然存在差距。插入這些程式碼(並可能在該過程中找到無效程式碼)將很有幫助。查詢併合並其他經過適當許可的測試套件以提供更多涵蓋各種表示式的內容也很有價值。
謝謝閱讀,翻譯自 [Regex Performance Improvements in .NET 5](https://devblogs.microsoft.com/dotnet/regex-performance-improvements-in