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 comoSse,Avx,Avx2para arquiteturas Intel/AMD.System.Runtime.Intrinsics.Arm: Contém classes comoAdvSimdpara 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:
- Priorizar
Vector<T>para código portável e que se adapta auotmaticamente a diferentes larguras de registrador (SSE ou AVX2). - Utilizar Hardware Intrinsics (
Vector256<T>) apenas quando instruções específicas não mapeadas pelo JIT forem necessárias. - Utilizar
Span<T>comMemoryMarshalpara evitar alocações de memória ao converter tipos escalares em vetoriais.