Utilizar SIMD e intrínsecos do hardware no .NET

SIMD (instrução única, múltiplos dados) é suporte de hardware para aplicar uma operação a múltiplos dados em paralelo com uma única instrução. O código vetorizado processa vários valores por iteração em vez de apenas um, o que pode aumentar significativamente a taxa de processamento em cargas de trabalho numéricas, científicas, gráficas, de processamento de texto e paralelas de dados, do tipo em que a mesma operação se repete ao longo de um buffer. A contrapartida é o aumento da complexidade, pelo que isso compensa sobretudo quando os dados de entrada são suficientemente grandes e o ganho é confirmado por medições.

.NET oferece vários tipos de suporte SIMD. Escolhe aquele que corresponde ao controlo que precisas e à complexidade que estás disposto a assumir.

Tipos de suporte a SIMD em .NET

API Namespace Quando utilizá-lo
Tipos de vetores e matrizes de propósito fixo System.Numerics Matemática gráfica e geométrica com vetores de 2-4 elementos, matrizes, quaternions e planos.
Vector<T> System.Numerics Vetorização portátil de largura variável quando não precisa de controlo por plataforma.
Vector64<T>, Vector128<T>, Vector256<T>, Vector512<T> System.Runtime.Intrinsics Vetorização multiplataforma de largura fixa com controlo de granulação fina. Este é o ponto de partida recomendado para novos algoritmos vetorizados.
Funções intrínsecas de hardware System.Runtime.Intrinsics.X86, System.Runtime.Intrinsics.Arm, System.Runtime.Intrinsics.Wasm Instruções específicas do processador que as APIs de nível superior não expõem, para obter o máximo desempenho possível numa secção crítica.
TensorPrimitives System.Numerics.Tensors Operações matemáticas vetorizadas pré-definidas sobre spans. Faz a vetorização por ti.

Quando estas APIs se sobrepõem, relacionam-se através de camadas de abstração. Os tipos de vetores genéricos são os tipos fundamentais de troca que as outras camadas utilizam, pelo que são tecnicamente os de nível mais baixo: o Vector<T> de largura variável, que aumenta até à largura suportada pelo hardware em utilização, e os Vector64<T> a Vector512<T> de largura fixa. As intrínsecas de hardware específicas da plataforma em System.Runtime.Intrinsics.X86, System.Runtime.Intrinsics.Arm e System.Runtime.Intrinsics.Wasm operam sobre esses tipos, correspondendo cada uma diretamente a uma instrução individual do processador. As operações multiplataforma expostas nos tipos genéricos situam-se um nível acima das intrínsecas específicas da plataforma, sendo convertidas para estas em cada plataforma de destino. Num nível ainda superior estão as APIs geridas que operam sobre buffers completos — métodos vetorizados em Span<T>, string e TensorPrimitives — que se baseiam nas camadas abaixo, o que lhe permite obter aceleração SIMD sem ter de escrever nada disso manualmente. Os System.Numerics tipos de forma fixa são tipos utilitários específicos do domínio para computação gráfica e geometria, e não parte desta pilha de intercâmbio.

O resto deste artigo aborda estas APIs do nível mais alto ao mais baixo, depois aborda testes, benchmarking e melhores práticas.

Tipos de vetores e matrizes System.Numerics

O System.Numerics namespace fornece tipos acelerados por SIMD com uma forma fixa:

Estes tipos correspondem naturalmente a gráficos e geometria, e o runtime acelera as suas operações com instruções SIMD onde o hardware as suporta. O exemplo seguinte soma dois vetores:

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

Também disponibilizam as operações comuns de matemática vetorial que se esperam, tais como produto escalar, distância e limitação:

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

Os tipos de matrizes suportam matemática matricial, como transposição e multiplicação:

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

Vetor<T>

Vector<T> representa um vetor de largura variável de um tipo numérico primitivo. O seu comprimento é fixo durante a vida útil do processo, mas o valor de Vector<T>.Count depende da CPU que executa o código. O compilador Just-In-Time (JIT) trata Count como uma constante, pelo que os ciclos que o usam são bem otimizados.

Vector<T> Dá-te vetorização portátil sem código por plataforma, ao custo de não saberes a largura do vetor em tempo de compilação. O exemplo seguinte calcula a adição elemento a elemento de dois 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;
}

Observação

Este exemplo é ilustrativo. Raramente precisas de escrever um loop assim à mão, porque TensorPrimitives já fornece matemática acelerada baseada em span. Esta adição elemento a elemento é Add, e reduções como Sum também são fornecidas. Estas operações são aceleradas por hardware para os tipos de elementos que Vector<T> suportam (Vector<T>.IsSupported).

Verificar se existe aceleração por hardware

Os tipos acelerados por SIMD funcionam mesmo em configurações de hardware ou JIT que não suportam SIMD, porque recorrem a implementações de software não aceleradas. Para determinar se a aceleração está realmente disponível, verifique a propriedade IsHardwareAccelerated relevante:

Estas propriedades são transformadas em constantes pelo JIT, por isso as ramificações que não tomas são eliminadas e não há custo de execução para as verificar. Não guardes os valores em cache; Lê-os diretamente onde precisares. O mesmo se aplica às Count propriedades (por exemplo, Vector128<T>.Count), que também são constantes de tempo JIT.

A maioria das operações com largura acelerada são também aceleradas, mas tal não é garantido em todas as operações. Por exemplo, a divisão em ponto flutuante pode ser acelerada onde a divisão inteira não é. Quando Vector256 está acelerado, Vector128 normalmente também é, mas não há garantias, por isso verifica cada largura que usas.

Sugestão

Se uma operação de que precisas não for acelerada numa plataforma de que gostas, ou se quiseres uma nova API multiplataforma, apresenta uma questão no dotnet/runtime. O mesmo se aplica a melhorias no codegen.

Nem todos os tipos de elementos são válidos para todos os vetores. Vector128<T> e os seus semelhantes suportam hoje os tipos numéricos primitivos (byte, sbyte, short, ushort, int, uint, long, ulong, float, double, nint e nuint), e esse conjunto poderá vir a crescer para incluir outros tipos no futuro. Usar Vector128<T>.IsSupported para determinar se um dado T é válido, o que é especialmente útil a partir de código genérico.

Tipos que não são suportados, como char e bool, podem ainda ser vetorizados reinterpretando o buffer como um tipo suportado do mesmo tamanho. Use Cast para reinterpretar um span — por exemplo, char para ushort — ou o método As<TFrom, TTo> do vetor para reinterpretar um vetor que já tem. A reinterpretação apenas altera o tipo, não os bits subjacentes, pelo que é da sua responsabilidade manter os dados bem formados: um bool tem de continuar a ser 0 ou 1, e um char tem de continuar a ser uma unidade de código UTF-16 válida. Se uma operação vetorizada puder produzir um valor fora de alcance, tenha o cuidado de normalizar o resultado antes de o reescrever.

Vetorização entre plataformas com Vector128

Vector128<T> é o denominador comum em todas as plataformas que suportam vetorização, por isso é o melhor ponto de partida. Contém um vetor de 128 bits: 16 bytes, 8 shorts, 4 ints/floats, ou 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> é duas vezes mais larga, e Vector512<T> duas vezes mais. Nem todo o hardware suporta larguras maiores, por isso os exemplos que se seguem usam Vector128 para portabilidade.

Cada largura tem um tipo genérico (Vector128<T>) para os dados e uma classe estática não genérica (Vector128) que contém a maioria das operações, incluindo métodos estáticos de fábrica como Create e Load. Operadores como +, & e << são a forma idiomática de expressar operações aritméticas e sobre bits; prefira-os aos equivalentes com nome para evitar erros de precedência de operadores e melhorar a legibilidade. Para algoritmos que dependem da ordem dos bytes, ramifica-se em IsLittleEndian, que o JIT também dobra para uma constante.

Observação

Em x86/x64, Vector256<T> as operações são geralmente tratadas como duas "lanes" independentes de 128 bits. Para a maioria das operações elemento a elemento, isto é transparente, mas as operações que cruzam vias (como permutações ou operações par a par/horizontais) podem comportar-se de forma diferente ou ter um custo superior ao do Vector128 equivalente. Confirme com benchmarks antes de assumir que um vetor mais largo é mais rápido.

As operações de cruzamento de vias não se expandem automaticamente

As operações elemento a elemento não se preocupam com a largura: v1 + v2 o resultado por elemento é o mesmo se v1 e v2 são Vector128<T> ou Vector256<T>— o alargamento processa apenas mais uma faixa de dados por instrução. Add em v = [a, b, c, d] e w = [e, f, g, h] sempre combina elementos do mesmo índice:

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

As operações de cruzamento de faixas não escalam assim tão facilmente, porque quais elementos são combinados depende da largura do vetor. Uma redução par a par combina elementos adjacentes em vez dos de igual índice, pelo que a ampliação muda quais os elementos que acabam emparelhados entre si:

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)

É exatamente isso que uma redução horizontal faz: soma os elementos de um vetor com duas rondas de adições pares. Em x86/x64, Vector128<float> (4 elementos) leva-o lá com duas chamadas para 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();
}

Estenda o mesmo padrão de duas chamadas a Vector256<float> (8 elementos) e parece correto — mas não está. HorizontalAdd não opera em todo o vetor de 256 bits; repete o padrão par a par independentemente dentro de cada faixa de 128 bits. Duas iterações produzem a soma da metade inferior (elementos 0-3), replicada em toda a metade inferior, e a soma da metade superior (elementos 4-7), replicada em toda a metade superior, não a soma de todos os oito elementos:

// 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 obter o total correto, faça explicitamente a ligação entre faixas no limite entre elas: leia a soma parcial de cada faixa com GetLower/GetUpper e adicione-as — GetLower e GetUpper dividem um vetor na primeira e na segunda metade:

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

Esse passo extra é o verdadeiro custo de atravessar faixas. Um algoritmo de cruzamento de faixas não se alarga gratuitamente como um algoritmo elemento a elemento—mede antes de assumir que o vetor mais largo vence.

Operações comuns

Vector128 e as suas variantes mais abrangentes oferecem uma API extensa. Não precisas de memorizar—conhece as categorias e procura os detalhes quando precisares. Cada operação tem um recurso de software para plataformas que não conseguem acelerá-la. A tabela seguinte cobre essencialmente toda a superfície.

Categoria O que faz APIs representativas
Constants Vetores constantes pré-definidos Zero, One, NegativeOne, AllBitsSet, Indices, SignSequence, E, Pi, Tau, Epsilon, NaN, PositiveInfinity, NegativeInfinity, NegativeZero
Criação Transmitir um escalar, definir elementos ou gerar uma sequência Create, CreateScalar, CreateScalarUnsafe, Create(ReadOnlySpan<T>), CreateSequence, CreateGeometricSequence, CreateHarmonicSequence, CreateAlternatingSequence
Carregar e armazenar Mover dados entre memória e um vetor Load, LoadUnsafe, LoadAligned, LoadAlignedNonTemporal, Store, StoreUnsafe, StoreAligned, StoreAlignedNonTemporal, CopyTo, TryCopyTo
Arithmetic Operações matemáticas elemento a elemento e reduções Add (x + y), Subtract (x - y), Multiply (x * y), Divide (x / y), Negate (-x), AddSaturate, SubtractSaturate, Abs, Sqrt, FusedMultiplyAdd, Dot, Sum
Operações de bits Lógica bit a bit e deslocamentos 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 e clamp Clampagem mínima, máxima e de alcance elemento a elemento Min, Max, Clamp, MinMagnitude, MaxMagnitudeMinNumber, MaxNumber, MinMagnitudeNumber, MaxMagnitudeNumber
Arredondamento Arredonda cada elemento para um valor integral Ceiling, Floor, Round, Truncate
Funções Matemáticas Signo, interpolação, ângulo e auxiliares transcendentais CopySign, Lerp, DegreesToRadians, RadiansToDegrees, Hypot, Sin, Cos, SinCos, Asin, Exp, Log, Log2
Comparação Compare cada elemento; O resultado é uma máscara vetorial, não o bool que um operador dá Equals, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual
Classification Predicados por elemento sobre a reta numérica, cada um dos quais devolve um vetor de máscara IsNaN, IsFinite, IsInfinity, IsPositiveInfinity, IsNegativeInfinity, IsInteger, IsEvenInteger, IsOddInteger, IsNegative, IsPositive, IsNormal, IsSubnormal, IsZero
Reduções comparativas Reduza uma comparação por elemento a uma única bool EqualsAll (x == y), EqualsAny, GreaterThanAll, GreaterThanAny, GreaterThanOrEqualAll, GreaterThanOrEqualAny, LessThanAll, LessThanAny, LessThanOrEqualAll, LessThanOrEqualAny
Predicados de vetor completo Reduza um vetor a um bool: se todos, quaisquer ou nenhum elemento é igual a um valor, ou (as WhereAllBitsSet formas) têm todos os bits definidos. Prefira estas opções em vez de converter uma máscara num índice All, Any, None, AllWhereAllBitsSet, AnyWhereAllBitsSet, NoneWhereAllBitsSet
Pesquisa Contar ou localizar elementos por valor ou (as formas WhereAllBitsSet) definir vias numa máscara Count, IndexOf, LastIndexOf, CountWhereAllBitsSet, IndexOfWhereAllBitsSet, LastIndexOfWhereAllBitsSet
Mascarar para indexação Transforma uma máscara de comparação numa bitmask escalar e analisa-a ExtractMostSignificantBits com TrailingZeroCount ou LeadingZeroCount
Seleção Misturar dois vetores de acordo com uma máscara, pouco a pouco ConditionalSelect(x, y, z), equivalente a (y & x) \| (z & ~x)
Conversion Alterar o tipo numérico, calcular novos valores (por exemplo, int para float) ConvertToInt32, ConvertToInt64, ConvertToUInt32, ConvertToUInt64, ConvertToSingle, ConvertToDouble
Alargamento e estreitamento Divida os elementos num tipo mais largo ou agrupe-os num tipo mais estreito Widen, WidenLower, WidenUpper, Narrow, NarrowWithSaturation
Reinterpretação Reinterprete os bits como outro tipo de elemento sem os alterar As<TFrom, TTo>, AsByte, AsInt32, AsSingle e as outras formas de elemento As*
Interoperabilidade do System.Numerics Reinterpretar entre Vector128<T> e os tipos numéricos de formato fixo AsVector, AsVector2, AsVector3, AsVector4, AsPlane, AsQuaternion, AsVector128, AsVector128Unsafe
Reorder Rearranjar elementos por índice ou intercalar dois vetores Shuffle, Reverse, Zip, ZipLower, ZipUpper, Unzip, UnzipEven, UnzipOdd, ConcatLowerLower, ConcatLowerUpper, ConcatUpperLower, ConcatUpperUpper
Acesso por via Ler ou substituir elementos e metades individuais, ou redimensionar o vetor GetElement, WithElement, ToScalar, GetLower, GetUpper, WithLower, WithUpper, ToVector256

Sugestão

Várias operações têm Estimate e Native variantes — por exemplo, MultiplyAddEstimate, ClampNative, MinNative, MaxNative, ShuffleNative, e ConvertToInt32Native. Elas correspondem a uma instrução de hardware mais rápida que troca alguma precisão ou oferece uma garantia de casos limites IEEE (como o manuseamento de NaN), por isso só os recorra quando um benchmark mostra que a forma exata é o gargalo e que a semântica mais frouxa é aceitável.

Observação

Vector256.Shuffle trata a sua entrada como um único vetor de 256 bits, enquanto a plataforma específica Avx2.Shuffle opera como duas vias independentes de 128 bits. A API multiplataforma é a opção mais portátil, mas confirme o comportamento de que necessita ao adaptar intrínsecos escritos manualmente.

Estrutura do caminho do código

Um método vetorizado normalmente expande-se num caminho por largura vetorial mais um recurso escalar para entradas pequenas e hardware não acelerado. Para usar o maior vetor suportado pelo hardware, verifique primeiro o vetor mais largo e desça:

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

A guarda exterior de cada largura combina Vector128.IsHardwareAccelerated (uma constante de tempo JIT para saber se a plataforma acelera essa largura) com Vector128<T>.IsSupported (se o tipo T de elemento é válido para essa largura). Dentro de um bloco suportado, compare o comprimento da entrada com Count para escolher entre a via vetorizada e uma alternativa para entradas pequenas. O método é genérico em relação a T, e os blocos Vector256 e Vector512 — idênticos ao bloco Vector128, mas usando o tipo mais abrangente — são mostrados comentados por razões de brevidade.

Existem duas alternativas distintas. Um buffer que é demasiado pequeno até para o vetor mais estreito, mas em hardware acelerado, vai para SumVectorSmall—uma tabela de salto explícita switch que lida com cada possível comprimento de subvetor sem um loop:

// 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) é também uma constante conhecida em tempo de JIT, pelo que SumVectorSmall faz o despacho com base na largura do elemento para uma tabela dimensionada para o número de elementos do vetor mais largo, a mesma abordagem que TensorPrimitives utiliza. (Quando o novo modelo de segurança de memória é ativado, sizeof(T) expressões num parâmetro de tipo com a unmanaged restrição são permitidas no código seguro.) Apenas a tabela de 4 bytes é mostrada; as tabelas de 1, 2 bytes e 8 bytes partilham a sua forma. Os seus casos maiores tratam o remanescente com um Vector256 ou Vector128 usando duas leituras sobrepostas — uma do início, outra do fim —, de modo que o tratamento do remanescente maior para os caminhos omitidos Vector512/Vector256 fica diretamente na tabela de saltos. As duas cargas sobrepõem-se sempre que o comprimento não é um múltiplo exato da largura, por isso a cauda é mascarada à identidade aditiva com ConditionalSelect antes de ser somada. Essa máscara só é necessária porque a adição não é idempotente; uma operação idempotente, como uma pesquisa, poderia dobrar diretamente a cauda sobreposta. Um buffer em hardware sem qualquer vetorização passa para SumScalar, um ciclo escalar normal.

Percorra os dados de entrada e processe o restante

Para processar um buffer maior do que um único vetor, passa por cima dele um vetor de cada vez, depois trata dos elementos restantes que não preenchem um vetor completo. A forma robusta de lidar com essa cauda é reprocessar o último conjunto completo de elementos correspondente a um vetor, sobrepondo alguns aos que o loop já processou — o que evita um epílogo escalar à parte. Se essa sobreposição precisa de ser corrigida depende da operação.

Uma operação não idempotente, como uma soma, contaria os elementos sobrepostos duas vezes, por isso masque-os para o elemento neutro da operação antes de os combinar. Use isto quando cada elemento tiver de contribuir exatamente uma 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 versão protege o loop desenrolado com um if, pelo que blocos de dados pequenos contornam por completo os quatro acumuladores e passam diretamente para o processamento restante. Quando há dados suficientes, a do/while acumula quatro vetores por iteração em acumuladores independentes — o que permite ao processador pipelinear as adições — e combina-as em pares. Uma switch tabela de saltos incorpora depois os restantes zero a três vetores completos e, em case 0, a cauda do subvetor: reutiliza um vetor completo pré-carregado a partir do fim do buffer, sobrepondo-se aos elementos já processados, e mascara essa sobreposição, reduzindo-a à identidade aditiva com ConditionalSelect, para que a cauda permaneça vetorizada em vez de recorrer a um ciclo escalar. Como antes, Vector128.Create lê elementos Vector128<T>.Count do span. O JIT omite a verificação de limites do span em padrões de acesso típicos, pelo que Create é uma boa opção predefinida mesmo num ciclo crítico; LoadUnsafe (abordado de seguida) é a alternativa de nível mais baixo para quando se percorre o buffer através de uma referência gerida. Para entradas muito grandes, uma implementação completa também alinharia o buffer e usaria cargas e armazenamentos não temporais para evitar despejar linhas de cache úteis — ambas omitidas aqui e totalmente cobertas por TensorPrimitives.

Uma operação idempotente, como a pesquisa de um valor, pode reprocessar a sobreposição sem qualquer problema, pelo que incorpora diretamente o último vetor, sem 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;
}

Warning

A má gestão do restante é uma fonte comum de bugs. Um ciclo que lê para além do fim do buffer produz resultados não determinísticos e pode crashar. O conjunto de testes do ambiente de execução utiliza um auxiliar BoundedMemory que coloca uma página inacessível imediatamente após o buffer, o que faz com que qualquer leitura para além dos limites gere um AccessViolationException durante os testes. Abrange sempre a lógica dos elementos remanescentes, incluindo os buffers cujo comprimento não é múltiplo da largura do vetor.

Carregar e armazenar vetores de forma segura

Para a maioria do código, Vector128.Create(span) e CopyTo são a forma mais simples de mover dados entre um espaço e um vetor, e o JIT mantém-nos eficientes. Quando precisar das operações de carregamento e armazenamento de nível inferior — por exemplo, para percorrer um buffer através de uma referência gerida — dê preferência às sobrecargas LoadUnsafe e StoreUnsafe que recebem uma referência gerida e um deslocamento de elementos nuint. Ao contrário das sobrecargas baseadas Load/Store em ponteiros, não requerem fixar o buffer e, ao contrário da aritmética de referência bruta, não precisam que avances manualmente um ref. Ambas as alternativas são fáceis de errar de formas que introduzem buracos no coletor de lixo ou violações de acesso.

Para que buffers vazios não lançam, obtenha a referência inicial de GetReference (ou GetArrayDataReference para arrays) em vez de ref span[0].

Importante

A aritmética de deslocamento usa inteiros sem sinal nuint. Verifique sempre o comprimento do buffer antes de calcular um deslocamento como buffer.Length - Vector128<int>.Count. Se o buffer for menor do que um vetor, essa subtração flui para um valor enorme e o ciclo lê memória inválida.

Intrínsecas de hardware específicas da plataforma

Quando uma instrução específica do processador lhe dá uma vantagem que as APIs portáteis não expõem, procure as intrínsecas de hardware em System.Runtime.Intrinsics.X86, System.Runtime.Intrinsics.Arm, e System.Runtime.Intrinsics.Wasm. Cada classe intrínseca tem uma propriedade IsSupported (também uma constante JIT), para poderes proteger a via especializada e recorrer a código portátil nos restantes casos:

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

O método anterior mostra como iluminar caminhos de código por arquitetura quando os queres, mas é deliberadamente um exemplo simples: na verdade, não precisas disso aqui. A expressão (vector & mask) == Vector128<byte>.Zero portátil já desce para a instrução ótima em cada plataforma (por exemplo, ptest em x86/x64), por isso está a fazer o mesmo trabalho que as ramificações manuscritas, só que sem a complexidade. Procure intrínsecos explícitos apenas quando uma instrução específica supera de forma mensurável o que as APIs portáteis geram.

Os intrínsecos de hardware requerem uma implementação separada para cada conjunto de instruções, pelo que devem ser encarados como uma otimização para percursos críticos identificados por medição, em vez de serem a opção predefinida. As Vector128/Vector256 APIs já reduzem para instruções eficientes em cada plataforma e, na prática, o código sofisticado por instrução nem sempre vence. Confirme a diferença com um benchmark antes de se comprometer com a manutenção extra.

Matemática de nível superior com TensorPrimitives

Se precisares de matemática vetorizada em intervalos e não quiseres escrever tu próprio as iterações, TensorPrimitives fornece um grande conjunto de operações numéricas — operações aritméticas elemento a elemento, exponenciais e reduções, como o produto escalar e a similaridade de cosseno — que já são vetorizadas internamente. Está disponível no pacote 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);

Para cargas de trabalho de IA e numéricas, TensorPrimitives oferece frequentemente grande parte das vantagens do SIMD escrito manualmente, sem qualquer complexidade.

Testar todos os caminhos de código

Uma vez que um método vetorizado tem vários caminhos de código, os testes precisam de cobrir cada um deles: o caminho Vector256, o caminho Vector128 e o caminho escalar, cada um com entradas suficientemente grandes e entradas demasiado pequenas para dele tirar partido. Podes variar o tamanho da entrada nos testes, mas não podes alternar a aceleração por hardware ao nível do teste. Em vez disso, controle-o com variáveis de ambiente antes de o processo começar:

  • Defina DOTNET_EnableAVX2=0 para que Vector256.IsHardwareAccelerated devolva false.
  • Definir DOTNET_EnableHWIntrinsic=0 para desativar completamente os intrínsecos, por isso Vector128, Vector64, e Vector<T> todos não reportam aceleração.

Para exercitar todos os caminhos numa única máquina, execute o conjunto de testes uma vez sem sobrescrituras, uma vez com DOTNET_EnableAVX2=0, e outra com DOTNET_EnableHWIntrinsic=0. A alternativa é executá-los em hardware suficientemente variado para os abranger.

Botões de configuração do conjunto de instruções

Para além desses dois, o runtime reconhece um botão por agrupamento lógico de conjuntos de instruções, cada um com prefixo DOTNET_. Um único botão pode cobrir vários conjuntos de instruções relacionados —EnableAVX2 por exemplo, portas AVX2 juntamente com BMI1, BMI2, F16C, FMA, LZCNT e MOVBE. Ao definir um controlo para 0, desativa todo o seu grupo e tudo o que estiver por cima dele. Definir para 1 (o padrão para a maioria) permite o grupo, mas o hardware tem de o suportar — ativar um botão que o CPU atual não tem é ignorado, por isso só podes restringir o que é usado, nunca forçar uma instrução não suportada e quebrar-te. DOTNET_EnableHWIntrinsic=0 é a solução mais drástica — desativa tudo até ao nível mais básico, por isso Vector128, Vector64 e Vector<T> deixam todos de assinalar qualquer aceleração e o código recorre à via de software.

Importante

Estas são ferramentas de diagnóstico, destinadas sobretudo a testes e validação — percorrendo cada caminho de execução do código, reproduzindo um problema específico do hardware ou confirmando um mecanismo de recurso. Não são concebidos para uso geral ou de produção, nem são um contrato de estabilidade. O conjunto seguinte é o que o .NET 11 reconhece; versões anteriores expuseram um conjunto diferente — os botões base e AVX-512 foram reconfigurados — por isso confirme os nomes com a versão de runtime que pretende.

Estas ferramentas também têm limites no que atingem. Como bloqueiam decisões JIT, não afetam código que já foi compilado antecipadamente através do ReadyToRun ou do AOT nativo, e não afetam necessariamente rotinas internas que as bibliotecas de runtime e core usam. Trata-os como uma forma de orientar o teu próprio código compilado just-in-time (JIT), não como um mecanismo global para desativar um conjunto de instruções.

O interruptor base e os limites de largura aplicam-se a todas as arquiteturas:

Botão (DOTNET_ prefixo) Default Effect
EnableHWIntrinsic 1 Comutador mestre para todos os elementos intrínsecos do hardware; 0 Força o caminho totalmente software.
MaxVectorTBitWidth Predefinição do sistema Limita Vector<T> a uma largura máxima em bits; um valor inferior a 128 significa o valor predefinido do sistema.
PreferredVectorBitWidth Predefinição do sistema Limita o vetor máximo de largura fixa que reporta IsHardwareAccelerated, em bits; um valor inferior a 128 significa o padrão do sistema.

A predefinição do sistema para MaxVectorTBitWidth pode ser mais estreita do que o hardware suporta na totalidade, pelo que Vector<T> não aumenta automaticamente até ao vetor mais largo disponível. Por exemplo, Vector512<T>.IsHardwareAccelerated pode ser true enquanto Vector<T> permanece com 256 bits; defina DOTNET_MaxVectorTBitWidth=512 para que Vector<T> utilize a largura maior.

PreferredVectorBitWidth define como limite a largura máxima do vetor reportada por IsHardwareAccelerated. Reduzi-lo para um valor inferior ao suportado pelo hardware desativa as larguras de vetor maiores: numa máquina que suporta vetores de 512 bits, DOTNET_PreferredVectorBitWidth=256 faz com que Vector512<T>.IsHardwareAccelerated indique false. É um botão geral, mas só o x86/x64 oferece larguras acima de 128 hoje em dia, por isso é o único sítio onde tem um efeito observável.

Cada agrupamento lógico de conjuntos de instruções x86/x64 também tem o seu próprio comutador:

Botão (prefixo DOTNET_) Default Gates
EnableAVX 1 AVX e dependentes
EnableAVX2 1 AVX2, BMI1, BMI2, F16C, FMA, LZCNT, MOVBE e dependentes
EnableAVX512 1 AVX-512 F+BW+CD+DQ+VL e dependentes
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 (registos de uso geral estendidos)
EnableAES 1 AES, PCLMULQDQ
EnableAVX512VP2INTERSECT 1 AVX-512 VP2INTERSECT
EnableAVXIFMA 1 AVX-IFMA
EnableAVXVNNI 1 AVX-VNNI
EnableAVXVNNIINT 1 VEX AVX -VNNI-INT8 e AVX -VNNI-INT16
EnableGFNI 1 GFNI
EnableSHA 1 SHA
EnableVAES 1 VAES, VPCLMULQDQ
EnableWAITPKG 1 WAITPKG
EnableX86Serialize 1 X86 SERIALIZAR

No Arm64, cada agrupamento lógico de conjuntos de instruções tem o seu próprio interruptor:

Manípulo (prefixo DOTNET_) Default Gates
EnableArm64Aes 1 AES
EnableArm64Atomics 1 Atómicas de Extensões de Grandes Sistemas (LSE)
EnableArm64Crc32 1 CRC32
EnableArm64Dczva 1 DC ZVA Zeramento em cache
EnableArm64Dp 1 Produto escalar
EnableArm64Rdm 1 Arredondamento, Duplicação, Multiplicação, Acumulação (RDM)
EnableArm64Sha1 1 SHA1
EnableArm64Sha256 1 SHA256
EnableArm64Rcpc 1 Ordenação consistente no lançamento e consistência no processador (RCpc)
EnableArm64Rcpc2 1 RCpc2
EnableArm64Cssc 0 Compressão de Sequências Curtas Comuns (CSSC)
EnableArm64Sve 1 Extensão Vetorial Escalável (SVE)
EnableArm64Sve2 1 SVE2
EnableArm64Sha3 1 SHA3
EnableArm64Sm4 1 SM4
EnableArm64SveAes 1 SVE AES
EnableArm64SveSha3 1 SVE SHA3
EnableArm64SveSm4 1 SVE SM4

Um botão que por defeito é 0 (por exemplo, EnableAVX10v2 ou EnableArm64Cssc) bloqueia um conjunto de instruções que ainda está online, por isso fica desligado até te inscreveres.

Teste de desempenho para confirmar a vitória

A vetorização acrescenta complexidade, por isso verifica se compensa antes de a manter. Utilize o BenchmarkDotNet e as mesmas variáveis de ambiente apresentadas anteriormente para comparar, numa única execução, as implementações escalar, Vector128 e Vector256. O diagnóstico de desmontagem do BenchmarkDotNet também pode emitir o conjunto gerado, o que é inestimável ao ajustar código de alto desempenho.

Alguns aspetos a ter em conta:

  • Entradas maiores beneficiam mais. Para buffers pequenos, o código vetorizado pode ser mais lento do que o código escalar devido à sobrecarga de configuração. Compare os tamanhos de entrada que os seus chamadores realmente usam.
  • As acelerações raramente são perfeitas. Um vetor de 256 bits a operar sobre elementos de 32 bits não será necessariamente 8 vezes mais rápido; a largura de banda da memória, o alinhamento e a latência das instruções também influenciam.
  • O alinhamento da memória afeta a estabilidade. O alinhamento da alocação aleatorizada adiciona ruído entre execuções. Pode alocar memória alinhada com AlignedAlloc para obter resultados estáveis, ou ativar a aleatorização da memória do BenchmarkDotNet para observar a distribuição completa.

Melhores práticas

  • Procure primeiro as APIs de nível mais alto existentes. Span<T>, string, LINQ, TensorPrimitives, e os tipos de tensores já aceleram por si muitas operações comuns — não implemente manualmente o que já está otimizado e testado.
  • Comece com Vector128<T>; tem aceleração na mais vasta gama de hardware e não precisa de Vector256<T> para obter uma implementação correta e portátil. Adicione tipos de maior largura e instruções intrínsecas de hardware apenas para os percursos críticos identificados por medição.
  • Verificar IsHardwareAccelerated e Count diretamente em vez de os armazenar em cache; o JIT transforma-os em constantes.
  • Manuse-se sempre o restante do ciclo e tenha em conta a sobreposição dos buffers de origem e destino ao armazenar.
  • Escreve primeiro testes de casos limite, depois uma solução escalar, e depois expressa essa lógica escalar com as APIs vetoriais.
  • Testa todos os caminhos de código (incluindo violações de acesso) e faz benchmarks de tamanhos realistas de entrada antes de te comprometeres com a complexidade adicional.

Ver também