.NET / C#中的SIMD概述

邀请您注意.NET Framework和.NETCORE中算法矢量化功能的概述。 本文的目的是向完全不了解这些技术的人介绍这些技术,并表明.NET与本机的“真正的,已编译的”语言相差无几
发展。


我才刚刚开始学习向量化技术,因此,如果社区中有人将我指出一个明显的偏见,或提出以下所述算法的改进版本,我将非常高兴。


一点历史


在.NET中,SIMD首次出现是在2015年发布的.NET Framework 4.6中。 然后添加了Matrix3x2,Matrix4x4,Plane,Quaternion,Vector2,Vector3和Vector4类型,从而可以构造矢量化计算。 后来,添加了Vector <T>类型,这为矢量化算法提供了更多机会。 但是许多程序员仍然不满意,因为 上述类型限制了程序员的思想流程,并且不允许使用现代处理器的SIMD指令的全部功能。 如今,在.NET Core 3.0预览版中,已经出现了System.Runtime.Intrinsics命名空间,该命名空间为选择指令提供了更大的自由度。 为了获得最快的速度,您需要使用RyuJit,并且需要在x64下构建或禁用Prefer 32位并在AnyCPU下进行构建。 我在配备3.40 GHz Intel Core i7-6700处理器(Skylake)的计算机上运行的所有基准测试。


总结数组的元素


我决定从经典问题开始,当涉及矢量化时,通常首先写这个问题。 这是查找数组元素之和的任务。 我们将编写此任务的四个实现,我们将总结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中位数
天真1075.12 ns
LINQ101 186.85 ns
向量1060.09 ns
本征10255.40 ns
天真100360.56 ns
LINQ1002 719.24 ns
向量10060.09 ns
本征100345.54 ns
天真10001 847.88 ns
LINQ100012 033.78 ns
向量1000240.38 ns
本征1000630.98 ns
天真10,00018 403.72 ns
LINQ10,000102 489.96 ns
向量10,0007 316.42 ns
本征10,0003 365.25 ns
天真100,000176630.67 ns
LINQ100,000975 998.24 ns
向量100,00078 828.03 ns
本征100,00041 269.41 ns

可以看出,向量和内在函数的解决方案比明显的解决方案和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,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。
  • 最后,如果需要,则将不适合最后一个向量的数字相加。

如果您查看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; } 

您可以看到它与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中输入了指向数组的指针。
  • 然后执行与向量中相同的操作:将数据加载到向量中并添加两个向量。
  • 为了总结向量的元素,使用了以下方法:
    • 在堆栈上创建一个数组: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,00066 719.1 ns
LINQ10,00071 211.1 ns
向量10,0003 695.8 ns
Memcmp10,000600.9 ns
本征10,0001 607.5 ns
天真100,000588 633.7 ns
LINQ100,000651 191.3 ns
向量100,00034 659.1 ns
Memcmp100,0005 513.6 ns
本征100,00012,078.9 ns
天真1,000,0005637 293.1 ns
LINQ1,000,0006622 666.0 ns
向量1,000,000777 974.2 ns
Memcmp1,000,000361 704.5 ns
本征1,000,000434 252.7 ns

我认为这些方法的所有代码都是可以理解的,除了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个单字节元素中每个元素的高位。 伪代码可以在这里找到。


因此,如果va和vb中的某些字节不匹配,则在areEqual中对应的字节将为0,因此这些字节的最高有效位也将为0,这意味着Avx2.MoveMask响应中的对应位也将为0并进行比较与equalsMask一起使用将不起作用。


让我们分析一个小例子,假设向量的长度为8个字节(写得更少):


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

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


有时有必要计算在集合中找到特定元素的次数(例如int),此算法也可以加速。 让我们写一些比较的方法,我们将在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 mask = Avx2.SetAllVector256(Item); //var mask = Avx2.SetVector256(Item, Item, Item, Item, Item, Item, Item, Item); 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中位数
天真10002 824.41 ns
LINQ100012 138.95 ns
向量1000961.50 ns
本征1000691.08 ns
天真10,00027 072.25 ns
LINQ10,000113967.87 ns
向量10,0007 571.82 ns
本征10,0004,296.71 ns
天真100,000361 028.46 ns
LINQ100,0001,091,994.28 ns
向量100,00082 839.29 ns
本征100,00040 307.91 ns
天真1,000,0001634 175.46 ns
LINQ1,000,0006194257.38 ns
向量1,000,000583 901.29 ns
本征1,000,000413 520.38 ns

向量和内部方法的逻辑完全相同,不同之处仅在于特定操作的实现。 总体而言,该想法是:


  • 创建一个掩码向量,其中将所需数量存储在每个元素中;
  • 将数组的一部分加载到向量v中并与掩码进行比较,然后将所有位设置为areEqual中的相等元素,因为 areEqual是来自int的向量,如果您设置一个元素的所有位,则在该元素中我们将获得-1((int)(1111_1111_1111_1111_1111_1111_1111_1111b)== -1);
  • 从accVector中减去向量areEqual,然后accVector将是每个位置上所有向量v中item元素出现的次数的总和(负min表示正数)。

文章中的所有代码都可以在GitHub找到


结论


我只检查了.NET为矢量化计算提供的可能性中的很小一部分。 有关x86下.NETCORE中可用内在函数的完整和最新列表,请参考源代码 。 方便的是,在每个内在函数摘要的C#文件中,都有一个自己的名字来自C语言世界,这简化了对该内在函数目的的理解以及将现有C ++ / C算法转换为.NET的过程。 msdn上提供了System.Numerics.Vector文档。


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


UPD(09/15/2019):


基准测试有门槛

在基准测试中,我使用了IterationSetup,事实证明,它会大大影响不到100ms的基准测试性能。 如果在GlobalSetup上重做,则结果将是这样。


数组元素的总和:


方法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

比较两个数组:


方法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

数组中元素出现的次数:


方法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

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


All Articles