▲
回複“1”擷取開發者路線圖
學習分享 丨作者 / 鄭 子 銘
這是DotNet NB 公衆号的第213篇原創文章
原文 | 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在與able和EqualityComparer.Default的配合下也得到了提升。EqualityComparer.Default緩存了一個從所有對Default的通路中傳回的EqualityComparer執行個體的單子執行個體。該單例根據相關的T進行初始化,實作者可以從衆多不同的内部實作中進行選擇,例如專門用于位元組的ByteArrayComparer,用于實作IComparable的T的GenericEqualityComparer,等等。對于任意類型來說,萬能的是一個ObjectEqualityComparer。dotnet/runtime#68077修複了這一問題,它確定了able enums被映射到(現有的)able的專門比較器上,并簡單地調整了其定義以確定它能與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的平等操作也變快了,這要感謝@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,其中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進一步優化其中的一些集合,并利用硬體加速的優勢。
來自@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執行個體或一個Span和一個ReadOnlySpan,以及一個可選的IEqualityComparer,并傳回每個輸入跨度開始時相同元素的數量。當你想知道兩個輸入的第一處不同時,這很有用。來自@gfoidl的dotnet/runtime#68210然後利用新的Vector128功能,提供了一個基本的矢量化實作。因為它要比較兩個序列并尋找它們之間的第一個不同點,這個實作使用了一個巧妙的技巧,那就是用一個單一的方法來實作序列與位元組的比較。如果被比較的T是bitwise-equatable,并且沒有提供自定義的平等比較器,那麼它就把跨度中的引用重新解釋為位元組引用,并使用單一的共享實作。
另一組新的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
推薦閱讀:【譯】.NET 7 中的性能改進(八)【譯】.NET 7 中的性能改進(七)
【譯】.NET 7 中的性能改進(六)
.NET 8 Preview 1 中新增的 Random 方法
一個.NET開發的小而美的通用業務型背景管理系統【譯】.NET 7 中的性能改進(五)
點選下方卡片關注DotNet NB
一起交流學習
▲ 點選上方卡片關注DotNet NB,一起交流學習
請在公衆号背景
回複 【路線圖】擷取.NET 2021開發者路線圖回複 【原創内容】擷取公衆号原創内容回複 【峰會視訊】擷取.NET Conf開發者大會視訊回複 【個人簡介】擷取作者個人簡介回複 【年終總結】擷取作者年終總結回複 【加群】加入DotNet NB 交流學習群
和我一起,交流學習,分享心得。