[C#] 对32位图像进行水平翻转(FlipX)的跨平台SIMD硬件加速向量算法(使用VectorTraits的YShuffleKernel方法来解决Shuffle的缺点)

  • [C#] 对32位图像进行水平翻转(FlipX)的跨平台SIMD硬件加速向量算法(使用VectorTraits的YShuffleKernel方法来解决Shuffle的缺点)已关闭评论
  • 12 次浏览
  • A+
所属分类:.NET技术
摘要

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

上一篇文章里,我们讲解了图像的垂直翻转(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 翻转一行

基于“一个向量内翻转”的办法,可实现对一行像素进行翻转。具体来说,依然可以按照“逆序读取、顺序写入”的策略来处理。

若一行像素的字节数,刚好是向量大小的整数倍时,此时处理起来最简单。算法步骤如下:

  1. 对源指针p设置初值,将它指向源位图当前行的最后一笔数据的地址。即: Vector<int>* p = (Vector<int>*)(pRow + maxX * cbPixel)
  2. 对目标指针q设置初值,将它指向目标位图当前行的起始地址。即: Vector<int>* q = (Vector<int>*)qRow
  3. 根据源指针p,将内存中的数据加载到向量中。即: data = *p
  4. 对向量进行翻转。即: temp = Vectors.YShuffleKernel(data, indices)
  5. 根据目标指针q,将向量写入到内存中。即: *q = temp
  6. 判断数据是否都处理完了。若是,跳到第9步完成。
  7. 移动指针,处理下一个向量。即: --p; ++q;
  8. 跳到第3步,继续循环。
  9. 完成。

假设向量大小为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的标量算法要好。

附录