WPF 画素値読み出しの高速化検討

はじめに

趣味で WPF の画像ビューアを開発しています。

画像ビューアには『指定範囲の画素平均値を色チャンネル別に表示する機能』を搭載しているのですが、結果の取得に少しディレイを感じるので処理の高速化を検討してみました。

取り組み結果

最初に結果を貼っておきます。 実行環境 と 画素数 によりますが 35~70% 高速化できました。

benchmark

ベースライン

以下が高速化前コードです。 BitmapSource の画像に対して、引数2 Int32Rect で指定したエリア(roi)の画素平均値を求めて結果を返しています。

public static PixelAverage GetPixelsAverage_Baseline(this BitmapSource bitmap, Int32Rect roi)
{
    // 入力ROIのエラーチェックは省いています
    var bytesPerPixel = (bitmap.Format.BitsPerPixel + (8 - 1)) / 8;     // Ceiling
    // 累算値管理のスタック配列
    Span<ulong> sum = stackalloc ulong[bytesPerPixel];

    var stride = bitmap.PixelWidth * bytesPerPixel;
    var lineBufferSize = roi.Width * bytesPerPixel;
    var lineBuffer = ArrayPool<byte>.Shared.Rent(lineBufferSize);
    try
    {
        unsafe
        {
            fixed (byte* head = lineBuffer)
            {
                var tail = head + lineBufferSize;
                var rect1Line = new Int32Rect(roi.X, 0, roi.Width, 1);
                for (var y = roi.Y; y < (roi.Y + roi.Height); y++)
                {
                    // 水平ラインごとに画素をコピー
                    rect1Line.Y = y;
                    bitmap.CopyPixels(rect1Line, lineBuffer, stride, 0);

                    for (var ptr = head; ptr < tail; ptr += bytesPerPixel)
                    {
                        sum[0] += *ptr;
                        sum[1] += *(ptr + 1);
                        sum[2] += *(ptr + 2);
                        sum[3] += *(ptr + 3);
                    }
                }
            }
        }
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(lineBuffer);
    }

    var count = (double)(roi.Width * roi.Height);
    var aveB = sum[0] / count;
    var aveG = sum[1] / count;
    var aveR = sum[2] / count;
    var aveA = sum[3] / count;
    return new(aveB, aveG, aveR, aveA);
}

書いた当時の記憶はありませんが、何となく実行効率を意識して実装したように思います。 上記実装のこだわりポイントは以下だと思います。

  1. 読み出し対象画素のメモリコピー BitmapSource.CopyPixels() を水平ライン毎に分割して読み出すことで ArrayPool<byte>.Rent() のメモリ確保量を削減している。

  2. unsafe pointer を使用して画素値のアドレスを直参照している。

  3. 色チャンネル別の累計値の管理変数は stackalloc で確保して、配列のヒープを確保を削減している。

上記コードをベースラインとして、高速化の検討をスタートしました。

高速化検討1

ILSpy で IL を確認したところ、 stackalloc で確保した累計値のスタック配列へのアクセスで無駄な処理が含まれていそうです。 stackalloc (Span) により、いかにも やってる感(悪い意味)は出ていますが 実測 は行っていませんでした。

対応

累計値の変数を色チャンネル別にベタ書き宣言しました。 型はベースと同様に ulong (64bit) としています。8bit 画素 を対象としていますので、32bit だと 232-8=1677万画素 までしか扱えません。近年はスマホでも 2000万画素 を超えていますので 32bit では不十分です。

Span<ulong> sum = stackalloc ulong[bytesPerPixel];
    ↓ 変更 ↓
ulong sumB = 0, sumG = 0, sumR = 0, sumA = 0;

効果

画像サイズによりますが、これだけで 20~30% 高速化できました。(ベースの実装が残念過ぎました…)

高速化検討2

ベースラインの画素累算部は、unsafeポインタでメモリ直読み→累算 の実装となっており、無駄な処理は含まれていなさそうです。

そこで以前から気になっていた SIMD 演算 Vector<T> を使ってみました。効果は実行環境(CPU)に依存してしまいますが、WPFWindows限定)環境なので、大抵のユーザは恩恵を受けられるはずです!

対応

私の環境は AMD Ryzen 7 PRO 4750GE(2020年7月発売)の Vector<byte>.Count は 32 でした。 画素値の累算変数は(上に書いた通り)64bit 必要ですが、水平ラインごとに処理する実装としていますので、水平ラインは 32bit で累算しています。

var sum32 = Vector<uint>.Zero;  // 水平2^24(1677万)画素まで累算可
for (index = 0; index < lineBufferSize - vecByteCount; index += vecByteCount)
{
    // 8bit画素配列をVector化
    var vec8 = new Vector<byte>(lineBuffer, index);

    // 8bit画素Vecorを16bitに拡張して加算
    Vector.Widen(vec8, out var low16, out var high16);
    var sum16 = Vector.Add(low16, high16);

    // 16bitの画素合計値を32bitに拡張して加算
    Vector.Widen(sum16, out var low32, out var high32);
    sum32 = Vector.Add(sum32, low32);
    sum32 = Vector.Add(sum32, high32);
}

効果

実行環境(CPU)によりますが、私の環境では 20~40% 高速化できました。

ベンチマーク結果

BenchmarkDotNet で測定した結果以下となりました。

Method _filePath Mean Error StdDev Ratio RatioSD Gen 0 Allocated
Base image320x240.jpg 75.46 us 0.508 us 0.475 us 1.00 0.00 - 48 B
Fast1 image320x240.jpg 63.22 us 1.205 us 1.290 us 0.84 0.02 - 48 B
Fast2_Vector image320x240.jpg 48.78 us 0.947 us 1.198 us 0.65 0.01 - 48 B
Base image1280x960.jpg 703.77 us 0.950 us 0.793 us 1.00 0.00 - 49 B
Fast1 image1280x960.jpg 508.36 us 1.149 us 1.075 us 0.72 0.00 - 49 B
Fast2_Vector image1280x960.jpg 270.89 us 0.970 us 0.908 us 0.38 0.00 - 48 B
Base image4000x3000.jpg 6,280.73 us 11.463 us 10.723 us 1.00 0.00 - 54 B
Fast1 image4000x3000.jpg 4,274.75 us 3.579 us 3.348 us 0.68 0.00 - 54 B
Fast2_Vector image4000x3000.jpg 1,914.80 us 11.773 us 11.013 us 0.30 0.00 - 50 B
BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19043.1706 (21H1/May2021Update)
AMD Ryzen 7 PRO 4750GE with Radeon Graphics, 1 CPU, 16 logical and 8 physical cores
.NET SDK=6.0.300
  [Host]     : .NET 6.0.5 (6.0.522.21309), X64 RyuJIT
  DefaultJob : .NET 6.0.5 (6.0.522.21309), X64 RyuJIT

benchmark

おわりに

最終のソースコードを貼っておきます。

public static PixelAverage GetPixelsAverage(this BitmapSource bitmap, System.Windows.Int32Rect roi)
{
    // 入力ROIのエラーチェックは省いています

    var bytesPerPixel = (bitmap.Format.BitsPerPixel + (8 - 1)) / 8;     // Ceiling
    if (bytesPerPixel != 4)
        throw new NotSupportedException($"Only 4ch is supported. BitsPerPixel={bitmap.Format.BitsPerPixel}");

    // Vectorサイズチェック
    var vecByteCount = Vector<byte>.Count;
    if (vecByteCount < 32 || vecByteCount % 32 != 0)
        throw new NotImplementedException($"Only multiples of 32 are supported. Vector<byte>.Count={vecByteCount}");

    ulong sumB = 0, sumG = 0, sumR = 0, sumA = 0;
    (var roiWidth, var roiHeight) = (roi.Width, roi.Height);
    var stride = bitmap.PixelWidth * bytesPerPixel;
    var lineBufferSize = roiWidth * bytesPerPixel;
    var lineBuffer = ArrayPool<byte>.Shared.Rent(lineBufferSize);
    try
    {
        var rect1Line = new System.Windows.Int32Rect(roi.X, y: 0, roiWidth,  1);
        for (var y = roi.Y; y < roi.Y + roiHeight; y++)
        {
            rect1Line.Y = y;
            bitmap.CopyPixels(rect1Line, lineBuffer, stride, 0);

            int index;
            var sum32 = Vector<uint>.Zero;  // 水平2^24(1677万)画素までは加算可
            for (index = 0; index < lineBufferSize - vecByteCount; index += vecByteCount)
            {
                var vec8 = new Vector<byte>(lineBuffer, index);

                // 8bit画素Vecorを16bitに拡張して加算
                Vector.Widen(vec8, out var low16, out var high16);
                var sum16 = Vector.Add(low16, high16);

                // 16bitの画素合計値を32bitに拡張して加算
                Vector.Widen(sum16, out var low32, out var high32);
                sum32 = Vector.Add(sum32, low32);
                sum32 = Vector.Add(sum32, high32);
            }
            // Vectorサイズ未満の領域
            for (; index < lineBufferSize; index += 4)
            {
                sumB += lineBuffer[index];
                sumG += lineBuffer[index + 1];
                sumR += lineBuffer[index + 2];
                sumA += lineBuffer[index + 3];
            }
            for (var i = 0; i < Vector<uint>.Count; i += 4)
            {
                sumB += sum32[i];
                sumG += sum32[i + 1];
                sumR += sum32[i + 2];
                sumA += sum32[i + 3];
            }
        }
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(lineBuffer);
    }

    var count = (double)(roiWidth * roiHeight);
    var aveB = sumB / count;
    var aveG = sumG / count;
    var aveR = sumR / count;
    var aveA = sumA / count;
    return new(aveB, aveG, aveR, aveA);
}

Vector<T> は強力ですが『サイズ上限は CPU レジスタに依存する』とのことなので動作に自信ないです。どうテストすれば良いんでしょうか…