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
- Intel Intrinsics Guide — Documentação oficial completa de todas as intrinsics Intel SSE/AVX/AVX-512, com descrições, latências e exemplos.
- ARM NEON Intrinsics Reference — Referência oficial da ARM para intrinsics NEON, incluindo tipos, operações e exemplos para arquiteturas ARM.
- Agner Fog's Optimization Manuals — Manuais detalhados sobre otimização em C++/C incluindo SIMD, microarquitetura de processadores e técnicas avançadas de programação vetorial.
- SLEEF: SIMD Library for Evaluating Elementary Functions — Biblioteca open-source que implementa funções matemáticas elementares usando SIMD, com suporte a SSE, AVX, AVX-512 e NEON.
- Highway: Performance-portable SIMD library — Biblioteca da Google para programação SIMD portável entre x86 e ARM, com abstrações de alto desempenho e suporte a múltiplas extensões.
- CppCon Talk: "SIMD in C++" by Jason Turner — Palestra técnica sobre uso de SIMD em C/C++ com exemplos práticos, embora focada em C++ os conceitos se aplicam diretamente a C.