在 .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 向量和矩阵类型

命名空间 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>及其兄弟姐妹目前支持基元数值类型(byte、、、sbyteshortushortintuintlongulongfloatdouble、和nintnuint),并且该集将来可能会增长为包含其他类型的类型。 用于 Vector128<T>.IsSupported 确定给定 T 是否有效,这在泛型代码中特别有用。

不支持的类型(例如 char ,并且 bool)仍可通过将缓冲区重新解释为相同大小的受支持类型来矢量化。 使用 Cast 重新解释一个跨度(例如,从 charushort),或者使用向量的 As<TFrom, TTo> 方法来重新解释你已经持有的向量。 重新解释只会更改类型,而不是基础位,因此你有责任保持数据格式正确: bool 必须保留 01,并且 char 必须保留有效的 UTF-16 代码单元。 如果矢量化操作可以生成范围外值,在写回结果之前,请注意规范化结果。

使用 Vector128 进行跨平台矢量化

Vector128<T> 是支持向量化的每个平台的通用分母,因此是开始的最佳位置。 它保存一个 128 位向量:16 个字节、8 个短整型、4 个 int/float,或 2 个 long/double。

------------------------------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 对应的等效操作。 在假设更宽的向量更快之前,先确认基准。

跨车道操作不会免费扩展

按元素进行的操作不受宽度影响:无论 v1v2Vector128<T> 还是 Vector256<T>v1 + v2 的逐元素结果都是相同的——加宽只是让每条指令多处理一个通道的数据量。 Addv = [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();
}

若要得到正确的总和,请显式跨越通道边界:使用 GetLower/GetUpper 读取各个通道的部分和,再将它们相加——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 运算符给出的结果 EqualsGreaterThanGreaterThanOrEqualLessThanLessThanOrEqual
Classification 数轴上针对每个元素的谓词,每个谓词均返回一个向量掩码 IsNaNIsFiniteIsInfinityIsPositiveInfinityIsNegativeInfinityIsIntegerIsEvenIntegerIsOddIntegerIsNegativeIsPositiveIsNormalIsSubnormalIsZero
比较减少 将逐元素比较折叠为单个值 bool EqualsAll (x == y)EqualsAnyGreaterThanAllGreaterThanAnyGreaterThanOrEqualAllGreaterThanOrEqualAnyLessThanAllLessThanAnyLessThanOrEqualAllLessThanOrEqualAny
全向量谓词 将向量归约为一个 bool:判断所有元素、任意元素或没有元素等于某个值,或者( WhereAllBitsSet 形式)是否所有位都已设置。 优先使用这些方法,而不是将掩码转换为索引 AllAnyNoneAllWhereAllBitsSetAnyWhereAllBitsSetNoneWhereAllBitsSet
搜寻 按值对元素进行计数或定位,或者(WhereAllBitsSet 形式)在掩码中设置各通道 CountIndexOfLastIndexOfCountWhereAllBitsSetIndexOfWhereAllBitsSetLastIndexOfWhereAllBitsSet
掩码转索引 将比较掩码转换为标量位掩码并扫描它 ExtractMostSignificantBits使用TrailingZeroCountLeadingZeroCount
选择 根据掩码逐位混合两个向量 ConditionalSelect(x, y, z),等效于 (y & x) \| (z & ~x)
Conversion 更改数值类型,计算新值(例如, int 更改为 float ConvertToInt32ConvertToInt64ConvertToUInt32ConvertToUInt64ConvertToSingleConvertToDouble
加宽和缩窄 将元素拆分为较宽的类型,或将它们打包为较窄的类型 WidenWidenLowerWidenUpperNarrowNarrowWithSaturation
重新解释 在不更改这些位的情况下,将其重新解释为另一种元素类型 As<TFrom, TTo>AsByteAsInt32AsSingle和其他 As* 元素形式
System.Numerics 互操作 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 所采用的方法。 (启用新的内存安全模型后,在安全代码中可对具有 unmanaged 约束的类型参数使用 sizeof(T) 表达式。)仅显示 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 对展开循环进行保护,因此较小的负载数据会完全跳过四个累加器的处理,直接进入余数处理部分。 当数据足够时,do/while 会在每次迭代中将四个向量分别累积到独立的累加器中,从而使处理器能够以流水线方式执行加法,并将它们两两合并。 随后,switch 跳转表会将剩余的 0 到 3 个完整向量并入计算,并且在 case 0 中处理子向量尾部:它复用一个从缓冲区末尾预加载的完整向量,与已处理过的元素发生重叠,并用 ConditionalSelect 将这部分重叠屏蔽为加法单位元,从而使尾部保持向量化,而不是退化为标量循环。 与以前一样, Vector128.Create 从范围读取 Vector128<T>.Count 元素。 JIT 会针对典型的访问模式省略 span 的边界检查,因此 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

对余数处理不当是常见的 bug 来源。 读取缓冲区末尾的循环会生成不确定的结果,并且可能会崩溃。 运行时的测试套件使用一个 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 已降低到每个平台上的最佳指令(例如 ptest ,在 x86/x64 上),因此它所做的工作与手写分支相同,只是没有复杂性。 只有当特定指令经实测明显优于可移植 API 生成的代码时,才应选择使用显式内在函数。

硬件内在函数需要针对每种指令集分别实现,因此应将其视为对经测量验证的热点路径的优化手段,而非默认选择。 Vector128 / Vector256 API 已经可以在各个平台上降为高效指令,而在实践中,复杂的逐指令代码并不总能胜出。 在承诺额外维护之前,请确认与基准的差异。

使用 TensorPrimitives 进行更高级别的数学运算

如果你需要对 Span 执行向量化数学运算,又不想自己编写循环,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 设置为完全禁用内建函数,这样 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 可识别的是以下这组选项;较早版本提供的是另一组选项——尤其是 baseline 和 AVX-512 相关选项已被重新配置——因此,请根据你所针对的运行时版本确认这些名称。

这些工具还对它们达到的内容有限制。 由于它们对 JIT 决策进行门控,因此不会影响通过 ReadyToRun 或 Native AOT 提前编译的代码,它们不一定影响运行时和核心库使用的内部例程。 将它们视为控制你自己的 JIT 编译代码的一种方式,而不是某个指令集的全局禁用开关。

基本开关和宽度上限适用于每个体系结构:

旋钮(DOTNET_ 前缀) 默认 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_ 前缀) 默认 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 SERIALIZE

在 Arm64 上,指令集的每个逻辑分组都有自己的开关:

旋钮(DOTNET_ 前缀) 默认 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,并使用前文所示的相同环境变量,在一次运行中比较标量、Vector128Vector256 这几种实现。 BenchmarkDotNet 的反汇编诊断程序还可以发出生成的程序集,这在优化高性能代码时非常有用。

请注意以下几点:

  • 输入越大,收益越明显。 对于小型缓冲区,由于设置开销,矢量化代码可能比标量代码慢。 对调用方实际使用的输入大小进行基准测试。
  • 加速效果很少是完美的。 在 32 位元素上运行的 256 位向量不会可靠地快 8 倍;内存吞吐量、对齐和指令延迟都考虑在内。
  • 内存对齐会影响稳定性。 随机分配对齐方式会增加运行之间的干扰。 可以为稳定结果分配一致的内存 AlignedAlloc ,或者启用 BenchmarkDotNet 的内存随机化来观察完整分布情况。

最佳做法

  • 首先访问现有的更高级别的 API。 Span<T>string、LINQ、TensorPrimitives以及张量类型已经为你加速了许多常见操作——不要再手写那些已经优化并经过测试的实现。
  • Vector128<T> 开始;它可在最广泛的硬件上获得加速,而且无需 Vector256<T>,也能获得正确且可移植的实现。 仅针对测量的热路径添加更广泛的宽度和硬件内部函数。
  • 直接检查 IsHardwareAcceleratedCount,而不是将它们缓存起来;JIT 会将它们优化为常量。
  • 始终处理循环余数,并在存储时考虑重叠的源缓冲区和目标缓冲区。
  • 先编写边缘用例测试,然后编写标量解决方案,然后使用矢量 API 表达该标量逻辑。
  • 在决定采用这种额外复杂性之前,先测试每一条代码路径(包括访问违规),并针对真实的输入规模进行基准测试。

另见