是什么让.NET7的Min和Max方法性能暴增了45倍?

打印 上一主题 下一主题

主题 566|帖子 566|积分 1698

简介

在之前的一篇文章.NET性能系列文章一:.NET7的性能改进中我们聊到Linq中的Min()和Max()方法.NET7比.NET6有高达45倍的性能提升,当时Benchmark代码和结果如下所示:
  1. [Params(1000)]
  2. public int Length { get; set; }
  3. private int[] arr;
  4. [GlobalSetup]
  5. public void GlobalSetup() => arr = Enumerable.Range(0, Length).ToArray();
  6. [Benchmark]
  7. public int Min() => arr.Min();
  8. [Benchmark]
  9. public int Max() => arr.Max();
复制代码
方法运行时数组长度平均值比率分配Min10003,494.08 ns53.2432 BMin100065.64 ns1.00-Max10003,025.41 ns45.9232 BMax100065.93 ns1.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数组是否相等),它是CPU的特殊指令,使用它可以大幅度的增强运算性能,我猜这就是性能提升的原因。
我们可以看到在上面只为int[]做了优化,然后继续浏览了Stephen大佬的其它几个PR,Stephen大佬将代码抽象了一下,使用了泛型的特性,然后顺便为其它的基本值类型都做了优化。能享受到性能提升的有byte sbyte ushort short uint int ulong long nuint nint。

所以我们以最后一个提交为例,看看到底是用了什么SIMD指令,什么样的方法来提升的性能。抽取出来的核心代码如下所示:
  1. private static T MinMaxInteger<T, TMinMax>(this IEnumerable<T> source)
  2.     where T : struct, IBinaryInteger<T>
  3.     where TMinMax : IMinMaxCalc<T>
  4. {
  5.     T value;
  6.     if (source.TryGetSpan(out ReadOnlySpan<T> span))
  7.     {
  8.         if (span.IsEmpty)
  9.         {
  10.             ThrowHelper.ThrowNoElementsException();
  11.         }
  12.         // 判断当前平台是否支持使用Vector-128 或者 总数据长度是否小于128位
  13.         // Vector128是指硬件支持同时计算128位二进制数据
  14.         if (!Vector128.IsHardwareAccelerated || span.Length < Vector128<T>.Count)
  15.         {
  16.             // 进入到此路径,说明最基础的Vector128都不支持,那么直接使用for循环来比较
  17.             value = span[0];
  18.             for (int i = 1; i < span.Length; i++)
  19.             {
  20.                 if (TMinMax.Compare(span[i], value))
  21.                 {
  22.                     value = span[i];
  23.                 }
  24.             }
  25.         }
  26.         // 判断当前平台是否支持使用Vector-256 或者 总数据长度是否小于256位
  27.         // Vector256是指硬件支持同时计算256位二进制数据
  28.         else if (!Vector256.IsHardwareAccelerated || span.Length < Vector256<T>.Count)
  29.         {
  30.             // 进入到此路径,说明支持Vector128但不支持Vector256
  31.             // 那么进入128位的向量化的比较
  32.             // 获取当前数组的首地址,也就是指向第0个元素
  33.             ref T current = ref MemoryMarshal.GetReference(span);
  34.             // 获取Vector128能使用的最后地址,因为整个数组占用的bit位有可能不能被128整除
  35.             // 也就是说最后的尾巴不够128位让CPU跑一次,那么就直接最后往前数128位,让CPU能完整的跑完
  36.             ref T lastVectorStart = ref Unsafe.Add(ref current, span.Length - Vector128<T>.Count);
  37.             // 从内存首地址加载0-127bit数据,作为最大值的基准
  38.             Vector128<T> best = Vector128.LoadUnsafe(ref current);
  39.             // 计算下一个的位置,也就是偏移128位
  40.             current = ref Unsafe.Add(ref current, Vector128<T>.Count);
  41.             // 循环比较 确保地址小于最后地址
  42.             while (Unsafe.IsAddressLessThan(ref current, ref lastVectorStart))
  43.             {
  44.                 // 此时TMinMax.Compare重载代码 => Vector128.Max(left, right);
  45.                 // Vector128.Max 会根据类型一一比较,每x位最大的返回,
  46.                 // 比如int就是每32位比较,详情可以看我后文的解析
  47.                 best = TMinMax.Compare(best, Vector128.LoadUnsafe(ref current));
  48.                 current = ref Unsafe.Add(ref current, Vector128<T>.Count);
  49.             }
  50.             // 最后一组Vector128进行比较
  51.             best = TMinMax.Compare(best, Vector128.LoadUnsafe(ref lastVectorStart));
  52.             // 由于Vector128最后的结果是128位,比如我们类型是int32,那么最后的结果就有
  53.             // 4个int32元素,我们还需要从这4个int32元素中找到最大的
  54.             value = best[0];
  55.             for (int i = 1; i < Vector128<T>.Count; i++)
  56.             {
  57.                 // 这里 TMinMax.Compare就是简单的大小于比较
  58.                 // left > right
  59.                 if (TMinMax.Compare(best[i], value))
  60.                 {
  61.                     value = best[i];
  62.                 }
  63.             }
  64.         }
  65.         else
  66.         {
  67.             // Vector256执行流程和Vector128一致
  68.             // 只是它能一次性判断256位,举个例子就是一个指令8个int32
  69.             ref T current = ref MemoryMarshal.GetReference(span);
  70.             ref T lastVectorStart = ref Unsafe.Add(ref current, span.Length - Vector256<T>.Count);
  71.             Vector256<T> best = Vector256.LoadUnsafe(ref current);
  72.             current = ref Unsafe.Add(ref current, Vector256<T>.Count);
  73.             while (Unsafe.IsAddressLessThan(ref current, ref lastVectorStart))
  74.             {
  75.                 best = TMinMax.Compare(best, Vector256.LoadUnsafe(ref current));
  76.                 current = ref Unsafe.Add(ref current, Vector256<T>.Count);
  77.             }
  78.             best = TMinMax.Compare(best, Vector256.LoadUnsafe(ref lastVectorStart));
  79.             value = best[0];
  80.             for (int i = 1; i < Vector256<T>.Count; i++)
  81.             {
  82.                 if (TMinMax.Compare(best[i], value))
  83.                 {
  84.                     value = best[i];
  85.                 }
  86.             }
  87.         }
  88.     }
  89.     else
  90.     {
  91.         // 如果不是基本类型的数组,那么进入迭代器,使用原始方法比较
  92.         using (IEnumerator<T> e = source.GetEnumerator())
  93.         {
  94.             if (!e.MoveNext())
  95.             {
  96.                 ThrowHelper.ThrowNoElementsException();
  97.             }
  98.             value = e.Current;
  99.             while (e.MoveNext())
  100.             {
  101.                 T x = e.Current;
  102.                 if (TMinMax.Compare(x, value))
  103.                 {
  104.                     value = x;
  105.                 }
  106.             }
  107.         }
  108.     }
  109.     return value;
  110. }
复制代码
以上就是代码的解析,相信很多人疑惑的地方就是Vector128.Max做了什么,我们可以构造一个代码,让大家简单的看出来发生了什么。代码和运行结果如下所示:
  1. // 定义一个数组
  2. var array = new int[] { 4, 3, 2, 1, 1, 2, 3, 4 };
  3. // 拿到数组首地址指针
  4. ref int current = ref MemoryMarshal.GetReference(array.AsSpan());
  5. // 从首地址加载128位数据,上面是int32
  6. // 所以x = 4, 3, 2, 1
  7. var x = Vector128.LoadUnsafe(ref current);
  8. // 偏移128位以后,继续加载128位数据
  9. // 所以y = 1, 2, 3, 4
  10. var y = Vector128.LoadUnsafe(ref Unsafe.Add(ref current, Vector128<int>.Count));
  11. // 使用Vector128.Max进行计算
  12. var result = Vector128.Max(x, y);
  13. // 打印输出结果
  14. x.Dump();
  15. y.Dump();
  16. result.Dump();
复制代码

从运行的结果可以看到,result中保存的是x和y对应位置的最大值,这样是不是就觉得清晰明了,Stephe大佬上文的代码就是做了这样一个操作。
同样,如果我们把int32换成int64,也就是long类型,由于一个元素占用64位,所以一次只能加载2个int64元素比较最大值,得出对应位置的最大值:

最后使用下面的for循环代码,从result中找到最大的那个int32元素,从我们上文的案例中就是4,结果和代码如下所示:
  1. var value = result[0];
  2. for (int i = 1; i < Vector128<int>.Count; i++)
  3. {
  4.         if (value < result[i])
  5.         {
  6.                 value = result[i];
  7.         }
  8. }
复制代码

要注意的是,为了演示方便我这里数组bit长度刚好是128倍数,实际情况中需要考虑不是128倍数的场景。
总结

答案显而易见,试.NET7中Min()和Max()方法性能暴增45倍的原因就是Stephe大佬对基本几个连续的值类型比较做了SIMD优化,而这样的优化在本次的.NET7版本中有非常多,后面有时间带大家一起看看SIMD又是如何提升其它方面的性能的。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

乌市泽哥

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表