Uso de SIMD y intrínsecos de hardware en .NET

SIMD (instrucción única, varios datos) es compatibilidad de hardware para aplicar una operación a varios fragmentos de datos en paralelo con una sola instrucción. El código vectorizado procesa varios valores por iteración en lugar de uno, lo que puede aumentar considerablemente el rendimiento del tipo de trabajo numérico, científico, gráfico, procesamiento de texto y paralelo de datos donde la misma operación se repite en un búfer. La contrapartida es una complejidad añadida, por lo que compensa sobre todo cuando la entrada es lo bastante grande y la mejora se confirma con mediciones.

.NET proporciona varios tipos de compatibilidad con SIMD. Elija el que coincida con la cantidad de control que necesita y la complejidad que está dispuesto a asumir.

Tipos de compatibilidad con SIMD en .NET

Interfaz de Programación de Aplicaciones (API) Namespace Cuándo usarlo
Tipos de vector y matriz de uso fijo System.Numerics Gráficos y matemáticas de geometría con vectores de 2 a 4 elementos, matrices, cuaterniones y planos.
Vector<T> System.Numerics Vectorización portátil de ancho variable cuando no se necesita control por plataforma.
Vector64<T>, Vector128<T>, , Vector256<T>, Vector512<T> System.Runtime.Intrinsics Vectorización multiplataforma de ancho fijo con control granular. Este es el punto de partida recomendado para los nuevos algoritmos vectorizados.
Funciones intrínsecas de hardware System.Runtime.Intrinsics.X86, , System.Runtime.Intrinsics.Arm, System.Runtime.Intrinsics.Wasm Instrucciones específicas del procesador que las API de más alto nivel no ponen a disposición, para exprimir al máximo el rendimiento en una ruta crítica del código.
TensorPrimitives System.Numerics.Tensors Operaciones matemáticas vectorizadas ya preparadas para spans. Hace la vectorización para usted.

Cuando estas API se superponen, se relacionan a través de capas de abstracción. Los tipos vectoriales genéricos son los tipos de intercambio fundamentales que usan las demás capas, por lo que técnicamente son los de nivel más bajo: el de ancho variable Vector<T>, que crece hasta el ancho que admita el hardware en ejecución, y los de ancho fijo Vector64<T> a Vector512<T>. Los intrínsecos de hardware específicos de la plataforma de System.Runtime.Intrinsics.X86, System.Runtime.Intrinsics.Arm y System.Runtime.Intrinsics.Wasm operan sobre esos tipos, y cada uno de ellos se corresponde directamente con una instrucción individual del procesador. Las operaciones multiplataforma expuestas en los tipos genéricos están un nivel por encima de las funciones intrínsecas específicas de cada plataforma, y se traducen a estas para cada plataforma de destino. Más arriba aún se encuentran las API gestionadas que operan sobre búferes completos —métodos vectorizados en Span<T> y string, y TensorPrimitives—, que se basan en las capas inferiores, por lo que se obtiene aceleración SIMD sin tener que escribir nada de ello a mano. Los tipos System.Numerics de forma fija son tipos prácticos específicos de un dominio para gráficos y geometría, y no forman parte de esta pila de intercambio.

El resto de este artículo recorre estas API del nivel más alto al más bajo y, a continuación, aborda las pruebas, la evaluación comparativa y las buenas prácticas.

Tipos de vectores y matrices de System.Numerics

El System.Numerics espacio de nombres proporciona tipos acelerados por SIMD con una forma fija:

Estos tipos se adaptan de forma natural a los gráficos y la geometría, y el tiempo de ejecución acelera sus operaciones con instrucciones SIMD cuando el hardware las admite. En el ejemplo siguiente se agregan dos vectores:

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

También exponen las matemáticas de vectores comunes que cabría esperar, como el producto de puntos, la distancia y la fijación:

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

Los tipos de matriz admiten matemáticas de matriz, como transponer y multiplicar:

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

Vector<T>

Vector<T> representa un vector de ancho variable de un tipo numérico primitivo. Su longitud es fija durante toda la vida del proceso, pero el valor de Vector<T>.Count depende de la CPU que ejecuta el código. El compilador Just-In-Time (JIT) trata Count como una constante, por lo que los bucles escritos en él se optimizan bien.

Vector<T> proporciona vectorización portátil sin código por plataforma, a costa de no conocer el ancho del vector en tiempo de compilación. En el ejemplo siguiente se calcula la suma elemento a elemento de dos arrays:

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

Nota:

Este ejemplo es ilustrativo. Rara vez necesita escribir un bucle como este manualmente, ya que TensorPrimitives ya proporciona matemáticas aceleradas basadas en intervalos. Esta suma elemento a elemento es Add, y también se proporcionan reducciones como Sum. Estas operaciones se aceleran por hardware para los tipos de elemento que Vector<T> admiten (Vector<T>.IsSupported).

Comprobación de la aceleración de hardware

Los tipos con aceleración SIMD funcionan incluso en configuraciones de hardware o del JIT que no admiten SIMD, ya que recurren a implementaciones de software no aceleradas. Para determinar si la aceleración está realmente disponible, compruebe la propiedad correspondiente IsHardwareAccelerated:

El JIT convierte estas propiedades en constantes, por lo que las ramas que no se toman se eliminan y comprobarlas no supone ningún coste en tiempo de ejecución. No almacene en caché los valores; léelos directamente donde los necesite. Lo mismo se aplica a las Count propiedades (por ejemplo, Vector128<T>.Count), que también son constantes en tiempo JIT.

La mayoría de las operaciones con un ancho acelerado también están aceleradas, pero no está garantizado en todas las operaciones. Por ejemplo, la división de punto flotante podría acelerarse donde no se encuentra la división de enteros. Cuando Vector256 se acelera, Vector128 normalmente también es, pero no hay ninguna garantía, así que compruebe cada ancho que use.

Tip

Si una operación que necesita no se acelera en una plataforma que le interesa o desea una nueva API multiplataforma, abra un problema en dotnet/runtime. Lo mismo se aplica a las mejoras de codegen.

No todos los tipos de elemento son válidos para cada vector. Vector128<T> y sus homólogos admiten actualmente los tipos numéricos primitivos (byte, sbyte, short, ushort, int, uint, long, ulong, float, double, nint y nuint), y ese conjunto podría ampliarse para incluir otros tipos en el futuro. Use Vector128<T>.IsSupported para determinar si un determinado T es válido, lo que resulta especialmente útil a partir del código genérico.

Los tipos que no se admiten, como char y bool, todavía se pueden vectorizar mediante la reinterpretación del búfer como un tipo admitido del mismo tamaño. Usa Cast para reinterpretar un segmento —por ejemplo, de char a ushort— o el método As<TFrom, TTo> del vector para reinterpretar un vector que ya tienes. La reinterpretación solo cambia el tipo, no los bits subyacentes, por lo que es su responsabilidad mantener los datos bien formados: debe bool permanecer 0 o 1, y debe char permanecer una unidad de código UTF-16 válida. Si una operación vectorizada podría generar un valor fuera del intervalo, tenga cuidado de normalizar el resultado antes de volver a escribirlo.

Vectorización multiplataforma con Vector128

Vector128<T> es el denominador común en todas las plataformas que admite la vectorización, por lo que es el mejor lugar para empezar. Contiene un vector de 128 bits: 16 bytes, 8 shorts, 4 ints/floats o 2 longs/doubles.

------------------------------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> es el doble de ancho y Vector512<T> dos veces más. No todo el hardware admite los anchos más grandes, por lo que los ejemplos siguientes usan Vector128 para la portabilidad.

Cada ancho tiene un tipo genérico (Vector128<T>) para los datos y una clase estática no genérica (Vector128) que contiene la mayoría de las operaciones, incluidos los métodos de fábrica estáticos como Create y Load. Los operadores como +, & y << son la forma idiomática de expresar operaciones aritméticas y de bits; es preferible usarlos en lugar de los métodos equivalentes con nombre para evitar errores de precedencia de operadores y mejorar la legibilidad. Para los algoritmos que dependen del orden de bytes, use una rama condicional basada en IsLittleEndian, que el JIT también reduce a una constante.

Nota:

En x86/x64, las operaciones Vector256<T> suelen tratarse como dos "lanes" independientes de 128 bits. Para la mayoría de las operaciones elemento a elemento, esto es transparente, pero las operaciones que cruzan los carriles (como los reordenamientos o las operaciones por pares u horizontales) pueden comportarse de forma distinta o tener un coste mayor que su equivalente Vector128. Confirme con pruebas comparativas antes de asumir que un vector más amplio es más rápido.

Las operaciones de cruce de carril no se amplían sin coste adicional

Las operaciones elemento a elemento no dependen del ancho: v1 + v2 produce el mismo resultado por elemento tanto si v1 y v2 son Vector128<T> como Vector256<T>; ensanchar solo procesa los datos de un carril más por instrucción. Add en v = [a, b, c, d] y w = [e, f, g, h] siempre combina los mismos elementos de índice:

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

Las operaciones entre carriles no se amplían de forma tan sencilla, porque qué elementos se combinan depende del ancho del vector. Una reducción por pares combina elementos adyacentes en lugar de elementos con el mismo índice, por lo que ampliar cambia qué elementos acaban emparejados:

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)

Eso es exactamente lo que hace una reducción horizontal: suma los elementos de un vector con dos rondas de agregaciones en pares. En x86/x64, Vector128<float> (4 elementos) le lleva allí con dos llamadas a 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();
}

Si se aplica el mismo patrón de dos llamadas a Vector256<float> (8 elementos), parece correcto, pero no lo es. HorizontalAdd no funciona en todo el vector de 256 bits; repite el patrón en pares independientemente dentro de cada carril de 128 bits. Dos rondas dan como resultado la suma del carril inferior (elementos 0-3) difundida por todo el carril inferior y la suma del carril superior (elementos 4-7) difundida por todo el carril superior, no la suma de los ocho elementos en total:

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

Para obtener el total correcto, cruce explícitamente el límite entre carriles: lea la suma parcial de cada carril con GetLower/GetUpper y súmelas—GetLower y GetUpper dividen un vector en su primera y su segunda mitad:

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

Ese paso adicional es el costo real de cruzar carriles. Un algoritmo de cruce entre carriles no se amplía sin coste como sí lo hace uno que opera elemento a elemento; mide antes de dar por hecho que el vector más ancho será el ganador.

Operaciones comunes

Vector128 y sus homólogos más amplios exponen una API extensa. No es necesario memorizarlo: conozca las categorías y busque los detalles cuando los necesite. Cada operación tiene una alternativa por software para las plataformas que no pueden acelerarla por hardware. La siguiente tabla cubre prácticamente toda la superficie.

Categoría Qué hace APIs representativas
Constants Vectores de constante predefinidos Zero, One, NegativeOne, AllBitsSet, Indices, SignSequence, E, Pi, Tau, Epsilon, NaN, PositiveInfinity, NegativeInfinity, NegativeZero
Creación Difundir un escalar, establecer elementos o generar una secuencia Create, CreateScalar, CreateScalarUnsafe, Create(ReadOnlySpan<T>), CreateSequence, CreateGeometricSequence, , CreateHarmonicSequenceCreateAlternatingSequence
Cargar y almacenar Mover datos entre la memoria y un vector Load, LoadUnsafe, LoadAligned, LoadAlignedNonTemporal, Store, StoreUnsafe, StoreAligned, StoreAlignedNonTemporal, CopyTo, TryCopyTo
Arithmetic Operaciones matemáticas por elementos y reducciones Add (x + y), Subtract (x - y), Multiply (x * y), Divide (x / y), Negate (-x), AddSaturate, SubtractSaturate, Abs, Sqrt, FusedMultiplyAdd, Dot, Sum
Operaciones de bits Lógica a nivel de bit y desplazamientos BitwiseAnd (x & y), BitwiseOr (x \| y), Xor (x ^ y), AndNot (x & ~y), OnesComplement (~x), ShiftLeft (x << n), , ShiftRightArithmetic (x >> n)ShiftRightLogical (x >>> n)
Min, max y limitar Mínimo, máximo y limitación a un rango elemento a elemento Min, Max, Clamp, MinMagnitude, MaxMagnitude, MinNumber, MaxNumber, MinMagnitudeNumberMaxMagnitudeNumber
Redondeo Redondea cada elemento a un valor entero Ceiling, Floor, , Round, Truncate
Funciones matemáticas Funciones auxiliares de signo, interpolación, ángulo y trascendentales CopySign, Lerp, DegreesToRadians, RadiansToDegrees, Hypot, Sin, Cos, SinCos, Asin, Exp, Log, Log2
Comparación Compare cada elemento; el resultado es una máscara vectorial, no la que da un operador bool Equals, GreaterThan, GreaterThanOrEqual, , LessThan, LessThanOrEqual
Classification Predicados por elemento sobre la línea de número, cada uno de los cuales devuelve una máscara vectorial IsNaN, IsFinite, IsInfinity, IsPositiveInfinity, IsNegativeInfinity, IsInteger, IsEvenInteger, IsOddInteger, IsNegative, IsPositive, IsNormal, IsSubnormal, IsZero
Reducciones de comparación Contraer una comparación por elemento a un único valor bool EqualsAll (x == y), EqualsAny, GreaterThanAll, GreaterThanAny, GreaterThanOrEqualAll, GreaterThanOrEqualAny, LessThanAll, LessThanAny, LessThanOrEqualAll, LessThanOrEqualAny
Predicados de vector completo Reduzca un vector a bool: si todos, cualquiera o ningún elemento son iguales a un valor o (los WhereAllBitsSet formularios) tienen todos los bits establecidos. Prefiere estos en lugar de convertir una máscara en un índice All, Any, None, AllWhereAllBitsSet, , AnyWhereAllBitsSet, NoneWhereAllBitsSet
Search Contar o localizar elementos según su valor o (las formas WhereAllBitsSet) establecer posiciones en una máscara Count, IndexOf, LastIndexOf, CountWhereAllBitsSet, , IndexOfWhereAllBitsSet, LastIndexOfWhereAllBitsSet
Máscara para indexar Convertir una máscara de comparación en una máscara de bits escalar y examinarla ExtractMostSignificantBits con TrailingZeroCount o LeadingZeroCount
Selection Combinar dos vectores según una máscara, bit a bit ConditionalSelect(x, y, z), equivalente a (y & x) \| (z & ~x)
Conversion Cambiar el tipo numérico, calcular nuevos valores (por ejemplo, int a float) ConvertToInt32, ConvertToInt64, ConvertToUInt32, ConvertToUInt64, , ConvertToSingle, ConvertToDouble
Ampliación y restricción Dividir elementos en un tipo más amplio o empaquetarlos en uno más estrecho Widen, WidenLower, WidenUpper, , Narrow, NarrowWithSaturation
Reinterpretación Reinterpretar los bits como otro tipo de elemento sin cambiarlos As<TFrom, TTo>, AsByte, AsInt32, AsSingle, y las demás formas del elemento As*
Interoperabilidad de System.Numerics Reinterpretación entre Vector128<T> y los tipos numéricos de forma fija AsVector, AsVector2, AsVector3, AsVector4, AsPlane, AsQuaternion, , AsVector128AsVector128Unsafe
Reordenar Reorganizar elementos por índice o intercalar dos vectores Shuffle, Reverse, Zip, ZipLower, ZipUpper, Unzip, UnzipEven, UnzipOdd, ConcatLowerLower, ConcatLowerUpper, ConcatUpperLower, ConcatUpperUpper
Acceso al carril Leer o reemplazar elementos individuales y mitades, o cambiar el tamaño del vector GetElement, WithElement, ToScalar, GetLower, GetUpper, WithLower, , WithUpperToVector256

Tip

Varias operaciones tienen variantes Estimate y Native; por ejemplo, MultiplyAddEstimate, ClampNative, MinNative, MaxNative, ShuffleNative y ConvertToInt32Native. Se traducen en una instrucción de hardware más rápida que sacrifica algo de precisión o renuncia a una garantía de IEEE para casos límite (como la gestión de NaN), así que recurre a ellas solo cuando una evaluación comparativa muestre que la forma exacta es el cuello de botella y la semántica más laxa sea aceptable.

Nota:

Vector256.Shuffle trata su entrada como un único vector de 256 bits, mientras que Avx2.Shuffle, específico de la plataforma, funciona como dos bloques independientes de 128 bits. La API multiplataforma es la opción más portátil, pero conviene confirmar el comportamiento que necesita al portar intrínsecos escritos manualmente.

Estructura la ruta del código

Normalmente, un método vectorizado se divide en una ruta para cada ancho de vector, además de una alternativa escalar para entradas pequeñas y hardware sin aceleración. Para usar el vector más grande que admite el hardware, compruebe primero el vector más amplio y trabaje hacia abajo:

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

La comprobación externa de cada ancho combina Vector128.IsHardwareAccelerated (una constante en tiempo de compilación JIT que indica si la plataforma acelera ese ancho) con Vector128<T>.IsSupported (si el tipo de elemento T es válido para ese ancho). Dentro de un bloque compatible, compare la longitud de la entrada con Count para elegir entre la ruta vectorizada y una alternativa para entradas pequeñas. El método es genérico respecto de T, y los bloques Vector256 y Vector512 —idénticos al bloque Vector128, pero que usan el tipo más amplio— se muestran comentados para mayor brevedad.

Hay dos alternativas distintas. Un búfer demasiado pequeño incluso para el vector más pequeño, pero, en hardware acelerado, pasa a SumVectorSmall—una tabla de saltos explícita switch que gestiona cada posible longitud de subvector sin bucle:

// 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) también es una constante en tiempo de compilación JIT, por lo que SumVectorSmall selecciona en función del ancho del elemento una tabla dimensionada para el número de elementos del vector más ancho, el mismo enfoque que utiliza TensorPrimitives. (Cuando está habilitado el nuevo modelo de seguridad de memoria, se permiten en código seguro las expresiones sizeof(T) en un parámetro de tipo con la restricción unmanaged). Solo se muestra la tabla de 4 bytes; las tablas de 1, 2 y 8 bytes tienen la misma estructura. Sus casos más grandes integran el resto con una Vector256 o Vector128 usando dos cargas superpuestas —una desde el principio y otra desde el final—, de modo que el manejo del resto más amplio para las rutas omitidas Vector512/Vector256 está directamente en la tabla de saltos. Las dos cargas se superponen cuando la longitud no es un múltiplo exacto del ancho, por lo que la cola se enmascara hasta obtener la identidad aditiva con ConditionalSelect antes de sumarse. Esa máscara solo es necesaria porque la adición no es idempotente; Una operación idempotente, como una búsqueda, podría plegar la cola superpuesta directamente. Un búfer en hardware sin vectorización en absoluto pasa a SumScalar, un bucle escalar normal.

Recorrer en bucle la entrada y controlar el resto

Para procesar un búfer mayor que un único vector, recorra en bucle un vector a la vez y, a continuación, controle los elementos que no rellenan un vector completo. La forma sólida de controlar esa cola es volver a procesar el último vector completo de elementos, superponiendo algunos de los bucles ya controlados, lo que evita un epílogo escalar independiente. Si esa superposición necesita corregirse depende de la operación.

Una operación no idempotente , como una suma, contaría dos veces los elementos superpuestos, por lo que los enmascararía a la identidad de la operación antes de plegarlos. Úselo cuando cada elemento debe contribuir exactamente una vez:

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

Esta versión protege el bucle desenrollado mediante un if, por lo que las cargas útiles pequeñas omiten por completo los cuatro acumuladores y pasan directamente al procesamiento del resto. Cuando hay suficientes datos, un do/while acumula cuatro vectores por iteración en acumuladores independientes (lo que permite a la canalización del procesador las adiciones) y los combina en pares. Una tabla de saltos switch incorpora entonces los entre cero y tres vectores completos restantes y, en case 0, la cola de subvectores: reutiliza un vector completo precargado desde el final del búfer, solapando los elementos ya procesados, y enmascara ese solapamiento hasta convertirlo en la identidad aditiva con ConditionalSelect para que la cola siga vectorizada en lugar de pasar a un bucle escalar. Como antes, Vector128.Create lee los elementos Vector128<T>.Count del span. El JIT omite la comprobación de límites de Span en los patrones de acceso habituales, por lo que Create es una opción predeterminada adecuada incluso en un bucle crítico; LoadUnsafe (que se explica a continuación) es la alternativa de más bajo nivel para cuando se recorre el búfer mediante una referencia administrada. En el caso de las entradas muy grandes, una implementación completa también alinearía el búfer y usaría cargas y almacenes no temporales para evitar expulsar líneas de caché útiles, ambas omitidas aquí y cubiertas completamente por TensorPrimitives.

Una operación idempotente, tal como la búsqueda de un valor, puede reprocesar la superposición sin problemas, por lo que incorpora directamente el último vector, sin necesidad de máscara:

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

Advertencia

El control erróneo del resto es un origen común de errores. Un bucle que lee más allá del final del búfer produce resultados no deterministas y puede provocar un fallo. El conjunto de pruebas del tiempo de ejecución usa un asistente BoundedMemory que coloca una página sin acceso inmediatamente después del búfer, por lo que cualquier lectura fuera de los límites provoca una excepción AccessViolationException durante las pruebas. Cubra siempre la lógica de resto, incluidos los búferes cuya longitud no sea un múltiplo del ancho del vector.

Carga y almacenamiento de vectores de forma segura

Para la mayoría del código, Vector128.Create(span) y CopyTo son la manera más sencilla de mover datos entre un intervalo y un vector, y el JIT los mantiene eficientes. Cuando necesite las operaciones de carga y almacenamiento de nivel inferior (por ejemplo, para recorrer un búfer mediante una referencia administrada), prefiera las sobrecargas de LoadUnsafe y StoreUnsafe que aceptan una referencia administrada y un desplazamiento de elementos nuint. A diferencia de las sobrecargas basadas en punteros Load/Store, no requieren fijar el búfer y, a diferencia de la aritmética con referencias sin procesar, no requieren que avance manualmente una referencia ref. Es fácil equivocarse con ambas alternativas de forma que se introduzcan fallos en el recolector de basura o violaciones de acceso.

Para que los búferes vacíos no se inicien, obtenga la referencia inicial de GetReference (o GetArrayDataReference para matrices) en lugar de ref span[0].

Importante

La aritmética de desplazamiento usa nuint sin signo. Compruebe siempre la longitud del búfer antes de calcular un desplazamiento como buffer.Length - Vector128<int>.Count. Si el búfer es más pequeño que un vector, esa resta produce un desbordamiento por debajo hasta dar un valor enorme y el bucle lee memoria no válida.

Intrínsecos de hardware específicos de la plataforma

Cuando una instrucción específica del procesador te ofrece una ventaja que las API portátiles no exponen, recurre a los intrínsecos de hardware en System.Runtime.Intrinsics.X86, System.Runtime.Intrinsics.Arm y System.Runtime.Intrinsics.Wasm. Cada clase intrínseca tiene una IsSupported propiedad (también una constante JIT) para que pueda proteger la ruta de acceso especializada y revertir al código portátil en otro lugar:

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

El método anterior muestra cómo activar rutas de código específicas para cada arquitectura cuando quieras, pero es deliberadamente un ejemplo sencillo: en realidad, aquí no lo necesitas. La expresión portable (vector & mask) == Vector128<byte>.Zero ya se reduce a la instrucción óptima en cada plataforma (por ejemplo, ptest en x86/x64), por lo que realiza el mismo trabajo que las ramas escritas manualmente, pero sin la complejidad. Alcance los intrínsecos explícitos solo cuando una instrucción específica supera considerablemente lo que generan las API portátiles.

Las funciones intrínsecas de hardware requieren una implementación independiente para cada conjunto de instrucciones, así que trátalas como una optimización para rutas de ejecución críticas identificadas mediante mediciones, y no como una opción predeterminada. Las Vector128/Vector256 API ya se reducen a instrucciones eficaces en cada plataforma y, en la práctica, el código sofisticado por instrucción no siempre gana. Confirme la diferencia con una prueba comparativa antes de confirmar el mantenimiento adicional.

Matemáticas de nivel superior con TensorPrimitives

Si necesita operaciones matemáticas vectorizadas sobre segmentos y no quiere escribir los bucles usted mismo, TensorPrimitives proporciona un amplio conjunto de operaciones numéricas —aritmética elemento a elemento, exponenciales y reducciones como el producto escalar y la similitud del coseno— que ya están vectorizadas internamente. Está disponible en el paquete NuGet System.Numerics.Tensors .

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

En el caso de la inteligencia artificial y las cargas de trabajo numéricas, TensorPrimitives a menudo ofrece la mayor parte de la ventaja de SIMD escrito a mano sin ninguna de las complejidades.

Pruebe todas las rutas de ejecución del código

Dado que un método vectorizado tiene varias rutas de código, las pruebas deben cubrir cada una de ellas: la ruta Vector256, la ruta Vector128 y la ruta escalar, cada una con valores de entrada tanto lo bastante grandes como demasiado pequeños para que la vectorización resulte beneficiosa. Puedes variar el tamaño de entrada en las pruebas, pero no puedes activar o desactivar la aceleración por hardware a nivel de prueba. En su lugar, controlelo con variables de entorno antes de que se inicie el proceso:

  • Establezca DOTNET_EnableAVX2=0 para hacer que Vector256.IsHardwareAccelerated devuelva false.
  • Configure DOTNET_EnableHWIntrinsic=0 para inhabilitar por completo las funciones intrínsecas, de modo que Vector128, Vector64 y Vector<T> indiquen que no hay aceleración.

Para probar todas las rutas en una sola máquina, ejecute la batería de pruebas una vez sin anulaciones, una vez con DOTNET_EnableAVX2=0 y una vez con DOTNET_EnableHWIntrinsic=0. La alternativa es ejecutarla en una variedad suficiente de hardware para cubrir todos los casos.

Botones de configuración del conjunto de instrucciones

Más allá de esos dos, el entorno de ejecución reconoce un parámetro para cada agrupación lógica de conjuntos de instrucciones, cada uno precedido por DOTNET_. Un solo control puede abarcar varios conjuntos de instrucciones relacionados—EnableAVX2, por ejemplo, controla AVX2 junto con BMI1, BMI2, F16C, FMA, LZCNT y MOVBE. Configurar un control en 0 desactiva todo su grupo y todo lo que esté por encima. Establecerlo en 1 (el valor predeterminado para la mayoría) permite el grupo, pero el hardware debe seguir siendo realmente compatible con él; activar una opción de la que carece la CPU actual se ignora, por lo que solo puedes restringir lo que se usa, nunca forzar la activación de una instrucción no compatible y provocar fallos. DOTNET_EnableHWIntrinsic=0 es la opción más drástica: desactiva todo desde la base, por lo que Vector128, Vector64 y Vector<T> indican que no hay aceleración y el código recurre a su ruta de software.

Importante

Se trata de herramientas de diagnóstico, pensadas principalmente para pruebas y validación: poner a prueba cada ruta de código, reproducir un problema específico del hardware o confirmar un mecanismo alternativo. No están diseñados para uso general o de producción, y no son un contrato de estabilidad. El siguiente conjunto es el que reconoce .NET 11; las versiones anteriores ofrecían un conjunto distinto —en particular, se reconfiguraron las opciones de línea base y de AVX-512—, así que confirma los nombres en función de la versión del entorno de ejecución a la que te diriges.

Estas herramientas también tienen límites en su alcance. Dado que condicionan las decisiones de JIT, no afectan al código que ya se había compilado previamente mediante ReadyToRun o Native AOT, y no afectan necesariamente a las rutinas internas que el propio entorno de ejecución y las bibliotecas principales usan por sí mismos. Trátelos como una forma de controlar su propio código compilado con JIT, no como un interruptor de desactivación global para un conjunto de instrucciones.

El conmutador base y los límites máximos de ancho se aplican en todas las arquitecturas:

Knob (DOTNET_ prefijo) Default Effect
EnableHWIntrinsic 1 Interruptor maestro para todas las funciones intrínsecas de hardware; 0 fuerza la ruta totalmente por software.
MaxVectorTBitWidth valor predeterminado del sistema Limita Vector<T> a un ancho máximo, en bits; un valor inferior a 128 indica que se usa el valor predeterminado del sistema.
PreferredVectorBitWidth valor predeterminado del sistema Limita el vector de ancho fijo máximo que notifica IsHardwareAccelerated, en bits; un valor inferior a 128 significa el valor predeterminado del sistema.

El valor predeterminado del sistema para MaxVectorTBitWidth puede ser más estrecho que el hardware totalmente compatible, por lo que Vector<T> no crece automáticamente hasta el vector más amplio disponible. Por ejemplo, Vector512<T>.IsHardwareAccelerated puede ser true mientras Vector<T> permanece en 256 bits; establezca DOTNET_MaxVectorTBitWidth=512 para usar Vector<T> con el ancho mayor.

PreferredVectorBitWidth limita el ancho de vector máximo que notifica IsHardwareAccelerated. Reducirlo por debajo de lo que admite el hardware desactiva los anchos mayores: en una máquina que admite vectores de 512 bits, DOTNET_PreferredVectorBitWidth=256 hace que Vector512<T>.IsHardwareAccelerated informe de false. Es un ajuste general, pero actualmente solo x86/x64 ofrece anchos superiores a 128, así que ese es el único caso en el que tiene un efecto observable.

Cada grupo lógico de conjuntos de instrucciones x86/x64 también tiene su propia opción:

Knob (DOTNET_ prefijo) Default Gates
EnableAVX 1 AVX y dependientes
EnableAVX2 1 AVX2, BMI1, BMI2, F16C, FMA, LZCNT, MOVBE y dependientes
EnableAVX512 1 AVX-512 F+BW+CD+DQ+VL y sus dependencias
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 (registros extendidos de uso general)
EnableAES 1 AES, PCLMULQDQ
EnableAVX512VP2INTERSECT 1 AVX-512 VP2INTERSECT
EnableAVXIFMA 1 AVX-IFMA
EnableAVXVNNI 1 AVX-VNNI
EnableAVXVNNIINT 1 VEX AVX-VNNI-INT8 y AVX-VNNI-INT16
EnableGFNI 1 GFNI
EnableSHA 1 SHA
EnableVAES 1 VAES, VPCLMULQDQ
EnableWAITPKG 1 WAITPKG
EnableX86Serialize 1 SERIALIZACIÓN DE X86

En Arm64, cada agrupación lógica de conjuntos de instrucciones tiene su propia opción:

Knob (DOTNET_ prefijo) Default Gates
EnableArm64Aes 1 Estándar de Cifrado Avanzado (AES)
EnableArm64Atomics 1 Operaciones atómicas de las extensiones de sistema grandes (LSE)
EnableArm64Crc32 1 CRC32
EnableArm64Dczva 1 DC ZVA puesta a cero de la caché
EnableArm64Dp 1 Dot Product
EnableArm64Rdm 1 Redondeo de multiplicación y acumulación con duplicación (RDM)
EnableArm64Sha1 1 SHA1
EnableArm64Sha256 1 SHA256
EnableArm64Rcpc 1 Ordenación coherente con la versión y coherente con el procesador (RCpc)
EnableArm64Rcpc2 1 RCpc2
EnableArm64Cssc 0 Compresión de secuencia corta común (CSSC)
EnableArm64Sve 1 Extensión de vector escalable (SVE)
EnableArm64Sve2 1 SVE2
EnableArm64Sha3 1 SHA3
EnableArm64Sm4 1 SM4
EnableArm64SveAes 1 SVE AES
EnableArm64SveSha3 1 SVE SHA3
EnableArm64SveSm4 1 SVE SM4

Un ajuste con valor predeterminado 0 (por ejemplo, EnableAVX10v2 o EnableArm64Cssc) controla un conjunto de instrucciones que aún está en proceso de activación, por lo que permanece desactivado hasta que decida activarlo.

Prueba comparativa para confirmar la victoria

La vectorización añade complejidad, así que mide si compensa antes de mantenerla. Use BenchmarkDotNet y use las mismas variables de entorno mostradas anteriormente para comparar las implementaciones escalares, Vector128y Vector256 en una sola ejecución. El diagnóstico de desensamblado de BenchmarkDotNet también puede emitir el ensamblado generado, que es muy valioso al optimizar el código de alto rendimiento.

Algunos aspectos que hay que tener en cuenta:

  • Cuanto mayores son las entradas, más se benefician. En el caso de los búferes pequeños, el código vectorizado puede ser más lento que el código escalar debido a la sobrecarga de configuración. Realice pruebas comparativas de los tamaños de entrada que usan los autores de las llamadas.
  • Las aceleraciones rara vez son perfectas. Un vector de 256 bits que opera con elementos de 32 bits no será de forma fiable 8 veces más rápido; el rendimiento de la memoria, la alineación y la latencia de las instrucciones influyen.
  • La alineación de la memoria afecta a la estabilidad. La alineación de asignación aleatoria agrega ruido entre ejecuciones. Puede asignar memoria alineada con AlignedAlloc para resultados estables o habilitar la selección aleatoria de memoria de BenchmarkDotNet para observar la distribución completa.

procedimientos recomendados

  • Alcance primero las API de nivel superior existentes. Span<T>, string, LINQ, TensorPrimitives y los tipos de tensor ya aceleran muchas operaciones comunes; no implementes manualmente lo que ya está optimizado y probado.
  • Empiece con Vector128<T>; está acelerado en la gama más amplia de hardware y no necesita Vector256<T> para obtener una implementación correcta y portable. Añada anchuras mayores e intrínsecos del hardware solo en las rutas críticas medidas.
  • Compruebe IsHardwareAccelerated y Count directamente en lugar de almacenarlos en caché; el JIT los convierte en constantes.
  • Controle siempre el resto del bucle y tenga en cuenta los búferes de origen y destino superpuestos al almacenar.
  • Escriba primero pruebas de casos límite, luego una solución escalar y, a continuación, exprese esa lógica escalar con las API vectoriales.
  • Pruebe cada ruta de ejecución del código (incluidas las violaciones de acceso) y evalúe el rendimiento con tamaños de entrada realistas antes de comprometerse con la complejidad adicional.

Véase también