.NET / C#中的SIMD概述

快速浏览.NET Framework和.NET Core中的算法矢量化功能。 本文适用于对这些技术一无所知的人。 我还将说明,.NET实际上在本机开发方面并不落后于“真正的编译”语言。


我才刚刚开始学习矢量化技术。 因此,如果社区成员发现明显的错误或对所描述的算法提出改进建议,我将不胜感激。


一些历史


SIMD出现在2015年的.NET Framework 4.6中。那时,添加了Matrix3x2,Matrix4x4,Plane,Quaternion,Vector2,Vector3和Vector4类型。 他们允许矢量化计算。 接下来是Vector <T>类型,它提供了更多的机会来对算法进行矢量化。 但是,许多程序员仍然不满意,因为这些类型限制了编码人员的想法流,并且不允许在现代处理器中使用SIMD指令的全部功能。 现在,在.NET Core 3.0 Preview中,我们有了System.Runtime.Intrinsics命名空间,该命名空间为指令选择提供了很大的自由度。 要获得最高速度,您需要使用RyuJit并采用x64汇编或关闭Prefer 32-bit并选择AnyCPU汇编。 我在Intel Core i7-6700 3.40 GHz(Skylake)CPU计算机上运行了所有基准测试。


汇总数组元素


我决定从经典任务开始,如果涉及矢量化,通常会首先执行。 它涉及查找数组元素的总和。 让我们编写此任务的四个实现以汇总Array的元素。


最明显的实现:


public int Naive() { int result = 0; foreach (int i in Array) { result += i; } return result; } 

基于LINQ的实现:


 public long LINQ() => Array.Aggregate<int, long>(0, (current, i) => current + i); 

基于System.Numerics中的向量的实现:


 public int Vectors() { int vectorSize = Vector<int>.Count; var accVector = Vector<int>.Zero; int i; var array = Array; for (i = 0; i <= array.Length - vectorSize; i += vectorSize) { var v = new Vector<int>(array, i); accVector = Vector.Add(accVector, v); } int result = Vector.Dot(accVector, Vector<int>.One); for (; i < array.Length; i++) { result += array[i]; } return result; } 

基于System.Runtime.Intrinsics命名空间中的代码的实现:


 public unsafe int Intrinsics() { int vectorSize = 256 / 8 / 4; var accVector = Vector256<int>.Zero; int i; var array = Array; fixed (int* ptr = array) { for (i = 0; i <= array.Length - vectorSize; i += vectorSize) { var v = Avx2.LoadVector256(ptr + i); accVector = Avx2.Add(accVector, v); } } int result = 0; var temp = stackalloc int[vectorSize]; Avx2.Store(temp, accVector); for (int j = 0; j < vectorSize; j++) { result += temp[j]; } for (; i < array.Length; i++) { result += array[i]; } return result; } 

我在计算机上对这4种方法进行了基准测试,并得到以下结果:


方法ItemsCount均值失误标准差比例
天真103.531 ns0.0336 ns0.0314纳秒1.00
LINQ1076.925 ns0.4166纳秒0.3897 ns21.79
向量102.750纳秒0.0210纳秒0.0196 ns0.78
本征106.513 ns0.0623 ns0.0582 ns1.84
天真10047.982 ns0.3975纳秒0.3524 ns1.00
LINQ100590.414 ns3.8808 ns3.4402纳秒12.31
向量10010.122 ns0.0747 ns0.0699 ns0.21
本征10014.277 ns0.0566 ns0.0529 ns0.30
天真1000569.910 ns2.8297 ns2.6469 ns1.00
LINQ10005,658.570 ns31.7465 ns29.6957 ns9.93
向量100079.598 ns0.3498 ns0.3272纳秒0.14
本征100066.970 ns0.3937纳秒0.3682纳秒0.12
天真10,0005,637.571 ns37.5050 ns29.2814 ns1.00
LINQ10,00056,498.987 ns294.8776 ns275.8287 ns10.02
向量10,000772.900 ns2.6802纳秒2.5070 ns0.14
本征10,000579.152 ns2.8371 ns2.6538 ns0.10
天真100,00056,352.865 ns230.7916 ns215.8826 ns1.00
LINQ100,000562,610.571 ns3,775.7631 ns3,152.9332 ns9.98
向量100,0008,389.647 ns165.9590 ns227.1666 ns0.15
本征100,0007,261.334 ns89.6468 ns69.9903 ns0.13

显然,使用向量和内在函数的解决方案比明显的基于LINQ的解决方案要快得多。 现在我们需要弄清楚这两种方法中发生了什么。


让我们更仔细地考虑Vectors方法:


向量
 public int Vectors() { int vectorSize = Vector<int>.Count; var accVector = Vector<int>.Zero; int i; var array = Array; for (i = 0; i <= array.Length - vectorSize; i += vectorSize) { var v = new Vector<int>(array, i); accVector = Vector.Add(accVector, v); } int result = Vector.Dot(accVector, Vector<int>.One); for (; i < array.Length; i++) { result += array[i]; } return result; } 

  • int vectorSize =向量<int> .Count; -我们可以在向量中放置的4字节数字的数量。 如果使用硬件加速,则此值显示我们可以在一个SIMD寄存器中放入多少个4字节数字。 实际上,它显示了可以同时处理此类型的元素的数量。
  • accVector是累积函数结果的向量;
  • var v = new Vector <int>(array,i); -从i索引开始,将数组中的数据加载到新的v向量中。 数据的vectorSize将被完全加载;
  • accVector = Vector.Add(accVector,v); -将两个向量相加。
    例如,数组中有8个数字:{0、1、2、3、4、5、6、7}和vectorSize == 4。
    然后在第一个循环迭代期间,accVector = {0,0,0,0},v = {0,1,2,3},加法后,accVector将保持:{0,0,0,0} + {0,1 ,2,3} = {0,1,2,3}。
    在第二次迭代中,v = {4,5,6,7},加法后accVector = {0,1,2,3} + {4,5,6,7} = {4,6,8,10}。
  • 现在我们只需要获取所有向量元素的总和即可。 为此,我们可以使用标量乘法乘以一个填充有一个的向量:int result = Vector.Dot(accVector,Vector <int> .One);
    然后得到:{4,6,8,10} * {1,1,1,1} = 4 * 1 + 6 * 1 + 8 * 1 + 10 * 1 = 28。
  • 如有必要,那些不适合最后一个向量的数字将在末尾求和。

让我们看一下内部代码:


本征
 public unsafe int Intrinsics() { int vectorSize = 256 / 8 / 4; var accVector = Vector256<int>.Zero; int i; var array = Array; fixed (int* ptr = array) { for (i = 0; i <= array.Length - vectorSize; i += vectorSize) { var v = Avx2.LoadVector256(ptr + i); accVector = Avx2.Add(accVector, v); } } int result = 0; var temp = stackalloc int[vectorSize]; Avx2.Store(temp, accVector); for (int j = 0; j < vectorSize; j++) { result += temp[j]; } for (; i < array.Length; i++) { result += array[i]; } return result; } 

我们可以看到它就像Vectors,但有一个例外:


  • vectorSize由常量指定。 这是因为此方法明确使用了与256位寄存器一起操作的Avx2指令。 实际的应用程序应包括检查当前处理器是否支持Avx2。 如果不是,则应调用另一个代码。 看起来像这样:
     if (Avx2.IsSupported) { DoThingsForAvx2(); } else if (Avx.IsSupported) { DoThingsForAvx(); } ... else if (Sse2.IsSupported) { DoThingsForSse2(); } ... 
  • var accVector = Vector256 <int> .Zero; accVector声明为填充零的256位向量。
  • 固定(int * ptr = Array)-指向数组的指针放在ptr中。
  • 接下来是与Vectors中相同的操作:将数据加载到vector中并添加两个vector。
  • 向量元素的求和使用以下方法:
    • 在堆栈上创建一个数组:var temp = stackalloc int [vectorSize];
    • 将向量加载到此数组中:Avx2.Store(temp,accVector);
    • 在循环中对数组元素求和。
  • 接下来,汇总不适合最后一个向量的元素。

比较两个数组


我们需要比较两个字节数组。 正是这一任务使我在.NET中学习SIMD。 再次,让我们编写几种基准测试方法并比较两个数组:ArrayA和ArrayB。


最明显的解决方案:


 public bool Naive() { for (int i = 0; i < ArrayA.Length; i++) { if (ArrayA[i] != ArrayB[i]) return false; } return true; } 

基于LINQ的解决方案:


 public bool LINQ() => ArrayA.SequenceEqual(ArrayB); 

基于MemCmp功能的解决方案:


 [DllImport("msvcrt.dll", CallingConvention = CallingConvention.Cdecl)] static extern int memcmp(byte[] b1, byte[] b2, long count); public bool MemCmp() => memcmp(ArrayA, ArrayB, ArrayA.Length) == 0; 

基于System.Numerics中的向量的解决方案:


 public bool Vectors() { int vectorSize = Vector<byte>.Count; int i = 0; for (; i <= ArrayA.Length - vectorSize; i += vectorSize) { var va = new Vector<byte>(ArrayA, i); var vb = new Vector<byte>(ArrayB, i); if (!Vector.EqualsAll(va, vb)) { return false; } } for (; i < ArrayA.Length; i++) { if (ArrayA[i] != ArrayB[i]) return false; } return true; } 

基于内在的解决方案:


 public unsafe bool Intrinsics() { int vectorSize = 256 / 8; int i = 0; const int equalsMask = unchecked((int) (0b1111_1111_1111_1111_1111_1111_1111_1111)); fixed (byte* ptrA = ArrayA) fixed (byte* ptrB = ArrayB) { for (; i <= ArrayA.Length - vectorSize; i += vectorSize) { var va = Avx2.LoadVector256(ptrA + i); var vb = Avx2.LoadVector256(ptrB + i); var areEqual = Avx2.CompareEqual(va, vb); if (Avx2.MoveMask(areEqual) != equalsMask) { return false; } } for (; i < ArrayA.Length; i++) { if (ArrayA[i] != ArrayB[i]) return false; } return true; } } 

在计算机上运行基准测试的结果:


方法ItemsCount均值失误标准差比例
天真10,0007,033.8 ns50.636 ns47.365 ns1.00
LINQ10,00064,841.4 ns289.157 ns270.478 ns9.22
向量10,000504.0 ns2.406纳秒2.251纳秒0.07
Memcmp10,000368.1 ns2.637 ns2.466纳秒0.05
本征10,000283.6 ns1.135纳秒1.061纳秒0.04
天真100,00085,214.4 ns903.868 ns845.478 ns1.00
LINQ100,000702,279.4 ns2,846.609 ns2,662.720 ns8.24
向量100,0005,179.2 ns45.337 ns42.409 ns0.06
Memcmp100,0004,510.5 ns24.292 ns22.723 ns0.05
本征100,0002,957.0 ns11.452 ns10.712 ns0.03
天真1,000,000844,006.1 ns3,552.478 ns3,322.990 ns1.00
LINQ1,000,0006,483,079.3 ns42,641.040 ns39,886.455 ns7.68
向量1,000,00054,180.1 ns357.258 ns334.180 ns0.06
Memcmp1,000,00049,480.1 ns515.675 ns457.133 ns0.06
本征1,000,00036,633.9 ns680.525 ns636.564 ns0.04

我猜这些方法的代码很清楚,除了Intrinsics中的两行:


 var areEqual = Avx2.CompareEqual(va, vb); if (Avx2.MoveMask(areEqual) != equalsMask) { return false; } 

在第一行中,比较两个向量的相等性,并将结果保存在areEqual向量中,如果va和vb中的对应元素相等,则将特定位置元素中的所有位都设置为1。 因此,事实证明,如果字节向量va和vb相等,则areEquals中的所有元素都应等于255(11111111b)。 由于Avx2.CompareEqual是_mm256_cmpeq_epi8的包装,我们可以访问Intel网站并查看此操作的伪代码:
MoveMask方法从矢量产生32位数字。 向量中每32个单字节元素的高位是MoveMask结果中的位的值。 伪代码在此处可用。


因此,如果va和vb中的某些字节不匹配,则areEqual中的相应字节将为0。因此,这些字节的高位也将为0。 这意味着Avx2.MoveMask响应中的相应位也将为0,而areEqual将不等于equalsMask。


让我们看一个示例,假设矢量长度为8字节(以减少写入):


  • 令va = {100,10,20,30,100,40,50,100},vb = {100,20,10,30,100,40,80,90}。
  • 然后areEquals将为{255,0,0,255,255,255,0,0}。
  • MoveMask方法将返回10011100b,应将其与11111111b掩码进行比较。 由于这些掩码不相等,因此va和vb向量也不相等。

计算元素在集合中出现的次数。


有时您需要计算集合中特定元素(例如整数)的出现次数。 我们也可以加快该算法的速度。 为了进行比较,让我们编写几种方法来搜索Array中的Item元素。


最明显的一个:


 public int Naive() { int result = 0; foreach (int i in Array) { if (i == Item) { result++; } } return result; } 

使用LINQ:


 public int LINQ() => Array.Count(i => i == Item); 

使用System.Numerics.Vectors中的向量:


 public int Vectors() { var mask = new Vector<int>(Item); int vectorSize = Vector<int>.Count; var accResult = new Vector<int>(); int i; var array = Array; for (i = 0; i <= array.Length - vectorSize; i += vectorSize) { var v = new Vector<int>(array, i); var areEqual = Vector.Equals(v, mask); accResult = Vector.Subtract(accResult, areEqual); } int result = 0; for (; i < array.Length; i++) { if (array[i] == Item) { result++; } } result += Vector.Dot(accResult, Vector<int>.One); return result; } 

使用内在函数:


 public unsafe int Intrinsics() { int vectorSize = 256 / 8 / 4; var temp = stackalloc int[vectorSize]; for (int j = 0; j < vectorSize; j++) { temp[j] = Item; } var mask = Avx2.LoadVector256(temp); var accVector = Vector256<int>.Zero; int i; var array = Array; fixed (int* ptr = array) { for (i = 0; i <= array.Length - vectorSize; i += vectorSize) { var v = Avx2.LoadVector256(ptr + i); var areEqual = Avx2.CompareEqual(v, mask); accVector = Avx2.Subtract(accVector, areEqual); } } int result = 0; Avx2.Store(temp, accVector); for(int j = 0; j < vectorSize; j++) { result += temp[j]; } for(; i < array.Length; i++) { if (array[i] == Item) { result++; } } return result; } 

在计算机上运行基准测试的结果:


方法ItemsCount均值失误标准差比例
天真108.844纳秒0.0772纳秒0.0603 ns1.00
LINQ1087.456 ns0.9496纳秒0.8883纳秒9.89
向量103.140纳秒0.0406纳秒0.0380纳秒0.36
本征1013.813 ns0.0825 ns0.0772纳秒1.56
天真100107.310 ns0.6975纳秒0.6183 ns1.00
LINQ100626.285 ns5.7677 ns5.3951 ns5.83
向量10011.844 ns0.2113纳秒0.1873纳秒0.11
本征10019.616纳秒0.1018 ns0.0903 ns0.18
天真10001,032.466 ns6.3799 ns5.6556 ns1.00
LINQ10006,266.605 ns42.6585 ns39.9028 ns6.07
向量100083.417 ns0.5393纳秒0.4780纳秒0.08
本征100088.358 ns0.4921纳秒0.4603纳秒0.09
天真10,0009,942.503 ns47.9732 ns40.0598 ns1.00
LINQ10,00062,305.598 ns643.8775 ns502.6972 ns6.27
向量10,000914.967 ns7.2959 ns6.8246 ns0.09
本征10,000931.698 ns6.3444 ns5.9346 ns0.09
天真100,00094,834.804 ns793.8585 ns703.7349 ns1.00
LINQ100,000626,620.968 ns4,696.9221 ns4,393.5038 ns6.61
向量100,0009,000.827 ns179.5351 ns192.1005 ns0.09
本征100,0008,690.771 ns101.7078 ns95.1376 ns0.09
天真1,000,000959,302.249 ns4,268.2488 ns3,783.6914 ns1.00
LINQ1,000,0006,218,681.888 ns31,321.9277 ns29,298.5506 ns6.48
向量1,000,00099,778.488 ns1,975.6001 ns4,252.6877 ns0.10
本征1,000,00096,449.350 ns1,171.8067 ns978.5116 ns0.10

向量和本征方法在逻辑上完全重合,但在特定操作的实现上有所不同。 这个想法如下:


  • 创建掩码向量,其中每个元素中都存储有所需的编号;
  • 在v向量中加载数组的一部分,并将其与掩码进行比较。 结果,所有位将设置在areEqual的相等元素中。 因为areEqual是一个整数数组,所以如果我们设置一个元素的所有位,我们将在该元素中获得-1((int)(1111_1111_1111_1111_1111_1111_1111_1111b)== -1);
  • 从accVector中减去areEqual向量。 然后,accVector将保存每个位置的所有v向量中item元素出现的次数(减乘以加号)。

本文的全部代码在GitHub上


结论


我仅描述了用于计算矢量化的.NET功能的一小部分。 要查看x86下.NET Core中可用的所有内部函数的完整更新列表,请转到源代码 。 方便的是,C#文件中每个内在函数的摘要都在C世界中包含其名称。 这有助于理解此内在函数的目的以及将现有C ++ / C算法转移到.NET的方法。 msdn上提供了System.Numerics.Vector文档。


我认为.NET比C ++具有很大的优势。 由于JIT编译已在客户端计算机上进行,因此编译器可以为特定客户端处理器优化代码,从而提供最佳性能。 同时,程序员可以使用一种语言和相同的技术来编写快速代码。

Source: https://habr.com/ru/post/zh-CN467689/


All Articles