文章目录
- 一、算法思路
- 1.1 瓶颈分析
- 1.2 优化思路
- 1.3 计算索引
- 二、算法实现
- 2.1 程序里计算索引
- 2.2 思路A的实现
- 2.3 思路B的实现
- 三、基准测试结果
- 3.1 X86 架构
- 3.1.1 X86 架构上`.NET 6.0`程序的测试结果
- 3.1.2 X86 架构上`.NET 7.0`程序的测试结果
- 3.1.3 X86 架构上`.NET 8.0`程序的测试结果
- 3.2 Arm 架构
- 3.2.1 Arm 架构上`.NET 6.0`程序的测试结果
- 3.2.2 Arm 架构上`.NET 7.0`程序的测试结果
- 3.2.3 Arm 架构上`.NET 8.0`程序的测试结果
- 3.3 .NET Framework
- 四、结语
- 附录
在本主题的 上一篇文章里,给大家讲解了24位图像水平翻转(FlipX)算法。但该文章主要是为了介绍 YShuffleX3Kernel 的使用,该算法性能并不是最优的。于是本文将介绍如何使用 YShuffleX2Kernel 来优化程序。而且Imageshop在留言区给了一份C语言的、基于Sse系列指令集实现的代码,正好一起对比一下。
一、算法思路
1.1 瓶颈分析
当硬件没有多向量换位(shuffle)的指令时,一般来说需要3条换位指令才能实现 YShuffleX3Kernel 方法。
对于24位图像水平翻转来说,每次内循环是处理3个向量长度的数据,需调用3次 YShuffleX3Kernel 方法,故共调用了 3*3=9
条换位指令。
且在使用X86平台的Avx2指令集时,因它没有提供“跨小道(lane)换位”指令,导致需要用2条换位指令来实现“向量内全范围的换位”。于是对于24位图像水平翻转来说,故共调用了 3*3*2=18
条换位指令。这个数量很大了。
于是,若能降低换位指令的数量,将能提升程序的性能。
1.2 优化思路
X86架构的Sse指令集,与Arm架构的AdvSimd指令集里均使用128位的向量寄存器,即16个字节。
24位像素是3个字节一组。对于16个字节向量寄存器来说,仅能完整存放 5个像素((int)(16 / 3) = 5
),占据了 3*5=15
个字节。剩余的1个字节超出向量大小的边界了,位于另一个向量中。
换个角度来看,使用1条单向量的换位指令,能将16个字节中的15个字节做好水平翻转。仅剩1个字节的数据不正确,此时可以另想办法来修正。
一种思路是用标量的内存读写语句来修正这些不正确的字节。这就是 Imageshop的Sse版算法的思路,对于每次处理48个字节的内循环,使用标量的内存读写语句修正了6个字节的数据。这种办法能将换位指令数量降到了最低,但代价是增加了标量的内存读写工作。需要进行基准测试,实际对比性能。
另一种思路是再用1条换位指令来计算剩余字节的数据,然后使用条件掩码将这2种结果(15个字节+剩余1字节)合并。而且大多数架构(X86、Arm)的换位指令带有清零能力,小心的调整掩码,能使无效字节为0,这样仅需 or 指令就能将2种结果合并了。
上面这种计算,正好是“2向量的换位”操作。X86架构的Avx512系列指令集,以及Arm架构的AdvSimd指令集,均提供了这样的指令。而且 .NET 8.0 支持了这些指令的内在函数(Intrinsic Functions)。
手动调用内在函数是很繁琐的,且难以跨平台。于是 VectorTraits 提供了 YShuffleX2Kernel 方法。它会检查硬件是否支持“2向量的换位”指令,如支持便会使用各平台的这些指令;若不支持,便会自动计算好掩码,调用2条换位指令并组合。
也就是说,当硬件没有多向量换位的指令时,YShuffleX2Kernel 比起 YShuffleX3Kernel,能少使用1个换位指令。从而降低了开销,优化了性能。
1.3 计算索引
对于24位图像水平翻转来说,每次内循环是处理3个向量长度的数据,此时需要3个索引向量。其中 索引0、索引2,仅需访问2个数据向量,正好能使用 YShuffleX2Kernel。而中间的索引1,默认情况会访问3个数据向量。下面的表格详细说明了这些情况。
Name | offset | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Indice | 0 | 45 | 46 | 47 | 42 | 43 | 44 | 39 | 40 | 41 | 36 | 37 | 38 | 33 | 34 | 35 | 30 | 31 | 32 | 27 | 28 | 29 | 24 | 25 | 26 | 21 | 22 | 23 | 18 | 19 | 20 | 15 | 16 | 17 | 12 | 13 | 14 | 9 | 10 | 11 | 6 | 7 | 8 | 3 | 4 | 5 | 0 | 1 | 2 |
Indice0 | 16 | 29 | 30 | 31 | 26 | 27 | 28 | 23 | 24 | 25 | 20 | 21 | 22 | 17 | 18 | 19 | 14 | ||||||||||||||||||||||||||||||||
Indice1E | 16 | 15 | 16 | 11 | 12 | 13 | 8 | 9 | 10 | 5 | 6 | 7 | 2 | 3 | 4 | -1 | 0 | ||||||||||||||||||||||||||||||||
Indice1A | 0 | 31 | 32 | 27 | 28 | 29 | 24 | 25 | 26 | 21 | 22 | 23 | 18 | 19 | 20 | 15 | 16 | ||||||||||||||||||||||||||||||||
Indice1B | 15 | 16 | 17 | 12 | 13 | 14 | 9 | 10 | 11 | 6 | 7 | 8 | 3 | 4 | 5 | 0 | 1 | ||||||||||||||||||||||||||||||||
Indice2 | 0 | 17 | 12 | 13 | 14 | 9 | 10 | 11 | 6 | 7 | 8 | 3 | 4 | 5 | 0 | 1 | 2 |
变量说明:
- Indice: YShuffleX3Kernel 的索引。3个索引均写在同一行。
- Indice0: YShuffleX2Kernel 的索引0。
- Indice1E: 演示了错误方案下的索引1。
- Indice1A: 索引1使用A方案。
- Indice1B: 索引1使用B方案。
- Indice2: YShuffleX2Kernel 的索引2。
索引2(Indice2)最简单,直接使用 YShuffleX3Kernel的索引2就行。因它仅需访问第0个与第1个输入向量。
索引0(Indice2)稍微复杂一点。它仅需访问第1个与第2个输入向量,于是从数据地址的偏移量(offset)来说,偏移量为16。即向量寄存器的字节数(Vector<byte>.Count
)。
索引1最麻烦,因为它需要访问3个向量。于是上表给出了 Indice1E 这一行,将 offset 设为16,于是可以观察到它不仅有小于下界的值(-1),还有超过上界的值(16)。有2种思路来解决这一难题:
- A. 干脆用 YShuffleX3Kernel 来计算它。于是可以直接使用 YShuffleX3Kernel 时的索引1,即 Indice1A。也就说,内循环会调用2次 YShuffleX2Kernel,和1次 YShuffleX3Kernel,比起调用3次YShuffleX3Kernel的开销低。
- B. 干脆为它提供的输入向量。此时用“偏移量15”来加载2笔连续的向量数据,便能用 YShuffleX2Kernel 来计算了。由于现在数据加载地址很近(偏移15与偏移16很近),且现在处理器的高速缓存(Cache)技术很成熟,使得这种加载的开销很低。
随后可以通过基准测试,来看哪种思路的性能更好。
二、算法实现
2.1 程序里计算索引
首先需要计算索引。可以在先前YShuffleX3Kernel的索引计算代码上修改,关键是处理好 offset。
// -- Indices of YShuffleX3Kernel
private static readonly Vector<byte> _shuffleIndices0;
private static readonly Vector<byte> _shuffleIndices1;
private static readonly Vector<byte> _shuffleIndices2;// -- Indices of YShuffleX2Kernel
private static readonly byte _shuffleX2Offset0 = (byte)Vector<byte>.Count;
private static readonly byte _shuffleX2Offset1A = 0;
private static readonly byte _shuffleX2Offset1B = (byte)(Vector<byte>.Count / 3 * 3);
private static readonly byte _shuffleX2Offset2 = 0;
private static readonly Vector<byte> _shuffleX2Indices0;
private static readonly Vector<byte> _shuffleX2Indices1A; // Need YShuffleX3Kernel
private static readonly Vector<byte> _shuffleX2Indices1B;
private static readonly Vector<byte> _shuffleX2Indices2;static ImageFlipXOn24bitBenchmark() {const int cbPixel = 3; // 24 bit: Bgr24, Rgb24.int vectorWidth = Vector<byte>.Count;int blockSize = vectorWidth * cbPixel;Span<byte> buf = stackalloc byte[blockSize];for (int i = 0; i < blockSize; i++) {int m = i / cbPixel;int n = i % cbPixel;buf[i] = (byte)((vectorWidth - 1 - m) * cbPixel + n);}_shuffleIndices0 = Vectors.Create(buf);_shuffleIndices1 = Vectors.Create(buf.Slice(vectorWidth * 1));_shuffleIndices2 = Vectors.Create(buf.Slice(vectorWidth * 2));// -- Indices of YShuffleX2Kernel_shuffleX2Indices0 = Vector.Subtract(_shuffleIndices0, new Vector<byte>(_shuffleX2Offset0));_shuffleX2Indices1A = _shuffleIndices1; // _shuffleX2Offset1A is 0_shuffleX2Indices1B = Vector.Subtract(_shuffleIndices1, new Vector<byte>(_shuffleX2Offset1B));_shuffleX2Indices2 = _shuffleIndices2; // _shuffleX2Offset2 is 0
}
可见,代码改动不多。仅需使用 Vector.Subtract
做向量减法,减去偏移量。
2.2 思路A的实现
根据上面的思路A,编写代码。源代码如下。
public static unsafe void UseVectorsX2AArgsDoBatch(byte* pSrc, int strideSrc, int width, int height, byte* pDst, int strideDst) {const int cbPixel = 3; // 24 bit: Bgr24, Rgb24.Vectors.YShuffleX2Kernel_Args(_shuffleX2Indices0, out var indices0arg0, out var indices0arg1, out var indices0arg2, out var indices0arg3);Vectors.YShuffleX3Kernel_Args(_shuffleX2Indices1A, out var indices1arg0, out var indices1arg1, out var indices1arg2, out var indices1arg3);Vectors.YShuffleX2Kernel_Args(_shuffleX2Indices2, out var indices2arg0, out var indices2arg1, out var indices2arg2, out var indices2arg3);int vectorWidth = Vector<byte>.Count;if (width <= vectorWidth) {ScalarDoBatch(pSrc, strideSrc, width, height, pDst, strideDst);return;}int maxX = width - vectorWidth;byte* pRow = pSrc;byte* qRow = pDst;for (int i = 0; i < height; i++) {Vector<byte>* pLast = (Vector<byte>*)pRow;Vector<byte>* qLast = (Vector<byte>*)(qRow + maxX * cbPixel);Vector<byte>* p = (Vector<byte>*)(pRow + maxX * cbPixel);Vector<byte>* q = (Vector<byte>*)qRow;for (; ; ) {Vector<byte> data0, data1, data2, temp0, temp1, temp2;// Load.data0 = p[0];data1 = p[1];data2 = p[2];// FlipX.temp0 = Vectors.YShuffleX2Kernel_Core(data1, data2, indices0arg0, indices0arg1, indices0arg2, indices0arg3);temp1 = Vectors.YShuffleX3Kernel_Core(data0, data1, data2, indices1arg0, indices1arg1, indices1arg2, indices1arg3);temp2 = Vectors.YShuffleX2Kernel_Core(data0, data1, indices2arg0, indices2arg1, indices2arg2, indices2arg3);// Store.q[0] = temp0;q[1] = temp1;q[2] = temp2;// Next.if (p <= pLast) break;p -= cbPixel;q += cbPixel;if (p < pLast) p = pLast; // The last block is also use vector.if (q > qLast) q = qLast;}pRow += strideSrc;qRow += strideDst;}
}
前面文章有提到,使用 Args、Core后缀的方法可以将部分运算从循环内,挪至循环前,从而提高了性能。于是本函数便使用了这个办法。
2.3 思路B的实现
根据上面的思路B,编写代码。源代码如下。
public static unsafe void UseVectorsX2BArgsDoBatch(byte* pSrc, int strideSrc, int width, int height, byte* pDst, int strideDst) {const int cbPixel = 3; // 24 bit: Bgr24, Rgb24.int offsetB0 = _shuffleX2Offset1B;int offsetB1 = offsetB0 + Vector<byte>.Count;Vectors.YShuffleX2Kernel_Args(_shuffleX2Indices0, out var indices0arg0, out var indices0arg1, out var indices0arg2, out var indices0arg3);Vectors.YShuffleX2Kernel_Args(_shuffleX2Indices1B, out var indices1arg0, out var indices1arg1, out var indices1arg2, out var indices1arg3);Vectors.YShuffleX2Kernel_Args(_shuffleX2Indices2, out var indices2arg0, out var indices2arg1, out var indices2arg2, out var indices2arg3);int vectorWidth = Vector<byte>.Count;if (width <= vectorWidth) {ScalarDoBatch(pSrc, strideSrc, width, height, pDst, strideDst);return;}int maxX = width - vectorWidth;byte* pRow = pSrc;byte* qRow = pDst;for (int i = 0; i < height; i++) {Vector<byte>* pLast = (Vector<byte>*)pRow;Vector<byte>* qLast = (Vector<byte>*)(qRow + maxX * cbPixel);Vector<byte>* p = (Vector<byte>*)(pRow + maxX * cbPixel);Vector<byte>* q = (Vector<byte>*)qRow;for (; ; ) {Vector<byte> data0, data1, data2, dataB0, dataB1, temp0, temp1, temp2;// Load.data0 = p[0];data1 = p[1];data2 = p[2];dataB0 = *(Vector<byte>*)((byte*)p + offsetB0);dataB1 = *(Vector<byte>*)((byte*)p + offsetB1);// FlipX.temp0 = Vectors.YShuffleX2Kernel_Core(data1, data2, indices0arg0, indices0arg1, indices0arg2, indices0arg3);temp1 = Vectors.YShuffleX2Kernel_Core(dataB0, dataB1, indices1arg0, indices1arg1, indices1arg2, indices1arg3);temp2 = Vectors.YShuffleX2Kernel_Core(data0, data1, indices2arg0, indices2arg1, indices2arg2, indices2arg3);// Store.q[0] = temp0;q[1] = temp1;q[2] = temp2;// Next.if (p <= pLast) break;p -= cbPixel;q += cbPixel;if (p < pLast) p = pLast; // The last block is also use vector.if (q > qLast) q = qLast;}pRow += strideSrc;qRow += strideDst;}
}
思路B的内循环,全部使用 YShuffleX2Kernel_Core 来计算。只是需要为索引1(_shuffleX2Indices1B),加载其特有偏移量的2个向量(dataB0、dataB1)。由于偏移量是基于字节的,于是在在计算地址时,需将指针p转为了 byte*
类型后再加偏移量,最后再转回 Vector<byte>*
类型来做向量加载。
三、基准测试结果
3.1 X86 架构
3.1.1 X86 架构上.NET 6.0
程序的测试结果
X86架构上.NET 6.0
程序的基准测试结果如下。
BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.3476)
AMD Ryzen 7 7840H w/ Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores
.NET SDK 9.0.200[Host] : .NET 6.0.36 (6.0.3624.51421), X64 RyuJIT AVX2DefaultJob : .NET 6.0.36 (6.0.3624.51421), X64 RyuJIT AVX2| Method | Width | Mean | Error | StdDev | Ratio | Code Size |
|------------------ |------ |------------:|----------:|----------:|------:|----------:|
| Scalar | 1024 | 1,047.8 us | 10.47 us | 9.79 us | 1.00 | 2,053 B |
| UseVectors | 1024 | 375.6 us | 7.49 us | 7.69 us | 0.36 | 4,505 B |
| UseVectorsArgs | 1024 | 202.0 us | 4.02 us | 4.94 us | 0.19 | 4,234 B |
| UseVectorsX2AArgs | 1024 | 149.6 us | 2.97 us | 8.63 us | 0.14 | 4,275 B |
| UseVectorsX2BArgs | 1024 | 125.2 us | 2.39 us | 2.11 us | 0.12 | 3,835 B |
| ImageshopSse | 1024 | 145.0 us | 2.81 us | 4.30 us | 0.14 | 1,440 B |
| | | | | | | |
| Scalar | 2048 | 4,248.4 us | 41.26 us | 38.59 us | 1.00 | 2,053 B |
| UseVectors | 2048 | 2,578.7 us | 18.84 us | 17.63 us | 0.61 | 4,505 B |
| UseVectorsArgs | 2048 | 2,022.4 us | 22.92 us | 21.44 us | 0.48 | 4,234 B |
| UseVectorsX2AArgs | 2048 | 1,710.7 us | 16.22 us | 14.38 us | 0.40 | 4,275 B |
| UseVectorsX2BArgs | 2048 | 1,682.1 us | 18.11 us | 16.94 us | 0.40 | 3,835 B |
| ImageshopSse | 2048 | 1,854.0 us | 21.15 us | 19.78 us | 0.44 | 1,440 B |
| | | | | | | |
| Scalar | 4096 | 16,231.0 us | 133.81 us | 118.62 us | 1.00 | 2,053 B |
| UseVectors | 4096 | 8,418.7 us | 55.64 us | 52.04 us | 0.52 | 4,490 B |
| UseVectorsArgs | 4096 | 5,906.4 us | 49.55 us | 46.34 us | 0.36 | 4,219 B |
| UseVectorsX2AArgs | 4096 | 5,497.9 us | 46.65 us | 43.64 us | 0.34 | 4,260 B |
| UseVectorsX2BArgs | 4096 | 5,385.9 us | 79.28 us | 74.16 us | 0.33 | 3,820 B |
| ImageshopSse | 4096 | 5,784.4 us | 50.70 us | 44.94 us | 0.36 | 1,440 B |
- Scalar: 标量算法。
- UseVectors: 向量算法。
- UseVectorsArgs: 使用Args将部分运算挪至循环前的向量算法。
- UseVectorsX2AArgs: 思路A的算法(2次YShuffleX2Kernel + 1次YShuffleX3Kernel)。
- UseVectorsX2BArgs: 思路B的算法(3次YShuffleX2Kernel,并处理特殊偏移 )。
- ImageshopSse: Imageshop的Sse版算法,翻译为 C# 语言。
以1024时的测试结果为例,来观察向量化算法比起标量算法的性能提升。
- UseVectors:1,047.8/492.3 ≈ 2.13。即性能提升了 2.13 倍。
- UseVectorsArgs:1,047.8/202.0 ≈5.19。即性能提升了5.19 倍。
- UseVectorsX2AArgs:1,047.8/149.6 ≈7.00。即性能提升了 7.00 倍。
- UseVectorsX2BArgs:1,047.8/125.2 ≈8.37。即性能提升了 8.37 倍。
- ImageshopSse:1,047.8/145.0 ≈7.23。即性能提升了 7.23 倍。
可以发现,减少换位指令数量的办法确实有效,UseVectorsX2AArgs 等函数的速度,均比 UseVectorsArgs 要快。
还可发现,内循环仅使用3次换位的ImageshopSse ,性能与 UseVectorsX2AArgs 差不多。由于AVX2没有“跨小道(lane)换位”指令,导致需要用2条换位指令来实现“向量内全范围的换位”,于是UseVectorsX2AArgs的内循环里有 (2+3+2) * 2 = 14
条换位指令。虽然换位指令的数量相差这么多,但性能差不多,说明标量内存读写的开销颇大。
UseVectorsX2BArgs 的速度最快。这是因为 UseVectorsX2BArgs的内循环 比 UseVectorsX2AArgs 少一次换位操作,代价仅是多了2次向量加载。
3.1.2 X86 架构上.NET 7.0
程序的测试结果
X86架构上.NET 7.0
程序的基准测试结果如下。
BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.3476)
AMD Ryzen 7 7840H w/ Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores
.NET SDK 9.0.200[Host] : .NET 7.0.20 (7.0.2024.26716), X64 RyuJIT AVX2DefaultJob : .NET 7.0.20 (7.0.2024.26716), X64 RyuJIT AVX2| Method | Width | Mean | Error | StdDev | Ratio | Code Size |
|------------------ |------ |------------:|----------:|----------:|------:|----------:|
| Scalar | 1024 | 1,009.2 us | 10.62 us | 9.42 us | 1.00 | 1,673 B |
| UseVectors | 1024 | 214.5 us | 4.05 us | 3.98 us | 0.21 | 3,724 B |
| UseVectorsArgs | 1024 | 179.5 us | 3.47 us | 3.71 us | 0.18 | 4,031 B |
| UseVectorsX2AArgs | 1024 | 146.9 us | 2.89 us | 2.84 us | 0.15 | 3,912 B |
| UseVectorsX2BArgs | 1024 | 119.5 us | 2.39 us | 2.75 us | 0.12 | 3,673 B |
| ImageshopSse | 1024 | 149.3 us | 2.92 us | 5.42 us | 0.15 | 1,350 B |
| | | | | | | |
| Scalar | 2048 | 4,233.3 us | 48.45 us | 45.32 us | 1.00 | 1,673 B |
| UseVectors | 2048 | 1,707.1 us | 21.99 us | 20.57 us | 0.40 | 3,724 B |
| UseVectorsArgs | 2048 | 1,625.7 us | 14.62 us | 13.68 us | 0.38 | 4,031 B |
| UseVectorsX2AArgs | 2048 | 1,519.1 us | 19.57 us | 18.30 us | 0.36 | 3,912 B |
| UseVectorsX2BArgs | 2048 | 1,439.8 us | 16.77 us | 15.69 us | 0.34 | 3,673 B |
| ImageshopSse | 2048 | 1,425.7 us | 18.37 us | 16.28 us | 0.34 | 1,350 B |
| | | | | | | |
| Scalar | 4096 | 15,994.4 us | 134.29 us | 119.04 us | 1.00 | 1,673 B |
| UseVectors | 4096 | 5,962.0 us | 76.95 us | 68.22 us | 0.37 | 3,709 B |
| UseVectorsArgs | 4096 | 5,858.2 us | 74.10 us | 69.31 us | 0.37 | 4,016 B |
| UseVectorsX2AArgs | 4096 | 5,528.2 us | 34.26 us | 32.05 us | 0.35 | 3,897 B |
| UseVectorsX2BArgs | 4096 | 5,342.9 us | 51.69 us | 48.35 us | 0.33 | 3,658 B |
| ImageshopSse | 4096 | 5,603.8 us | 38.53 us | 34.15 us | 0.35 | 1,350 B |
以1024时的测试结果为例,来观察向量化算法比起标量算法的性能提升。
- UseVectors:1,009.2/214.5 ≈ 4.70。
- UseVectorsArgs:1,009.2/179.5 ≈5.62。
- UseVectorsX2AArgs:1,009.2/146.9 ≈6.87。
- UseVectorsX2BArgs:1,009.2/119.5 ≈8.44。
- ImageshopSse:1,009.2/149.3 ≈6.76。
与 .NET 6.0 时的测试结果差不多,UseVectorsX2BArgs 最快,UseVectorsX2AArgs、ImageshopSse 并列第2。
3.1.3 X86 架构上.NET 8.0
程序的测试结果
X86架构上.NET 8.0
程序的基准测试结果如下。
Vectors.Instance: VectorTraits256Avx2 // Avx, Avx2, Sse, Sse2, Avx512VL
YShuffleX3Kernel_AcceleratedTypes: SByte, Byte, Int16, UInt16, Int32, UInt32, Int64, UInt64, Single, DoubleBenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.3476)
AMD Ryzen 7 7840H w/ Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores
.NET SDK 9.0.200[Host] : .NET 8.0.13 (8.0.1325.6609), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMIDefaultJob : .NET 8.0.13 (8.0.1325.6609), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI| Method | Width | Mean | Error | StdDev | Ratio | Code Size |
|------------------ |------ |-------------:|----------:|----------:|------:|----------:|
| Scalar | 1024 | 565.61 us | 6.062 us | 5.671 us | 1.00 | NA |
| UseVectors | 1024 | 70.15 us | 0.946 us | 0.839 us | 0.12 | NA |
| UseVectorsArgs | 1024 | 71.35 us | 1.395 us | 2.368 us | 0.13 | NA |
| UseVectorsX2AArgs | 1024 | 70.38 us | 1.389 us | 1.757 us | 0.12 | NA |
| UseVectorsX2BArgs | 1024 | 71.11 us | 1.417 us | 1.325 us | 0.13 | NA |
| ImageshopSse | 1024 | 147.10 us | 3.065 us | 5.286 us | 0.28 | 1,304 B |
| | | | | | | |
| Scalar | 2048 | 2,778.83 us | 31.741 us | 28.137 us | 1.00 | NA |
| UseVectors | 2048 | 1,021.40 us | 10.916 us | 10.211 us | 0.37 | NA |
| UseVectorsArgs | 2048 | 1,057.84 us | 20.079 us | 18.782 us | 0.38 | NA |
| UseVectorsX2AArgs | 2048 | 1,057.32 us | 16.454 us | 15.391 us | 0.38 | NA |
| UseVectorsX2BArgs | 2048 | 1,012.21 us | 13.793 us | 12.227 us | 0.36 | NA |
| ImageshopSse | 2048 | 1,742.22 us | 15.396 us | 14.401 us | 0.63 | 1,308 B |
| | | | | | | |
| Scalar | 4096 | 11,051.36 us | 86.964 us | 77.092 us | 1.00 | NA |
| UseVectors | 4096 | 4,408.84 us | 48.341 us | 45.218 us | 0.40 | NA |
| UseVectorsArgs | 4096 | 4,330.39 us | 39.934 us | 35.401 us | 0.39 | NA |
| UseVectorsX2AArgs | 4096 | 4,336.47 us | 48.908 us | 45.748 us | 0.39 | NA |
| UseVectorsX2BArgs | 4096 | 4,083.04 us | 72.525 us | 67.840 us | 0.37 | NA |
| ImageshopSse | 4096 | 5,692.53 us | 53.488 us | 50.032 us | 0.52 | 1,311 B |
以1024时的测试结果为例,来观察向量化算法比起标量算法的性能提升。
- UseVectors:565.61/70.15 ≈ 8.06。
- UseVectorsArgs:565.61/71.35 ≈7.93。
- UseVectorsX2AArgs:565.61/70.38 ≈8.04。
- UseVectorsX2BArgs:565.61/71.11 ≈8.44。
- ImageshopSse:565.61/147.10 ≈6.76。
其实,由于 .NET 8.0
也优化了标量算法,这导致上面的的性能提升倍数看起来比较低。若拿 .NET 7.0
的测试结果,与 .NET 8.0
的UseVectors进行对比,就能看出差别了。
- Scalar:1,120.3/70.15 ≈ 16.42。即
.NET 8.0
向量算法的性能,是.NET 7.0
标量算法的 16.42 倍。 - UseVectors:236.7/70.15 ≈ 3.47。即
.NET 8.0
向量算法的性能,是.NET 7.0
向量算法的 3.47 倍。也可看做,Avx512的性能是Avx2的3.47倍。 - UseVectorsX2AArgs:146.9/70.38 ≈2.09。
- UseVectorsX2BArgs:119.5/71.11 ≈1.68。
- ImageshopSse:149.3/147.10 ≈1.01。
此时 UseVectors、UseVectorsArgs、UseVectorsX2AArgs、UseVectorsX2BArgs 的成绩相差不大,考虑到测试误差,可以将它们并列第一。这是因为使用Avx512系列指令集之后,256位向量无论是“2向量换位”,还是“3向量换位”,都是仅需1条换位指令。
而 ImageshopSse 的性能保持不变,这是因为它固定使用了 Sse系列指令集。
3.2 Arm 架构
同样的源代码可以在 Arm 架构上运行。
3.2.1 Arm 架构上.NET 6.0
程序的测试结果
Arm架构上.NET 6.0
程序的基准测试结果如下。
Vectors.Instance: VectorTraits128AdvSimdB64 // AdvSimd
YShuffleX3Kernel_AcceleratedTypes: SByte, Byte, Int16, UInt16, Int32, UInt32, Int64, UInt64, Single, DoubleBenchmarkDotNet v0.14.0, macOS Sequoia 15.1.1 (24B91) [Darwin 24.1.0]
Apple M2, 1 CPU, 8 logical and 8 physical cores
.NET SDK 9.0.102[Host] : .NET 6.0.33 (6.0.3324.36610), Arm64 RyuJIT AdvSIMDDefaultJob : .NET 6.0.33 (6.0.3324.36610), Arm64 RyuJIT AdvSIMD| Method | Width | Mean | Error | StdDev | Ratio | RatioSD |
|------------------ |------ |-------------:|-----------:|-----------:|------:|--------:|
| Scalar | 1024 | 1,504.09 us | 0.575 us | 0.480 us | 1.00 | 0.00 |
| UseVectors | 1024 | 120.26 us | 1.569 us | 1.468 us | 0.08 | 0.00 |
| UseVectorsArgs | 1024 | 83.77 us | 0.067 us | 0.056 us | 0.06 | 0.00 |
| UseVectorsX2AArgs | 1024 | 72.68 us | 0.034 us | 0.030 us | 0.05 | 0.00 |
| UseVectorsX2BArgs | 1024 | 82.61 us | 0.283 us | 0.265 us | 0.05 | 0.00 |
| ImageshopSse | 1024 | NA | NA | NA | ? | ? |
| | | | | | | |
| Scalar | 2048 | 6,015.27 us | 5.786 us | 4.831 us | 1.00 | 0.00 |
| UseVectors | 2048 | 479.44 us | 0.424 us | 0.397 us | 0.08 | 0.00 |
| UseVectorsArgs | 2048 | 320.78 us | 0.212 us | 0.165 us | 0.05 | 0.00 |
| UseVectorsX2AArgs | 2048 | 332.22 us | 0.314 us | 0.263 us | 0.06 | 0.00 |
| UseVectorsX2BArgs | 2048 | 319.60 us | 1.490 us | 1.394 us | 0.05 | 0.00 |
| ImageshopSse | 2048 | NA | NA | NA | ? | ? |
| | | | | | | |
| Scalar | 4096 | 24,709.98 us | 308.477 us | 288.549 us | 1.00 | 0.02 |
| UseVectors | 4096 | 3,362.91 us | 1.807 us | 1.509 us | 0.14 | 0.00 |
| UseVectorsArgs | 4096 | 2,840.79 us | 13.642 us | 12.760 us | 0.11 | 0.00 |
| UseVectorsX2AArgs | 4096 | 2,592.20 us | 25.326 us | 23.690 us | 0.10 | 0.00 |
| UseVectorsX2BArgs | 4096 | 2,843.72 us | 30.984 us | 28.982 us | 0.12 | 0.00 |
| ImageshopSse | 4096 | NA | NA | NA | ? | ? |
以1024时的测试结果为例,来观察向量化算法比起标量算法的性能提升。
- UseVectors:1,504.09/120.26 ≈ 12.51。
- UseVectorsArgs:1,504.09/ 83.77 ≈17.94。
- UseVectorsX2AArgs:1,504.09/72.68 ≈20.69。
- UseVectorsX2BArgs:1,504.09/82.61 ≈18.21。
注意一下 Vectors.Instance
右侧的信息,会发现它使用了 AdvSimd 指令集。
因为现在是 Arm架构的处理器,没有Sse系列指令集,于是 ImageshopSse 没有测试结果。而 UseVectorsX2AArgs 等支持跨平台的测试函数,都有测试结果。
可以发现 UseVectorsX2AArgs 最快,而 UseVectorsX2BArgs 与 UseVectorsArgs 的性能差不多。看来在Arm平台上 UseVectorsX2BArgs 减少1次换位指令的优化,被它额外的2次向量加载给抵消了。
3.2.2 Arm 架构上.NET 7.0
程序的测试结果
Arm架构上.NET 7.0
程序的基准测试结果如下。
Vectors.Instance: VectorTraits128AdvSimdB64 // AdvSimd
YShuffleX3Kernel_AcceleratedTypes: SByte, Byte, Int16, UInt16, Int32, UInt32, Int64, UInt64, Single, DoubleBenchmarkDotNet v0.14.0, macOS Sequoia 15.1.1 (24B91) [Darwin 24.1.0]
Apple M2, 1 CPU, 8 logical and 8 physical cores
.NET SDK 9.0.102[Host] : .NET 7.0.20 (7.0.2024.26716), Arm64 RyuJIT AdvSIMDDefaultJob : .NET 7.0.20 (7.0.2024.26716), Arm64 RyuJIT AdvSIMD| Method | Width | Mean | Error | StdDev | Ratio | RatioSD |
|------------------ |------ |-------------:|----------:|----------:|------:|--------:|
| Scalar | 1024 | 1,506.38 us | 2.527 us | 2.240 us | 1.00 | 0.00 |
| UseVectors | 1024 | 108.38 us | 0.170 us | 0.159 us | 0.07 | 0.00 |
| UseVectorsArgs | 1024 | 81.57 us | 0.070 us | 0.058 us | 0.05 | 0.00 |
| UseVectorsX2AArgs | 1024 | 69.35 us | 0.111 us | 0.098 us | 0.05 | 0.00 |
| UseVectorsX2BArgs | 1024 | 80.66 us | 0.104 us | 0.081 us | 0.05 | 0.00 |
| ImageshopSse | 1024 | NA | NA | NA | ? | ? |
| | | | | | | |
| Scalar | 2048 | 6,014.79 us | 2.863 us | 2.235 us | 1.00 | 0.00 |
| UseVectors | 2048 | 425.96 us | 0.234 us | 0.207 us | 0.07 | 0.00 |
| UseVectorsArgs | 2048 | 317.95 us | 0.273 us | 0.228 us | 0.05 | 0.00 |
| UseVectorsX2AArgs | 2048 | 270.73 us | 0.238 us | 0.199 us | 0.05 | 0.00 |
| UseVectorsX2BArgs | 2048 | 308.50 us | 1.324 us | 1.239 us | 0.05 | 0.00 |
| ImageshopSse | 2048 | NA | NA | NA | ? | ? |
| | | | | | | |
| Scalar | 4096 | 24,451.53 us | 31.420 us | 27.853 us | 1.00 | 0.00 |
| UseVectors | 4096 | 3,263.99 us | 3.354 us | 2.801 us | 0.13 | 0.00 |
| UseVectorsArgs | 4096 | 2,868.68 us | 7.482 us | 6.999 us | 0.12 | 0.00 |
| UseVectorsX2AArgs | 4096 | 2,512.38 us | 11.036 us | 9.783 us | 0.10 | 0.00 |
| UseVectorsX2BArgs | 4096 | 2,787.01 us | 4.692 us | 3.918 us | 0.11 | 0.00 |
| ImageshopSse | 4096 | NA | NA | NA | ? | ? |
以1024时的测试结果为例,来观察向量化算法比起标量算法的性能提升。
- UseVectors:1,506.38/108.38 ≈ 13.90。
- UseVectorsArgs:1,506.38/81.57 ≈18.47。
- UseVectorsX2AArgs:1,506.38/69.35 ≈21.72。
- UseVectorsX2BArgs:1,506.38/80.66 ≈18.68。
与 .NET 6.0 时的测试结果差不多,UseVectorsX2AArgs 最快,UseVectorsX2BArgs、UseVectorsArgs 并列第2。
3.2.3 Arm 架构上.NET 8.0
程序的测试结果
Arm架构上.NET 8.0
程序的基准测试结果如下。
BenchmarkDotNet v0.14.0, macOS Sequoia 15.1.1 (24B91) [Darwin 24.1.0]
Apple M2, 1 CPU, 8 logical and 8 physical cores
.NET SDK 9.0.102[Host] : .NET 8.0.12 (8.0.1224.60305), Arm64 RyuJIT AdvSIMDDefaultJob : .NET 8.0.12 (8.0.1224.60305), Arm64 RyuJIT AdvSIMD| Method | Width | Mean | Error | StdDev | Ratio | RatioSD |
|------------------ |------ |------------:|----------:|----------:|------:|--------:|
| Scalar | 1024 | 489.45 us | 9.667 us | 8.570 us | 1.00 | 0.02 |
| UseVectors | 1024 | 60.78 us | 0.050 us | 0.045 us | 0.12 | 0.00 |
| UseVectorsArgs | 1024 | 60.20 us | 0.621 us | 0.551 us | 0.12 | 0.00 |
| UseVectorsX2AArgs | 1024 | 61.02 us | 0.054 us | 0.045 us | 0.12 | 0.00 |
| UseVectorsX2BArgs | 1024 | 73.73 us | 0.159 us | 0.141 us | 0.15 | 0.00 |
| ImageshopSse | 1024 | NA | NA | NA | ? | ? |
| | | | | | | |
| Scalar | 2048 | 1,904.18 us | 25.572 us | 23.920 us | 1.00 | 0.02 |
| UseVectors | 2048 | 262.79 us | 0.482 us | 0.428 us | 0.14 | 0.00 |
| UseVectorsArgs | 2048 | 266.08 us | 1.379 us | 1.290 us | 0.14 | 0.00 |
| UseVectorsX2AArgs | 2048 | 266.29 us | 0.949 us | 0.887 us | 0.14 | 0.00 |
| UseVectorsX2BArgs | 2048 | 297.26 us | 1.482 us | 1.237 us | 0.16 | 0.00 |
| ImageshopSse | 2048 | NA | NA | NA | ? | ? |
| | | | | | | |
| Scalar | 4096 | 8,042.44 us | 17.405 us | 16.281 us | 1.00 | 0.00 |
| UseVectors | 4096 | 2,307.59 us | 2.411 us | 1.882 us | 0.29 | 0.00 |
| UseVectorsArgs | 4096 | 2,309.09 us | 4.411 us | 3.910 us | 0.29 | 0.00 |
| UseVectorsX2AArgs | 4096 | 2,193.09 us | 7.278 us | 6.078 us | 0.27 | 0.00 |
| UseVectorsX2BArgs | 4096 | 2,478.22 us | 3.373 us | 2.816 us | 0.31 | 0.00 |
| ImageshopSse | 4096 | NA | NA | NA | ? | ? |
以1024时的测试结果为例,来观察向量化算法比起标量算法的性能提升。
- UseVectors:489.45/60.78 ≈ 8.05。
- UseVectorsArgs:489.45/60.20 ≈8.13。
- UseVectorsX2AArgs:489.45/61.02 ≈8.02。
- UseVectorsX2BArgs:489.45/73.73 ≈ 6.64。
由于 .NET 8.0
也优化了标量算法,这导致上面的的性能提升倍数看起来比较低。若拿 .NET 7.0
的测试结果,与 .NET 8.0
的UseVectors进行对比,就能看出差别了。
- Scalar:1,504.47/60.78 ≈ 24.75。即
.NET 8.0
向量算法的性能,是.NET 7.0
标量算法的 24.75 倍。 - UseVectors:108.65/60.78 ≈1.79。
- UseVectorsArgs:81.78/60.20 ≈ 1.36。即
.NET 8.0
向量算法的性能,是.NET 7.0
向量算法的 1.36 倍。 - UseVectorsX2AArgs:69.35/61.02 ≈1.17。
- UseVectorsX2BArgs:80.66/73.73 ≈1.09。
此时 UseVectors、UseVectorsArgs、UseVectorsX2AArgs 的成绩相差不大,考虑到测试误差,可以将它们并列第一。这是因为使用AdvSimd的“多向量换位”指令之后,无论是“2向量换位”,还是“3向量换位”,都是仅需1条换位指令。
而 UseVectorsX2BArgs 的比 UseVectorsArgs 还慢了。看来对于 Arm架构,2次向量加载的开销也是颇大的。
3.3 .NET Framework
同样的源代码可以在 .NET Framework
上运行。基准测试结果如下。
Vectors.Instance: VectorTraits256Base //
YShuffleX3Kernel_AcceleratedTypes: NoneBenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.3476)
AMD Ryzen 7 7840H w/ Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores[Host] : .NET Framework 4.8.1 (4.8.9290.0), X64 RyuJIT VectorSize=256DefaultJob : .NET Framework 4.8.1 (4.8.9290.0), X64 RyuJIT VectorSize=256| Method | Width | Mean | Error | StdDev | Ratio | RatioSD | Code Size |
|------------------ |------ |-----------:|----------:|----------:|------:|--------:|----------:|
| Scalar | 1024 | 1.033 ms | 0.0177 ms | 0.0165 ms | 1.00 | 0.02 | 2,717 B |
| UseVectors | 1024 | 6.161 ms | 0.0461 ms | 0.0409 ms | 5.96 | 0.10 | 4,883 B |
| UseVectorsArgs | 1024 | 6.089 ms | 0.1066 ms | 0.0998 ms | 5.89 | 0.13 | 4,928 B |
| UseVectorsX2AArgs | 1024 | 6.349 ms | 0.0531 ms | 0.0497 ms | 6.15 | 0.11 | 5,288 B |
| UseVectorsX2BArgs | 1024 | 6.512 ms | 0.1288 ms | 0.1205 ms | 6.30 | 0.15 | 4,794 B |
| | | | | | | | |
| Scalar | 2048 | 4.284 ms | 0.0539 ms | 0.0504 ms | 1.00 | 0.02 | 2,717 B |
| UseVectors | 2048 | 23.636 ms | 0.3372 ms | 0.3155 ms | 5.52 | 0.09 | 4,883 B |
| UseVectorsArgs | 2048 | 23.650 ms | 0.2341 ms | 0.2190 ms | 5.52 | 0.08 | 4,928 B |
| UseVectorsX2AArgs | 2048 | 25.062 ms | 0.3512 ms | 0.3113 ms | 5.85 | 0.10 | 5,288 B |
| UseVectorsX2BArgs | 2048 | 25.362 ms | 0.3052 ms | 0.2706 ms | 5.92 | 0.09 | 4,794 B |
| | | | | | | | |
| Scalar | 4096 | 16.291 ms | 0.2417 ms | 0.2261 ms | 1.00 | 0.02 | 2,717 B |
| UseVectors | 4096 | 94.486 ms | 1.5107 ms | 1.4131 ms | 5.80 | 0.11 | 4,883 B |
| UseVectorsArgs | 4096 | 93.715 ms | 0.8965 ms | 0.7486 ms | 5.75 | 0.09 | 4,928 B |
| UseVectorsX2AArgs | 4096 | 99.979 ms | 1.9541 ms | 1.9192 ms | 6.14 | 0.14 | 5,288 B |
| UseVectorsX2BArgs | 4096 | 101.354 ms | 1.6959 ms | 1.5864 ms | 6.22 | 0.13 | 4,794 B |
UseVectors 等向量函数反而更慢了,这是因为 YShuffleX3Kernel 没有硬件加速。可以看到 “YShuffleX3Kernel_AcceleratedTypes”为“None”。
在实际使用时,应先检查YShuffleX3Kernel_AcceleratedTypes属性。当发现它没有硬件加速时,宜切换为标量算法。
四、结语
对于 Arm架构,很明显思路A(UseVectorsX2AArgs)的性能最好。
而对于 X86架构,情况有一些复杂——
- 不支持Avx512时:思路B(UseVectorsX2BArgs)最快。思路A(UseVectorsX2AArgs)与ImageshopSse并列第2,都比 UseVectorsArgs 快。
- 支持Avx512时:思路A(UseVectorsX2AArgs)、思路B(UseVectorsX2BArgs)与UseVectorsArgs等办法,均并列第1。
考虑到跨平台时代码的维护成本,选择维护唯一一套代码比较好。此时推荐 思路A(UseVectorsX2AArgs)。因为它在绝大多数时候(Arm、X86支持Avx512时)都是排第1,仅在不支持Avx512时才排到第2。而且它的代码相对更简单。
若追求极致性能,可考虑维护2套代码(思路A、思路B),随后根据是否支持Avx512来切换。
附录
- 完整源代码: https://github.com/zyl910/VectorTraits.Sample.Benchmarks/blob/main/VectorTraits.Sample.Benchmarks.Inc/Image/ImageFlipXOn24bitBenchmark.cs
- YShuffleX2Kernel 的文档: https://zyl910.github.io/VectorTraits_doc/api/Zyl.VectorTraits.Vectors.YShuffleX2Kernel.html
- VectorTraits 的NuGet包: https://www.nuget.org/packages/VectorTraits
- VectorTraits 的在线文档: https://zyl910.github.io/VectorTraits_doc/
- VectorTraits 源代码: https://github.com/zyl910/VectorTraits
- [C#] 对24位图像进行水平翻转(FlipX)的跨平台SIMD硬件加速向量算法(使用YShuffleX3Kernel)