Otimização de Soma de Arrays com SIMD: Uso de Intrinsics AVX em C# e Comparação com C++

Com o lançamento do .NET Core 3.0, a Microsoft introduziu suporte nativo a funções intrínsecas (Hardware Intrinsics), permitindo que desenvolvedores C# acesssem instruções específicas da CPU para otimização de performance. Este artigo explora o uso de vetores de largura fixa, como Vector256<T>, utilizando o conjunto de instruções AVX (Advanced Vector Extensions) para acelerar operações aritméticas em arrays de ponto flutuante.

Acessando Hardware Intrinsics no .NET

Diferente do tipo Vector<T>, que possui largura variável conforme o hardware, os tipos no namespace System.Runtime.Intrinsics (como Vector128<T> e Vector256<T>) representam registradores de tamanho fixo. Para operar esses vetores, utilizamos classes estáticas que mapeiam diretamente para instruções de assembly, localizadas nos namespaces:

  • System.Runtime.Intrinsics.X86: Contém classes como Sse, Avx, Avx2 para arquiteturas Intel/AMD.
  • System.Runtime.Intrinsics.Arm: Contém classes como AdvSimd para arquiteturas ARM.

Cada método nessas classes, como Avx.Add(), corresponde a uma instrução específica (ex: VADDPS). Para entender o comportamento detalhado, desenvolvedores costumam consultar o "Intel Intrinsics Guide", que fornece o pseudocódigo da operação realizada no nível do silício.

Implementação em C# com AVX

Para garantir a compatibilidade e performance, utilizamos diretivas de compilação e verificamos o suporte do hardware em tempo de execução via Avx.IsSupported.

Soma básica com Vector256

A implementação inicial envolve carregar os dados manualmente para o vetor. Como versões anteriores ao .NET 7 possuem limitações na criação de vetores a partir de spans ou arrays, a carga inicial pode ser feita via Vector256.Create.


private static float SomaSimdAvx(float[] dados, int tamanho, int iteracoes) {
#if NETCOREAPP3_0_OR_GREATER
    if (!Avx.IsSupported) throw new NotSupportedException("AVX não suportado.");

    float resultadoFinal = 0;
    int larguraVetor = Vector256<float>.Count;
    int blocos = tamanho / larguraVetor;
    int restante = tamanho % larguraVetor;

    Vector256<float> acumuladorVetorial = Vector256<float>.Zero;

    // Pré-carregamento para simular processamento pesado
    Vector256<float>[] vetoresFonte = new Vector256<float>[blocos];
    for (int i = 0; i < blocos; i++) {
        int idx = i * larguraVetor;
        vetoresFonte[i] = Vector256.Create(
            dados[idx], dados[idx+1], dados[idx+2], dados[idx+3],
            dados[idx+4], dados[idx+5], dados[idx+6], dados[idx+7]
        );
    }

    for (int j = 0; j < iteracoes; j++) {
        for (int i = 0; i < blocos; i++) {
            acumuladorVetorial = Avx.Add(acumuladorVetorial, vetoresFonte[i]);
        }
        
        // Processa elementos restantes
        int baseIdx = blocos * larguraVetor;
        for (int k = 0; k < restante; k++) {
            resultadoFinal += dados[baseIdx + k];
        }
    }

    // Reduce: Soma os elementos internos do registrador
    for (int i = 0; i < larguraVetor; i++) {
        resultadoFinal += acumuladorVetorial.GetElement(i);
    }

    return resultadoFinal;
#else
    throw new PlatformNotSupportedException();
#endif
}

Otimização com Span e MemoryMarshal

A alocação de um novo array de vetores gera overhead. Podemos usar MemoryMarshal.Cast para reinterpretar o array de float como um ReadOnlySpan<Vector256<float>>, eliminando cópias desnecessárias.


private static float SomaSimdAvxSpan(float[] dados, int tamanho, int iteracoes) {
    float acc = 0;
    int step = Vector256<float>.Count;
    int blocos = tamanho / step;
    Vector256<float> vAcc = Vector256<float>.Zero;

    ReadOnlySpan<Vector256<float>> vSpan = System.Runtime.InteropServices.MemoryMarshal.Cast<float, Vector256<float>>(dados);

    for (int j = 0; j < iteracoes; j++) {
        for (int i = 0; i < blocos; i++) {
            vAcc = Avx.Add(vAcc, vSpan[i]);
        }
    }

    for (int i = 0; i < step; i++) acc += vAcc.GetElement(i);
    return acc;
}

Uso de Ponteiros (Unsafe Code)

Para performance máxima, similar ao C++, podemos utilizar o carregamento direto da memória via ponteiros.


private unsafe static float SomaSimdAvxUnsafe(float[] dados, int tamanho, int iteracoes) {
    fixed (float* pDados = dados) {
        float total = 0;
        int step = Vector256<float>.Count;
        int blocos = tamanho / step;
        Vector256<float> vTotal = Vector256<float>.Zero;

        for (int j = 0; j < iteracoes; j++) {
            float* pAtual = pDados;
            for (int i = 0; i < blocos; i++) {
                var vLoad = Avx.LoadVector256(pAtual);
                vTotal = Avx.Add(vTotal, vLoad);
                pAtual += step;
            }
        }

        for (int i = 0; i < step; i++) total += vTotal.GetElement(i);
        return total;
    }
}

Comparação com C++

Em C++, o uso de intrinsics é similar, exigindo a inclusão de immintrin.h. O compilador Visual C++ (MSVC) oferece quase a mesma interface que o .NET.


#include <immintrin.h>

float SomaAvxCpp(const float* dados, size_t n, int loops) {
    float res = 0;
    size_t largura = 8; // 256 bits / 32 bits (float)
    size_t blocos = n / largura;
    __m256 vAcc = _mm256_setzero_ps();

    for (int j = 0; j < loops; j++) {
        const float* p = dados;
        for (size_t i = 0; i < blocos; i++) {
            __m256 vData = _mm256_load_ps(p);
            vAcc = _mm256_add_ps(vAcc, vData);
            p += largura;
        }
    }

    float* fArr = (float*)&vAcc;
    for (size_t i = 0; i < largura; i++) res += fArr[i];
    return res;
}

Análise de Resultados e Conclusão

Testes realizados em processadores Intel de 8ª geração mostram que tanto o C# quanto o C++ atingem níveis de performence quase idênticos ao utilizar intrinsics AVX de forma direta. Em cenários de soma de arrays, a aceleração pode chegar a 8x em relação ao código escalar (loop simples).

As melhores práticas para o uso de SIMD no ecossistema .NET incluem:

  1. Priorizar Vector<T> para código portável e que se adapta auotmaticamente a diferentes larguras de registrador (SSE ou AVX2).
  2. Utilizar Hardware Intrinsics (Vector256<T>) apenas quando instruções específicas não mapeadas pelo JIT forem necessárias.
  3. Utilizar Span<T> com MemoryMarshal para evitar alocações de memória ao converter tipos escalares em vetoriais.

Tags: .NET SIMD AVX C# C++

Publicado em 6-23 17:23