- A+
在上一篇文章里,我们讲解了图像的垂直翻转(FlipY)算法,于是本文来探讨水平翻转(FlipX)。先讲解比较容易的32位图像水平翻转算法,便于后续文章来探讨复杂的24位图像水平翻转算法。
本文除了会给出标量算法外,还会给出向量算法。且这些算法是跨平台的,同一份源代码,能在 X86(Sse、Avx等指令集)及Arm(AdvSimd等指令集)等架构上运行,且均享有SIMD硬件加速。
一、标量算法
1.1 算法思路
水平翻转又称左右翻转,是将图像沿着垂直中轴线进行翻转。
假设用 src[x, y]
可以访问源图像中的像素,用 dst[x, y]
可以访问目标图像中的像素,width是图像的像素宽度。那么水平翻转的公式为——
dst[x, y] = src[width - 1 - x, y]
注意像素坐标是从0开始编号的。于是最右边像素的x坐标是 width - 1
。
简单来说,就是将行内的每一个像素,按相反的方向复制一遍。
由于需要逐个逐个的处理每一个像素,所以得根据不同的像素大小来编写算法。
1.1.1 32位像素的说明
32位像素是容易处理的。因为32位就是4字节,这是2的整数次幂,处理起来很方便。所以32位图像的使用频率最高。
受到 RGB通道顺序、是否含有 Alpha通道 等细节的影响,32位的像素有很多种像素格式——
- Bgr32。又称 BGRX8888、B8G8R8X8。GDI+ 里称 “Format32bppRgb”。
- Bgra32。又称 BGRA8888、B8G8R8A8。GDI+ 里称“Format32bppArgb”。
- Pbgra32。又称 预乘的BGRA8888、B8G8R8A8。GDI+ 里称“Format32bppPArgb”。
- Rgb32。又称 RGBX8888、R8G8B8X8。
- Rgba32。又称 RGBA8888、R8G8B8A8。
- Prgba32。又称 预乘的RGBA8888、R8G8B8A8。
由于现在是做图像水平翻转,无需精确到颜色通道,而是可以将整个像素作为整体进行处理。所以,本文的算法对所有的32位像素格式都有效,不仅上面提到的Bgr32等格式,其实连 Cmyk32等其他32位像素也有效。
1.2 算法实现
知道像素的字节数(cbPixel)后,便可以根据它来复制像素了。32位,就是4个字节。
源代码如下。
public static unsafe void ScalarDoBatch(byte* pSrc, int strideSrc, int width, int height, byte* pDst, int strideDst) { const int cbPixel = 4; // 32 bit: Bgr32, Bgra32, Rgb32, Rgba32. byte* pRow = pSrc; byte* qRow = pDst; for (int i = 0; i < height; i++) { byte* p = pRow + (width - 1) * cbPixel; byte* q = qRow; for (int j = 0; j < width; j++) { for (int k = 0; k < cbPixel; k++) { q[k] = p[k]; } p -= cbPixel; q += cbPixel; } pRow += strideSrc; qRow += strideDst; } }
用指针来编写图像的水平翻转算法,最关键的是做好地址计算。现在是水平翻转,故重点是做好行内像素(内循环j)相关的地址计算。
内循环采用了“逆序读取、顺序写入”的策略。具体来说——
- 读取是从最后像素开始的,每次循环后移动到前一个像素。于是在上面的源代码中,p的初值是
pRow + (width - 1) * cbPixel
(目标位图最后一行的地址),每次循环后q会 减去 cbPixel。 - 写入是从第0个像素开始的,每次循环后移动到下一个像素。于是在上面的源代码中,q的初值就是
qRow
,每次循环后q会 加上 cbPixel。
1.3 基准测试代码
使用 BenchmarkDotNet 进行基准测试。
[Benchmark(Baseline = true)] public void Scalar() { ScalarDo(_sourceBitmapData, _destinationBitmapData, false); } //[Benchmark] public void ScalarParallel() { ScalarDo(_sourceBitmapData, _destinationBitmapData, true); } public static unsafe void ScalarDo(BitmapData src, BitmapData dst, bool useParallel = false) { int width = src.Width; int height = src.Height; int strideSrc = src.Stride; int strideDst = dst.Stride; byte* pSrc = (byte*)src.Scan0.ToPointer(); byte* pDst = (byte*)dst.Scan0.ToPointer(); bool allowParallel = useParallel && (height > 16) && (Environment.ProcessorCount > 1); if (allowParallel) { Parallel.For(0, height, i => { int start = i; int len = 1; byte* pSrc2 = pSrc + start * (long)strideSrc; byte* pDst2 = pDst + start * (long)strideDst; ScalarDoBatch(pSrc2, strideSrc, width, len, pDst2, strideDst); }); } else { ScalarDoBatch(pSrc, strideSrc, width, height, pDst, strideDst); } }
由于现在是图像水平翻转,是对行内像素进行处理。而对外循环的每一行,可以简单的依次来处理。于是并行(allowParallel)计算时的地址计算比较简单。
二、向量算法
2.1 算法思路
2.1.1 一个向量内如何做翻转
上面的标量算法是每次复制1个字节,而向量算法可以每次复制1个向量。
此时会遇到第一个难点——向量的颗粒度太大了。先前的标量算法是逐个字节的复制,能精准定位到每一个字节,具有很高的灵活性。而现在使用向量类型后,是一次性操作至少16个字节,笨重了很多。
Vector 类型的最小长度是128位,既16个字节。此时对于32位像素来说,1个Vector内可以存储4个像素。
所以首先要解决一个向量内如何做翻转的难题。
2.1.1.1 .NET 7.0
的解决办法
在 .NET 7.0
之前,是没有好的办法。
从.NET 7.0
开始,Vector128等向量类型增加了 Shuffle 方法。用该方法,可以给向量内的元素进行换位。为了支持不同的元素类型,该方法具有这些重载:
public static Vector128<byte> Shuffle(Vector128<byte> vector, Vector128<byte> indices); public static Vector128<int> Shuffle(Vector128<int> vector, Vector128<int> indices); ...
参数说明如下。
- vector: 源向量。
- indices: 索引。
- 返回值:一个新向量,其中包含在vector里根据 indices所选定的值。例如它的第i个元素,就是vector里的第
indices[i]
个元素。即vector[indices[i]]
。若索引超过范围,对应的元素会设置为0。
首先想到的是使用byte版的Shuffle方法,来做向量内的翻转。因为这是先前标量算法的思路。
但它不是最佳选择。因为现在是对32位像素进行处理,可以将整个像素一起处理。int是32位整数,于是可以选择int版的Shuffle方法。(由于是整个像素进行处理,不必关心符号位等细节,故 int、uint都能处理。只是用int会更简洁一些)
Vector128里可以存放4个32位像素。于是可以使用下面的代码进行翻转。
// Vector128<int> src = …… // 加载源值. Vector128<int> indices = Vector128.Create((int)3, 2, 1, 0); Vector128<int> dst = Vector128.Shuffle(src, indices);
上述代码能够正常工作。但是实际使用,你会发现它存在一个重大缺点——速度太慢。
对它进行反汇编分析,会发现直至 .NET 8.0
,Shuffle都没有硬件加速。而是使用了标量回退代码。
除了没有硬件加速外,Shuffle还存在这些缺点:
- 仅固定大小的向量类型(如 Vector128、Vector256 等)提供了Shuffle方法,而自动大小的向量类型(Vector)尚未提供。
.NET 7.0
才开始提供Shuffle方法,而早期版本的.NET
没有这个方法,导致很多算法难以实现。
2.1.1.2 使用VectorTraits来解决Shuffle的缺点
为了解决 Shuffle 方法没有硬件加速的问题,我开发了VectorTraits 库。它使用了各个架构的shuffle类别的指令,从而使 Shuffle 方法具有硬件加速。具体来说,它分别使用了以下指令。
- X86: 使用
_mm_shuffle_epi8
等指令. - Arm: 使用
vqvtbl1q_u8
指令. - Wasm: 使用
i8x16.swizzle
指令.
VectorTraits 不仅为固定大小的向量类型(如 Vector128)提供了Shuffle方法,它还为自动大小的向量类型(Vector)也提供了Vector方法。
而且 VectorTraits 能支持早期版本的 .NET
。目前 3.0 版的VectorTraits,支持以下 .NET
版本。
.NET
: 5.0 - 8.0。.NET Core
: 2.0 - 3.1。.NET Framework
: 4.5 - 4.8.1。.NET Standard
: 1.1 - 2.1。
借助VectorTraits,可以方便的编写跨平台的SIMD硬件加速向量算法。
VectorTraits给各种向量类型,都提供了对应的静态类,规则是 “原名+s”。例如对于Vector128,提供了Vector128s类。于是将上述代码中的 Vector128s.Shuffle
,加上一个字母“s”,使其变为 Vector128.Shuffle
,便能享有SIMD硬件加速。
using Zyl.VectorTraits; // Vector128<int> src = …… // 加载源值. Vector128<int> indices = Vector128.Create((int)3, 2, 1, 0); Vector128<int> dst = Vector128s.Shuffle(src, indices);
2.1.1.3 使用自动大小的向量类型Vector
从.NET Core 3.0
开始,才提供Vector128等固定大小向量类型。所以上面代码需要 .NET Core 3.0
或更高的环境。
如果是更早版本的 .NET
,该怎么办呢?
答案是——换成自动大小的向量类型Vector。
从 .NET Framework 4.5
开始,使用 nuget 安装了 System.Numerics.Vectors
包后,就能使用自动大小的向量类型Vector。
Vector 类型的大小不是固定的。一般来说,它是本机CPU的最大向量大小。
- X86:当支持 Avx和Avx2 指令集时,为256位;否则(例如仅支持 Sse系列指令集时)为128位。(直至
.NET 8.0
, Vector 类型还不支持512位。即使CPU支持Avx512指令集,Vector 类型还是最高256位) - Arm:目前固定为 128位。
- Wasm:目前固定为 128位。
Vector 类型提供了 Count属性,用来获取向量内元素数量。
- 若 Vector为128位时,
Vector<int>.Count
的结果为4。 - 若 Vector为256位时,
Vector<int>.Count
的结果为8。
由于Vector 类型的大小不是固定的,这给我们使用Shuffle方法带来了一些麻烦。先前给Vector128类型的indices设置初值时,因为元素数量固定,故直接写好每一个值就行。而面对自动大小的向量类型Vector,不能直接给indices设置初值。
查看文档,会发现 Vector的构造函数支持数组参数。故可以事先创建好数组,随后写个循环,在数组内填充值,最后用 Vector的构造函数来创建向量。
从.NET Core 3.0
开始,Vector的构造函数还支持Span参数。于是可以使用栈分配,来减少内存分配的开销。源代码如下。
Span<int> buf = stackalloc int[Vector<int>.Count]; for (int i = 0;i< Vector<int>.Count; i++) { buf[i] = Vector<int>.Count - 1 - i; } indices = Vectors.Create(buf);
上面代码中的 Vector<int>.Count - 1 - i
,就是计算各个元素在逆序时的索引。
还可以注意到,上面的代码并未使用构造函数来创建 Vector,而是使用VectorTraits提供的 Vectors.Create
方法。这是为了能支持 .NET Core 3.0
之前的版本,例如 .NET Framework 4.5
。
由于在程序启动后,Vector的Count属性将会固定为实际的值。于是没必要每次重新计算 indices,可以将它的计算挪至类的静态构造方法。
private static readonly Vector<int> _shuffleIndices; static ImageFlipXOn32bitBenchmark() { bool AllowCreateByDoubleLoop = true; if (AllowCreateByDoubleLoop) { _shuffleIndices = Vectors.CreateByDoubleLoop<int>(Vector<int>.Count - 1, -1); } else { Span<int> buf = stackalloc int[Vector<int>.Count]; for (int i = 0;i< Vector<int>.Count; i++) { buf[i] = Vector<int>.Count - 1 - i; } _shuffleIndices = Vectors.Create(buf); } }
从上面的源代码中可以发现,Vectors 还提供了 CreateByDoubleLoop 方法,可以简化 indices 这样的向量的初始化。它比使用for循环要方便了很多。
有了 indices值(实际是 _shuffleIndices)后,便可以使用 Vectors 的 Shuffle 方法,对 自动大小的向量类型Vector 进行换位了。源代码如下。
// Vector<int> src = …… // 加载源值. Vector<int> indices =_shuffleIndices; Vector<int> dst = Vectors.Shuffle(src, indices);
与先前Vector128的用法相同。
2.1.1.4 使用YShuffleKernel方法来做进一步的优化
Shuffle 方法还具有清零功能。若索引超过范围,对应的元素会设置为0。
而现在是做翻转,索引总是在有效范围内。于是,可以将 Shuffle 更换成 YShuffleKernel 方法。它不判断索引是否在范围内,所以它的性能一般更好。
// Vector<int> src = …… // 加载源值. Vector<int> indices =_shuffleIndices; Vector<int> dst = Vectors.YShuffleKernel(src, indices);
为了与BCL的方法名进行区分,VectorTraits库追加的方法,都统一以字母“Y”开头。
2.1.2 翻转一行
基于“一个向量内翻转”的办法,可实现对一行像素进行翻转。具体来说,依然可以按照“逆序读取、顺序写入”的策略来处理。
若一行像素的字节数,刚好是向量大小的整数倍时,此时处理起来最简单。算法步骤如下:
- 对源指针p设置初值,将它指向源位图当前行的最后一笔数据的地址。即:
Vector<int>* p = (Vector<int>*)(pRow + maxX * cbPixel)
。 - 对目标指针q设置初值,将它指向目标位图当前行的起始地址。即:
Vector<int>* q = (Vector<int>*)qRow
。 - 根据源指针p,将内存中的数据加载到向量中。即:
data = *p
。 - 对向量进行翻转。即:
temp = Vectors.YShuffleKernel(data, indices)
。 - 根据目标指针q,将向量写入到内存中。即:
*q = temp
。 - 判断数据是否都处理完了。若是,跳到第9步完成。
- 移动指针,处理下一个向量。即:
--p; ++q;
。 - 跳到第3步,继续循环。
- 完成。
假设向量大小为128位,此时1个向量里可以存储4个像素。下面演示一下各种倍数时的处理情况:
- 1倍:此时一行是 4*1=4个像素。水平翻转是将
{x0, x1, x2, x3}
,翻转为{x3, x2, x1, x0}
. - 2倍:此时一行是 4*2=8个像素。水平翻转是将
{x0, x1, x2, x3, x4, x5, x6, x7}
,翻转为{x7, x6, x5, x4, x3, x2, x1, x0}
. - 3倍:此时一行是 4*3=12个像素。水平翻转是将
{x0, x1, x2, x3, x4, x5, x6, x7, x8, x9, x10, x11}
,翻转为{x11, x10, x9, x8, x7, x6, x5, x4, x3, x2, x1, x0}
. - ……
从上面的数据中可以看出,按照“逆序读取、顺序写入”的策略来翻转数据,便能顺利的完成图像的水平翻转。
在实际使用中,一行像素的字节数在大多数时候,并不是向量大小的整数倍,此时处理起来会复杂一些。可以参考上一篇文章里提到的“末尾指针”办法,进行处理。
2.2 算法实现
根据上面的思路,编写代码。源代码如下。
public static unsafe void UseVectorsDoBatch(byte* pSrc, int strideSrc, int width, int height, byte* pDst, int strideDst) { const int cbPixel = 4; // 32 bit: Bgr32, Bgra32, Rgb32, Rgba32. Vector<int> indices = _shuffleIndices; int vectorWidth = Vector<int>.Count; int maxX = width - vectorWidth; byte* pRow = pSrc; byte* qRow = pDst; for (int i = 0; i < height; i++) { Vector<int>* pLast = (Vector<int>*)pRow; Vector<int>* qLast = (Vector<int>*)(qRow + maxX * cbPixel); Vector<int>* p = (Vector<int>*)(pRow + maxX * cbPixel); Vector<int>* q = (Vector<int>*)qRow; for (; ; ) { Vector<int> data, temp; // Load. data = *p; // FlipX. //temp = Vectors.Shuffle(data, indices); temp = Vectors.YShuffleKernel(data, indices); // Store. *q = temp; // Next. if (p <= pLast) break; --p; ++q; if (p < pLast) p = pLast; // The last block is also use vector. if (q > qLast) q = qLast; } pRow += strideSrc; qRow += strideDst; } }
2.3 基准测试代码
随后为该算法编写基准测试代码。
[Benchmark] public void UseVectors() { UseVectorsDo(_sourceBitmapData, _destinationBitmapData, false); } //[Benchmark] public void UseVectorsParallel() { UseVectorsDo(_sourceBitmapData, _destinationBitmapData, true); } public static unsafe void UseVectorsDo(BitmapData src, BitmapData dst, bool useParallel = false) { int vectorWidth = Vector<byte>.Count; int width = src.Width; int height = src.Height; if (width <= vectorWidth) { ScalarDo(src, dst, useParallel); return; } int strideSrc = src.Stride; int strideDst = dst.Stride; byte* pSrc = (byte*)src.Scan0.ToPointer(); byte* pDst = (byte*)dst.Scan0.ToPointer(); bool allowParallel = useParallel && (height > 16) && (Environment.ProcessorCount > 1); if (allowParallel) { Parallel.For(0, height, i => { int start = i; int len = 1; byte* pSrc2 = pSrc + start * (long)strideSrc; byte* pDst2 = pDst + start * (long)strideDst; UseVectorsDoBatch(pSrc2, strideSrc, width, len, pDst2, strideDst); }); } else { UseVectorsDoBatch(pSrc, strideSrc, width, height, pDst, strideDst); } }
2.4 使用 YShuffleKernel_Args 来做进一步的优化
可以进一步提高性能,就是使用 YShuffleKernel_Args与YShuffleKernel_Core。
若循环内存在一些重复计算的话,可以将这些计算挪至循环外,从而提高了性能。Args、Core 后缀的方法,就是这种情况下使用的。
Args
: 参数运算。例如用于检查及转换参数。用本方法转换参数后,随后可调用 Core 版方法。一般在循环前使用。Core
: 核心运算。需先调用 Args 版函数,才可调用本方法。一般在循环内使用。
于是我们可以将YShuffleKernel,换为 YShuffleKernel_Args与YShuffleKernel_Core。源代码如下。
public static unsafe void UseVectorsArgsDoBatch(byte* pSrc, int strideSrc, int width, int height, byte* pDst, int strideDst) { const int cbPixel = 4; // 32 bit: Bgr32, Bgra32, Rgb32, Rgba32. Vector<int> indices = _shuffleIndices; Vector<int> args0, args1; Vectors.YShuffleKernel_Args(indices, out args0, out args1); int vectorWidth = Vector<int>.Count; int maxX = width - vectorWidth; byte* pRow = pSrc; byte* qRow = pDst; for (int i = 0; i < height; i++) { Vector<int>* pLast = (Vector<int>*)pRow; Vector<int>* qLast = (Vector<int>*)(qRow + maxX * cbPixel); Vector<int>* p = (Vector<int>*)(pRow + maxX * cbPixel); Vector<int>* q = (Vector<int>*)qRow; for (; ; ) { Vector<int> data, temp; // Load. data = *p; // FlipX. //temp = Vectors.YShuffleKernel(data, indices); temp = Vectors.YShuffleKernel_Core(data, args0, args1); // Store. *q = temp; // Next. if (p <= pLast) break; --p; ++q; if (p < pLast) p = pLast; // The last block is also use vector. if (q > qLast) q = qLast; } pRow += strideSrc; qRow += strideDst; } }
三、基准测试结果
3.1 X86 架构
X86架构下的基准测试结果如下。
BenchmarkDotNet v0.14.0, Windows 11 (10.0.22631.4541/23H2/2023Update/SunValley3) AMD Ryzen 7 7840H w/ Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores .NET SDK 8.0.403 [Host] : .NET 8.0.10 (8.0.1024.46610), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI DefaultJob : .NET 8.0.10 (8.0.1024.46610), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI | Method | Width | Mean | Error | StdDev | Ratio | RatioSD | |--------------- |------ |------------:|----------:|----------:|------:|--------:| | Scalar | 1024 | 784.7 us | 14.56 us | 14.30 us | 1.00 | 0.03 | | UseVectors | 1024 | 106.4 us | 2.12 us | 4.96 us | 0.14 | 0.01 | | UseVectorsArgs | 1024 | 101.4 us | 2.03 us | 3.85 us | 0.13 | 0.01 | | | | | | | | | | Scalar | 2048 | 3,453.5 us | 25.88 us | 22.94 us | 1.00 | 0.01 | | UseVectors | 2048 | 1,520.8 us | 15.11 us | 14.13 us | 0.44 | 0.00 | | UseVectorsArgs | 2048 | 1,412.9 us | 27.96 us | 47.48 us | 0.41 | 0.01 | | | | | | | | | | Scalar | 4096 | 12,932.8 us | 177.40 us | 165.94 us | 1.00 | 0.02 | | UseVectors | 4096 | 6,113.0 us | 43.35 us | 40.55 us | 0.47 | 0.01 | | UseVectorsArgs | 4096 | 6,270.9 us | 56.80 us | 50.35 us | 0.48 | 0.01 |
- Scalar: 标量算法。
- UseVectors: 向量算法。
- UseVectorsArgs: 使用Args将部分运算挪至循环前的向量算法。
以1024时的测试结果为例,UseVectorsArgs的处理性能,大约是Scalar的 7.74 倍。即向量化算法的性能,是标量算法的7.74 倍。
注:784.7 / 101.4 ≈ 7.74
3.2 Arm 架构
同样的源代码可以在 Arm 架构上运行。基准测试结果如下。
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 8.0.204 [Host] : .NET 8.0.4 (8.0.424.16909), Arm64 RyuJIT AdvSIMD [AttachedDebugger] DefaultJob : .NET 8.0.4 (8.0.424.16909), Arm64 RyuJIT AdvSIMD | Method | Width | Mean | Error | StdDev | Ratio | |--------------- |------ |------------:|---------:|---------:|------:| | Scalar | 1024 | 625.8 us | 0.81 us | 0.68 us | 1.00 | | UseVectors | 1024 | 151.9 us | 0.32 us | 0.27 us | 0.24 | | UseVectorsArgs | 1024 | 151.2 us | 0.13 us | 0.12 us | 0.24 | | | | | | | | | Scalar | 2048 | 2,522.4 us | 1.28 us | 1.14 us | 1.00 | | UseVectors | 2048 | 666.9 us | 0.55 us | 0.51 us | 0.26 | | UseVectorsArgs | 2048 | 663.8 us | 0.80 us | 0.67 us | 0.26 | | | | | | | | | Scalar | 4096 | 10,797.2 us | 11.21 us | 10.48 us | 1.00 | | UseVectors | 4096 | 3,349.0 us | 39.67 us | 37.11 us | 0.31 | | UseVectorsArgs | 4096 | 3,339.6 us | 20.76 us | 16.21 us | 0.31 |
以1024时的测试结果为例,UseVectorsArgs的处理性能,大约是Scalar的 4.14 倍。即向量化算法的性能,是标量算法的4.14 倍。
注:625.8 / 151.2 ≈ 4.14
此时很多人会注意到,UseVectors 与 UseVectorsArgs的性能差距不大。貌似Args版方法的作用不大啊。
这是因为从 .NET 7.0
开始,即时编译器(JIT)会自动将部分运算挪至循环前去处理,造成了差距不大的现象。若换成早期版本的 .NET
,差距会比较明显。
3.2.1 Arm 架构的 .NET 6.0
测试结果
将程序编译为 .NET 6.0
的,拿到 Arm 架构上运行。基准测试结果如下。
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 8.0.204 [Host] : .NET 6.0.33 (6.0.3324.36610), Arm64 RyuJIT AdvSIMD [AttachedDebugger] DefaultJob : .NET 6.0.33 (6.0.3324.36610), Arm64 RyuJIT AdvSIMD | Method | Width | Mean | Error | StdDev | Ratio | |--------------- |------ |------------:|---------:|---------:|------:| | Scalar | 1024 | 1,805.2 us | 0.72 us | 0.60 us | 1.00 | | UseVectors | 1024 | 454.5 us | 5.45 us | 5.10 us | 0.25 | | UseVectorsArgs | 1024 | 158.4 us | 0.05 us | 0.04 us | 0.09 | | | | | | | | | Scalar | 2048 | 7,229.0 us | 2.88 us | 2.69 us | 1.00 | | UseVectors | 2048 | 1,857.4 us | 2.73 us | 2.56 us | 0.26 | | UseVectorsArgs | 2048 | 656.2 us | 0.26 us | 0.23 us | 0.09 | | | | | | | | | Scalar | 4096 | 29,574.1 us | 13.21 us | 11.03 us | 1.00 | | UseVectors | 4096 | 8,117.2 us | 28.06 us | 26.25 us | 0.27 | | UseVectorsArgs | 4096 | 4,671.7 us | 2.50 us | 2.21 us | 0.16 |
以1024时的测试结果为例,来观察向量化算法相对于标量算法的性能提升。
- UseVectors:1,805.2/454.5 ≈ 3.97。即性能提高了 3.97 倍。
- UseVectorsArgs:1,805.2/158.4 ≈ 11.40。即性能提高了 11.40 倍。
3.3 .NET Framework
同样的源代码可以在 .NET Framework
上运行。基准测试结果如下。
BenchmarkDotNet v0.14.0, Windows 11 (10.0.22631.4541/23H2/2023Update/SunValley3) AMD Ryzen 7 7840H w/ Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores [Host] : .NET Framework 4.8.1 (4.8.9282.0), X64 RyuJIT VectorSize=256 DefaultJob : .NET Framework 4.8.1 (4.8.9282.0), X64 RyuJIT VectorSize=256 | Method | Width | Mean | Error | StdDev | Ratio | RatioSD | Code Size | |--------------- |------ |------------:|----------:|----------:|------:|--------:|----------:| | Scalar | 1024 | 1,315.2 us | 26.06 us | 25.59 us | 1.00 | 0.03 | 2,718 B | | UseVectors | 1024 | 968.2 us | 17.55 us | 16.42 us | 0.74 | 0.02 | 3,507 B | | UseVectorsArgs | 1024 | 887.0 us | 9.91 us | 8.78 us | 0.67 | 0.01 | 3,507 B | | | | | | | | | | | Scalar | 2048 | 5,259.4 us | 85.87 us | 80.32 us | 1.00 | 0.02 | 2,718 B | | UseVectors | 2048 | 3,696.0 us | 29.64 us | 27.72 us | 0.70 | 0.01 | 3,507 B | | UseVectorsArgs | 2048 | 3,722.9 us | 39.36 us | 34.90 us | 0.71 | 0.01 | 3,507 B | | | | | | | | | | | Scalar | 4096 | 19,763.1 us | 300.29 us | 266.20 us | 1.00 | 0.02 | 2,718 B | | UseVectors | 4096 | 14,303.8 us | 62.36 us | 55.28 us | 0.72 | 0.01 | 3,507 B | | UseVectorsArgs | 4096 | 14,988.7 us | 286.49 us | 281.37 us | 0.76 | 0.02 | 3,507 B |
以1024时的测试结果为例,UseVectorsArgs的处理性能,大约是Scalar的 1.48 倍。
注:1,315.2 / 887.0 ≈ 1.48
其实,因为 .NET Framework
不支持Sse等指令集,所以 Vectors用的是标量回退代码。只要由于它的标量算法也是高度优化的,且它是基于 int 来处理的,于是它的性能比基于byte的标量算法要好。
附录
- 完整源代码: https://github.com/zyl910/VectorTraits.Sample.Benchmarks/blob/main/VectorTraits.Sample.Benchmarks.Inc/Image/ImageFlipXOn32bitBenchmark.cs
- YShuffleKernel 的文档: https://zyl910.github.io/VectorTraits_doc/api/Zyl.VectorTraits.Vectors.YShuffleKernel.html
- 微软官方文档-Vector128.Shuffle 方法: https://learn.microsoft.com/zh-cn/dotnet/api/system.runtime.intrinsics.vector128.shuffle?view=net-9.0
- VectorTraits 的NuGet包: https://www.nuget.org/packages/VectorTraits
- VectorTraits 的在线文档: https://zyl910.github.io/VectorTraits_doc/
- VectorTraits 源代码: https://github.com/zyl910/VectorTraits
- C# 使用SIMD向量类型加速浮点数组求和运算(1):使用Vector4、
Vector<T>