簡介
在之前的一篇文章.NET性能系列文章一:.NET7的性能改進中我們聊到Linq
中的Min()
和Max()
方法.NET7比.NET6有高達45倍的性能提升,當時Benchmark代碼和結果如下所示:
[Params(1000)]
public?int?Length?{?get;?set;?}private?int[]?arr;[GlobalSetup]
public?void?GlobalSetup()?=>?arr?=?Enumerable.Range(0,?Length).ToArray();[Benchmark]
public?int?Min()?=>?arr.Min();[Benchmark]
public?int?Max()?=>?arr.Max();
方法 | 運行時 | 數組長度 | 平均值 | 比率 | 分配 |
---|---|---|---|---|---|
Min | ![]() | 1000 | 3,494.08 ns | 53.24 | 32 B |
Min | ![]() | 1000 | 65.64 ns | 1.00 | - |
Max | ![]() | 1000 | 3,025.41 ns | 45.92 | 32 B |
Max | ![]() | 1000 | 65.93 ns | 1.00 | - |

可以看到有高達45倍的性能提升,那就有小伙伴比較疑惑,在.NET7中到底是做了什么讓它有如此大的性能提升?所以本文就通過.NET7中的一些pr帶大家一起探索下.NET7的Min()
和Max()
方法是如何變快的。
探索
首先我們打開.NET Runtime的倉庫,應該沒有人不會知道倉庫的地址吧?里面包含了.NET運行時所有的代碼,包括CLR和BCL庫。地址如下所示:https://github.com/dotnet/runtime然后我們熟練的根據命名空間
System.Linq
找到Linq
所在的文件夾位置,如下所示:可以看到很多
Linq
相關的方法都在這個文件夾內,讓我們先來找一找Max()
方法所對應的類。就是下方所示,我們可以看到剛好異步小王子Stephen Toub大佬提交了一個優化代碼。然后我們點擊
History
查看這個類的提交歷史,我們發現Stephen大佬在今年多次提交代碼,都是優化其性能。找到Stephen大佬的第一個提交,我們發現在
Max
的代碼中,多了一個特殊的路徑,如果數據類型為int[]
,那么就走單獨的一個方法重載,并在這個重載中啟用了SIMD
向量化,代碼如下所示:SIMD向量化在我之前的多篇文章中都有提到(如:.NET如何快速比較兩個byte數組是否相等[1]),它是CPU的特殊指令,使用它可以大幅度的增強運算性能,我猜這就是性能提升的原因。
我們可以看到在上面只為int[]
做了優化,然后繼續瀏覽了Stephen大佬的其它幾個PR,Stephen大佬將代碼抽象了一下,使用了泛型的特性,然后順便為其它的基本值類型都做了優化。能享受到性能提升的有byte sbyte ushort short uint int ulong long nuint nint
。
所以我們以最后一個提交為例,看看到底是用了什么SIMD指令,什么樣的方法來提升的性能。抽取出來的核心代碼如下所示:
private?static?T?MinMaxInteger<T,?TMinMax>(this?IEnumerable<T>?source)where?T?:?struct,?IBinaryInteger<T>where?TMinMax?:?IMinMaxCalc<T>
{T?value;if?(source.TryGetSpan(out?ReadOnlySpan<T>?span)){if?(span.IsEmpty){ThrowHelper.ThrowNoElementsException();}//?判斷當前平臺是否支持使用Vector-128?或者?總數據長度是否小于128位//?Vector128是指硬件支持同時計算128位二進制數據if?(!Vector128.IsHardwareAccelerated?||?span.Length?<?Vector128<T>.Count){//?進入到此路徑,說明最基礎的Vector128都不支持,那么直接使用for循環來比較value?=?span[0];for?(int?i?=?1;?i?<?span.Length;?i++){if?(TMinMax.Compare(span[i],?value)){value?=?span[i];}}}//?判斷當前平臺是否支持使用Vector-256?或者?總數據長度是否小于256位//?Vector256是指硬件支持同時計算256位二進制數據else?if?(!Vector256.IsHardwareAccelerated?||?span.Length?<?Vector256<T>.Count){//?進入到此路徑,說明支持Vector128但不支持Vector256//?那么進入128位的向量化的比較//?獲取當前數組的首地址,也就是指向第0個元素ref?T?current?=?ref?MemoryMarshal.GetReference(span);//?獲取Vector128能使用的最后地址,因為整個數組占用的bit位有可能不能被128整除//?也就是說最后的尾巴不夠128位讓CPU跑一次,那么就直接最后往前數128位,讓CPU能完整的跑完ref?T?lastVectorStart?=?ref?Unsafe.Add(ref?current,?span.Length?-?Vector128<T>.Count);//?從內存首地址加載0-127bit數據,作為最大值的基準Vector128<T>?best?=?Vector128.LoadUnsafe(ref?current);//?計算下一個的位置,也就是偏移128位current?=?ref?Unsafe.Add(ref?current,?Vector128<T>.Count);//?循環比較?確保地址小于最后地址while?(Unsafe.IsAddressLessThan(ref?current,?ref?lastVectorStart)){//?此時TMinMax.Compare重載代碼?=>?Vector128.Max(left,?right);//?Vector128.Max?會根據類型一一比較,每x位最大的返回,//?比如int就是每32位比較,詳情可以看我后文的解析best?=?TMinMax.Compare(best,?Vector128.LoadUnsafe(ref?current));current?=?ref?Unsafe.Add(ref?current,?Vector128<T>.Count);}//?最后一組Vector128進行比較best?=?TMinMax.Compare(best,?Vector128.LoadUnsafe(ref?lastVectorStart));//?由于Vector128最后的結果是128位,比如我們類型是int32,那么最后的結果就有//?4個int32元素,我們還需要從這4個int32元素中找到最大的value?=?best[0];for?(int?i?=?1;?i?<?Vector128<T>.Count;?i++){//?這里?TMinMax.Compare就是簡單的大小于比較//?left?>?rightif?(TMinMax.Compare(best[i],?value)){value?=?best[i];}}}else{//?Vector256執行流程和Vector128一致//?只是它能一次性判斷256位,舉個例子就是一個指令8個int32ref?T?current?=?ref?MemoryMarshal.GetReference(span);ref?T?lastVectorStart?=?ref?Unsafe.Add(ref?current,?span.Length?-?Vector256<T>.Count);Vector256<T>?best?=?Vector256.LoadUnsafe(ref?current);current?=?ref?Unsafe.Add(ref?current,?Vector256<T>.Count);while?(Unsafe.IsAddressLessThan(ref?current,?ref?lastVectorStart)){best?=?TMinMax.Compare(best,?Vector256.LoadUnsafe(ref?current));current?=?ref?Unsafe.Add(ref?current,?Vector256<T>.Count);}best?=?TMinMax.Compare(best,?Vector256.LoadUnsafe(ref?lastVectorStart));value?=?best[0];for?(int?i?=?1;?i?<?Vector256<T>.Count;?i++){if?(TMinMax.Compare(best[i],?value)){value?=?best[i];}}}}else{//?如果不是基本類型的數組,那么進入迭代器,使用原始方法比較using?(IEnumerator<T>?e?=?source.GetEnumerator()){if?(!e.MoveNext()){ThrowHelper.ThrowNoElementsException();}value?=?e.Current;while?(e.MoveNext()){T?x?=?e.Current;if?(TMinMax.Compare(x,?value)){value?=?x;}}}}return?value;
}
以上就是代碼的解析,相信很多人疑惑的地方就是Vector128.Max
做了什么,我們可以構造一個代碼,讓大家簡單的看出來發生了什么。代碼和運行結果如下所示:
//?定義一個數組
var?array?=?new?int[]?{?4,?3,?2,?1,?1,?2,?3,?4?};//?拿到數組首地址指針
ref?int?current?=?ref?MemoryMarshal.GetReference(array.AsSpan());//?從首地址加載128位數據,上面是int32
//?所以x?=?4,?3,?2,?1
var?x?=?Vector128.LoadUnsafe(ref?current);//?偏移128位以后,繼續加載128位數據
//?所以y?=?1,?2,?3,?4
var?y?=?Vector128.LoadUnsafe(ref?Unsafe.Add(ref?current,?Vector128<int>.Count));//?使用Vector128.Max進行計算
var?result?=?Vector128.Max(x,?y);//?打印輸出結果
x.Dump();
y.Dump();
result.Dump();
從運行的結果可以看到,
result
中保存的是x
和y
對應位置的最大值,這樣是不是就覺得清晰明了,Stephe大佬上文的代碼就是做了這樣一個操作。
同樣,如果我們把int32換成int64,也就是long類型,由于一個元素占用64位,所以一次只能加載2個int64元素比較最大值,得出對應位置的最大值:
最后使用下面的for循環代碼,從result
中找到最大的那個int32
元素,從我們上文的案例中就是4,結果和代碼如下所示:
var?value?=?result[0];
for?(int?i?=?1;?i?<?Vector128<int>.Count;?i++)
{if?(value?<?result[i]){value?=?result[i];}
}
要注意的是,為了演示方便我這里數組bit長度剛好是128倍數,實際情況中需要考慮不是128倍數的場景。
總結
答案顯而易見,試.NET7中Min()
和Max()
方法性能暴增45倍的原因就是Stephe大佬對基本幾個連續的值類型比較做了SIMD優化,而這樣的優化在本次的.NET7版本中有非常多,后面有時間帶大家一起看看SIMD又是如何提升其它方面的性能的。
參考資料
[1]
.NET如何快速比較兩個byte數組是否相等: https://www.cnblogs.com/InCerry/p/dotnet-compare-two-byte-arrays.html