.Net7向量化的效能優化

2023-06-12 21:00:22

前言

向量化是效能優化的重要技術,也是寄託硬體層面的優化技術。本篇來看下。


概括

一:向量化支援的問題:
向量化的System.Runtime.Intrinsics.X86.Sse2.MoveMask
函數和向量化的Vector128.Create().ExtractMostSignificantBits()
函數返回的結果是一樣的。但是前者只能在支援SSE2的128位元向量化平臺上工作,而後者可以在任何支援128位元向量化平臺上工作,包括Risc-V,Arm64,WASM等平臺。這裡以一段程式碼看下:

private static void Main()
{
   Vector128<byte> v = Vector128.Create((byte)123);
   while (true)
   {
      WithIntrinsics(v);
      WithVector(v);
      break;
   }
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static int WithIntrinsics(Vector128<byte> v) => Sse2.MoveMask(v);
[MethodImpl(MethodImplOptions.NoInlining)]
private static uint WithVector(Vector128<byte> v) => v.ExtractMostSignificantBits();

看下它的ASM程式碼:

WithIntrinsics:
G_M000_IG01:                ;; offset=0000H
       55                   push     rbp
       C5F877               vzeroupper
       488BEC               mov      rbp, rsp
       48894D10             mov      bword ptr [rbp+10H], rcx

G_M000_IG02:                ;; offset=000BH
       488B4510             mov      rax, bword ptr [rbp+10H]
       C5F91000             vmovupd  xmm0, xmmword ptr [rax]
       C5F9D7C0             vpmovmskb eax, xmm0

G_M000_IG03:                ;; offset=0017H
       5D                   pop      rbp
       C3                   ret


WithVector
G_M000_IG01:                ;; offset=0000H
       55                   push     rbp
       C5F877               vzeroupper
       488BEC               mov      rbp, rsp
       48894D10             mov      bword ptr [rbp+10H], rcx

G_M000_IG02:                ;; offset=000BH
       488B4510             mov      rax, bword ptr [rbp+10H]
       C5F91000             vmovupd  xmm0, xmmword ptr [rax]
       C5F9D7C0             vpmovmskb eax, xmm0

G_M000_IG03:                ;; offset=0017H
       5D                   pop      rbp
       C3                   ret

可以看到這兩個函數生成的ASM幾乎一模一樣。

2.向量化的一個例子
由於以上程式碼體現的SSE2的侷限性,所以需要把一些程式碼向量化,以便在任何平臺上執行,這裡看一個例子。

static bool Contains(ReadOnlySpan<byte> haystack, byte needle)
{
    for (int i = 0; i < haystack.Length; i++)
    {
        if (haystack[i] == needle)
        {
            return true;
        }
    }

    return false;
}

查詢元素,找到了然後返回。怎麼對這例子進行向量化呢?首先需要判斷你程式碼執行的硬體是否支援向量化,可以通過Vector.IsHardwareAccelerated的返回值來判斷。其次,傳入的變數長度(haystack.length)必須的大於一個向量的長度(Vector.Count,win11加VS2022這個值是32)。那麼改造之後如下:

static bool Contains(ReadOnlySpan<byte> haystack, byte needle)
{
    if (Vector.IsHardwareAccelerated && haystack.Length >= Vector<byte>.Count)
    {
        // ...
    }
    else
    {
        for (int i = 0; i < haystack.Length; i++)
        {
            if (haystack[i] == needle)
            {
                return true;
            }
        }
    }
    return false;
}

如果以上if的兩個判斷均為true的話,那麼我們進入向量化階段。程式碼如下:

static unsafe bool Contains(ReadOnlySpan<byte> haystack, byte needle)
{
    if (Vector.IsHardwareAccelerated && haystack.Length >= Vector<byte>.Count)//判斷當前執行的硬體是否符合向量化以及變數的長度不能小於向量化裡面一個向量的長度。
    {
        fixed (byte* haystackPtr = &MemoryMarshal.GetReference(haystack))//獲取變數的頭指標
        {
            Vector<byte> target = new Vector<byte>(needle);//向量化需要查詢的變數needle
            byte* current = haystackPtr;//變數haystack的頭指標,以便於後面迴圈
            byte* endMinusOneVector = haystackPtr + haystack.Length - Vector<byte>.Count;//頭指標+變數的長度減去一個向量的長度。同頭指標current開始到endMinusOneVector在這個裡面遍歷迴圈,查詢需要查詢的變數target也就是向量化的needle,這裡為什麼要進去Vector<byte>.Count因為向量是從0開始查詢的。
            do
            {
                if (Vector.EqualsAny(target, *(Vector<byte>*)current))//判斷當前的指標是否與需要查詢的變數相等
                {
                    return true;//相等就返回true
                }

                current += Vector<byte>.Count;//不相等指標就位移到下一個向量,繼續遍歷迴圈。
            }
            while (current < endMinusOneVector);//這裡判斷是否達到迴圈終點。
        }
    }
    else
    {
        for (int i = 0; i < haystack.Length; i++)
        {
            if (haystack[i] == needle)
            {
                return true;
            }
        }
    }
    return false;
}

以上程式碼幾乎完成了90%,但是依然有點點問題。那就是最後一個向量endMinusOneVector沒有被查詢。所以還需要加上它的查詢。最後的點如下,第一個Contains是不向量化的,第二個Contains_Vector是向量化之後的。

static bool Contains(ReadOnlySpan<byte> haystack, byte needle)
{
    for (int i = 0; i < haystack.Length; i++)
    {
        if (haystack[i] == needle)
        {
            return true;
        }
    }

    return false;
}
static unsafe bool Contains_Vector(ReadOnlySpan<byte> haystack, byte needle)
{
    if (Vector.IsHardwareAccelerated && haystack.Length >= Vector<byte>.Count)
    {
        fixed (byte* haystackPtr = &MemoryMarshal.GetReference(haystack))
        {
            Vector<byte> target = new Vector<byte>(needle);
            byte* current = haystackPtr;
            byte* endMinusOneVector = haystackPtr + haystack.Length - Vector<byte>.Count;
            do
            {
                if (Vector.EqualsAny(target, *(Vector<byte>*)current))
                {
                    return true;
                }

                current += Vector<byte>.Count;
            }
            while (current < endMinusOneVector);

            if (Vector.EqualsAny(target, *(Vector<byte>*)endMinusOneVector))
            {
                return true;
            }
        }
    }
    else
    {
        for (int i = 0; i < haystack.Length; i++)
        {
            if (haystack[i] == needle)
            {
                return true;
            }
        }
    }

    return false;
}

上面的程式碼幾乎是完美的,測試下基準

private byte[] _data = Enumerable.Repeat((byte)123, 999).Append((byte)42).ToArray();//Enumerable.Repeat表示999個123的byte,放在陣列,最後又加了一個42數值到陣列

[Benchmark(Baseline = true)]
[Arguments((byte)42)]
public bool Find(byte value) => Contains(_data, value); // just the fallback path in its own method

[Benchmark]
[Arguments((byte)42)]
public bool FindVectorized(byte value) => Contains_Vectorized(_data, value); // the implementation we just wrote



|         Method | value |      Mean |    Error |   StdDev | Ratio | Code Size |
|--------------- |------ |----------:|---------:|---------:|------:|----------:|
|           Find |    42 | 508.42 ns | 2.336 ns | 2.185 ns |  1.00 |     110 B |
| FindVectorized |    42 |  21.57 ns | 0.342 ns | 0.303 ns |  0.04 |     253 B |

可以看到向量化之後的效能,進行了誇張的25倍的增長。這段程式碼幾乎完美,但是並不完美。這裡是用的1000個元素測試,如果是小於30個元素呢?有兩個方法,第一個是退回到沒有向量化的程式碼也就是Contains函數,第二個是把Vector切換到128位元來操作。程式碼如下,幾乎沒變更:

static unsafe bool Contains128(ReadOnlySpan<byte> haystack, byte needle)
{
    if (Vector128.IsHardwareAccelerated && haystack.Length >= Vector128<byte>.Count)
    {
        ref byte current = ref MemoryMarshal.GetReference(haystack);

        Vector128<byte> target = Vector128.Create(needle);
        ref byte endMinusOneVector = ref Unsafe.Add(ref current, haystack.Length - Vector128<byte>.Count);
        do
        {
            if (Vector128.EqualsAny(target, Vector128.LoadUnsafe(ref current)))
            {
                return true;
            }

            current = ref Unsafe.Add(ref current, Vector128<byte>.Count);
        }
        while (Unsafe.IsAddressLessThan(ref current, ref endMinusOneVector));

        if (Vector128.EqualsAny(target, Vector128.LoadUnsafe(ref endMinusOneVector)))
        {
            return true;
        }
    }
    else
    {
        for (int i = 0; i < haystack.Length; i++)
        {
            if (haystack[i] == needle)
            {
                return true;
            }
        }
    }

    return false;
}

來進行一個基準測試:

private byte[] _data = Enumerable.Repeat((byte)123, 29).Append((byte)42).ToArray();

[Benchmark(Baseline = true)]
[Arguments((byte)42)]
public bool Find(byte value) => Contains(_data, value);

[Benchmark]
[Arguments((byte)42)]
public bool FindVectorized(byte value) => Contains_Vectorized(_data, value);


|         Method | value |      Mean |     Error |    StdDev | Ratio | Code Size |
|--------------- |------ |----------:|----------:|----------:|------:|----------:|
|           Find |    42 | 16.363 ns | 0.1833 ns | 0.1530 ns |  1.00 |     110 B |
| FindVectorized |    42 |  1.799 ns | 0.0320 ns | 0.0299 ns |  0.11 |     191 B |

同樣的效能進行了16倍的提速。


結尾

作者:江湖評談
歡迎關注公眾號:jianghupt。文章首發。