【譯】.NET 7 中的效能改進(六)

鄭子銘發表於2023-02-26

原文 | Stephen Toub

翻譯 | 鄭子銘

向量化 (Vectorization)

SIMD,即單指令多資料 (Single Instruction Multiple Data),是一種處理方式,其中一條指令同時適用於多條資料。你有一個數字列表,你想找到一個特定值的索引?你可以在列表中一次比較一個元素,這在功能上是沒有問題的。但是,如果在讀取和比較一個元素的相同時間內,你可以讀取和比較兩個元素,或四個元素,或32個元素呢?這就是SIMD,利用SIMD指令的藝術被親切地稱為 "向量化",其中操作同時應用於一個 "向量 "中的所有元素。

.NET長期以來一直以Vector的形式支援向量化,它是一種易於使用的型別,具有一流的JIT支援,使開發者能夠編寫向量化的實現。Vector最大的優點之一也是它最大的缺點之一。該型別被設計為適應你的硬體中可用的任何寬度的向量指令。如果機器支援256位寬度的向量,很好,這就是Vector的目標。如果不支援,如果機器支援128位寬度的向量,很好,這就是Vector的目標。但是這種靈活性有各種缺點,至少在今天是這樣;例如,你可以在Vector上執行的操作最終需要與所用向量的寬度無關,因為寬度是根據程式碼實際執行的硬體而變化的。這意味著可以在Vector上進行的操作是有限的,這反過來又限制了可以用它來向量化的操作種類。另外,由於它在一個給定的程式中只有單一的大小,一些介於128位和256位之間的資料集大小可能不會像你希望的那樣被處理好。你寫了基於Vector的演算法,並在一臺支援256位向量的機器上執行,這意味著它一次可以處理32個位元組,但是你給它的輸入是31個位元組。如果Vector對映到128位向量,它就可以用來改善對該輸入的處理,但是由於它的向量大小大於輸入資料的大小,實現最終會退回到沒有加速的狀態。還有與R2R和Native AOT有關的問題,因為超前編譯需要事先知道哪些指令應該用於Vector操作。你在前面討論DOTNET_JitDisasmSummary的輸出時已經看到了這一點;我們看到NarrowUtf16ToAscii方法是 "hello, world "控制檯應用程式中僅有的幾個被JIT編譯的方法之一,這是因為它由於使用Vector而缺乏R2R程式碼。

從.NET Core 3.0開始,.NET獲得了數以千計的新的 "硬體本徵 "方法,其中大部分是對映到這些SIMD指令之一的.NET API。這些內在因素使專家能夠編寫一個針對特定指令集的實現,如果做得好,可以獲得最好的效能,但這也要求開發者瞭解每個指令集,併為每個可能相關的指令集實現他們的演算法,例如,如果支援AVX2實現,或支援SSE2實現,或支援ArmBase實現,等等。

.NET 7引入了一箇中間地帶。以前的版本引入了Vector128和Vector256型別,但純粹是作為資料進出硬體本徵的載體,因為它們都與特定寬度的向量有關。現在在.NET 7中,透過dotnet/runtime#53450dotnet/runtime#63414dotnet/runtime#60094dotnet/runtime#68559,在這些型別上也定義了大量的跨平臺操作,例如Vector128.ExtractMostSignificantBits、Vector256.ConditionalSelect,等等。想要或需要超越高層Vector提供的內容的開發者可以選擇針對這兩種型別中的一種或多種。通常情況下,開發者會在Vector128的基礎上編寫一條程式碼路徑,因為這條路徑的覆蓋面最廣,可以從向量化中獲得大量的收益,然後如果有動力的話,可以為Vector256新增第二條路徑,以便在擁有256位寬度向量的平臺上進一步增加吞吐量。把這些型別和方法看作是一個平臺抽象層:你向這些方法編碼,然後JIT將它們翻譯成最適合底層平臺的指令。考慮一下這個簡單的程式碼作為一個例子。

using System.Runtime.CompilerServices;
using System.Runtime.Intrinsics;
using System.Runtime.Intrinsics.X86;

internal class Program
{
    private static void Main()
    {
        Vector128<byte> v = Vector128.Create((byte)123);
        while (true)
        {
            WithIntrinsics(v);
            WithVector(v);
        }
    }

    [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();
}

我有兩個函式:一個直接使用Sse2.MoveMask硬體本徵,一個使用新的Vector128.ExtractMostSignificantBits方法。使用DOTNET_JitDisasm=Program.*,以下是在我的x64 Windows機器上這些最佳化後的一級程式碼的樣子。

; Assembly listing for method Program:WithIntrinsics(Vector128`1):int
G_M000_IG01:                ;; offset=0000H
       C5F877               vzeroupper

G_M000_IG02:                ;; offset=0003H
       C5F91001             vmovupd  xmm0, xmmword ptr [rcx]
       C5F9D7C0             vpmovmskb eax, xmm0

G_M000_IG03:                ;; offset=000BH
       C3                   ret

; Total bytes of code 12

; Assembly listing for method Program:WithVector(Vector128`1):int
G_M000_IG01:                ;; offset=0000H
       C5F877               vzeroupper

G_M000_IG02:                ;; offset=0003H
       C5F91001             vmovupd  xmm0, xmmword ptr [rcx]
       C5F9D7C0             vpmovmskb eax, xmm0

G_M000_IG03:                ;; offset=000BH
       C3                   ret

; Total bytes of code 12

注意到什麼了嗎?這兩種方法的程式碼是相同的,都會產生一條vpmovmskb(移動位元組掩碼 (Move Byte Mask))指令。然而,前者的程式碼只能在支援SSE2的平臺上工作,而後者的程式碼可以在任何支援128位向量的平臺上工作,包括Arm64和WASM(以及未來任何支援SIMD的平臺);只是在這些平臺上發出的指令不同。

為了進一步探討這個問題,讓我們舉一個簡單的例子,並將其向量化。我們將實現一個包含方法,我們想在一個位元組的範圍內搜尋一個特定的值,並返回是否找到它。

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對其進行向量化?首先,我們需要檢查它是否被支援,如果不被支援,則退回到我們現有的實現(Vector.IsHardwareAccelerated)。如果輸入的長度小於一個向量的大小,我們也需要回退(Vector.Count)。

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;
}

現在我們知道我們有足夠的資料,我們可以開始為我們的向量迴圈編碼了。在這個迴圈中,我們將搜尋針,這意味著我們需要一個每個元素都包含該值的向量;Vector的建構函式提供了這一點(new Vector(need))。而且我們需要能夠一次切下一個向量寬度的資料;為了更有效率,我將使用指標。我們需要一個當前的迭代指標,我們需要迭代到我們無法形成另一個向量的地方,因為我們離終點太近了,一個直接的方法是得到一個離終點正好是一個向量寬度的指標;這樣,我們就可以直接迭代到我們當前的指標等於或大於這個閾值。最後,在我們的迴圈體中,我們需要比較我們的當前向量和目標向量,看是否有任何元素是相同的(Vector.EqualsAny),如果有則返回真,如果沒有則將我們的當前指標撞向下一個位置。在這一點上,我們有。

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);
            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);

            // ...
        }
    }
    else
    {
        for (int i = 0; i < haystack.Length; i++)
        {
            if (haystack[i] == needle)
            {
                return true;
            }
        }
    }

    return false;
}

而我們幾乎已經完成了。最後要處理的問題是,我們可能在最後還有一些元素沒有搜尋到。我們有幾種方法可以處理這個問題。一種是繼續執行我們的後退方案,並逐個處理剩餘的元素。另一種方法是採用向量非同步操作時常見的技巧。我們的操作沒有改變任何東西,這意味著如果我們多次比較同一個元素也沒有關係,這意味著我們可以只對搜尋空間中的最後一個向量做最後的向量比較;這可能與我們已經看過的元素重疊,也可能不重疊,但即使重疊也不會有什麼影響。就這樣,我們的實現就完成了。

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);
            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;
}

恭喜你,我們對這個操作進行了向量處理,而且處理得相當好。我們可以把它扔到benchmarkdotnet中,看到非常好的速度。

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

[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
方法 平均值 比率
Find 484.05 ns 1.00
FindVectorized 20.21 ns 0.04

24倍的提速! 嗚呼,勝利,你所有的表現都屬於我們!

你在你的服務中部署了這個,你看到蘄春在你的熱路徑上被呼叫,但你沒有看到你所期望的改進。你再深入研究一下,你發現雖然你是用一個有1000個元素的輸入陣列來測試的,但典型的輸入有30個元素。如果我們改變我們的基準,只有30個元素,會發生什麼?這還不足以形成一個向量,所以我們又回到了一次一個的路徑,而且我們根本沒有得到任何速度的提升。

我們現在可以做的一件事是從使用Vector切換到Vector128。這將把閾值從32位元組降低到16位元組,這樣,在這個範圍內的輸入仍然會有一定量的向量化應用。由於這些Vector128和Vector256型別是最近設計的,它們也利用了所有很酷的新玩具,因此我們可以使用Refs而不是指標。除此之外,我們可以保持我們的實現的形狀幾乎相同,在我們使用Vector的地方替換Vector128,並在我們固定的跨度上使用Unsafe上的一些方法來操作我們的Refs而不是指標算術。

static unsafe bool Contains(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;
}

有了這一點,我們現在可以在我們較小的30個元素的資料集上試試。

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);
方法 平均值 比率
Find 15.388 ns 1.00
FindVectorized 1.747 ns 0.11

嗚呼,勝利,你所有的表現都屬於我們......再次!

再大的資料集上呢?之前用Vector我們有24倍的速度,但現在。

方法 平均值 比率
Find 484.25 ns 1.00
FindVectorized 32.92 ns 0.07

...更接近於15倍。這沒什麼好奇怪的,但它不是我們以前看到的24倍。如果我們想把蛋糕也吃了呢?讓我們也新增一個Vector256路徑。要做到這一點,我們從字面上複製/貼上我們的Vector128程式碼,在複製的程式碼中用Vector256搜尋/替換所有對Vector128的引用,然後把它放到一個額外的條件中,如果支援Vector256路徑並且有足夠的元素來利用它,就使用它。

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

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

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

            if (Vector256.EqualsAny(target, Vector256.LoadUnsafe(ref endMinusOneVector)))
            {
                return true;
            }
        }
        else
        {
            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;
}

然後,轟隆一聲,我們回來了。

方法 平均值 比率
Find 484.53 ns 1.00
FindVectorized 20.08 ns 0.04

我們現在有一個在任何具有128位或256位向量指令的平臺上向量化的實現(x86、x64、Arm64、WASM等),它可以根據輸入長度使用其中之一,如果有興趣的話,它可以被包含在R2R影像中。

有很多因素會影響你走哪條路,我希望我們會有指導意見,以幫助駕馭所有的因素和方法。但是能力都在那裡,無論你選擇使用Vector、Vector128和/或Vector256,還是直接使用硬體本徵,都有一些驚人的效能機會可以利用。

我已經提到了幾個暴露了新的跨平臺向量支援的PR,但這只是觸及了為實際啟用這些操作並使其產生高質量程式碼所做工作的表面。作為這類工作的一個例子,有一組修改是為了幫助確保零向量常量得到良好的處理,比如dotnet/runtime#63821將Vector128/256.Create(default) "變形"(改變)為Vector128/256.Zero,這使得後續最佳化只關注Zero;dotnet/runtime#65028使Vector128/256的持續傳播。 Zero;dotnet/runtime#68874dotnet/runtime#70171在JIT的中間表示中新增了向量常量的一流知識;dotnet/runtime#62933dotnet/runtime#65632dotnet/runtime#55875dotnet/runtime#67502dotnet/runtime#64783都提高了為零向量比較生成指令的程式碼質量。

內聯 (Inlining)

內聯是JIT可以做的最重要的最佳化之一。這個概念很簡單:與其呼叫某個方法,不如從該方法中獲取程式碼並將其烘烤到呼叫位置。這有一個明顯的優勢,就是避免了方法呼叫的開銷,但是除了在非常熱的路徑上的非常小的方法,這往往是內聯帶來的較小的優勢。更大的勝利是由於被呼叫者的程式碼被暴露給呼叫者的程式碼,反之亦然。例如,如果呼叫者將一個常數作為引數傳遞給被呼叫者,如果該方法沒有被內聯,被呼叫者的編譯就不知道這個常數,但是如果被呼叫者被內聯,被呼叫者的所有程式碼就知道它的引數是一個常數,並且可以對這樣一個常數進行所有可能的最佳化,比如消除死程式碼、消除分支、常數摺疊和傳播,等等。當然,如果這一切都是彩虹和獨角獸,所有可能被內聯的東西都會被內聯,但這顯然不會發生。內聯帶來的代價是可能增加二進位制的大小。如果被內聯的程式碼在呼叫者中會產生與呼叫被呼叫者相同或更少的彙編程式碼(如果JIT能夠快速確定這一點),那麼內聯就是一個沒有問題的事情。但是,如果被內聯的程式碼會不經意地增加被呼叫者的大小,現在JIT需要權衡程式碼大小的增加和可能帶來的吞吐量優勢。由於增加了要執行的不同指令的數量,從而給指令快取帶來了更大的壓力,程式碼大小的增加本身就可能導致吞吐量的下降。就像任何快取一樣,你需要從記憶體中讀取的次數越多,快取的效果就越差。如果你有一個被內聯到100個不同的呼叫點的函式,這些呼叫點的每一個被呼叫者的指令副本都是獨一無二的,呼叫這100個函式中的每一個最終都會使指令快取受到影響;相反,如果這100個函式都透過簡單地呼叫被呼叫者的單一例項來 "共享 "相同的指令,那麼指令快取可能會更有效,並導致更少的記憶體訪問。

所有這些都說明,內聯真的很重要,重要的是 "正確 "的東西被內聯,而且不能過度內聯,因此,在最近的記憶中,每一個.NET版本都圍繞內聯進行了很好的改進。.NET 7也不例外。

圍繞內聯的一個真正有趣的改進是dotnet/runtime#64521,它可能是令人驚訝的。考慮一下Boolean.ToString方法;這裡是它的完整實現。

public override string ToString()
{
    if (!m_value) return "False";
    return "True";
}

很簡單,對嗎?你會期望這麼簡單的東西能被內聯。唉,在.NET 6上,這個基準。

private bool _value = true;

[Benchmark]
public int BoolStringLength() => _value.ToString().Length;

產生這個彙編程式碼。

; Program.BoolStringLength()
       sub       rsp,28
       cmp       [rcx],ecx
       add       rcx,8
       call      System.Boolean.ToString()
       mov       eax,[rax+8]
       add       rsp,28
       ret
; Total bytes of code 23

請注意對System.Boolean.ToString()的呼叫。其原因是,從歷史上看,JIT不能跨彙編邊界內聯方法,如果這些方法包含字串字面(如Boolean.ToString實現中的 "False "和 "True")。這一限制與字串互譯有關,而且這種內聯可能會導致可見的行為差異。這些顧慮已不再有效,因此本PR刪除了這一限制。因此,在.NET 7上的同一基準測試現在產生了以下結果。

; Program.BoolStringLength()
       cmp       byte ptr [rcx+8],0
       je        short M00_L01
       mov       rax,1DB54800D20
       mov       rax,[rax]
M00_L00:
       mov       eax,[rax+8]
       ret
M00_L01:
       mov       rax,1DB54800D18
       mov       rax,[rax]
       jmp       short M00_L00
; Total bytes of code 38

不再呼叫System.Boolean.ToString()。

dotnet/runtime#61408做了兩個與內聯有關的修改。首先,它教會了內聯程式如何更好地看到內聯候選程式中正在呼叫的方法,特別是當分層編譯被禁用或一個方法將繞過第0層時(例如在OSR存在之前或OSR被禁用時帶有迴圈的方法);透過了解正在呼叫的方法,它可以更好地理解方法的成本,例如,如果這些方法呼叫實際上是成本很低的硬體內含物。第二,它在更多有SIMD向量的情況下啟用CSE。

dotnet/runtime#71778也影響了內聯,特別是在typeof()可以傳播給被呼叫者的情況下(例如透過方法引數)。在以前的.NET版本中,Type上的各種成員(如IsValueType)被轉化為JIT的內在因素,這樣JIT就可以為那些可以在編譯時計算出答案的呼叫替換一個常量值。例如,這個。

[Benchmark]
public bool IsValueType() => IsValueType<int>();

private static bool IsValueType<T>() => typeof(T).IsValueType;

在.NET 6上的這個彙編程式碼的結果是

; Program.IsValueType()
       mov       eax,1
       ret
; Total bytes of code 6

然而,稍微改變一下基準。

[Benchmark]
public bool IsValueType() => IsValueType(typeof(int));

private static bool IsValueType(Type t) => t.IsValueType;

而不再是那麼簡單了。

; Program.IsValueType()
       sub       rsp,28
       mov       rcx,offset MT_System.Int32
       call      CORINFO_HELP_TYPEHANDLE_TO_RUNTIMETYPE
       mov       rcx,rax
       mov       rax,[7FFCA47C9560]
       cmp       [rcx],ecx
       add       rsp,28
       jmp       rax
; Total bytes of code 38

實際上,作為內聯的一部分,JIT失去了引數是一個常量的概念,並且未能傳播它。這個PR修復了這個問題,因此在.NET 7上,我們現在可以得到我們所期望的。

; Program.IsValueType()
       mov       eax,1
       ret
; Total bytes of code 6

原文連結

Performance Improvements in .NET 7

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

如有任何疑問,請與我聯絡 (MingsonZheng@outlook.com)

相關文章