在 .NET 中使用 SIMD 和硬體內建元件

SIMD(單指令,多資料)是一種硬體支援,用於對多個資料並行執行一項操作,並用一條指令。 向量化程式碼每次迭代處理多個值,而非一個值,這能大幅提升數值、科學、圖形、文字處理及資料平行處理等數值運算在緩衝區重複的吞吐量。 代價是增加了複雜性,因此只有在輸入規模夠大,且經由實測確認確實能帶來效益時,才最值得採用。

.NET 提供多種 SIMD 支援。 選擇符合你需要多少控制權和願意承擔複雜度的方案。

.NET 中 SIMD 支援的類型

API 命名空間 何時使用它
固定用途向量與矩陣類型 System.Numerics 圖形與幾何數學,包含2-4元素向量、矩陣、四元數和平面。
Vector<T> System.Numerics 不需要針對各平台分別控制時,可使用可攜式的可變寬度向量化。
Vector64<T>Vector128<T>Vector256<T>Vector512<T> System.Runtime.Intrinsics 跨平台、固定寬度向量化與細粒度控制。 這是新向量化演算法的建議起點。
硬體內在元件 System.Runtime.Intrinsics.X86System.Runtime.Intrinsics.ArmSystem.Runtime.Intrinsics.Wasm 高階 API 未提供的特定處理器指令,用於在熱路徑上榨出最後一點效能。
TensorPrimitives System.Numerics.Tensors 現成的向量化跨跨數學運算。 它會幫你做向量化。

這些 API 重疊的地方,透過層層抽象相互關聯。 通用向量型別是其他層之間傳遞的基礎交換型別,因此在技術上屬於最低層:可變寬度的 Vector<T>,其寬度會擴展到執行中硬體所支援的寬度,以及固定寬度的 Vector64<T>Vector512<T>。 在 System.Runtime.Intrinsics.X86System.Runtime.Intrinsics.ArmSystem.Runtime.Intrinsics.Wasm 中的平台特定硬體內建函式會對這些型別進行運算,而且每個內建函式都直接對應到單一處理器指令。 通用型的跨平台操作高於平台專屬的內在操作,並針對每個目標降低至此。 更高一層的是可對整個緩衝區進行操作的受控 API——也就是 Span<T>stringTensorPrimitives 上的向量化方法——這些方法建立在下方的層級之上,因此你無須手動撰寫任何這類程式碼,就能獲得 SIMD 加速。 System.Numerics固定形狀類型是針對圖形和幾何圖形的領域專用便利類型,而非此交換堆疊的一部分。

本文其餘部分將從最高層級到最低層級介紹這些 API,接著涵蓋測試、基準測試與最佳實務。

系統。數值向量與矩陣類型

System.Numerics 命名空間提供經 SIMD 加速且具有固定形狀的型別:

這些類型自然對應到圖形與幾何,執行時會透過硬體支援的 SIMD 指令加速其操作。 以下範例加入兩個向量:

Vector2 v1 = Vector2.Create(0.1f, 0.2f);
Vector2 v2 = Vector2.Create(1.1f, 2.2f);
Vector2 sum = v1 + v2;

它們也提供了你預期會有的常見向量運算,例如點積、距離和限制:

float dot = Vector2.Dot(v1, v2);
float distance = Vector2.Distance(v1, v2);
Vector2 clamped = Vector2.Clamp(v1, Vector2.Zero, Vector2.One);

矩陣類型支援矩陣數學運算,如轉置與乘法:

Matrix4x4 m1 = Matrix4x4.Create(
    1.1f, 1.2f, 1.3f, 1.4f,
    2.1f, 2.2f, 3.3f, 4.4f,
    3.1f, 3.2f, 3.3f, 3.4f,
    4.1f, 4.2f, 4.3f, 4.4f);

Matrix4x4 m2 = Matrix4x4.Transpose(m1);
Matrix4x4 product = Matrix4x4.Multiply(m1, m2);

向量<T>

Vector<T> 表示一個原始數值型態的可變寬度向量。 其長度在程序的生命週期內固定,但值 Vector<T>.Count 取決於執行程式碼的 CPU。 Just-In-Time(JIT)編譯器會 Count 把它當作常數處理,因此針對它寫的迴圈優化效果很好。

Vector<T> 提供可攜式的向量化,無需每個平台的程式碼,但代價是編譯時無法知道向量寬度。 以下範例計算兩個陣列的元素加法:

// Illustrative: element-wise add with Vector<T>. In practice, prefer the already-accelerated
// TensorPrimitives.Add, which is optimized for every Vector<T>.IsSupported element type.
public static double[] Add(double[] left, double[] right)
{
    ArgumentNullException.ThrowIfNull(left);
    ArgumentNullException.ThrowIfNull(right);
    ArgumentOutOfRangeException.ThrowIfNotEqual(right.Length, left.Length);

    double[] result = new double[left.Length];

    int i = 0;

    // Vector<T>.Count is a JIT-time constant, so the compiler optimizes the loop bound.
    int lastVectorStart = left.Length - Vector<double>.Count;

    for (; i <= lastVectorStart; i += Vector<double>.Count)
    {
        Vector<double> v1 = Vector.Create(left.AsSpan(i));
        Vector<double> v2 = Vector.Create(right.AsSpan(i));
        (v1 + v2).CopyTo(result, i);
    }

    // Process any remaining elements that don't fill a full vector.
    // Simplified for illustration: a scalar tail isn't optimal. A vectorized
    // remainder that reprocesses the last full vector avoids the per-element loop.
    for (; i < left.Length; i++)
    {
        result[i] = left[i] + right[i];
    }

    return result;
}

備註

這個例子具有說明性。 你很少需要手寫這樣的迴圈,因為 TensorPrimitives 已經提供了加速的基於跨度的數學運算。 此逐元素加法為 Add,也提供像是 Sum 的歸約。 這些作業已針對 Vector<T> 支援的元素類型提供硬體加速(Vector<T>.IsSupported)。

檢查硬體加速

SIMD 加速型態即使在不支援 SIMD 的硬體或 JIT 配置上也能運作,因為它們會回退到非加速軟體實作。 若要判斷是否確實可使用加速功能,請檢查相關的 IsHardwareAccelerated 屬性:

這些屬性會被 JIT 轉換成常數,所以你沒拿到的分支會被消除,檢查它們也不會有執行時間成本。 不要將這些值快取起來;在需要時直接讀取。 同樣的情況也適用於 Count 屬性(例如 Vector128<T>.Count),它們也是 JIT 時間常數。

對加速位寬執行的大多數運算本身也會加速,但這並不保證適用於每一種運算。 例如,浮點除法可能被加速,而整數除法則不會。 加速 Vector256 時通常 Vector128 也會加速,但沒有保證,所以請檢查你使用的每個寬度。

Tip

如果你需要的作業在你所關注的平台上尚未獲得加速支援,或是你希望有新的跨平台 API,請在 dotnet/runtime 回報問題。 這也適用於程式碼生成的改進。

並非每個元素類型都適用於每個向量。 Vector128<T>其兄弟組目前支援原始數值型別(bytesbyteshortushortintuintlongulongfloatdoublenintnuint),而該集合未來可能會擴展到包含其他類型。 用 Vector128<T>.IsSupported 來判斷給定 T 是否有效,這在通用程式碼中特別有用。

不支援的類型,如 charbool,仍可透過重新詮釋緩衝區為相同大小的支援型態來向量化。 使用 Cast 來重新解讀一個區段——例如從 char 轉為 ushort——或者使用向量的 As<TFrom, TTo> 方法來重新解讀你已持有的向量。 重新詮釋只會改變類型,不會改變底層位元,因此確保資料維持良好格式是你的責任:bool 必須維持為 01,而 char 則必須仍是有效的 UTF-16 碼單元。 如果向量化操作會產生超出範圍的值,寫回前務必對結果進行正規化。

與 Vector128 的跨平台向量化

Vector128<T> 是所有支援向量化的平台的共通點,因此它是最好的起點。 它包含一個 128 位元的向量:16 個位元組、8 個短整數、4 個整數/浮點數,或 2 個長整數/雙精度浮點數。

------------------------------128-bits---------------------------
|             64                |               64              |
-----------------------------------------------------------------
|      32       |      32       |      32       |      32       |
-----------------------------------------------------------------
|  16   |  16   |  16   |  16   |  16   |  16   |  16   |  16   |
-----------------------------------------------------------------
| 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 |
-----------------------------------------------------------------

Vector256<T> 的寬度是兩倍,而 Vector512<T> 又是前者的兩倍。 並非所有硬體都支援較寬的位元寬度,因此以下範例為了可攜性而使用 Vector128

每個寬度都有一個用來表示資料的泛型型別(Vector128<T>),以及一個包含大多數作業的非泛型靜態類別(Vector128),其中包括 CreateLoad 等靜態工廠方法。 像 +&<< 這類運算子是表達算術與位元運算的成語方式;優先使用它們而非命名的方法,以避免運算子優先錯誤並提升可讀性。 對於依賴位元組順序的演算法,可根據 IsLittleEndian 進行分支,而 JIT 也會將它摺疊成常數。

備註

在 x86/x64 上, Vector256<T> 操作通常被視為兩個獨立的 128 位元「通道」。 對大多數逐元素操作而言,這通常不會有差別,但跨通道的操作(例如重排或成對/水平操作)其行為可能不同,或成本可能高於等效的 Vector128 操作。 在假設寬向量越快之前,請先先用基準測試確認。

車道交叉作業不會免費拓寬

元素層次操作不在乎寬度:v1 + v2無論是 v1v2Vector128<T>Vector256<T>,每個元素的結果都是一樣的——寬度只是每條指令多處理一條通道的資料。 Add 在 和 v = [a, b, c, d]w = [e, f, g, h] 上總是結合相同指標的元素:

v: [ a | b | c | d ]
w: [ e | f | g | h ]
     +   +   +   +
r: [a+e|b+f|c+g|d+h]

跨線操作並不能如此簡單地擴展,因為哪些元素會被組合取決於向量的寬度。 成對歸約會結合 相鄰 的元素,而不是相同索引的元素,因此,擴寬會改變最後哪些元素會被配成一對:

v:       [  a  |  b  |  c  |  d  ]
            \_____/     \_____/
round 1: [ a+b | c+d | a+b | c+d ]
            \_________________/
round 2: [  S  |  S  |  S  |  S  ]   (S = a+b+c+d)

這正是水平約簡的原理:用兩輪成對加法將向量的元素相加。 在 x86/x64 上,Vector128<float>(4 個元素)只要呼叫 HorizontalAdd 兩次即可達成:

// Sums all four elements with two rounds of pairwise horizontal adds.
// HorizontalAdd(v, v) on [a, b, c, d] gives [a+b, c+d, a+b, c+d]; a second round
// collapses that to the full sum in every element.
public static float SumVector128(Vector128<float> v)
{
    Debug.Assert(Sse3.IsSupported);

    Vector128<float> step1 = Sse3.HorizontalAdd(v, v);
    Vector128<float> step2 = Sse3.HorizontalAdd(step1, step1);

    return step2.ToScalar();
}

將相同的兩次呼叫模式套用到 Vector256<float>(8 個元素)上,看起來是對的——其實不然。 HorizontalAdd 並未在整個 256 位元向量中運作;它會在每個 128 位元通道內獨立重複這種成對模式。 經過兩輪後,你會得到低半部的總和(元素 0–3)廣播到整個低半部,以及高半部的總和(元素 4–7)廣播到整個高半部,而不是全部八個元素的總和:

// The same two-round pattern on Vector256<float> looks like it should sum all eight
// elements, but Avx.HorizontalAdd repeats the pairwise pattern independently within
// each 128-bit lane. The result holds the lower lane's sum (elements 0-3) broadcast
// across the lower lane and the upper lane's sum (elements 4-7) broadcast across the
// upper lane -- ToScalar only returns the lower lane's partial sum, not the total.
public static float SumVector256Naive(Vector256<float> v)
{
    Debug.Assert(Avx.IsSupported);

    Vector256<float> step1 = Avx.HorizontalAdd(v, v);
    Vector256<float> step2 = Avx.HorizontalAdd(step1, step1);

    return step2.ToScalar();
}

要得到正確的總和,請明確跨越各 lane 的邊界:使用 GetLower/GetUpper 讀取每個 lane 的部分總和,並將它們加總——GetLowerGetUpper 會將向量分成前半部和後半部:

------------------------------128-bits---------------------------
|           LOWER               |             UPPER             |
-----------------------------------------------------------------
|      32       |      32       |      32       |      32       |
-----------------------------------------------------------------
|  16   |  16   |  16   |  16   |  16   |  16   |  16   |  16   |
-----------------------------------------------------------------
| 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 |
-----------------------------------------------------------------
// Getting the full sum needs an explicit step to cross the lane boundary: read each
// lane's partial sum out with GetLower/GetUpper and add them together.
public static float SumVector256(Vector256<float> v)
{
    Debug.Assert(Avx.IsSupported);

    Vector256<float> step1 = Avx.HorizontalAdd(v, v);
    Vector256<float> step2 = Avx.HorizontalAdd(step1, step1);

    Vector128<float> lower = step2.GetLower();
    Vector128<float> upper = step2.GetUpper();

    return lower.ToScalar() + upper.ToScalar();
}

多出的這一步,才是跨越通道真正的代價。 跨線演算法不會像元素層面那樣無條件地擴大——先測量後再假設較寬的向量會勝出。

一般作業

Vector128 而其更廣泛的兄弟姊妹則暴露出龐大的 API 表面積。 你不需要死記硬背——知道分類,需要時查細節就行。 每項操作都有對應的軟體後備方案,可用於無法加速該操作的平台。 下表基本上涵蓋了整個表面。

類別 其功能是什麼 代表性 API
Constants 預定義的常數向量 ZeroOneNegativeOneAllBitsSetIndicesSignSequenceEPiTauEpsilonNaNPositiveInfinityNegativeInfinityNegativeZero
建立作業 廣播標量、集合元素,或產生序列 CreateCreateScalarCreateScalarUnsafeCreate(ReadOnlySpan<T>)CreateSequenceCreateGeometricSequenceCreateHarmonicSequenceCreateAlternatingSequence
裝載與儲存 在記憶體與向量之間移動資料 LoadLoadUnsafeLoadAlignedLoadAlignedNonTemporalStoreStoreUnsafeStoreAlignedStoreAlignedNonTemporalCopyToTryCopyTo
Arithmetic 按元素的數學與約化 Add (x + y)Subtract (x - y)Multiply (x * y)Divide (x / y)Negate (-x)AddSaturateSubtractSaturateAbsSqrtFusedMultiplyAddDotSum
位元運算 位元邏輯與移位 BitwiseAnd (x & y)BitwiseOr (x \| y)Xor (x ^ y)AndNot (x & ~y)OnesComplement (~x)ShiftLeft (x << n)ShiftRightArithmetic (x >> n)ShiftRightLogical (x >>> n)
最小、最大與夾具 逐元素最小值、最大值與範圍限制 MinMaxClampMinMagnitudeMaxMagnitudeMinNumberMaxNumberMinMagnitudeNumberMaxMagnitudeNumber
四捨五入 將每個元素四捨五入為整數值 CeilingFloorRoundTruncate
數學函數 符號、插值、角度與超越輔助 CopySignLerpDegreesToRadiansRadiansToDegreesHypotSinCosSinCosAsinExpLogLog2
Comparison 比較每個元素;結果是向量遮罩,而不是 bool運算子所產生的結果 Equals、、 GreaterThanGreaterThanOrEqualLessThanLessThanOrEqual
Classification 作用於數線上的逐元素謂詞,各自回傳一個向量遮罩 IsNaNIsFiniteIsInfinityIsPositiveInfinityIsNegativeInfinityIsIntegerIsEvenIntegerIsOddIntegerIsNegativeIsPositiveIsNormalIsSubnormalIsZero
比較歸約 將逐元素比較簡化為單一 bool EqualsAll (x == y)EqualsAnyGreaterThanAllGreaterThanAnyGreaterThanOrEqualAllGreaterThanOrEqualAnyLessThanAllLessThanAnyLessThanOrEqualAllLessThanOrEqualAny
全向量謂詞 將向量歸約為一個 bool:表示是否所有、任一或沒有元素等於某個值,或(WhereAllBitsSet 形式)是否所有位元皆已設為 1。 我比較喜歡這些,而不是把遮罩轉換成索引 AllAnyNoneAllWhereAllBitsSetAnyWhereAllBitsSetNoneWhereAllBitsSet
搜尋 依值計數元素或找出元素位置,或者(WhereAllBitsSet 形式)在遮罩中設定各位置 CountIndexOfLastIndexOfCountWhereAllBitsSetIndexOfWhereAllBitsSetLastIndexOfWhereAllBitsSet
遮罩轉索引 將比較遮罩轉換為純量位元遮罩並加以掃描 ExtractMostSignificantBits 搭配 TrailingZeroCountLeadingZeroCount
Selection 根據遮罩一點點混合兩個向量 ConditionalSelect(x, y, z),等價於 (y & x) \| (z & ~x)
Conversion 改變數值類型,計算新值(例如, intfloat ConvertToInt32ConvertToInt64ConvertToUInt32ConvertToUInt64ConvertToSingleConvertToDouble
擴寬與縮小 將元素拆分成較寬的類型,或壓縮成較窄的類型 Widen、、 WidenLowerWidenUpperNarrowNarrowWithSaturation
重新詮釋 將位元重新詮釋成另一種元素類型,但不改變它們 As<TFrom, TTo>、、AsByteAsInt32AsSingle,以及其他As*元素形式
System.數值互通 Vector128<T> 與固定形狀的數值型別之間重新詮釋 AsVectorAsVector2AsVector3AsVector4AsPlaneAsQuaternionAsVector128AsVector128Unsafe
Reorder 依索引重新排列元素,或交錯排列兩個向量 ShuffleReverseZipZipLowerZipUpperUnzipUnzipEvenUnzipOddConcatLowerLowerConcatLowerUpperConcatUpperLowerConcatUpperUpper
車道進出 讀取或替換個別元素與半部,或調整向量大小 GetElementWithElementToScalarGetLowerGetUpperWithLowerWithUpperToVector256

Tip

有幾種運算具有 EstimateNative 的變體——例如,MultiplyAddEstimateClampNativeMinNativeMaxNativeShuffleNativeConvertToInt32Native。 它們會對映到速度更快的硬體指令,但會以犧牲部分精度或放棄 IEEE 對邊界情況的保證(例如 NaN 處理)為代價,因此只有在基準測試顯示精確形式確實是瓶頸,且較寬鬆的語意也可接受時,才應使用它們。

備註

Vector256.Shuffle 其輸入視為單一 256 位元向量,而平台專用 Avx2.Shuffle 則作為兩條獨立的 128 位元通道運作。 跨平台 API 是較具可攜性的選擇,但請確認你在移植手寫內在文件時所需的行為。

結構化程式碼路徑

向量化方法通常會依不同的向量寬度分成各自的執行路徑,並另外提供純量後備路徑,以因應小型輸入和未加速的硬體。 要使用硬體支援的最大向量,請先檢查最寬的向量,然後向下調整:

// Sums a buffer, choosing the widest vector the hardware and element type support.
public static T Sum<T>(ReadOnlySpan<T> buffer)
    where T : unmanaged, INumberBase<T>
{
    // The widest-first order continues with the Vector512 and Vector256 paths, which belong
    // here ahead of the Vector128 block below. They're identical to it aside from the wider
    // type (for example, Vector512<T> with Vector512.Create and Vector512.Sum), so they're
    // omitted for brevity:
    //
    // if (Vector512.IsHardwareAccelerated && Vector512<T>.IsSupported)
    // {
    //     if (buffer.Length >= Vector512<T>.Count)
    //     {
    //         return SumVector512(buffer);
    //     }
    //     return SumVectorSmall(buffer);
    // }
    //
    // if (Vector256.IsHardwareAccelerated && Vector256<T>.IsSupported)
    // {
    //     if (buffer.Length >= Vector256<T>.Count)
    //     {
    //         return SumVector256(buffer);
    //     }
    //     return SumVectorSmall(buffer);
    // }

    if (Vector128.IsHardwareAccelerated && Vector128<T>.IsSupported)
    {
        if (buffer.Length >= Vector128<T>.Count)
        {
            return SumVector128(buffer);
        }
        return SumVectorSmall(buffer);
    }

    return SumScalar(buffer);
}

每個寬度的外層防護條件會將 Vector128.IsHardwareAccelerated(用於判斷平台是否對該寬度提供加速的 JIT 時期常數)與 Vector128<T>.IsSupported(元素類型 T 是否適用於該寬度)結合。 在受支援的區塊中,將輸入長度與 Count 比較,以在向量化路徑與小輸入回退機制之間進行選擇。 此方法是以 T 為泛型,而 Vector256Vector512 區塊——與 Vector128 區塊相同,但使用較寬的型別——則為求簡潔而以註解方式標示。

有兩種明顯的後備方案。 即使對最窄的向量來說,緩衝區也太小,但在加速硬體上,則會改用 SumVectorSmall——一個明確列出的 switch 跳躍表,可在不使用迴圈的情況下處理每一種可能的子向量長度:

// Sums a buffer smaller than the widest vector. The complete "optimal" shape dispatches on the
// element width so each width uses a switch jump table sized to the number of elements that fit
// in the widest vector (63 for byte, 31 for short, 15 for int/float, 7 for long/double).
private static T SumVectorSmall<T>(ReadOnlySpan<T> buffer)
    where T : unmanaged, INumberBase<T>
{
    // sizeof(T) is a JIT constant, so only the matching branch survives for a given T.
    if (sizeof(T) == 4)
    {
        return SumVectorSmall4(buffer);
    }

    // The 1-, 2-, and 8-byte tables share the shape below, sized for their element width.
    // They're omitted for brevity, so those widths fall back to a scalar loop here:
    //
    // if (sizeof(T) == 1) return SumVectorSmall1(buffer); // switch over lengths 0..63
    // if (sizeof(T) == 2) return SumVectorSmall2(buffer); // switch over lengths 0..31
    // if (sizeof(T) == 8) return SumVectorSmall8(buffer); // switch over lengths 0..7
    return SumScalar(buffer);
}

private static T SumVectorSmall4<T>(ReadOnlySpan<T> buffer)
    where T : unmanaged, INumberBase<T>
{
    Debug.Assert(sizeof(T) == 4);
    Debug.Assert(buffer.Length < Vector512<T>.Count);

    T result = T.Zero;

    // A 4-byte element gives Count == 4/8/16 for Vector128/256/512, so a remainder can be up to
    // 15 elements. The larger cases fold the leftover with the widest vector that fits, using two
    // overlapping loads (one from the start, one from the end) rather than recursing—the shape
    // TensorPrimitives uses. The loads overlap for lengths that aren't an exact multiple of the
    // width, so the tail is masked down to the additive identity before it's summed. That mask is
    // only needed because addition is non-idempotent; an idempotent operation such as a search
    // could fold the overlapping tail in directly.
    switch (buffer.Length)
    {
        // One or two Vector256's worth of data.
        case 15:
        case 14:
        case 13:
        case 12:
        case 11:
        case 10:
        case 9:
        case 8:
        {
            Vector256<T> beg = Vector256.Create(buffer);
            Vector256<T> end = Vector256.Create(buffer.Slice(buffer.Length - Vector256<T>.Count));

            Vector256<T> msk = CreateRemainderMask256<T>(buffer.Length - Vector256<T>.Count);
            end = Vector256.ConditionalSelect(msk, end, Vector256<T>.Zero);

            result = Vector256.Sum(beg + end);
            break;
        }

        // One or two Vector128's worth of data.
        case 7:
        case 6:
        case 5:
        case 4:
        {
            Vector128<T> beg = Vector128.Create(buffer);
            Vector128<T> end = Vector128.Create(buffer.Slice(buffer.Length - Vector128<T>.Count));

            Vector128<T> msk = CreateRemainderMask128<T>(buffer.Length - Vector128<T>.Count);
            end = Vector128.ConditionalSelect(msk, end, Vector128<T>.Zero);

            result = Vector128.Sum(beg + end);
            break;
        }

        // Smaller than a single vector: each case falls through to the next, accumulating one
        // element per label.
        case 3:
        {
            result += buffer[2];
            goto case 2;
        }

        case 2:
        {
            result += buffer[1];
            goto case 1;
        }

        case 1:
        {
            result += buffer[0];
            goto case 0;
        }

        case 0:
        {
            break;
        }
    }

    return result;
}

// Builds a mask whose last `keepLast` lanes are all-bits-set and the rest zero, so an overlapping
// tail load can be folded in without double-counting the lanes the head already covered.
// TensorPrimitives uses an internal table-based helper. The mask is only a bit pattern keyed on
// lane width, so it's built with the same-width integer Indices ([0, 1, 2, ...]) and reinterpreted
// to T: integer comparisons are cheaper than floating-point ones, so a float/double table would
// still compare as int/long rather than in its own element type.
private static Vector256<T> CreateRemainderMask256<T>(int keepLast)
    where T : unmanaged, INumberBase<T>
{
    Debug.Assert(sizeof(T) == 4);

    Vector256<int> firstKept = Vector256.Create(Vector256<int>.Count - keepLast);
    return Vector256.GreaterThanOrEqual(Vector256<int>.Indices, firstKept).As<int, T>();
}

private static Vector128<T> CreateRemainderMask128<T>(int keepLast)
    where T : unmanaged, INumberBase<T>
{
    Debug.Assert(sizeof(T) == 4);

    Vector128<int> firstKept = Vector128.Create(Vector128<int>.Count - keepLast);
    return Vector128.GreaterThanOrEqual(Vector128<int>.Indices, firstKept).As<int, T>();
}

sizeof(T) 也是 JIT 編譯時常數,因此 SumVectorSmall 會根據元素寬度分派到一個大小以最寬向量可容納的元素數量為準的表格——這與 TensorPrimitives 採用的方法相同。 (當啟用新的記憶體安全模型時, sizeof(T) 安全程式碼允許對帶有 unmanaged 約束的型別參數進行表達式。)僅顯示 4 位元組的表格;1 位元組、2 位元組和 8 位元組的表格形狀相同。 較大的情況會用 Vector256Vector128,搭配兩次彼此重疊的載入操作——一次從開頭,一次從尾端——來摺疊剩餘部分,因此,省略的 Vector512/Vector256 路徑所需的較寬剩餘部分處理邏輯就直接放在跳躍表裡。 當長度不是寬度的整數倍時,兩次載入會發生重疊,因此在加總之前,尾端會先以 ConditionalSelect 遮罩為加法單位元。 這個遮罩之所以需要,是因為加法不具冪等性;而像搜尋這樣的冪等運算,則可以直接將重疊的尾部折疊進來。 在完全不支援向量化的硬體上,緩衝區會退回到 SumScalar,也就是一個一般的純量迴圈。

對輸入進行迴圈,處理剩餘部分

要處理比單一向量更大的緩衝區,先逐個向量循環,然後處理無法填滿完整向量的剩餘元素。 穩健處理尾端資料的方法,是重新處理最後一個完整向量所涵蓋的元素,讓其中部分元素與迴圈先前已處理的內容重疊,從而避免另外撰寫純量收尾程式碼。 是否需要修正這種重疊,取決於操作本身。

非冪等運算(例如加總)會將重疊元素計算兩次,因此在將其納入折疊之前,先將這些元素遮蔽為該運算的單位元。 當每個元素必須恰好貢獻一次時,請使用此方法:

// Sums a buffer with an unrolled vector loop plus a masked, jump-table remainder.
private static T SumVector128<T>(ReadOnlySpan<T> buffer)
    where T : unmanaged, INumberBase<T>
{
    Debug.Assert(Vector128.IsHardwareAccelerated && Vector128<T>.IsSupported);
    Debug.Assert(buffer.Length >= Vector128<T>.Count);

    // Preload the last full vector, overlapping the tail. Any sub-vector remainder is folded in
    // from here (masked) by case 0 of the switch below, so the loop never falls out to a separate
    // scalar tail—the same shape TensorPrimitives uses.
    Vector128<T> end = Vector128.Create(buffer.Slice(buffer.Length - Vector128<T>.Count));

    // A production implementation would also align the buffer to a vector boundary and, for
    // very large inputs, use non-temporal loads/stores so the data doesn't evict useful
    // cache lines. Both are omitted here; see TensorPrimitives for a complete treatment.

    Vector128<T> sum = Vector128<T>.Zero;

    // Only pay for the four independent accumulators when there's enough data to unroll;
    // smaller payloads skip straight to the remainder below. Four vectors per iteration lets
    // the accumulators pipeline; Vector128.Create reads the first Vector128<T>.Count elements.
    if (buffer.Length >= Vector128<T>.Count * 4)
    {
        Vector128<T> sum0 = Vector128<T>.Zero;
        Vector128<T> sum1 = Vector128<T>.Zero;
        Vector128<T> sum2 = Vector128<T>.Zero;
        Vector128<T> sum3 = Vector128<T>.Zero;

        do
        {
            sum0 += Vector128.Create(buffer);
            sum1 += Vector128.Create(buffer.Slice(Vector128<T>.Count));
            sum2 += Vector128.Create(buffer.Slice(Vector128<T>.Count * 2));
            sum3 += Vector128.Create(buffer.Slice(Vector128<T>.Count * 3));

            buffer = buffer.Slice(Vector128<T>.Count * 4);
        }
        while (buffer.Length >= Vector128<T>.Count * 4);

        // Combine pairwise so the two independent adds can pipeline.
        sum = (sum0 + sum1) + (sum2 + sum3);
    }

    // Split the remainder into its full vectors and a sub-vector tail. The full vectors fall
    // through the jump table; the tail lands in case 0, where the preloaded end is masked so only
    // the trailing elements the full vectors didn't already cover are added.
    (int blocks, int trailing) = Math.DivRem(buffer.Length, Vector128<T>.Count);

    switch (blocks)
    {
        case 3:
        {
            sum += Vector128.Create(buffer.Slice(Vector128<T>.Count * 2));
            goto case 2;
        }

        case 2:
        {
            sum += Vector128.Create(buffer.Slice(Vector128<T>.Count));
            goto case 1;
        }

        case 1:
        {
            sum += Vector128.Create(buffer);
            goto case 0;
        }

        case 0:
        {
            Vector128<T> msk = CreateRemainderMask128<T>(trailing);
            sum += Vector128.ConditionalSelect(msk, end, Vector128<T>.Zero);
            break;
        }
    }

    // Horizontally add the lanes into a single scalar.
    return Vector128.Sum(sum);
}

此版本以 if,保護未展開的迴路,使小型有效載荷完全跳過四個累加器,直接落入剩餘部分。 當資料足夠時,a do/while 每次迭代會累積四個向量,形成獨立的累加器——這讓處理器能將加法流程化——並成對組合。 接著,switch 跳轉表會納入剩餘的 0 到 3 個完整向量,並在 case 0 中納入子向量尾端部分:它會重用從緩衝區末端預先載入的一個完整向量,與已處理的元素重疊,並使用 ConditionalSelect 將該重疊部分遮罩為加法恆等元,讓尾端部分維持向量化,而不是退回純量迴圈。 如前所述,Vector128.Create 會從 span 讀取 Vector128<T>.Count 元素。 JIT 省略了區間邊界檢查,避免典型存取模式,因此 Create 即使在熱迴圈中也是不錯的預設; LoadUnsafe (接下來會介紹)是當你透過管理參考走過緩衝區時的低階替代方案。 對於非常大的輸入,完整的實作還會對齊緩衝區,並使用非時間性的載入與儲存,以避免驅逐有用的快取行——這裡省略了這兩點,且完全由 TensorPrimitives

像搜尋某個值這類冪等運算可以安全地再次處理重疊部分,因此會直接將最後一個向量併入,而不使用遮罩:

// Idempotent search that re-processes the final vector instead of a scalar loop.
public static bool Contains(ReadOnlySpan<int> buffer, int searched)
{
    Debug.Assert(Vector128.IsHardwareAccelerated);

    Vector128<int> values = Vector128.Create(searched);
    ReadOnlySpan<int> remaining = buffer;

    while (remaining.Length >= Vector128<int>.Count)
    {
        if (Vector128.EqualsAny(Vector128.Create(remaining), values))
        {
            return true;
        }
        remaining = remaining.Slice(Vector128<int>.Count);
    }

    if (remaining.IsEmpty)
    {
        return false;
    }

    // A partial vector remains. When the buffer holds at least one full vector,
    // re-check the last one (overlapping the tail); otherwise scan the few elements directly.
    if (buffer.Length >= Vector128<int>.Count)
    {
        Vector128<int> tail = Vector128.Create(buffer.Slice(buffer.Length - Vector128<int>.Count));
        return Vector128.EqualsAny(tail, values);
    }

    foreach (int value in remaining)
    {
        if (value == searched)
        {
            return true;
        }
    }

    return false;
}

Warning

不當處理餘數是錯誤的常見來源。 讀取超過緩衝區末端的迴圈會產生非確定性結果,且可能當機。 執行階段的測試套件使用一個 BoundedMemory 輔助程式,會在緩衝區後方緊接著放置一個不可存取頁面,因此任何越界讀取都會在測試期間觸發 AccessViolationException。 務必涵蓋餘數處理邏輯,包括長度不是向量寬度倍數的緩衝區。

安全載入與儲存向量

對大多數程式碼來說, Vector128.Create(span)CopyTo 是跨度與向量之間資料移動最簡單的方式,而 JIT 讓它們保持效率。 當你需要較低層級的載入和儲存——例如,透過受管理參考來走走緩衝區時——會偏好 LoadUnsafeStoreUnsafe ,這些多載會取一個受管理的參考和一個 nuint 元素偏移量。 與以指標為基礎的 Load/Store 多載不同,它們不需要固定緩衝區;也不像原始參考算術運算那樣,需要您手動推進 ref。 這兩種選擇都很容易出錯,導致垃圾回收漏洞或存取違規。

為了避免空緩衝區拋出,從(或GetReference陣列)取得起始參考GetArrayDataReference,而不是 ref span[0]

Important

偏移算術使用無符號 nuint。 計算偏移量前,務必檢查緩衝區長度,例如 buffer.Length - Vector128<int>.Count。 如果緩衝區小於一個向量,該減法會溢出到一個龐大的值,迴圈會讀取無效記憶體。

平台專屬硬體內在元件

當特定的處理器指令能帶來可攜式 API 未提供的優勢時,不妨使用 System.Runtime.Intrinsics.X86System.Runtime.Intrinsics.ArmSystem.Runtime.Intrinsics.Wasm 中的硬體內建函式。 每個內在類別都有一個 IsSupported 屬性(也是 JIT 常數),你可以保護專用路徑並退回到其他可攜式程式碼:

// Illustrates per-platform lightup. The portable '(vector & mask) == Zero' below
// already lowers optimally, so prefer it unless a specific instruction measurably wins.
public static bool AllBitsClear(Vector128<byte> vector, Vector128<byte> mask)
{
    if (Sse41.IsSupported)
    {
        // x86/x64: a single ptest instruction.
        return Sse41.TestZ(vector, mask);
    }
    else if (AdvSimd.Arm64.IsSupported)
    {
        // Arm64: AND, then reduce the maximum byte across every lane.
        Vector128<byte> anded = AdvSimd.And(vector, mask);
        return AdvSimd.Arm64.MaxAcross(anded).ToScalar() == 0;
    }
    else if (PackedSimd.IsSupported)
    {
        // WebAssembly: AND, then test whether any lane is non-zero.
        return !PackedSimd.AnyTrue(PackedSimd.And(vector, mask));
    }
    else
    {
        // Portable fallback for any other platform.
        return (vector & mask) == Vector128<byte>.Zero;
    }
}

前面的方法展示了如何在你想要時點亮每個架構的程式碼路徑,但這只是個簡單的範例:你其實不需要它。 可攜式的 (vector & mask) == Vector128<byte>.Zero 表達式在各平台上都已經會降階為最佳指令(例如在 x86/x64 上為 ptest),因此它執行的工作與手寫分支相同,只是少了那些複雜性。 只有當特定指令明顯超過可攜式 API 產生的指令時,才會採用明確的內在指令。

硬體內在元件需要每組指令集獨立實作,因此應將其視為對熱路徑的優化,而非預設值。 Vector128 / Vector256 API 在各個平台上都已經會降階為高效率的指令,而在實務上,精心設計的逐指令程式碼也不一定總是更有優勢。 在決定額外維護前,請先用基準測試確認差異。

使用 TensorPrimitives 的進階數學

如果你需要跨跨度的向量化數學,且不想自己寫迴圈, TensorPrimitives 可以提供一大組數值運算——如元素運算、指數運算,以及像點積和餘弦相似度等歸約——這些運算已經在內部被向量化。 它在 System.Numerics.Tensors NuGet 套件中提供。

// Computes result = (left * right) + addend over the whole span, vectorized internally.
public static float[] MultiplyAdd(float[] left, float[] right, float[] addend)
{
    float[] result = new float[left.Length];

    TensorPrimitives.Multiply(left, right, result);
    TensorPrimitives.Add(result, addend, result);

    return result;
}

// Higher-level reductions are available too.
public static float CosineSimilarity(float[] left, float[] right) =>
    TensorPrimitives.CosineSimilarity(left, right);

對於 AI 和數值工作負載, TensorPrimitives 通常能提供手寫 SIMD 的大部分優勢,卻沒有那麼複雜。

測試所有程式碼路徑

由於向量化方法有多個程式碼路徑,測試必須涵蓋每一條:Vector256 路徑、Vector128 路徑,以及純量路徑;而且每一條路徑都必須測試兩種輸入:一種大到足以發揮效益,另一種小到無法受益。 你可以在測試中調整輸入大小,但無法在測試層級切換硬體加速。 相反地,在程序開始前,先用環境變數控制它:

  • 設定 DOTNET_EnableAVX2=0 使 Vector256.IsHardwareAccelerated 傳回 false
  • DOTNET_EnableHWIntrinsic=0 設為完全停用 intrinsics,這樣 Vector128Vector64Vector<T> 都會回報未啟用加速。

若要在單一機器上涵蓋所有路徑,請執行測試套件三次:一次不使用覆寫、一次使用 DOTNET_EnableAVX2=0,以及一次使用 DOTNET_EnableHWIntrinsic=0。 另一種做法是在夠多且多樣化的硬體上執行,以涵蓋這些情況。

指令集配置旋鈕

除此之外,執行時會根據邏輯指令集群組識別一個旋鈕,每個指令集前綴為 DOTNET_。 一個旋鈕可以同時涵蓋多個相關指令集——EnableAVX2例如閘 AVX2 以及 BMI1、BMI2、F16C、FMA、LZCNT 和 MOVBE。 將旋鈕設為 0 會停用其所屬的整個群組,以及所有疊加於其上的項目。 將其設為 1(對大多數情況而言的預設值)即可允許該群組,但硬體本身仍必須確實支援它——若啟用目前 CPU 不具備的選項,系統會直接忽略,因此你最多只能縮限實際使用的指令範圍,無法強行啟用不受支援的指令而把系統搞壞。 DOTNET_EnableHWIntrinsic=0是大絕招——它會關閉一路到底層的所有功能,所以 Vector128Vector64Vector<T> 都會回報不支援加速,而程式碼就會改走軟體路徑。

Important

這些是診斷工具,主要用於測試與驗證——練習每條程式碼路徑、重現硬體特定問題,或確認備援方案。 它們不是為一般或量產用途設計的,也不是穩定合約。 以下這組是 .NET 11 所識別的;早期版本則暴露了不同的組合——基線旋鈕和 AVX-512 旋鈕被重新配置——因此請確認名稱是否符合你目標的執行版本。

這些工具也限制了它們能達到的範圍。 因為它們會限制 JIT 決策,不會影響已經透過 ReadyToRun 或 Native AOT 編譯好的程式碼,也不一定影響執行時和核心函式庫本身使用的內部例程。 把它們視為控制你自己經 JIT 編譯之程式碼的方式,而不是某個指令集的全域停用開關。

基底交換器和寬度上限適用於每個架構:

旋鈕(DOTNET_ 前綴) Default Effect
EnableHWIntrinsic 1 所有硬體內部函式的總開關;0 會強制使用完全軟體路徑。
MaxVectorTBitWidth 系統預設 Vector<T> 限制為以位元為單位的最大寬度;值低於 128 表示使用系統預設值。
PreferredVectorBitWidth 系統預設 將回報 IsHardwareAccelerated 的固定寬度向量上限設為以位元為單位的最大值;值小於 128 表示使用系統預設值。

MaxVectorTBitWidth 的系統預設值可能比硬體完整支援的寬度更窄,因此 Vector<T> 不會自動擴大到可用的最寬向量。 例如,Vector512<T>.IsHardwareAccelerated 可以是 true,而 Vector<T> 維持為 256 位元;將 DOTNET_MaxVectorTBitWidth=512 設為讓 Vector<T> 採用較寬的位寬。

PreferredVectorBitWidth 限制報告 IsHardwareAccelerated的最大向量寬度。 將其降低到低於硬體支援的值,會停用較寬的位寬:在支援 512 位元向量的機器上,DOTNET_PreferredVectorBitWidth=256 會使 Vector512<T>.IsHardwareAccelerated 回報 false。 這是個通用旋鈕,但目前只有 x86/x64 能提供超過 128 寬度的寬度,所以只有那個位置有明顯影響。

每個邏輯群組的 x86/x64 指令集也有自己的交換器:

旋鈕(DOTNET_ 前綴) Default Gates
EnableAVX 1 AVX 及其相依項
EnableAVX2 1 AVX2、BMI1、BMI2、F16C、FMA、LZCNT、MOVBE及受扶養人
EnableAVX512 1 AVX-512 F+BW+CD+DQ+VL 及其後繼系統
EnableAVX512BMM 1 AVX-512 BMM
EnableAVX512v2 1 AVX-512 IFMA+VBMI
EnableAVX512v3 1 AVX-512 BITALG+VBMI2+VPOPCNTDQ+VNNI
EnableAVX10v1 1 AVX10.1
EnableAVX10v2 0 AVX10.2
EnableAPX 0 APX(擴展通用暫存器)
EnableAES 1 AES, PCLMULQDQ
EnableAVX512VP2INTERSECT 1 AVX-512 VP2INTERSECT
EnableAVXIFMA 1 AVX-IFMA
EnableAVXVNNI 1 AVX-VNNI
EnableAVXVNNIINT 1 VEX AVX-VNNI-INT8 和 AVX-VNNI-INT16
EnableGFNI 1 GFNI
EnableSHA 1 SHA
EnableVAES 1 VAES, VPCLMULQDQ
EnableWAITPKG 1 WAITPKG
EnableX86Serialize 1 X86 序列化

在 Arm64 上,每個邏輯指令集群組都有自己的開關:

旋鈕(DOTNET_ 前綴) Default Gates
EnableArm64Aes 1 AES
EnableArm64Atomics 1 大型系統擴展(LSE)原子
EnableArm64Crc32 1 CRC32
EnableArm64Dczva 1 DC ZVA 快取歸零
EnableArm64Dp 1 點積
EnableArm64Rdm 1 四捨五入、加倍乘法累積(RDM)
EnableArm64Sha1 1 SHA1
EnableArm64Sha256 1 SHA256
EnableArm64Rcpc 1 釋放一致性、處理器一致性排序(RCpc)
EnableArm64Rcpc2 1 RCpc2
EnableArm64Cssc 0 常見短序列壓縮(CSSC)
EnableArm64Sve 1 可擴展向量擴展(SVE)
EnableArm64Sve2 1 SVE2
EnableArm64Sha3 1 SHA3
EnableArm64Sm4 1 SM4
EnableArm64SveAes 1 SVE AES
EnableArm64SveSha3 1 SVE SHA3
EnableArm64SveSm4 1 SVE SM4

預設為 0 (例如) EnableAVX10v2EnableArm64Cssc的旋鈕會限制還在線上的指令集,所以它會一直關閉,直到你選擇加入。

進行基準測試以確認勝出結果

向量化會增加複雜度,所以在你保留它之前,先衡量它的效益。 使用 BenchmarkDotNet,並使用先前展示的環境變數,一次比較標量 Vector128、 和 Vector256 實作。 BenchmarkDotNet 的反組譯診斷器也能輸出產生的組合碼,這對調校高效能程式碼而言非常有價值。

有幾點需要注意:

  • 投入量較大的資源會帶來更多好處。 對於小型緩衝區,向量化程式碼因設定開銷可能比純量碼慢。 針對呼叫端實際使用的輸入大小進行基準測試。
  • 加速效果很少是理想的。 一個256位元向量在32位元元素上運作,不會穩定地快8倍;記憶體吞吐量、對齊度和指令延遲都會考量在內。
  • 記憶對齊會影響穩定性。 隨機分配對齊會在執行間增加雜訊。 你可以配置對齊記憶體 AlignedAlloc 以取得穩定結果,或啟用 BenchmarkDotNet 的記憶體隨機化來觀察完整分布。

最佳做法

  • 先取得現有的高階 API。 Span<T>string、 LINQ TensorPrimitives和 張量類型已經能加速許多常見操作——不要手動捲動已經優化和測試過的部分。
  • Vector128<T> 開始;它可在最廣泛的硬體上獲得加速,而且您不需要 Vector256<T>,就能獲得正確且可攜的實作。 僅針對經量測確認的熱路徑,才增加位寬並使用硬體內建函式。
  • 直接檢查 IsHardwareAcceleratedCount,而不要快取它們;JIT 會將它們視為常數。
  • 儲存時務必處理迴圈剩餘部分,並考慮來源與目的緩衝區的重疊。
  • 先寫邊緣案例測試,接著寫純量解,然後用向量 API 表達純量邏輯。
  • 在投入額外複雜度前,先測試每條程式碼路徑(包括存取違規),並以實際的輸入大小為基準測試。

參見