【譯】.NET 7 中的性能改進(九)
原文 | Stephen Toub
翻譯 | 鄭子銘
原始類型和數值 (Primitive Types and Numerics)
我們已經看過了代碼生成和GC,線程和矢量化,互操作......讓我們把注意力轉向系統中的一些基本類型。像int、bool和double這樣的基本類型,像Guid和DateTime這樣的核心類型,它們構成了構建一切的支柱,每一個版本都能看到這些類型的改進,這讓人興奮。
來自@CarlVerret的dotnet/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
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的平等操作也變快了,這要感謝@madelson的dotnet/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。
由于@SergeiPavlov的dotnet/runtime#72712和@SergeiPavlov的dotnet/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#66748、dotnet/runtime#67453、dotnet/runtime#69391、dotnet/runtime#69582、dotnet/runtime#69756、dotnet/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#68226和dotnet/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
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進一步優化其中的一些集合,并利用硬件加速的優勢。
來自@am11的dotnet/runtime#63881對Math.Abs和Math.AbsF(絕對值)進行了遷移,來自@alexcovington的dotnet/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有關(而且不僅僅是針對真正的大數據),來自@sakno的dotnet/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
另一組新的API是IndexOfAnyExcept和LastIndexOfAnyExcept方法,由dotnet/runtime#67941引入,并由dotnet/runtime#71146和dotnet/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.rzrgm.cn/MingsonZheng/ ),不得用于商業目的,基于本文修改后的作品務必以相同的許可發布。
如有任何疑問,請與我聯系 (MingsonZheng@outlook.com)


浙公網安備 33010602011771號