Performance Improvements in .NET 8 & 7 & 6 -- String【翻譯】

原文:https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/#strings-arrays-and-spans

.Net 8

.NET 8在數據處理領域有了巨大的改進,特別是在有效操作字符串,數組和Span方面。既然我們剛剛談到了UTF8和IUtf8SpanFormattable,那就從這裏開始。

UTF8

如前所述,現在有很多類型實現了IUtf8SpanFormattable。我注意到所有的數值原始類型,DateTime{Offset},和Guid,以及dotnet/runtime#84556,System.Version類型也實現了它,同樣,由於dotnet/runtime#84487,IPAddress和新的IPNetwork類型也實現了它。然而,.NET 8不僅在所有這些類型上提供了這個接口的實現,而且還在一個關鍵的地方使用了這個接口。

如果你還記得,C# 10和.NET 6的字符串插值完全被改造了。這不僅使字符串插值變得更加高效,而且還提供了一種模式,類型可以實現這種模式,以便有效地使用字符串插值語法來做除創建新字符串之外的事情。例如,爲Span添加了一個新的TryWrite擴展方法,使得可以直接將格式化的插值字符串格式化到目標char緩衝區中:

public bool Format(Span<char> span, DateTime dt, out int charsWritten) =>
    span.TryWrite($"Date: {dt:R}", out charsWritten);

上述內容由編譯器轉換爲以下等效內容:

public bool Format(Span<char> span, DateTime dt, out int charsWritten)
{
    var handler = new MemoryExtensions.TryWriteInterpolatedStringHandler(6, 1, span, out bool shouldAppend);
    _ = shouldAppend &&
        handler.AppendLiteral("Date: ") &&
        handler.AppendFormatted<DateTime>(dt, "R");
    return MemoryExtensions.TryWrite(span, ref handler, out charsWritten);
}

該通用的 AppendFormatted 調用的實現會檢查 T 並嘗試做最優的事情。在這種情況下,它會看到 T 實現了 ISpanFormattable,並最終使用其 TryFormat 直接格式化到目標 span 中。

這是針對 UTF16 的。現在有了 IUtf8SpanFormattable,我們有機會做同樣的事情,但是針對 UTF8。這正是 dotnet/runtime#83852 所做的。它引入了新的 Utf8.TryWrite 方法,該方法的行爲與前述的 TryWrite 完全相同,只是以 UTF8 的形式寫入目標 Span,而不是以 UTF16 的形式寫入目標 Span。實現也對 IUtf8SpanFormattable 進行了特殊處理,使用其 TryFormat 直接寫入目標緩衝區。

有了這個,我們可以編寫與我們之前編寫的方法等效的方法:

public bool Format(Span<byte> span, DateTime dt, out int bytesWritten) =>
    Utf8.TryWrite(span, $"Date: {dt:R}", out bytesWritten);

並且這會按照你現在預期的方式進行降低處理:

public bool Format(Span<byte> span, DateTime dt, out int bytesWritten)
{
    var handler = new Utf8.TryWriteInterpolatedStringHandler(6, 1, span, out bool shouldAppend);
    _ = shouldAppend &&
        handler.AppendLiteral("Date: ") &&
        handler.AppendFormatted<DateTime>(dt, "R");
    return Utf8.TryWrite(span, ref handler, out bytesWritten);
}

因此,除了你期望改變的部分之外,它們是相同的。但從某些方面來說,這也是一個問題。看看那個 AppendLiteral("Date: ") 調用。在處理目標爲 Span 的 UTF16 情況下,AppendLiteral 的實現只需要將該字符串複製到目標中;不僅如此,JIT 還會內聯調用,看到正在複製一個字符串字面量,並展開復制操作,使其非常高效。但在處理 UTF8 的情況下,我們不能只是將 UTF16 字符串字符複製到目標的 UTF8 Span 緩衝區中;我們需要對字符串進行 UTF8 編碼。雖然我們當然可以做到這一點(通過引入新的 Encoding.TryGetBytes 方法,dotnet/runtime#84609 和 dotnet/runtime#85120 使得這變得簡單),但需要在運行時反覆花費時間來執行可以在編譯時完成的工作,這是非常低效的。畢竟,我們處理的是在 JIT 時間已知的字符串字面量;如果 JIT 能夠執行 UTF8 編碼,然後像在 UTF16 情況下已經執行的那樣進行展開復制,那將非常好。通過 dotnet/runtime#85328 和 dotnet/runtime#89376,這正是發生的情況,因此性能在它們之間實際上是相同的。

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.Unicode;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly char[] _chars = new char[100];
    private readonly byte[] _bytes = new byte[100];
    private readonly int _major = 1, _minor = 2, _build = 3, _revision = 4;

    [Benchmark] public bool FormatUTF16() => _chars.AsSpan().TryWrite($"{_major}.{_minor}.{_build}.{_revision}", out int charsWritten);
    [Benchmark] public bool FormatUTF8() => Utf8.TryWrite(_bytes, $"{_major}.{_minor}.{_build}.{_revision}", out int bytesWritten);
}
方法 平均值
FormatUTF16 19.07 ns
FormatUTF8 19.33 ns

ASCII

UTF8 是互聯網上和在端點之間傳輸文本的主要編碼方式。然而,這些數據中的大部分實際上是 ASCII 的子集,即範圍在 [0, 127] 的 128 個值。當你知道你正在處理的數據是 ASCII 時,你可以通過使用針對子集優化的例程來獲得更好的性能。.NET 8 中的新 Ascii 類,由 dotnet/runtime#75012 和 dotnet/runtime#84886 引入,然後在 dotnet/runtime#85926(來自 @gfoidl),dotnet/runtime#85266(來自 @Daniel-Svensson),dotnet/runtime#84881,和 dotnet/runtime#87141 中進一步優化,提供了這個功能:

namespace System.Text;

public static class Ascii
{
    public static bool Equals(ReadOnlySpan<byte> left, ReadOnlySpan<byte> right);
    public static bool Equals(ReadOnlySpan<byte> left, ReadOnlySpan<char> right);
    public static bool Equals(ReadOnlySpan<char> left, ReadOnlySpan<byte> right);
    public static bool Equals(ReadOnlySpan<char> left, ReadOnlySpan<char> right);

    public static bool EqualsIgnoreCase(ReadOnlySpan<byte> left, ReadOnlySpan<byte> right);
    public static bool EqualsIgnoreCase(ReadOnlySpan<byte> left, ReadOnlySpan<char> right);
    public static bool EqualsIgnoreCase(ReadOnlySpan<char> left, ReadOnlySpan<byte> right);
    public static bool EqualsIgnoreCase(ReadOnlySpan<char> left, ReadOnlySpan<char> right);

    public static bool IsValid(byte value);
    public static bool IsValid(char value);
    public static bool IsValid(ReadOnlySpan<byte> value);
    public static bool IsValid(ReadOnlySpan<char> value);

    public static OperationStatus ToLower(ReadOnlySpan<byte> source, Span<byte> destination, out int bytesWritten);
    public static OperationStatus ToLower(ReadOnlySpan<char> source, Span<char> destination, out int charsWritten);
    public static OperationStatus ToLower(ReadOnlySpan<byte> source, Span<char> destination, out int charsWritten);
    public static OperationStatus ToLower(ReadOnlySpan<char> source, Span<byte> destination, out int bytesWritten);

    public static OperationStatus ToUpper(ReadOnlySpan<byte> source, Span<byte> destination, out int bytesWritten);
    public static OperationStatus ToUpper(ReadOnlySpan<char> source, Span<char> destination, out int charsWritten);
    public static OperationStatus ToUpper(ReadOnlySpan<byte> source, Span<char> destination, out int charsWritten);
    public static OperationStatus ToUpper(ReadOnlySpan<char> source, Span<byte> destination, out int bytesWritten);

    public static OperationStatus ToLowerInPlace(Span<byte> value, out int bytesWritten);
    public static OperationStatus ToLowerInPlace(Span<char> value, out int charsWritten);
    public static OperationStatus ToUpperInPlace(Span<byte> value, out int bytesWritten);
    public static OperationStatus ToUpperInPlace(Span<char> value, out int charsWritten);

    public static OperationStatus FromUtf16(ReadOnlySpan<char> source, Span<byte> destination, out int bytesWritten);
    public static OperationStatus ToUtf16(ReadOnlySpan<byte> source, Span<char> destination, out int charsWritten);

    public static Range Trim(ReadOnlySpan<byte> value);
    public static Range Trim(ReadOnlySpan<char> value);

    public static Range TrimEnd(ReadOnlySpan<byte> value);
    public static Range TrimEnd(ReadOnlySpan<char> value);

    public static Range TrimStart(ReadOnlySpan<byte> value);
    public static Range TrimStart(ReadOnlySpan<char> value);
}

注意,它提供了操作 UTF16 (char) 和 UTF8 (byte) 的重載,並且在許多情況下,它們是混合的,這樣你可以比如說,比較一個 UTF8 ReadOnlySpan 和一個 UTF16 ReadOnlySpan,或者將一個 UTF16 ReadOnlySpan 轉碼爲一個 UTF8 ReadOnlySpan(當處理 ASCII 時,這純粹是一個縮小操作,去掉每個 char 中的前導 0 字節)。例如,添加這些方法的 PR 也在各種地方使用了它們(我強烈主張這一點,以確保所設計的實際上是滿足需求的,或者確保其他核心庫代碼從新的 API 中受益,這反過來使這些 API 更有價值,因爲它們的好處積累到更多的間接消費者),包括在 SocketsHttpHandler 的多個地方。以前,SocketsHttpHandler 有自己的助手來完成這個目的,我在這個基準測試中複製了一個例子:

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly byte[] _bytes = "Strict-Transport-Security"u8.ToArray();
    private readonly string _chars = "Strict-Transport-Security";

    [Benchmark(Baseline = true)]
    public bool Equals_OpenCoded() => EqualsOrdinalAsciiIgnoreCase(_chars, _bytes);

    [Benchmark]
    public bool Equals_Ascii() => Ascii.EqualsIgnoreCase(_chars, _bytes);

    internal static bool EqualsOrdinalAsciiIgnoreCase(string left, ReadOnlySpan<byte> right)
    {
        if (left.Length != right.Length)
            return false;

        for (int i = 0; i < left.Length; i++)
        {
            uint charA = left[i], charB = right[i];

            if ((charA - 'a') <= ('z' - 'a')) charA -= ('a' - 'A');
            if ((charB - 'a') <= ('z' - 'a')) charB -= ('a' - 'A');

            if (charA != charB)
                return false;
        }

        return true;
    }
}
方法 平均值 比率
Equals_OpenCoded 31.159 ns 1.00
Equals_Ascii 3.985 ns 0.13

許多這些新的 Ascii API 也得到了 Vector512 的處理,這樣噹噹前機器支持 AVX512 時,它們就會亮起,感謝 @anthonycanino 在 dotnet/runtime#88532 和 @khushal1996 在 dotnet/runtime#88650 的貢獻。

Base64

一個更進一步限制的文本子集是 Base64 編碼的數據。當需要將任意字節作爲文本傳輸時,就會使用這種方法,結果是隻使用 64 個字符(小寫 ASCII 字母,大寫 ASCII 字母,ASCII 數字,'+',和 '/')的文本。.NET 一直有在 System.Convert 上用於編碼和解碼 Base64 的方法,使用 UTF16 (char),並且在 .NET Core 2.1 中引入 Span 時,它得到了一組基於 span 的方法。在那個時候,System.Text.Buffers.Base64 類也被引入,專門用於編碼和解碼 UTF8 (byte) 的 Base64。這在 .NET 8 中得到了進一步的改進。

dotnet/runtime#85938 來自 @heathbm 和 dotnet/runtime#86396 在這裏做出了兩個貢獻。首先,他們使 Base64.Decode 方法的 UTF8 行爲與 Convert 類的對應方法一致,特別是在處理空白字符方面。由於 Base64 編碼數據中經常有換行符,所以 Convert 類的 Base64 解碼方法允許有空白字符;相比之下,Base64 類的解碼方法如果遇到空白字符就會失敗。現在這些解碼方法允許的空白字符與 Convert 完全相同。這部分是因爲這些 PR 的第二個貢獻,即一組新的 Base64.IsValid 靜態方法。與 Ascii.IsValid 和 Utf8.IsValid 一樣,這些方法簡單地聲明提供的 UTF8 或 UTF16 輸入是否代表有效的 Base64 輸入,以便 Convert 和 Base64 的解碼方法都能成功解碼。與我們看到的所有此類 .NET 中引入的處理一樣,我們努力使新功能儘可能高效,以便它可以在其他地方最大限度地受益。例如,dotnet/runtime#86221 來自 @WeihanLi 更新了新的 Base64Attribute 來使用它,dotnet/runtime#86002 更新了 PemEncoding.TryCountBase64 來使用它。在這裏,我們可以看到一個基準測試,比較了舊的非向量化的 TryCountBase64 和使用向量化的 Base64.IsValid 的新版本:

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers.Text;
using System.Runtime.CompilerServices;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly string _exampleFromPemEncodingTests =
        "MHQCAQEEICBZ7/8T1JL2amvNB/QShghtgZPtnPD4W+sAcHxA+hJsoAcGBSuBBAAK\n" +
        "oUQDQgAE3yNC5as8JVN5MjF95ofNSgRBVXjf0CKtYESWfPnmvT3n+cMMJUB9lUJf\n" +
        "dkFNgaSB7JlB+krZVVV8T7HZQXVDRA==\n";

    [Benchmark(Baseline = true)]
    public bool Count_Old() => TryCountBase64_Old(_exampleFromPemEncodingTests, out _, out _, out _);

    [Benchmark] 
    public bool Count_New() => TryCountBase64_New(_exampleFromPemEncodingTests, out _, out _, out _);

    private static bool TryCountBase64_New(ReadOnlySpan<char> str, out int base64Start, out int base64End, out int base64DecodedSize)
    {
        int start = 0, end = str.Length - 1;
        for (; start < str.Length && IsWhiteSpaceCharacter(str[start]); start++) ;
        for (; end > start && IsWhiteSpaceCharacter(str[end]); end--) ;

        if (Base64.IsValid(str.Slice(start, end + 1 - start), out base64DecodedSize))
        {
            base64Start = start;
            base64End = end + 1;
            return true;
        }

        base64Start = 0;
        base64End = 0;
        return false;
    }

    private static bool TryCountBase64_Old(ReadOnlySpan<char> str, out int base64Start, out int base64End, out int base64DecodedSize)
    {
        base64Start = 0;
        base64End = str.Length;

        if (str.IsEmpty)
        {
            base64DecodedSize = 0;
            return true;
        }

        int significantCharacters = 0;
        int paddingCharacters = 0;

        for (int i = 0; i < str.Length; i++)
        {
            char ch = str[i];

            if (IsWhiteSpaceCharacter(ch))
            {
                if (significantCharacters == 0) base64Start++;
                else base64End--;
                continue;
            }

            base64End = str.Length;

            if (ch == '=') paddingCharacters++;
            else if (paddingCharacters == 0 && IsBase64Character(ch)) significantCharacters++;
            else
            {
                base64DecodedSize = 0;
                return false;
            }
        }

        int totalChars = paddingCharacters + significantCharacters;

        if (paddingCharacters > 2 || (totalChars & 0b11) != 0)
        {
            base64DecodedSize = 0;
            return false;
        }

        base64DecodedSize = (totalChars >> 2) * 3 - paddingCharacters;
        return true;
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static bool IsBase64Character(char ch) => char.IsAsciiLetterOrDigit(ch) || ch is '+' or '/';

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static bool IsWhiteSpaceCharacter(char ch) => ch is ' ' or '\t' or '\n' or '\r';
}

方法 平均值 比率
Count_Old 356.37 ns 1.00
Count_New 33.72 ns 0.09

Hex

ASCII 的另一個相關子集是十六進制,.NET 8 在字節與其十六進制表示之間的轉換方面做出了改進。特別是,dotnet/runtime#82521 使用 Langdale 和 Mula 描述的算法向量化了 Convert.FromHexString 方法。即使在適度長度的輸入上,這對吞吐量也有非常明顯的影響:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Security.Cryptography;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private string _hex;

    [Params(4, 16, 128)]
    public int Length { get; set; }

    [GlobalSetup]
    public void Setup() => _hex = Convert.ToHexString(RandomNumberGenerator.GetBytes(Length));

    [Benchmark]
    public byte[] ConvertFromHex() => Convert.FromHexString(_hex);
}
方法 運行時 長度 平均值 比率
ConvertFromHex .NET 7.0 4 24.94 ns 1.00
ConvertFromHex .NET 8.0 4 20.71 ns 0.83
ConvertFromHex .NET 7.0 16 57.66 ns 1.00
ConvertFromHex .NET 8.0 16 17.29 ns 0.30
ConvertFromHex .NET 7.0 128 337.41 ns 1.00
ConvertFromHex .NET 8.0 128 56.72 ns 0.17

當然,.NET 8 的改進遠不止於對某些已知字符集的操作;還有許多其他的改進值得探索。讓我們從 System.Text.CompositeFormat 開始,它在 dotnet/runtime#80753 中被引入。

String Formatting

自 .NET 開始以來,string 及其相關類就提供了處理複合格式字符串的 API,這些字符串中的文本與格式項佔位符交錯,例如 "The current time is {0:t}"。然後,這些字符串可以傳遞給各種 API,如 string.Format,這些 API 既提供複合格式字符串,也提供應替換佔位符的參數,例如 string.Format("The current time is {0:t}", DateTime.Now) 將返回一個類似於 "The current time is 3:44 PM" 的字符串(佔位符中的 0 表示要替換的參數的基於 0 的編號,t 是應使用的格式,在這種情況下是標準的短時間模式)。這樣的方法調用需要在每次調用時解析複合格式字符串,儘管對於給定的調用站點,複合格式字符串通常不會從調用到調用改變。這些 API 也通常是非泛型的,這意味着如果參數是值類型(如我的示例中的 DateTime),它將產生一個裝箱分配。爲了簡化這些操作的語法,C# 6 增加了對字符串插值的支持,這樣你可以寫 $"The current time is {DateTime.Now:t}",而不是寫 string.Format(null, "The current time is {0:t}", DateTime.Now),然後由編譯器來實現與使用 string.Format 相同的行爲(編譯器通常只是通過將插值降低爲對 string.Format 的調用來實現)。

在 .NET 6 和 C# 10 中,字符串插值得到了顯著的改進,無論是在支持的場景還是在效率上。效率的一個關鍵方面是它使解析可以一次性完成(在編譯時)。它還避免了與提供參數相關的所有分配。這些改進有助於所有使用字符串插值的場景,以及實際應用和服務中大部分使用 string.Format 的場景。然而,編譯器支持的工作方式是能夠在編譯時看到字符串。如果格式字符串直到運行時才知道,比如說它是從 .resx 資源文件或其他配置源中提取的,那該怎麼辦?在這個時候,string.Format 仍然是答案。

現在在 .NET 8 中,有了一個新的答案:CompositeFormat。就像插值字符串允許編譯器一次性做重複使用的優化工作,CompositeFormat 也允許這種可重用的工作一次性完成,以優化重複使用。由於它在運行時進行解析,所以它能夠處理字符串插值無法達到的剩餘情況。要創建一個實例,只需調用其 Parse 方法,該方法接受一個複合格式字符串,解析它,並返回一個 CompositeFormat 實例:

private static readonly CompositeFormat s_currentTimeFormat = CompositeFormat.Parse(SR.CurrentTime);

然後,像 string.Format 這樣的現有方法現在有了新的重載,與現有的完全相同,但是它們接受的是 CompositeFormat 格式,而不是字符串格式。然後可以像這樣完成之前的格式化:

string result = string.Format(null, s_currentTimeFormat, DateTime.Now);

這個重載(以及像 StringBuilder.AppendFormat 和 MemoryExtensions.TryWrite 這樣的方法的其他新重載)接受泛型參數,避免了裝箱。

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
    private static readonly CompositeFormat s_format = CompositeFormat.Parse(SR.CurrentTime);

    [Benchmark(Baseline = true)]
    public string FormatString() => string.Format(null, SR.CurrentTime, DateTime.Now);

    [Benchmark]
    public string FormatComposite() => string.Format(null, s_format, DateTime.Now);
}

internal static class SR
{
    public static string CurrentTime => /*load from resource file*/"The current time is {0:t}";
}
方法 平均值 比率 分配 分配比率
FormatString 163.6 ns 1.00 96 B 1.00
FormatComposite 146.5 ns 0.90 72 B 0.75

如果你在編譯時知道複合格式字符串,那麼插值字符串就是答案。否則,CompositeFormat 可以以一些啓動成本爲代價,提供在同一球場的吞吐量。使用 CompositeFormat 進行格式化實際上是使用與字符串插值相同的插值字符串處理器實現的,例如,string.Format(..., compositeFormat, ...) 最終會調用 DefaultInterpolatedStringHandler 上的方法來完成實際的格式化工作。

還有一個新的分析器可以幫助這個。CA1863 "Use 'CompositeFormat'" 在 dotnet/roslyn-analyzers#6675 中被引入,用於識別可能從使用 CompositeFormat 參數中受益的 string.Format 和 StringBuilder.AppendFormat 調用。CA1863

Span

從格式化轉向,讓我們將注意力轉向人們經常想要對數據序列執行的所有其他類型的操作,無論是數組、字符串,還是通過跨度統一的所有這些的操作。System.MemoryExtensions 類型是許多用於操作所有這些的例程的家,通過跨度,它在 .NET 8 中收到了許多新的 API。

一個非常常見的操作是計算有多少個東西。例如,爲了支持多行註釋,System.Text.Json 需要計算給定的 JSON 片段中有多少個換行符。這當然可以寫成一個循環,無論是字符-by-字符還是使用 IndexOf 和切片。現在在 .NET 8 中,你也可以只調用 Count 擴展方法,感謝 @bollhals 的 dotnet/runtime#80662 和 @gfoidl 的 dotnet/runtime#82687。在這裏,我們正在計算 Project Gutenberg 的 "The Adventures of Sherlock Holmes" 中的換行符數量:

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private static readonly byte[] s_utf8 = new HttpClient().GetByteArrayAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;

    [Benchmark(Baseline = true)]
    public int Count_ForeachLoop()
    {
        int count = 0;
        foreach (byte c in s_utf8)
        {
            if (c == '\n') count++;
        }
        return count;
    }

    [Benchmark]
    public int Count_IndexOf()
    {
        ReadOnlySpan<byte> remaining = s_utf8;
        int count = 0;

        int pos;
        while ((pos = remaining.IndexOf((byte)'\n')) >= 0)
        {
            count++;
            remaining = remaining.Slice(pos + 1);
        }

        return count;
    }

    [Benchmark]
    public int Count_Count() => s_utf8.AsSpan().Count((byte)'\n');
}
方法 平均值 比率
Count_ForeachLoop 314.23 us 1.00
Count_IndexOf 95.39 us 0.30
Count_Count 13.68 us 0.04

使 MemoryExtensions.Count 如此快速的實現的核心,特別是在搜索單個值時,基於兩個關鍵原語:PopCount 和 ExtractMostSignificantBits。這是構成 Count 實現的大部分的 Vector128 循環(實現還有類似的 Vector256 和 Vector512 循環):

Vector128<T> targetVector = Vector128.Create(value);
ref T oneVectorAwayFromEnd = ref Unsafe.Subtract(ref end, Vector128<T>.Count);
do
{
    count += BitOperations.PopCount(Vector128.Equals(Vector128.LoadUnsafe(ref current), targetVector).ExtractMostSignificantBits());
    current = ref Unsafe.Add(ref current, Vector128<T>.Count);
}
while (!Unsafe.IsAddressGreaterThan(ref current, ref oneVectorAwayFromEnd));

這是創建一個向量,其中向量的每個元素都是目標(在這種情況下,'\n')。然後,只要還有至少一個向量的數據剩餘,它就加載下一個向量(Vector128.LoadUnsafe)並將其與目標向量(Vector128.Equals)進行比較。這產生了一個新的 Vector128,其中每個 T 元素在值相等時都是全 1,而在值不相等時都是全 0。然後我們提取出每個元素的最高有效位(ExtractMostSignificantBits),所以在值相等的地方得到值爲 1 的位,否則爲 0。然後我們在結果 uint 上使用 BitOperations.PopCount 來獲取“人口計數”,即值爲 1 的位的數量,並將其添加到我們的運行總數。通過這種方式,計數操作的內部循環保持無分支,實現可以非常快速地處理數據。你可以在 dotnet/runtime#81325 中找到使用 Count 的幾個例子,它在覈心庫的幾個地方使用了它。

一個類似的新的 MemoryExtensions 方法是 Replace,它在 .NET 8 中有兩種形式。dotnet/runtime#76337 來自 @gfoidl 添加了一個就地變體:

public static unsafe void Replace<T>(this Span<T> span, T oldValue, T newValue) where T : IEquatable<T>?;

dotnet/runtime#83120 添加了一個複製變體:

public static unsafe void Replace<T>(this ReadOnlySpan<T> source, Span<T> destination, T oldValue, T newValue) where T : IEquatable<T>?;

這個方法在哪裏會派上用場呢?例如,Uri 有一些代碼路徑需要將目錄分隔符標準化爲 '/',這樣任何 '\' 字符都需要被替換。這之前使用了一個 IndexOf 循環,如前面的 Count 基準測試所示,現在它可以直接使用 Replace。以下是一個比較(純粹爲了基準測試,它來回標準化,以便每次運行基準測試時都能找到原始狀態):

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly char[] _uri = "server/somekindofpathneeding/normalizationofitsslashes".ToCharArray();

    [Benchmark(Baseline = true)]
    public void Replace_ForLoop()
    {
        Replace(_uri, '/', '\\');
        Replace(_uri, '\\', '/');

        static void Replace(char[] chars, char from, char to)
        {
            for (int i = 0; i < chars.Length; i++)
            {
                if (chars[i] == from)
                {
                    chars[i] = to;
                }
            }
        }
    }

    [Benchmark]
    public void Replace_IndexOf()
    {
        Replace(_uri, '/', '\\');
        Replace(_uri, '\\', '/');

        static void Replace(char[] chars, char from, char to)
        {
            Span<char> remaining = chars;
            int pos;
            while ((pos = remaining.IndexOf(from)) >= 0)
            {
                remaining[pos] = to;
                remaining = remaining.Slice(pos + 1);
            }
        }
    }

    [Benchmark]
    public void Replace_Replace()
    {
        _uri.AsSpan().Replace('/', '\\');
        _uri.AsSpan().Replace('\\', '/');
    }
}
方法 平均值 比率
Replace_ForLoop 40.28 ns 1.00
Replace_IndexOf 29.26 ns 0.73
Replace_Replace 18.88 ns 0.47

新的 Replace 方法比手動循環和 IndexOf 循環都要好。和 Count 一樣,Replace 有一個相當簡單和緊湊的內部循環;再次,這裏是該循環的 Vector128 變體:

do
{
    original = Vector128.LoadUnsafe(ref src, idx);
    mask = Vector128.Equals(oldValues, original);
    result = Vector128.ConditionalSelect(mask, newValues, original);
    result.StoreUnsafe(ref dst, idx);

    idx += (uint)Vector128<T>.Count;
}
while (idx < lastVectorIndex);

這是加載下一個向量的數據(Vector128.LoadUnsafe)並將其與填充了 oldValue 的向量進行比較,這會產生一個新的掩碼向量,對於相等的情況爲 1,對於不等的情況爲 0。然後它調用超級方便的 Vector128.ConditionalSelect。這是一個無分支的 SIMD 條件操作:它產生一個新的向量,如果掩碼的位是 1,則從一個向量中取出元素,如果掩碼的位是 0,則從另一個向量中取出元素(想象一下三元運算符)。然後將結果向量保存爲結果。以這種方式,它會覆蓋整個跨度,在某些情況下,只是寫回之前的值,在原始值是目標 oldValue 的情況下,寫出 newValue。這個循環體是無分支的,不會根據需要替換的元素數量改變成本。在極端的情況下,如果沒有任何東西需要被替換,基於 IndexOf 的循環可能會稍微快一點,因爲 IndexOf 的內部循環的主體有更少的指令,但是這樣的 IndexOf 循環對於每個需要做的替換都要付出相對較高的成本。

StringBuilder 也有這樣一個基於 IndexOf 的實現,用於其 Replace(char oldChar, char newChar) 和 Replace(char oldChar, char newChar, int startIndex, int count) 方法,現在它們基於 MemoryExtensions.Replace,所以改進也會積累在這裏。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly StringBuilder _sb = new StringBuilder("http://server\\this\\is\\a\\test\\of\\needing\\to\\normalize\\directory\\separators\\");

    [Benchmark]
    public void Replace()
    {
        _sb.Replace('\\', '/');
        _sb.Replace('/', '\\');
    }
}
方法 運行時 平均值 比率
Replace .NET 7.0 150.47 ns 1.00
Replace .NET 8.0 24.79 ns 0.16

有趣的是,儘管 StringBuilder.Replace(char, char) 使用了 IndexOf 並切換到使用 Replace,但 StringBuilder.Replace(string, string) 根本沒有使用 IndexOf,這個問題在 dotnet/runtime#81098 中得到了修復。在處理字符串時,StringBuilder 中的 IndexOf 更復雜,因爲它的分段特性。StringBuilder 不僅僅是由一個數組支持:它實際上是一個分段的鏈表,每個分段都存儲一個數組。對於基於字符的 Replace,它可以簡單地在每個分段上單獨操作,但對於基於字符串的 Replace,它需要處理被搜索的值可能跨越分段邊界的可能性。因此,StringBuilder.Replace(string, string) 是在每個分段上逐字符地進行遍歷,每個位置都進行等值檢查。現在有了這個 PR,它使用 IndexOf,只有在足夠接近分段邊界可能會被跨越時,纔會回退到逐字符檢查。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly StringBuilder _sb = new StringBuilder()
        .Append("Shall I compare thee to a summer's day? ")
        .Append("Thou art more lovely and more temperate: ")
        .Append("Rough winds do shake the darling buds of May, ")
        .Append("And summer's lease hath all too short a date; ")
        .Append("Sometime too hot the eye of heaven shines, ")
        .Append("And often is his gold complexion dimm'd; ")
        .Append("And every fair from fair sometime declines, ")
        .Append("By chance or nature's changing course untrimm'd; ")
        .Append("But thy eternal summer shall not fade, ")
        .Append("Nor lose possession of that fair thou ow'st; ")
        .Append("Nor shall death brag thou wander'st in his shade, ")
        .Append("When in eternal lines to time thou grow'st: ")
        .Append("So long as men can breathe or eyes can see, ")
        .Append("So long lives this, and this gives life to thee.");

    [Benchmark]
    public void Replace()
    {
        _sb.Replace("summer", "winter");
        _sb.Replace("winter", "summer");
    }
}
方法 運行時 平均值 比率
Replace .NET 7.0 5,158.0 ns 1.00
Replace .NET 8.0 476.4 ns 0.09

只要我們在討論 StringBuilder,那麼在 .NET 8 中,它也有一些很好的改進。@yesmey 在 dotnet/runtime#85894 中調整了 StringBuilder.Append(string value) 和 JIT,使 JIT 能夠展開作爲附加常量字符串一部分的內存複製。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly StringBuilder _sb = new();

    [Benchmark]
    public void Append()
    {
        _sb.Clear();
        _sb.Append("This is a test of appending a string to StringBuilder");
    }
}
方法 運行時 平均值 比率
Append .NET 7.0 7.597 ns 1.00
Append .NET 8.0 3.756 ns 0.49

@yesmey 在 dotnet/runtime#86287 中改變了 StringBuilder.Append(char value, int repeatCount) 的實現,使用 Span.Fill 替代手動循環,利用了 Fill 實現的優化,即使對於相對較小的計數也是如此。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly StringBuilder _sb = new();

    [Benchmark]
    public void Append()
    {
        _sb.Clear();
        _sb.Append('x', 8);
    }
}
方法 運行時 平均值 比率
Append .NET 7.0 11.520 ns 1.00
Append .NET 8.0 5.292 ns 0.46

回到 MemoryExtensions,另一個新的有用的方法是 MemoryExtensions.Split(和 MemoryExtensions.SplitAny)。這是 string.Split 的一些用法的基於 span 的對應方法。我說“一些”,是因爲實際上有兩種主要的使用 string.Split 的模式:當你期望有一定數量的部分,和當有未知數量的部分。例如,如果你想解析一個由 System.Version 使用的版本字符串,最多有四個部分(“主版本號.次版本號.構建號.修訂號”)。但是,如果你想將一個文件的內容分割成文件中的所有行(由 \n 分隔),那就是未知的(並且可能相當大的)數量的部分。新的 MemoryExtensions.Split 方法專注於預期有已知的(並且相當小的)最大數量的部分的情況。在這種情況下,它可以比 string.Split 更有效,特別是從分配的角度來看。

string.Split 有接受 int count 的重載,MemoryExtensions.Split 的行爲與這些重載完全相同;然而,你給它的不是 int count,而是一個 Span 目標,其長度是你本來會用於 count 的值。例如,假設你想分割一個由 '=' 分隔的鍵/值對。如果這是 string.Split,你可以這樣寫:

string[] parts = keyValuePair.Split('=');

當然,如果輸入實際上對你的期望是錯誤的,並且有100個等號,你將最終創建一個包含101個字符串的數組。所以,你可能會這樣寫:

string[] parts = keyValuePair.Split('=', 3);

等等,“3”?不是隻有兩部分嗎,如果是,爲什麼不傳遞“2”?這是因爲最後一部分發生的行爲。最後一部分包含分隔符前的字符串的剩餘部分,所以例如調用:

"shall=i=compare=thee".Split(new[] { '=' }, 2)

會產生數組:

string[2] { "shall", "i=compare=thee" }

如果你想知道是否有超過兩部分,你需要請求至少一個更多,然後如果最後一個被產生,你知道輸入是錯誤的。例如,這個:

"shall=i=compare=thee".Split(new[] { '=' }, 3)

產生這個:

string[3] { "shall", "i", "compare-thee" }

和這個:

"shall=i".Split(new[] { '=' }, 3)

產生這個:

string[2] { "shall", "i" }

我們可以用新的重載做同樣的事情,除了 a) 調用者提供目標 span 來寫入結果,和 b) 結果被存儲爲 System.Range 而不是 string。這意味着整個操作是無分配的。並且,由於 Span 上的索引器允許你傳入一個 Range 並切片 span,你可以輕鬆地使用寫入的範圍來訪問輸入的相關部分。

Span<Range> parts = stackalloc Range[3];
int count = keyValuePairSpan.Split(parts, '=');
if (count == 2)
{
    Console.WriteLine($"key={keyValuePairSpan[parts[0]]}, value={keyValuePairSpan[parts[1]]}");
}

這是一個來自 dotnet/runtime#80211 的例子,它使用 SplitAny 來降低 MimeBasePart.DecodeEncoding 的成本:

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
    private readonly string _input = "=?utf-8?B?RmlsZU5hbWVf55CG0Y3Qq9C60I5jw4TRicKq0YIM0Y1hSsSeTNCy0Klh?=";
    private static readonly char[] s_decodeEncodingSplitChars = new char[] { '?', '\r', '\n' };

    [Benchmark(Baseline = true)]
    public Encoding Old()
    {
        if (string.IsNullOrEmpty(_input))
        {
            return null;
        }

        string[] subStrings = _input.Split(s_decodeEncodingSplitChars);
        if (subStrings.Length < 5 || 
            subStrings[0] != "=" || 
            subStrings[4] != "=")
        {
            return null;
        }

        string charSet = subStrings[1];
        return Encoding.GetEncoding(charSet);
    }

    [Benchmark]
    public Encoding New()
    {
        if (string.IsNullOrEmpty(_input))
        {
            return null;
        }

        ReadOnlySpan<char> valueSpan = _input;
        Span<Range> subStrings = stackalloc Range[6];
        if (valueSpan.SplitAny(subStrings, "?\r\n") < 5 ||
            valueSpan[subStrings[0]] is not "=" ||
            valueSpan[subStrings[4]] is not "=")
        {
            return null;
        }

        return Encoding.GetEncoding(_input[subStrings[1]]);
    }
}
方法 平均值 比率 分配 分配比率
舊的 143.80 ns 1.00 304 B 1.00
新的 94.52 ns 0.66 32 B 0.11

MemoryExtensions.Split 和 MemoryExtensions.SplitAny 的更多示例可以在 dotnet/runtime#80471 和 dotnet/runtime#82007 中找到。這兩個都從之前使用 string.Split 的各種 System.Net 類型中移除了分配。

感謝 dotnet/runtime#76803,MemoryExtensions 還包括了一組新的針對範圍的 IndexOf 方法:

public static int IndexOfAnyInRange<T>(this ReadOnlySpan<T> span, T lowInclusive, T highInclusive) where T : IComparable<T>;
public static int IndexOfAnyExceptInRange<T>(this ReadOnlySpan<T> span, T lowInclusive, T highInclusive) where T : IComparable<T>;
public static int LastIndexOfAnyInRange<T>(this ReadOnlySpan<T> span, T lowInclusive, T highInclusive) where T : IComparable<T>;
public static int LastIndexOfAnyExceptInRange<T>(this ReadOnlySpan<T> span, T lowInclusive, T highInclusive) where T : IComparable<T>;

想要找到下一個 ASCII 數字的索引?沒問題:

int pos = text.IndexOfAnyInRange('0', '9');

想要確定一些輸入是否包含任何非 ASCII 或控制字符?你得到了:

bool nonAsciiOrControlCharacters = text.IndexOfAnyExceptInRange((char)0x20, (char)0x7e) >= 0;

例如,dotnet/runtime#78658 使用 IndexOfAnyInRange 快速確定 Uri 的部分是否可能包含雙向控制字符,搜索範圍
[\u200E, \u202E] 中的任何內容,然後只有在找到該範圍內的任何內容時才進一步檢查。而 dotnet/runtime#79357 使用 IndexOfAnyExceptInRange 來確定是否使用 Encoding.UTF8 或 Encoding.ASCII。它之前是用一個簡單的 foreach 循環實現的,現在是用一個更簡單的調用 IndexOfAnyExceptInRange:

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly string _text =
        "Shall I compare thee to a summer's day? " +
        "Thou art more lovely and more temperate: " +
        "Rough winds do shake the darling buds of May, " +
        "And summer's lease hath all too short a date; " +
        "Sometime too hot the eye of heaven shines, " +
        "And often is his gold complexion dimm'd; " +
        "And every fair from fair sometime declines, " +
        "By chance or nature's changing course untrimm'd; " +
        "But thy eternal summer shall not fade, " +
        "Nor lose possession of that fair thou ow'st; " +
        "Nor shall death brag thou wander'st in his shade, " +
        "When in eternal lines to time thou grow'st: " +
        "So long as men can breathe or eyes can see, " +
        "So long lives this, and this gives life to thee.";

    [Benchmark(Baseline = true)]
    public Encoding Old()
    {
        foreach (char c in _text)
            if (c > 126 || c < 32)
                return Encoding.UTF8;

        return Encoding.ASCII;
    }

    [Benchmark]
    public Encoding New() =>
        _text.AsSpan().IndexOfAnyExceptInRange((char)32, (char)126) >= 0 ?
            Encoding.UTF8 :
            Encoding.ASCII;
}
方法 平均值 比率
舊的 297.56 ns 1.00
新的 20.69 ns 0.07

這更多的是一個生產力的事情,而不是性能(至少是今天),但是 .NET 8 也包括了新的 ContainsAny 方法(dotnet/runtime#87621),它允許以稍微清潔的方式編寫這些然後與 0 進行比較的 IndexOf 調用,例如,前面的例子可以稍微簡化爲:

public Encoding New() =>
    _text.AsSpan().ContainsAnyExceptInRange((char)32, (char)126) ?
        Encoding.UTF8 :
        Encoding.ASCII;

我喜歡這種幫助器的一件事是,代碼可以簡化爲使用它們,然後隨着幫助器的改進,依賴它們的代碼也會改進。在 .NET 8 中,有很多“幫助器改進”。

dotnet/runtime#86655 來自 @DeepakRajendrakumaran,爲 MemoryExtensions 中的大多數基於 span 的幫助器添加了對 Vector512 的支持。這意味着當在支持 AVX512 的硬件上運行時,許多這些操作簡單地變得更快。這個基準測試使用環境變量來顯式禁用對各種指令集的支持,這樣我們可以比較給定操作在沒有向量化時的性能,當 Vector128 被使用並硬件加速時,當 Vector256 被使用並硬件加速時,以及當 Vector512 被使用並硬件加速時的性能。我在支持 AVX512 的開發機上運行了這個:

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using BenchmarkDotNet.Toolchains.CoreRun;

var config = DefaultConfig.Instance
    .AddJob(Job.Default.WithId("Scalar").WithEnvironmentVariable("DOTNET_EnableHWIntrinsic", "0").AsBaseline())
    .AddJob(Job.Default.WithId("Vector128").WithEnvironmentVariable("DOTNET_EnableAVX512F", "0").WithEnvironmentVariable("DOTNET_EnableAVX2", "0"))
    .AddJob(Job.Default.WithId("Vector256").WithEnvironmentVariable("DOTNET_EnableAVX512F", "0"))
    .AddJob(Job.Default.WithId("Vector512"));
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config);

[HideColumns("Error", "StdDev", "Median", "RatioSD", "EnvironmentVariables")]
public class Tests
{
    private readonly char[] _sourceChars = Enumerable.Repeat('a', 1024).ToArray();

    [Benchmark]
    public bool Contains() => _sourceChars.AsSpan().IndexOfAny('b', 'c') >= 0;
}
方法 任務 平均值 比率
Contains Scalar 491.50 ns 1.00
Contains Vector128 53.77 ns 0.11
Contains Vector256 34.75 ns 0.07
Contains Vector512 21.12 ns 0.04

所以,從 128 位到 256 位並不完全是減半,從 256 位到 512 位也不完全是減半,但是非常接近。

dotnet/runtime#77947 爲足夠大的輸入向量化了 Equals(..., StringComparison.OrdinalIgnoreCase)(string 和 ReadOnlySpan 都使用相同的底層實現)。在循環中,它加載下兩個向量。然後檢查這些向量中是否有任何非 ASCII 字符;通過將它們合併(vec1 | vec2)然後查看任何元素的高位是否設置,它可以有效地做到這一點...如果沒有,那麼輸入向量中的所有元素都是 ASCII (((vec1 | vec2) & Vector128.Create(unchecked((ushort)~0x007F))) == Vector128.Zero)。如果它找到任何非 ASCII 字符,它就會繼續使用舊的比較模式。但只要所有內容都是 ASCII,那麼它就可以以向量化的方式進行比較。對於每個向量,它使用一些位黑客技巧來創建向量的小寫版本,然後比較小寫版本的相等性。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly string _a = "shall i compare thee to a summer's day? thou art more lovely and more temperate";
    private readonly string _b = "SHALL I COMPARE THEE TO A SUMMER'S DAY? THOU ART MORE LOVELY AND MORE TEMPERATE";

    [Benchmark]
    public bool Equals() => _a.AsSpan().Equals(_b, StringComparison.OrdinalIgnoreCase);
}
方法 運行時 平均值 比率
Equals .NET 7.0 47.97 ns 1.00
Equals .NET 8.0 18.93 ns 0.39

dotnet/runtime#78262 使用相同的技巧來向量化 ToLowerInvariant 和 ToUpperInvariant:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly string _a = "shall i compare thee to a summer's day? thou art more lovely and more temperate";
    private readonly char[] _b = new char[100];

    [Benchmark]
    public int ToUpperInvariant() => _a.AsSpan().ToUpperInvariant(_b);
}
方法 運行時 平均值 比率
ToUpperInvariant .NET 7.0 33.22 ns 1.00
ToUpperInvariant .NET 8.0 16.16 ns 0.49

dotnet/runtime#78650 來自 @yesmey 還優化了 MemoryExtensions.Reverse:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly byte[] _bytes = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();

    [Benchmark]
    public void Reverse() => _bytes.AsSpan().Reverse();
}
方法 運行時 平均值 比率
Reverse .NET 7.0 3.801 ns 1.00
Reverse .NET 8.0 2.052 ns 0.54

dotnet/runtime#75640 改進了內部的 RuntimeHelpers.IsBitwiseEquatable 方法,這個方法被絕大多數的 MemoryExtensions 使用。如果你查看 MemoryExtensions 的源代碼,你會發現一個相當常見的模式:對 byte、ushort、uint 和 ulong 進行特殊處理,使用向量化實現,然後對其他所有內容回退到一般的非向量化實現。但它並不完全是“特殊處理 byte、ushort、uint 和 ulong”,而是“特殊處理與 byte、ushort、uint 或 ulong 大小相同的位可等類型”。如果某個東西是“位可等的”,那就意味着我們不需要擔心它可能提供的任何 IEquatable 實現或它可能有的任何 Equals 重寫,我們可以簡單地依賴於值的位與另一個值相同或不同來確定值是否相同或不同。如果這樣的位等價語義適用於某種類型,那麼確定 byte、ushort、uint 和 ulong 的等價性的內聯函數可以用於任何 1、2、4 或 8 字節的類型。在 .NET 7 中,RuntimeHelpers.IsBitwiseEquatable 只對運行時中的有限和硬編碼的列表爲真:bool、byte、sbyte、char、short、ushort、int、uint、long、ulong、nint、nuint、Rune 和枚舉。現在在 .NET 8 中,該列表擴展到了一個動態可發現的集合,其中運行時可以輕鬆地看到該類型本身並未提供任何等價性實現。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
    private MyColor[] _values1, _values2;

    [GlobalSetup]
    public void Setup()
    {
        _values1 = Enumerable.Range(0, 1_000).Select(i => new MyColor { R = (byte)i, G = (byte)i, B = (byte)i, A = (byte)i }).ToArray();
        _values2 = (MyColor[])_values1.Clone();
    }

    [Benchmark] public int IndexOf() => Array.IndexOf(_values1, new MyColor { R = 1, G = 2, B = 3, A = 4 });

    [Benchmark] public bool SequenceEquals() => _values1.AsSpan().SequenceEqual(_values2);

    struct MyColor { public byte R, G, B, A; }
}
方法 運行時 平均值 比率 分配的內存 分配比率
IndexOf .NET 7.0 24,912.42 ns 1.000 48000 B 1.00
IndexOf .NET 8.0 70.44 ns 0.003 - 0.00
SequenceEquals .NET 7.0 25,041.00 ns 1.000 48000 B 1.00
SequenceEquals .NET 8.0 68.40 ns 0.003 - 0.00

注意,這不僅意味着結果得到了向量化,而且還避免了過度的裝箱(因此所有的分配),因爲它不再對每個值類型實例調用 Equals(object)。

dotnet/runtime#85437 改進了 IndexOf(string/span, StringComparison.OrdinalIgnoreCase) 的向量化。想象一下,我們正在搜索一些文本中的“elementary”一詞。在 .NET 7 中,它會執行 IndexOfAny('E', 'e') 來找到“elementary”可能匹配的第一個位置,然後執行類似於 Equals("elementary", textAtFoundPosition, StringComparison.OrdinalIgnoreCase) 的操作。如果 Equals 失敗,那麼它就會循環到下一個可能的起始位置進行搜索。如果要搜索的字符很少見,這是可以的,但在這個例子中,'e' 是英語字母表中最常見的字母,所以 IndexOfAny('E', 'e') 經常停止,跳出向量化的內循環,以進行完全的 Equals 比較。相比之下,在 .NET 7 中,使用 Mula 描述的算法改進了 IndexOf(string/span, StringComparison.Ordinal):這裏的想法是,你不僅僅是搜索一個字符(例如第一個),你還有一個向量用於另一個字符(例如最後一個),你適當地偏移它們,並且你將它們的比較結果一起作爲內循環的一部分。即使 'e' 非常常見,'e' 然後九個字符後的 'y' 就少得多,因此它可以在緊密的內循環中停留更長時間。現在在 .NET 8 中,當我們在輸入中找到兩個 ASCII 字符時,我們將同樣的技巧應用於 OrdinalIgnoreCase,例如,它會同時搜索 'E' 或 'e' 後面跟着九個字符後的 'Y' 或 'y'。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;

    private readonly string _needle = "elementary";

    [Benchmark]
    public int Count()
    {
        ReadOnlySpan<char> haystack = s_haystack;
        ReadOnlySpan<char> needle = _needle;
        int count = 0;

        int pos;
        while ((pos = haystack.IndexOf(needle, StringComparison.OrdinalIgnoreCase)) >= 0)
        {
            count++;
            haystack = haystack.Slice(pos + needle.Length);
        }

        return count;
    }
}
方法 運行時 平均值 比率
Count .NET 7.0 676.91 us 1.00
Count .NET 8.0 62.04 us 0.09

即使是簡單的 IndexOf(char) 在 .NET 8 中也有顯著的改進。在這裏,我在搜索 "The Adventures of Sherlock Holmes" 中的 '@',我恰好知道它並未出現,因此整個搜索將在 IndexOf(char) 的緊密內循環中進行。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;

    [Benchmark]
    public int IndexOfAt() => s_haystack.AsSpan().IndexOf('@');
}
方法 運行時 平均值 比率
IndexOfAt .NET 7.0 32.17 us 1.00
IndexOfAt .NET 8.0 20.84 us 0.64

這個改進要歸功於 dotnet/runtime#78861。SIMD 和向量化的目標是用相同的資源做更多的事情;而不是一次處理一件事,而是一次處理 2 或 4 或 8 或 16 或 32 或 64 件事。對於大小爲 16 位的字符,在 128 位向量中,你可以一次處理 8 個字符;對於 256 位,這個數字翻倍,對於 512 位,再翻倍。但這不僅僅關於向量的大小;你還可以找到創新的方法,使用向量處理比你原本能處理的更多的事情。例如,在 128 位向量中,你可以一次處理 8 個字符...但你可以一次處理 16 個字節。如果你可以將字符作爲字節來處理呢?你當然可以將 8 個字符重新解釋爲 16 個字節,但對於大多數算法,你會得到錯誤的答案(因爲字符的每個字節將被獨立處理)。如果你可以將兩個向量的字符壓縮到一個字節向量,然後在這個字節向量上進行後續處理呢?只要你在字節向量上做幾個指令的處理,並且壓縮的成本足夠低,你就可以接近將算法的性能提高一倍。這就是這個 PR 所做的,至少對於非常常見的針,以及支持 SSE2 的硬件。SSE2 有專門的指令,用於將兩個向量縮小到一個向量,例如,取一個 Vector128 a 和一個 Vector128 b,並將它們組合成一個 Vector c,通過取輸入中每個 short 的低字節。然而,這些特定的指令並不完全忽略每個 short 中的另一個字節;相反,它們“飽和”。這意味着,如果將 short 值轉換爲 byte 會溢出,它會產生 255,如果會下溢,它會產生 0。這意味着我們可以取兩個 16 位值的向量,將它們打包成一個 8 位值的向量,然後只要我們搜索的東西在範圍 [1, 254] 內,我們就可以確保對向量的等式檢查是準確的(對 0 或 255 的比較可能會導致假陽性)。注意,雖然 Arm 支持類似的“飽和縮窄”,但這些特定指令的成本被測量爲足夠高,以至於在這裏使用它們是不可行的(它們在其他地方被使用)。這個改進也適用於其他幾個基於字符的方法,包括 IndexOfAny(char, char) 和 IndexOfAny(char, char, char)。

最後要強調的是以 Span 爲中心的改進。Memory 和 ReadOnlyMemory 類型並未實現 IEnumerable,但 MemoryMarshal.ToEnumerable 方法確實存在,可以從它們中獲取可枚舉的對象。它主要隱藏在 MemoryMarshal 中,以引導開發者不直接遍歷 Memory,而是遍歷其 Span,例如:

foreach (T value in memory.Span) { ... }

這背後的推動力是 Memory.Span 屬性有一些開銷,因爲 Memory 可以由多種不同的對象類型支持(即 T[],如果是 ReadOnlyMemory 則爲字符串,或 MemoryManager),Span 需要獲取正確對象的 Span。即便如此,有時你確實需要從 {ReadOnly}Memory 獲取 IEnumerable,ToEnumerable 提供了這個功能。在這種情況下,從性能的角度來看,不僅僅將 {ReadOnly}Memory 作爲 IEnumerable 傳遞實際上是有益的,因爲這樣做會對值進行裝箱,然後枚舉該可枚舉對象將需要爲 IEnumerator 進行第二次分配。相比之下,MemoryMarshal.ToEnumerable 可以返回一個既是 IEnumerable 又是 IEnumerator 的 IEnumerable 實例。實際上,自從它被添加以來,它就是這樣做的,整個實現如下:

public static IEnumerable<T> ToEnumerable<T>(ReadOnlyMemory<T> memory)
{
    for (int i = 0; i < memory.Length; i++)
        yield return memory.Span[i];
}

C# 編譯器爲這樣的迭代器生成一個實際上也實現了 IEnumerator 並從 GetEnumerator 返回自身以避免額外分配的 IEnumerable,所以這是好的。然而,如前所述,Memory.Span 有一些開銷,而這是每個元素都訪問 .Span 一次...並不理想。dotnet/runtime#89274 以多種方式解決了這個問題。首先,ToEnumerable 本身可以檢查 Memory 背後的底層對象的類型,對於 T[] 或字符串,可以返回一個不同的迭代器,該迭代器直接索引數組或字符串,而不是每次訪問都通過 .Span。此外,ToEnumerable 可以檢查 Memory 表示的邊界是否爲數組或字符串的全長...如果是,那麼 ToEnumerable 可以直接返回原始對象,無需任何額外的分配。結果是,對於除 MemoryManager 之外的任何東西,都有一個更有效的枚舉方案,這是非常罕見的(但也不會受到其他類型改進的負面影響)。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers;
using System.Runtime.InteropServices;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly Memory<char> _array = Enumerable.Repeat('a', 1000).ToArray();

    [Benchmark]
    public int Count() => Count(MemoryMarshal.ToEnumerable<char>(_array));

    [Benchmark]
    public int CountLINQ() => Enumerable.Count(MemoryMarshal.ToEnumerable<char>(_array));

    private static int Count<T>(IEnumerable<T> source)
    {
        int count = 0;
        foreach (T item in source) count++;
        return count;
    }

    private sealed class WrapperMemoryManager<T>(Memory<T> memory) : MemoryManager<T>
    {
        public override Span<T> GetSpan() => memory.Span;
        public override MemoryHandle Pin(int elementIndex = 0) => throw new NotSupportedException();
        public override void Unpin() => throw new NotSupportedException();
        protected override void Dispose(bool disposing) { }
    }
}
方法 運行時 平均值 比率
Count .NET 7.0 6,336.147 ns 1.00
Count .NET 8.0 1,323.376 ns 0.21
CountLINQ .NET 7.0 4,972.580 ns 1.000
CountLINQ .NET 8.0 9.200 ns 0.002

SearchValues

從這篇文檔的長度就可以看出,.NET 8 中有大量的性能改進。正如我之前提到的,我認爲 .NET 8 中最有價值的新增功能是默認啓用動態 PGO。在此之後,我認爲最令人興奮的新增功能是新的 System.Buffers.SearchValues 類型。在我看來,它簡直太棒了。

從功能上講,SearchValues 並沒有做任何你不能已經做的事情。例如,假設你想在文本中搜索下一個 ASCII 字母或數字。你已經可以通過 IndexOfAny 來實現:

ReadOnlySpan<char> text = ...;
int pos = text.IndexOfAny("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789");

這是可以工作的,但它並不特別快。在 .NET 7 中,IndexOfAny(ReadOnlySpan) 是針對搜索最多 5 個目標字符進行優化的,例如,它可以有效地向量化搜索英文元音(IndexOfAny("aeiou"))。但是,對於像前面的例子中的 62 個字符的目標集,它將不再向量化,而是試圖看看每個字符可以使用多少指令,而不是試圖看看每個指令可以處理多少字符(這意味着我們不再談論在 haystack 中每個字符的指令的分數,而是談論在 haystack 中每個字符的多個指令)。它通過一個布隆過濾器來實現,這在實現中被稱爲“概率映射”。其思想是維護一個 256 位的位圖。對於每個 needle 字符,它在該位圖中設置 2 位。然後在搜索 haystack 時,對於每個字符,它查看位圖中是否設置了兩個位;如果至少有一個沒有設置,那麼這個字符就不可能在 needle 中,搜索可以繼續,但如果兩個位都在位圖中,那麼 haystack 字符可能在 needle 中,但不能確定,然後搜索 needle 中的字符,看看我們是否找到了匹配。

實際上,已經有已知的算法可以更有效地進行這些搜索。例如,Mula 描述的“通用”算法在搜索任意集合的 ASCII 字符時是一個很好的選擇,使我們能夠有效地向量化搜索由 ASCII 的任何子集組成的 needle。這樣做需要一些計算來分析 needle 並構建執行搜索所需的相關位圖和向量,就像我們必須爲布隆過濾器這樣做一樣(儘管生成的是不同的工件)。dotnet/runtime#76740 在 {Last}IndexOfAny{Except} 中實現了這些技術。而不是總是構建一個概率映射,它首先檢查 needle,看看所有的值是否都是 ASCII,如果是,那麼它切換到這個優化的基於 ASCII 的搜索;如果不是,它回退到之前使用的相同的概率映射方法。PR 還認識到,只有在正確的條件下,嘗試任何優化纔是值得的;例如,如果 haystack 真的很短,我們最好只是做簡單的 O(M*N) 搜索,對於 haystack 中的每個字符,我們搜索 needle,看看 char 是否是目標。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;

    [Benchmark]
    public int CountEnglishVowelsAndSometimeVowels()
    {
        ReadOnlySpan<char> remaining = s_haystack;
        int count = 0, pos;
        while ((pos = remaining.IndexOfAny("aeiouyAEIOUY")) >= 0)
        {
            count++;
            remaining = remaining.Slice(pos + 1);
        }

        return count;
    }
}
方法 運行時 平均值 比率
CountEnglishVowelsAndSometimeVowels .NET 7.0 6.823 ms 1.00
CountEnglishVowelsAndSometimeVowels .NET 8.0 3.735 ms 0.55

即使有了這些改進,構建這些向量的工作還是相當重複的,而且並不是免費的。如果你在循環中有這樣的 IndexOfAny,你就需要反覆地支付構建這些向量的成本。我們還可以做更多的工作來進一步檢查數據,選擇一個更優的方法,但是每進行一次額外的檢查,都會增加 IndexOfAny 調用的開銷。這就是 SearchValues 的用武之地。SearchValues 的想法是一次性完成所有這些工作,然後將其緩存。幾乎總是,使用 SearchValues 的模式是創建一個,將其存儲在一個靜態的只讀字段中,然後使用該 SearchValues 進行所有針對該目標集的搜索操作。現在有一些像 IndexOfAny 這樣的方法的重載,它們接受一個 SearchValues 或 SearchValues,例如,而不是一個 ReadOnlySpan 或 ReadOnlySpan。因此,我之前的 ASCII 字母或數字的例子現在看起來是這樣的:

private static readonly SearchValues<char> s_asciiLettersOrDigits = SearchValues.Create("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789");
...
int pos = text.IndexOfAny(s_asciiLettersOrDigits);

dotnet/runtime#78093 提供了 SearchValues 的初始實現(它最初被命名爲 IndexOfAnyValues,但我們隨後將其重命名爲更通用的 SearchValues,以便我們現在和將來可以與其他方法一起使用,如 Count 或 Replace)。如果你瀏覽實現,你會看到 Create 工廠方法不僅僅返回一個具體的 SearchValues 類型;相反,SearchValues 提供了一個內部抽象,然後由十五個以上的派生實現實現,每個都專門用於不同的場景。你可以通過運行以下程序很容易地在代碼中看到這一點:

// dotnet run -f net8.0

using System.Buffers;

Console.WriteLine(SearchValues.Create(""));
Console.WriteLine(SearchValues.Create("a"));
Console.WriteLine(SearchValues.Create("ac"));
Console.WriteLine(SearchValues.Create("ace"));
Console.WriteLine(SearchValues.Create("ab\u05D0\u05D1"));
Console.WriteLine(SearchValues.Create("abc\u05D0\u05D1"));
Console.WriteLine(SearchValues.Create("abcdefghijklmnopqrstuvwxyz"));
Console.WriteLine(SearchValues.Create("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"));
Console.WriteLine(SearchValues.Create("\u00A3\u00A5\u00A7\u00A9\u00AB\u00AD"));
Console.WriteLine(SearchValues.Create("abc\u05D0\u05D1\u05D2"));
and you’ll see output like the following:

System.Buffers.EmptySearchValues`1[System.Char]
System.Buffers.SingleCharSearchValues`1[System.Buffers.SearchValues+TrueConst]
System.Buffers.Any2CharSearchValues`1[System.Buffers.SearchValues+TrueConst]
System.Buffers.Any3CharSearchValues`1[System.Buffers.SearchValues+TrueConst]
System.Buffers.Any4SearchValues`2[System.Char,System.Int16]
System.Buffers.Any5SearchValues`2[System.Char,System.Int16]
System.Buffers.RangeCharSearchValues`1[System.Buffers.SearchValues+TrueConst]
System.Buffers.AsciiCharSearchValues`1[System.Buffers.IndexOfAnyAsciiSearcher+Default]
System.Buffers.ProbabilisticCharSearchValues
System.Buffers.ProbabilisticWithAsciiCharSearchValues`1[System.Buffers.IndexOfAnyAsciiSearcher+Default]

每個不同的輸入都會被映射到一個不同的 SearchValues 派生類型。

在初始的 PR 之後,SearchValues 一直在不斷地改進和完善。例如,dotnet/runtime#78863 添加了 AVX2 支持,這樣,當使用 256 位向量(如果可用)而不是 128 位向量時,一些基準測試的吞吐量幾乎翻了一倍,而 dotnet/runtime#83122 則啓用了 WASM 支持。dotnet/runtime#78996 添加了一個 Contains 方法,用於實現標量回退路徑。而 dotnet/runtime#86046 通過調整如何在內部傳遞相關的位圖和向量,減少了使用 SearchValues 調用 IndexOfAny 的開銷。但我最喜歡的兩個調整是 dotnet/runtime#82866 和 dotnet/runtime#84184,它們在 '\0'(空)是 needle 中的字符之一時,改進了開銷。爲什麼這會有關係呢?搜索 '\0' 看起來並不常見吧?有趣的是,在各種情況下,它可能會很常見。假設你有一個非常擅長搜索 ASCII 的任何子集的算法,但你想用它來搜索特定的 ASCII 子集或非 ASCII 的東西。如果你只搜索子集,你就不會了解到非 ASCII 的命中。如果你搜索除子集之外的所有東西,你會了解到非 ASCII 的命中,但也會了解到所有錯誤的 ASCII 字符。相反,你想做的是反轉 ASCII 子集,例如,如果你的目標字符是 'A' 到 'Z' 和 'a' 到 'z',你反而創建包括 '\u0000' 到 '\u0040','\u005B' 到 '\u0060',和 '\u007B' 到 '\u007F' 的子集。然後,你不是用那個反轉的子集做 IndexOfAny,而是用那個反轉的子集做 IndexOfAnyExcept;這是一個真正的“兩個錯誤造就一個正確”的情況,因爲我們最終會得到我們想要的行爲,即搜索原始的 ASCII 字母子集加上任何非 ASCII 的東西。你會注意到,'\0' 在我們的反轉子集中,這使得 '\0' 在其中時的性能比其他情況更重要。

有趣的是,.NET 8 中的概率映射代碼路徑實際上也享受到了一定程度的向量化,即使沒有 SearchValues,也要感謝 dotnet/runtime#80963(它在 dotnet/runtime#85189 中得到了進一步的改進,該改進在 Arm 上使用了更好的指令,在 dotnet/runtime#85203 中避免了一些無用的工作)。這意味着,無論是否使用 SearchValues,涉及概率映射的搜索都會比在 .NET 7 中快得多。例如,這裏有一個基準測試,再次搜索 “The Adventures of Sherlock Holmes”,並計算其中的行結束符數量,使用的是 string.ReplaceLineEndings 使用的相同的 needle:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;

    [Benchmark]
    public int CountLineEndings()
    {
        int count = 0;
        ReadOnlySpan<char> haystack = s_haystack;

        int pos;
        while ((pos = haystack.IndexOfAny("\n\r\f\u0085\u2028\u2029")) >= 0)
        {
            count++;
            haystack = haystack.Slice(pos + 1);
        }

        return count;
    }
}
方法 運行時 平均值 比率
CountLineEndings .NET 7.0 2.155 ms 1.00
CountLineEndings .NET 8.0 1.323 ms 0.61

然後,可以使用 SearchValues 來進一步改進。它不僅通過緩存每次調用 IndexOfAny 都需要重新計算的概率映射來實現,而且還通過識別當 needle 包含 ASCII 時,這是一個好的指示(啓發式)ASCII haystacks 將會突出。因此,dotnet/runtime#89155 添加了一個快速路徑,該路徑執行對任何 ASCII needle 值或任何非 ASCII 值的搜索,如果它找到一個非 ASCII 值,那麼它就回退到執行向量化概率映射搜索。

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;
    private static readonly SearchValues<char> s_lineEndings = SearchValues.Create("\n\r\f\u0085\u2028\u2029");

    [Benchmark]
    public int CountLineEndings_Chars()
    {
        int count = 0;
        ReadOnlySpan<char> haystack = s_haystack;

        int pos;
        while ((pos = haystack.IndexOfAny("\n\r\f\u0085\u2028\u2029")) >= 0)
        {
            count++;
            haystack = haystack.Slice(pos + 1);
        }

        return count;
    }

    [Benchmark]
    public int CountLineEndings_SearchValues()
    {
        int count = 0;
        ReadOnlySpan<char> haystack = s_haystack;

        int pos;
        while ((pos = haystack.IndexOfAny(s_lineEndings)) >= 0)
        {
            count++;
            haystack = haystack.Slice(pos + 1);
        }

        return count;
    }
}
方法 平均值
CountLineEndings_Chars 1,300.3 us
CountLineEndings_SearchValues 430.9 us

dotnet/runtime#89224 進一步增強了這種啓發式方法,通過快速檢查下一個字符是否爲非 ASCII 來保護 ASCII 快速路徑,如果是,則跳過基於 ASCII 的搜索,從而避免處理全非 ASCII 輸入時的開銷。例如,這是運行前一個基準測試的結果,代碼完全相同,只是將 URL 改爲 https://www.gutenberg.org/files/39963/39963-0.txt,這是一份幾乎完全由希臘文組成的文檔,包含了亞里士多德的《雅典人的憲法》:

方法 平均值
CountLineEndings_Chars 542.6 us
CountLineEndings_SearchValues 283.6 us

有了 SearchValues 的所有優點,現在在 dotnet/runtime 中得到了廣泛的應用。例如,System.Text.Json 之前有自己專用的實現函數 IndexOfQuoteOrAnyControlOrBackSlash,用於搜索任何序數值小於 32 的字符,或引號,或反斜槓。在 .NET 7 中,該實現是大約 200 行的複雜 Vector 基礎代碼。現在在 .NET 8 中,感謝 dotnet/runtime#82789,它只是這樣:

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int IndexOfQuoteOrAnyControlOrBackSlash(this ReadOnlySpan<byte> span) =>
    span.IndexOfAny(s_controlQuoteBackslash);

private static readonly SearchValues<byte> s_controlQuoteBackslash = SearchValues.Create(
    "\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u0009\u000A\u000B\u000C\u000D\u000E\u000F\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001A\u001B\u001C\u001D\u001E\u001F"u8 + // Any Control, < 32 (' ')
    "\""u8 + // Quote
    "\\"u8); // Backslash

這種使用方式在一系列的 PR 中得到了推廣,例如 dotnet/runtime#78664 在 System.Private.Xml 中使用了 SearchValues,dotnet/runtime#81976 在 JsonSerializer 中使用,dotnet/runtime#78676 在 X500NameEncoder 中使用,dotnet/runtime#78667 在 Regex.Escape 中使用,dotnet/runtime#79025 在 ZipFile 和 TarFile 中使用,dotnet/runtime#79974 在 WebSocket 中使用,dotnet/runtime#81486 在 System.Net.Mail 中使用,以及 dotnet/runtime#78896 在 Cookie 中使用。dotnet/runtime#78666 和 dotnet/runtime#79024 在 Uri 中的使用特別好,包括使用 SearchValues 優化了常用的 Uri.EscapeDataString 助手;這顯示出了顯著的改進,特別是當沒有需要轉義的內容時。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private string _value = Convert.ToBase64String("How did I escape? With difficulty. How did I plan this moment? With pleasure. "u8);

    [Benchmark]
    public string EscapeDataString() => Uri.EscapeDataString(_value);
}
方法 運行時 平均值 比率
EscapeDataString .NET 7.0 85.468 ns 1.00
EscapeDataString .NET 8.0 8.443 ns 0.10

總的來說,僅在 dotnet/runtime 中,SearchValues.Create 現在已經在 40 多個地方被使用,這還不包括作爲 Regex 的一部分生成的所有使用(稍後會有更多介紹)。dotnet/roslyn-analyzers#6898 對此起到了推動作用,它添加了一個新的分析器,該分析器將標記 SearchValues 的使用機會並更新代碼以使用它:CA1870。

在整個討論中,我多次提到了 ReplaceLineEndings,將其作爲一個想要有效搜索多個字符的例子。在 dotnet/runtime#78678 和 dotnet/runtime#81630 之後,它現在也使用了 SearchValues,並且還增加了其他優化。鑑於對 SearchValues 的討論,它在這裏的使用方式將是顯而易見的,至少基本的使用方式是這樣的。以前,ReplaceLineEndings 依賴於一個內部助手 IndexOfNewlineChar 來實現這個功能:

internal static int IndexOfNewlineChar(ReadOnlySpan<char> text, out int stride)
{
    const string Needles = "\r\n\f\u0085\u2028\u2029";
    int idx = text.IndexOfAny(needles);
    ...
}

現在,它這樣做:

int idx = text.IndexOfAny(SearchValuesStorage.NewLineChars);

其中,NewLineChars 只是:

internal static class SearchValuesStorage
{
    public static readonly SearchValues<char> NewLineChars = SearchValues.Create("\r\n\f\u0085\u2028\u2029");
}

這很直接。然而,它進一步推進了事情。注意,這個列表中有 6 個字符,其中一些是 ASCII,一些不是。瞭解 SearchValues 目前採用的算法,我們知道這將使它偏離只做 ASCII 搜索的路徑,而是使用一種搜索 3 個 ASCII 字符加上任何非 ASCII 的算法,如果找到任何非 ASCII,那麼將回退到執行概率映射搜索。如果我們能去掉其中一個字符,我們就能回到只使用可以處理任何 5 個字符的 IndexOfAny 實現的範圍。在非 Windows 系統上,我們很幸運。ReplaceLineEndings 默認用 Environment.NewLine 替換行結束符;在 Windows 上,這是 "\r\n",但在 Linux 和 macOS 上,這是 "\n"。如果替換文本是 "\n"(在 Windows 上也可以通過使用 ReplaceLineEndings(string replacementText) 重載選擇),那麼搜索 '\n' 只是爲了用 '\n' 替換它,這是一個無操作,這意味着我們可以在替換文本是 "\n" 時從搜索列表中刪除 '\n',這使我們只有 5 個目標字符,給我們帶來了一點優勢。雖然這是一個很好的小收穫,但更大的收穫是我們不會頻繁地跳出向量化循環,或者如果所有的行結束符都是替換文本,我們根本不會跳出。此外,.NET 7 的實現總是創建一個新的字符串來返回,但如果我們實際上沒有用任何新的東西替換任何東西,我們可以避免分配它。所有這些的結果是對 ReplaceLineEndings 的巨大改進,一部分是由於 SearchValues,一部分是超出了 SearchValues。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
    // NOTE: This text uses \r\n as its line endings
    private static readonly string s_text = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;

    [Benchmark]
    [Arguments("\r\n")]
    [Arguments("\n")]
    public string ReplaceLineEndings(string replacement) => s_text.ReplaceLineEndings(replacement);
}
方法 運行時 替換 平均值 比率 分配 分配比率
ReplaceLineEndings .NET 7.0 \n 2,746.3 us 1.00 1163121 B 1.00
ReplaceLineEndings .NET 8.0 \n 995.9 us 0.36 1163121 B 1.00
ReplaceLineEndings .NET 7.0 \r\n 2,920.1 us 1.00 1187729 B 1.00
ReplaceLineEndings .NET 8.0 \r\n 356.5 us 0.12 0.00

SearchValue 的變化也積累到了基於 span 的非分配的 EnumerateLines 中:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private static readonly string s_text = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;

    [Benchmark]
    public int CountLines()
    {
        int count = 0;
        foreach (ReadOnlySpan<char> _ in s_text.AsSpan().EnumerateLines()) count++;
        return count;
    }
}
方法 運行時 平均值 比率
CountLines .NET 7.0 2,029.9 us 1.00
CountLines .NET 8.0 353.2 us 0.17

Regex

剛剛研究了 SearchValues,現在是談論正則表達式的好時機,因爲前者現在在後者中起着重要的作用。.NET 5 中顯著改進了正則表達式,然後在 .NET 7 中再次進行了全面改革,引入了正則表達式源代碼生成器。現在在 .NET 8 中,正則表達式繼續得到重大投資,特別是在這個版本中,利用了在堆棧較低處引入的許多已經討論過的工作,以實現更有效的搜索。

作爲提醒,System.Text.RegularExpressions 中有效地有三種不同的“引擎”,這意味着實際上有三種不同的組件用於處理正則表達式。最簡單的引擎是“解釋器”;正則表達式構造函數將正則表達式轉換爲一系列正則表達式操作碼,然後 RegexInterpreter 針對傳入的文本評估這些操作碼。這是在一個“掃描”循環中完成的,(簡化後)看起來像這樣:

while (TryFindNextStartingPosition(text))
{
    if (TryMatchAtCurrentPosition(text) || _currentPosition == text.Length) break;
    _currentPosition++;
}

TryFindNextStartingPosition 嘗試儘可能多地移動輸入文本,直到找到輸入中可能開始匹配的位置,然後 TryMatchAtCurrentPosition 在該位置對輸入評估模式。在解釋器中,該評估涉及到像這樣的循環,處理從模式產生的操作碼:

while (true)
{
    switch (_opcode)
    {
        case RegexOpcode.Stop:
            return match.FoundMatch;
        case RegexOpcode.Goto:
            Goto(Operand(0));
            continue;
        ... // cases for ~50 other opcodes
    }
}

然後是非回溯引擎,當你選擇在 .NET 7 中引入的 RegexOptions.NonBacktracking 選項時,你會得到這個引擎。這個引擎與解釋器共享相同的 TryFindNextStartingPosition 實現,這樣所有涉及儘可能多地跳過文本(理想情況下通過向量化的 IndexOf 操作)的優化都會累積到解釋器和非回溯引擎中。然而,相似之處就到此爲止。非回溯引擎不是處理正則表達式操作碼,而是通過將正則表達式模式轉換爲懶構造的確定性有限自動機(DFA)或非確定性有限自動機(NFA),然後使用它來評估輸入文本。非回溯引擎的關鍵優點是它在輸入長度上提供線性時間執行保證。更多詳細信息,請閱讀 .NET 7 中的正則表達式改進。

第三種引擎實際上有兩種形式:RegexOptions.Compiled 和正則表達式源代碼生成器(在 .NET 7 中引入)。除了一些邊角案例,這兩者在工作方式上實際上是相同的。它們都生成針對提供的輸入模式的特定代碼,前者在運行時生成 IL,後者在構建時生成 C#(然後由 C# 編譯器編譯爲 IL)。生成的代碼的結構,以及應用的 99% 的優化,在它們之間是相同的;事實上,在 .NET 7 中,RegexCompiler 完全被重寫爲 C# 代碼的塊對塊翻譯,這些代碼是正則表達式源代碼生成器發出的。對於兩者,實際發出的代碼都完全定製爲提供的確切模式,兩者都試圖生成儘可能有效地處理正則表達式的代碼,源代碼生成器試圖通過生成儘可能接近專家 .NET 開發人員可能編寫的代碼來實現這一點。這在很大程度上是因爲它生成的源代碼是可見的,甚至在 Visual Studio 中編輯模式時也是實時的:在 Visual Studio 中生成的正則表達式。

我提到所有這些,是因爲在整個正則表達式中,無論是解釋器和非回溯引擎使用的 TryFindNextStartingPosition,還是 RegexCompiler 和正則表達式源代碼生成器生成的代碼中,都有大量的機會使用新引入的 API 來加速搜索。我在看你,IndexOf 和朋友們。

如前所述,.NET 8 中引入了新的 IndexOf 變體,用於搜索範圍,而且從 dotnet/runtime#76859 開始,正則表達式現在將在生成的代碼中充分利用它們。例如,考慮 [GeneratedRegex(@"[0-9]{5}")], 它可能被用來在美國搜索郵政編碼。.NET 7 中的正則表達式源代碼生成器會爲 TryFindNextStartingPosition 生成包含以下內容的代碼:

// The pattern begins with '0' through '9'.
// Find the next occurrence. If it can't be found, there's no match.
ReadOnlySpan<char> span = inputSpan.Slice(pos);
for (int i = 0; i < span.Length - 4; i++)
{
    if (char.IsAsciiDigit(span[i]))
    ...
}

現在在 .NET 8 中,同樣的屬性會生成這樣的代碼:

// 模式以集合 [0-9] 中的字符開始。
// 找到下一個出現的位置。如果找不到,那就沒有匹配。
ReadOnlySpan<char> span = inputSpan.Slice(pos);
for (int i = 0; i < span.Length - 4; i++)
{
    int indexOfPos = span.Slice(i).IndexOfAnyInRange('0', '9');
    ...
}

.NET 7 的實現一次檢查一個字符,而 .NET 8 的代碼通過 IndexOfAnyInRange 向量化搜索,一次檢查多個字符。這可以顯著提高速度。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;

    private readonly Regex _regex = new Regex("[0-9]{5}", RegexOptions.Compiled);

    [Benchmark]
    public int Count() => _regex.Count(s_haystack);
}
方法 運行時 平均值 比率
Count .NET 7.0 423.88 us 1.00
Count .NET 8.0 29.91 us 0.07

生成的代碼也可以在其他地方使用這些 API,甚至作爲驗證匹配本身的一部分。假設你的模式是 [GeneratedRegex(@"(\w{3,})[0-9]")], 它將尋找並捕獲至少三個單詞字符的序列,然後跟着一個 ASCII 數字。這是一個標準的貪婪循環,所以它會消耗盡可能多的單詞字符(包括 ASCII 數字),然後回溯,退回一些消耗的單詞字符,直到找到一個數字。以前,這是通過退回一個字符,看看它是否是一個數字,退回一個字符,看看它是否是一個數字,等等來實現的。現在呢?源代碼生成器發出包含以下內容的代碼:

charloop_ending_pos = inputSpan.Slice(charloop_starting_pos, charloop_ending_pos - charloop_starting_pos).LastIndexOfAnyInRange('0', '9')

換句話說,它使用 LastIndexOfAnyInRange 來優化向後搜索下一個可行回溯位置的操作。

另一個顯著的改進是建立在堆棧較低處的改進之上的 dotnet/runtime#85438。如前所述,.NET 8 中 span.IndexOf("...", StringComparison.OrdinalIgnoreCase) 的向量化已經得到改進。以前,Regex 沒有使用這個 API,因爲它通常可以用自己的自定義生成的代碼做得更好。但現在這個 API 已經被優化,這個 PR 改變了 Regex 使用它,使生成的代碼更簡單,更快。這裏我正在不區分大小寫地搜索整個單詞“year”:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;

    private readonly Regex _regex = new Regex(@"\byear\b", RegexOptions.Compiled | RegexOptions.IgnoreCase);

    [Benchmark]
    public int Count() => _regex.Count(s_haystack);
}
| 方法 | 運行時 | 平均值 | 比率 |
| --- | --- | --- | --- |
| Count | .NET 7.0 | 181.80 us | 1.00 |
| Count | .NET 8.0 | 63.10 us | 0.35 |

除了學習如何使用現有的 IndexOf(..., StringComparison.OrdinalIgnoreCase) 和新的 IndexOfAnyInRange 和 IndexOfAnyExceptInRange,.NET 8 中的 Regex 還學習如何使用新的 SearchValues。這對於 Regex 來說是一個巨大的提升,因爲它現在意味着它可以向量化搜索比以前更多的集合。例如,假設你想搜索所有的十六進制數字。你可能會使用像 [0123456789ABCDEFabcdef]+ 這樣的模式。如果你將它插入到 .NET 7 中的 regex 源代碼生成器,你會得到一個發出包含如下代碼的 TryFindNextPossibleStartingPosition:

// 模式以集合 [0-9A-Fa-f] 中的字符開始。
// 找到下一個出現的位置。如果找不到,那就沒有匹配。
ReadOnlySpan<char> span = inputSpan.Slice(pos);
for (int i = 0; i < span.Length; i++)
{
    if (char.IsAsciiHexDigit(span[i]))
    {
        base.runtextpos = pos + i;
        return true;
    }
}

現在在 .NET 8 中,主要歸功於 dotnet/runtime#78927,你會得到像這樣的代碼:

// 模式以集合 [0-9A-Fa-f] 中的字符開始。
// 找到下一個出現的位置。如果找不到,那就沒有匹配。
int i = inputSpan.Slice(pos).IndexOfAny(Utilities.s_asciiHexDigits);
if (i >= 0)
{
    base.runtextpos = pos + i;
    return true;
}

那麼 Utilities.s_asciiHexDigits 是什麼呢?它是一個 SearchValues,被輸出到文件的 Utilities 類中:

/// <summary>支持搜索在 "0123456789ABCDEFabcdef" 中或不在其中的字符。</summary>
internal static readonly SearchValues<char> s_asciiHexDigits = SearchValues.Create("0123456789ABCDEFabcdef");

源代碼生成器明確識別了這個集合,因此爲它創建了一個好的名字,但這純粹是爲了可讀性;即使它不識別集合爲某種衆所周知且易於命名的東西,它仍然可以使用 SearchValues。例如,如果我將集合擴充爲所有有效的十六進制數字和下劃線,我會得到這樣的結果:

/// <summary>支持搜索在 "0123456789ABCDEF_abcdef" 中或不在其中的字符。</summary>
internal static readonly SearchValues<char> s_ascii_FF037E0000807E000000 = SearchValues.Create("0123456789ABCDEF_abcdef");

當最初添加到 Regex 中時,SearchValues 只在輸入集全爲 ASCII 時使用。但隨着 .NET 8 的開發,SearchValues 的改進,Regex 對它的使用也隨之改進。有了 dotnet/runtime#89205,Regex 現在依賴於 SearchValues 的能力,有效地搜索 ASCII 和非 ASCII,並且如果它能有效地枚舉集合的內容,且該集合包含相當少的字符(今天,這意味着不超過 128 個),它也會發出一個 SearchValues。有趣的是,SearchValues 的優化,首先搜索目標的 ASCII 子集,然後回退到向量化的概率映射搜索,最初是在 Regex 中原型化的(dotnet/runtime#89140),之後我們決定將優化推向 SearchValues,這樣 Regex 可以生成更簡單的代碼,其他非 Regex 消費者也會受益。
然而,這仍然留下了我們無法有效地枚舉集合以確定它包含的每個字符的情況,也不希望將大量的字符傳遞給 SearchValues。考慮集合 \w,即“單詞字符”。在 65,536 個 char 值中,有 50,409 個匹配集合 \w。爲了嘗試爲它們創建一個 SearchValues,枚舉所有這些字符將是低效的,Regex 也不會嘗試。相反,從 dotnet/runtime#83992 開始,Regex 採用了上面提到的類似方法,但是有一個標量回退。例如,對於模式 \w+,它將以下助手發射到 Utilities:

internal static int IndexOfAnyWordChar(this ReadOnlySpan<char> span)
{
    int i = span.IndexOfAnyExcept(Utilities.s_asciiExceptWordChars);
    if ((uint)i < (uint)span.Length)
    {
        if (char.IsAscii(span[i]))
        {
            return i;
        }

        do
        {
            if (Utilities.IsWordChar(span[i]))
            {
                return i;
            }
            i++;
        }
        while ((uint)i < (uint)span.Length);
    }

    return -1;
}

/// <summary>支持搜索在"\0\u0001\u0002\u0003\u0004\u0005\u0006\a\b\t\n\v\f\r\u000e\u000f\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001a\u001b\u001c\u001d\u001e\u001f !\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~\u007f" 中或不在其中的字符。</summary>
internal static readonly SearchValues<char> s_asciiExceptWordChars = SearchValues.Create("\0\u0001\u0002\u0003\u0004\u0005\u0006\a\b\t\n\v\f\r\u000e\u000f\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001a\u001b\u001c\u001d\u001e\u001f !\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~\u007f");

它將助手命名爲“IndexOfAnyWordChar”是一回事,再次,這與它能夠生成這個助手是分開的;它只是在這裏識別集合作爲確定名稱的一部分,並能夠提出一個更好的名稱,但如果它沒有識別它,方法的主體將是相同的,名稱只是不太可讀,因爲它會提出相當混亂但唯一的東西。

有趣的是,我注意到源代碼生成器和 RegexCompiler 實際上是相同的,只是一個生成 C#,一個生成 IL。這有 99% 是正確的。然而,他們使用 SearchValues 的方式有一個有趣的區別,這使得源代碼生成器在如何利用這種類型方面更有效率。每當源代碼生成器需要一個新的字符組合的 SearchValues 實例時,它可以只發出另一個靜態只讀字段,因爲它是靜態只讀的,JIT 的去虛擬化和內聯優化可以啓動,使用這個看到實例的實際類型並基於此進行優化。好極了。RegexCompiler 是另一回事。RegexCompiler 爲給定的 Regex 發出 IL,並使用 DynamicMethod 這提供了反射發射最輕量級的解決方案,也允許當它們不再被引用時,生成的方法被垃圾收集。然而,DynamicMethods 就是方法。沒有支持創建額外的靜態字段的需求,沒有進入更昂貴的 TypeBuilder-based 解決方案。那麼 RegexCompiler 如何創建和存儲任意數量的 SearchValue 實例,以及如何以類似地啓用去虛擬化的方式來做呢?它採用了一些技巧。首先,向內部的 CompiledRegexRunner 類型添加了一個字段,該字段存儲生成方法的委託:private readonly SearchValues[]? _searchValues; 作爲數組,這使得可以存儲任意數量的 SearchValues;發出的 IL 可以訪問字段,獲取數組,並索引到它以獲取相關的 SearchValues 實例。當然,只做這個,不允許去虛擬化,甚至動態 PGO 在這裏也沒有幫助,因爲目前 DynamicMethods 不參與分層;編譯直接進入 tier 1,所以沒有機會進行儀器化以查看實際使用的 SearchValues-derived 類型。幸運的是,有可用的解決方案。JIT 可以從存儲它的本地類型中瞭解實例的類型,所以一個解決方案是創建一個具體的和密封的 SearchValues 派生類型的本地(我們在這一點上正在寫 IL,所以我們可以做這樣的事情,而不實際訪問問題中的類型),從數組中讀取 SearchValues,將其存儲到本地,然後使用本地進行後續訪問。實際上,我們在 .NET 8 的開發過程中這樣做了一段時間。然而,這確實需要一個本地,並需要額外的讀/寫本地。相反,dotnet/runtime#85954 中的一個調整允許 JIT 使用 Unsafe.As(object o) 中的 T 來了解 T 的實際類型,所以 RegexCompiler 只需要使用 Unsafe.As 來通知 JIT 實例的實際類型,然後它就被去虛擬化了。RegexCompiler 用來發出 IL 加載 SearchValues 的代碼是這樣的:

// from RegexCompiler.cs, tweaked for readability in this post
private void LoadSearchValues(ReadOnlySpan<char> chars)
{
    List<SearchValues<char>> list = _searchValues ??= new();
    int index = list.Count;
    list.Add(SearchValues.Create(chars));

    // Unsafe.As<DerivedSearchValues>(Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(this._searchValues), index));
    _ilg.Emit(OpCodes.Ldarg_0);
    _ilg.Emit(OpCodes.Ldfld, s_searchValuesArrayField);
    _ilg.Emit(OpCodes.Call, s_memoryMarshalGetArrayDataReferenceSearchValues);
    _ilg.Emit(OpCodes.Ldc_I4, index * IntPtr.Size);
    _ilg.Emit(OpCodes.Add);
    _ilg.Emit(OpCodes.Ldind_Ref);
    _ilg.Emit(OpCodes.Call, typeof(Unsafe).GetMethod("As", new[] { typeof(object) })!.MakeGenericMethod(list[index].GetType()));
}

我們可以通過如下的基準測試來看到所有這些操作:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
    private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;

    private static readonly Regex s_names = new Regex("Holmes|Watson|Lestrade|Hudson|Moriarty|Adler|Moran|Morstan|Gregson", RegexOptions.Compiled);

    [Benchmark]
    public int Count() => s_names.Count(s_haystack);
}

在這裏,我們在同一份夏洛克·福爾摩斯的文本中搜索偵探故事中最常見的一些角色的名字。正則表達式模式分析器會嘗試找到可以向量化搜索的東西,它會查看每個匹配位置可以有效存在的所有字符,例如,所有匹配都以 'H'、'W'、'L'、'M'、'A' 或 'G' 開始。而且,由於最短的匹配是五個字母(“Adler”),它最終會查看前五個位置,得出以下集合:

0: [AGHLMW]
1: [adeoru]
2: [delrst]
3: [aegimst]
4: [aenorst]

所有這些集合中的字符都超過了五個,這是一個重要的區分,因爲在 .NET 7 中,這是 IndexOfAny 會向量化搜索的字符的最大數量。因此,在 .NET 7 中,Regex 最終會生成遍歷輸入並逐個檢查字符的代碼(儘管它確實使用了快速的無分支位圖機制來匹配集合):

ReadOnlySpan<char> span = inputSpan.Slice(pos);
for (int i = 0; i < span.Length - 4; i++)
{
    if (((long)((0x8318020000000000UL << (int)(charMinusLow = (uint)span[i] - 'A')) & (charMinusLow - 64)) < 0) &&
    ...
}

現在在 .NET 8 中,有了 SearchValues,我們可以有效地搜索這些集合中的任何一個,實現最終會選擇它認爲統計上最不可能匹配的一個:

int indexOfPos = span.Slice(i).IndexOfAny(Utilities.s_ascii_8231800000000000);

其中,s_ascii_8231800000000000 定義爲:

/// <summary>支持搜索 "AGHLMW" 中的字符或不在其中的字符。</summary>
internal static readonly SearchValues<char> s_ascii_8231800000000000 = SearchValues.Create("AGHLMW");

這使得整個搜索過程更加高效。

方法 運行時 平均值 比率
Count .NET 7.0 630.5 us 1.00
Count .NET 8.0 142.3 us 0.23

像 dotnet/runtime#84370, dotnet/runtime#89099, 和 dotnet/runtime#77925 這樣的其他 PR 也對 IndexOf 和朋友們的使用做出了貢獻,調整了涉及的各種啓發式方法。但是,除此之外,Regex 的改進也有所提高。例如,dotnet/runtime#84003 通過使用位扭轉技巧,優化了在非 ASCII 字符上匹配 \w 的性能。而 dotnet/runtime#84843 改變了內部枚舉的底層類型,從 int 變爲 byte,這樣做可以縮小包含此枚舉值的對象的大小 8 字節(在 64 位進程中)。更有影響力的是 dotnet/runtime#85564,它對 Regex.Replace 做出了可衡量的改進。Replace 保持了一個 ReadOnlyMemory 段的列表,這些段將被組合回最終的字符串;一些段來自原始字符串,而一些則是替換字符串。然而,事實證明,該 ReadOnlyMemory 中包含的字符串引用是不必要的。我們可以只維護一個 int 列表,每次我們添加一個段時,我們向列表中添加 int 偏移量和 int 計數,由於替換的性質,我們可以簡單地依賴於我們需要在每對值之間插入替換文本的事實。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;

    private static readonly Regex s_vowels = new Regex("[aeiou]", RegexOptions.Compiled);

    [Benchmark]
    public string RemoveVowels() => s_vowels.Replace(s_haystack, "");
}
方法 運行時 平均值 比率
RemoveVowels .NET 7.0 8.763 ms 1.00
RemoveVowels .NET 8.0 7.084 ms 0.81

最後要強調的 Regex 改進實際上並不是由於 Regex 本身的任何內容,而是由於 Regex 在每個操作中都使用的一個基元:Interlocked.Exchange。考慮以下基準測試:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private static readonly Regex s_r = new Regex("", RegexOptions.Compiled);

    [Benchmark]
    public bool Overhead() => s_r.IsMatch("");
}

這純粹是測量調用 Regex 實例的開銷;匹配程序將立即完成,因爲模式匹配任何輸入。由於我們只討論幾十納秒,你的數字可能會有所不同,但我經常得到這樣的結果:

方法 運行時 平均值 比率
Overhead .NET 7.0 32.01 ns 1.00
Overhead .NET 8.0 28.81 ns 0.90

這幾納秒的改進主要歸功於 dotnet/runtime#79181,該改進將 Interlocked.CompareExchange 和 Interlocked.Exchange 對於引用類型變爲內聯,特別是當 JIT 可以看到要寫入的新值是 null 時。這些 API 需要在將對象引用寫入共享位置的過程中使用 GC 寫入屏障,原因與本文前面討論的相同,但是在寫入 null 時,不需要這樣的屏障。這對 Regex 有利,Regex 在租用 RegexRunner 來實際處理匹配時使用 Interlocked.Exchange:

RegexRunner runner = Interlocked.Exchange(ref _runner, null) ?? CreateRunner();
try { ... }
finally { _runner = runner; }

許多對象池實現都使用了類似的 Interlocked.Exchange,並將同樣受益。

Hashing

在 .NET 6 中引入的 System.IO.Hashing 庫提供了非加密哈希算法的實現;最初,它附帶了四種類型:Crc32、Crc64、XxHash32 和 XxHash64。在 .NET 8 中,它得到了重大投資,增加了新的優化算法,提高了現有實現的性能,並在所有算法中增加了新的表面區域。

由於其在大型和小型輸入上的高性能以及其整體質量水平(例如,產生的衝突少,輸入分散良好等),xxHash 系列哈希算法近來已經變得非常流行。System.IO.Hashing 之前包含了舊的 XXH32 和 XXH64 算法的實現(分別作爲 XxHash32 和 XxHash64)。現在在 .NET 8 中,感謝 dotnet/runtime#76641,它包括了 XXH3 算法(作爲 XxHash3),並且感謝來自 @xoofx 的 dotnet/runtime#77944,它包括了 XXH128 算法(作爲 XxHash128)。XxHash3 算法在 @xoofx 的 dotnet/runtime#77756 中通過分攤一些加載和存儲的成本進一步優化,在 @xoofx 的 dotnet/runtime#77881 中,通過更好地利用 AdvSimd 硬件內在功能,提高了在 Arm 上的吞吐量。

爲了看到這些哈希函數的整體性能,這裏有一個微基準測試,比較了加密的 SHA256 與每一個非加密哈希函數的吞吐量。我還包括了 FNV-1a 的實現,這是 C# 編譯器可能用於 switch 語句的哈希算法(當它需要切換一個字符串,例如,它不能想出一個更好的方案,它哈希輸入,然後在每個 case 的預生成哈希中進行二分搜索),以及基於 System.HashCode 的實現(注意 HashCode 與其他的不同,它專注於啓用對任意 .NET 類型的哈希,包括每個進程的隨機化,而這些其他哈希函數的目標是在進程邊界上 100% 確定)。

// For this test, you'll also need to add:
//     <PackageReference Include="System.IO.Hashing" Version="8.0.0-rc.1.23419.4" />
// to the benchmarks.csproj's <ItemGroup>.
// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers.Binary;
using System.IO.Hashing;
using System.Security.Cryptography;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly byte[] _result = new byte[100];
    private byte[] _source;

    [Params(3, 33_333)]
    public int Length { get; set; }

    [GlobalSetup]
    public void Setup() => _source = Enumerable.Range(0, Length).Select(i => (byte)i).ToArray();

    // Cryptographic
    [Benchmark(Baseline = true)] public void TestSHA256() => SHA256.HashData(_source, _result);

    // Non-cryptographic
    [Benchmark] public void TestCrc32() => Crc32.Hash(_source, _result);
    [Benchmark] public void TestCrc64() => Crc64.Hash(_source, _result);
    [Benchmark] public void TestXxHash32() => XxHash32.Hash(_source, _result);
    [Benchmark] public void TestXxHash64() => XxHash64.Hash(_source, _result);
    [Benchmark] public void TestXxHash3() => XxHash3.Hash(_source, _result);
    [Benchmark] public void TestXxHash128() => XxHash128.Hash(_source, _result);

    // Algorithm used by the C# compiler for switch statements
    [Benchmark]
    public void TestFnv1a()
    {
        int hash = unchecked((int)2166136261);
        foreach (byte b in _source) hash = (hash ^ b) * 16777619;
        BinaryPrimitives.WriteInt32LittleEndian(_result, hash);
    }

    // Randomized with a custom seed per process
    [Benchmark]
    public void TestHashCode()
    {
        HashCode hc = default;
        hc.AddBytes(_source);
        BinaryPrimitives.WriteInt32LittleEndian(_result, hc.ToHashCode());
    }
}
方法 長度 平均值 比率
TestSHA256 3 856.168 ns 1.000
TestHashCode 3 9.933 ns 0.012
TestXxHash64 3 7.724 ns 0.009
TestXxHash128 3 5.522 ns 0.006
TestXxHash32 3 5.457 ns 0.006
TestCrc32 3 3.954 ns 0.005
TestCrc64 3 3.405 ns 0.004
TestXxHash3 3 3.343 ns 0.004
TestFnv1a 3 1.617 ns 0.002
TestSHA256 33333 60,407.625 ns 1.00
TestFnv1a 33333 31,027.249 ns 0.51
TestHashCode 33333 4,879.262 ns 0.08
TestXxHash32 33333 4,444.116 ns 0.07
TestXxHash64 33333 3,636.989 ns 0.06
TestCrc64 33333 1,571.445 ns 0.03
TestXxHash3 33333 1,491.740 ns 0.03
TestXxHash128 33333 1,474.551 ns 0.02
TestCrc32 33333 1,295.663 ns 0.02

XxHash3 和 XxHash128 比 XxHash32 和 XxHash64 表現得更好的主要原因是它們的設計主要是爲了向量化。因此,.NET 實現使用 System.Runtime.Intrinsics 中的支持,充分利用底層硬件。這些數據也暗示了爲什麼 C# 編譯器使用 FNV-1a:它非常簡單,對於小輸入的開銷也非常小,這是 switch 語句中最常用的輸入形式,但如果你主要預期的是較長的輸入,那麼它將是一個糟糕的選擇。

你會注意到,在上一個例子中,Crc32 和 Crc64 在吞吐量方面都與 XxHash3 處於同一水平(XXH3 通常在質量方面比 CRC32/64 排名更高)。在這個比較中,Crc32 得益於來自 @brantburnett 的 dotnet/runtime#83321,dotnet/runtime#86539 和 dotnet/runtime#85221。這些基於 Intel 十年前的一篇名爲 "Fast CRC Computation for Generic Polynomials Using PCLMULQDQ Instruction" 的論文,對 Crc32 和 Crc64 的實現進行了向量化。所引用的 PCLMULQDQ 指令是 SSE2 的一部分,然而 PR 也能夠通過利用 Arm 的 PMULL 指令在 Arm 上進行向量化。結果是,與 .NET 7 相比,對於需要哈希的較大輸入,有了巨大的提升。

// For this test, you'll also need to add:
//     <PackageReference Include="System.IO.Hashing" Version="7.0.0" />
// to the benchmarks.csproj's <ItemGroup>.
// dotnet run -c Release -f net7.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using System.IO.Hashing;

var config = DefaultConfig.Instance
    .AddJob(Job.Default.WithRuntime(CoreRuntime.Core70).WithNuGet("System.IO.Hashing", "7.0.0").AsBaseline())
    .AddJob(Job.Default.WithRuntime(CoreRuntime.Core80).WithNuGet("System.IO.Hashing", "8.0.0-rc.1.23419.4"));
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config);

[HideColumns("Error", "StdDev", "Median", "RatioSD", "NuGetReferences")]
public class Tests
{
    private readonly byte[] _source = Enumerable.Range(0, 1024).Select(i => (byte)i).ToArray();
    private readonly byte[] _destination = new byte[4];

    [Benchmark]
    public void Hash() => Crc32.Hash(_source, _destination);
}
方法 運行時 平均值 比率
Hash .NET 7.0 2,416.24 ns 1.00
Hash .NET 8.0 39.01 ns 0.02

另一個改變也進一步提高了這些算法的性能,但其主要目的實際上是使它們在各種場景中更易於使用。NonCryptographicHashAlgorithm 的原始設計是專注於創建現有加密算法的非加密替代品,因此所有的 API 都專注於輸出結果摘要,這些摘要是不透明的字節,例如,CRC32 生成一個 4 字節的哈希。然而,特別是對於這些非加密算法,許多開發者更熟悉返回數值結果,例如,CRC32 生成一個 uint。同樣的數據,只是表示方式不同。有趣的是,這些算法中的一些以這樣的整數爲操作對象,所以獲取字節實際上需要一個單獨的步驟,既要確保有某種存儲位置可用於寫入結果字節,然後再將結果提取到該位置。爲了解決所有這些問題,dotnet/runtime#78075 在 System.IO.Hashing 的所有類型中添加了新的實用方法來生成這樣的數字。例如,Crc32 添加了兩個新方法:

public static uint HashToUInt32(ReadOnlySpan<byte> source);
public uint GetCurrentHashAsUInt32();

如果你只想要某些輸入字節的基於 uint 的 CRC32 哈希,你可以簡單地調用這個一次性靜態方法 HashToUInt32。或者,如果你正在逐步構建哈希,已經創建了 Crc32 類型的實例並向其追加了數據,你可以通過 GetCurrentHashAsUInt32 獲取當前的 uint 哈希。對於像 XxHash3 這樣的算法,這也省去了幾條指令,因爲它實際上需要做更多的工作來將結果作爲字節產生,只是然後需要將這些字節作爲 ulong 獲取回來:

// For this test, you'll also need to add:
//     <PackageReference Include="System.IO.Hashing" Version="8.0.0-rc.1.23419.4" />
// to the benchmarks.csproj's <ItemGroup>.
// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.IO.Hashing;
using System.Runtime.InteropServices;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly byte[] _source = new byte[] { 1, 2, 3 };

    [Benchmark(Baseline = true)]
    public ulong HashToBytesThenGetUInt64()
    {
        ulong hash = 0;
        XxHash3.Hash(_source, MemoryMarshal.AsBytes(new Span<ulong>(ref hash)));
        return hash;
    }

    [Benchmark]
    public ulong HashToUInt64() => XxHash3.HashToUInt64(_source);
}
方法 平均值 比率
HashToBytesThenGetUInt64 3.686 ns 1.00
HashToUInt64 3.095 ns 0.84

在哈希方面,@deeprobin 的 dotnet/runtime#61558 添加了新的 BitOperations.Crc32C 方法,允許進行迭代的 crc32c 哈希計算。crc32c 的一個好處是多個平臺提供了這個操作的指令,包括 SSE 4.2 和 Arm,.NET 方法將使用任何可用的硬件支持,通過委託到 System.Runtime.Intrinsics 中的相關硬件內在功能,例如:

if (Sse42.X64.IsSupported) return (uint)Sse42.X64.Crc32(crc, data);
if (Sse42.IsSupported) return Sse42.Crc32(Sse42.Crc32(crc, (uint)(data)), (uint)(data >> 32));
if (Crc32.Arm64.IsSupported) return Crc32.Arm64.ComputeCrc32C(crc, data);

我們可以通過比較 crc32c 算法的手動實現和現在的內置實現,看到這些內在功能的影響:

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Numerics;
using System.Security.Cryptography;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly byte[] _data = RandomNumberGenerator.GetBytes(1024 * 1024);

    [Benchmark(Baseline = true)]
    public uint Crc32c_Manual()
    {
        uint c = 0;
        foreach (byte b in _data) c = Tests.Crc32C(c, b);
        return c;
    }

    [Benchmark]
    public uint Crc32c_BitOperations()
    {
        uint c = 0;
        foreach (byte b in _data) c = BitOperations.Crc32C(c, b);
        return c;
    }

    private static readonly uint[] s_crcTable = Generate(0x82F63B78u);

    internal static uint Crc32C(uint crc, byte data) =>
        s_crcTable[(byte)(crc ^ data)] ^ (crc >> 8);

    internal static uint[] Generate(uint reflectedPolynomial)
    {
        var table = new uint[256];

        for (int i = 0; i < 256; i++)
        {
            uint val = (uint)i;
            for (int j = 0; j < 8; j++)
            {
                if ((val & 0b0000_0001) == 0)
                {
                    val >>= 1;
                }
                else
                {
                    val = (val >> 1) ^ reflectedPolynomial;
                }
            }

            table[i] = val;
        }

        return table;
    }
}
方法 平均值 比率
Crc32c_Manual 1,977.9 us 1.00
Crc32c_BitOperations 739.9 us 0.37

Initialization

幾個版本前,C# 編譯器添加了一個非常有價值的優化,現在在覈心庫中被大量使用,新的 C# 構造(如 u8)也嚴重依賴它。在代碼中存儲和訪問序列或數據表是非常常見的。例如,假設我想快速查找公曆中一個月有多少天,基於該月的 0-based 索引。我可以使用這樣的查找表(爲了解釋的目的,忽略閏年):

byte[] daysInMonth = new byte[] { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };

當然,現在我正在分配一個 byte[],所以我應該將它移動到一個靜態只讀字段。即使這樣,數組仍然需要被分配,數據加載到它,第一次使用時會產生一些啓動開銷。相反,我可以這樣寫:

ReadOnlySpan<byte> daysInMonth = new byte[] { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };

雖然這看起來像是在分配,但實際上並沒有。C# 編譯器認識到用來初始化 byte[] 的所有數據都是常量,而且數組被直接存儲到 ReadOnlySpan 中,它不提供任何提取數組的方法。因此,編譯器將其降低爲實際上做這個的代碼(我們不能確切地用 C# 表達生成的 IL,所以這是僞代碼):

ReadOnlySpan<byte> daysInMonth = new ReadOnlySpan<byte>(
    &<PrivateImplementationDetails>.9D61D7D7A1AA7E8ED5214C2F39E0C55230433C7BA728C92913CA4E1967FAF8EA,
    12);

它將數組的數據複製到程序集中,然後構造 span 不是通過數組分配,而是直接將 span 包裝到程序集數據的指針中。這不僅避免了啓動開銷和堆上的額外對象,而且更好地啓用了各種 JIT 優化,特別是當 JIT 能夠看到正在訪問的偏移量時。如果我運行這個基準測試:

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser]
public class Tests
{
    private static readonly byte[] s_daysInMonthArray = new byte[] { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
    private static ReadOnlySpan<byte> DaysInMonthSpan => new byte[] { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };

    [Benchmark] public int ViaArray() => s_daysInMonthArray[0];

    [Benchmark] public int ViaSpan() => DaysInMonthSpan[0];
}

它生成了這樣的彙編代碼:

; Tests.ViaArray()
       mov       rax,1B860002028
       mov       rax,[rax]
       movzx     eax,byte ptr [rax+10]
       ret
; Total bytes of code 18

; Tests.ViaSpan()
       mov       eax,1F
       ret
; Total bytes of code 6

換句話說,對於數組,它正在讀取數組的地址,然後讀取偏移量爲0x10,或者十進制16的元素,這是數組數據開始的地方。對於 span,它只是加載值0x1F,或者十進制31,因爲它直接從程序集數據中讀取數據。(這不是 JIT 對數組示例中缺失的優化...數組是可變的,所以 JIT 不能基於數組中存儲的當前值進行常量摺疊,因爲從技術上講,它可能會改變。)

然而,這個編譯器優化只適用於 byte,sbyte 和 bool。任何其他原始類型,編譯器只會簡單地做你要求它做的事情:分配數組。這遠非理想。限制的原因是字節順序。編譯器需要生成在小端和大端系統上都能工作的二進制文件;對於單字節類型,沒有字節順序問題(因爲字節順序是關於字節的排序,如果只有一個字節,那麼只有一種排序),但對於多字節類型,生成的代碼不能再直接指向數據,因爲在某些系統上,數據的字節會被反轉。

.NET 7 添加了一個新的 API 來幫助解決這個問題,即 RuntimeHelpers.CreateSpan。這個 API 的設計思路是,編譯器會發出對 CreateSpan 的調用,傳入包含數據的字段的引用,而不是僅僅發出 new ReadOnlySpan(ptrIntoData, dataLength)。然後,JIT 和 VM 會共同確保數據被正確且高效地加載;在小端系統上,代碼會被髮出,就好像調用不存在一樣(被替換爲圍繞指針和長度包裝 span 的等價物),而在大端系統上,數據會被加載、反轉和緩存到數組中,然後代碼生成會創建一個包裝該數組的 span。不幸的是,儘管這個 API 在 .NET 7 中發佈,但編譯器對它的支持並沒有發佈,而且因爲沒有人實際使用它,所以在工具鏈中存在各種問題,這些問題都沒有被注意到。

值得慶幸的是,所有這些問題現在在 .NET 8 和 C# 編譯器中都得到了解決(並且也回溯到了 .NET 7)。dotnet/roslyn#61414 爲 C# 編譯器添加了對 short、ushort、char、int、uint、long、ulong、double、float 以及基於這些的枚舉的支持。在目標框架中,如果 CreateSpan 可用(.NET 7+),編譯器生成使用它的代碼。在函數不可用的框架上,編譯器回退到發出靜態只讀數組以緩存數據,並圍繞它包裝一個 span。這對於構建多目標框架的庫來說是一個重要的考慮因素,這樣在向“下層”構建時,實現不會因爲依賴這個優化而從 proverbial 性能懸崖上掉下來(這個優化有點奇怪,因爲你實際上需要以一種方式編寫你的代碼,沒有優化,性能會比你原本應該得到的更差)。有了編譯器實現,以及對 Mono 運行時的修復 dotnet/runtime#82093 和 dotnet/runtime#81695,以及對修剪器的修復(需要保留編譯器發出的數據的對齊) dotnet/cecil#60,運行時的其餘部分就能夠使用這個特性,它在 dotnet/runtime#79461 中這樣做了。所以現在,例如,System.Text.Json 可以使用這個來存儲一個(非閏年)年有多少天,但也可以存儲在給定月份之前有多少天,這是之前由於存在大於字節可以存儲的值而無法以這種形式有效地實現的。

// dotnet run -c Release -f net8.0 --filter **

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD", "i")]
[MemoryDiagnoser(displayGenColumns: false)]
[DisassemblyDiagnoser]
public class Tests
{
    private static ReadOnlySpan<int> DaysToMonth365 => new int[] { 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365 };

    [Benchmark]
    [Arguments(1)]
    public int DaysToMonth(int i) => DaysToMonth365[i];
}
方法 平均值 代碼大小 分配
DaysToMonth 0.0469 ns 35 B
; Tests.DaysToMonth(Int32)
       sub       rsp,28
       cmp       edx,0D
       jae       short M00_L00
       mov       eax,edx
       mov       rcx,12B39072DD0
       mov       eax,[rcx+rax*4]
       add       rsp,28
       ret
M00_L00:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 35

dotnet/roslyn#69820(尚未合併,但應該很快就會)通過確保初始化 ReadOnlySpan 到新的 T[] { const of T, const of T, ... /* all const values */ } 的模式將始終避免數組分配,無論使用的 T 的類型如何。T 只需要能夠在 C# 中表示爲常量。這意味着這個優化現在也適用於 string,decimal,nint 和 nuint。對於這些,編譯器將回退到使用緩存的數組單例。有了這個,這段代碼:

// dotnet build -c Release -f net8.0

internal static class Program
{
    private static void Main() { }

    private static ReadOnlySpan<bool> Booleans => new bool[] { false, true };
    private static ReadOnlySpan<sbyte> SBytes => new sbyte[] { 0, 1, 2 };
    private static ReadOnlySpan<byte> Bytes => new byte[] { 0, 1, 2 };

    private static ReadOnlySpan<short> Shorts => new short[] { 0, 1, 2 };
    private static ReadOnlySpan<ushort> UShorts => new ushort[] { 0, 1, 2 };
    private static ReadOnlySpan<char> Chars => new char[] { '0', '1', '2' };
    private static ReadOnlySpan<int> Ints => new int[] { 0, 1, 2 };
    private static ReadOnlySpan<uint> UInts => new uint[] { 0, 1, 2 };
    private static ReadOnlySpan<long> Longs => new long[] { 0, 1, 2 };
    private static ReadOnlySpan<ulong> ULongs => new ulong[] { 0, 1, 2 };
    private static ReadOnlySpan<float> Floats => new float[] { 0, 1, 2 };
    private static ReadOnlySpan<double> Doubles => new double[] { 0, 1, 2 };

    private static ReadOnlySpan<nint> NInts => new nint[] { 0, 1, 2 };
    private static ReadOnlySpan<nuint> NUInts => new nuint[] { 0, 1, 2 };
    private static ReadOnlySpan<decimal> Decimals => new decimal[] { 0, 1, 2 };
    private static ReadOnlySpan<string> Strings => new string[] { "0", "1", "2" };
}

現在編譯成類似這樣的代碼(再次強調,這是僞代碼,因爲我們無法用 C# 精確表示 IL 中發出的內容):

internal static class Program
{
    private static void Main() { }

    //
    // No endianness concerns. Create a span that points directly into the assembly data,
    // using the `ReadOnlySpan<T>(void*, int)` constructor.
    //

    private static ReadOnlySpan<bool> Booleans => new ReadOnlySpan<bool>(
        &<PrivateImplementationDetails>.B413F47D13EE2FE6C845B2EE141AF81DE858DF4EC549A58B7970BB96645BC8D2, 2);

    private static ReadOnlySpan<sbyte> SBytes => new ReadOnlySpan<sbyte>(
        &<PrivateImplementationDetails>.AE4B3280E56E2FAF83F414A6E3DABE9D5FBE18976544C05FED121ACCB85B53FC, 3);

    private static ReadOnlySpan<byte> Bytes => new ReadOnlySpan<byte>(
        &<PrivateImplementationDetails>.AE4B3280E56E2FAF83F414A6E3DABE9D5FBE18976544C05FED121ACCB85B53FC, 3);

    //
    // Endianness concerns but with data that a span could point to directly if
    // of the correct byte ordering. Go through the RuntimeHelpers.CreateSpan intrinsic.
    //

    private static ReadOnlySpan<short> Shorts => RuntimeHelpers.CreateSpan<short>((RuntimeFieldHandle)
        &<PrivateImplementationDetails>.90C2698921CA9FD02950BE353F721888760E33AB5095A21E50F1E4360B6DE1A02);

    private static ReadOnlySpan<ushort> UShorts => RuntimeHelpers.CreateSpan<ushort>((RuntimeFieldHandle)
        &<PrivateImplementationDetails>.90C2698921CA9FD02950BE353F721888760E33AB5095A21E50F1E4360B6DE1A02);

    private static ReadOnlySpan<char> Chars => RuntimeHelpers.CreateSpan<char>((RuntimeFieldHandle)
        &<PrivateImplementationDetails>.9B9A3CBF2B718A8F94CE348CB95246738A3A9871C6236F4DA0A7CC126F03A8B42);

    private static ReadOnlySpan<int> Ints => RuntimeHelpers.CreateSpan<int>((RuntimeFieldHandle)
        &<PrivateImplementationDetails>.AD5DC1478DE06A4C2728EA528BD9361A4B945E92A414BF4D180CEDAAEAA5F4CC4);

    private static ReadOnlySpan<uint> UInts => RuntimeHelpers.CreateSpan<uint>((RuntimeFieldHandle)
        &<PrivateImplementationDetails>.AD5DC1478DE06A4C2728EA528BD9361A4B945E92A414BF4D180CEDAAEAA5F4CC4);

    private static ReadOnlySpan<long> Longs => RuntimeHelpers.CreateSpan<long>((RuntimeFieldHandle)
        &<PrivateImplementationDetails>.AB25350E3E65EFEBE24584461683ECDA68725576E825E550038B90E7B14799468);

    private static ReadOnlySpan<ulong> ULongs => RuntimeHelpers.CreateSpan<ulong>((RuntimeFieldHandle)
        &<PrivateImplementationDetails>.AB25350E3E65EFEBE24584461683ECDA68725576E825E550038B90E7B14799468);

    private static ReadOnlySpan<float> Floats => RuntimeHelpers.CreateSpan<float>((RuntimeFieldHandle)
        &<PrivateImplementationDetails>.75664B4DA1C08DE9E8FAD52303CC458B3E420EDDE6591E58761E138CC5E3F1634);

    private static ReadOnlySpan<double> Doubles => RuntimeHelpers.CreateSpan<double>((RuntimeFieldHandle)
        &<PrivateImplementationDetails>.B0C45303F7F11848CB5E6E5B2AF2FB2AECD0B72C28748B88B583AB6BB76DF1748);

    //
    // Create a span around a cached array.
    //

    private unsafe static ReadOnlySpan<nuint> NUInts => new ReadOnlySpan<nuint>(
        <PrivateImplementationDetails>.AD5DC1478DE06A4C2728EA528BD9361A4B945E92A414BF4D180CEDAAEAA5F4CC_B16
            ??= new nuint[] { 0, 1, 2 });

    private static ReadOnlySpan<nint> NInts => new ReadOnlySpan<nint>(
        <PrivateImplementationDetails>.AD5DC1478DE06A4C2728EA528BD9361A4B945E92A414BF4D180CEDAAEAA5F4CC_B8
            ??= new nint[] { 0, 1, 2 });

    private static ReadOnlySpan<decimal> Decimals => new ReadOnlySpan<decimal>(
        <PrivateImplementationDetails>.93AF9093EDC211A9A941BDE5EF5640FD395604257F3D945F93C11BA9E918CC74_B18
            ??= new decimal[] { 0, 1, 2 });

    private static ReadOnlySpan<string> Strings => new ReadOnlySpan<string>(
        <PrivateImplementationDetails>.9B9A3CBF2B718A8F94CE348CB95246738A3A9871C6236F4DA0A7CC126F03A8B4_B11
            ??= new string[] { "0", "1", "2" });
}

另一個與 C# 編譯器密切相關的改進來自 @alrz 的 dotnet/runtime#66251。前面提到的關於單字節類型的優化也適用於 stackalloc 初始化。如果我寫:

Span<int> span = stackalloc int[] { 1, 2, 3 };

C# 編譯器發出的代碼類似於我寫的以下內容:

byte* ptr = stackalloc byte[12];
*(int*)ptr = 1;
*(int*)(ptr) = 2;
*(int*)(ptr + (nint)2 * (nint)4) = 3;
Span<int> span = new Span<int>(ptr);

然而,如果我從多字節的 int 切換到單字節的 byte:

Span<byte> span = stackalloc byte[] { 1, 2, 3 };

那麼我得到的內容更接近這樣:

byte* ptr = stackalloc byte[3];
Unsafe.CopyBlock(ptr, ref <PrivateImplementationDetails>.039058C6F2C0CB492C533B0A4D14EF77CC0F78ABCCCED5287D84A1A2011CFB81, 3); // 實際上是 cpblk 指令
Span<byte> span = new Span<byte>(ptr, 3);

然而,與 new[] 情況不同,後者不僅優化了 byte、sbyte 和 bool,還優化了以 byte 和 sbyte 爲基礎類型的枚舉,而 stackalloc 優化並沒有。多虧了這個 PR,現在它也可以了。

C# 12 和 .NET 8 中有一個半相關的新特性:InlineArrayAttribute。stackalloc 一直提供了一種使用棧空間作爲緩衝區的方法,而不需要在堆上分配內存;然而,在 .NET 的大部分歷史中,這是“不安全的”,因爲它產生了一個指針:

byte* buffer = stackalloc byte[8];

C# 7.2 引入了一個極其有用的改進,可以直接在棧上分配到一個 span,此時它變成了“安全的”,不需要在不安全的上下文中,並且像對任何其他 span 一樣,對 span 的所有訪問都會適當地進行邊界檢查:

Span<byte> buffer = stackalloc byte[8];

C# 編譯器會將其降低到類似以下的內容:

Span<byte> buffer;
unsafe
{
    byte* tmp = stackalloc byte[8];
    buffer = new Span<byte>(tmp, 8);
}

然而,這仍然限制了可以被 stackalloc 的東西,即不包含任何託管引用的非託管類型,而且它的使用也受到限制。這不僅是因爲 stackalloc 不能在 catch 和 finally 塊等地方使用,而且還因爲你希望能夠在其他類型內部擁有這樣的緩衝區,而不僅僅限於棧:C# 一直支持“固定大小緩衝區”的概念,例如:

struct C
{
    internal unsafe fixed char name[30];
}

但這些需要在不安全的上下文中,因爲它們向消費者展示爲一個指針(在上述示例中,C.name 的類型是 char*),並且它們不進行邊界檢查,而且它們支持的元素類型有限(只能是 bool、sbyte、byte、short、ushort、char、int、uint、long、ulong、double 或 float)。

.NET 8 和 C# 12 爲此提供了一個答案:[InlineArray]。這個新的屬性可以放在包含單個字段的結構體上,如下所示:

[InlineArray(8)]
internal struct EightStrings
{
    private string _field;
}

然後,運行時將該結構體擴展爲邏輯上等同於你寫的:

internal struct EightStrings
{
    private string _field0;
    private string _field1;
    private string _field2;
    private string _field3;
    private string _field4;
    private string _field5;
    private string _field6;
    private string _field7;
}

確保所有的存儲都適當地連續和對齊。爲什麼這很重要?因爲 C# 12 然後使得從這些實例中獲取一個 span 變得容易,例如:

EightStrings strings = default;
Span<string> span = strings;

這都是“安全的”,並且字段的類型可以是任何有效的泛型類型參數。這意味着幾乎可以是除了 refs、ref 結構體和指針之外的任何東西。這是 C# 語言強加的一個約束,因爲如果字段類型是 T,你將無法構造一個 Span,但是可以抑制警告,因爲運行時本身確實支持任何字段類型。獲取 span 的編譯器生成的代碼等同於你寫的:

EightStrings strings = default;
Span<string> span = MemoryMarshal.CreateSpan(ref Unsafe.As<EightStrings, string>(ref strings), 8);

這顯然很複雜,不是你經常想要寫的東西。實際上,編譯器也不想經常發出這樣的代碼,所以它將其放入程序集中的一個助手中,以便重用。

[CompilerGenerated]
internal sealed class <PrivateImplementationDetails>
{
    internal static Span<TElement> InlineArrayAsSpan<TBuffer, TElement>(ref TBuffer buffer, int length) =>
        MemoryMarshal.CreateSpan(ref Unsafe.As<TBuffer, TElement>(ref buffer), length);
    ...
}

是 C# 編譯器發出的一個類,用於包含由它在程序的其他地方發出的代碼使用的助手和其他編譯器生成的工件。你在前面的討論中也看到了它,因爲它是它發出支持從常量初始化數組和 span 的數據的地方。)

帶有 [InlineArray] 屬性的類型也是一個普通的結構體,可以在任何其他結構體可以使用的地方使用;它使用 [InlineArray] 實際上是一個實現細節。所以,例如,你可以將它嵌入到另一個類型中,以下代碼將按你期望的方式打印出 "0" 到 "7":

// dotnet run -c Release -f net8.0

using System.Runtime.CompilerServices;

MyData data = new();
Span<string> span = data.Strings;

for (int i = 0; i < span.Length; i++) span[i] = i.ToString();

foreach (string s in data.Strings) Console.WriteLine(s);

public class MyData
{
    private EightStrings _strings;

    public Span<string> Strings => _strings;

    [InlineArray(8)]
    private unsafe struct EightStrings { private string _field; }
}

dotnet/runtime#82744 爲 CoreCLR 運行時提供了 InlineArray 的支持,dotnet/runtime#83776 和 dotnet/runtime#84097 爲 Mono 運行時提供了支持,dotnet/roslyn#68783 合併了 C# 編譯器的支持。

這個特性並不僅僅是關於你直接使用它。編譯器本身也使用 [InlineArray] 作爲其他新的和計劃中的特性的實現細節...我們在討論集合時會更多地討論這個問題。

Analyzers

最後,儘管運行時和核心庫在提高現有功能的性能和添加新的性能相關支持方面取得了長足的進步,但有時最好的修復實際上是在消費代碼中。這就是分析器的作用。在 .NET 8 中添加了幾個新的分析器,以幫助找到特定類別的字符串相關性能問題。

CA1858,由 @Youssef1313 在 dotnet/roslyn-analyzers#6295 中添加,尋找對 IndexOf 的調用,其中結果然後被檢查是否等於 0。這在功能上與調用 StartsWith 相同,但是它的代價要高得多,因爲它可能最終會檢查整個源字符串,而不僅僅是開始位置(dotnet/runtime#79896 在 dotnet/runtime 中修復了一些這樣的用法)。CA1858

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly string _haystack = """
        It was the best of times, it was the worst of times,
        it was the age of wisdom, it was the age of foolishness,
        it was the epoch of belief, it was the epoch of incredulity,
        it was the season of light, it was the season of darkness,
        it was the spring of hope, it was the winter of despair.
        """;
    private readonly string _needle = "hello";

    [Benchmark(Baseline = true)]
    public bool StartsWith_IndexOf0() =>
        _haystack.IndexOf(_needle, StringComparison.OrdinalIgnoreCase) == 0;

    [Benchmark]
    public bool StartsWith_StartsWith() =>
        _haystack.StartsWith(_needle, StringComparison.OrdinalIgnoreCase);
}
方法 平均值 比率
StartsWith_IndexOf0 31.327 ns 1.00
StartsWith_StartsWith 4.501 ns 0.14

CA1865、CA1866 和 CA1867 都是相互關聯的。這些都是在 dotnet/roslyn-analyzers#6799 中由 @mrahhal 添加的,用於尋找對字符串方法(如 StartsWith)的調用,搜索傳入單字符字符串參數的調用,例如 str.StartsWith("@"),並建議將參數轉換爲字符。分析器引發的診斷 ID 取決於轉換是否100%等效,或者是否可能導致行爲改變,例如,從語言比較切換到序數比較。CA1865

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly string _haystack = "All we have to decide is what to do with the time that is given us.";

    [Benchmark(Baseline = true)]
    public int IndexOfString() => _haystack.IndexOf("v");

    [Benchmark]
    public int IndexOfChar() => _haystack.IndexOf('v');
}
方法 平均值 比率
IndexOfString 37.634 ns 1.00
IndexOfChar 1.979 ns 0.05

CA1862,添加在 dotnet/roslyn-analyzers#6662 中,尋找代碼中執行不區分大小寫的比較的地方(這是可以的),但是通過首先將輸入字符串轉換爲小寫/大寫,然後進行比較(這遠非理想)。直接使用 StringComparison 要有效得多。dotnet/runtime#89539 修復了一些這樣的情況。CA1862

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
    private readonly string _input = "https://dot.net";

    [Benchmark(Baseline = true)]
    public bool IsHttps_ToUpper() => _input.ToUpperInvariant().StartsWith("HTTPS://");

    [Benchmark]
    public bool IsHttps_StringComparison() => _input.StartsWith("HTTPS://", StringComparison.OrdinalIgnoreCase);
}
方法 平均值 比率 分配 分配比率
IsHttps_ToUpper 46.3702 ns 1.00 56 B 1.00
IsHttps_StringComparison 0.4781 ns 0.01 - 0.00

而 CA1861,由 @steveberdy 在 dotnet/roslyn-analyzers#5383 中添加,尋找提升和緩存作爲參數傳遞的數組的機會。dotnet/runtime#86229 解決了在 dotnet/runtime 中由分析器發現的問題。CA1861

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
    private static readonly char[] s_separator = new[] { ',', ':' };
    private readonly string _value = "1,2,3:4,5,6";

    [Benchmark(Baseline = true)]
    public string[] Split_Original() => _value.Split(new[] { ',', ':' });

    [Benchmark]
    public string[] Split_Refactored() => _value.Split(s_separator);
}
方法 平均值 比率 分配 分配比率
Split_Original 108.6 ns 1.00 248 B 1.00
Split_Refactored 104.0 ns 0.96 216 B 0.87

.Net 7

Arrays, Strings, and Spans

在應用程序中,可以消耗資源的計算形式有很多,其中最常見的包括處理存儲在數組、字符串和現在的跨度中的數據。因此,你會看到每個 .NET 發佈版都專注於從這些場景中移除儘可能多的開銷,同時也尋找進一步優化開發人員常用操作的方法。

讓我們從一些新的 API 開始,這些 API 可以幫助更容易地編寫更高效的代碼。當檢查字符串解析/處理代碼時,常常會看到字符被檢查是否包含在各種集合中。例如,你可能會看到一個循環尋找 ASCII 數字字符:

while (i < str.Length)
{
    if (str[i] >= '0' && str[i] <= '9')
    {
        break;
    }
    i++;
}

或者是 ASCII 字母字符:

while (i < str.Length)
{
    if ((str[i] >= 'a' && str[i] <= 'z') || (str[i] >= 'A' && str[i] <= 'Z'))
    {
        break;
    }
    i++;
}

或者其他類似的組。有趣的是,這樣的檢查代碼有廣泛的變化,通常取決於開發人員爲優化它們付出了多少努力,或者在某些情況下可能甚至沒有意識到一些性能被忽視了。例如,同樣的 ASCII 字母檢查可以被寫成:

while (i < str.Length)
{
    if ((uint)((c | 0x20) - 'a') <= 'z' - 'a')
    {
        break;
    }
    i++;
}

雖然這更“激進”,但也更簡潔,更有效。它利用了一些技巧。首先,它不是通過兩次比較來確定字符是否大於或等於下界並且小於或等於上界,而是基於字符和下界之間的距離進行單次比較 ((uint)(c - 'a'))。如果 'c' 超過 'z',那麼 'c' - 'a' 將大於25,比較將失敗。如果 'c' 早於 'a',那麼 'c' - 'a' 將爲負,將其轉換爲 uint 將導致它環繞到一個巨大的數字,也大於25,再次導致比較失敗。因此,我們能夠通過額外的一次減法來避免整個額外的比較和分支,這幾乎總是一個好交易。第二個技巧是 | 0x20。ASCII 表有一些深思熟慮的關係,包括大寫 'A' 和小寫 'a' 只相差一個位 ('A' 是 0b1000001 和 'a' 是 0b1100001)。要從任何小寫 ASCII 字母轉換爲其大寫 ASCII 等價物,我們只需要 & ~0x20(關閉該位),要從任何大寫 ASCII 字母轉換爲其小寫 ASCII 等價物,我們只需要 | 0x20(打開該位)。我們可以在我們的範圍檢查中利用這一點,通過將我們的字符 c 規範化爲小寫,以便以一點位扭曲的低成本,我們可以實現小寫和大寫的範圍檢查。當然,我們不希望每個開發者都必須知道並在每次使用時編寫這些技巧。相反,.NET 7 在 System.Char 上公開了一堆新的幫助器,以封裝這些常見的檢查,以有效的方式完成。char 已經有了像 IsDigit 和 IsLetter 這樣的方法,它們提供了這些名稱的更全面的 Unicode 含義(例如,有大約320個 Unicode 字符被分類爲“數字”)。現在在 .NET 7 中,也有這些幫助器:

IsAsciiDigit
IsAsciiHexDigit
IsAsciiHexDigitLower
IsAsciiHexDigitUpper
IsAsciiLetter
IsAsciiLetterLower
IsAsciiLetterUpper
IsAsciiLetterOrDigit

這些方法由 dotnet/runtime#69318 添加,該方法還在 dotnet/runtime 中的許多位置使用它們,這些位置正在執行此類檢查(其中許多使用的方法效率較低)。

另一個專注於封裝常見模式的新 API 是新的 MemoryExtensions.CommonPrefixLength 方法,由 dotnet/runtime#67929 引入。這個方法接受兩個 ReadOnlySpan 實例或一個 Span 和一個 ReadOnlySpan,以及一個可選的 IEqualityComparer,並返回在每個輸入跨度開始處相同的元素數量。當你想知道兩個輸入的第一個不同點時,這個方法非常有用。dotnet/runtime#68210 由 @gfoidl 提供,然後利用新的 Vector128 功能提供了實現的基本向量化。由於它正在比較兩個序列並尋找它們的第一個不同點,這個實現使用了一個巧妙的技巧,即有一個單獨的方法實現來比較序列作爲字節。如果正在比較的 T 是位等價的,並且沒有提供自定義的等價比較器,那麼它將重新解釋跨度的引用作爲字節引用,並使用單個共享的實現。

另一組新的 API 是 IndexOfAnyExcept 和 LastIndexOfAnyExcept 方法,由 dotnet/runtime#67941 引入,並在 dotnet/runtime#71146 和 dotnet/runtime#71278 的多個其他調用站點中使用。雖然這些方法有點複雜,但它們非常方便。它們做的就是它們的名字所暗示的:而 IndexOf(T value) 在輸入中搜索 value 的第一個出現,而 IndexOfAny(T value0, T value1, ...) 在輸入中搜索 value0, value1 等的第一個出現,IndexOfAnyExcept(T value) 搜索的是第一個不等於 value 的出現,同樣,IndexOfAnyExcept(T value0, T value1, ...) 搜索的是第一個不等於 value0, value1 等的出現。例如,假設你想知道一個整數數組是否完全爲 0。你現在可以這樣寫:

bool allZero = array.AsSpan().IndexOfAnyExcept(0) < 0;

dotnet/runtime#73488 也向量化了這個重載。

private byte[] _zeros = new byte[1024];

[Benchmark(Baseline = true)]
public bool OpenCoded()
{
    foreach (byte b in _zeros)
    {
        if (b != 0)
        {
            return false;
        }
    }

    return true;
}

[Benchmark]
public bool IndexOfAnyExcept() => _zeros.AsSpan().IndexOfAnyExcept((byte)0) < 0;
方法 平均值 比率
OpenCoded 370.47 ns 1.00
IndexOfAnyExcept 23.84 ns 0.06

當然,雖然新的“索引”變體很有幫助,但我們已經有了一堆這樣的方法,重要的是它們要儘可能高效。這些核心的 IndexOf{Any} 方法在大量的地方被使用,其中許多地方對性能敏感,所以每個版本都會得到額外的關懷。雖然像 dotnet/runtime#67811 這樣的 PR 通過非常仔細地關注生成的彙編代碼(在這種情況下,調整了 Arm64 上 IndexOf 和 IndexOfAny 中使用的一些檢查,以實現更好的利用率)來獲得收益,但最大的改進來自於添加了向量化而之前沒有使用,或者重做了向量化方案以獲得顯著的收益的地方。讓我們從 dotnet/runtime#63285 開始,這爲字節和字符的“子字符串”的 IndexOf 和 LastIndexOf 的許多使用帶來了巨大的改進。以前,給定一個像 str.IndexOf("hello") 這樣的調用,實現基本上會做的相當於反覆搜索 'h',當找到一個 'h' 時,然後執行一個 SequenceEqual 來匹配剩餘的部分。然而,你可以想象,很容易遇到第一個字符被搜索的情況非常常見,以至於你經常需要跳出向量化循環,以便進行完整的字符串比較。相反,PR 實現了一個基於 SIMD 友好的算法進行子字符串搜索的算法。它不僅僅是搜索第一個字符,而是可以向量化搜索第一個和最後一個字符,它們之間的距離適當。在我們的“hello”例子中,在任何給定的輸入中,找到一個 'h' 的可能性比找到一個 'h' 後面四個字符後的 'o' 的可能性要大得多,因此這個實現能夠在向量化循環中停留更長的時間,產生更少的假陽性,迫使它走 SequenceEqual 的路線。實現還處理了兩個字符選擇相等的情況,在這種情況下,它會快速尋找另一個不等的字符,以最大化搜索的效率。我們可以通過一些例子看到所有這些的影響:


private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;

[Benchmark]
[Arguments("Sherlock")]
[Arguments("elementary")]
public int Count(string needle)
{
    ReadOnlySpan<char> haystack = s_haystack;
    int count = 0, pos;
    while ((pos = haystack.IndexOf(needle)) >= 0)
    {
        haystack = haystack.Slice(pos + needle.Length);
        count++;
    }

    return count;
}

這是從Project Gutenberg下載《福爾摩斯探案集》的文本,然後使用IndexOf來計算文本中“Sherlock”和“elementary”出現的次數。在我的機器上,我得到了這樣的結果:

方法 運行時 平均值 比率
Count .NET 6.0 Sherlock 43.68 us 1.00
Count .NET 7.0 Sherlock 48.33 us 1.11
Count .NET 6.0 elementary 1,063.67 us 1.00
Count .NET 7.0 elementary 56.04 us 0.05

對於“Sherlock”,.NET 7的性能實際上比.NET 6稍差一點;不多,但可測量的10%。這是因爲在源文本中大寫'S'字符非常少,確切地說是841個,文檔中的字符總數爲593,836個。在只有0.1%的起始字符密度的情況下,新算法並沒有帶來太多的好處,因爲現有的只搜索第一個字符的算法基本上捕獲了所有可能的向量化收益,我們確實在搜索'S'和'k'時付出了一些開銷,而以前我們只搜索'S'。相比之下,文檔中有54,614個'e'字符,所以源的近10%。在這種情況下,.NET 7比.NET 6快20倍,用.NET 7計算所有'e'需要53微秒,而.NET 6需要1084微秒。在這種情況下,新方案帶來了巨大的收益,通過向量化搜索'e'和特定距離的'y',這種組合的頻率要少得多。這是那些總體上觀察到的平均收益巨大,儘管我們可以看到一些特定輸入的小回歸的情況。

另一個顯著改變使用算法的例子是dotnet/runtime#67758,它使一些向量化可以應用於IndexOf("...", StringComparison.OrdinalIgnoreCase)。以前,這個操作是用一個相當典型的子字符串搜索實現的,遍歷輸入字符串,在每個位置做一個內循環來比較目標字符串,除了對每個字符進行ToUpper以便以不區分大小寫的方式進行。現在有了這個PR,它基於之前由Regex使用的方法,如果目標字符串以ASCII字符開始,實現可以使用IndexOf(如果字符不是ASCII字母)或IndexOfAny(如果字符是ASCII字母)來快速跳到可能匹配的第一個位置。讓我們看看剛纔看過的完全相同的基準測試,但是調整爲使用OrdinalIgnoreCase:

private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;

[Benchmark]
[Arguments("Sherlock")]
[Arguments("elementary")]
public int Count(string needle)
{
    ReadOnlySpan<char> haystack = s_haystack;
    int count = 0, pos;
    while ((pos = haystack.IndexOf(needle, StringComparison.OrdinalIgnoreCase)) >= 0)
    {
        haystack = haystack.Slice(pos + needle.Length);
        count++;
    }

    return count;
}

在這裏,兩個單詞在 .NET 7 上的速度比在 .NET 6 上快了大約4倍:

方法 運行時 平均值 比率
Count .NET 6.0 Sherlock 2,113.1 us 1.00
Count .NET 7.0 Sherlock 467.3 us 0.22
Count .NET 6.0 elementary 2,325.6 us 1.00
Count .NET 7.0 elementary 638.8 us 0.27

我們現在正在執行向量化的 IndexOfAny('S', 's') 或 IndexOfAny('E', 'e'),而不是手動遍歷每個字符並進行比較。 (dotnet/runtime#73533 現在也使用相同的方法來處理 IndexOf(char, StringComparison.OrdinalIgnoreCase)。)

另一個例子來自 @gfoidl 的 dotnet/runtime#67492。它使用我們之前討論過的方法更新了 MemoryExtensions.Contains,用於處理向量化操作結束時剩餘的元素:處理最後一個向量的數據,即使這意味着重複已經完成的一些工作。這對於處理時間可能被那些剩餘部分的串行處理所主導的較小輸入特別有幫助。

private byte[] _data = new byte[95];

[Benchmark]
public bool Contains() => _data.AsSpan().Contains((byte)1);
方法 運行時 平均值 比率
Contains .NET 6.0 15.115 ns 1.00
Contains .NET 7.0 2.557 ns 0.17

@alexcovington 的 dotnet/runtime#60974 擴大了 IndexOf 的影響範圍。在此 PR 之前,IndexOf 爲一字節和兩字節大小的基本類型進行了向量化,但此 PR 也將其擴展到四字節和八字節大小的基本類型。與大多數其他向量化實現一樣,它檢查 T 是否可以進行位等價,這對於向量化很重要,因爲它只查看內存中的位,而不關注可能在類型上定義的任何 Equals 實現。在實踐中,這意味着這僅限於運行時對其有深入瞭解的少數幾種類型(Boolean,Byte,SByte,UInt16,Int16,Char,UInt32,Int32,UInt64,Int64,UIntPtr,IntPtr,Rune 和枚舉),但理論上它可以在未來被擴展。

private int[] _data = new int[1000];

[Benchmark]
public int IndexOf() => _data.AsSpan().IndexOf(42);
方法 運行時 平均值 比率
IndexOf .NET 6.0 252.17 ns 1.00
IndexOf .NET 7.0 78.82 ns 0.31

最後一個有趣的與 IndexOf 相關的優化。字符串長期以來都有 IndexOf/IndexOfAny/LastIndexOf/LastIndexOfAny,顯然對於字符串,這都是關於處理字符的。當 ReadOnlySpan 和 Span 出現時,MemoryExtensions 被添加以爲 spans 和朋友提供擴展方法,包括這樣的 IndexOf/IndexOfAny/LastIndexOf/LastIndexOfAny 方法。但對於 spans,這不僅僅是關於 char,因此 MemoryExtensions 增長了其大部分與字符串分開的實現集。多年來,MemoryExtensions 的實現已經專門化了越來越多的類型,但特別是 byte 和 char,因此隨着時間的推移,字符串的實現大部分已被替換爲委託到 MemoryExtensions 使用的相同實現。然而,IndexOfAny 和 LastIndexOfAny 一直是統一的阻力,每個都有自己的方向。string.IndexOfAny 確實委託給了與 MemoryExtensions.IndexOfAny 相同的實現,用於搜索1-5個值,但對於超過5個值,string.IndexOfAny 使用了一個“概率映射”,基本上是一個布隆過濾器。它創建一個256位表,並根據要搜索的值快速設置該表中的位(基本上是對它們進行哈希,但使用了一個簡單的哈希函數)。然後它遍歷輸入,而不是將每個輸入字符與每個目標值進行比較,而是首先在表中查找輸入字符。如果相應的位沒有設置,它知道輸入字符與任何目標值都不匹配。如果設置了相應的位,那麼它將繼續將輸入字符與每個目標值進行比較,有很高的可能性是其中之一。MemoryExtensions.IndexOfAny 對於超過5個值缺少這樣的過濾器。相反,string.LastIndexOfAny 沒有爲多個目標值提供任何向量化,而 MemoryExtensions.LastIndexOfAny 向量化了兩個和三個目標值。從 dotnet/runtime#63817 開始,所有這些現在都已統一,這樣字符串和 MemoryExtensions 都可以得到對方的最好部分。

private readonly char[] s_target = new[] { 'z', 'q' };
const string Sonnet = """
    Shall I compare thee to a summer's day?
    Thou art more lovely and more temperate:
    Rough winds do shake the darling buds of May,
    And summer's lease hath all too short a date;
    Sometime too hot the eye of heaven shines,
    And often is his gold complexion dimm'd;
    And every fair from fair sometime declines,
    By chance or nature's changing course untrimm'd;
    But thy eternal summer shall not fade,
    Nor lose possession of that fair thou ow'st;
    Nor shall death brag thou wander'st in his shade,
    When in eternal lines to time thou grow'st:
    So long as men can breathe or eyes can see,
    So long lives this, and this gives life to thee.
    """;

[Benchmark]
public int LastIndexOfAny() => Sonnet.LastIndexOfAny(s_target);

[Benchmark]
public int CountLines()
{
    int count = 0;
    foreach (ReadOnlySpan<char> _ in Sonnet.AsSpan().EnumerateLines())
    {
        count++;
    }

    return count;
}
方法 運行時 平均值 比率
LastIndexOfAny .NET 6.0 443.29 ns 1.00
LastIndexOfAny .NET 7.0 31.79 ns 0.07
CountLines .NET 6.0 1,689.66 ns 1.00
CountLines .NET 7.0 1,461.64 ns 0.86

該 PR 還清理了使用 IndexOf 系列的用法,特別是在檢查包含而不是實際結果的索引的用法。IndexOf 系列的方法在找到元素時返回非負值,否則返回 -1。這意味着在檢查是否找到元素時,代碼可以使用 >= 0 或 != -1,當檢查元素是否未找到時,代碼可以使用 < 0 或 == -1。事實證明,與 -1 生成的比較相比,對 0 生成的代碼的效率略高,而這不是 JIT 可以自己替換的,除非 IndexOf 方法是內置的,這樣 JIT 可以理解返回值的語義。因此,爲了保持一致性和小的性能提升,所有相關的調用站點都被切換爲與 0 比較,而不是與 -1 比較。

說到調用站點,擁有高度優化的 IndexOf 方法的一大好處是可以在所有可以受益的地方使用它們,消除了開放編碼替換的維護影響,同時也獲得了性能提升。dotnet/runtime#63913 在 StringBuilder.Replace 內部使用了 IndexOf,以加速對下一個要替換的字符的搜索:

private StringBuilder _builder = new StringBuilder(Sonnet);

[Benchmark]
public void Replace()
{
    _builder.Replace('?', '!');
    _builder.Replace('!', '?');
}
方法 運行時 平均值 比率
Replace .NET 6.0 1,563.69 ns 1.00
Replace .NET 7.0 70.84 ns 0.04

dotnet/runtime#60463 來自 @nietras 在 StringReader.ReadLine 中使用了 IndexOfAny 來搜索 '\r' 和 '\n' 行結束字符,即使在分配和方法設計固有的情況下,也可以獲得一些實質性的吞吐量增益:

[Benchmark]
public void ReadAllLines()
{
    var reader = new StringReader(Sonnet);
    while (reader.ReadLine() != null) ;
}
方法 運行時 平均值 比率
ReadAllLines .NET 6.0 947.8 ns 1.00
ReadAllLines .NET 7.0 385.7 ns 0.41

dotnet/runtime#70176 清理了大量其他用法。

最後在 IndexOf 方面,正如所述,多年來我們在優化這些方法上投入了大量的時間和精力。在以前的版本中,一部分精力是直接使用硬件內置函數,例如,有一個 SSE2 代碼路徑,一個 AVX2 代碼路徑和一個 AdvSimd 代碼路徑。現在我們有了 Vector128 和 Vector256,許多這樣的用法可以被簡化(例如,避免在 SSE2 實現和 AdvSimd 實現之間的重複),同時仍然保持良好甚至更好的性能,同時自動支持在其他具有自己內置函數的平臺上的向量化,如 WebAssembly。dotnet/runtime#73481, dotnet/runtime#73556, dotnet/runtime#73368, dotnet/runtime#73364, dotnet/runtime#73064, 和 dotnet/runtime#73469 都在這裏做出了貢獻,在某些情況下帶來了有意義的吞吐量增益:

[Benchmark]
public int IndexOfAny() => Sonnet.AsSpan().IndexOfAny("!.<>");
方法 運行時 平均值 比率
IndexOfAny .NET 6.0 52.29 ns 1.00
IndexOfAny .NET 7.0 40.17 ns 0.77

IndexOf 系列只是 string/MemoryExtensions 上許多得到顯著改進的方法之一。另一個是 SequenceEquals 系列,包括 Equals, StartsWith, 和 EndsWith。我在整個版本中最喜歡的改變之一是 dotnet/runtime#65288,它正好在這個領域。調用 StartsWith 這樣的方法並使用常量字符串參數是非常常見的,例如 value.StartsWith("https://"), value.SequenceEquals("Key") 等。這些方法現在被 JIT 識別,JIT 現在可以自動展開比較並一次比較多個字符,例如,一次讀取四個字符作爲一個長整數,並對該長整數與這四個字符的預期組合進行一次比較。結果是美妙的。使其更好的是 dotnet/runtime#66095,它爲 OrdinalIgnoreCase 添加了這種支持。還記得那些稍早前討論的與 char.IsAsciiLetter 和朋友們有關的 ASCII 位操作技巧嗎?JIT 現在採用了同樣的技巧作爲這種展開的一部分,所以如果你做同樣的 value.StartsWith("https://") 但是作爲 value.StartsWith("https://", StringComparison.OrdinalIgnoreCase),它會識別到整個比較字符串是 ASCII,並且會在比較常量和從輸入中讀取的數據上 OR 入適當的掩碼,以便以不區分大小寫的方式進行比較。

private string _value = "https://dot.net";

[Benchmark]
public bool IsHttps_Ordinal() => _value.StartsWith("https://", StringComparison.Ordinal);

[Benchmark]
public bool IsHttps_OrdinalIgnoreCase() => _value.StartsWith("https://", StringComparison.OrdinalIgnoreCase);

方法 運行時 平均值 比率
IsHttps_Ordinal .NET 6.0 4.5634 ns 1.00
IsHttps_Ordinal .NET 7.0 0.4873 ns 0.11
IsHttps_OrdinalIgnoreCase .NET 6.0 6.5654 ns 1.00
IsHttps_OrdinalIgnoreCase .NET 7.0 0.5577 ns 0.08

有趣的是,自 .NET 5 以來,由 RegexOptions.Compiled 生成的代碼在比較多個字符的序列時會執行類似的展開,當在 .NET 7 中添加了源生成器時,它也學會了如何做這個。然而,源生成器在這種優化上有問題,因爲字節順序問題。被比較的常量受到字節排序問題的影響,因此源生成器需要生成可以在小端或大端機器上運行的代碼。JIT 沒有這個問題,因爲它在生成代碼的機器上執行代碼(在它被用來提前生成代碼的場景中,整個代碼已經綁定到特定的架構)。通過將這個優化移動到 JIT,可以從 RegexOptions.Compiled 和正則表達式源生成器中刪除相應的代碼,這樣也可以從生成使用 StartsWith 的更易讀的代碼中獲益,速度也一樣快(dotnet/runtime#65222 和 dotnet/runtime#66339)。這是全面的勝利。(只有在 dotnet/runtime#68055 之後,才能從 RegexOptions.Compiled 中刪除這個,該修復了 JIT 識別這些字符串字面量在 DynamicMethods 中的能力,RegexOptions.Compiled 使用反射發出來吐出正在編譯的正則表達式的 IL。)

StartsWith 和 EndsWith 在其他方面也有所改進。dotnet/runtime#63734(由 dotnet/runtime#64530 進一步改進)添加了另一個非常有趣的基於 JIT 的優化,但要理解它,我們需要理解 string 的內部佈局。string 在內存中基本上是表示爲一個 int 長度,後面跟着那麼多的 chars 加上一個 null 終止符 char。實際的 System.String 類在 C# 中將這個表示爲一個 int _stringLength 字段,後面跟着一個 char _firstChar 字段,這樣 _firstChar 確實與字符串的第一個字符對齊,或者如果字符串爲空,則與 null 終止符對齊。在 System.Private.CoreLib 內部,特別是在 string 本身的方法中,代碼通常會在需要查閱第一個字符時直接引用 _firstChar,因爲這通常比使用 str[0] 更快,特別是因爲沒有邊界檢查涉及到,通常不需要查閱字符串的長度。現在,考慮一下 string 上的這樣一個方法,比如 public bool StartsWith(char value)。在 .NET 6 中,實現是:

return Length != 0 && _firstChar == value;

根據我剛纔的描述,這是有道理的:如果 Length 是 0,那麼字符串就不會以指定的字符開始,如果 Length 不是 0,那麼我們就可以直接將 value 與 _firstChar 進行比較。但是,爲什麼需要這個 Length 檢查呢?我們不能只做 return _firstChar == value; 嗎?這將避免額外的比較和分支,而且它將工作得很好……除非目標字符本身就是 '\0',在這種情況下,我們可能會在結果上得到假陽性。現在來看這個 PR。PR 引入了一個內部的 JIT intrinsinc RuntimeHelpers.IsKnownConstant,如果包含方法被內聯,然後傳遞給 IsKnownConstant 的參數被看到是一個常量,那麼 JIT 將用 true 替換它。在這種情況下,實現可以依賴其他 JIT 優化踢入並優化方法中的各種代碼,有效地使開發者能夠寫出兩種不同的實現,一種是當參數被知道是常量時,另一種是當不是時。有了這個,PR 能夠優化 StartsWith 如下:

public bool StartsWith(char value)
{
    if (RuntimeHelpers.IsKnownConstant(value) && value != '\0')
        return _firstChar == value;

    return Length != 0 && _firstChar == value;
}

如果 value 參數不是一個常量,那麼 IsKnownConstant 將被替換爲 false,整個開始的 if 塊將被消除,方法將被留下來就像之前一樣。但是,如果這個方法被內聯並且 value 實際上是一個常量,那麼 value != '\0' 的條件也將在 JIT 編譯時被評估。如果 value 實際上是 '\0',那麼,再次,整個 if 塊將被消除,我們並沒有更糟。但在常見的情況下,value 不是 null,整個方法將最終被編譯,就好像它是:

return _firstChar == ConstantValue;

我們爲自己節省了讀取字符串長度的時間,一個比較和一個分支。dotnet/runtime#69038 然後採用了類似的技術來處理 EndsWith。

private string _value = "https://dot.net";

[Benchmark]
public bool StartsWith() =>
    _value.StartsWith('a') ||
    _value.StartsWith('b') ||
    _value.StartsWith('c') ||
    _value.StartsWith('d') ||
    _value.StartsWith('e') ||
    _value.StartsWith('f') ||
    _value.StartsWith('g') ||
    _value.StartsWith('i') ||
    _value.StartsWith('j') ||
    _value.StartsWith('k') ||
    _value.StartsWith('l') ||
    _value.StartsWith('m') ||
    _value.StartsWith('n') ||
    _value.StartsWith('o') ||
    _value.StartsWith('p');
方法 運行時 平均值 比率
StartsWith .NET 6.0 8.130 ns 1.00
StartsWith .NET 7.0 1.653 ns 0.20

IsKnownConstant 的另一個使用示例來自 dotnet/runtime#64016,它用它來改進當指定 MidpointRounding 模式時的 Math.Round。對此的調用幾乎總是明確地將枚舉值指定爲常量,這就允許 JIT 爲方法專門生成特定模式的代碼;這反過來,例如,使得在 Arm64 上的 Math.Round(..., MidpointRounding.AwayFromZero) 調用被降低爲單個 frinta 指令。

EndsWith 在 dotnet/runtime#72750 中也得到了改進,特別是當指定 StringComparison.OrdinalIgnoreCase 時。這個簡單的 PR 只是切換了用來實現這個方法的內部輔助方法,利用了一個對這個方法的需求足夠且開銷較低的方法。

[Benchmark]
[Arguments("System.Private.CoreLib.dll", ".DLL")]
public bool EndsWith(string haystack, string needle) =>
haystack.EndsWith(needle, StringComparison.OrdinalIgnoreCase);

方法 運行時 平均值 比率
EndsWith .NET 6.0 10.861 ns 1.00
EndsWith .NET 7.0 5.385 ns 0.50

最後,dotnet/runtime#67202 和 dotnet/runtime#73475 使用 Vector128 和 Vector256 替換了直接硬件內在使用,就像之前爲各種 IndexOf 方法所示,但這裏是爲 SequenceEqual 和 SequenceCompareTo。

在 .NET 7 中受到一些關注的另一個方法是 MemoryExtensions.Reverse(以及 Array.Reverse,因爲它共享相同的實現),它執行目標跨度的就地反轉。dotnet/runtime#64412 來自 @alexcovington 提供了通過直接使用 AVX2 和 SSSE3 硬件內在的向量化實現,dotnet/runtime#72780 來自 @SwapnilGaikwad 跟進添加了一個 AdvSimd 內在實現用於 Arm64。(原始向量化更改引入了一個意外的迴歸,但那是由 dotnet/runtime#70650 修復的。)

private char[] text = "Free. Cross-platform. Open source.\r\nA developer platform for building all your apps.".ToCharArray();

[Benchmark]
public void Reverse() => Array.Reverse(text);
方法 運行時 平均值 比率
Reverse .NET 6.0 21.352 ns 1.00
Reverse .NET 7.0 9.536 ns 0.45

String.Split 也在 dotnet/runtime#64899 中看到了向量化的改進,這是由 @yesmey 提供的。就像之前討論的一些 PR 一樣,它將現有的 SSE2 和 SSSE3 硬件內在的使用切換到新的 Vector128 幫助器,這改進了現有的實現,同時也隱式地爲 Arm64 添加了向量化支持。

許多應用程序和服務都需要轉換各種格式的字符串,無論是從 UTF8 字節到字符串和反向轉換,還是格式化和解析十六進制值。在 .NET 7 中,這些操作也以各種方式得到了改進。例如,Base64 編碼是一種將任意二進制數據(想想 byte[])表示在只支持文本的媒介上的方式,將字節編碼爲 64 種不同的 ASCII 字符中的一種。.NET 中有多個 API 實現了這種編碼。對於將以 ReadOnlySpan 表示的二進制數據和以 ReadOnlySpan 表示的 UTF8(實際上是 ASCII)編碼數據之間進行轉換,System.Buffers.Text.Base64 類型提供了 EncodeToUtf8 和 DecodeFromUtf8 方法。這些在幾個版本前就已經向量化了,但在 .NET 7 中,它們通過 dotnet/runtime#70654 從 @a74nh 進一步改進,將基於 SSSE3 的實現轉換爲使用 Vector128(這反過來隱式地在 Arm64 上啓用了向量化)。然而,對於將以 ReadOnlySpan/byte[] 表示的任意二進制數據和以 ReadOnlySpan/char[]/string 表示的數據之間進行轉換,System.Convert 類型公開了多個方法,例如 Convert.ToBase64String,這些方法歷史上並未向量化。這在 .NET 7 中發生了變化,其中 dotnet/runtime#71795 和 dotnet/runtime#73320 向量化了 ToBase64String、ToBase64CharArray 和 TryToBase64Chars 方法。他們這樣做的方式很有趣。而不是有效地複製來自 Base64.EncodeToUtf8 的向量化實現,他們反而在 EncodeToUtf8 上層進行操作,調用它將輸入字節數據編碼到輸出 Span。然後,他們將這些字節“擴寬”爲字符(記住,Base64 編碼數據是一組 ASCII 字符,所以從這些字節到字符只需要在每個元素上添加一個 0 字節)。這種擴寬本身可以很容易地以向量化的方式完成。這種層疊的另一個有趣之處是它實際上並不需要爲編碼的字節提供單獨的中間存儲。實現可以完美地計算將 X 字節編碼爲 Y Base64 字符的結果字符數(有一個公式),並且實現可以分配最終的空間(例如,在 ToBase64CharArray 的情況下)或確保提供的空間足夠(例如,在 TryToBase64Chars 的情況下)。既然我們知道初始編碼將需要恰好一半的字節數,我們就可以在同一空間內進行編碼(目標 span 被重新解釋爲字節 span 而不是字符 span),然後“就地”擴寬:從字節的末尾和字符空間的末尾開始走,將字節複製到目標中。

private byte[] _data = Encoding.UTF8.GetBytes("""
    Shall I compare thee to a summer's day?
    Thou art more lovely and more temperate:
    Rough winds do shake the darling buds of May,
    And summer's lease hath all too short a date;
    Sometime too hot the eye of heaven shines,
    And often is his gold complexion dimm'd;
    And every fair from fair sometime declines,
    By chance or nature's changing course untrimm'd;
    But thy eternal summer shall not fade,
    Nor lose possession of that fair thou ow'st;
    Nor shall death brag thou wander'st in his shade,
    When in eternal lines to time thou grow'st:
    So long as men can breathe or eyes can see,
    So long lives this, and this gives life to thee.
    """);
private char[] _encoded = new char[1000];

[Benchmark]
public bool TryToBase64Chars() => Convert.TryToBase64Chars(_data, _encoded, out _);
方法 運行時 平均值 比率
TryToBase64Chars .NET 6.0 623.25 ns 1.00
TryToBase64Chars .NET 7.0 81.82 ns 0.13

就像可以使用擴寬從字節轉換爲字符一樣,也可以使用縮小從字符轉換爲字節,特別是如果字符實際上是 ASCII,因此上字節爲 0。這種縮小可以向量化,內部的 NarrowUtf16ToAscii 實用程序助手就是這樣做的,作爲 Encoding.ASCII.GetBytes 等方法的一部分。雖然此方法以前已經向量化,但其主要快速路徑使用了 SSE2,因此不適用於 Arm64;感謝 @SwapnilGaikwad 的 dotnet/runtime#70080,該路徑被改爲基於跨平臺的 Vector128,在支持的平臺上實現了相同級別的優化。同樣,@SwapnilGaikwad 的 dotnet/runtime#71637 爲 Encoding.UTF8.GetByteCount 等方法使用的 GetIndexOfFirstNonAsciiChar 內部助手添加了 Arm64 向量化。(同樣,dotnet/runtime#67192 將內部的 HexConverter.EncodeToUtf16 方法從使用 SSSE3 內在函數改爲使用 Vector128,自動提供了 Arm64 實現。)

Encoding.UTF8 也有所改進。特別是,dotnet/runtime#69910 簡化了 GetMaxByteCount 和 GetMaxCharCount 的實現,使它們足夠小,常常在直接從 Encoding.UTF8 使用時內聯,使 JIT 能夠去虛擬化調用。

[Benchmark]
public int GetMaxByteCount() => Encoding.UTF8.GetMaxByteCount(Sonnet.Length);
方法 運行時 平均值 比率
GetMaxByteCount .NET 6.0 1.7442 ns 1.00
GetMaxByteCount .NET 7.0 0.4746 ns 0.27

可以說,.NET 7 中關於 UTF8 的最大改進是新的 C# 11 對 UTF8 字面量的支持。最初在 C# 編譯器中實現,在 dotnet/roslyn#58991,隨後在 dotnet/roslyn#59390,dotnet/roslyn#61532,和 dotnet/roslyn#62044 中進行了後續工作,UTF8 字面量使編譯器能夠在編譯時將 UTF8 編碼爲字節。而不是寫一個普通的字符串,例如 "hello",開發者只需在字符串字面量後面添加新的 u8 後綴,例如 "hello"u8。此時,這不再是一個字符串。相反,這個表達式的自然類型是 ReadOnlySpan。如果你寫:

public static ReadOnlySpan<byte> Text => "hello"u8;

C# 編譯器將編譯等效於你寫的:

public static ReadOnlySpan<byte> Text =>
    new ReadOnlySpan<byte>(new byte[] { (byte)'h', (byte)'e', (byte)'l', (byte)'l', (byte)'o', (byte)'\0' }, 0, 5);    

換句話說,編譯器在編譯時執行了等同於 Encoding.UTF8.GetBytes 的操作,並硬編碼了結果字節,節省了在運行時執行該編碼的成本。當然,乍一看,這種數組分配可能看起來效率極低。然而,外表可能會欺騙人,這種情況也不例外。在過去的幾個版本中,當 C# 編譯器看到一個 byte[](或 sbyte[] 或 bool[])用常量長度和常量值初始化,並立即轉換爲或用於構造 ReadOnlySpan 時,它會優化掉 byte[] 分配。相反,它將該 span 的數據複製到程序集的數據部分,然後構造一個直接指向加載的程序集中的數據的 span。這是上述屬性的實際生成的 IL:

IL_0000: ldsflda valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=6' '<PrivateImplementationDetails>'::F3AEFE62965A91903610F0E23CC8A69D5B87CEA6D28E75489B0D2CA02ED7993C
IL_0005: ldc.i4.5
IL_0006: newobj instance void valuetype [System.Runtime]System.ReadOnlySpan`1<uint8>::.ctor(void*, int32)
IL_000b: ret

這意味着我們不僅在運行時節省了編碼成本,而且我們避免了可能需要存儲結果數據的任何託管分配,我們還從 JIT 能夠看到關於編碼數據的信息(如它的長度)中受益,從而實現了連鎖優化。你可以通過檢查像這樣的方法生成的彙編清楚地看到這一點:

public static int M() => Text.Length;

對於這段代碼,JIT生成的代碼如下:

; Program.M()
       mov       eax,5
       ret
; Total bytes of code 6

JIT內聯了屬性訪問,看到span是用長度5構造的,所以它並沒有發出任何數組分配或span構造,甚至沒有任何類似的操作,它只是輸出 mov eax, 5 來返回span的已知長度。

主要歸功於 @am11 的 dotnet/runtime#70568, dotnet/runtime#69995, dotnet/runtime#70894, dotnet/runtime#71417,以及 dotnet/runtime#71292, dotnet/runtime#70513, 和 dotnet/runtime#71992,現在在整個 dotnet/runtime 中使用了超過2100次 u8。雖然這並不是一個公平的比較,但以下的基準測試展示了在執行時實際上對 u8 執行的工作有多少:

[Benchmark(Baseline = true)]
public ReadOnlySpan<byte> WithEncoding() => Encoding.UTF8.GetBytes("test");

[Benchmark] 
public ReadOnlySpan<byte> Withu8() => "test"u8;
方法 平均值 比率 分配 分配比率
WithEncoding 17.3347 ns 1.000 32 B 1.00
Withu8 0.0060 ns 0.000 0.00

就像我說的,這並不公平,但它證明了這一點 🙂

編碼當然只是創建字符串實例的一種機制。在 .NET 7 中,其他方法也有所改進。以超常見的 long.ToString 爲例。以前的版本改進了 int.ToString,但是 32 位和 64 位算法之間有足夠的差異,因此 long 並沒有看到所有相同的增益。現在,感謝 dotnet/runtime#68795,64 位格式化代碼路徑變得更像 32 位,從而提高了性能。

你還可以看到 string.Format 和 StringBuilder.AppendFormat 的改進,以及其他在這些之上的助手(如 TextWriter.AppendFormat)。dotnet/runtime#69757 徹底改造了 Format 內部的核心例程,以避免不必要的邊界檢查,偏向預期的情況,並一般清理實現。然而,它也使用 IndexOfAny 來搜索需要填充的下一個插值孔,如果非孔字符到孔的比率高(例如,具有少數孔的長格式字符串),那麼它可能比以前快得多。

private StringBuilder _sb = new StringBuilder();

[Benchmark]
public void AppendFormat()
{
    _sb.Clear();
    _sb.AppendFormat("There is already one outstanding '{0}' call for this WebSocket instance." +
                     "ReceiveAsync and SendAsync can be called simultaneously, but at most one " +
                     "outstanding operation for each of them is allowed at the same time.",
                     "ReceiveAsync");
}
方法 運行時 平均值 比率
AppendFormat .NET 6.0 338.23 ns 1.00
AppendFormat .NET 7.0 49.15 ns 0.15

說到 StringBuilder,除了對 AppendFormat 的上述改變之外,它還有其他改進。一個有趣的變化是 dotnet/runtime#64405,它實現了兩個相關的事情。第一個是去除格式化操作中的固定。例如,StringBuilder 有一個 Append(char* value, int valueCount) 重載,它將指定數量的字符從指定的指針複製到 StringBuilder,其他 API 是根據這個方法實現的;例如,Append(string? value, int startIndex, int count) 方法基本上是這樣實現的:

fixed (char* ptr = value)
{
    Append(ptr + startIndex, count);
}

這個 fixed 語句被翻譯成一個“固定指針”。通常,GC 可以自由地在堆上移動託管對象,它可能會這樣做以壓縮堆(例如,避免對象之間的小的、不可用的內存碎片)。但是,如果 GC 可以移動對象,那麼一個普通的本地指針指向那個內存將是非常不安全和不可靠的,因爲沒有通知,被指向的數據可能會移動,你的指針現在可能指向垃圾或者被移動到這個位置的其他對象。有兩種方法可以處理這個問題。第一種是“託管指針”,也被稱爲“引用”或“ref”,這就是你在 C# 中使用“ref”關鍵字時得到的;它是一個運行時會用被指向的對象移動時的正確值更新的指針。第二種是阻止被指向的對象被移動,將其“固定”在原地。這就是“fixed”關鍵字所做的,它在 fixed 塊的持續時間內固定引用的對象,這段時間內使用提供的指針是安全的。幸運的是,當沒有 GC 發生時,固定是便宜的;然而,當 GC 發生時,固定的對象不能被移動,因此固定可能對應用程序的性能(以及 GC 本身)產生全局影響。固定也會阻止各種優化。隨着 C# 在許多地方能夠使用 ref 的新特性(例如,ref 局部變量,ref 返回,現在在 C# 11 中,ref 字段),以及 .NET 中用於操作 ref 的新 API(例如,Unsafe.Add,Unsafe.AreSame),現在可以將使用固定指針的代碼重寫爲使用託管指針,從而避免固定帶來的問題。這就是這個 PR 所做的。所有的 Append 方法不再以 Append(char*, int) 輔助函數爲基礎實現,而是以 Append(ref char, int) 輔助函數爲基礎實現。所以,例如,之前顯示的 Append(string? value, int startIndex, int count) 實現現在類似於

Append(ref Unsafe.Add(ref value.GetRawStringData(), startIndex), count);

其中,string.GetRawStringData 方法只是公共的 string.GetPinnableReference 方法的內部版本,返回的是 ref 而不是 ref readonly。這意味着 StringBuilder 內部所有的高性能代碼都可以繼續使用指針來避免邊界檢查等,但現在也可以在不固定所有輸入的情況下這樣做。

這個 StringBuilder 的改變做的第二件事是統一了對 string 輸入的優化,也適用於 char[] 輸入和 ReadOnlySpan 輸入。具體來說,因爲將 string 實例追加到 StringBuilder 是非常常見的,所以很久以前就加入了一個特殊的代碼路徑來優化這個輸入,特別是在 StringBuilder 已經有足夠的空間來容納整個輸入的情況下,此時可以使用高效的複製。但是,有了共享的 Append(ref char, int) 輔助函數,這個優化可以被移動到那個輔助函數中,這樣不僅可以幫助 string,還可以幫助任何其他也調用同一個輔助函數的類型。這個效果在一個簡單的微基準測試中是可見的:

private StringBuilder _sb = new StringBuilder();

[Benchmark]
public void AppendSpan()
{
    _sb.Clear();
    _sb.Append("this".AsSpan());
    _sb.Append("is".AsSpan());
    _sb.Append("a".AsSpan());
    _sb.Append("test".AsSpan());
    _sb.Append(".".AsSpan());
}
方法 運行時 平均值 比率
AppendSpan .NET 6.0 35.98 ns 1.00
AppendSpan .NET 7.0 17.59 ns 0.49

改進底層堆棧中的事物的一大優點是它們具有乘法效應;它們不僅有助於提高直接依賴於改進功能的用戶代碼的性能,還可以幫助提高核心庫中其他代碼的性能,進一步幫助依賴的應用程序和服務。例如,你可以看到 DateTimeOffset.ToString,它依賴於 StringBuilder:

private DateTimeOffset _dto = DateTimeOffset.UtcNow;

[Benchmark]
public string DateTimeOffsetToString() => _dto.ToString();
方法 運行時 平均值 比率
DateTimeOffsetToString .NET 6.0 340.4 ns 1.00
DateTimeOffsetToString .NET 7.0 289.4 ns 0.85

StringBuilder 本身隨後由 @teo-tsirpanis 的 dotnet/runtime#64922 進一步更新,該更新改進了 Insert 方法。過去,StringBuilder 上的 Append(primitive) 方法(例如 Append(int))會對值調用 ToString,然後追加生成的字符串。隨着 ISpanFormattable 的出現,作爲一種快速路徑,這些方法現在嘗試直接格式化到 StringBuilder 的內部緩衝區中的值,只有當剩餘空間不足時,它們纔會作爲後備採取舊路徑。當時,Insert 沒有以這種方式進行改進,因爲它不能只是格式化到構建器末尾的空間;插入位置可能在構建器的任何地方。這個 PR 解決了這個問題,通過格式化到一些臨時的堆棧空間,然後委託給之前討論的 PR 中現有的內部基於 ref 的輔助函數,將生成的字符插入到正確的位置(當 ISpanFormattable.TryFormat 的堆棧空間不足時,它也會回退到 ToString,但這隻會在極其角落的情況下發生,比如格式化爲數百位的浮點值)。

private StringBuilder _sb = new StringBuilder();

[Benchmark]
public void Insert()
{
    _sb.Clear();
    _sb.Insert(0, 12345);
}
方法 運行時 平均值 比率 分配 分配比率
Insert .NET 6.0 30.02 ns 1.00 32 B 1.00
Insert .NET 7.0 25.53 ns 0.85 - 0.00

StringBuilder 也有其他一些小的改進,比如 dotnet/runtime#60406,它從 Replace 方法中移除了一個小的 int[] 分配。然而,即使有了所有這些改進,StringBuilder 的最快使用方式也沒有使用;dotnet/runtime#68768 移除了一堆最好用其他字符串創建機制的 StringBuilder 的使用。例如,舊的 DataView 類型有一些代碼,用於創建排序規範作爲字符串:

private static string CreateSortString(PropertyDescriptor property, ListSortDirection direction)
{
    var resultString = new StringBuilder();
    resultString.Append('[');
    resultString.Append(property.Name);
    resultString.Append(']');
    if (ListSortDirection.Descending == direction)
    {
        resultString.Append(" DESC");
    }
    return resultString.ToString();
}

我們實際上並不需要這裏的 StringBuilder,因爲在最壞的情況下,我們只是連接三個字符串,而 string.Concat 有一個專門的重載用於這個精確的操作,它具有該操作的最佳可能的實現(如果我們找到了更好的方法,那麼該方法將會相應地得到改進)。所以我們可以直接使用它:

private static string CreateSortString(PropertyDescriptor property, ListSortDirection direction) =>
    direction == ListSortDirection.Descending ?
        $"[{property.Name}] DESC" :
        $"[{property.Name}]";

注意,我通過插值字符串來表達這個連接,但是C#編譯器會將這個插值字符串“降級”爲對string.Concat的調用,所以這個的IL與我寫的幾乎無法區分:

private static string CreateSortString(PropertyDescriptor property, ListSortDirection direction) =>
    direction == ListSortDirection.Descending ?
        string.Concat("[", property.Name, "] DESC") :
        string.Concat("[", property.Name, "]");

順便說一下,擴展的 string.Concat 版本強調了,如果這個方法被寫成以下形式,那麼生成的 IL 會少一些:

private static string CreateSortString(PropertyDescriptor property, ListSortDirection direction) =>
    string.Concat("[", property.Name, direction == ListSortDirection.Descending ? "] DESC" : "]"); 

但這並不會對性能產生實質性影響,在這裏,清晰性和可維護性比節省幾個字節更重要。

[Benchmark(Baseline = true)]
[Arguments("SomeProperty", ListSortDirection.Descending)]
public string WithStringBuilder(string name, ListSortDirection direction)
{
    var resultString = new StringBuilder();
    resultString.Append('[');
    resultString.Append(name);
    resultString.Append(']');
    if (ListSortDirection.Descending == direction)
    {
        resultString.Append(" DESC");
    }
    return resultString.ToString();
}

[Benchmark]
[Arguments("SomeProperty", ListSortDirection.Descending)]
public string WithConcat(string name, ListSortDirection direction) =>
    direction == ListSortDirection.Descending?
        $"[{name}] DESC" :
方法 平均值 比率 分配 分配比率
WithStringBuilder 68.34 ns 1.00 272 B 1.00
WithConcat 20.78 ns 0.31 64 B 0.24

還有一些地方,StringBuilder 仍然適用,但它被用在足夠熱的路徑上,以至於.NET的早期版本看到StringBuilder實例被緩存。包括 System.Private.CoreLib 在內的幾個核心庫,都有一個內部的 StringBuilderCache 類型,它在 [ThreadStatic] 中緩存一個 StringBuilder 實例,這意味着每個線程都可能最終擁有這樣一個實例。這裏有幾個問題,包括 StringBuilder 使用的緩衝區在 StringBuilder 未被使用時無法用於其他任何事情,因此,StringBuilderCache 對可以被緩存的 StringBuilder 實例的容量設置了限制;試圖緩存超過該長度的實例會導致它們被丟棄。相反,最好使用緩存的數組,這些數組沒有長度限制,每個人都可以訪問以進行共享。許多核心 .NET 庫都有一個內部的 ValueStringBuilder 類型,這是一個基於 ref struct 的類型,它可以使用 stackalloc 分配的內存開始,然後如果需要,可以增長到 ArrayPool 數組。並且,隨着 dotnet/runtime#64522 和 dotnet/runtime#69683,許多剩餘的 StringBuilderCache 的使用已經被替換。我希望我們將來能完全移除 StringBuilderCache。

在同樣的不做不必要工作的思路中,有一個相當常見的模式出現在 string.Substring 和 span.Slice 這樣的方法中:

span = span.Slice(offset, str.Length - offset);

這裏需要認識到的相關事情是,這些方法有隻接受開始偏移量的重載。由於指定的長度是在指定的偏移量之後的剩餘部分,所以調用可以簡化爲:

span = span.Slice(offset);

這不僅更易讀和可維護,而且還有一些小的效率優勢,例如,在64位上,Slice(int, int) 構造函數比 Slice(int) 多一個加法操作,在32位上,Slice(int, int) 構造函數會產生額外的比較和分支。因此,簡化這些調用對於代碼維護和性能都是有益的,dotnet/runtime#68937 就是對所有找到的這種模式進行了這樣的簡化。然後,dotnet/runtime#73882 進一步增強了這個影響,它精簡了 string.Substring,去除了不必要的開銷,例如,它將四個參數驗證檢查壓縮爲一個單一的快速路徑比較(在64位進程中)。

好了,關於字符串就說到這裏。那麼,關於 spans 呢?C# 11 中最酷的特性之一就是對 ref 字段的新支持。什麼是 ref 字段?你對 C# 中的 refs 應該很熟悉,我們已經討論過它們基本上就是被管理的指針,即運行時可以隨時更新的指針,因爲它引用的對象可能在堆上移動。這些引用可以指向對象的開始,也可以指向對象內部的某個地方,在這種情況下,它們被稱爲“內部指針”。ref 在 C# 1.0 中就已經存在,但那時它主要是用於將引用傳遞給方法調用,例如:

class Data
{
    public int Value;
}
...
void Add(ref int i)
{
    i++;
}
...
var d = new Data { Value = 42 };
Add(ref d.Value);
Debug.Assert(d.Value == 43);

後來的 C# 版本增加了對本地 refs 的支持,例如:

void Add(ref int i)
{
    ref int j = ref i;
    j++;
}

甚至還有 ref 返回,例如:

ref int Add(ref int i)
{
    ref int j = ref i;
    j++;
    return ref j;
}

這些功能更爲高級,但在高性能代碼庫中被廣泛使用,近年來 .NET 中的許多優化在很大程度上都是由於這些 ref 相關的能力。

Span 和 ReadOnlySpan 本身就大量基於 refs。例如,許多舊的集合類型的索引器是作爲 get/set 屬性實現的,例如:

private T[] _items;
...
public T this[int i]
{
    get => _items[i];
    set => _items[i] = value;
}
But not span. Span<T>‘s indexer looks more like this:

public ref T this[int index]
{
    get
    {
        if ((uint)index >= (uint)_length)
            ThrowHelper.ThrowIndexOutOfRangeException();

        return ref Unsafe.Add(ref _reference, index);
    }
}

注意,這裏只有一個 getter,沒有 setter;這是因爲它返回一個 ref T,指向實際的存儲位置。這是一個可寫的 ref,所以你可以給它賦值,例如,你可以寫:

span[i] = value;

但這並不等同於調用某個 setter:

span.set_Item(i, value);

實際上,它等同於使用 getter 獲取 ref,然後通過該 ref 寫入值,例如:

ref T item = ref span.get_Item(i);
item = value;

這很好,但 getter 定義中的 _reference 是什麼呢?好吧,Span 實際上只是兩個字段的元組:一個引用(指向被引用的內存的開始)和一個長度(從該引用開始包含在 span 中的元素數量)。在過去,運行時必須使用一個內部類型(ByReference)來實現這個,這個類型被運行時特別識別爲引用。但是從 C# 11 和 .NET 7 開始,ref 結構現在可以包含 ref 字段,這意味着今天的 Span 實際上就是這樣定義的:

public readonly ref struct Span<T>
{
    internal readonly ref T _reference;
    private readonly int _length;
    ...
}

在 dotnet/runtime#71498 中,ref 字段在整個 dotnet/runtime 中的推出,跟隨着 C# 語言主要在 dotnet/roslyn#62155 中獲得這種支持,這本身是許多 PRs 首先進入一個特性分支的結果。ref 字段本身並不會自動提高性能,但它確實可以顯著簡化代碼,並且它允許使用 ref 字段的新的自定義代碼以及利用它們的新 API,這兩者都可以幫助提高性能(特別是在不犧牲潛在安全性的情況下的性能)。一個新 API 的例子是 ReadOnlySpan 和 Span 上的新構造函數:

public Span(ref T reference);
public ReadOnlySpan(in T reference);

在 dotnet/runtime#67447 中添加(然後在 dotnet/runtime#71589 中公開並更廣泛地使用)。這可能會引發一個問題,爲什麼 ref 字段的支持會啓用兩個新的接受 refs 的構造函數,考慮到 spans 已經能夠存儲一個 ref?畢竟,MemoryMarshal.CreateSpan(ref T reference, int length) 和相應的 CreateReadOnlySpan 方法已經存在了,只要 spans 存在,這些新的構造函數就等同於調用那些方法,長度爲 1。答案是:安全性。

想象一下,如果你可以隨意調用這個構造函數。你將能夠寫出這樣的代碼:

public Span<int> RuhRoh()
{
    int i = 42;
    return new Span<int>(ref i);
}

在這一點上,這個方法的調用者被交給了一個指向垃圾的 span;在預期爲安全的代碼中,這是不好的。你已經可以通過使用指針來實現同樣的事情:

public Span<int> RuhRoh()
{
    unsafe
    {
        int i = 42;
        return new Span<int>(&i, 1);
    }
}

但在這一點上,你已經承擔了使用不安全的代碼和指針的風險,任何結果的問題都在你身上。使用 C# 11,如果你現在試圖使用基於 ref 的構造函數寫上述代碼,你會收到這樣的錯誤:

錯誤 CS8347: 不能在此上下文中使用 'Span<int>.Span(ref int)' 的結果,因爲它可能會將參數 'reference' 引用的變量暴露在其聲明範圍之外

換句話說,編譯器現在理解到 Span 作爲一個 ref 結構可能會存儲傳入的 ref,如果它確實存儲了它(Span 就是這樣做的),這就類似於將一個 ref 傳遞給一個局部方法,這是不好的。因此,這與 ref 字段有關:因爲 ref 字段現在是一種事物,編譯器對 refs 的安全處理規則已經更新,這反過來使我們能夠在 {ReadOnly}Span 上公開上述構造函數。

正如通常情況一樣,解決一個問題會將問題推到路的一邊並暴露出另一個問題。編譯器現在認爲傳遞給 ref 結構的方法的 ref 可能使該 ref 結構實例存儲 ref(注意,這已經是傳遞給 ref 結構的方法的 ref 結構的情況),但如果我們不希望這樣呢?如果我們希望能夠說“這個 ref 不可存儲,不應該逃脫調用範圍”?從調用者的角度來看,我們希望編譯器允許傳入這樣的 refs,而不會抱怨可能的生命週期延長,從被調用者的角度來看,我們希望編譯器阻止方法做它不應該做的事情。進入 scoped。新的 C# 關鍵字正好做了我們剛纔希望的事情:將它放在 ref 或 ref 結構參數上,編譯器既會保證(除非使用不安全的代碼)方法不能存儲參數,也會使調用者能夠編寫依賴於該保證的代碼。例如,考慮以下程序:

var writer = new SpanWriter(stackalloc char[128]);
Append(ref writer, 123);
writer.Write(".");
Append(ref writer, 45);
Console.WriteLine(writer.AsSpan().ToString());

static void Append(ref SpanWriter builder, byte value)
{
    Span<char> tmp = stackalloc char[3];
    value.TryFormat(tmp, out int charsWritten);
    builder.Write(tmp.Slice(0, charsWritten));
}

ref struct SpanWriter
{
    private readonly Span<char> _chars;
    private int _length;

    public SpanWriter(Span<char> destination) => _chars = destination;

    public Span<char> AsSpan() => _chars.Slice(0, _length);

    public void Write(ReadOnlySpan<char> value)
    {
        if (_length > _chars.Length - value.Length)
        {
            throw new InvalidOperationException("Not enough remaining space");
        }

        value.CopyTo(_chars.Slice(_length));
        _length += value.Length;
    }
}

我們有一個 ref 結構 SpanWriter,它接受一個 Span 到其構造函數,並允許通過複製額外的內容然後更新存儲的長度來寫入它。Write 方法接受一個 ReadOnlySpan。然後我們有一個 helper Append 方法,它將一個字節格式化到一些 stackalloc‘d 臨時空間,並將結果格式化的字符傳入到 Write。直接的。除了,這不編譯:

錯誤 CS8350: 'SpanWriter.Write(ReadOnlySpan<char>)' 的這種參數組合是不允許的,因爲它可能會將參數 'value' 引用的變量暴露在其聲明範圍之外

我們該怎麼辦?Write 方法實際上並不存儲 value 參數,也永遠不需要,所以我們可以改變方法的簽名來註解它爲 scoped:

public void Write(scoped ReadOnlySpan<char> value)

如果 Write 然後試圖存儲 value,編譯器會抱怨:

錯誤 CS8352: 不能在此上下文中使用變量 'ReadOnlySpan<char>',因爲它可能會將引用的變量暴露在其聲明範圍之外

但是因爲它並沒有試圖這樣做,所以現在一切都成功編譯了。你可以在上述的 dotnet/runtime#71589 中看到如何使用這個的例子。

還有另一個方向:有一些事情是隱式 scoped 的,比如在結構上的 this 引用。考慮以下代碼:

public struct SingleItemList
{
    private int _value;

    public ref int this[int i]
    {
        get
        {
            if (i != 0) throw new IndexOutOfRangeException();

            return ref _value;
        }
    }
}

這會產生一個編譯器錯誤:

錯誤 CS8170: 結構成員不能通過引用返回 'this' 或其他實例成員

實際上,這是因爲 this 是隱式 scoped 的(即使以前沒有這個關鍵字)。如果我們想要使這樣的項能夠被返回呢?進入 [UnscopedRef]。這種需求足夠罕見,以至於它沒有得到自己的 C# 語言關鍵字,但 C# 編譯器確實識別新的 [UnscopedRef] 屬性。它可以放在相關參數上,也可以放在方法和屬性上,在這種情況下,它適用於該成員的 this 引用。因此,我們可以修改我們之前的代碼示例爲:

[UnscopedRef]
public ref int this[int i]

現在代碼將成功編譯。當然,這也對這個方法的調用者提出了要求。對於一個調用站點,編譯器看到被調用的成員上的 [UnscopedRef],然後知道返回的 ref 可能引用該結構中的某個東西,因此將返回的 ref 的生命週期與該結構的生命週期相同。所以,如果那個結構是一個在棧上的局部變量,ref 也將限制在同一個方法中。

另一個有影響的 span 相關改變來自 dotnet/runtime#70095,由 @teo-tsirpanis 提出。System.HashCode 的目標是提供一個快速、易於使用的實現,用於生成高質量的哈希碼。在其當前的版本中,它包含一個隨機的進程範圍種子,並且是非加密哈希算法 xxHash32 的實現。在之前的版本中,HashCode 添加了一個 AddBytes 方法,它接受一個 ReadOnlySpan,並且對於包含應該是類型哈希碼的一部分的數據序列非常有用,例如,BigInteger.GetHashCode 包含了構成 BigInteger 的所有數據。xxHash32 算法通過累積 4 個 32 位無符號整數,然後將它們組合成哈希碼;因此,如果你調用 HashCode.Add(int),你前三次調用它時只是將值分別存儲到實例中,然後你第四次調用它時,所有這些值都被組合成哈希碼(並且有一個單獨的過程,如果添加的 32 位值的數量不是 4 的精確倍數,那麼它會包含任何剩餘的值)。因此,以前的 AddBytes 只是簡單地實現爲從輸入 span 中重複讀取下一個 4 字節,並用這些字節作爲整數調用 Add(int)。但是這些 Add 調用有開銷。相反,這個 PR 跳過了 Add 調用,並直接處理了 16 字節的累積和組合。有趣的是,它仍然必須處理之前的 Add 調用可能留下一些狀態的可能性,這意味着(至少在當前的實現中),如果有多個狀態需要包含在哈希碼中,比如說一個 ReadOnlySpan 和一個額外的 int,那麼首先添加 span 然後添加 int 比反過來的方式更有效率。所以例如,當 dotnet/runtime#71274 由 @huoyaoyuan 改變了 BigInteger.GetHashCode 以使用 HashCode.AddBytes 時,它編碼了方法,首先用 BigInteger 的 _bits 調用 AddBytes,然後用 _sign 調用 Add。

private byte[] _data = Enumerable.Range(0, 256).Select(i => (byte)i).ToArray();

[Benchmark]
public int AddBytes()
{
    HashCode hc = default;
    hc.AddBytes(_data);
    return hc.ToHashCode();
}
方法 運行時 平均值 比率
AddBytes .NET 6.0 159.11 ns 1.00
AddBytes .NET 7.0 42.11 ns 0.26

另一個與 span 相關的變化,dotnet/runtime#72727 重構了一堆代碼路徑,以消除一些緩存數組。爲什麼要避免緩存數組呢?畢竟,一次緩存數組並反覆使用它不是很好嗎?如果那是最好的選擇,那的確是,但有時候有更好的選擇。例如,其中一個改變將像這樣的代碼:

private static readonly char[] s_pathDelims = { ':', '\\', '/', '?', '#' };
...
int index = value.IndexOfAny(s_pathDelims);

替換爲像這樣的代碼:

int index = value.AsSpan().IndexOfAny(@":\/?#");

這有各種好處。將被搜索的字符保持在使用點附近有可用性的好處,列表不可變的特性也有可用性的好處,這樣某些代碼就不會意外地替換數組中的值。但也有性能上的好處。我們不需要額外的字段來存儲數組。我們不需要在這個類型的靜態構造函數中分配數組。加載/使用字符串也稍微快一些。

private static readonly char[] s_pathDelims = { ':', '\\', '/', '?', '#' };
private static readonly string s_value = "abcdefghijklmnopqrstuvwxyz";

[Benchmark]
public int WithArray() => s_value.IndexOfAny(s_pathDelims);

[Benchmark]
public int WithString() => s_value.AsSpan().IndexOfAny(@":\/?#");
方法 平均值 比率
WithArray 8.601 ns 1.00
WithString 6.949 ns 0.81

該 PR 的另一個例子將類似以下的代碼:

private static readonly char[] s_whitespaces = new char[] { ' ', '\t', '\n', '\r' };
...
switch (attr.Value.Trim(s_whitespaces))
{
    case "preserve": return Preserve;
    case "default": return Default;
}

替換爲如下代碼:

switch (attr.Value.AsSpan().Trim(" \t\n\r"))
{
    case "preserve": return Preserve;
    case "default": return Default;
}

在這種情況下,我們不僅避免了 char[],而且如果文本確實需要去除空格,新版本(它修剪 span 而不是原始字符串)將節省一個分配給修剪字符串的空間。這是利用新的 C# 11 功能,它支持在 ReadOnlySpan 上進行切換,就像你可以在字符串上進行切換一樣,這是在 dotnet/roslyn#44388 中由 @YairHalberstadt 添加的。dotnet/runtime#68831 也在幾個其他地方利用了這個特性。

當然,在某些情況下,數組完全是不必要的。在同一個 PR 中,有幾個類似這樣的例子:

private static readonly char[] WhiteSpaceChecks = new char[] { ' ', '\u00A0' };
...
int wsIndex = target.IndexOfAny(WhiteSpaceChecks, targetPosition);
if (wsIndex < 0)
{
    return false;
}

通過切換到使用 spans,我們可以將其改寫爲這樣:

int wsIndex = target.AsSpan(targetPosition).IndexOfAny(' ', '\u00A0');
if (wsIndex < 0)
{
    return false;
}
wsIndex += targetPosition;

MemoryExtensions.IndexOfAny 有一個專門用於兩個和三個參數的重載,此時我們根本不需要數組(這些重載也恰好更快;當傳遞一個包含兩個字符的數組時,實現會從數組中提取兩個字符,然後將它們傳遞給相同的兩參數實現)。其他多個 PR 也類似地移除了數組分配。dotnet/runtime#60409 移除了一個被緩存的單字符數組,以便將其傳遞給 string.Split,並用直接接受單個字符的 Split 重載替換了它。

最後,來自 @NewellClark 的 dotnet/runtime#59670 去掉了更多的數組。我們之前看到 C# 編譯器如何對使用常量長度和常量元素構造的 byte[] 進行特殊處理,然後立即將其轉換爲 ReadOnlySpan。因此,任何時候緩存這樣的 byte[] 並將其暴露爲 ReadOnlySpan 都可能是有益的。正如我在 .NET 6 文章中討論的,這避免了你會得到的用於緩存數組的一次性數組分配,結果訪問效率更高,而且向 JIT 編譯器提供了更多信息,使其能夠更加優化...全方位的好處。這個 PR 以這種方式移除了更多的數組,dotnet/runtime#60411、dotnet/runtime#72743、來自 @vcsjones 的 dotnet/runtime#73115 和 dotnet/runtime#70665 也是如此。

Regex

在五月份,我分享了一篇關於 .NET 7 中正則表達式改進的相當詳細的文章。回顧一下,.NET 5 之前,Regex 的實現已經有相當長一段時間沒有變動了。在 .NET 5 中,我們使其在性能方面達到或超過了其他多個行業實現。.NET 7 在此基礎上取得了一些重大的進步。如果你還沒有讀過那篇文章,現在請繼續閱讀;我會等你的...

歡迎回來。有了這個背景,我將避免在這裏重複內容,而是專注於這些改進是如何實現的以及實現它們的 PR。

RegexOptions.NonBacktracking
讓我們從 Regex 中一個較大的新特性開始,新的 RegexOptions.NonBacktracking 實現。如前文所述,RegexOptions.NonBacktracking 將 Regex 的處理切換到使用基於有限自動機的新引擎。它有兩種主要的執行模式,一種依賴於 DFA(確定性有限自動機),一種依賴於 NFA(非確定性有限自動機)。兩種實現都提供了一個非常有價值的保證:處理時間與輸入的長度成線性關係。而回溯引擎(如果沒有指定 NonBacktracking,Regex 就會使用它)可能會遇到一個被稱爲“災難性回溯”的情況,其中有問題的表達式和有問題的輸入可能導致輸入長度的指數級處理,NonBacktracking 保證它只會對輸入中的每個字符做出均攤常數的工作量。在 DFA 的情況下,這個常數非常小。在 NFA 的情況下,這個常數可能會大得多,取決於模式的複雜性,但對於任何給定的模式,工作量仍然與輸入的長度成線性關係。

NonBacktracking 實現投入了大量的開發年份,最初在 dotnet/runtime#60607 中添加到 dotnet/runtime 中。然而,它的原始研究和實現實際上來自微軟研究院(MSR),並以 Symbolic Regex Matcher(SRM)庫的形式作爲一個實驗性的包發佈。你仍然可以在現在的 .NET 7 中看到這個的痕跡,但它已經發展得非常顯著,.NET 團隊的開發人員和 MSR 的研究人員之間緊密合作(在被集成到 dotnet/runtime 之前,它在 dotnet/runtimelab 中孵化了一年多,原始的 SRM 代碼是通過 dotnet/runtimelab#588 從 @veanes 引入的)。
這個實現基於正則表達式導數的概念,這個概念已經存在了幾十年(這個術語最初是在1960年代由 Janusz Brzozowski 在一篇論文中提出的),並且在這個實現中得到了顯著的提升。正則表達式導數構成了用於處理輸入的自動機(想象爲“圖”)構造的基礎。其核心的想法相當簡單:取一個正則表達式並處理一個單獨的字符...處理那一個字符後剩下的新的正則表達式是什麼?那就是導數。例如,給定正則表達式 \w{3} 來匹配三個單詞字符,如果你將這個應用到下一個輸入字符 'a',那麼,這將剝離第一個 \w,留下我們的導數 \w{2}。簡單,對吧?那麼更復雜的東西呢,比如表達式 .(the|he)。如果下一個字符是 t 呢?那麼,t 可能會被模式開頭的 . 消耗掉,在這種情況下,剩下的正則表達式將與開始的正則表達式完全相同(.(the|he)),因爲在匹配 t 之後我們仍然可以匹配與沒有 t 時完全相同的輸入。但是,t 也可能是匹配 the 的一部分,應用到 the 上,我們會剝離 t 並留下 he,所以現在我們的導數是 .(the|he)|he。那麼原始交替中的 he 呢?t 不匹配 h,所以導數將是沒有,我們在這裏將其表示爲一個空的字符類,給我們 .(the|he)|he|[]。當然,作爲交替的一部分,最後的“沒有”是一個 nop,所以我們可以將整個導數簡化爲只有 .(the|he)|he...完成。這是所有應用原始模式對下一個 t 的情況。如果它是針對 h 的呢?按照 t 的相同邏輯,這次我們得到的是 .(the|he)|e。等等。如果我們開始的是 h 導數,下一個字符是 e 呢?那麼我們正在取模式 .(the|he)|e 並將其應用到 e。對於交替的左側,它可以被 .* 消耗(但不匹配 t 或 h),所以我們只是得到了相同的子表達式。但是對於交替的右側,e 匹配 e,留下我們的空字符串():.*(the|he)|()。在模式是“可爲空”(它可以匹配空字符串)的地方,那可以被認爲是一個匹配。我們可以將整個過程可視化爲一個圖,對於每個輸入字符到來自應用它的導數的轉換。

來自 NonBacktracking 引擎的 DFA,.NET 7 中的性能改進

看起來很像 DFA,對吧?它應該是。這正是 NonBacktracking 構造用於處理輸入的 DFA 的方式。對於每個正則表達式構造(連接,交替,循環等),引擎知道如何根據正在評估的字符推導出下一個正則表達式。這個應用是懶惰的,所以我們有一個初始的起始狀態(原始模式),然後當我們評估輸入中的下一個字符時,它會查看是否已經有一個可用的導數用於該轉換:如果有,它就會遵循它,如果沒有,它就會動態/懶惰地推導出圖中的下一個節點。這就是它的核心工作方式。

當然,魔鬼在於細節,有很多複雜性和工程智慧投入到使引擎高效。一個例子是在內存消耗和吞吐量之間的權衡。考慮到可以將任何字符作爲輸入,你可以有效地從每個節點出來有 ~65K 的轉換(例如,每個節點可能需要一個 ~65K 元素的表);這將顯著增加內存消耗。然而,如果你實際上有那麼多的轉換,很可能大部分都會指向同一個目標節點。因此,NonBacktracking 維護了它自己的字符分組,稱爲“minterms”。如果兩個字符將有完全相同的轉換,它們就是同一個 minterm 的一部分。然後以 minterms 的形式構造轉換,每個給定節點最多有一個 minterm 的轉換。當讀取下一個輸入字符時,它將其映射到一個 minterm ID,然後找到該 ID 的適當轉換;爲了節省可能的大量內存,增加了一層間接性。該映射通過一個 ASCII 的數組位圖和一個稱爲二元決策圖(BDD)的高效數據結構處理,用於處理 0x7F 以上的所有內容。

正如前面提到的,非回溯引擎在輸入長度上是線性的。但這並不意味着它總是精確地查看每個輸入字符一次。如果你調用 Regex.IsMatch,它確實會這樣做;畢竟,IsMatch 只需要確定是否有匹配,不需要計算任何額外的信息,如匹配實際開始或結束的位置,任何關於捕獲的信息等。因此,引擎可以簡單地使用其自動機沿着輸入行走,從圖中的一個節點轉移到另一個節點,直到它到達一個最終狀態或用完輸入。然而,其他操作確實需要它收集更多的信息。Regex.Match 需要計算所有的東西,這實際上可能需要對輸入進行多次遍歷。在初始實現中,Match 的等價物總是需要進行三次遍歷:向前匹配以找到匹配的結束位置,然後從該結束位置反向匹配模式的反向副本以找到匹配實際開始的位置,然後再從已知的開始位置向前行走一次以找到實際的結束位置。然而,有了來自 @olsaarik 的 dotnet/runtime#68199,除非需要捕獲,否則現在只需要兩次遍歷:一次向前找到匹配的確保的結束位置,然後反向一次找到其開始位置。並且來自 @olsaarik 的 dotnet/runtime#65129 添加了捕獲支持,這是原始實現也沒有的。這種捕獲支持增加了第三次遍歷,一旦知道了匹配的邊界,引擎再次運行向前的遍歷,但這次是基於 NFA 的“模擬”,能夠在轉換上記錄“捕獲效果”。所有這些都使非回溯實現具有與回溯引擎完全相同的語義,總是以相同的順序產生相同的匹配和相同的捕獲信息。在這方面的唯一區別是,雖然在回溯引擎中循環內的捕獲組會存儲循環的每次迭代中捕獲的所有值,但在非回溯實現中只存儲最後一次迭代。除此之外,非回溯實現簡單地不支持一些構造,嘗試使用任何這些構造時,嘗試構造 Regex 時都會失敗,例如後向引用和環視。

即使在作爲 MSR 的獨立庫的進展之後,也有超過 100 個 PR 使 RegexOptions.NonBacktracking 成爲了 .NET 7 中的現狀,包括像來自 @olsaarik 的 dotnet/runtime#70217 的優化,試圖簡化 DFA 的核心的緊密內部匹配循環(例如,讀取下一個輸入字符,找到要採取的適當轉換,移動到下一個節點,並檢查節點的信息,如是否是最終狀態)和像來自 @veanes 的 dotnet/runtime#65637 的優化,優化了 NFA 模式以避免多餘的分配,緩存和重用列表和集合對象,使狀態列表的處理成爲分攤的無分配。

對於 NonBacktracking,還有一組性能相關的 PR 值得關注。無論使用哪種引擎,Regex 實現將模式轉換爲可處理的內容的過程本質上是一個編譯器,就像許多編譯器一樣,它自然適合遞歸算法。在 Regex 的情況下,這些算法涉及遍歷正則表達式構造的樹。遞歸最終成爲表達這些算法的非常方便的方式,但遞歸也可能導致棧溢出;本質上它是使用棧空間作爲臨時空間,如果它使用得太多,事情就會變得糟糕。處理這個問題的一種常見方法是將遞歸算法轉換爲迭代算法,這通常涉及使用顯式的狀態棧而不是隱式的狀態棧。這樣做的好處是你可以存儲的狀態數量僅受你擁有的內存量的限制,而不是受你的線程的棧空間的限制。然而,缺點是,以這種方式編寫算法通常不太自然,而且通常需要爲棧分配堆空間,這就導致瞭如果你想避免這種分配,就會出現額外的複雜性,例如各種類型的池化。dotnet/runtime#60385 爲 Regex 引入了一種不同的方法,然後由 @olsaarik 在 dotnet/runtime#60786 中特別用於 NonBacktracking 實現。它仍然使用遞歸,因此可以從遞歸算法的表達性以及能夠使用棧空間(從而在最常見的情況下避免額外的分配)中受益,但爲了避免棧溢出,它發出顯式檢查以確保我們在棧上沒有太深(.NET 長期以來爲此目的提供了 RuntimeHelpers.EnsureSufficientExecutionStack 和 RuntimeHelpers.TryEnsureSufficientExecutionStack 助手)。如果它檢測到在棧上太深,它會將繼續執行分叉到另一個線程。觸發這個條件是昂貴的,但在實踐中很少(例如,在我們的大量功能測試中唯一觸發的時間是在專門編寫用於壓力測試的測試中),它保持代碼簡單,並保持典型的情況快速。在 dotnet/runtime 的其他區域,例如 System.Linq.Expressions,也使用了類似的方法。

正如我在之前關於正則表達式的博客文章中提到的,回溯實現和非回溯實現都有其應用場所。非回溯實現的主要優點是可預測性:由於線性處理保證,一旦你構建了正則表達式,你就不需要擔心惡意輸入導致你可能容易受到影響的表達式在處理過程中出現最壞情況的行爲。這並不意味着 RegexOptions.NonBacktracking 總是最快的;事實上,它經常不是。爲了降低最佳性能,它提供了最佳的最壞情況性能,對於某些類型的應用,這是一個非常有價值的權衡。

New APIs

在 .NET 7 中,Regex 獲得了幾個新的方法,所有這些都能提高性能。新 API 的簡單性可能也誤導了人們對啓用它們所需的工作量的理解,特別是因爲新的 API 都支持將 ReadOnlySpan 輸入到正則表達式引擎中。
dotnet/runtime#65473 將 Regex 帶入了 .NET 的基於 span 的時代,克服了自從在 .NET Core 2.1 中引入 span 以來 Regex 的一個重大限制。Regex 歷來都是基於處理 System.String 輸入的,這個事實貫穿了 Regex 的設計和實現,包括爲 .NET Framework 中依賴的擴展模型 Regex.CompileToAssembly 暴露的 API(CompileToAssembly 現在已被棄用,且在 .NET Core 中從未起作用)。依賴於將字符串作爲輸入的一個微妙之處是如何將匹配信息返回給調用者。Regex.Match 返回一個表示輸入中的第一個匹配的 Match 對象,該 Match 對象暴露了一個 NextMatch 方法,該方法可以移動到下一個匹配。這意味着 Match 對象需要存儲對輸入的引用,以便它可以作爲這樣一個 NextMatch 調用的一部分反饋到匹配引擎中。如果輸入是一個字符串,那麼很好,沒有問題。但是,如果輸入是一個 ReadOnlySpan,那麼作爲一個 ref struct 的 span 不能存儲在類 Match 對象上,因爲 ref struct 只能在棧上而不是堆上。這本身就使得支持 span 成爲一個挑戰,但問題的根源更深。所有的正則表達式引擎都依賴於一個 RegexRunner,這是一個基類,它存儲了所有需要輸入到 FindFirstChar 和 Go 方法的狀態,這些方法組成了正則表達式的實際匹配邏輯(這些方法包含了執行匹配的所有核心代碼,其中 FindFirstChar 是一個優化,用於跳過那些不可能開始匹配的輸入位置,然後 Go 執行實際的匹配邏輯)。如果你看一下內部的 RegexInterpreter 類型,這是你在構造一個新的 Regex(...) 時得到的引擎,而不是 RegexOptions.Compiled 或 RegexOptions.NonBacktracking 標誌,它從 RegexRunner 派生。同樣,當你使用 RegexOptions.Compiled 時,它將它反射發出的動態方法交給一個從 RegexRunner 派生的類型,RegexOptions.NonBacktracking 有一個 SymbolicRegexRunnerFactory,它產生從 RegexRunner 派生的類型,等等。最相關的是,RegexRunner 是公開的,因爲由 Regex.CompileToAssembly 類型(現在是正則表達式源生成器)生成的類型包括從這個 RegexRunner 派生的類型。因此,這些 FindFirstChar 和 Go 方法是抽象的和受保護的,並且沒有參數,因爲它們從基類的受保護成員中獲取所有需要的狀態。這包括要處理的字符串輸入。那麼 span 呢?我們當然可以在輸入 ReadOnlySpan 上調用 ToString()。這在功能上是正確的,但是完全違背了接受 span 的目的,更糟糕的是,這可能會讓消費應用的性能比沒有 API 時還要差。相反,我們需要一種新的方法和新的 API。

首先,我們將 FindFirstChar 和 Go 方法從抽象方法改爲了虛方法。這種將這些方法分開的設計在很大程度上已經過時,特別是強制將查找下一個可能的匹配位置的處理階段和在該位置實際執行匹配的階段分開,這並不適合所有的引擎,比如 NonBacktracking 使用的引擎(它最初將 FindFirstChar 實現爲 nop,並將所有邏輯放在 Go 中)。然後我們添加了一個新的虛方法 Scan,重要的是,這個方法接受一個 ReadOnlySpan 作爲參數;span 不能從基類 RegexRunner 中暴露出來,必須傳入。然後我們根據 Scan 實現了 FindFirstChar 和 Go,並使它們“正常工作”。然後,所有的引擎都是基於該 span 實現的;它們不再需要訪問保護成員 RegexRunner.runtext、RegexRunner.runtextbeg 和 RegexRunner.runtextend 來獲取輸入;它們只是接收到 span,已經切片到輸入區域,然後處理它。從性能的角度來看,這樣做的一個好處是它使 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

相比之下,如果我們處理的是一個 span,它已經考慮了邊界,那麼我們可以寫一個更加規範的循環,如下所示:

[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

即使沒有所有其他基於 span 操作的好處,我們立即從以 span 爲基礎執行所有邏輯中獲得了低級代碼生成的好處。雖然上面的例子是編造的(顯然匹配邏輯不僅僅是一個簡單的 for 循環),但這裏有一個真實的例子。當一個正則表達式包含一個 \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;

以下是 span 版本的樣子:

[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 爲數組、字符串或 span 發出拋出索引超出範圍異常的代碼時,生成的彙編代碼就會是這樣。它在末尾,因爲它被認爲是“冷門”,很少執行。在第一個版本中存在,是因爲 JIT 不能基於該函數的本地分析證明 runtext[index-1] 和 runtext[index] 的訪問將在字符串的範圍內(它不能知道或信任 startpos、endpos 和 runtext 的邊界之間的任何隱含關係)。但在第二個版本中,JIT 可以知道並信任 ReadOnlySpan 的下界是 0,上界(不包括)是 span 的 Length,而且根據方法的構造,它可以證明 span 的訪問總是在邊界內。因此,它不需要在方法中發出任何邊界檢查,方法就不會有索引超出範圍拋出的標誌。你可以在 dotnet/runtime#66129,dotnet/runtime#66178 和 dotnet/runtime#72728 中看到更多利用 span 成爲所有引擎核心的例子,所有這些都清理了不必要的檢查,這些檢查總是針對邊界,然後總是 0 和 span.Length。

好的,所以現在引擎能夠接收 span 輸入並處理它們,那麼我們能做什麼呢?好吧,Regex.IsMatch 很簡單:它不被需要執行多個匹配的需求所困擾,因此不需要擔心如何存儲那個輸入的 ReadOnlySpan 以供下一次匹配使用。同樣,新的 Regex.Count,它提供了一個優化的實現,用於計算輸入中有多少個匹配,可以繞過使用 Match 或 MatchCollection,因此也可以很容易地操作 span;dotnet/runtime#64289 添加了基於字符串的重載,dotnet/runtime#66026 添加了基於 span 的重載。我們可以通過將額外的信息傳遞到引擎中來進一步優化 Count,讓它們知道它們實際需要計算多少信息。例如,我之前提到 NonBacktracking 在需要收集的信息相對於需要做的工作量方面是相當公平的。只確定是否有匹配是最便宜的,因爲它可以在單次通過輸入時就做到這一點。如果它還需要計算實際的開始和結束邊界,那就需要再反向通過一部分輸入。如果它還需要計算捕獲信息,那就需要基於 NFA 再進行一次前向傳遞(即使其他兩個是基於 DFA 的)。Count 需要邊界信息,因爲它需要知道從哪裏開始尋找下一個匹配,但它不需要捕獲信息,因爲沒有任何捕獲信息被返回給調用者。dotnet/runtime#68242 更新了引擎以接收這個額外的信息,這樣像 Count 這樣的方法就可以變得更有效率。

所以,IsMatch 和 Count 可以使用 span。但我們仍然沒有一個方法可以讓你實際獲取那些匹配信息。新的 EnumerateMatches 方法就是這樣的方法,由 dotnet/runtime#67794 添加。EnumerateMatches 與 Match 非常相似,只是它返回的是一個 ref struct 枚舉器,而不是一個 Match 類實例:

public ref struct ValueMatchEnumerator
{
    private readonly Regex _regex;
    private readonly ReadOnlySpan<char> _input;
    private ValueMatch _current;
    private int _startAt;
    private int _prevLen;
    ...
}

作爲一個 ref struct,枚舉器能夠存儲對輸入 span 的引用,因此能夠遍歷由 ValueMatch ref struct 表示的匹配。值得注意的是,目前 ValueMatch 不提供捕獲信息,這也使它能夠參與之前爲 Count 提到的優化。即使你有一個輸入字符串,EnumerateMatches 因此是一種在輸入中枚舉所有匹配的分攤無分配的方式。然而,在 .NET 7 中,如果你還需要所有的捕獲數據,就沒有辦法進行這樣的無分配枚舉。這是我們將來如果/根據需要,會考慮設計的一項功能。
嘗試尋找下一個可能的起始位置
如前所述,所有引擎的核心都是一個接受輸入文本進行匹配的 Scan(ReadOnlySpan) 方法,它結合了來自基礎實例的位置信息,並在找到下一個匹配的位置或者在沒有找到其他匹配的情況下耗盡輸入時退出。對於回溯引擎,該方法的實現邏輯如下:

protected override void Scan(ReadOnlySpan<char> inputSpan)
{
    while (!TryMatchAtCurrentPosition(inputSpan) &&
           base.runtextpos != inputSpan.Length)
    {
        base.runtextpos++;
    }
}

我們嘗試在當前位置匹配輸入,如果成功,那就退出。然而,如果當前位置不匹配,那麼如果還有剩餘的輸入,我們就“推進”位置並重新開始過程。在正則表達式引擎術語中,這通常被稱爲“推進循環”。然而,如果我們在每個輸入字符處都運行完整的匹配過程,那可能會不必要地慢。對於許多模式,有一些關於模式的東西可以讓我們更有思路地在哪裏進行完全匹配,快速跳過那些不可能匹配的位置,只在有真正匹配機會的位置上花費我們的時間和資源。爲了提升這個概念到一流的地位,回溯引擎的“推進循環”通常更像下面這樣(我說“通常”是因爲在某些情況下,編譯和源生成的正則表達式能夠生成更好的東西)。

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 添加了一個 switch,以快速跳轉到正確的策略,並在構造解釋器時預計算要使用的策略。這不僅使解釋器在匹配時間的實現更快,而且使得添加額外策略實際上是免費的(在匹配時間的運行時開銷方面)。

然後 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,它們都可能受到所使用的文化影響,並且可能不區分大小寫地映射到超過兩個值)。在覆蓋了足夠的常見情況後,實現就使用 IndexOfAny(char, char, ...) 來搜索開始集,而不是使用 Boyer-Moore 來執行不區分大小寫的搜索,由 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 中一樣。這個 PR 不再硬編碼僅 ASCII 字符映射到的不區分大小寫的集合,而是硬編碼每個可能的字符的集合。一旦完成,我們就不再需要在匹配時間知道大小寫不敏感,而可以只專注於有效地匹配集合,這是我們已經需要做得很好的事情。現在,我說它爲每個可能的字符編碼集合;這並不完全正確。如果是真的,那將佔用大量的內存,實際上,大部分內存都會被浪費,因爲絕大多數字符不參與大小寫轉換...我們只需要處理大約2000個字符。因此,實現採用了三層表格方案。第一個表有64個元素,將所有字符的完整範圍劃分爲64個分組;這64個組中,有54個組的字符不參與大小寫轉換,所以如果我們碰到其中一個條目,我們可以立即停止搜索。對於剩下的10個至少有一個字符在其範圍內參與的,字符和第一個表的值用於計算第二個表的索引;在那裏,大多數條目也表示沒有參與大小寫轉換。只有當我們在第二個表中得到一個合法的命中,這纔給我們一個索引到第三個表,在那個位置我們可以找到所有被認爲與第一個大小寫等價的字符。

dotnet/runtime#63477(然後在 dotnet/runtime#66572 中進一步改進)繼續添加了另一種搜索策略,這一策略受到了 nim-regex 的字面優化的啓發。我們跟蹤了許多正則表達式的性能,以確保我們在常見情況下沒有退步,並幫助指導投資。其中之一是 mariomka/regex-benchmark 語言正則表達式基準測試中的模式。其中之一是用於 URIs 的:(@"[\w]+://[/\s?#]+[\s?#]+(?:?[\s#]*)?(?:#[\s]*)?"。這個模式挑戰了到目前爲止啓用的尋找下一個好位置的策略,因爲它保證以“單詞字符”(\w)開始,這包括大約65000個可能字符中的50000個;我們沒有好的方法來向量化搜索這樣的字符類。然而,這個模式很有趣,因爲它以一個循環開始,不僅如此,它是一個上界無限的循環,我們的分析將確定它是原子的,因爲緊接着循環的字符保證是 ':',它本身不是一個單詞字符,因此循環可能匹配並作爲回溯的一部分放棄的沒有什麼可以匹配 ':' 的。這一切都適合於向量化的不同方法:而不是試圖搜索 \w 字符類,我們可以反過來搜索子字符串 "😕/",然後一旦我們找到它,我們可以向後匹配儘可能多的 [\w]s;在這種情況下,唯一的限制是我們需要匹配至少一個。這個 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 正則表達式實現長期以來都有針對帶有起始錨點的模式的優化:例如,如果模式以 ^ 開始(並且沒有指定 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);
方法 運行時 平均值 比率
IsMatch .NET 6.0 867,890.56 ns 1.000
IsMatch .NET 7.0 33.55 ns 0.000

dotnet/runtime#67732 是另一個與改進錨點處理相關的 PR。當一個 bug 修復或代碼簡化重構變成性能改進時,總是很有趣。這個 PR 的主要目的是簡化一些複雜的代碼,這些代碼正在計算可能開始匹配的字符集。事實證明,這種複雜性隱藏了一個邏輯錯誤,這個錯誤表現在它錯過了報告有效的開始字符類的一些機會,其影響是一些本可以向量化的搜索沒有被向量化。通過簡化實現,修復了這個 bug,揭示了更多的性能機會。

到這個階段,引擎已經能夠使用 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; // 將搜索 "ing"
方法 運行時 平均值 比率
Count .NET 6.0 444.2 us 1.00
Count .NET 7.0 122.6 us 0.28

循環和回溯

在編譯和源代碼生成的引擎中,循環處理已經得到了顯著改進,無論是在加快處理速度還是在減少回溯方面。
對於常規的貪婪循環(例如 c),我們需要關注兩個方向:我們消耗匹配循環的所有元素的速度有多快,以及我們回退可能需要作爲表達式其餘部分匹配的元素的速度有多快。而對於懶惰循環,我們主要關注的是回溯,這是前進的方向(因爲懶惰循環在回溯的過程中消耗,而不是在回溯的過程中回退)。通過 PRs 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' 重疊。所以假設輸入是 "aaaaaaaab"。循環是懶惰的,所以我們首先嚐試匹配 '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 尋找可以移除的樹中的“空”和“無”節點。“空”節點是匹配空字符串的東西,所以例如在 alternation abc|def||ghi 中,第三個分支就是空的。“無”節點是不能匹配任何東西的東西,所以例如在 concatenation abc(?!)def 中,中間的 (?!) 是一個圍繞空的負向前瞻,它不可能匹配任何東西,因爲它表示如果後面跟着空字符串,表達式就不會匹配,而所有東西都是後面跟着空字符串的。這些結構通常是其他轉換的結果,而不是開發者通常會手寫的東西,就像 JIT 中有一些優化,你可能會看着它們說“這怎麼可能是開發者會寫的東西”,但它最終還是成爲了有價值的優化,因爲內聯可能會將完全合理的代碼轉換爲匹配目標模式的東西。因此,例如,如果你有 abc(?!)def,由於這個連接需要 (?!) 匹配才能成功,所以連接本身可以簡單地被替換爲一個“無”。如果你使用源代碼生成器嘗試這個:

[GeneratedRegex(@"abc(?!)def")]

它會生成像這樣的 Scan 方法(包括註釋):

protected override void Scan(ReadOnlySpan<char> inputSpan)
{
    // The pattern never matches anything.
}

另一組轉換是在 dotnet/runtime#59903 中引入的,特別是關於 alternations(除了循環,這是回溯的另一個來源)。這引入了兩個主要的優化。首先,它可以將 alternations 重寫爲 alternations 的 alternations,例如,將 axy|axz|bxy|bxz 轉換爲 ax(?:y|z)|bx(?:y|z),然後進一步簡化爲 ax[yz]|bx[yz]。這可以使回溯引擎更有效地處理 alternations,因爲分支更少,因此可能的回溯也更少。PR 還啓用了 alternation 中分支的有限重排序。通常分支不能被重新排序,因爲順序可以影響到底匹配了什麼和捕獲了什麼,但是如果引擎可以證明排序對結果沒有影響,那麼它就可以自由地重新排序。一個關鍵的地方是,如果 alternation 是原子的,因爲它被包裹在一個原子組中(並且自動原子性分析會在某些情況下隱式地添加這樣的組),那麼排序就不是一個因素。重新排序分支可以啓用其他優化,比如前面提到的這個 PR 中的一個。然後一旦這些優化生效,如果我們剩下的是一個原子 alternation,其中每個分支都以不同的字母開始,那麼就可以進一步優化 alternation 的降低方式;這個 PR 教導源代碼生成器如何發出 switch 語句,這會導致更高效和更易讀的代碼。(檢測樹中的節點是否是原子的,以及其他諸如執行捕獲或引入回溯的屬性,被證明是足夠有價值的,以至於 dotnet/runtime#65734 添加了專門的支持。)

Net 6

Arrays, Strings, Spans

對於許多應用和服務來說,創建和操作數組、字符串和跨度是它們處理的重要部分,大量的努力投入到不斷降低這些操作的成本中。.NET 6 也不例外。

讓我們從 Array.Clear 開始。當前的 Array.Clear 簽名接受要清除的 Array,起始位置,和要清除的元素數量。然而,如果你看看使用情況,絕大多數用例是像 Array.Clear(array, 0, array.Length) 這樣的代碼...換句話說,清除整個數組。對於在熱路徑上使用的基本操作,爲了確保偏移量和計數在範圍內,所需的額外驗證加起來也是一筆不小的開銷。dotnet/runtime#51548 和 dotnet/runtime#53388 添加了一個新的 Array.Clear(Array) 方法,避免了這些開銷,並改變了 dotnet/runtime 中許多調用點使用新的重載。

private int[] _array = new int[10];

[Benchmark(Baseline = true)]
public void Old() => Array.Clear(_array, 0, _array.Length);

[Benchmark]
public void New() => Array.Clear(_array);
方法 平均值 比率
Old 5.563 ns 1.00
New 3.775 ns 0.68

類似的還有 Span.Fill,它不僅將每個元素設爲零,而且將每個元素設爲特定值。dotnet/runtime#51365 在這裏提供了顯著的改進:雖然對於 byte[] 它已經能夠直接調用 initblk (memset) 實現,這是向量化的,但對於其他 T[] 數組,其中 T 是原始類型(例如,char),它現在也可以使用向量化的實現,帶來了相當不錯的加速。然後 dotnet/runtime#52590 從 @xtqqczze 重用 Span.Fill 作爲 Array.Fill 的底層實現。

private char[] _array = new char[128];
private char _c = 'c';

[Benchmark]
public void SpanFill() => _array.AsSpan().Fill(_c);

[Benchmark]
public void ArrayFill() => Array.Fill(_array, _c);
方法 運行時 平均值 比率
SpanFill .NET 5.0 32.103 ns 1.00
SpanFill .NET 6.0 3.675 ns 0.11
ArrayFill .NET 5.0 55.994 ns 1.00
ArrayFill .NET 6.0 3.810 ns 0.07

有趣的是,Array.Fill 不能簡單地委託給 Span.Fill,原因與其他希望在(可變)跨度上重建基於數組的實現的人相關。.NET 中的引用類型數組是協變的,這意味着給定一個從 A 派生的引用類型 B,你可以編寫如下代碼:

var arrB = new B[4];
A[] arrA = arrB;

現在你有了一個 A[],你可以愉快地讀出實例作爲 A,但只能存儲 B 實例,例如,這是可以的:

arrA[0] = new B();

但這將拋出異常:

arrA[0] = new A();

類似於 System.ArrayTypeMismatchException:試圖以與數組不兼容的類型訪問元素。這也會在每次將元素存儲到(大多數)引用類型的數組時產生可衡量的開銷。當引入跨度時,人們認識到,如果你創建一個可寫的跨度,你很可能會寫入它,因此,如果需要支付檢查的成本,最好在創建跨度時一次性支付,而不是每次寫入跨度時都支付。因此,Span 是不變的,其構造函數包括此代碼:

if (!typeof(T).IsValueType && array.GetType() != typeof(T[]))
    ThrowHelper.ThrowArrayTypeMismatchException();

這個檢查,對於值類型完全被 JIT 移除,對於引用類型由 JIT 重度優化,驗證指定的 T 匹配數組的具體類型。例如,如果你編寫這樣的代碼:

new Span<A>(new B[4]);

這將拋出一個異常。爲什麼這與 Array.Fill 有關?它可以接受任意的 T[] 數組,沒有保證 T 與數組類型完全匹配,例如:

var arr = new B[4];
Array.Fill<A>(new B[4], null);

如果 Array.Fill 是純粹的以 new Span(array).Fill(value) 實現的,上述代碼將從 Span 的構造函數中拋出一個異常。相反,Array.Fill 本身執行與 Span 構造函數相同的檢查;如果檢查通過,它創建 Span 並調用 Fill,但如果檢查不通過,它回退到典型的循環,將值寫入數組的每個元素。

只要我們在談論向量化,這個版本中的其他支持已經被向量化。dotnet/runtime#44111 利用 SSSE3 硬件內在函數(例如 Ssse3.Shuffle)來優化內部 HexConverter.EncodeToUtf16 的實現,它在一些地方被使用,包括公共的 Convert.ToHexString:

private byte[] _data;

[GlobalSetup]
public void Setup()
{
    _data = new byte[64];
    RandomNumberGenerator.Fill(_data);
}

[Benchmark]
public string ToHexString() => Convert.ToHexString(_data);
方法 運行時 平均值 比率
ToHexString .NET 5.0 130.89 ns 1.00
ToHexString .NET 6.0 44.78 ns 0.34

dotnet/runtime#44088 也利用了向量化,儘管是間接的,通過使用已經向量化的 IndexOf 方法來提高 String.Replace(String, String) 的性能。這個 PR 是另一個很好的例子,展示了“優化”通常是權衡,以犧牲使其他場景變慢的代價來使一些場景更快,並需要根據這些場景發生的預期頻率來做出決定。在這種情況下,PR 顯著地改進了三個特定的情況:

  • 如果兩個輸入都只是一個字符(例如,str.Replace("\n", " ")),那麼它可以委託給已經優化的 String.Replace(char, char) 重載。
  • 如果 oldValue 是一個字符,實現可以使用 IndexOf(char) 來找到它,而不是使用手動循環。
  • 如果 oldValue 是多個字符,實現可以使用類似於 IndexOf(string, StringComparison.Ordinal) 的方法來找到它。

第二和第三個要點如果在輸入中 oldValue 不是非常頻繁的話,可以顯著加速操作,使向量化能夠支付自身的成本並且更多。然而,如果它非常頻繁(比如輸入中的每個或者每隔一個字符),這個改變實際上可能會降低性能。我們的賭注,基於在各種代碼庫中審查用例,是這總的來說將是一個非常積極的勝利。

private string _str;

[GlobalSetup]
public async Task Setup()
{
    using var hc = new HttpClient();
    _str = await hc.GetStringAsync("https://www.gutenberg.org/cache/epub/3200/pg3200.txt"); // The Entire Project Gutenberg Works of Mark Twain
}

[Benchmark]
public string Yell() => _str.Replace(".", "!");

[Benchmark]
public string ConcatLines() => _str.Replace("\n", "");

[Benchmark]
public string NormalizeEndings() => _str.Replace("\r\n", "\n");
方法 運行時 平均值 比率
Yell .NET 5.0 32.85 ms 1.00
Yell .NET 6.0 16.99 ms 0.52
ConcatLines .NET 5.0 34.36 ms 1.00
ConcatLines .NET 6.0 22.93 ms 0.67
NormalizeEndings .NET 5.0 33.09 ms 1.00
NormalizeEndings .NET 6.0 23.61 ms 0.71

對於向量化,之前的 .NET 版本在 System.Text.Encodings.Web 的各種算法中添加了向量化,但特別是使用 x86 硬件內在函數,這樣這些優化最終沒有在 ARM 上應用。dotnet/runtime#49847 現在通過 AdvSimd 硬件內在函數的支持進行了增強,使 ARM64 設備能夠實現類似的加速。只要我們正在查看 System.Text.Encodings.Web,值得一提的是 dotnet/runtime#49373,它完全改變了庫的實現,主要目標是顯著減少涉及的不安全代碼量;然而,在過程中,我們已經一次又一次地看到,使用跨度和其他現代實踐替換不安全的基於指針的代碼,通常不僅使代碼更簡單、更安全,而且更快。部分更改涉及向量化所有編碼器使用的“跳過所有不需要編碼的 ASCII 字符”的邏輯,幫助在常見場景中產生一些顯著的加速。

private string _text;

[Params("HTML", "URL", "JSON")]
public string Encoder { get; set; }

private TextEncoder _encoder;

[GlobalSetup]
public async Task Setup()
{
    using (var hc = new HttpClient())
        _text = await hc.GetStringAsync("https://www.gutenberg.org/cache/epub/3200/pg3200.txt");

    _encoder = Encoder switch
    {
        "HTML" => HtmlEncoder.Default,
        "URL" => UrlEncoder.Default,
        _ => JavaScriptEncoder.Default,
    };
}

[Benchmark]
public string Encode() => _encoder.Encode(_text);
方法 運行時 編碼器 平均 比率 分配
Encode .NET Core 3.1 HTML 106.44 ms 1.00 128 MB
Encode .NET 5.0 HTML 101.58 ms 0.96 128 MB
Encode .NET 6.0 HTML 43.97 ms 0.41 36 MB
Encode .NET Core 3.1 JSON 113.70 ms 1.00 124 MB
Encode .NET 5.0 JSON 96.36 ms 0.85 124 MB
Encode .NET 6.0 JSON 39.73 ms 0.35 33 MB
Encode .NET Core 3.1 URL 165.60 ms 1.00 136 MB
Encode .NET 5.0 URL 141.26 ms 0.85 136 MB
Encode .NET 6.0 URL 70.63 ms 0.43 44 MB

在.NET 6中增強的另一個字符串API是string.Join。Join的一個重載接受一個IEnumerable<string?>作爲要連接的字符串,它在迭代過程中將字符串附加到構建器。但是已經有一個基於數組的代碼路徑,它對字符串進行兩次遍歷,一次計算所需的大小,然後填充所需長度的結果字符串。dotnet/runtime#44032將該功能轉換爲基於ReadOnlySpan<string?>而不是string?[],然後對實際上是List<string?>的可枚舉對象進行特殊處理,通過CollectionsMarshal.AsSpan方法獲取List<string?>的後備數組的跨度。dotnet/runtime#56857然後對IEnumerable-based重載做同樣的處理。

private List<string> _strings = new List<string>() { "Hi", "How", "are", "you", "today" };

[Benchmark]
public string Join() => string.Join(", ", _strings);
方法 運行時 平均 比率 分配
Join .NET Framework 4.8 124.81 ns 1.00 120 B
Join .NET 5.0 123.54 ns 0.99 112 B
Join .NET 6.0 51.08 ns 0.41 72 B

然而,最大的字符串相關改進來自於C# 10和.NET 6中新的插值字符串處理器支持,新的語言支持在dotnet/roslyn#54692中添加,庫支持在dotnet/runtime#51086和dotnet/runtime#51653中添加。如果我寫:

static string Format(int major, int minor, int build, int revision) =>
    $"{major}.{minor}.{build}.{revision}";

// C# 9 would compile that as:

static string Format(int major, int minor, int build, int revision)
{
    var array = new object[4];
    array[0] = major;
    array[1] = minor;
    array[2] = build;
    array[3] = revision;
    return string.Format("{0}.{1}.{2}.{3}", array);
}

這會產生各種開銷,例如在每次調用時都必須在運行時解析複合格式字符串,裝箱每個int,並分配一個數組來存儲它們。使用C# 10和.NET 6,它會被編譯爲:

static string Format(int major, int minor, int build, int revision)
{
    var h = new DefaultInterpolatedStringHandler(3, 4);
    h.AppendFormatted(major);
    h.AppendLiteral(".");
    h.AppendFormatted(minor);
    h.AppendLiteral(".");
    h.AppendFormatted(build);
    h.AppendLiteral(".");
    h.AppendFormatted(revision);
    return h.ToStringAndClear();
}

所有的解析都在編譯時處理,沒有額外的數組分配,也沒有額外的裝箱分配。你可以通過將上述示例轉化爲基準測試來看到這些改變的影響:

private int Major = 6, Minor = 0, Build = 100, Revision = 21380;

[Benchmark(Baseline = true)]
public string Old()
{
    object[] array = new object[4];
    array[0] = Major;
    array[1] = Minor;
    array[2] = Build;
    array[3] = Revision;
    return string.Format("{0}.{1}.{2}.{3}", array);
}

[Benchmark]
public string New()
{
    var h = new DefaultInterpolatedStringHandler(3, 4);
    h.AppendFormatted(Major);
    h.AppendLiteral(".");
    h.AppendFormatted(Minor);
    h.AppendLiteral(".");
    h.AppendFormatted(Build);
    h.AppendLiteral(".");
    h.AppendFormatted(Revision);
    return h.ToStringAndClear();
}

方法 平均 比率 分配
舊的 127.31 ns 1.00 200 B
新的 69.62 ns 0.55 48 B

要深入瞭解,包括討論.NET 6內置的各種自定義插值字符串處理器,以改進對StringBuilder、Debug.Assert和MemoryExtensions的支持,請參閱C# 10和.NET 6中的字符串插值。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章