C# 使用SIMD向量類型加速浮點數組求和運算(1):使用Vector4、Vector

作者:

目錄

    一、緣由

    從.NET Core 1.0開始,.NET里增加了2種向量類型——

    1. 大小固定的向量(Vectors with a fixed size)。例如 結構體(struct) Vector2、Vector3、Vector4。
    2. 大小與硬體相關的向量(Vectors with a hardware dependent size)。例如 只讀結構體(readonly struct) Vector<T>,及輔助的靜態類 Vector。

    到了 .NET Core 3.0,增加了內在函數(Intrinsics Functions)的支援,並增加了第3類向量類型——
    3. 總位寬固定的向量(Vector of fixed total bit width)。例如 只讀結構體 Vector64<T>Vector128<T>Vector256<T>,及輔助的靜態類 Vector64、Vector128、Vector256。

    這3類向量類型,均能利用CPU硬體的SIMD(float Instruction Multiple Data,單指令多數據流)功能,來加速多媒體數據的處理。但是它們名稱很接近,對於初學者來說容易混淆,而且應用場景稍有區別,本文致力於解決這些問題。
    本章重點解說前2種向量類型(Vector4、Vector<T>),第3種向量類型將由第2章來解說。

    本章回答了這些問題——

    • 怎樣使用這2種向量類型?以做浮點數組求和運算為例。
    • 這2種向量類型的使用場景,及最佳實踐是怎樣的?
    • 我們的普通PC機的浮點運算性能,能達到每秒多少 MFLOPS(百萬次浮點運算)?
    • 官方文檔上,.NET Framework 4.6 才支援大小固定的向量(如Vector4),且Vector<T>未提到.NET Framework的支援版本。難道 .NET Framework用不了Vector<T> 嗎? .NET Framework 4.5等版本時是否能使用它們?
    • 官方文檔上,僅 .NET Standard 2.1 才支援這2種向量類型。而.NET Standard 2.0應用最廣泛,該怎麼在.NET Standard 2.0上使用它們?
    • 若在類庫里使用了向量類型,那麼 .NET Core或.NET Framework引用類庫時,向量類型是否仍會有硬體加速?
    • 當沒有硬體加速(Vector.IsHardwareAccelerated==false)時,使用向量類型會有什麼問題嗎?
    • 有人說「僅64位、Release模式編譯時」向量類型才會有硬體加速,而其他情況沒有硬體加速,是這樣的嗎?

    二、使用向量類型

    用高級語言處理數據時,一般是SISD(float instruction float data,單指令流單數據流)模型的,即一個語句只能處理一條數據。
    而對於多媒體數據處理,任務的特點是運算相對簡單,但是數據量很大,導致SISD模型的效率很低。
    若使用SIMD模型的話,一次能處理多條數據,從而能成倍的提高性能。
    .NET Core引入了向量數據類型,從而使C#(等.NET中語言)能使用SIMD加速數據的處理。

    並不是所有的數據處理工作都適合SIMD處理。一般來說,需滿足以下條件,才能充分利用SIMD加速——

    1. 數據量大(至少超過1000)且連續的存放在記憶體里。若數據規模小,SIMD無法體現性能優勢;若數據不是連續存放,那麼會遇到記憶體傳輸率的瓶頸,無法發揮SIMD的實力。
    2. 每個元素的處理運算需比較簡單。因為SIMD的函數,只能處理簡單的數學函數。
    3. 每個元素的處理步驟,大致相同。當每個元素的處理運算相同時,便能一個命令同時處理多條數據。當存在差異時,便需要利用掩碼與位運算,分別進行處理。當差異很大時,甚至向量程式碼比起標量程式碼,沒有優勢。
    4. 元素的數據類型,必須是.NET的基元類型,如 float、double、int 等。這是.NET向量類型的限制。

    對於以下情況,SIMD程式碼的性能會急劇下降,應盡量避免——

    • 分支跳轉。分支跳轉會導致流水線失效,導致SIMD性能會急劇下降。故在處理步驟稍有差異時,應盡量利用掩碼與位運算分別進行處理,而不是分支。
    • 元素間的數據相關性高。當沒有相關性時,才適合SIMD並發處理。若相關性高,那麼等待相關處理處理會浪費不少時間,無法發揮SIMD並發處理的優勢。很多時候可以使用MapReduce策略來處理數據,先在Map階段處理並發處理「無相關性的步驟」,最後在Reduce階段專門處理「有相關性的步驟」。

    基於以上原因,發現最適合演示SIMD運算優勢的,是做「浮點數組求和運算」。先在Map階段處理並發的進行分組求和,最後在Reduce階段將各組結果加起來。

    2.1 基本演算法

    為了對比測試,先用傳統的辦法來編寫一個「單精度浮點數組求和」的函數。
    其實演算法很簡單,寫個循環進行累加求和就行。程式碼如下。

    private static float SumBase(float[] src, int count) {
        float rt = 0; // Result.
        for(int i=0; i< count; ++i) {
            rt += src[i];
        }
        return rt;
    }
    

    由於.NET向量類型的初始化會有一些開銷,為了避免這些開銷影響主循環的性能測試結果,於是需要將它們移到循環外。為了測試方便,求和函數可增加一個loops參數,它是測試次數,作為外循環。loops為1時,就是標準的變數求和;為其他值時,是多輪變數求和的累計值。由於浮點精度有限的問題,累計值可能與乘法結果不同。
    為了能統一進行測試,於是基本演算法也增加了 loops 參數。

    private static float SumBase(float[] src, int count, int loops) {
        float rt = 0; // Result.
        for (int j=0; j< loops; ++j) {
            for(int i=0; i< count; ++i) {
                rt += src[i];
            }
        }
        return rt;
    }
    

    2.2 使用大小固定的向量(如 Vector4)

    2.2.1 介紹

    大小固定的向量類型,是以下3種結構體——

    • Vector2:表示一個具有兩個單精度浮點值的向量。
    • Vector3:表示一個具有三個單精度浮點值的向量。
    • Vector4:表示一個具有四個單精度浮點值的向量。

    它們實際上是對數學(線性代數分支)里「向量」(Vector)的封裝。命名規則為「’Vector’ + [維數]」,例如 Vector2是數學裡的「二維向量」、Vector3是數學裡的「三維向量」、Vector4是數學裡的「四維向量」。
    於是這些類型,除了提供了常見的四則運算函數外,還提供了 向量長度(Length)、向量距離(Distance)、點積(Dot)、叉積(Cross) 等線性代數領域的函數。
    它其中元素的數據類型,被限制為 float(32位單精度浮點值)。能用於常見單精度浮點運算場合。

    使用這些向量類型時,JIT會儘可能的利用硬體加速,但是沒有提供「是否有硬體加速」的標誌。
    這是因為不同的運算函數,在不同的CPU指令集里,有些能硬體加速,而另一些不能,很難通過簡單的標誌來區分。於是JIT僅是保證能儘可能的利用硬體加速,讓使用者不用關心這些硬體細節。
    一般來說,直接用這些類型的封裝函數(如點積、叉積 運算等),比手工按數學定義編寫的運算函數,效率更高。因為即使沒有硬體加速時,這些封裝好的函數是高水平的程式設計師編寫的成熟程式碼。

    Vector2、Vector3 比起 Vector4,元素個數要少一些,從數學定義上來看,理論運算量要少一些。
    但是硬體的SIMD加速,大多是按「4元素並行處理」來設計。故很多時候,「Vector2、Vector3」運算性能與「Vector4」差不多。甚至在一些特別場合,比「Vector4」性能還低,因為對於硬體來說,可能會有多餘的 忽略多餘元素處理、數據轉換 工作。

    於是建議這樣使用——

    • 若是開發數學上的向量運算相關的功能,可根據業務上對向量運算的要求,使用維度匹配的向量類。例如 2維向量處理時用Vector2、3維向量處理時用Vector3、3維齊次向量處理時用Vector4。
    • 若是想對數據進行SIMD優化,那麼應該用 Vector4。

    2.2.2 用Vector4編寫浮點數組求和函數

    現在,我們使用Vector4,來編寫浮點數組求和函數。
    思路:Vector4內有4個元素,於是可以分為4個組分別進行求和(即Map階段),最後再將4個組的結果加起來(即Reduce階段)。

    我們先可建立SumVector4函數。根據之前所說(為了.NET向量類型的初始化),該函數還增加了1個loops參數。

    /// <summary>
    /// Sum - Vector4.
    /// </summary>
    /// <param name="src">Soure array.</param>
    /// <param name="count">Soure array count.</param>
    /// <param name="loops">Benchmark loops.</param>
    /// <returns>Return the sum value.</returns>
    private static float SumVector4(float[] src, int count, int loops) {
        float rt = 0; // Result.
        // TODO
        return rt;
    }
    

    注意,數組長度可能不是4的整數倍。此時僅能對前面的、4的整數倍的數據用Vector4進行運算,而對於末尾剩餘的元素,只能用傳統辦法來處理。
    此時可利用「塊」(Block)的概念來簡化思路:每次內循環處理1個塊,先對能湊齊整塊的數據用Vector4進行循環處理(cntBlock),最後再對末尾剩餘的元素(cntRem)按傳統方式來處理。
    Vector4有4個元素,於是塊寬度(nBlockWidth)為4。程式碼摘錄如下。

        const int VectorWidth = 4;
        int nBlockWidth = VectorWidth; // Block width.
        int cntBlock = count / nBlockWidth; // Block count.
        int cntRem = count % nBlockWidth; // Remainder count.
    

    C#是強類型的,會嚴格檢查類型是否匹配,為了能使用Vector4,需要先將浮點數組轉換為Vector4。這一步驟,一般叫做「Load」(載入)。
    再加上相關變數的定義及初始化,「Load」部分的程式碼摘錄如下。

        Vector4 vrt = Vector4.Zero; // Vector result.
        int p; // Index for src data.
        int i;
        // Load.
        Vector4[] vsrc = new Vector4[cntBlock]; // Vector src.
        p = 0;
        for (i = 0; i < vsrc.Length; ++i) {
            vsrc[i] = new Vector4(src[p], src[p + 1], src[p + 2], src[p + 3]);
            p += VectorWidth;
        }
    

    由於 Vector4 的構造函數不支援從數組裡載入數據,僅支援「傳遞4個浮點變數」。於是上面的循環里,使用「傳遞4個浮點變數」的方式創建Vector4,然後放到vsrc數組中。vsrc數組中的每一項,就是一個塊(Block)。

    現在已經準備好了,可以用循環進行數據運算(Map階段:分為4個組分別進行求和)了。程式碼摘錄如下。

        // Body.
        for (int j = 0; j < loops; ++j) {
            // Vector processs.
            for (i = 0; i < cntBlock; ++i) {
                // Equivalent to scalar model: rt += src[i];
                vrt += vsrc[i]; // Add.
            }
            // Remainder processs.
            p = cntBlock * nBlockWidth;
            for (i = 0; i < cntRem; ++i) {
                rt += src[p + i];
            }
        }
    

    外循環loops的作用僅是為了方便測試,關鍵程式碼在2個內循環里:

    1. Vector processs(向量處理):以塊為單位進行循環處理,利用 Vector4 有4個元素特點,進行4路並發加法,將 vsrc[i] 的值,加到 vrt 里。vrt是Vector4類型的變數,定義時已初始化為0。
    2. Remainder processs(剩餘數據處理):先計算一下剩餘數據的起始索引(p = cntBlock * nBlockWidth),然後使用傳統循環寫法,將剩餘數據累積到 rt 里。

    由於Vector4重載了「+」運演算法,所以可以很簡單的使用「+=」運算符來做「相加並賦值」操作。程式碼寫法,與傳統的標量程式碼很相似,程式碼可讀性高。

    rt += src[i]; // 標量程式碼.
    vrt += vsrc[i]; // 向量程式碼.
    

    最後我們需要將各組的結果加在一起(Reduce階段)。程式碼摘錄如下。

        // Reduce.
        rt += vrt.X + vrt.Y + vrt.Z + vrt.W;
        return rt;
    

    因 Vector4 暴露了 X、Y、Z、W 這4個成員,於是可以很方便的用「+」運算符,將結果加在一起。

    該函數的完整程式碼如下。

    private static float SumVector4(float[] src, int count, int loops) {
        float rt = 0; // Result.
        const int VectorWidth = 4;
        int nBlockWidth = VectorWidth; // Block width.
        int cntBlock = count / nBlockWidth; // Block count.
        int cntRem = count % nBlockWidth; // Remainder count.
        Vector4 vrt = Vector4.Zero; // Vector result.
        int p; // Index for src data.
        int i;
        // Load.
        Vector4[] vsrc = new Vector4[cntBlock]; // Vector src.
        p = 0;
        for (i = 0; i < vsrc.Length; ++i) {
            vsrc[i] = new Vector4(src[p], src[p + 1], src[p + 2], src[p + 3]);
            p += VectorWidth;
        }
        // Body.
        for (int j = 0; j < loops; ++j) {
            // Vector processs.
            for (i = 0; i < cntBlock; ++i) {
                // Equivalent to scalar model: rt += src[i];
                vrt += vsrc[i]; // Add.
            }
            // Remainder processs.
            p = cntBlock * nBlockWidth;
            for (i = 0; i < cntRem; ++i) {
                rt += src[p + i];
            }
        }
        // Reduce.
        rt += vrt.X + vrt.Y + vrt.Z + vrt.W;
        return rt;
    }
    

    2.3 使用大小與硬體相關的向量(如 Vector<T>

    2.3.1 介紹

    Vector4的痛點是——元素類型固定為float,且僅有4個元素。導致它的使用範圍有限。
    Vector<T> 解決了這2大痛點——

    1. 它具有泛型參數T,可以支援各種數值型的基元類型,如 float、double、int 等。
    2. 它的元素個數不止4個,而是由硬體決定的。若硬體支援向量位寬越寬,那麼Vector<T>的元素個數便越大。使用Vector<T>在各種向量位寬的硬體上運行時,會以最大向量位寬來運行,而僅需只編寫一套程式碼。

    以下是官方文檔對 Vector<T> 的介紹。

    `Vector<T>` 是一個不可變結構,表示指定數值類型的單個向量。 實例計數是固定的 `Vector<T>` ,但其上限取決於 CPU 暫存器。 它旨在用作向量大型演算法的構建基塊,因此不能直接用作任意長度向量或張量。
    該 `Vector<T>` 結構為硬體加速提供支援。
    本文中的術語 基元數值數據類型 是指 CPU 直接支援的數值數據類型,並具有可以操作這些數據類型的說明。 下表顯示了哪些基元數值數據類型和操作組合使用內部指令來加快執行速度:
    
    基元類型 + - * /
    sbyte
    byte
    short
    ushort
    int
    uint
    long
    ulong
    float
    double
    2.2.1.1 使用經驗

    有一個跟 Vector<T> 配合使用的靜態類 Vector。它有2大作用——

    1. 提供了 IsHardwareAccelerated 屬性,用於檢查 Vector<T> 是否有硬體加速。應用程式應該檢查該屬性,僅在該屬性為true,才使用 Vector<T>
    2. 提供了大量的數學函數,能便於 SIMD數據處理。Vector<T> 只是重載了運算符,對於運算符無法辦到的一些數學運算,可以去靜態類 Vector 里找。

    Vector<T> 具有這些屬性:

    • Count:【靜態】返回存儲在向量中的元素數量。
    • Item[int]:獲取指定索引處的元素。
    • One:【靜態】返回一個包含所有 1 的向量。
    • Zero:【靜態】返回一個包含所有 0 的向量。

    因為 Vector<T> 長度是與硬體有關的,所以每次在使用 Vector<T> 時,別忘了需要先從 Count 屬性里的到元素數量。

    一般來說——

    • 若CPU是 x86體系的,且支援 AVX2指令集 時,那麼 Vector<T> 長度為256位,即32位元組。此時能並行的處理 32個byte,或 16個short、8個int、4個long、8個float、4個double。
    • 若CPU是 x86體系的,不支援AVX2指令集,但支援 SSE2指令集 時,那麼 Vector<T> 長度為128位,即16位元組。此時能並行的處理 16個byte,或 8個short、4個int、2個long、4個float、2個double。
    • 若CPU不支援向量硬體加速時,那麼 Vector<T> 長度仍為128位,即16位元組。Vector.IsHardwareAccelerated為false,不建議使用。長度仍為128位,這可能是為了方便程式碼兼容性。

    這些情況的IsHardwareAccelerated、Count屬性,一般為這些值——

    // If the CPU is x86 and supports the AVX2 instruction set.
    Vector.IsHardwareAccelerated = true
    Vector<sbyte>.Count = 32
    Vector<byte>.Count = 32
    Vector<short>.Count = 16
    Vector<ushort>.Count = 16
    Vector<int>.Count = 8
    Vector<uint>.Count = 8
    Vector<long>.Count = 4
    Vector<ulong>.Count = 4
    Vector<float>.Count = 8
    Vector<double>.Count = 4
    
    // If the CPU is x86, the AVX2 instruction set is not supported, but the SSE2 instruction set is supported.
    Vector.IsHardwareAccelerated = true
    Vector<sbyte>.Count = 16
    Vector<byte>.Count = 16
    Vector<short>.Count = 8
    Vector<ushort>.Count = 8
    Vector<int>.Count = 4
    Vector<uint>.Count = 4
    Vector<long>.Count = 2
    Vector<ulong>.Count = 2
    Vector<float>.Count = 4
    Vector<double>.Count = 2
    
    // If the CPU does not support vector hardware acceleration.
    Vector.IsHardwareAccelerated = false
    Vector<sbyte>.Count = 16
    Vector<byte>.Count = 16
    Vector<short>.Count = 8
    Vector<ushort>.Count = 8
    Vector<int>.Count = 4
    Vector<uint>.Count = 4
    Vector<long>.Count = 2
    Vector<ulong>.Count = 2
    Vector<float>.Count = 4
    Vector<double>.Count = 2
    

    2.3.2 用 Vector<T> 編寫浮點數組求和函數

    現在,我們使用 Vector<T>,來編寫浮點數組求和函數。
    思路:先使用Count屬性獲得元素個數,然後按Count分組分別進行求和(即Map階段),最後再將這些組的結果加起來(即Reduce階段)。

    根據上面的經驗,我們可編寫好 SumVectorT 函數。

    private static float SumVectorT(float[] src, int count, int loops) {
        float rt = 0; // Result.
        int VectorWidth = Vector<float>.Count; // Block width.
        int nBlockWidth = VectorWidth; // Block width.
        int cntBlock = count / nBlockWidth; // Block count.
        int cntRem = count % nBlockWidth; // Remainder count.
        Vector<float> vrt = Vector<float>.Zero; // Vector result.
        int p; // Index for src data.
        int i;
        // Load.
        Vector<float>[] vsrc = new Vector<float>[cntBlock]; // Vector src.
        p = 0;
        for (i = 0; i < vsrc.Length; ++i) {
            vsrc[i] = new Vector<float>(src, p);
            p += VectorWidth;
        }
        // Body.
        for (int j = 0; j < loops; ++j) {
            // Vector processs.
            for (i = 0; i < cntBlock; ++i) {
                vrt += vsrc[i]; // Add.
            }
            // Remainder processs.
            p = cntBlock * nBlockWidth;
            for (i = 0; i < cntRem; ++i) {
                rt += src[p + i];
            }
        }
        // Reduce.
        for (i = 0; i < VectorWidth; ++i) {
            rt += vrt[i];
        }
        return rt;
    }
    

    對比 SumVector4,除了將 Vector4 類型換為 Vector<T>,還有這些變化——

    • VectorWidth不再是一個固定常數,而是通過 Vector<float>.Count 屬性來得到。
    • Vector<T> 的構造函數支援數組參數。於是可以用 new Vector<float>(src, p),代替繁瑣的 new Vector4(src[p], src[p + 1], src[p + 2], src[p + 3])
    • Vector<T>支援索引器(文檔里的Item屬性),可以使用索引器運算符 [],簡潔的獲取它的元素。於是在Reduce階段,可以寫個循環對結果進行累加。

    三、搭建測試程式

    對於這2類向量類型,計劃在以下平台進行測試——

    • .NET Core
    • .NET Framework
    • .NET Standard

    開發環境選擇VS2017。解決方案名的名稱是「BenchmarkVector」。
    因需要測試這麼多平台,為了避免程式碼重複問題,故將主測試程式碼放到共享項目(Shared Project)里。隨後各個平台的測試程式,可以引用該共享項目。

    3.1 主測試程式碼(BenchmarkVectorDemo)

    共享項目的名稱是「BenchmarkVector」。其中的BenchmarkVectorDemo類,是主測試程式碼。

    3.1.1 測試方法(Benchmark)

    Benchmark是測試方法,程式碼如下。

    /// <summary>
    /// Do Benchmark.
    /// </summary>
    /// <param name="tw">Output <see cref="TextWriter"/>.</param>
    /// <param name="indent">The indent.</param>
    public static void Benchmark(TextWriter tw, string indent) {
        if (null == tw) return;
        if (null == indent) indent = "";
        //string indentNext = indent + "\t";
        // init.
        int tickBegin, msUsed;
        double mFlops; // MFLOPS/s .
        double scale;
        float rt;
        const int count = 1024*4;
        const int loops = 1000 * 1000;
        //const int loops = 1;
        const double countMFlops = count * (double)loops / (1000.0 * 1000);
        float[] src = new float[count];
        for(int i=0; i< count; ++i) {
            src[i] = i;
        }
        tw.WriteLine(indent + string.Format("Benchmark: \tcount={0}, loops={1}, countMFlops={2}", count, loops, countMFlops));
        // SumBase.
        tickBegin = Environment.TickCount;
        rt = SumBase(src, count, loops);
        msUsed = Environment.TickCount - tickBegin;
        mFlops = countMFlops * 1000 / msUsed;
        tw.WriteLine(indent + string.Format("SumBase:\t{0}\t# msUsed={1}, MFLOPS/s={2}", rt, msUsed, mFlops));
        double mFlopsBase = mFlops;
        // SumVector4.
        tickBegin = Environment.TickCount;
        rt = SumVector4(src, count, loops);
        msUsed = Environment.TickCount - tickBegin;
        mFlops = countMFlops * 1000 / msUsed;
        scale = mFlops / mFlopsBase;
        tw.WriteLine(indent + string.Format("SumVector4:\t{0}\t# msUsed={1}, MFLOPS/s={2}, scale={3}", rt, msUsed, mFlops, scale));
        // SumVectorT.
        tickBegin = Environment.TickCount;
        rt = SumVectorT(src, count, loops);
        msUsed = Environment.TickCount - tickBegin;
        mFlops = countMFlops * 1000 / msUsed;
        scale = mFlops / mFlopsBase;
        tw.WriteLine(indent + string.Format("SumVectorT:\t{0}\t# msUsed={1}, MFLOPS/s={2}, scale={3}", rt, msUsed, mFlops, scale));
    }
    

    變數說明——

    • count:浮點數組的長度。
    • loops:測試所用的外循環次數。
    • countMFlops:每次測試運算量是多少 MFLOPS(百萬次浮點運算)。
    • src:測試所用的浮點數組。
    • tickBegin:記錄測試開始的時刻。測試計時用的是 Environment.TickCount,它以毫秒為單位.
    • msUsed:測試所用的毫秒數。
    • mFlops:該函數的浮點性能。單位是 MFLOPS/s(百萬次浮點運算/秒)。
    • mFlopsBase:基本演算法的浮點性能。單位是 MFLOPS/s(百萬次浮點運算/秒)。
    • scale:性能提高倍數。既 當前演算法的性能,是基本演算法的多少倍。

    註:只有一級快取是在CPU中的,一級快取的讀取需要1-4個時鐘周期;二級快取的讀取需要10個左右的時鐘周期;而三級快取需要30-40個時鐘周期,但是容量一次增大。
    SIMD的數據規模大,一級快取放不下。為了避免快取速度干擾運算速度評測,故一般建議測試數據不要超過二級快取的大小。
    於是本範例的數據長度為 4K(1024*4),這是現代CPU的二級快取大多能接受的長度。

    例如在 .NET Core 2.0、lntel(R) Core(TM) i5-8250U CPU @ 1.60GHz、Windows 10 平台運行時,該測試函數的測試結果為:

    Benchmark:      count=4096, loops=1000000, countMFlops=4096
    SumBase:        6.871948E+10    # msUsed=4937, MFLOPS/s=829.653635811221
    SumVector4:     2.748779E+11    # msUsed=1234, MFLOPS/s=3319.2868719611, scale=4.00081037277147
    SumVectorT:     5.497558E+11    # msUsed=625, MFLOPS/s=6553.6, scale=7.8992
    

    輸出資訊說明——

    • SumBase:浮點性能為 MFLOPS/s=829.653635811221,即約 0.829 GFLOPS/s。
    • SumVector4:浮點性能為 MFLOPS/s=3319.2868719611,即約 3.319 GFLOPS/s。性能是基礎演算法的 4.00081037277147 倍。
    • SumVectorT:浮點性能為 MFLOPS/s=6553.6,即約 6.553 GFLOPS/s。性能是基礎演算法的 7.8992 倍。

    性能提高倍數(scale),與理論值相符。因為SumVector4能同時處理4個浮點數,支援AVX2指令集時的SumVectorT能同時處理8個浮點數。
    i5-8250U是2017年Intel發布的晶片,對於現在來說是老掉牙的配置了。C#程式碼不使用硬體加速時,是 0.829 GFLOPS/s 的浮點性能;使用 Vector<T> 並有硬體加速時,能達到 6.553 GFLOPS/s 的浮點性能,這樣的指標已經很不錯了。
    而且我們的測試,只是對單核的測試,多核並行處理的浮點性能會更高。編寫多執行緒程式便利用CPU多核,有興趣的讀者可以自己試試。

    注意上面的測試結果中,各函數返回的累加結果是不同的。這是主要是因為是分組統計,循環次數(loops)比較多,導致超過單精度浮點數的精度範圍。
    若臨時將loops改回1,會發現各函數的返回值是相同。故在開發時,可將loops改回1,便於檢查程式是否有問題;帶了測試時,再將loops改為較大的值。

    3.1.2 輸出環境資訊(OutputEnvironment)

    因為這次測試了多個平台,不同平台的環境資訊資訊均不同。於是可以專門用一個函數來輸出環境資訊,源碼如下。

    /// <summary>
    /// Is release make.
    /// </summary>
    public static readonly bool IsRelease =
    #if DEBUG
        false
    #else
        true
    #endif
    ;
    
    /// <summary>
    /// Output Environment.
    /// </summary>
    /// <param name="tw">Output <see cref="TextWriter"/>.</param>
    /// <param name="indent">The indent.</param>
    public static void OutputEnvironment(TextWriter tw, string indent) {
        if (null == tw) return;
        if (null == indent) indent="";
        //string indentNext = indent + "\t";
        tw.WriteLine(indent + string.Format("IsRelease:\t{0}", IsRelease));
        tw.WriteLine(indent + string.Format("EnvironmentVariable(PROCESSOR_IDENTIFIER):\t{0}", Environment.GetEnvironmentVariable("PROCESSOR_IDENTIFIER")));
        tw.WriteLine(indent + string.Format("Environment.ProcessorCount:\t{0}", Environment.ProcessorCount));
        tw.WriteLine(indent + string.Format("Environment.Is64BitOperatingSystem:\t{0}", Environment.Is64BitOperatingSystem));
        tw.WriteLine(indent + string.Format("Environment.Is64BitProcess:\t{0}", Environment.Is64BitProcess));
        tw.WriteLine(indent + string.Format("Environment.OSVersion:\t{0}", Environment.OSVersion));
        tw.WriteLine(indent + string.Format("Environment.Version:\t{0}", Environment.Version));
        //tw.WriteLine(indent + string.Format("RuntimeEnvironment.GetSystemVersion:\t{0}", System.Runtime.InteropServices.RuntimeEnvironment.GetSystemVersion())); // Same Environment.Version
        tw.WriteLine(indent + string.Format("RuntimeEnvironment.GetRuntimeDirectory:\t{0}", System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory()));
    #if (NET47 || NET462 || NET461 || NET46 || NET452 || NET451 || NET45 || NET40 || NET35 || NET20) || (NETSTANDARD1_0)
    #else
        tw.WriteLine(indent + string.Format("RuntimeInformation.FrameworkDescription:\t{0}", System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription));
    #endif
        tw.WriteLine(indent + string.Format("BitConverter.IsLittleEndian:\t{0}", BitConverter.IsLittleEndian));
        tw.WriteLine(indent + string.Format("IntPtr.Size:\t{0}", IntPtr.Size));
        tw.WriteLine(indent + string.Format("Vector.IsHardwareAccelerated:\t{0}", Vector.IsHardwareAccelerated));
        tw.WriteLine(indent + string.Format("Vector<byte>.Count:\t{0}\t# {1}bit", Vector<byte>.Count, Vector<byte>.Count * sizeof(byte) * 8));
        tw.WriteLine(indent + string.Format("Vector<float>.Count:\t{0}\t# {1}bit", Vector<float>.Count, Vector<float>.Count*sizeof(float)*8));
        tw.WriteLine(indent + string.Format("Vector<double>.Count:\t{0}\t# {1}bit", Vector<double>.Count, Vector<double>.Count * sizeof(double) * 8));
        Assembly assembly = typeof(Vector4).GetTypeInfo().Assembly;
        //tw.WriteLine(string.Format("Vector4.Assembly:\t{0}", assembly));
        tw.WriteLine(string.Format("Vector4.Assembly.CodeBase:\t{0}", assembly.CodeBase));
        assembly = typeof(Vector<float>).GetTypeInfo().Assembly;
        tw.WriteLine(string.Format("Vector<T>.Assembly.CodeBase:\t{0}", assembly.CodeBase));
    }
    

    例如在 .NET Core 2.0 平台運行時,會輸出這些資訊:

    IsRelease:      True
    EnvironmentVariable(PROCESSOR_IDENTIFIER):      Intel64 Family 6 Model 142 Stepping 10, GenuineIntel
    Environment.ProcessorCount:     8
    Environment.Is64BitOperatingSystem:     True
    Environment.Is64BitProcess:     True
    Environment.OSVersion:  Microsoft Windows NT 10.0.19044.0
    Environment.Version:    4.0.30319.42000
    RuntimeEnvironment.GetRuntimeDirectory: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\2.0.9\
    RuntimeInformation.FrameworkDescription:        .NET Core 4.6.26614.01
    BitConverter.IsLittleEndian:    True
    IntPtr.Size:    8
    Vector.IsHardwareAccelerated:   True
    Vector<byte>.Count:     32      # 256bit
    Vector<float>.Count:    8       # 256bit
    Vector<double>.Count:   4       # 256bit
    Vector4.Assembly.CodeBase:      file:///C:/Program Files/dotnet/shared/Microsoft.NETCore.App/2.0.9/System.Numerics.Vectors.dll
    Vector<T>.Assembly.CodeBase:    file:///C:/Program Files/dotnet/shared/Microsoft.NETCore.App/2.0.9/System.Numerics.Vectors.dll
    

    輸出資訊說明——

    • IsRelease: 是不是以 Release方式編譯的程式。
    • EnvironmentVariable(PROCESSOR_IDENTIFIER): CPU型號標識。
    • Environment.ProcessorCount: 邏輯處理器數量。
    • Environment.Is64BitOperatingSystem: 是不是64位作業系統。
    • Environment.Is64BitProcess: 當前進程是不是64位的。
    • Environment.OSVersion: 作業系統的版本。
    • Environment.Version: .NET運行環境的版本。
    • RuntimeEnvironment.GetRuntimeDirectory: .NET基礎庫的運行路徑。
    • RuntimeInformation.FrameworkDescription: .NET平台的版本。
    • BitConverter.IsLittleEndian: 是不是小端方式。
    • IntPtr.Size: 指針的大小。32位時為4,64位時為8。
    • Vector.IsHardwareAccelerated: Vector<T> 是否支援硬體加速。
    • Vector<byte>.Count: Vector<byte>的元素個數、總位數。
    • Vector<float>.Count: Vector<float>的元素個數、總位數。
    • Vector<double>.Count: Vector<double>的元素個數、總位數。
    • Vector4.Assembly.CodeBase: Vector4 所屬程式集的路徑。
    • Vector<T>.Assembly.CodeBase: Vector<T> 所屬程式集的路徑。

    3.1.3 匯總

    下面是BenchmarkVectorDemo類的完整程式碼。

    using System;
    using System.Collections.Generic;
    using System.IO;
    using System.Numerics;
    using System.Reflection;
    using System.Text;
    
    namespace BenchmarkVector {
        /// <summary>
        /// Benchmark Vector Demo
        /// </summary>
        static class BenchmarkVectorDemo {
            /// <summary>
            /// Is release make.
            /// </summary>
            public static readonly bool IsRelease =
    #if DEBUG
                false
    #else
                true
    #endif
            ;
    
            /// <summary>
            /// Output Environment.
            /// </summary>
            /// <param name="tw">Output <see cref="TextWriter"/>.</param>
            /// <param name="indent">The indent.</param>
            public static void OutputEnvironment(TextWriter tw, string indent) {
                if (null == tw) return;
                if (null == indent) indent="";
                //string indentNext = indent + "\t";
                tw.WriteLine(indent + string.Format("IsRelease:\t{0}", IsRelease));
                tw.WriteLine(indent + string.Format("EnvironmentVariable(PROCESSOR_IDENTIFIER):\t{0}", Environment.GetEnvironmentVariable("PROCESSOR_IDENTIFIER")));
                tw.WriteLine(indent + string.Format("Environment.ProcessorCount:\t{0}", Environment.ProcessorCount));
                tw.WriteLine(indent + string.Format("Environment.Is64BitOperatingSystem:\t{0}", Environment.Is64BitOperatingSystem));
                tw.WriteLine(indent + string.Format("Environment.Is64BitProcess:\t{0}", Environment.Is64BitProcess));
                tw.WriteLine(indent + string.Format("Environment.OSVersion:\t{0}", Environment.OSVersion));
                tw.WriteLine(indent + string.Format("Environment.Version:\t{0}", Environment.Version));
                //tw.WriteLine(indent + string.Format("RuntimeEnvironment.GetSystemVersion:\t{0}", System.Runtime.InteropServices.RuntimeEnvironment.GetSystemVersion())); // Same Environment.Version
                tw.WriteLine(indent + string.Format("RuntimeEnvironment.GetRuntimeDirectory:\t{0}", System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory()));
    #if (NET47 || NET462 || NET461 || NET46 || NET452 || NET451 || NET45 || NET40 || NET35 || NET20) || (NETSTANDARD1_0)
    #else
                tw.WriteLine(indent + string.Format("RuntimeInformation.FrameworkDescription:\t{0}", System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription));
    #endif
                tw.WriteLine(indent + string.Format("BitConverter.IsLittleEndian:\t{0}", BitConverter.IsLittleEndian));
                tw.WriteLine(indent + string.Format("IntPtr.Size:\t{0}", IntPtr.Size));
                tw.WriteLine(indent + string.Format("Vector.IsHardwareAccelerated:\t{0}", Vector.IsHardwareAccelerated));
                tw.WriteLine(indent + string.Format("Vector<byte>.Count:\t{0}\t# {1}bit", Vector<byte>.Count, Vector<byte>.Count * sizeof(byte) * 8));
                tw.WriteLine(indent + string.Format("Vector<float>.Count:\t{0}\t# {1}bit", Vector<float>.Count, Vector<float>.Count*sizeof(float)*8));
                tw.WriteLine(indent + string.Format("Vector<double>.Count:\t{0}\t# {1}bit", Vector<double>.Count, Vector<double>.Count * sizeof(double) * 8));
                Assembly assembly = typeof(Vector4).GetTypeInfo().Assembly;
                //tw.WriteLine(string.Format("Vector4.Assembly:\t{0}", assembly));
                tw.WriteLine(string.Format("Vector4.Assembly.CodeBase:\t{0}", assembly.CodeBase));
                assembly = typeof(Vector<float>).GetTypeInfo().Assembly;
                tw.WriteLine(string.Format("Vector<T>.Assembly.CodeBase:\t{0}", assembly.CodeBase));
            }
    
            /// <summary>
            /// Do Benchmark.
            /// </summary>
            /// <param name="tw">Output <see cref="TextWriter"/>.</param>
            /// <param name="indent">The indent.</param>
            public static void Benchmark(TextWriter tw, string indent) {
                if (null == tw) return;
                if (null == indent) indent = "";
                //string indentNext = indent + "\t";
                // init.
                int tickBegin, msUsed;
                double mFlops; // MFLOPS/s .
                double scale;
                float rt;
                const int count = 1024*4;
                const int loops = 1000 * 1000;
                //const int loops = 1;
                const double countMFlops = count * (double)loops / (1000.0 * 1000);
                float[] src = new float[count];
                for(int i=0; i< count; ++i) {
                    src[i] = i;
                }
                tw.WriteLine(indent + string.Format("Benchmark: \tcount={0}, loops={1}, countMFlops={2}", count, loops, countMFlops));
                // SumBase.
                tickBegin = Environment.TickCount;
                rt = SumBase(src, count, loops);
                msUsed = Environment.TickCount - tickBegin;
                mFlops = countMFlops * 1000 / msUsed;
                tw.WriteLine(indent + string.Format("SumBase:\t{0}\t# msUsed={1}, MFLOPS/s={2}", rt, msUsed, mFlops));
                double mFlopsBase = mFlops;
                // SumVector4.
                tickBegin = Environment.TickCount;
                rt = SumVector4(src, count, loops);
                msUsed = Environment.TickCount - tickBegin;
                mFlops = countMFlops * 1000 / msUsed;
                scale = mFlops / mFlopsBase;
                tw.WriteLine(indent + string.Format("SumVector4:\t{0}\t# msUsed={1}, MFLOPS/s={2}, scale={3}", rt, msUsed, mFlops, scale));
                // SumVectorT.
                tickBegin = Environment.TickCount;
                rt = SumVectorT(src, count, loops);
                msUsed = Environment.TickCount - tickBegin;
                mFlops = countMFlops * 1000 / msUsed;
                scale = mFlops / mFlopsBase;
                tw.WriteLine(indent + string.Format("SumVectorT:\t{0}\t# msUsed={1}, MFLOPS/s={2}, scale={3}", rt, msUsed, mFlops, scale));
            }
    
            /// <summary>
            /// Sum - base.
            /// </summary>
            /// <param name="src">Soure array.</param>
            /// <param name="count">Soure array count.</param>
            /// <param name="loops">Benchmark loops.</param>
            /// <returns>Return the sum value.</returns>
            private static float SumBase(float[] src, int count, int loops) {
                float rt = 0; // Result.
                for (int j=0; j< loops; ++j) {
                    for(int i=0; i< count; ++i) {
                        rt += src[i];
                    }
                }
                return rt;
            }
    
            /// <summary>
            /// Sum - Vector4.
            /// </summary>
            /// <param name="src">Soure array.</param>
            /// <param name="count">Soure array count.</param>
            /// <param name="loops">Benchmark loops.</param>
            /// <returns>Return the sum value.</returns>
            private static float SumVector4(float[] src, int count, int loops) {
                float rt = 0; // Result.
                const int VectorWidth = 4;
                int nBlockWidth = VectorWidth; // Block width.
                int cntBlock = count / nBlockWidth; // Block count.
                int cntRem = count % nBlockWidth; // Remainder count.
                Vector4 vrt = Vector4.Zero; // Vector result.
                int p; // Index for src data.
                int i;
                // Load.
                Vector4[] vsrc = new Vector4[cntBlock]; // Vector src.
                p = 0;
                for (i = 0; i < vsrc.Length; ++i) {
                    vsrc[i] = new Vector4(src[p], src[p + 1], src[p + 2], src[p + 3]);
                    p += VectorWidth;
                }
                // Body.
                for (int j = 0; j < loops; ++j) {
                    // Vector processs.
                    for (i = 0; i < cntBlock; ++i) {
                        // Equivalent to scalar model: rt += src[i];
                        vrt += vsrc[i]; // Add.
                    }
                    // Remainder processs.
                    p = cntBlock * nBlockWidth;
                    for (i = 0; i < cntRem; ++i) {
                        rt += src[p + i];
                    }
                }
                // Reduce.
                rt += vrt.X + vrt.Y + vrt.Z + vrt.W;
                return rt;
            }
    
            /// <summary>
            /// Sum - Vector<T>.
            /// </summary>
            /// <param name="src">Soure array.</param>
            /// <param name="count">Soure array count.</param>
            /// <param name="loops">Benchmark loops.</param>
            /// <returns>Return the sum value.</returns>
            private static float SumVectorT(float[] src, int count, int loops) {
                float rt = 0; // Result.
                int VectorWidth = Vector<float>.Count; // Block width.
                int nBlockWidth = VectorWidth; // Block width.
                int cntBlock = count / nBlockWidth; // Block count.
                int cntRem = count % nBlockWidth; // Remainder count.
                Vector<float> vrt = Vector<float>.Zero; // Vector result.
                int p; // Index for src data.
                int i;
                // Load.
                Vector<float>[] vsrc = new Vector<float>[cntBlock]; // Vector src.
                p = 0;
                for (i = 0; i < vsrc.Length; ++i) {
                    vsrc[i] = new Vector<float>(src, p);
                    p += VectorWidth;
                }
                // Body.
                for (int j = 0; j < loops; ++j) {
                    // Vector processs.
                    for (i = 0; i < cntBlock; ++i) {
                        vrt += vsrc[i]; // Add.
                    }
                    // Remainder processs.
                    p = cntBlock * nBlockWidth;
                    for (i = 0; i < cntRem; ++i) {
                        rt += src[p + i];
                    }
                }
                // Reduce.
                for (i = 0; i < VectorWidth; ++i) {
                    rt += vrt[i];
                }
                return rt;
            }
    
        }
    }
    

    3.2 在 .NET Core 里進行測試

    3.2.1 搭建測試項目(BenchmarkVectorCore20)

    雖然從.NET Core 1.0開始就支援了向量類型,但本文考慮到需要與.NET Standard進行對比測試,故選擇 .NET Core 2.0 比較好。
    在解決方案里建立新項目「BenchmarkVectorCore20」,它是 .NET Core 2.0 控制台程式的項目。並讓「BenchmarkVectorCore20」引用共享項目「BenchmarkVector」。
    隨後我們修改一下 Program 類的程式碼,加上調用測試函數的程式碼。程式碼如下。

    using BenchmarkVector;
    using System;
    using System.IO;
    using System.Numerics;
    
    namespace BenchmarkVectorCore20 {
        class Program {
            static void Main(string[] args) {
                string indent = "";
                TextWriter tw = Console.Out;
                tw.WriteLine("BenchmarkVectorCore20");
                tw.WriteLine();
                BenchmarkVectorDemo.OutputEnvironment(tw, indent);
                //tw.WriteLine(string.Format("Main-Vector4.Assembly.CodeBase:\t{0}", typeof(Vector4).Assembly.CodeBase));
                tw.WriteLine(indent);
                BenchmarkVectorDemo.Benchmark(tw, indent);
                // Vector<int> a = Vector<int>.One;
                // a <<= 1; // CS0019	Operator '<<=' cannot be applied to operands of type 'Vector<int>' and 'int'
            }
        }
    }
    

    註:上面程式碼還測試了一下 Vector<T> 是否支援移位運算符,發現目前不支援。從 .NET 的發展路線圖來看,到了 .NET 7Vector<T>會支援移位運算符。

    3.2.2 BenchmarkVectorCore20的測試結果

    在我的電腦(lntel(R) Core(TM) i5-8250U CPU @ 1.60GHz、Windows 10)上運行時,輸出資訊為:

    BenchmarkVectorCore20
    
    IsRelease:      True
    EnvironmentVariable(PROCESSOR_IDENTIFIER):      Intel64 Family 6 Model 142 Stepping 10, GenuineIntel
    Environment.ProcessorCount:     8
    Environment.Is64BitOperatingSystem:     True
    Environment.Is64BitProcess:     True
    Environment.OSVersion:  Microsoft Windows NT 10.0.19044.0
    Environment.Version:    4.0.30319.42000
    RuntimeEnvironment.GetRuntimeDirectory: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\2.0.9\
    RuntimeInformation.FrameworkDescription:        .NET Core 4.6.26614.01
    BitConverter.IsLittleEndian:    True
    IntPtr.Size:    8
    Vector.IsHardwareAccelerated:   True
    Vector<byte>.Count:     32      # 256bit
    Vector<float>.Count:    8       # 256bit
    Vector<double>.Count:   4       # 256bit
    Vector4.Assembly.CodeBase:      file:///C:/Program Files/dotnet/shared/Microsoft.NETCore.App/2.0.9/System.Numerics.Vectors.dll
    Vector<T>.Assembly.CodeBase:    file:///C:/Program Files/dotnet/shared/Microsoft.NETCore.App/2.0.9/System.Numerics.Vectors.dll
    
    Benchmark:      count=4096, loops=1000000, countMFlops=4096
    SumBase:        6.871948E+10    # msUsed=4937, MFLOPS/s=829.653635811221
    SumVector4:     2.748779E+11    # msUsed=1234, MFLOPS/s=3319.2868719611, scale=4.00081037277147
    SumVectorT:     5.497558E+11    # msUsed=625, MFLOPS/s=6553.6, scale=7.8992
    

    3.3 在 .NET Core 里測試 .NET Standard類庫里的測試程式碼

    官方文檔上,僅 .NET Standard 2.1 才支援這2種向量類型。而.NET Standard 2.0應用最廣泛,該怎麼在.NET Standard 2.0上使用它們?
    在nuget上找了一下,發現 System.Numerics.Vectors 包提供了這2類向量類型,且它支援 .NET Standard 2.0 平台。可以考慮引用該包。

    此時有一個疑問——若引用的是nuget的System.Numerics.Vectors 包,向量類型是否仍會有硬體加速?
    我們將建立一個測試程式,來檢測這一點。

    3.4.1 搭建類庫項目(BenchmarkVectorLib)

    在解決方案里建立新項目「BenchmarkVectorLib」,它是 .NET Standard 2.0 類庫項目。並讓「BenchmarkVectorLib」引用共享項目「BenchmarkVector」。
    隨後建立一個 BenchmarkVectorUtil 類,用於暴露測試函數。程式碼如下。

    using BenchmarkVector;
    using System;
    using System.IO;
    
    namespace BenchmarkVectorLib {
        /// <summary>
        /// Benchmark Vector Util
        /// </summary>
        public static class BenchmarkVectorUtil {
    
            /// <summary>
            /// Output Environment.
            /// </summary>
            /// <param name="tw">Output <see cref="TextWriter"/>.</param>
            /// <param name="indent">The indent.</param>
            public static void OutputEnvironment(TextWriter tw, string indent) {
                BenchmarkVectorDemo.OutputEnvironment(tw, indent);
            }
    
            /// <summary>
            /// Do Benchmark.
            /// </summary>
            /// <param name="tw">Output <see cref="TextWriter"/>.</param>
            /// <param name="indent">The indent.</param>
            public static void Benchmark(TextWriter tw, string indent) {
                BenchmarkVectorDemo.Benchmark(tw, indent);
            }
        }
    }
    

    3.4.2 搭建測試項目(BenchmarkVectorCore20UseLib)

    在解決方案里建立新項目「BenchmarkVectorCore20UseLib」,它是 .NET Core 2.0 控制台程式的項目。並讓「BenchmarkVectorCore20」引用剛才建立的.NET Standard 2.0類庫「BenchmarkVectorLib」。
    隨後我們修改一下 Program 類的程式碼,加上調用測試函數的程式碼。程式碼如下。

    using BenchmarkVectorLib;
    using System;
    using System.IO;
    using System.Numerics;
    
    namespace BenchmarkVectorCore20UseLib {
        class Program {
            static void Main(string[] args) {
                string indent = "";
                TextWriter tw = Console.Out;
                tw.WriteLine("BenchmarkVectorCore20UseLib");
                tw.WriteLine();
                BenchmarkVectorUtil.OutputEnvironment(tw, indent);
                //tw.WriteLine(string.Format("Main-Vector4.Assembly.CodeBase:\t{0}", typeof(Vector4).Assembly.CodeBase));
                tw.WriteLine(indent);
                BenchmarkVectorUtil.Benchmark(tw, indent);
            }
        }
    }
    

    3.4.3 BenchmarkVectorCore20UseLib的測試結果

    在我的電腦(lntel(R) Core(TM) i5-8250U CPU @ 1.60GHz、Windows 10)上運行時,輸出資訊為:

    BenchmarkVectorCore20UseLib
    
    IsRelease:      True
    EnvironmentVariable(PROCESSOR_IDENTIFIER):      Intel64 Family 6 Model 142 Stepping 10, GenuineIntel
    Environment.ProcessorCount:     8
    Environment.Is64BitOperatingSystem:     True
    Environment.Is64BitProcess:     True
    Environment.OSVersion:  Microsoft Windows NT 10.0.19044.0
    Environment.Version:    4.0.30319.42000
    RuntimeEnvironment.GetRuntimeDirectory: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\2.0.9\
    RuntimeInformation.FrameworkDescription:        .NET Core 4.6.26614.01
    BitConverter.IsLittleEndian:    True
    IntPtr.Size:    8
    Vector.IsHardwareAccelerated:   True
    Vector<byte>.Count:     32      # 256bit
    Vector<float>.Count:    8       # 256bit
    Vector<double>.Count:   4       # 256bit
    Vector4.Assembly.CodeBase:      file:///C:/Program Files/dotnet/shared/Microsoft.NETCore.App/2.0.9/System.Numerics.Vectors.dll
    Vector<T>.Assembly.CodeBase:    file:///C:/Program Files/dotnet/shared/Microsoft.NETCore.App/2.0.9/System.Numerics.Vectors.dll
    
    Benchmark:      count=4096, loops=1000000, countMFlops=4096
    SumBase:        6.871948E+10    # msUsed=4906, MFLOPS/s=834.896045658377
    SumVector4:     2.748779E+11    # msUsed=1219, MFLOPS/s=3360.13125512715, scale=4.02461033634126
    SumVectorT:     5.497558E+11    # msUsed=625, MFLOPS/s=6553.6, scale=7.8496
    

    可以發現該程式測得的浮點性能,與BenchmarkVectorCore20的差不多,表示硬體加速生效了。於是可以解答之前的問題了——

    • 若引用的是nuget的System.Numerics.Vectors 包,向量類型仍會有硬體加速。
    • 若在.NET Standard 2.0 類庫里使用了向量類型,那麼 .NET Core引用類庫時,向量類型仍會有硬體加速。

    3.4 在 .NET Framework 里進行測試

    官方文檔上,.NET Framework 4.6 才支援大小固定的向量(如Vector4),且Vector<T>未提到.NET Framework的支援版本。難道 .NET Framework用不了Vector<T> 嗎? .NET Framework 4.5等版本時是否能使用它們?
    在nuget上找了一下,發現 System.Numerics.Vectors 包支援.NET Framework,最早能支援 .NET Framework 4.5。
    而且 System.Numerics.Vectors 包里提供了這2類向量類型。對比官方文檔,此時有這些疑惑——

    • 官方文檔的Vector<T>未提到.NET Framework的支援版本,當 .NET Framework 下使用System.Numerics.Vectors 包時,是否有硬體加速?
    • 官方文檔里說.NET Framework 4.6才支援大小固定的向量(如Vector4),當 .NET Framework 4.5下使用System.Numerics.Vectors 包時,是否有硬體加速?
    • 官方文檔里說.NET Framework 4.6才支援大小固定的向量(如Vector4),當 .NET Framework 4.5下使用System.Numerics.Vectors 包時,Vector4是屬於哪個程式集的?

    下面的測試程式,將回答以上問題。

    3.4.1 搭建4.5的測試項目(BenchmarkVectorFw45)

    在解決方案里建立新項目「BenchmarkVectorFw45」,它是 .NET Framework 4.5 控制台程式的項目。並讓「BenchmarkVectorFw45」引用共享項目「BenchmarkVector」。
    隨後我們修改一下 Program 類的程式碼,加上調用測試函數的程式碼。程式碼如下:

    using BenchmarkVector;
    using System;
    using System.Collections.Generic;
    using System.IO;
    using System.Linq;
    using System.Text;
    using System.Threading.Tasks;
    
    namespace BenchmarkVectorFw45 {
        class Program {
            static void Main(string[] args) {
                string indent = "";
                TextWriter tw = Console.Out;
                tw.WriteLine("BenchmarkVectorFw45");
                tw.WriteLine();
                BenchmarkVectorDemo.OutputEnvironment(tw, indent);
                //tw.WriteLine(string.Format("Main-Vector4.Assembly.CodeBase:\t{0}", typeof(Vector4).Assembly.CodeBase));
                tw.WriteLine(indent);
                BenchmarkVectorDemo.Benchmark(tw, indent);
            }
        }
    }
    

    3.4.2 BenchmarkVectorFw45的測試結果

    在我的電腦(lntel(R) Core(TM) i5-8250U CPU @ 1.60GHz、Windows 10)上運行時,輸出資訊為:

    BenchmarkVectorFw45
    
    IsRelease:      True
    EnvironmentVariable(PROCESSOR_IDENTIFIER):      Intel64 Family 6 Model 142 Stepping 10, GenuineIntel
    Environment.ProcessorCount:     8
    Environment.Is64BitOperatingSystem:     True
    Environment.Is64BitProcess:     True
    Environment.OSVersion:  Microsoft Windows NT 6.2.9200.0
    Environment.Version:    4.0.30319.42000
    RuntimeEnvironment.GetRuntimeDirectory: C:\Windows\Microsoft.NET\Framework64\v4.0.30319\
    BitConverter.IsLittleEndian:    True
    IntPtr.Size:    8
    Vector.IsHardwareAccelerated:   True
    Vector<byte>.Count:     32      # 256bit
    Vector<float>.Count:    8       # 256bit
    Vector<double>.Count:   4       # 256bit
    Vector4.Assembly.CodeBase:      file:///E:/zylSelf/Code/cs/base/BenchmarkVector/BenchmarkVector1/BenchmarkVectorFw45/bin/Release/System.Numerics.Vectors.DLL
    Vector<T>.Assembly.CodeBase:    file:///E:/zylSelf/Code/cs/base/BenchmarkVector/BenchmarkVector1/BenchmarkVectorFw45/bin/Release/System.Numerics.Vectors.DLL
    
    Benchmark:      count=4096, loops=1000000, countMFlops=4096
    SumBase:        6.871948E+10    # msUsed=4922, MFLOPS/s=832.182039821211
    SumVector4:     2.748779E+11    # msUsed=1235, MFLOPS/s=3316.5991902834, scale=3.98542510121457
    SumVectorT:     5.497558E+11    # msUsed=625, MFLOPS/s=6553.6, scale=7.8752
    

    可以發現該程式測得的浮點性能,與BenchmarkVectorCore20的差不多,表示硬體加速生效了。於是可以解答之前的問題了——

    • 官方文檔的Vector<T>未提到.NET Framework的支援版本,當 .NET Framework 下使用System.Numerics.Vectors 包時,仍會有硬體加速。
    • 官方文檔里說.NET Framework 4.6才支援大小固定的向量(如Vector4),當 .NET Framework 4.5下使用System.Numerics.Vectors 包時,仍會有硬體加速。

    這一點貌似有點奇怪——.NET Framework 4.5 標準庫未提供向量類型,靠nuget引用第三方庫使用向量類型,卻也能得到硬體加速。
    其實原因並不複雜,讓向量類型獲得硬體加速,其實是JIT(即時編譯器)的工作。具體來說,是 RyuJIT 讓向量類型獲得了硬體加速的。
    .NET Framework 4.5 標準庫未提供向量類型,僅是編譯無法通過的問題;通過nuget包,可以引入向量類型,解決了編譯問題。隨後.NET Framework 4.5程式運行時,若用了RyuJIT且硬體支援SIMD時,程式便能用上硬體加速。

    3.4.3 搭建4.6.1的測試項目(BenchmarkVectorFw46)

    官方文檔里說.NET Framework 4.6才支援大小固定的向量(如Vector4),我們來測試一下吧。隨後為了便於與 .NET Standard 2.0類庫測試做對比,故選擇了 .NET Framework 4.6.1。為了使項目名簡單,故項目名為「BenchmarkVectorFw46」。
    在解決方案里建立新項目「BenchmarkVectorFw46」,它是 .NET Framework 4.6.1 控制台程式的項目。並讓「BenchmarkVectorFw46」引用共享項目「BenchmarkVector」。
    隨後我們修改一下 Program 類的程式碼,加上調用測試函數的程式碼。程式碼如下:

    using BenchmarkVector;
    using System;
    using System.Collections.Generic;
    using System.IO;
    using System.Linq;
    using System.Numerics;
    using System.Text;
    using System.Threading.Tasks;
    
    namespace BenchmarkVectorFw46 {
        class Program {
            static void Main(string[] args) {
                string indent = "";
                TextWriter tw = Console.Out;
                tw.WriteLine("BenchmarkVectorFw46");
                tw.WriteLine();
                BenchmarkVectorDemo.OutputEnvironment(tw, indent);
                //tw.WriteLine(string.Format("Main-Vector4.Assembly.CodeBase:\t{0}", typeof(Vector4).Assembly.CodeBase));
                tw.WriteLine(indent);
                BenchmarkVectorDemo.Benchmark(tw, indent);
            }
        }
    }
    

    3.4.4 BenchmarkVectorFw46的測試結果

    在我的電腦(lntel(R) Core(TM) i5-8250U CPU @ 1.60GHz、Windows 10)上運行時,輸出資訊為:

    BenchmarkVectorFw46
    
    IsRelease:      True
    EnvironmentVariable(PROCESSOR_IDENTIFIER):      Intel64 Family 6 Model 142 Stepping 10, GenuineIntel
    Environment.ProcessorCount:     8
    Environment.Is64BitOperatingSystem:     True
    Environment.Is64BitProcess:     True
    Environment.OSVersion:  Microsoft Windows NT 6.2.9200.0
    Environment.Version:    4.0.30319.42000
    RuntimeEnvironment.GetRuntimeDirectory: C:\Windows\Microsoft.NET\Framework64\v4.0.30319\
    BitConverter.IsLittleEndian:    True
    IntPtr.Size:    8
    Vector.IsHardwareAccelerated:   True
    Vector<byte>.Count:     32      # 256bit
    Vector<float>.Count:    8       # 256bit
    Vector<double>.Count:   4       # 256bit
    Vector4.Assembly.CodeBase:      file:///C:/WINDOWS/Microsoft.Net/assembly/GAC_MSIL/System.Numerics/v4.0_4.0.0.0__b77a5c561934e089/System.Numerics.dll
    Vector<T>.Assembly.CodeBase:    file:///E:/zylSelf/Code/cs/base/BenchmarkVector/BenchmarkVector1/BenchmarkVectorFw46/bin/Release/System.Numerics.Vectors.DLL
    
    Benchmark:      count=4096, loops=1000000, countMFlops=4096
    SumBase:        6.871948E+10    # msUsed=4922, MFLOPS/s=832.182039821211
    SumVector4:     2.748779E+11    # msUsed=1218, MFLOPS/s=3362.88998357964, scale=4.04105090311987
    SumVectorT:     5.497558E+11    # msUsed=609, MFLOPS/s=6725.77996715928, scale=8.08210180623974
    

    可以發現該程式測得的浮點性能,與BenchmarkVectorCore20、BenchmarkVectorFw45的差不多,表示硬體加速生效了。
    還可發現 Vector4 與 Vector<T> 的程式集不同,Vector4的程式集在系統目錄,而Vector<T>的程式集在程式目錄。
    這表示官方文檔里說的「.NET Framework 4.6才支援大小固定的向量(如Vector4)」,原來是這樣的——.NET Framework 4.6內置支援大小固定的向量(如Vector4),於是它們的程式集在系統目錄;而 Vector<T> 不是內置支援,是引用nuget包,於是程式集在程式目錄。

    查了一下資料,.NET Framework 4.6 宣稱不再使用已使用10年的JIT64,換成 RyuJIT x64。這可能就是 .NET Framework 4.6 官方文檔說支援支援大小固定的向量(如Vector4)的原因。
    而對於 Vector<T>,可能是因為它的最新版設計為「只讀結構體」(readonly struct)、且很多方法依賴 Span。只讀結構體是 C# 7.2、VS2017.4 才支援的功能,比.NET Framework 4.6晚好幾年,那時微軟已宣布不再繼續發展.NET Framework,轉為統一的 .NET了。這可能就是 .NET Framework 里不包含 Vector<T> 的原因。
    但由於 RyuJIT 是支援 Vector<T> 的,於是引用 nuget 包後,就能通過Vector<T>使用硬體加速了。

    3.5 在 .NET Framework 里測試 .NET Standard類庫里的測試程式碼

    現在我們來試試,在 .NET Framework 里測試 .NET Standard類庫里的測試程式碼。
    先前我們建立了 .NET Standard 2.0類庫「BenchmarkVectorLib」,現在可以建立一個.NET Framework 控制台程式引用它,進行測試。
    因 .NET Framework 4.6.1 是支援 .NET Standard 2.0 的最低版本。於是測試程式選擇了 .NET Framework 4.6.1。

    3.5.1 搭建類庫測試項目(BenchmarkVectorFw46UseLib)

    在解決方案里建立新項目「BenchmarkVectorFw46UseLib」,它是 .NET Framework 4.6.1 控制台程式的項目。並讓「BenchmarkVectorFw46UseLib」引用.NET Standard 2.0類庫「BenchmarkVectorLib」。
    隨後我們修改一下 Program 類的程式碼,加上調用測試函數的程式碼。程式碼如下:

    using BenchmarkVectorLib;
    using System;
    using System.Collections.Generic;
    using System.IO;
    using System.Linq;
    using System.Text;
    using System.Threading.Tasks;
    
    namespace BenchmarkVectorFw46UseLib {
        class Program {
            static void Main(string[] args) {
                string indent = "";
                TextWriter tw = Console.Out;
                tw.WriteLine("BenchmarkVectorFw46UseLib");
                tw.WriteLine();
                BenchmarkVectorUtil.OutputEnvironment(tw, indent);
                tw.WriteLine(indent);
                BenchmarkVectorUtil.Benchmark(tw, indent);
            }
        }
    }
    

    3.5.2 的測試結果

    在我的電腦(lntel(R) Core(TM) i5-8250U CPU @ 1.60GHz、Windows 10)上運行時,輸出資訊為:

    BenchmarkVectorFw46UseLib
    
    IsRelease:      True
    EnvironmentVariable(PROCESSOR_IDENTIFIER):      Intel64 Family 6 Model 142 Stepping 10, GenuineIntel
    Environment.ProcessorCount:     8
    Environment.Is64BitOperatingSystem:     True
    Environment.Is64BitProcess:     True
    Environment.OSVersion:  Microsoft Windows NT 6.2.9200.0
    Environment.Version:    4.0.30319.42000
    RuntimeEnvironment.GetRuntimeDirectory: C:\Windows\Microsoft.NET\Framework64\v4.0.30319\
    RuntimeInformation.FrameworkDescription:        .NET Framework 4.8.4515.0
    BitConverter.IsLittleEndian:    True
    IntPtr.Size:    8
    Vector.IsHardwareAccelerated:   True
    Vector<byte>.Count:     32      # 256bit
    Vector<float>.Count:    8       # 256bit
    Vector<double>.Count:   4       # 256bit
    Vector4.Assembly.CodeBase:      file:///C:/WINDOWS/Microsoft.Net/assembly/GAC_MSIL/System.Numerics/v4.0_4.0.0.0__b77a5c561934e089/System.Numerics.dll
    Vector<T>.Assembly.CodeBase:    file:///E:/zylSelf/Code/cs/base/BenchmarkVector/BenchmarkVector1/BenchmarkVectorFw46UseLib/bin/Release/System.Numerics.Vectors.DLL
    
    Benchmark:      count=4096, loops=1000000, countMFlops=4096
    SumBase:        6.871948E+10    # msUsed=4922, MFLOPS/s=832.182039821211
    SumVector4:     2.748779E+11    # msUsed=1234, MFLOPS/s=3319.2868719611, scale=3.98865478119935
    SumVectorT:     5.497558E+11    # msUsed=625, MFLOPS/s=6553.6, scale=7.8752
    

    可以發現該程式測得的浮點性能,與BenchmarkVectorFw46的差不多,表示硬體加速生效了。

    四、測試數據分析

    4.1 測試數據

    測試環境統一是 lntel(R) Core(TM) i5-8250U CPU @ 1.60GHz、Windows 10。CPU硬體支援AVX2指令集,Vector<T> 理論上能同時處理4個 float。
    用上面的測試程式,補上一些 Debug/Release、x86/x64 情況時的測試數據,再加上 .NET Core 3.0的測試數據(下一篇文章會詳細說明),可匯總為一個表。為了便於閱讀,省略了MFLOPS的小數,且scale保留3位小數,表格如下:

    程式及配置 加速 SumBase SumVector4 SumVectorT
    BenchmarkVectorCore20, Debug, x64 true 368 946, scale=2.570 1659, scale=4.506
    BenchmarkVectorCore20, Release, x64 true 829 3319, scale=4.001 6554, scale=7.899
    BenchmarkVectorCore20UseLib, Release, x64 true 834 3360, scale=4.025 6554, scale=7.850
    BenchmarkVectorFw45, Release, x64 true 832 3316, scale=3.985 6554, scale=7.875
    BenchmarkVectorFw45, Release, x86 false 1111 883, scale=0.795 213, scale=0.192
    BenchmarkVectorFw46, Release, x86 false 1101 880, scale=0.799 214, scale=0.194
    BenchmarkVectorFw46, Release, x64 true 832 3363, scale=4.041 6726, scale=8.082
    BenchmarkVectorFw46UseLib, Release, x64 true 832 3319, scale=3.989 6554, scale=7.875
    BenchmarkVectorFw46UseLib, Release, x86 false 1111 883, scale=0.794 165, scale=0.149
    BenchmarkVectorCore30, Debug, x86 true 368 822, scale=2.231 1560, scale=4.238
    BenchmarkVectorCore30, Debug, x64 true 370 946, scale=2.559 1659, scale=4.487
    BenchmarkVectorCore30, Release, x86 true 835 1481, scale=1.774 2648, scale=3.171
    BenchmarkVectorCore30, Release, x64 true 829 3363, scale=4.054 6726, scale=8.108

    註:「加速」指硬體加速。

    從該表中可以看出——

    • 以Debug方式編譯時,有時也能獲得硬體加速,只是速度要慢一些,且提速比(scale)比理論值要低一些。例如 SumVector4 只能達到2.5倍左右,SumVectorT只能達到4倍左右。
    • 當不支援硬體加速時,使用向量類型的演算法仍能運行,只是性能比不上基礎演算法。例如 SumVector4 只有基礎演算法的0.8倍左右,有少量衰減;而SumVectorT衰減的很厲害,不足基礎演算法的0.2倍,既不足1/5。故在使用 Vector<T> 前,一定要檢查是否支援硬體加速(Vector.IsHardwareAccelerated).
    • .NET Framework 在x86(32位)下不支援硬體加速,應該是因為它的x86 JIT用的還是舊版,並不是 RyuJIT。這個x86 JIT雖然不支援向量類型的硬體加速,但它經過多年改進,對基礎演算法的優化很好,能達到 「1111 MFLOPS/s」,比起 RyuJIT x64下測得的最高值「834 MFLOPS/s」,性能要高一些。
    • 到了 .NET Core 3.0 時代,RyuJIT支援了 x86(32位),所以 .NET Core 3.0 的 x86程式也能使用硬體加速。只是目前優化的還不夠好,沒達到理論值。
    • Release, x64 方式編譯的程式,均能使用硬體加速,且與理論值接近。SumVector4的性能約是基礎演算法的4倍,SumVectorT的性能約是基礎演算法的8倍。
    • 在.NET Standard 2.0類庫里運行的向量類型操作程式碼,與控制台程式里直接運行的向量類型操作程式碼,性能差不多。於是可以放心大膽的在.NET Standard 2.0類庫里使用向量類型。

    4.2 最佳實踐

    最核心的使用經驗就2條——

    1. 若僅需要使用單精度浮點類型(float),且是開發數學上的向量運算相關的功能,可根據業務上對向量運算的要求,使用維度匹配的向量類(例如 2維向量處理時用Vector2、3維向量處理時用Vector3、3維齊次向量處理時用Vector4)。其他情況下,至少應編寫一套傳統的、不使用向量類型的程式碼。
    2. 若某項計算任務需要進一步做性能優化、且它的工作比較適合SIMD處理時,可以再開發一套基於 Vector<T> 的向量程式碼。在使用時別忘了檢查是否支援硬體加速,若不支援,應退回到使用傳統程式碼。

    採用以上策略,對於一項計算任務,最多只需開發2套程式碼(數學向量/傳統、Vector<T>)就行。

    編譯選項里的CPU平台,選「Any CPU」就行了。因為向量類型的硬體加速是由JIT處理的。當編譯好的程式在 x86、x64等平台下運行時,JIT會使用該平台的向量硬體加速。
    當然,若業務需要,也可以固定選擇 x86、x64等平台。

    4.3 源碼地址

    源碼地址——
    //github.com/zyl910/BenchmarkVector/tree/main/BenchmarkVector1

    參考文獻