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

鄭子銘發表於2023-03-01

原文 | Stephen Toub

翻譯 | 鄭子銘

原始型別和數值 (Primitive Types and Numerics)

我們已經看過了程式碼生成和GC,執行緒和向量化,互操作......讓我們把注意力轉向系統中的一些基本型別。像int、bool和double這樣的基本型別,像Guid和DateTime這樣的核心型別,它們構成了構建一切的支柱,每一個版本都能看到這些型別的改進,這讓人興奮。

來自@CarlVerretdotnet/runtime#62301極大地提高了double.Parse和float.Parse將UTF16文字解析為浮點值的能力。這一點特別好,因為它是基於@lemire@CarlVerret最近的一些研究,他們用C#和.NET 5實現了一個非常快速的浮點數解析實現,而這個實現現在已經進入了.NET 7!

private string[] _valuesToParse;

[GlobalSetup]
public void Setup()
{
    using HttpClient hc = new HttpClient();
    string text = hc.GetStringAsync("https://raw.githubusercontent.com/CarlVerret/csFastFloat/1d800237275f759b743b86fcce6680d072c1e834/Benchmark/data/canada.txt").Result;
    var lines = new List<string>();
    foreach (ReadOnlySpan<char> line in text.AsSpan().EnumerateLines())
    {
        ReadOnlySpan<char> trimmed = line.Trim();
        if (!trimmed.IsEmpty)
        {
            lines.Add(trimmed.ToString());
        }
    }
    _valuesToParse = lines.ToArray();
}

[Benchmark]
public double ParseAll()
{
    double total = 0;
    foreach (string s in _valuesToParse)
    {
        total += double.Parse(s);
    }
    return total;
}
方法 執行時 平均值 比率
ParseAll .NET 6.0 26.84 ms 1.00
ParseAll .NET 7.0 12.63 ms 0.47

bool.TryParse和bool.TryFormat也得到了改進。dotnet/runtime#64782透過使用BinaryPrimitives執行更少的寫和讀,簡化了這些實現。例如,TryFormat透過執行以下操作而不是寫出 "True"。

destination[0] = 'T';
destination[1] = 'r';
destination[2] = 'u';
destination[3] = 'e';

這需要四次寫操作,相反,它可以透過一次寫來實現相同的操作。

BinaryPrimitives.WriteUInt64LittleEndian(MemoryMarshal.AsBytes(destination), 0x65007500720054); // "True"

那0x65007500720054是記憶體中四個字元的數值,是一個單一的ulong。你可以透過一個微觀的基準測試看到這些變化的影響。

private bool _value = true;
private char[] _chars = new char[] { 'T', 'r', 'u', 'e' };

[Benchmark] public bool ParseTrue() => bool.TryParse(_chars, out _);
[Benchmark] public bool FormatTrue() => _value.TryFormat(_chars, out _);
方法 執行時 平均值 比率
ParseTrue .NET 6.0 7.347 ns 1.00
ParseTrue .NET 7.0 2.327 ns 0.32
FormatTrue .NET 6.0 3.030 ns 1.00
FormatTrue .NET 7.0 1.997 ns 0.66

Enum也得到了一些效能上的提升。例如,當執行像Enum.IsDefined、Enum.GetName或Enum.ToString這樣的操作時,該實現會查詢所有定義在列舉上的值的快取。這個快取包括Enum中每個定義的列舉的字串名稱和值。它也是按陣列中的值排序的,所以當這些操作之一被執行時,程式碼使用Array.BinarySearch來找到相關條目的索引。這方面的問題是開銷的問題。當涉及到演算法複雜性時,二進位制搜尋比線性搜尋更快;畢竟,二進位制搜尋是O(log N),而線性搜尋是O(N)。然而,線上性搜尋中,每一步演算法的開銷也較少,因此對於較小的N值,簡單地做簡單的事情會快很多。這就是dotnet/runtime#57973對列舉的作用。對於小於或等於32個定義值的列舉,現在的實現只是透過內部的SpanHelpers.IndexOf(在跨度、字串和陣列上的IndexOf背後的工作程式)進行線性搜尋,而對於超過這個值的列舉,它進行SpanHelpers.BinarySearch(這是對Array.BinarySearch的實現)。

private DayOfWeek[] _days = Enum.GetValues<DayOfWeek>();

[Benchmark]
public bool AllDefined()
{
    foreach (DayOfWeek day in _days)
    {
        if (!Enum.IsDefined(day))
        {
            return false;
        }
    }

    return true;
}
方法 執行時 平均值 比率
AllDefined .NET 6.0 159.28 ns 1.00
AllDefined .NET 7.0 94.86 ns 0.60

Enums在與Nullable和EqualityComparer.Default的配合下也得到了提升。EqualityComparer.Default快取了一個從所有對Default的訪問中返回的EqualityComparer例項的單子例項。該單例根據相關的T進行初始化,實現者可以從眾多不同的內部實現中進行選擇,例如專門用於位元組的ByteArrayComparer,用於實現IComparable的T的GenericEqualityComparer,等等。對於任意型別來說,萬能的是一個ObjectEqualityComparerdotnet/runtime#68077修復了這一問題,它確保了nullable enums被對映到(現有的)Nullable的專門比較器上,並簡單地調整了其定義以確保它能與enums很好地配合。結果表明,以前有多少不必要的開銷。

private DayOfWeek?[] _enums = Enum.GetValues<DayOfWeek>().Select(e => (DayOfWeek?)e).ToArray();

[Benchmark]
[Arguments(DayOfWeek.Saturday)]
public int FindEnum(DayOfWeek value) => IndexOf(_enums, value);

private static int IndexOf<T>(T[] values, T value)
{
    for (int i = 0; i < values.Length; i++)
    {
        if (EqualityComparer<T>.Default.Equals(values[i], value))
        {
            return i;
        }
    }

    return -1;
}
方法 執行時 平均值 比率
FindEnum .NET 6.0 421.608 ns 1.00
FindEnum .NET 7.0 5.466 ns 0.01

不容忽視的是,Guid的平等操作也變快了,這要感謝@madelsondotnet/runtime#66889。以前的Guid實現將資料分成4個32位的值,並進行4個int的比較。有了這個改變,如果當前的硬體支援128位SIMD,實現就會將兩個Guid的資料載入為兩個向量,並簡單地進行一次比較。

private Guid _guid1 = Guid.Parse("0aa2511d-251a-4764-b374-4b5e259b6d9a");
private Guid _guid2 = Guid.Parse("0aa2511d-251a-4764-b374-4b5e259b6d9a");

[Benchmark]
public bool GuidEquals() => _guid1 == _guid2;
方法 執行時 平均值 比率 程式碼大小
GuidEquals .NET 6.0 2.119 ns 1.00 90 B
GuidEquals .NET 7.0 1.354 ns 0.64 78 B

dotnet/runtime#59857還改進了DateTime.Equals的一些開銷。DateTime是用一個單一的ulong _dateData欄位實現的,其中大部分位儲存了從1/1/0001 12:00am開始的ticks偏移量,每個tick是100納秒,並且前兩個位描述了DateTimeKind。因此,公共的Ticks屬性返回_dateData的值,但前兩位被遮蔽掉了,例如:_dateData & 0x3FFFFFFFFFFFFFFF。然後,平等運算子只是將一個DateTime的Ticks與其他DateTime的Ticks進行比較,這樣我們就可以有效地得到(dt1._dateData & 0x3FFFFFFFFFFF)==(dt2._dateData & 0x3FFFFFFFFFFF)。然而,作為一個微觀的最佳化,可以更有效地表達為((dt1._dateData ^ dt2._dateData) << 2) == 0。在這種微小的操作中很難衡量差異,但你可以簡單地從所涉及的指令數量中看出,在.NET 6上,這產生了。

; Program.DateTimeEquals()
       mov       rax,[rcx+8]
       mov       rdx,[rcx+10]
       mov       rcx,0FFFFFFFFFFFF
       and       rax,rcx
       and       rdx,rcx
       cmp       rax,rdx
       sete      al
       movzx     eax,al
       ret
; Total bytes of code 34

而在.NET 7上則產生。

; Program.DateTimeEquals()
       mov       rax,[rcx+8]
       mov       rdx,[rcx+10]
       xor       rax,rdx
       shl       rax,2
       sete      al
       movzx     eax,al
       ret
; Total bytes of code 22

所以我們得到的不是mov、and、and、cmp,而是xor和shl。

由於@SergeiPavlovdotnet/runtime#72712@SergeiPavlovdotnet/runtime#73277,對DateTime的其他操作也變得更有效率。在另一個.NET受益於最新研究進展的案例中,這些PR實現了Neri和Schneider的 "Euclidean Affine Functions and Applications to Calendar Algorithms "中的演算法,以改進DateTime.Day、DateTime.DayOfYear、DateTime.DayOfYear的演算法。 DateTime.DayOfYear、DateTime.Month和DateTime.Year,以及DateTime.GetDate()的內部助手,該助手被DateTime.AddMonths、Utf8Formatter.TryFormat(DateTime, ...)、DateTime.TryFormat和DateTime.ToString等一堆其他方法使用。

private DateTime _dt = DateTime.UtcNow;
private char[] _dest = new char[100];

[Benchmark] public int Day() => _dt.Day;
[Benchmark] public int Month() => _dt.Month;
[Benchmark] public int Year() => _dt.Year;
[Benchmark] public bool TryFormat() => _dt.TryFormat(_dest, out _, "r");
方法 執行時 平均值 比率
Day .NET 6.0 5.2080 ns 1.00
Day .NET 7.0 2.0549 ns 0.39
Month .NET 6.0 4.1186 ns 1.00
Month .NET 7.0 2.0945 ns 0.51
Year .NET 6.0 3.1422 ns 1.00
Year .NET 7.0 0.8200 ns 0.26
TryFormat .NET 6.0 27.6259 ns 1.00
TryFormat .NET 7.0 25.9848 ns 0.94

所以,我們已經談到了對一些型別的改進,但在這個版本中,圍繞原始型別的最重要的是 "通用數學",它幾乎影響了.NET中的每一個原始型別。這裡有一些重要的改進,有些改進已經醞釀了十幾年了。

6月份有一篇關於通用數學的優秀博文,所以我在這裡就不多說了。然而,在高層次上,現在有超過30個新的介面,利用新的C# 11靜態抽象介面方法功能,暴露了從指數函式到三角函式到標準數字運算子的廣泛操作,所有這些都可以透過泛型來實現,因此你可以編寫一個實現,對這些介面進行泛型操作,並將你的程式碼應用於實現介面的任何型別....NET 7中所有的數字型別都是如此(不僅包括基數,還包括例如BigInteger和Complex)。這個功能的預覽版,包括必要的執行時支援、語言語法、C#編譯器支援、通用介面和介面實現,都在.NET 6和C# 10中提供,但它不支援生產使用,你必須下載一個實驗性參考程式集才能獲得。在dotnet/runtime#65731中,所有這些支援都作為支援的功能進入了.NET 7。dotnet/runtime#66748dotnet/runtime#67453dotnet/runtime#69391dotnet/runtime#69582dotnet/runtime#69756dotnet/runtime#71800都根據.NET 6和.NET 7預覽中的使用反饋以及我們API審查小組的適當API審查(.NET中每個新的API都要經過這一過程)更新設計和實施。 dotnet/runtime#67714新增了對使用者定義的檢查運算子的支援,這是C# 11的一個新特性,它使運算子的非檢查和檢查變化都能被暴露出來,編譯器會根據檢查的上下文選擇正確的運算子。dotnet/runtime#68096還新增了對C# 11新的無符號右移運算子(>>)的支援。) dotnet/runtime#69651, dotnet/runtime#67939, dotnet/runtime#73274, dotnet/runtime#71033, dotnet/runtime#71010, dotnet/runtime#68251, dotnet/runtime#68217, 以及 dotnet/runtime#68094 都為各種操作增加了大量新的公共面積,所有這些都有高效的管理實現,在許多情況下都是基於開源的AMD數學庫

雖然這些支援都是主要針對外部消費者的,但核心庫確實在內部消耗了一些。你可以在dotnet/runtime#68226dotnet/runtime#68183這樣的PR中看到這些API是如何清理消耗程式碼的,甚至在保持效能的同時,使用介面來重複Enumerable.Sum/Average/Min/Max中大量的LINQ程式碼。這些方法對int、long、float、double和decimal有多個過載。GitHub上的差異總結講述了能夠刪除多少程式碼的故事。

另一個簡單的例子來自.NET 7中新的System.Formats.Tar庫,顧名思義,它用於讀寫多種tar檔案格式中的任何一種檔案。tar檔案格式包括八進位制的整數值,所以TarReader類需要解析八進位制值。這些值中有些是32位整數,有些是64位整數。與其有兩個獨立的ParseOctalAsUInt32和ParseOctalAsUInt64方法,dotnet/runtime#74281]將這些方法合併成一個ParseOctal,其中T : struct, INumber的約束。然後,該實現完全以T為單位,並可用於這些型別中的任何一種(加上任何其他符合約束條件的型別,如果有必要的話)。這個例子特別有趣的是ParseOctal方法包括使用checked,例如value = checked((value * octalFactor) + T.CreateTruncating(digit)); 。這只是因為C# 11包括上述對使用者定義的檢查運算子的支援,使通用數學介面能夠同時支援正常和檢查品種,例如IMultiplyOperators<,,>介面包含這些方法。

static abstract TResult operator *(TSelf left, TOther right);
static virtual TResult operator checked *(TSelf left, TOther right) => left * right;

而編譯器會根據上下文選擇合適的一個。

除了所有獲得這些介面的現有型別外,還有一些新的型別。 dotnet/runtime#69204增加了新的Int128和UInt128型別。由於這些型別實現了所有相關的通用數學介面,它們帶有大量的方法,每個都超過100個,所有這些都在託管程式碼中有效實現。在未來,我們的目標是透過JIT進一步最佳化其中的一些集合,並利用硬體加速的優勢。

來自@am11dotnet/runtime#63881對Math.Abs和Math.AbsF(絕對值)進行了遷移,來自@alexcovingtondotnet/runtime#56236對Math.ILogB和MathF.ILogB(base 2整數對數)進行了遷移。後者的實現是基於相同演算法的MUSL libc實現,除了提高效能(部分是透過避免管理程式碼和原生程式碼之間的轉換,部分是透過實際採用的演算法),它還可以從原生程式碼中刪除兩個不同的實現,一個來自coreclr端,一個來自mono端,從可維護性的角度來看,這總是一個不錯的勝利。

[Benchmark]
[Arguments(12345.6789)]
public int ILogB(double arg) => Math.ILogB(arg);
方法 執行時 引數 平均值 比率
ILogB .NET 6.0 12345.6789 4.056 ns 1.00
ILogB .NET 7.0 12345.6789 1.059 ns 0.26

其他數學運算也得到了不同程度的改進。Math{F}.Truncate在dotnet/runtime#65014中被@MichalPetryka改進,使其成為JIT的內在因素,這樣在Arm64上,JIT可以直接發出frintz指令。 dotnet/runtime#65584對Max和Min做了同樣的改進,這樣可以使用Arm特有的fmax和fmin指令。在dotnet/runtime#71567中,幾個BitConverter APIs也被變成了本徵,以便在一些通用數學場景中能夠更好地生成程式碼。

dotnet/runtime#55121來自@key-moon,它也改進了解析,不過是針對BigInteger,更確切地說,是針對非常非常大的BigIntegers。之前採用的將字串解析為BigInteger的演算法是O(N^2),其中N是數字的數量,雖然演算法複雜度比我們通常希望的要高,但它的常數開銷很低,所以對於合理大小的數值來說還是合理的。相比之下,有一種替代演算法可以在O(N * (log N)^2)時間內執行,但涉及的常數因素要高得多。這使得它只值得為真正的大數字而轉換。這就是這個PR所做的。它實現了替代演算法,並在輸入至少為20000位時切換到它(所以,是的,很大)。但是對於這麼大的數字,它有很大的區別。

private string _input = string.Concat(Enumerable.Repeat("1234567890", 100_000)); // "One miiilliiiion digits"

[Benchmark]
public BigInteger Parse() => BigInteger.Parse(_input);
方法 執行時 平均值 比率
Parse .NET 6.0 3.474 s 1.00
Parse .NET 7.0 1.672 s 0.48

同樣與BigInteger有關(而且不僅僅是針對真正的大資料),來自@saknodotnet/runtime#35565將BigInteger的大部分內部結構修改為基於跨度而非陣列。這反過來又使得大量使用堆疊分配和分片來避免分配開銷,同時還透過將一些程式碼從不安全的指標轉移到安全的跨度來提高可靠性和安全性。主要的效能影響在分配數量上是可見的,特別是與除法有關的操作。

private BigInteger _bi1 = BigInteger.Parse(string.Concat(Enumerable.Repeat("9876543210", 100)));
private BigInteger _bi2 = BigInteger.Parse(string.Concat(Enumerable.Repeat("1234567890", 100)));
private BigInteger _bi3 = BigInteger.Parse(string.Concat(Enumerable.Repeat("12345", 10)));

[Benchmark]
public BigInteger ModPow() => BigInteger.ModPow(_bi1, _bi2, _bi3);
方法 執行時 平均值 比率 已分配 分配比率
ModPow .NET 6.0 1.527 ms 1.00 706 B 1.00
ModPow .NET 7.0 1.589 ms 1.04 50 B 0.07

陣列、字串和跨度 (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(開啟該位)。我們可以在我們的範圍檢查中利用這一點,將我們的char 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,並返回每個輸入跨度開始時相同元素的數量。當你想知道兩個輸入的第一處不同時,這很有用。來自@gfoidldotnet/runtime#68210然後利用新的Vector128功能,提供了一個基本的向量化實現。因為它要比較兩個序列並尋找它們之間的第一個不同點,這個實現使用了一個巧妙的技巧,那就是用一個單一的方法來實現序列與位元組的比較。如果被比較的T是bitwise-equatable,並且沒有提供自定義的平等比較器,那麼它就把跨度中的引用重新解釋為位元組引用,並使用單一的共享實現。

另一組新的API是IndexOfAnyExcept和LastIndexOfAnyExcept方法,由dotnet/runtime#67941引入,並由dotnet/runtime#71146dotnet/runtime#71278用於各種附加呼叫站點。雖然有些拗口,但這些方法還是很方便的。它們的作用就像它們的名字一樣:IndexOf(T 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透過密切關注正在生成的彙編程式碼獲得了收益(在這種情況下,調整了IndexOf和IndexOfAny中用於Arm64的一些檢查以獲得更好的利用率),但這裡最大的改進是在一些地方新增了向量化而以前沒有使用過,或者向量化方案被徹底修改以獲得顯著收益。讓我們從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;
}

這是從古騰堡計劃中拉下《夏洛克-福爾摩斯歷險記》的文字,然後用IndexOf來計算文字中出現的 "夏洛克 "和 "初級 "的基準。在我的機器上,我得到這樣的結果。

方法 執行時 基準 平均值 比率
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",確切地說,在文件的593,836個字元中只有841個。在起始字元的密度只有0.1%的情況下,新的演算法並沒有帶來多少好處,因為現有的演算法只搜尋了第一個字元,幾乎抓住了所有可能的向量化收益,而且我們在搜尋'S'和'k'時確實付出了一些開銷,而之前我們只搜尋了'S'。相比之下,檔案中有54,614個'e'字元,幾乎佔到原始檔的10%。在這種情況下,.NET 7比.NET 6快了20倍,在.NET 7上計算所有的'e'需要53us,而在.NET 6上則需要1084us。在這種情況下,新方案產生了巨大的收益,透過對'e'和特定距離的'y'進行向量搜尋,這種組合的頻率低得多。這是其中一種情況,儘管我們可以看到一些特定的輸入有小的退步,但總體上還是有巨大的觀察收益。

另一個顯著改變所採用的演算法的例子是dotnet/runtime#67758,它使某種程度的向量化被應用到IndexOf("...", StringComparison.OrdinalIgnoreCase)。以前,這個操作是透過一個相當典型的子串搜尋來實現的,在輸入字串的每個位置做一個內迴圈來比較目標字串,除了對每個字元執行ToUpper,以便以不區分大小寫的方式進行。現在有了這個基於Regex以前使用的方法的PR,如果目標字串以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)。)

另一個例子來自dotnet/runtime#67492,來自@gfoidl。它更新了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

dotnet/runtime#60974來自@alexcovington,擴大了 IndexOf 的影響。在此PR之前,IndexOf是針對一個和兩個位元組大小的原始型別的向量化,但此PR也將其擴充套件到四個和八個位元組大小的原始型別。與其他大多數向量實現一樣,它檢查T是否是位數相等的,這對向量化很重要,因為它只看記憶體中的位數,而不注意可能被定義在該型別上的任何等價實現。在今天的實踐中,這意味著這隻限於執行時對其有深入瞭解的少數型別(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

原文連結

Performance Improvements in .NET 7

知識共享許可協議

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

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

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

相關文章