zyl910

优化技巧、硬件体系、图像处理、图形学、游戏编程、国际化与文本信息处理。

  博客园 :: 首页 :: 博问 :: 闪存 :: 新随笔 :: 联系 :: 订阅 订阅 :: 管理 ::

将彩色位图转为灰度位图,是图像处理的常用算法。本文将介绍 Bgr24彩色位图转为Gray8灰度位图的算法,除了会给出标量算法外,还会给出向量算法。且这些算法是跨平台的,同一份源代码,能在 X86(Sse、Avx等指令集)及Arm(AdvSimd指令集)等架构上运行,且均享有SIMD硬件加速。

修订历史

  • 2024-11-19 v1:发表第1版。
  • 2024-12-15 v2:增加 512位向量算法、RGB2Y算法的测试对比,还增加了 .NET 7.0 的基准测试结果,且各章节补充了内容。

一、标量算法

1.1 算法原理

1.1.1 彩色转灰度的计算公式

对于彩色转灰度,由于人眼对红绿蓝三种颜色的敏感程度不同,在灰度转换时,每个颜色分配的权重也是不同的。有一个很著名的心理学公式:

Gray = R*0.299 + G*0.587 + B*0.114

该公式含有浮点数,而浮点数运算一般比较慢。

于是在具体实现时,需要做一些优化。可以将小数转为定点整数,这样便能将除法转为移位。整数计算比浮点型快,移位运算和加减法比乘除法快,于是取得了比较好的效果。

但是这种方法也会带来一定的精度损失,故应根据实际情况,来选择定点整数的精度位数。

1.1.2像素格式说明

Bgr24是一种打包(packed)像素格式。“Bgr”是指它有3个颜色通道,且颜色通道顺序为 B(蓝色)、G(绿色)、R(红色)。“24”是指像素的总位数为24,即3个8位字节。这3个字节,正好平均分配给每一个颜色通道,每个通道均为8位(1字节)。于是该格式也被称呼为 Bgr888、B8G8R8 等。

而对于 Gray8,“Gray”表示它只有1个颜色通道——灰度(Grayscale)。“8”是指像素的总位数为8,即用1个8位字节来表示灰度值。在介绍像素数据的存储布局时,一般用字母“Y”来代表灰度像素。

对于 Bgr24彩色转为Gray8灰度,数据是这样转化的。

位置:  0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 ...
源图: B0 G0 R0 B1 G1 R1 B2 G2 R2 B2 G3 R3 B4 G4 R4 B5 G5 R5 B6 G6 R6 B7 G7 R7 ...
目标: Y0 Y1 Y2 Y3 Y4 Y5 Y6 Y7 ...

每3个字节(B、G、R),给转成了1个灰度字节。

1.2 算法实现

了解了 彩色转灰度的计算公式,以及像素格式后,便可以编写彩色转灰度的算法了。这里使用了16位精度(shiftPoint为16),源代码如下。

public static unsafe void ScalarDo(BitmapData src, BitmapData dst) {
    const int cbPixel = 3; // Bgr24
    const int shiftPoint = 16;
    const int mulPoint = 1 << shiftPoint; // 0x10000
    const int mulRed = (int)(0.299 * mulPoint + 0.5); // 19595
    const int mulGreen = (int)(0.587 * mulPoint + 0.5); // 38470
    const int mulBlue = mulPoint - mulRed - mulGreen; // 7471
    int width = src.Width;
    int height = src.Height;
    int strideSrc = src.Stride;
    int strideDst = dst.Stride;
    byte* pRow = (byte*)src.Scan0.ToPointer();
    byte* qRow = (byte*)dst.Scan0.ToPointer();
    for (int i = 0; i < height; i++) {
        byte* p = pRow;
        byte* q = qRow;
        for (int j = 0; j < width; j++) {
            *q = (byte)((p[2] * mulRed + p[1] * mulGreen + p[0] * mulBlue) >> shiftPoint);
            p += cbPixel; // Bgr24
            q += 1; // Gray8
        }
        pRow += strideSrc;
        qRow += strideDst;
    }
}

1.3 基准测试代码

使用 BenchmarkDotNet 进行基准测试。
可以事先分配好数据。源代码如下。

private static readonly Random _random = new Random(1);
private BitmapData _sourceBitmapData = null;
private BitmapData _destinationBitmapData = null;
private BitmapData _expectedBitmapData = null;

[Params(1024, 2048, 4096)]
public int Width { get; set; }
public int Height { get; set; }

~Bgr24ToGrayBgr24Benchmark() {
    Dispose(false);
}

public void Dispose() {
    Dispose(true);
    GC.SuppressFinalize(this);
}

private void Dispose(bool disposing) {
    if (_disposed) return;
    _disposed = true;
    if (disposing) {
        Cleanup();
    }
}

private BitmapData AllocBitmapData(int width, int height, PixelFormat format) {
    const int strideAlign = 4;
    if (width <= 0) throw new ArgumentOutOfRangeException($"The width({width}) need > 0!");
    if (height <= 0) throw new ArgumentOutOfRangeException($"The width({height}) need > 0!");
    int stride = 0;
    switch (format) {
        case PixelFormat.Format8bppIndexed:
            stride = width * 1;
            break;
        case PixelFormat.Format24bppRgb:
            stride = width * 3;
            break;
    }
    if (stride <= 0) throw new ArgumentOutOfRangeException($"Invalid pixel format({format})!");
    if (0 != (stride % strideAlign)) {
        stride = stride - (stride % strideAlign) + strideAlign;
    }
    BitmapData bitmapData = new BitmapData();
    bitmapData.Width = width;
    bitmapData.Height = height;
    bitmapData.PixelFormat = format;
    bitmapData.Stride = stride;
    bitmapData.Scan0 = Marshal.AllocHGlobal(stride * height);
    return bitmapData;
}

private void FreeBitmapData(BitmapData bitmapData) {
    if (null == bitmapData) return;
    if (IntPtr.Zero == bitmapData.Scan0) return;
    Marshal.FreeHGlobal(bitmapData.Scan0);
    bitmapData.Scan0 = IntPtr.Zero;
}

[GlobalCleanup]
public void Cleanup() {
    FreeBitmapData(_sourceBitmapData); _sourceBitmapData = null;
    FreeBitmapData(_destinationBitmapData); _destinationBitmapData = null;
    FreeBitmapData(_expectedBitmapData); _expectedBitmapData = null;
}

[GlobalSetup]
public void Setup() {
    Height = Width;
    // Create.
    Cleanup();
    _sourceBitmapData = AllocBitmapData(Width, Height, PixelFormat.Format24bppRgb);
    _destinationBitmapData = AllocBitmapData(Width, Height, PixelFormat.Format8bppIndexed);
    _expectedBitmapData = AllocBitmapData(Width, Height, PixelFormat.Format8bppIndexed);
    RandomFillBitmapData(_sourceBitmapData, _random);
}

使用这些已分配好的数据,能很容易写出ScalarDo 的基准测试代码。

[Benchmark(Baseline = true)]
public void Scalar() {
    ScalarDo(_sourceBitmapData, _destinationBitmapData);
}

二、向量算法

2.1 算法思路

2.1.1 难点说明

24位像素的标量算法很简单,但是它的向量算法要复杂的多。

这是因为向量大小一般是 16或32字节这样的2的整数幂,而24位像素是3个字节一组,无法整除。这就给地址计算、数据处理等方面,带来很大的难题。

2.1.2 前人的经验(RGB2Y)

很多人探索过这个问题,其中komrad36给出了效率高的办法“RGB2Y”,并将源码公布在 github上。

RGB2Y的Sse版算法,是一次性处理 12个像素的,步骤比较复杂。这里简单说明一下:先使用9条加载指令加载不同偏移的的数据,然后对这9个向量分别用不同的系数做乘法,且通过多条 shuffle指令对元素进行换位,再通过移位、与或非 等指令的配合,将12个灰度值给算出来。最后每隔12字节存储一次,因为向量里前部的12个字节正好是有效的12个灰度值,而后部的4个字节会被下次循环给填充。为了避免“最后4个字节”写入时的内存越界问题,对于最右侧的像素,回退为标量算法来处理。

若想了解细节,可阅读 Imageshop的文章《SSE图像算法优化系列一:一段BGR2Y的SIMD代码解析。》。该文章详细介绍了该算法。

24位像素的向量化算法为什么这么复杂?这是因为向量大小无法被3整除,且还需要处理“每3个字节处理后缩紧为1个字节”的水平方向移动等。得花很多心思去考虑向量内各元素如何重排,使其符合计算公式所需。且对于Bgr这种有3个颜色通道的数据来说,将数据重排为公式所需有时是很困难的,于是这时需要折衷将公式进行变形,降低数据重排的开销。

而且,komrad36还在github上提供了基于Avx系列指令集的算法,性能有了进一步提升。是一次性处理 10个像素,即30个字节。读取了2次,利用 _mm256_mulhrs_epi16 指令的特点,同时对2组数据进行计算。随后进过一系列的256位Avx运算,算出10个灰度值,并存储在1个128位向量里。最后将结果向量里的前10字节保存到目标图像里。虽然一次只处理10个像素,比Sse版算法的12个像素要少,但由于精心挑选了更有效的指令,且利用了256位是128位的2倍长度的优势,进一步提升了性能。具体步骤比较复杂,可去github上看 RGB2Y 的源代码。本文的完整源码里,将komrad36的这2个算法均翻译为 C#语言的了,也可以通过它来查看。

此时可以注意到一个情况,RGB2Y的Avx版算法虽然用了256位的Avx指令集进行了主体运算,但在最后保存时还是使用了Sse的128位向量。这明显没有充分发挥Avx的256位运算的优势。而且128位是16字节,仅保存前10个字节,也表示运算不够充分。

这是有苦衷的。因为要使24位像素满足计算公式的要求,得做复杂数据重排操作。而高效率数据重排操作,是很依赖数据特点的,以及需要权衡更契合的 换位类指令、乘法类指令等细节。牵一发而动全身,导致这些办法很难扩展到更宽的向量类型。

2.1.3 更好的办法

上面的算法比较繁琐,我们希望有更好的办法。希望它能具备这些特点——

  • 能充分占满向量宽度,而不是 10或12字节那样没有占满向量宽度。
  • 能适应各种向量宽度。不仅能适应 128位向量,且能适应 256位(Avx2)、512位(Avx512)。
  • 代码可读性更高,便于理解与维护。

回想一下,Bgr24是一种打包像素格式。每3个字节为1个像素,连续存放在内存中。

根据上面经验,打包像素很难处理。因为向量指令一般是按垂直方向处理数据,而打包的像素存在水平方向的3元素组。

既然难点在于“水平方向的3元素组”,那就优先针对它进行处理——将打包(packed)布局的像素,转为平面(planar)布局的像素。随后就能方便的用向量指令来处理了。

而且可以观察到:

  • 若使用1个128位向量来读取,会读取16个字节。其中前面5个像素有效,最后1个字节无法整除。
  • 若使用2个128位向量来读取,会读取32个字节。其中前面10个像素有效,最后2个字节无法整除。
  • 若使用3个128位向量来读取,会读取48个字节。其中16个像素有效,正好整除!

打包布局像素转为平面布局像素,这种运算叫做 解交织(De-Interleave)。

于是对于24位转8位灰度,可以使用这种办法: 每次从源位图读取3个向量,进行3-元素组的解交织运算,得到 R,G,B 平面数据。随后使用向量化的乘法与加法,来计算灰度值。结果是存储了各个灰度值的1个向量,于是可将它存储到目标位图。

  • 例如 Sse指令集使用的是128位向量,此时1个向量为16字节。每次从源位图读取3个向量,就是读取了48字节,即16个RGB像素。最后将1个向量存储到目标位图,就是写入了16字节,即16个灰度像素。

对于3-元素组的解交织,可以使用 shuffle 类别的指令来实现。例如对于 128位向量,X86架构可以使用 SSSE3 的 _mm_shuffle_epi8 指令,它对应 .NET 中的 Ssse3.Shuffle 方法。源代码如下。

static readonly Vector128<byte> YGroup3Unzip_Shuffle_Byte_X_Part0 = Vector128.Create((sbyte)0, 3, 6, 9, 12, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1).AsByte();
static readonly Vector128<byte> YGroup3Unzip_Shuffle_Byte_X_Part1 = Vector128.Create((sbyte)-1, -1, -1, -1, -1, -1, 2, 5, 8, 11, 14, -1, -1, -1, -1, -1).AsByte();
static readonly Vector128<byte> YGroup3Unzip_Shuffle_Byte_X_Part2 = Vector128.Create((sbyte)-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 1, 4, 7, 10, 13).AsByte();
static readonly Vector128<byte> YGroup3Unzip_Shuffle_Byte_Y_Part0 = Vector128.Create((sbyte)1, 4, 7, 10, 13, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1).AsByte();
static readonly Vector128<byte> YGroup3Unzip_Shuffle_Byte_Y_Part1 = Vector128.Create((sbyte)-1, -1, -1, -1, -1, 0, 3, 6, 9, 12, 15, -1, -1, -1, -1, -1).AsByte();
static readonly Vector128<byte> YGroup3Unzip_Shuffle_Byte_Y_Part2 = Vector128.Create((sbyte)-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 2, 5, 8, 11, 14).AsByte();
static readonly Vector128<byte> YGroup3Unzip_Shuffle_Byte_Z_Part0 = Vector128.Create((sbyte)2, 5, 8, 11, 14, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1).AsByte();
static readonly Vector128<byte> YGroup3Unzip_Shuffle_Byte_Z_Part1 = Vector128.Create((sbyte)-1, -1, -1, -1, -1, 1, 4, 7, 10, 13, -1, -1, -1, -1, -1, -1).AsByte();
static readonly Vector128<byte> YGroup3Unzip_Shuffle_Byte_Z_Part2 = Vector128.Create((sbyte)-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, 3, 6, 9, 12, 15).AsByte();

public static Vector128<byte> YGroup3Unzip(Vector128<byte> data0, Vector128<byte> data1, Vector128<byte> data2, out Vector128<byte> y, out Vector128<byte> z) {
    var f0A = YGroup3Unzip_Shuffle_Byte_X_Part0;
    var f0B = YGroup3Unzip_Shuffle_Byte_X_Part1;
    var f0C = YGroup3Unzip_Shuffle_Byte_X_Part2;
    var f1A = YGroup3Unzip_Shuffle_Byte_Y_Part0;
    var f1B = YGroup3Unzip_Shuffle_Byte_Y_Part1;
    var f1C = YGroup3Unzip_Shuffle_Byte_Y_Part2;
    var f2A = YGroup3Unzip_Shuffle_Byte_Z_Part0;
    var f2B = YGroup3Unzip_Shuffle_Byte_Z_Part1;
    var f2C = YGroup3Unzip_Shuffle_Byte_Z_Part2;
    var rt0 = Sse2.Or(Sse2.Or(Ssse3.Shuffle(data0, f0A), Ssse3.Shuffle(data1, f0B)), Ssse3.Shuffle(data2, f0C));
    var rt1 = Sse2.Or(Sse2.Or(Ssse3.Shuffle(data0, f1A), Ssse3.Shuffle(data1, f1B)), Ssse3.Shuffle(data2, f1C));
    var rt2 = Sse2.Or(Sse2.Or(Ssse3.Shuffle(data0, f2A), Ssse3.Shuffle(data1, f2B)), Ssse3.Shuffle(data2, f2C));
    y = rt1;
    z = rt2;
    return rt0;
}

为了使跨平台编写向量算法更方便,我开发了 VectorTraits 库,已经集成了上述算法,提供了“Vectors.YGroup3Unzip”方法。该方法能够跨平台,它会使用各个平台的shuffle指令。

  • X86 256-bit: 使用 _mm256_shuffle_epi8 与其他指令.
  • Arm: 使用 vqvtbl1q_u8 等指令.
  • Wasm: 使用 i8x16.swizzle 指令.

这种办法能充分占满向量宽度,且容易拓展到各种向量宽度。并且代码的可读性高,易于维护。

2.2 算法实现

有了“Vectors.YGroup3Unzip”方法后,便能方便的编写24位转8位灰度的算法了。灰度系数设为8位精度,为了避免乘法结果溢出,于是需要将 8位数据变宽为16位后,再来计算乘法与加法。最后再将 16位数据,变窄为8位,保存灰度值。源代码如下。

public static unsafe void UseVectorsDoBatch(byte* pSrc, int strideSrc, int width, int height, byte* pDst, int strideDst) {
    const int cbPixel = 3; // Bgr24
    const int shiftPoint = 8;
    const int mulPoint = 1 << shiftPoint; // 0x100
    const ushort mulRed = (ushort)(0.299 * mulPoint + 0.5); // 77
    const ushort mulGreen = (ushort)(0.587 * mulPoint + 0.5); // 150
    const ushort mulBlue = mulPoint - mulRed - mulGreen; // 29
    Vector<ushort> vmulRed = new Vector<ushort>(mulRed);
    Vector<ushort> vmulGreen = new Vector<ushort>(mulGreen);
    Vector<ushort> vmulBlue = new Vector<ushort>(mulBlue);
    int vectorWidth = Vector<byte>.Count;
    int maxX = width - vectorWidth;
    byte* pRow = pSrc;
    byte* qRow = pDst;
    for (int i = 0; i < height; i++) {
        Vector<byte>* pLast = (Vector<byte>*)(pRow + maxX * cbPixel);
        Vector<byte>* qLast = (Vector<byte>*)(qRow + maxX * 1);
        Vector<byte>* p = (Vector<byte>*)pRow;
        Vector<byte>* q = (Vector<byte>*)qRow;
        for (; ; ) {
            Vector<byte> r, g, b, gray;
            Vector<ushort> wr0, wr1, wg0, wg1, wb0, wb1;
            // Load.
            b = Vectors.YGroup3Unzip(p[0], p[1], p[2], out g, out r);
            // widen(r) * mulRed + widen(g) * mulGreen + widen(b) * mulBlue
            Vector.Widen(r, out wr0, out wr1);
            Vector.Widen(g, out wg0, out wg1);
            Vector.Widen(b, out wb0, out wb1);
            wr0 = Vectors.Multiply(wr0, vmulRed);
            wr1 = Vectors.Multiply(wr1, vmulRed);
            wg0 = Vectors.Multiply(wg0, vmulGreen);
            wg1 = Vectors.Multiply(wg1, vmulGreen);
            wb0 = Vectors.Multiply(wb0, vmulBlue);
            wb1 = Vectors.Multiply(wb1, vmulBlue);
            wr0 = Vector.Add(wr0, wg0);
            wr1 = Vector.Add(wr1, wg1);
            wr0 = Vector.Add(wr0, wb0);
            wr1 = Vector.Add(wr1, wb1);
            // Shift right and narrow.
            wr0 = Vectors.ShiftRightLogical_Const(wr0, shiftPoint);
            wr1 = Vectors.ShiftRightLogical_Const(wr1, shiftPoint);
            gray = Vector.Narrow(wr0, wr1);
            // Store.
            *q = gray;
            // Next.
            if (p >= pLast) break;
            p += cbPixel;
            ++q;
            if (p > pLast) p = pLast; // The last block is also use vector.
            if (q > qLast) q = qLast;
        }
        pRow += strideSrc;
        qRow += strideDst;
    }
}

上面源代码中的“Vectors.ShiftRightLogical_Const”,是VectorTraits 库提供的方法。它能替代 NET 7.0 新增的 Vector.ShiftRightLogical 方法,使早期版本的 NET 也能使用逻辑右移位。

“Vectors.Multiply”也是VectorTraits 库提供的方法。它能避免无符号类型有时没有硬件加速的问题。

2.2.1 怎样处理非整数倍数据

对向量算法有经验的读者,可能会觉得上面的源代码少了点什么——源代码里没有标量算法,也没有RGB2Y那样的末尾复制操作,那它是怎样处理非整数倍数据呢?

例如向量大小为128位时,向量算法本身只能处理16字节的整数倍数据。而为了支持任意宽度的图像,对于末尾的非整数倍数据,得想办法去处理。最常见的做法有2种:

  1. 回退标量:在处理末尾数据时,回退为标量算法。大多数教程里,用的是这种办法。因为该办法简单易懂,且能保证正确性。
  2. 末尾复制:先分配1个向量大小的临时缓冲区,可使用栈内存等手段减少分配开销。随后在处理末尾数据时,先将源地址中的有效数据复制到临时缓冲区,于是便能安全的使用向量指令来处理了,最后再将处理结果的前N个有效字节,复制到目标地址。RGB2Y的Avx版算法,就是用了这个办法。

但是这2种办法存在缺点——

  1. 回退标量:末尾数据是用标量算法处理的,这时没有向量指令集的硬件加速。而且需要编写2种算法——向量的和标量的,会带来更多开发成本与维护成本。
  2. 末尾复制:虽然它能使末尾数据也使用向量算法,但存在多余的复制操作。且编码复杂,易留下缺陷。

对于“一边读取、一边写入”这种情况,由于读、写的一般是不同的数据区域。于是我找到了一种更好的处理办法——末尾指针调整。

该办法的关键思路是——先计算好“末尾指针”(向量处理时最后一笔数据的指针地址),然后在循环内每次均检查指针地址,当发现当前指针已经越过“末尾指针”时便将当前指针设置为“末尾指针”,以完成剩余数据的处理。

使用这种办法,能使末尾的数据也按同样的代码进行向量处理。而且代码简单的多,仅需调整指针。所以对于不熟悉的人,一下子很难发现它用了这个办法。于是源代码里加了 “The last block is also use vector.”的注释,进行提醒。

若想了解“末尾指针调整”这个办法的更多细节,可参考 [C#] 对图像进行垂直翻转(FlipY)的跨平台SIMD硬件加速向量算法,兼谈并行处理收益极少的原因

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);
        return;
    }
    int strideSrc = src.Stride;
    int strideDst = dst.Stride;
    byte* pSrc = (byte*)src.Scan0.ToPointer();
    byte* pDst = (byte*)dst.Scan0.ToPointer();
    int processorCount = Environment.ProcessorCount;
    int batchSize = height / (processorCount * 2);
    bool allowParallel = useParallel && (batchSize > 0) && (processorCount > 1);
    if (allowParallel) {
        int batchCount = (height + batchSize - 1) / batchSize; // ceil((double)length / batchSize)
        Parallel.For(0, batchCount, i => {
            int start = batchSize * i;
            int len = batchSize;
            if (start + len > height) len = height - start;
            byte* pSrc2 = pSrc + start * strideSrc;
            byte* pDst2 = pDst + start * strideDst;
            UseVectorsDoBatch(pSrc2, strideSrc, width, len, pDst2, strideDst);
        });
    } else {
        UseVectorsDoBatch(pSrc, strideSrc, width, height, pDst, strideDst);
    }
}

2.4 128位向量的算法

自动大小向量Vector在X86架构CPU上运行时,它的向量长度一般是256位。这是因为现在Avx2指令集已经普及了。

RGB2Y 的Sse版算法是使用128位向量的,拿256位向量与它去做对比的话,是不公平的。为了公平对比,于是专门编写了128位向量的算法 。

#if NETCOREAPP3_0_OR_GREATER

[Benchmark]
public void UseVector128s() {
    UseVector128sDo(_sourceBitmapData, _destinationBitmapData, false);
}

// [Benchmark]
public void UseVector128sParallel() {
    UseVector128sDo(_sourceBitmapData, _destinationBitmapData, true);
}

public static unsafe void UseVector128sDo(BitmapData src, BitmapData dst, bool useParallel = false) {
    int vectorWidth = Vector128<byte>.Count;
    int width = src.Width;
    int height = src.Height;
    if (width <= vectorWidth) {
        ScalarDo(src, dst);
        return;
    }
    int strideSrc = src.Stride;
    int strideDst = dst.Stride;
    byte* pSrc = (byte*)src.Scan0.ToPointer();
    byte* pDst = (byte*)dst.Scan0.ToPointer();
    int processorCount = Environment.ProcessorCount;
    int batchSize = height / (processorCount * 2);
    bool allowParallel = useParallel && (batchSize > 0) && (processorCount > 1);
    if (allowParallel) {
        int batchCount = (height + batchSize - 1) / batchSize; // ceil((double)length / batchSize)
        Parallel.For(0, batchCount, i => {
            int start = batchSize * i;
            int len = batchSize;
            if (start + len > height) len = height - start;
            byte* pSrc2 = pSrc + start * strideSrc;
            byte* pDst2 = pDst + start * strideDst;
            UseVector128sDoBatch(pSrc2, strideSrc, width, len, pDst2, strideDst);
        });
    } else {
        UseVector128sDoBatch(pSrc, strideSrc, width, height, pDst, strideDst);
    }
}

public static unsafe void UseVector128sDoBatch(byte* pSrc, int strideSrc, int width, int height, byte* pDst, int strideDst) {
    const int cbPixel = 3; // Bgr24
    const int shiftPoint = 8;
    const int mulPoint = 1 << shiftPoint; // 0x100
    const ushort mulRed = (ushort)(0.299 * mulPoint + 0.5); // 77
    const ushort mulGreen = (ushort)(0.587 * mulPoint + 0.5); // 150
    const ushort mulBlue = mulPoint - mulRed - mulGreen; // 29
    Vector128<ushort> vmulRed = Vector128.Create((ushort)mulRed);
    Vector128<ushort> vmulGreen = Vector128.Create((ushort)mulGreen);
    Vector128<ushort> vmulBlue = Vector128.Create((ushort)mulBlue);
    int Vector128Width = Vector128<byte>.Count;
    int maxX = width - Vector128Width;
    byte* pRow = pSrc;
    byte* qRow = pDst;
    for (int i = 0; i < height; i++) {
        Vector128<byte>* pLast = (Vector128<byte>*)(pRow + maxX * cbPixel);
        Vector128<byte>* qLast = (Vector128<byte>*)(qRow + maxX * 1);
        Vector128<byte>* p = (Vector128<byte>*)pRow;
        Vector128<byte>* q = (Vector128<byte>*)qRow;
        for (; ; ) {
            Vector128<byte> r, g, b, gray;
            Vector128<ushort> wr0, wr1, wg0, wg1, wb0, wb1;
            // Load.
            b = Vector128s.YGroup3Unzip(p[0], p[1], p[2], out g, out r);
            // widen(r) * mulRed + widen(g) * mulGreen + widen(b) * mulBlue
            Vector128s.Widen(r, out wr0, out wr1);
            Vector128s.Widen(g, out wg0, out wg1);
            Vector128s.Widen(b, out wb0, out wb1);
            wr0 = Vector128s.Multiply(wr0, vmulRed);
            wr1 = Vector128s.Multiply(wr1, vmulRed);
            wg0 = Vector128s.Multiply(wg0, vmulGreen);
            wg1 = Vector128s.Multiply(wg1, vmulGreen);
            wb0 = Vector128s.Multiply(wb0, vmulBlue);
            wb1 = Vector128s.Multiply(wb1, vmulBlue);
            wr0 = Vector128s.Add(wr0, wg0);
            wr1 = Vector128s.Add(wr1, wg1);
            wr0 = Vector128s.Add(wr0, wb0);
            wr1 = Vector128s.Add(wr1, wb1);
            // Shift right and narrow.
            wr0 = Vector128s.ShiftRightLogical_Const(wr0, shiftPoint);
            wr1 = Vector128s.ShiftRightLogical_Const(wr1, shiftPoint);
            gray = Vector128s.Narrow(wr0, wr1);
            // Store.
            *q = gray;
            // Next.
            if (p >= pLast) break;
            p += cbPixel;
            ++q;
            if (p > pLast) p = pLast; // The last block is also use vector.
            if (q > qLast) q = qLast;
        }
        pRow += strideSrc;
        qRow += strideDst;
    }
}

#endif // NETCOREAPP3_0_OR_GREATER

观察上面的源代码可以发现,仅需将 自动大小向量类型 Vector,替换为 Vector128 ,就能完成128位向量算法的改造。这归功于 VectorTraits库良好的设计,无论是对于自动大小向量类型 Vector,还是固定大小向量类型 Vector128/Vector256等,用法都是一致的。

注意,.NET Core 3.0才开始支持 Vector128,故需要使用条件编译符号 NETCOREAPP3_0_OR_GREATER进行判断。

由于 BenchmarkDotNet 的测试项需要执行很长时间,且评比算法时看单线程表现都足够了,于是 UseVector128sParallel 方法的 [Benchmark] 特性被注释掉了。若读者想知道它的基准测试表现,可以自行取消该注释,进行测试。

2.5 512位向量的算法

.NET 8.0 新增了对 X86架构的Avx512系列指令集的支持,且新增了 Vector512类型。VectorTraits 3.0版已经支持了Avx512系列指令集,于是能够很容易将128位向量的算法,改造为512位向量的算法。只需要做文本替换,将“128”替换为“512”,便完成了改造。而不用学习复杂的Avx512指令集,大大降低了门槛。

源代码如下。

#if NET8_0_OR_GREATER

[Benchmark]
public void UseVector512s() {
    UseVector512sDo(_sourceBitmapData, _destinationBitmapData, false);
}

// [Benchmark]
public void UseVector512sParallel() {
    UseVector512sDo(_sourceBitmapData, _destinationBitmapData, true);
}

public static unsafe void UseVector512sDo(BitmapData src, BitmapData dst, bool useParallel = false) {
    if (!Vector512s.IsHardwareAccelerated) throw new NotSupportedException("Vector512 does not have hardware acceleration!");
    int vectorWidth = Vector512<byte>.Count;
    int width = src.Width;
    int height = src.Height;
    if (width <= vectorWidth) {
        ScalarDo(src, dst);
        return;
    }
    int strideSrc = src.Stride;
    int strideDst = dst.Stride;
    byte* pSrc = (byte*)src.Scan0.ToPointer();
    byte* pDst = (byte*)dst.Scan0.ToPointer();
    int processorCount = Environment.ProcessorCount;
    int batchSize = height / (processorCount * 2);
    bool allowParallel = useParallel && (batchSize > 0) && (processorCount > 1);
    if (allowParallel) {
        int batchCount = (height + batchSize - 1) / batchSize; // ceil((double)length / batchSize)
        Parallel.For(0, batchCount, i => {
            int start = batchSize * i;
            int len = batchSize;
            if (start + len > height) len = height - start;
            byte* pSrc2 = pSrc + start * strideSrc;
            byte* pDst2 = pDst + start * strideDst;
            UseVector512sDoBatch(pSrc2, strideSrc, width, len, pDst2, strideDst);
        });
    } else {
        UseVector512sDoBatch(pSrc, strideSrc, width, height, pDst, strideDst);
    }
}

public static unsafe void UseVector512sDoBatch(byte* pSrc, int strideSrc, int width, int height, byte* pDst, int strideDst) {
    const int cbPixel = 3; // Bgr24
    const int shiftPoint = 8;
    const int mulPoint = 1 << shiftPoint; // 0x100
    const ushort mulRed = (ushort)(0.299 * mulPoint + 0.5); // 77
    const ushort mulGreen = (ushort)(0.587 * mulPoint + 0.5); // 150
    const ushort mulBlue = mulPoint - mulRed - mulGreen; // 29
    Vector512<ushort> vmulRed = Vector512.Create((ushort)mulRed);
    Vector512<ushort> vmulGreen = Vector512.Create((ushort)mulGreen);
    Vector512<ushort> vmulBlue = Vector512.Create((ushort)mulBlue);
    int Vector512Width = Vector512<byte>.Count;
    int maxX = width - Vector512Width;
    byte* pRow = pSrc;
    byte* qRow = pDst;
    for (int i = 0; i < height; i++) {
        Vector512<byte>* pLast = (Vector512<byte>*)(pRow + maxX * cbPixel);
        Vector512<byte>* qLast = (Vector512<byte>*)(qRow + maxX * 1);
        Vector512<byte>* p = (Vector512<byte>*)pRow;
        Vector512<byte>* q = (Vector512<byte>*)qRow;
        for (; ; ) {
            Vector512<byte> r, g, b, gray;
            Vector512<ushort> wr0, wr1, wg0, wg1, wb0, wb1;
            // Load.
            b = Vector512s.YGroup3Unzip(p[0], p[1], p[2], out g, out r);
            // widen(r) * mulRed + widen(g) * mulGreen + widen(b) * mulBlue
            Vector512s.Widen(r, out wr0, out wr1);
            Vector512s.Widen(g, out wg0, out wg1);
            Vector512s.Widen(b, out wb0, out wb1);
            wr0 = Vector512s.Multiply(wr0, vmulRed);
            wr1 = Vector512s.Multiply(wr1, vmulRed);
            wg0 = Vector512s.Multiply(wg0, vmulGreen);
            wg1 = Vector512s.Multiply(wg1, vmulGreen);
            wb0 = Vector512s.Multiply(wb0, vmulBlue);
            wb1 = Vector512s.Multiply(wb1, vmulBlue);
            wr0 = Vector512s.Add(wr0, wg0);
            wr1 = Vector512s.Add(wr1, wg1);
            wr0 = Vector512s.Add(wr0, wb0);
            wr1 = Vector512s.Add(wr1, wb1);
            // Shift right and narrow.
            wr0 = Vector512s.ShiftRightLogical_Const(wr0, shiftPoint);
            wr1 = Vector512s.ShiftRightLogical_Const(wr1, shiftPoint);
            gray = Vector512s.Narrow(wr0, wr1);
            // Store.
            *q = gray;
            // Next.
            if (p >= pLast) break;
            p += cbPixel;
            ++q;
            if (p > pLast) p = pLast; // The last block is also use vector.
            if (q > qLast) q = qLast;
        }
        pRow += strideSrc;
        qRow += strideDst;
    }
}

#endif // NET8_0_OR_GREATER

注意,从.NET 8.0才开始支持 Vector512,故需要使用条件编译符号NET8_0_OR_GREATER进行判断。

由于现在有不少处理器尚未支持 Avx512系列指令集,于是需要用if语句判断一下“Vector512s.IsHardwareAccelerated”是否为真。否则就不要执行了。

由于 BenchmarkDotNet 的测试项需要执行很长时间,且评比算法时看单线程表现都足够了,于是 UseVector512sParallel 方法的 [Benchmark] 特性被注释掉了。若读者想知道它的基准测试表现,可以自行取消该注释,进行测试。

完整源码在 Bgr24ToGray8Benchmark.cs

三、基准测试结果

3.1 X86 架构

3.1.1 X86 架构上.NET 7.0程序的测试结果

X86架构上.NET 7.0程序的基准测试结果如下。

BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.2605)
AMD Ryzen 7 7840H w/ Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores
.NET SDK 8.0.403
  [Host]     : .NET 7.0.20 (7.0.2024.26716), X64 RyuJIT AVX2
  DefaultJob : .NET 7.0.20 (7.0.2024.26716), X64 RyuJIT AVX2


| Method              | Width | Mean         | Error      | StdDev     | Median       | Ratio | RatioSD | Code Size |
|-------------------- |------ |-------------:|-----------:|-----------:|-------------:|------:|--------:|----------:|
| Scalar              | 1024  |  1,070.78 us |  14.018 us |  13.112 us |  1,067.33 us |  1.00 |    0.02 |     159 B |
| UseVector128s       | 1024  |    213.02 us |   2.539 us |   2.375 us |    212.91 us |  0.20 |    0.00 |   2,538 B |
| UseVectors          | 1024  |    124.48 us |   2.419 us |   2.589 us |    124.05 us |  0.12 |    0.00 |   2,640 B |
| UseVectorsParallel  | 1024  |     38.93 us |   0.772 us |   1.863 us |     39.00 us |  0.04 |    0.00 |   4,262 B |
| RGB2Y_Sse           | 1024  |    290.82 us |   4.458 us |   4.170 us |    290.22 us |  0.27 |    0.00 |   1,180 B |
| RGB2Y_Avx           | 1024  |    161.90 us |   2.921 us |   4.094 us |    161.13 us |  0.15 |    0.00 |   1,609 B |
|                     |       |              |            |            |              |       |         |           |
| Scalar              | 2048  |  4,308.26 us |  58.425 us |  54.651 us |  4,284.51 us |  1.00 |    0.02 |     159 B |
| UseVector128s       | 2048  |    961.45 us |  19.082 us |  24.132 us |    959.55 us |  0.22 |    0.01 |   2,538 B |
| UseVectors          | 2048  |    743.54 us |  14.289 us |  15.289 us |    743.50 us |  0.17 |    0.00 |   2,640 B |
| UseVectorsParallel  | 2048  |    189.77 us |   3.691 us |   4.927 us |    190.11 us |  0.04 |    0.00 |   4,262 B |
| RGB2Y_Sse           | 2048  |  1,157.93 us |  18.965 us |  15.837 us |  1,153.45 us |  0.27 |    0.00 |   1,180 B |
| RGB2Y_Avx           | 2048  |    810.24 us |  15.891 us |  28.655 us |    814.03 us |  0.19 |    0.01 |   1,609 B |
|                     |       |              |            |            |              |       |         |           |
| Scalar              | 4096  | 17,650.25 us | 192.173 us | 179.759 us | 17,657.81 us |  1.00 |    0.01 |     159 B |
| UseVector128s       | 4096  |  4,482.83 us |  89.644 us | 243.884 us |  4,529.06 us |  0.25 |    0.01 |   2,538 B |
| UseVectors          | 4096  |  3,663.72 us |  72.281 us |  98.939 us |  3,691.48 us |  0.21 |    0.01 |   2,640 B |
| UseVectorsParallel  | 4096  |  1,879.11 us |  36.739 us |  40.835 us |  1,884.76 us |  0.11 |    0.00 |   4,262 B |
| RGB2Y_Sse           | 4096  |  5,293.30 us | 101.847 us | 125.077 us |  5,314.90 us |  0.30 |    0.01 |   1,180 B |
| RGB2Y_Avx           | 4096  |  3,827.84 us |  74.990 us | 154.868 us |  3,825.17 us |  0.22 |    0.01 |   1,609 B |

方法说明——

  • Scalar: 标量算法。
  • UseVectors: 128位向量算法。
  • UseVectors: 向量算法。
  • UseVectorsParallel: 并行的向量算法。
  • RGB2Y_Sse: RGB2Y的Sse算法。
  • RGB2Y_Avx: RGB2Y的Avx向量算法。

以1024时的测试结果为例,来观察向量化算法比起标量算法的性能提升。

  • UseVector128s:1,070.78/213.02 ≈ 5.03。
  • UseVectors:1,070.78/124.48 ≈ 8.60。
  • UseVectorsParallel:1,070.78/38.93 ≈27.51。
  • RGB2Y_Sse:1,070.78/290.82 ≈ 3.68。UseVector128s性能是它的 290.82/213.02 ≈ 1.37(倍)。
  • RGB2Y_Avx:1,070.78/161.90 ≈ 6.61。UseVectors性能是它的 161.90/124.48 ≈ 1.30(倍)。

可见,本文的128位与256位算法,均比RGB2Y的对应算法快了 30%左右。

并行算法(UseVectorsParallel)获得了27.51倍的性能提升。这一方面是因为 向量算法(UseVectors)的底子比较优秀,另一方面是因为这种处理易于并行化(每一行可以分别交给不同的CPU核心去处理),且线程调度给力。

3.1.2 X86 架构上.NET 8.0程序的测试结果

X86架构上.NET 8.0程序的基准测试结果如下。

BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.2605)
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     | Median       | Ratio | RatioSD | Code Size |
|-------------------- |------ |-------------:|-----------:|-----------:|-------------:|------:|--------:|----------:|
| Scalar              | 1024  |  1,068.21 us |  20.677 us |  22.982 us |  1,071.65 us |  1.00 |    0.03 |     152 B |
| UseVector128s       | 1024  |    205.42 us |   4.069 us |   8.845 us |    206.40 us |  0.19 |    0.01 |        NA |
| UseVector512s       | 1024  |     89.76 us |   1.727 us |   2.305 us |     89.94 us |  0.08 |    0.00 |        NA |
| UseVectors          | 1024  |    115.26 us |   2.165 us |   2.127 us |    115.42 us |  0.11 |    0.00 |        NA |
| UseVectorsParallel  | 1024  |     30.53 us |   0.609 us |   0.540 us |     30.64 us |  0.03 |    0.00 |        NA |
| RGB2Y_Sse           | 1024  |    304.98 us |   6.029 us |   5.922 us |    304.93 us |  0.29 |    0.01 |   1,157 B |
| RGB2Y_Avx           | 1024  |    138.09 us |   2.691 us |   3.099 us |    138.87 us |  0.13 |    0.00 |   1,540 B |
|                     |       |              |            |            |              |       |         |           |
| Scalar              | 2048  |  4,353.42 us |  86.802 us | 112.867 us |  4,393.02 us |  1.00 |    0.04 |     152 B |
| UseVector128s       | 2048  |    955.72 us |  19.017 us |  22.638 us |    954.41 us |  0.22 |    0.01 |        NA |
| UseVector512s       | 2048  |    613.52 us |  12.220 us |  24.404 us |    615.05 us |  0.14 |    0.01 |        NA |
| UseVectors          | 2048  |    705.17 us |  13.965 us |  17.661 us |    709.60 us |  0.16 |    0.01 |        NA |
| UseVectorsParallel  | 2048  |    154.20 us |   3.019 us |   5.745 us |    155.46 us |  0.04 |    0.00 |        NA |
| RGB2Y_Sse           | 2048  |  1,305.99 us |  17.416 us |  16.291 us |  1,308.38 us |  0.30 |    0.01 |   1,157 B |
| RGB2Y_Avx           | 2048  |    723.61 us |  13.914 us |  13.665 us |    724.76 us |  0.17 |    0.01 |   1,540 B |
|                     |       |              |            |            |              |       |         |           |
| Scalar              | 4096  | 17,542.77 us | 294.576 us | 275.547 us | 17,583.06 us |  1.00 |    0.02 |     152 B |
| UseVector128s       | 4096  |  4,289.34 us |  84.197 us | 166.196 us |  4,327.95 us |  0.24 |    0.01 |        NA |
| UseVector512s       | 4096  |  3,002.27 us |  58.116 us |  81.470 us |  2,998.51 us |  0.17 |    0.01 |        NA |
| UseVectors          | 4096  |  3,244.71 us |  63.577 us |  62.441 us |  3,242.79 us |  0.19 |    0.00 |        NA |
| UseVectorsParallel  | 4096  |  1,887.42 us |  36.716 us |  49.015 us |  1,900.06 us |  0.11 |    0.00 |        NA |
| RGB2Y_Sse           | 4096  |  5,395.80 us | 107.190 us | 187.734 us |  5,460.36 us |  0.31 |    0.01 |   1,157 B |
| RGB2Y_Avx           | 4096  |  3,395.69 us |  65.199 us |  87.039 us |  3,402.75 us |  0.19 |    0.01 |   1,550 B |

以1024时的测试结果为例,来观察向量化算法比起标量算法的性能提升。

  • UseVector128s:1,070.78/205.42 ≈ 5.21。性能是.NET 7.0时的 213.02/205.42 ≈ 1.04(倍)。
  • UseVector512s:1,070.78/89.76 ≈ 11.93。性能是UseVectors的 115.26/89.76 ≈ 1.28(倍)。
  • UseVectors:1,070.78/115.26 ≈ 9.29。性能是.NET 7.0时的 124.48/115.26 ≈ 1.08(倍)。
  • UseVectorsParallel:1,070.78/30.53 ≈35.07。性能是.NET 7.0时的 38.93/30.53 ≈ 1.27(倍)。
  • RGB2Y_Sse:1,070.78/304.98 ≈ 3.51。性能是.NET 7.0时的 290.82/304.98 ≈ 0.95(倍)。UseVector128s性能是它的 304.98/205.42 ≈ 1.48 (倍)。
  • RGB2Y_Avx:1,070.78/138.09 ≈ 7.75。性能是.NET 7.0时的 161.90/138.09 ≈ 1.17 (倍)。UseVectors性能是它的 138.09/115.26 ≈ 1.20 (倍)。UseVector512s性能是它的 138.09/89.76 ≈ 1.54 (倍)。

性能进一步提升!这是因为 .NET 8.0 支持了Avx512系列指令集,且这个CPU支持,于是 Vectors等类使用了该指令集做进一步优化。而且.NET 8.0JIT的编译优化更加优秀。

且发现RGB2Y_Avx的速度快了一些,但它仍然没追上我们的 UseVectors。RGB2Y_Sse变得稍慢一点了(也可能是测试误差),导致它与 UseVector128s 的差距变得更大了。

这些单线程的方法里,512位向量算法(UseVector512s)毫无意外地夺冠了。它的性能是标量算法的 11.93倍,且是256位向量算法的1.28倍。当前CPU是 Zen4微架构的,其实它没有专门的512位运算单元,而是通过2个256位运算单元组合而成的,还不能完全发挥Avx512指令集的潜力。若换成 Zen5Sapphire Rapids等具有专门512位运算单元的微架构的CPU,能获得进一步性能提升。

并行算法(UseVectorsParallel)更是获得了35.07倍的性能提升。注意它还是基于自动大小Vector的,即仍是使用256位向量。对于使用512位向量的并行算法(UseVector512sParallel),它的性能会更高,有兴趣的读者可以去试试。

3.2 Arm 架构

同样的源代码可以在 Arm 架构上运行。

3.2.1 Arm 架构上.NET 7.0程序的测试结果

Arm架构上.NET 7.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 8.0.204
  [Host]     : .NET 7.0.20 (7.0.2024.26716), Arm64 RyuJIT AdvSIMD
  DefaultJob : .NET 7.0.20 (7.0.2024.26716), Arm64 RyuJIT AdvSIMD


| Method               | Width | Mean         | Error     | StdDev    | Ratio | RatioSD |
|--------------------- |------ |-------------:|----------:|----------:|------:|--------:|
| Scalar               | 1024  |    628.65 us |  0.416 us |  0.325 us |  1.00 |    0.00 |
| UseVector128s        | 1024  |    302.15 us |  3.819 us |  3.572 us |  0.48 |    0.01 |
| UseVectors           | 1024  |    167.75 us |  0.051 us |  0.047 us |  0.27 |    0.00 |
| UseVectorsParallel   | 1024  |     59.48 us |  0.659 us |  0.616 us |  0.09 |    0.00 |
| RGB2Y_Sse            | 1024  |           NA |        NA |        NA |     ? |       ? |
| RGB2Y_Avx            | 1024  |           NA |        NA |        NA |     ? |       ? |
|                      |       |              |           |           |       |         |
| Scalar               | 2048  |  2,561.87 us |  3.064 us |  2.866 us |  1.00 |    0.00 |
| UseVector128s        | 2048  |  1,254.35 us |  1.404 us |  1.245 us |  0.49 |    0.00 |
| UseVectors           | 2048  |    690.34 us |  0.403 us |  0.377 us |  0.27 |    0.00 |
| UseVectorsParallel   | 2048  |    168.07 us |  0.557 us |  0.465 us |  0.07 |    0.00 |
| RGB2Y_Sse            | 2048  |           NA |        NA |        NA |     ? |       ? |
| RGB2Y_Avx            | 2048  |           NA |        NA |        NA |     ? |       ? |
|                      |       |              |           |           |       |         |
| Scalar               | 4096  | 10,247.63 us | 23.753 us | 19.835 us |  1.00 |    0.00 |
| UseVector128s        | 4096  |  4,935.40 us |  4.547 us |  4.253 us |  0.48 |    0.00 |
| UseVectors           | 4096  |  2,799.62 us | 55.078 us | 65.567 us |  0.27 |    0.01 |
| UseVectorsParallel   | 4096  |  1,116.36 us | 14.602 us | 12.944 us |  0.11 |    0.00 |
| RGB2Y_Sse            | 4096  |           NA |        NA |        NA |     ? |       ? |
| RGB2Y_Avx            | 4096  |           NA |        NA |        NA |     ? |       ? |

以1024时的测试结果为例,来观察向量化算法比起标量算法的性能提升。

  • UseVector128s:628.65/302.15 ≈ 2.08。
  • UseVectors:628.65/167.75 ≈ 3.75。
  • UseVectorsParallel:628.65/59.48 ≈10.57。

目前Arm架构上,Vector类型是128位的,故 UseVector128s、UseVectors 的测试结果理应差不多。这次存在差异,可能是因为 ``.NET 7.0才首次支持 Vector128的数学函数,导致个别细节尚未优化到位。而 Vector 在Arm架构的SIMD加速,是从 .NET Core 1.0`时便提供了,优化的非常成熟。

并行算法(UseVectorsParallel)获得了10.57倍的性能提升。

3.2.2 Arm 架构上.NET 8.0程序的测试结果

Arm架构上.NET 8.0程序的基准测试结果如下。

BenchmarkDotNet v0.14.0, macOS Sequoia 15.0.1 (24A348) [Darwin 24.0.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 | RatioSD |
|--------------------- |------ |-------------:|----------:|----------:|------:|--------:|
| Scalar               | 1024  |    635.31 us |  0.537 us |  0.448 us |  1.00 |    0.00 |
| UseVector128s        | 1024  |    126.59 us |  0.492 us |  0.437 us |  0.20 |    0.00 |
| UseVector512s        | 1024  |           NA |        NA |        NA |     ? |       ? |
| UseVectors           | 1024  |    127.04 us |  0.567 us |  0.474 us |  0.20 |    0.00 |
| UseVectorsParallel   | 1024  |     46.37 us |  0.336 us |  0.314 us |  0.07 |    0.00 |
| RGB2Y_Sse            | 1024  |           NA |        NA |        NA |     ? |       ? |
| RGB2Y_Avx            | 1024  |           NA |        NA |        NA |     ? |       ? |
|                      |       |              |           |           |       |         |
| Scalar               | 2048  |  2,625.64 us |  1.795 us |  1.402 us |  1.00 |    0.00 |
| UseVector128s        | 2048  |    519.49 us |  0.218 us |  0.204 us |  0.20 |    0.00 |
| UseVector512s        | 2048  |           NA |        NA |        NA |     ? |       ? |
| UseVectors           | 2048  |    521.40 us |  0.301 us |  0.282 us |  0.20 |    0.00 |
| UseVectorsParallel   | 2048  |    152.11 us |  3.548 us | 10.064 us |  0.06 |    0.00 |
| RGB2Y_Sse            | 2048  |           NA |        NA |        NA |     ? |       ? |
| RGB2Y_Avx            | 2048  |           NA |        NA |        NA |     ? |       ? |
|                      |       |              |           |           |       |         |
| Scalar               | 4096  | 10,457.09 us |  5.697 us |  5.051 us |  1.00 |    0.00 |
| UseVector128s        | 4096  |  2,052.41 us |  0.819 us |  0.766 us |  0.20 |    0.00 |
| UseVector512s        | 4096  |           NA |        NA |        NA |     ? |       ? |
| UseVectors           | 4096  |  2,058.16 us |  4.110 us |  3.643 us |  0.20 |    0.00 |
| UseVectorsParallel   | 4096  |  1,152.15 us | 21.134 us | 21.703 us |  0.11 |    0.00 |
| RGB2Y_Sse            | 4096  |           NA |        NA |        NA |     ? |       ? |
| RGB2Y_Avx            | 4096  |           NA |        NA |        NA |     ? |       ? |

以1024时的测试结果为例,来观察向量化算法比起标量算法的性能提升。

  • UseVector128s:635.31/126.59 ≈ 5.02。
  • UseVectors:635.31/127.04 ≈ 5.00。
  • UseVectorsParallel:635.31/46.37 ≈13.70。

可见,现在 UseVector128s 与 UseVectors 的性能一致了。

.NET 8.0下,这3个方法的性能都有提升。并行算法(UseVectorsParallel)更是获得了13.70倍的性能提升。

这些性能提升,是因为 .NET 8.0增加了 Arm架构的“多向量查表”指令的支持。可参考《[C#] 24位图像水平翻转的跨平台SIMD硬件加速向量算法的关键——YShuffleX3Kernel源码解读(如Avx2解决shuffle的跨lane问题、Avx512优化等)

3.3 VC++编译RGB2Y并测试

先前是将 RGB2Y 翻译为 C# 语言后,再执行测试的。这只是一个间接的测试成绩,若想看RGB2Y的直接成绩,应该用 C++编译器来来编译它在github上的代码。

现在用 “VC++ 2022”对该程序进行编译,并使用目前最新的OpenCV版本 4.10.0。使用O2参数,并编译为Release版。在同样的X86电脑上运行,CPU是“AMD Ryzen 7 7840H ”。

先前 C# 测试时,“1024”是指 1024x1024的图片。于是也找一张同样尺寸的图片“test_1024.jpg”,给RGB2Y做测试。

3.1.1 单线程时的测试

RGB2Y为了与OpenCV做对比,默认启用了多线程。为便于观察算法本身的性能,还是用单线程测试一下比较好。即将 multithread 参数改为 false。

随后再修改图片路径等参数,最后将参数改为这样。

	// ------------- Configuration ------------
	constexpr auto warmups = 50;
	constexpr auto runs = 1000;

	// OpenCV's impl is multithreaded so set to true for fair comparison
	constexpr bool multithread = false;

	constexpr bool weighted_averaging = true;

	constexpr char name[] = "test_1024.jpg";

由于发现该程序的测试结果不太稳定,于是将 runs 从100 改为 1000。

该程序的运行结果如下。

------------- Ref ------------
Warming up...
Testing...
------------- RGB2Y ------------
Warming up...
Testing...
------------ OpenCV ------------
Warming up...
Testing...

0 pixels disagree.

Ref        took 2775.6  us.
RGB2Y      took 188.715 us.
OpenCV     took 60.676  us.

OpenCV 暂时不用理会,它总是使用多线程,而现在是看单线程。

RGB2Y 需花费 “188.715 us”,该程序是Avx版算法。这个成绩,比起 .NET 7.0 的RGB2Y_Avx的成绩“161.90 us”,还要慢一些。更别提UseVectors 的成绩是“124.48 us”,比它快的多。

原因可能是——

  • 测试办法不够好,导致测试结果不稳定。我的电脑上还运行了其他程序,例如 Chrome浏览器里开了30多个页面。BenchmarkDotNet通过长时间测试,剔除了不合理的数据,于是测试结果比较稳定。而 RGB2Y的测试代码写的很简单,即使将 runs 从100 改为 1000,稳定性也有限。
  • “VC++ 2022”O2做的编译优化还不够好。其他的C++编译器可能更好,有兴趣的读者可以试试。

3.1.2 多线程时的测试

将multithread参数改为 true,就可以进行多线程测试。全部参数如下。

	// ------------- Configuration ------------
	constexpr auto warmups = 50;
	constexpr auto runs = 1000;

	// OpenCV's impl is multithreaded so set to true for fair comparison
	constexpr bool multithread = true;

	constexpr bool weighted_averaging = true;

	constexpr char name[] = "test_1024.jpg";

此时程序的运行结果如下。

------------- Ref ------------
Warming up...
Testing...
------------- RGB2Y ------------
Warming up...
Testing...
------------ OpenCV ------------
Warming up...
Testing...

65536: got 0, should be 94
65537: got 0, should be 108
131072: got 0, should be 138
131073: got 0, should be 139
196608: got 0, should be 207
196609: got 0, should be 152
262144: got 0, should be 162
262145: got 0, should be 147
327680: got 0, should be 77
327681: got 0, should be 90
393216: got 0, should be 111
393217: got 0, should be 72
458752: got 0, should be 93
458753: got 0, should be 90
524288: got 0, should be 16
524289: got 0, should be 16
589824: got 0, should be 83
589825: got 0, should be 71
655360: got 0, should be 89
655361: got 0, should be 85
720896: got 0, should be 92
720897: got 0, should be 91
786432: got 0, should be 11
786433: got 0, should be 33
851968: got 0, should be 88
851969: got 0, should be 94
917504: got 0, should be 82
917505: got 0, should be 83
983040: got 0, should be 107
983041: got 0, should be 125
30 pixels disagree.

Ref        took 2601.3  us.
RGB2Y      took 104.658 us.
OpenCV     took 60.041  us.

可能是RGB2Y对非整数倍数据的处理还不够完善,少量像素的计算结果并不正确,故打印了一些错误日志。忽略它吧,我们重点看基准测试结果。

改为多线程后,RGB2Y的速度有所提升,为“104.658 us”。但它比不过 OpenCV的“60.041 us”。看来最新的 OpenCV 4.10.0也在不断优化,使它的性能超过了RGB2Y。

随后再来对比一下 C# 的多线程算法(UseVectorsParallel)的性能——

  • .NET 7.0:38.93 us。它的性能是RGB2Y的 104.658/38.93 ≈ 2.69 倍。且它的性能是OpenCV的 60.041/38.93 ≈ 1.54 倍。
  • .NET 8.0:30.53 us。它的性能是RGB2Y的 104.658/30.53 ≈ 3.42 倍。且它的性能是OpenCV的 60.041/30.53 ≈ 1.97 倍。

四、结语

其实使用“解交织”来处理RGB像素是很常见的做法,不少图像处理算法会用到。但对于彩色转灰度的向量算法,目前还没见到别人提过用“解交织”做法。

回想了一下,很可能以前也有人试过“解交织”的思路。但当时性能不佳,于是换思路了。

“解交织”需要用到很多shuffle指令。例如 Sse下需要使用9个shuffle指令,才能完成3元素组的解交织。而Avx因它的shuffle指令不能跨lane,于是需要使用18个shuffle指令,负担更重了。第二,对于“Core 2”等早期的支持byte级shuffle指令(Ssse3)的CPU,每个核心里只有1个shuffle单元。此时面对9个shuffle指令,得花费不少时间。于是 RGB2Y、OpenCV 的思路是尽量减少 shuffle 指令的使用,于是当时取得了比“解交织”办法更好的性能。我在一些比较老的Intel CPU的电脑上测试过,有时RGB2Y_Avx以小优势胜出。

而对于如今的CPU,每个核心里有多个shuffle单元。而且Avx512系列指令集增加了“2向量重排”指令,能减少shuffle类指令的使用。且Arm的AdvSimd指令集里,慷慨的提供了“2~4向量查表”指令,仅需1条指令就能解决1个颜色通道的解交织。

简单来说,在现代CPU上,“解交织”操作是能够非常快的被CPU处理。于是可以放心大胆的使用“解交织”思路,来实现RGB等3通道图像的处理算法。

VectorTraits库对解交织操作,提供了完善的支持,分别提供了2~4个元素的解交织方法:YGroup2Unzip、YGroup3Unzip、YGroup4Unzip。

且这些方法都有10种重载,能都支持 10种常见的数字类型,如 Byte、Int16、Int32、Int64 等。这就使不同位宽的数据,也能按照同样的办法去处理。

VectorTraits库还提供了交织操作的方法:YGroup2Zip、YGroup3Zip、YGroup4Unzip。

使用这些方法,能更方便地编写彩色图片的图像处理算法。不仅性能优秀,且代码的可读性高,便于理解与维护。

附录

posted on 2024-11-19 23:05  zyl910  阅读(73)  评论(0编辑  收藏  举报