邀请您注意.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种方法启动了基准测试,并得到以下结果:
可以看出,向量和内在函数的解决方案比明显的解决方案和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非常相似,但有一些例外:
比较两个数组
有必要比较两个字节数组。 实际上,这就是问题所在,因此我开始在.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; } }
我计算机上基准测试的结果:
我认为这些方法的所有代码都是可以理解的,除了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;
我计算机上基准测试的结果:
向量和内部方法的逻辑完全相同,不同之处仅在于特定操作的实现。 总体而言,该想法是:
- 创建一个掩码向量,其中将所需数量存储在每个元素中;
- 将数组的一部分加载到向量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上重做,则结果将是这样。
数组元素的总和:
比较两个数组:
数组中元素出现的次数: