SIMD com intrinsics: acelerando processamento vetorial

1. Introdução ao SIMD e Intrinsics em C

SIMD (Single Instruction, Multiple Data) é um paradigma de computação paralela onde uma única instrução opera simultaneamente sobre múltiplos dados. Em vez de processar um elemento por vez, o processador executa a mesma operação em um vetor inteiro de dados em um único ciclo de clock. Isso é especialmente útil em aplicações que envolvem grandes volumes de dados homogêneos, como processamento de imagem, áudio, simulações físicas e algoritmos de machine learning.

Intrinsics são funções especiais disponíveis em C que mapeiam diretamente para instruções SIMD do processador. Diferentemente do assembly inline, os intrinsics permitem que o compilador gerencie registro alocação, escalonamento de instruções e otimizações adicionais, resultando em código mais portável e de manutenção mais fácil. As principais extensões SIMD incluem SSE (SSE2, SSE4) e AVX (AVX2, AVX-512) para arquiteturas x86, e NEON para ARM.

2. Configuração do Ambiente e Tipos de Dados

Para utilizar intrinsics em C, é necessário incluir os headers apropriados:

#include <xmmintrin.h>   // SSE (128-bit floats)
#include <emmintrin.h>   // SSE2 (128-bit integers)
#include <pmmintrin.h>   // SSE3
#include <smmintrin.h>   // SSE4.1
#include <immintrin.h>   // AVX, AVX2, AVX-512
#include <arm_neon.h>    // ARM NEON

Os tipos de dados vetoriais representam registradores SIMD:

  • __m128: 4 floats (128 bits)
  • __m128d: 2 doubles (128 bits)
  • __m128i: 4 ints, 8 shorts, ou 16 chars (128 bits)
  • __m256: 8 floats (256 bits)
  • __m256i: 8 ints, 16 shorts, ou 32 chars (256 bits)

O alinhamento de memória é crucial para desempenho. Use _mm_malloc para alocar memória alinhada a 16 ou 32 bytes:

float* data = (float*)_mm_malloc(N * sizeof(float), 32);
// ... processamento ...
_mm_free(data);

3. Operações Aritméticas Básicas com Intrinsics

Vamos comparar uma soma de arrays com e sem SIMD:

Versão escalar:

void add_arrays_scalar(float* a, float* b, float* c, int n) {
    for (int i = 0; i < n; i++) {
        c[i] = a[i] + b[i];
    }
}

Versão SIMD com SSE:

#include <xmmintrin.h>

void add_arrays_sse(float* a, float* b, float* c, int n) {
    int i;
    for (i = 0; i <= n - 4; i += 4) {
        __m128 va = _mm_load_ps(&a[i]);
        __m128 vb = _mm_load_ps(&b[i]);
        __m128 vc = _mm_add_ps(va, vb);
        _mm_store_ps(&c[i], vc);
    }
    // Tail loop para elementos restantes
    for (; i < n; i++) {
        c[i] = a[i] + b[i];
    }
}

Para inteiros de 32 bits:

#include <emmintrin.h>

void add_int_arrays_sse(int* a, int* b, int* c, int n) {
    int i;
    for (i = 0; i <= n - 4; i += 4) {
        __m128i va = _mm_load_si128((__m128i*)&a[i]);
        __m128i vb = _mm_load_si128((__m128i*)&b[i]);
        __m128i vc = _mm_add_epi32(va, vb);
        _mm_store_si128((__m128i*)&c[i], vc);
    }
    for (; i < n; i++) {
        c[i] = a[i] + b[i];
    }
}

4. Carregamento, Armazenamento e Shuffling de Dados

O carregamento e armazenamento de dados são operações fundamentais:

// Load/store alinhados (requer alinhamento a 16 bytes)
__m128 va = _mm_load_ps(&array[i]);       // alinhado
_mm_store_ps(&array[i], va);

// Load/store não-alinhados (qualquer endereço)
__m128 vb = _mm_loadu_ps(&array[i]);      // não-alinhado
_mm_storeu_ps(&array[i], vb);

Shuffling permite rearranjar elementos dentro de um vetor:

__m128 v = _mm_set_ps(4.0f, 3.0f, 2.0f, 1.0f);  // v = {1, 2, 3, 4}
// Shuffle: pegar elementos nos índices especificados
__m128 shuffled = _mm_shuffle_ps(v, v, _MM_SHUFFLE(0, 1, 2, 3));
// Resultado: {4, 3, 2, 1}

Broadcast espalha um valor escalar para todas as posições:

float scalar = 3.14f;
__m128 vbroad = _mm_set1_ps(scalar);  // {3.14, 3.14, 3.14, 3.14}

5. Reduções, Comparações e Máscaras

Reduções horizontais somam elementos dentro de um vetor:

float horizontal_sum(__m128 v) {
    __m128 shuf = _mm_shuffle_ps(v, v, _MM_SHUFFLE(2, 3, 0, 1));
    __m128 sums = _mm_add_ps(v, shuf);
    shuf = _mm_movehl_ps(shuf, sums);
    sums = _mm_add_ss(sums, shuf);
    return _mm_cvtss_f32(sums);
}

Comparações geram máscaras de bits:

__m128 va = _mm_set_ps(1.0f, 2.0f, 3.0f, 4.0f);
__m128 vb = _mm_set_ps(2.0f, 2.0f, 2.0f, 2.0f);
__m128 mask = _mm_cmpgt_ps(va, vb);  // elementos > 2.0
// mask = {0, 0, -NaN, -NaN} (NaN representa true em float)

Blend condicional combina dois vetores baseado em máscara:

__m128 result = _mm_blendv_ps(va, vb, mask);
// Onde mask é 0, pega de va; onde mask é -NaN, pega de vb

6. Aplicações Práticas: Processamento de Imagem e Áudio

Conversão RGB para escala de cinza:

void rgb_to_gray_sse(const float* r, const float* g, const float* b,
                     float* gray, int n) {
    __m128 coeff_r = _mm_set1_ps(0.299f);
    __m128 coeff_g = _mm_set1_ps(0.587f);
    __m128 coeff_b = _mm_set1_ps(0.114f);

    int i;
    for (i = 0; i <= n - 4; i += 4) {
        __m128 vr = _mm_load_ps(&r[i]);
        __m128 vg = _mm_load_ps(&g[i]);
        __m128 vb = _mm_load_ps(&b[i]);

        __m128 vgray = _mm_add_ps(
            _mm_add_ps(_mm_mul_ps(vr, coeff_r), _mm_mul_ps(vg, coeff_g)),
            _mm_mul_ps(vb, coeff_b)
        );
        _mm_store_ps(&gray[i], vgray);
    }
    for (; i < n; i++) {
        gray[i] = 0.299f * r[i] + 0.587f * g[i] + 0.114f * b[i];
    }
}

Normalização de áudio:

void normalize_audio_sse(float* samples, int n, float max_amplitude) {
    __m128 vmax = _mm_set1_ps(max_amplitude);
    int i;
    for (i = 0; i <= n - 4; i += 4) {
        __m128 vs = _mm_load_ps(&samples[i]);
        __m128 vdiv = _mm_div_ps(vs, vmax);
        _mm_store_ps(&samples[i], vdiv);
    }
    for (; i < n; i++) {
        samples[i] /= max_amplitude;
    }
}

7. Otimizações Avançadas e Armadilhas Comuns

Loop unrolling: Processar múltiplos blocos por iteração reduz overhead do loop:

for (i = 0; i <= n - 16; i += 16) {
    __m128 va0 = _mm_load_ps(&a[i]);
    __m128 va1 = _mm_load_ps(&a[i+4]);
    __m128 va2 = _mm_load_ps(&a[i+8]);
    __m128 va3 = _mm_load_ps(&a[i+12]);
    // ... processamento ...
}

Tratamento de bordas: Sempre inclua um tail loop para elementos restantes quando o tamanho não for múltiplo do vetor.

Armadilhas comuns:
- Dependências de dados: evite operações que dependem do resultado imediato da instrução anterior
- False sharing: alinhe estruturas compartilhadas entre threads
- Microbenchmarking incorreto: use warm-up e múltiplas iterações para medições precisas

8. Comparação entre Plataformas: x86 vs ARM (NEON)

Soma vetorial em x86 (SSE):

__m128 va = _mm_load_ps(a);
__m128 vb = _mm_load_ps(b);
__m128 vc = _mm_add_ps(va, vb);
_mm_store_ps(c, vc);

Equivalente em ARM NEON:

#include <arm_neon.h>

float32x4_t va = vld1q_f32(a);
float32x4_t vb = vld1q_f32(b);
float32x4_t vc = vaddq_f32(va, vb);
vst1q_f32(c, vc);

Para código portável entre plataformas, use macros de abstração ou bibliotecas como SLEEF (SIMD Library for Evaluating Elementary Functions) e Highway. Exemplo de macro:

#if defined(__SSE__)
    #include <xmmintrin.h>
    #define VEC_LOAD _mm_load_ps
    #define VEC_ADD _mm_add_ps
    typedef __m128 vec_float;
#elif defined(__ARM_NEON)
    #include <arm_neon.h>
    #define VEC_LOAD vld1q_f32
    #define VEC_ADD vaddq_f32
    typedef float32x4_t vec_float;
#endif

SIMD com intrinsics oferece ganhos significativos de desempenho (tipicamente 2x a 8x) para operações vetoriais em C. Dominar essas técnicas é essencial para desenvolvedores que buscam extrair o máximo desempenho do hardware moderno.

Referências