翼度科技»论坛 编程开发 .net 查看内容

[C#] 对32位图像进行水平翻转(FlipX)的跨平台SIMD硬件加速向量算法(使用Ve

6

主题

6

帖子

18

积分

新手上路

Rank: 1

积分
18
上一篇文章里,我们讲解了图像的垂直翻转(FlipY)算法,于是本文来探讨水平翻转(FlipX)。先讲解比较容易的32位图像水平翻转算法,便于后续文章来探讨复杂的24位图像水平翻转算法。
本文除了会给出标量算法外,还会给出向量算法。且这些算法是跨平台的,同一份源代码,能在 X86(Sse、Avx等指令集)及Arm(AdvSimd等指令集)等架构上运行,且均享有SIMD硬件加速。
一、标量算法

1.1 算法思路

水平翻转又称左右翻转,是将图像沿着垂直中轴线进行翻转。
假设用 src[x, y] 可以访问源图像中的像素,用 dst[x, y] 可以访问目标图像中的像素,width是图像的像素宽度。那么水平翻转的公式为——
  1. 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个字节。
源代码如下。
  1. public static unsafe void ScalarDoBatch(byte* pSrc, int strideSrc, int width, int height, byte* pDst, int strideDst) {
  2.     const int cbPixel = 4; // 32 bit: Bgr32, Bgra32, Rgb32, Rgba32.
  3.     byte* pRow = pSrc;
  4.     byte* qRow = pDst;
  5.     for (int i = 0; i < height; i++) {
  6.         byte* p = pRow + (width - 1) * cbPixel;
  7.         byte* q = qRow;
  8.         for (int j = 0; j < width; j++) {
  9.             for (int k = 0; k < cbPixel; k++) {
  10.                 q[k] = p[k];
  11.             }
  12.             p -= cbPixel;
  13.             q += cbPixel;
  14.         }
  15.         pRow += strideSrc;
  16.         qRow += strideDst;
  17.     }
  18. }
复制代码
用指针来编写图像的水平翻转算法,最关键的是做好地址计算。现在是水平翻转,故重点是做好行内像素(内循环j)相关的地址计算。
内循环采用了“逆序读取、顺序写入”的策略。具体来说——

  • 读取是从最后像素开始的,每次循环后移动到前一个像素。于是在上面的源代码中,p的初值是 pRow + (width - 1) * cbPixel(目标位图最后一行的地址),每次循环后q会 减去 cbPixel。
  • 写入是从第0个像素开始的,每次循环后移动到下一个像素。于是在上面的源代码中,q的初值就是 qRow,每次循环后q会 加上 cbPixel。
1.3 基准测试代码

使用 BenchmarkDotNet 进行基准测试。
  1. [Benchmark(Baseline = true)]
  2. public void Scalar() {
  3.     ScalarDo(_sourceBitmapData, _destinationBitmapData, false);
  4. }
  5. //[Benchmark]
  6. public void ScalarParallel() {
  7.     ScalarDo(_sourceBitmapData, _destinationBitmapData, true);
  8. }
  9. public static unsafe void ScalarDo(BitmapData src, BitmapData dst, bool useParallel = false) {
  10.     int width = src.Width;
  11.     int height = src.Height;
  12.     int strideSrc = src.Stride;
  13.     int strideDst = dst.Stride;
  14.     byte* pSrc = (byte*)src.Scan0.ToPointer();
  15.     byte* pDst = (byte*)dst.Scan0.ToPointer();
  16.     bool allowParallel = useParallel && (height > 16) && (Environment.ProcessorCount > 1);
  17.     if (allowParallel) {
  18.         Parallel.For(0, height, i => {
  19.             int start = i;
  20.             int len = 1;
  21.             byte* pSrc2 = pSrc + start * (long)strideSrc;
  22.             byte* pDst2 = pDst + start * (long)strideDst;
  23.             ScalarDoBatch(pSrc2, strideSrc, width, len, pDst2, strideDst);
  24.         });
  25.     } else {
  26.         ScalarDoBatch(pSrc, strideSrc, width, height, pDst, strideDst);
  27.     }
  28. }
复制代码
由于现在是图像水平翻转,是对行内像素进行处理。而对外循环的每一行,可以简单的依次来处理。于是并行(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 方法。用该方法,可以给向量内的元素进行换位。为了支持不同的元素类型,该方法具有这些重载:
  1. public static Vector128<byte> Shuffle(Vector128<byte> vector, Vector128<byte> indices);
  2. public static Vector128<int> Shuffle(Vector128<int> vector, Vector128<int> indices);
  3. ...
复制代码
参数说明如下。

  • vector: 源向量。
  • indices: 索引。
  • 返回值:一个新向量,其中包含在vector里根据 indices所选定的值。例如它的第i个元素,就是vector里的第 indices 个元素。即 vector[indices]。若索引超过范围,对应的元素会设置为0。
首先想到的是使用byte版的Shuffle方法,来做向量内的翻转。因为这是先前标量算法的思路。
但它不是最佳选择。因为现在是对32位像素进行处理,可以将整个像素一起处理。int是32位整数,于是可以选择int版的Shuffle方法。(由于是整个像素进行处理,不必关心符号位等细节,故 int、uint都能处理。只是用int会更简洁一些)
Vector128里可以存放4个32位像素。于是可以使用下面的代码进行翻转。
  1. // Vector128<int> src = …… // 加载源值.
  2. Vector128<int> indices = Vector128.Create((int)3, 2, 1, 0);
  3. 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硬件加速。
  1. using Zyl.VectorTraits;
  2. // Vector128<int> src = …… // 加载源值.
  3. Vector128<int> indices = Vector128.Create((int)3, 2, 1, 0);
  4. 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.Count 的结果为4。
  • 若 Vector为256位时,Vector.Count 的结果为8。
由于Vector 类型的大小不是固定的,这给我们使用Shuffle方法带来了一些麻烦。先前给Vector128类型的indices设置初值时,因为元素数量固定,故直接写好每一个值就行。而面对自动大小的向量类型Vector,不能直接给indices设置初值。
查看文档,会发现 Vector的构造函数支持数组参数。故可以事先创建好数组,随后写个循环,在数组内填充值,最后用 Vector的构造函数来创建向量。
从.NET Core 3.0开始,Vector的构造函数还支持Span参数。于是可以使用栈分配,来减少内存分配的开销。源代码如下。
  1. Span<int> buf = stackalloc int[Vector<int>.Count];
  2. for (int i = 0;i< Vector<int>.Count; i++) {
  3.     buf[i] = Vector<int>.Count - 1 - i;
  4. }
  5. indices = Vectors.Create(buf);
复制代码
上面代码中的 Vector.Count - 1 - i,就是计算各个元素在逆序时的索引。
还可以注意到,上面的代码并未使用构造函数来创建 Vector,而是使用VectorTraits提供的 Vectors.Create方法。这是为了能支持 .NET Core 3.0 之前的版本,例如 .NET Framework 4.5。
由于在程序启动后,Vector的Count属性将会固定为实际的值。于是没必要每次重新计算 indices,可以将它的计算挪至类的静态构造方法。
  1. private static readonly Vector<int> _shuffleIndices;
  2. static ImageFlipXOn32bitBenchmark() {
  3.     bool AllowCreateByDoubleLoop = true;
  4.     if (AllowCreateByDoubleLoop) {
  5.         _shuffleIndices = Vectors.CreateByDoubleLoop<int>(Vector<int>.Count - 1, -1);
  6.     } else {
  7.         Span<int> buf = stackalloc int[Vector<int>.Count];
  8.         for (int i = 0;i< Vector<int>.Count; i++) {
  9.             buf[i] = Vector<int>.Count - 1 - i;
  10.         }
  11.         _shuffleIndices = Vectors.Create(buf);
  12.     }
  13. }
复制代码
从上面的源代码中可以发现,Vectors 还提供了 CreateByDoubleLoop 方法,可以简化 indices 这样的向量的初始化。它比使用for循环要方便了很多。
有了 indices值(实际是 _shuffleIndices)后,便可以使用 Vectors 的 Shuffle 方法,对 自动大小的向量类型Vector 进行换位了。源代码如下。
  1. // Vector<int> src = …… // 加载源值.
  2. Vector<int> indices =_shuffleIndices;
  3. Vector<int> dst = Vectors.Shuffle(src, indices);
复制代码
与先前Vector128的用法相同。
2.1.1.4 使用YShuffleKernel方法来做进一步的优化

Shuffle 方法还具有清零功能。若索引超过范围,对应的元素会设置为0。
而现在是做翻转,索引总是在有效范围内。于是,可以将 Shuffle 更换成 YShuffleKernel 方法。它不判断索引是否在范围内,所以它的性能一般更好。
  1. // Vector<int> src = …… // 加载源值.
  2. Vector<int> indices =_shuffleIndices;
  3. Vector<int> dst = Vectors.YShuffleKernel(src, indices);
复制代码
为了与BCL的方法名进行区分,VectorTraits库追加的方法,都统一以字母“Y”开头。
2.1.2 翻转一行

基于“一个向量内翻转”的办法,可实现对一行像素进行翻转。具体来说,依然可以按照“逆序读取、顺序写入”的策略来处理。
若一行像素的字节数,刚好是向量大小的整数倍时,此时处理起来最简单。算法步骤如下:

  • 对源指针p设置初值,将它指向源位图当前行的最后一笔数据的地址。即: Vector* p = (Vector*)(pRow + maxX * cbPixel)。
  • 对目标指针q设置初值,将它指向目标位图当前行的起始地址。即: Vector* q = (Vector*)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 算法实现

根据上面的思路,编写代码。源代码如下。
  1. public static unsafe void UseVectorsDoBatch(byte* pSrc, int strideSrc, int width, int height, byte* pDst, int strideDst) {
  2.     const int cbPixel = 4; // 32 bit: Bgr32, Bgra32, Rgb32, Rgba32.
  3.     Vector<int> indices = _shuffleIndices;
  4.     int vectorWidth = Vector<int>.Count;
  5.     int maxX = width - vectorWidth;
  6.     byte* pRow = pSrc;
  7.     byte* qRow = pDst;
  8.     for (int i = 0; i < height; i++) {
  9.         Vector<int>* pLast = (Vector<int>*)pRow;
  10.         Vector<int>* qLast = (Vector<int>*)(qRow + maxX * cbPixel);
  11.         Vector<int>* p = (Vector<int>*)(pRow + maxX * cbPixel);
  12.         Vector<int>* q = (Vector<int>*)qRow;
  13.         for (; ; ) {
  14.             Vector<int> data, temp;
  15.             // Load.
  16.             data = *p;
  17.             // FlipX.
  18.             //temp = Vectors.Shuffle(data, indices);
  19.             temp = Vectors.YShuffleKernel(data, indices);
  20.             // Store.
  21.             *q = temp;
  22.             // Next.
  23.             if (p <= pLast) break;
  24.             --p;
  25.             ++q;
  26.             if (p < pLast) p = pLast; // The last block is also use vector.
  27.             if (q > qLast) q = qLast;
  28.         }
  29.         pRow += strideSrc;
  30.         qRow += strideDst;
  31.     }
  32. }
复制代码
2.3 基准测试代码

随后为该算法编写基准测试代码。
  1. [Benchmark]
  2. public void UseVectors() {
  3.     UseVectorsDo(_sourceBitmapData, _destinationBitmapData, false);
  4. }
  5. //[Benchmark]
  6. public void UseVectorsParallel() {
  7.     UseVectorsDo(_sourceBitmapData, _destinationBitmapData, true);
  8. }
  9. public static unsafe void UseVectorsDo(BitmapData src, BitmapData dst, bool useParallel = false) {
  10.     int vectorWidth = Vector<byte>.Count;
  11.     int width = src.Width;
  12.     int height = src.Height;
  13.     if (width <= vectorWidth) {
  14.         ScalarDo(src, dst, useParallel);
  15.         return;
  16.     }
  17.     int strideSrc = src.Stride;
  18.     int strideDst = dst.Stride;
  19.     byte* pSrc = (byte*)src.Scan0.ToPointer();
  20.     byte* pDst = (byte*)dst.Scan0.ToPointer();
  21.     bool allowParallel = useParallel && (height > 16) && (Environment.ProcessorCount > 1);
  22.     if (allowParallel) {
  23.         Parallel.For(0, height, i => {
  24.             int start = i;
  25.             int len = 1;
  26.             byte* pSrc2 = pSrc + start * (long)strideSrc;
  27.             byte* pDst2 = pDst + start * (long)strideDst;
  28.             UseVectorsDoBatch(pSrc2, strideSrc, width, len, pDst2, strideDst);
  29.         });
  30.     } else {
  31.         UseVectorsDoBatch(pSrc, strideSrc, width, height, pDst, strideDst);
  32.     }
  33. }
复制代码
2.4 使用 YShuffleKernel_Args 来做进一步的优化

可以进一步提高性能,就是使用 YShuffleKernel_Args与YShuffleKernel_Core。
若循环内存在一些重复计算的话,可以将这些计算挪至循环外,从而提高了性能。Args、Core 后缀的方法,就是这种情况下使用的。

  • Args: 参数运算。例如用于检查及转换参数。用本方法转换参数后,随后可调用 Core 版方法。一般在循环前使用。
  • Core: 核心运算。需先调用 Args 版函数,才可调用本方法。一般在循环内使用。
于是我们可以将YShuffleKernel,换为 YShuffleKernel_Args与YShuffleKernel_Core。源代码如下。
  1. public static unsafe void UseVectorsArgsDoBatch(byte* pSrc, int strideSrc, int width, int height, byte* pDst, int strideDst) {
  2.     const int cbPixel = 4; // 32 bit: Bgr32, Bgra32, Rgb32, Rgba32.
  3.     Vector<int> indices = _shuffleIndices;
  4.     Vector<int> args0, args1;
  5.     Vectors.YShuffleKernel_Args(indices, out args0, out args1);
  6.     int vectorWidth = Vector<int>.Count;
  7.     int maxX = width - vectorWidth;
  8.     byte* pRow = pSrc;
  9.     byte* qRow = pDst;
  10.     for (int i = 0; i < height; i++) {
  11.         Vector<int>* pLast = (Vector<int>*)pRow;
  12.         Vector<int>* qLast = (Vector<int>*)(qRow + maxX * cbPixel);
  13.         Vector<int>* p = (Vector<int>*)(pRow + maxX * cbPixel);
  14.         Vector<int>* q = (Vector<int>*)qRow;
  15.         for (; ; ) {
  16.             Vector<int> data, temp;
  17.             // Load.
  18.             data = *p;
  19.             // FlipX.
  20.             //temp = Vectors.YShuffleKernel(data, indices);
  21.             temp = Vectors.YShuffleKernel_Core(data, args0, args1);
  22.             // Store.
  23.             *q = temp;
  24.             // Next.
  25.             if (p <= pLast) break;
  26.             --p;
  27.             ++q;
  28.             if (p < pLast) p = pLast; // The last block is also use vector.
  29.             if (q > qLast) q = qLast;
  30.         }
  31.         pRow += strideSrc;
  32.         qRow += strideDst;
  33.     }
  34. }
复制代码
三、基准测试结果

3.1 X86 架构

X86架构下的基准测试结果如下。
  1. BenchmarkDotNet v0.14.0, Windows 11 (10.0.22631.4541/23H2/2023Update/SunValley3)
  2. AMD Ryzen 7 7840H w/ Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores
  3. .NET SDK 8.0.403
  4.   [Host]     : .NET 8.0.10 (8.0.1024.46610), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  5.   DefaultJob : .NET 8.0.10 (8.0.1024.46610), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  6. | Method         | Width | Mean        | Error     | StdDev    | Ratio | RatioSD |
  7. |--------------- |------ |------------:|----------:|----------:|------:|--------:|
  8. | Scalar         | 1024  |    784.7 us |  14.56 us |  14.30 us |  1.00 |    0.03 |
  9. | UseVectors     | 1024  |    106.4 us |   2.12 us |   4.96 us |  0.14 |    0.01 |
  10. | UseVectorsArgs | 1024  |    101.4 us |   2.03 us |   3.85 us |  0.13 |    0.01 |
  11. |                |       |             |           |           |       |         |
  12. | Scalar         | 2048  |  3,453.5 us |  25.88 us |  22.94 us |  1.00 |    0.01 |
  13. | UseVectors     | 2048  |  1,520.8 us |  15.11 us |  14.13 us |  0.44 |    0.00 |
  14. | UseVectorsArgs | 2048  |  1,412.9 us |  27.96 us |  47.48 us |  0.41 |    0.01 |
  15. |                |       |             |           |           |       |         |
  16. | Scalar         | 4096  | 12,932.8 us | 177.40 us | 165.94 us |  1.00 |    0.02 |
  17. | UseVectors     | 4096  |  6,113.0 us |  43.35 us |  40.55 us |  0.47 |    0.01 |
  18. | 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 架构上运行。基准测试结果如下。
  1. BenchmarkDotNet v0.14.0, macOS Sequoia 15.1.1 (24B91) [Darwin 24.1.0]
  2. Apple M2, 1 CPU, 8 logical and 8 physical cores
  3. .NET SDK 8.0.204
  4.   [Host]     : .NET 8.0.4 (8.0.424.16909), Arm64 RyuJIT AdvSIMD [AttachedDebugger]
  5.   DefaultJob : .NET 8.0.4 (8.0.424.16909), Arm64 RyuJIT AdvSIMD
  6. | Method         | Width | Mean        | Error    | StdDev   | Ratio |
  7. |--------------- |------ |------------:|---------:|---------:|------:|
  8. | Scalar         | 1024  |    625.8 us |  0.81 us |  0.68 us |  1.00 |
  9. | UseVectors     | 1024  |    151.9 us |  0.32 us |  0.27 us |  0.24 |
  10. | UseVectorsArgs | 1024  |    151.2 us |  0.13 us |  0.12 us |  0.24 |
  11. |                |       |             |          |          |       |
  12. | Scalar         | 2048  |  2,522.4 us |  1.28 us |  1.14 us |  1.00 |
  13. | UseVectors     | 2048  |    666.9 us |  0.55 us |  0.51 us |  0.26 |
  14. | UseVectorsArgs | 2048  |    663.8 us |  0.80 us |  0.67 us |  0.26 |
  15. |                |       |             |          |          |       |
  16. | Scalar         | 4096  | 10,797.2 us | 11.21 us | 10.48 us |  1.00 |
  17. | UseVectors     | 4096  |  3,349.0 us | 39.67 us | 37.11 us |  0.31 |
  18. | 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 架构上运行。基准测试结果如下。
  1. BenchmarkDotNet v0.14.0, macOS Sequoia 15.1.1 (24B91) [Darwin 24.1.0]
  2. Apple M2, 1 CPU, 8 logical and 8 physical cores
  3. .NET SDK 8.0.204
  4.   [Host]     : .NET 6.0.33 (6.0.3324.36610), Arm64 RyuJIT AdvSIMD [AttachedDebugger]
  5.   DefaultJob : .NET 6.0.33 (6.0.3324.36610), Arm64 RyuJIT AdvSIMD
  6. | Method         | Width | Mean        | Error    | StdDev   | Ratio |
  7. |--------------- |------ |------------:|---------:|---------:|------:|
  8. | Scalar         | 1024  |  1,805.2 us |  0.72 us |  0.60 us |  1.00 |
  9. | UseVectors     | 1024  |    454.5 us |  5.45 us |  5.10 us |  0.25 |
  10. | UseVectorsArgs | 1024  |    158.4 us |  0.05 us |  0.04 us |  0.09 |
  11. |                |       |             |          |          |       |
  12. | Scalar         | 2048  |  7,229.0 us |  2.88 us |  2.69 us |  1.00 |
  13. | UseVectors     | 2048  |  1,857.4 us |  2.73 us |  2.56 us |  0.26 |
  14. | UseVectorsArgs | 2048  |    656.2 us |  0.26 us |  0.23 us |  0.09 |
  15. |                |       |             |          |          |       |
  16. | Scalar         | 4096  | 29,574.1 us | 13.21 us | 11.03 us |  1.00 |
  17. | UseVectors     | 4096  |  8,117.2 us | 28.06 us | 26.25 us |  0.27 |
  18. | 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 上运行。基准测试结果如下。
  1. BenchmarkDotNet v0.14.0, Windows 11 (10.0.22631.4541/23H2/2023Update/SunValley3)
  2. AMD Ryzen 7 7840H w/ Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores
  3.   [Host]     : .NET Framework 4.8.1 (4.8.9282.0), X64 RyuJIT VectorSize=256
  4.   DefaultJob : .NET Framework 4.8.1 (4.8.9282.0), X64 RyuJIT VectorSize=256
  5. | Method         | Width | Mean        | Error     | StdDev    | Ratio | RatioSD | Code Size |
  6. |--------------- |------ |------------:|----------:|----------:|------:|--------:|----------:|
  7. | Scalar         | 1024  |  1,315.2 us |  26.06 us |  25.59 us |  1.00 |    0.03 |   2,718 B |
  8. | UseVectors     | 1024  |    968.2 us |  17.55 us |  16.42 us |  0.74 |    0.02 |   3,507 B |
  9. | UseVectorsArgs | 1024  |    887.0 us |   9.91 us |   8.78 us |  0.67 |    0.01 |   3,507 B |
  10. |                |       |             |           |           |       |         |           |
  11. | Scalar         | 2048  |  5,259.4 us |  85.87 us |  80.32 us |  1.00 |    0.02 |   2,718 B |
  12. | UseVectors     | 2048  |  3,696.0 us |  29.64 us |  27.72 us |  0.70 |    0.01 |   3,507 B |
  13. | UseVectorsArgs | 2048  |  3,722.9 us |  39.36 us |  34.90 us |  0.71 |    0.01 |   3,507 B |
  14. |                |       |             |           |           |       |         |           |
  15. | Scalar         | 4096  | 19,763.1 us | 300.29 us | 266.20 us |  1.00 |    0.02 |   2,718 B |
  16. | UseVectors     | 4096  | 14,303.8 us |  62.36 us |  55.28 us |  0.72 |    0.01 |   3,507 B |
  17. | 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的标量算法要好。
附录

    出处:http://www.cnblogs.com/zyl910/    版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0.
来源:https://www.cnblogs.com/zyl910/p/18580435/VectorTraits_Sample_Image_ImageFlipXOn32bitBenchmark
免责声明:由于采集信息均来自互联网,如果侵犯了您的权益,请联系我们【E-Mail:cb@itdo.tech】 我们会及时删除侵权内容,谢谢合作!

举报 回复 使用道具