Quando falamos em alta performance, processamento vetorial com SIMD (Single Instruction, Multiple Data) é uma das técnicas mais poderosas disponíveis. Diferente de linguagens como C, onde você precisa lidar com intrinsics específicos de cada arquitetura, a linguagem Zig oferece suporte nativo a SIMD através do tipo @Vector, tornando o código portável e expressivo ao mesmo tempo.
Neste artigo, vamos explorar como usar SIMD em Zig — desde operações básicas até exemplos práticos de processamento de imagens e busca em strings.
O que é SIMD e Por que Importa?
SIMD permite que uma única instrução do processador opere sobre múltiplos dados simultaneamente. Em vez de somar dois números por vez, você soma 4, 8 ou até 16 números em um único ciclo de clock.
Na prática, isso significa que operações como:
- Processamento de pixels em imagens
- Parsing de dados (JSON, CSV)
- Cálculos científicos e matemáticos
- Criptografia e hashing
- Física em jogos (veja nosso artigo sobre Zig e Raylib)
…podem ser 4x a 16x mais rápidas quando vetorizadas corretamente.
O Tipo @Vector em Zig
Em Zig, vetores SIMD são cidadãos de primeira classe. O tipo @Vector(len, T) cria um vetor de tamanho fixo que mapeia diretamente para registradores SIMD da CPU:
const std = @import("std");
pub fn main() void {
// Vetor de 4 floats — mapeia para registradores SSE/NEON
const a: @Vector(4, f32) = .{ 1.0, 2.0, 3.0, 4.0 };
const b: @Vector(4, f32) = .{ 5.0, 6.0, 7.0, 8.0 };
// Soma vetorial: uma instrução, 4 operações
const soma = a + b; // { 6.0, 8.0, 10.0, 12.0 }
// Multiplicação elemento a elemento
const produto = a * b; // { 5.0, 12.0, 21.0, 32.0 }
std.debug.print("Soma: {any}\n", .{soma});
std.debug.print("Produto: {any}\n", .{produto});
}
O compilador de Zig automaticamente gera as instruções SIMD corretas para a arquitetura alvo — SSE/AVX no x86, NEON no ARM, ou emula em software quando necessário.
Operações Vetoriais Fundamentais
Operações Aritméticas
Todas as operações aritméticas básicas funcionam naturalmente com @Vector:
const v1: @Vector(8, i32) = .{ 1, 2, 3, 4, 5, 6, 7, 8 };
const v2: @Vector(8, i32) = .{ 10, 20, 30, 40, 50, 60, 70, 80 };
const soma = v1 + v2; // Adição
const diff = v2 - v1; // Subtração
const prod = v1 * v2; // Multiplicação
const shift = v1 << @as(@Vector(8, u5), @splat(2)); // Shift left
Comparações Vetoriais
Comparações retornam vetores booleanos, úteis para filtragem:
const dados: @Vector(8, f32) = .{ 1.0, -2.0, 3.0, -4.0, 5.0, -6.0, 7.0, -8.0 };
const zeros: @Vector(8, f32) = @splat(0.0);
// Máscara: quais elementos são positivos?
const positivos = dados > zeros; // { true, false, true, false, true, false, true, false }
// Selecionar apenas positivos, zerando negativos
const resultado = @select(f32, positivos, dados, zeros);
// { 1.0, 0.0, 3.0, 0.0, 5.0, 0.0, 7.0, 0.0 }
@reduce: Operações Horizontais
O builtin @reduce combina todos os elementos de um vetor em um único valor:
const v: @Vector(4, f32) = .{ 1.0, 2.0, 3.0, 4.0 };
const soma_total = @reduce(.Add, v); // 10.0
const produto_total = @reduce(.Mul, v); // 24.0
const maximo = @reduce(.Max, v); // 4.0
const minimo = @reduce(.Min, v); // 1.0
Isso é extremamente útil para calcular médias, normas de vetores e dot products.
@shuffle: Reorganizando Lanes
O @shuffle permite reorganizar os elementos de um ou dois vetores — essencial para transpor matrizes, intercalar dados ou implementar algoritmos de sorting em SIMD:
const a: @Vector(4, i32) = .{ 10, 20, 30, 40 };
const b: @Vector(4, i32) = .{ 50, 60, 70, 80 };
// Intercalar elementos dos dois vetores
const intercalado = @shuffle(i32, a, b, [4]i32{ 0, -1, 1, -2 });
// Índices positivos = vetor a, negativos = vetor b (invertido e -1)
// Resultado: { 10, 50, 20, 60 }
// Reverter um vetor
const reverso = @shuffle(i32, a, undefined, [4]i32{ 3, 2, 1, 0 });
// { 40, 30, 20, 10 }
Exemplo Prático: Ajuste de Brilho em Imagem
Vamos aplicar SIMD para ajustar o brilho de uma imagem (representada como array de bytes):
const std = @import("std");
fn ajustarBrilho(pixels: []u8, ajuste: i16) void {
const chunk_size = 16; // Processar 16 bytes por vez
var i: usize = 0;
// Processar em blocos de 16 pixels com SIMD
while (i + chunk_size <= pixels.len) : (i += chunk_size) {
// Carregar 16 pixels no vetor
const chunk: @Vector(chunk_size, u8) = pixels[i..][0..chunk_size].*;
// Converter para i16 para evitar overflow
const wide: @Vector(chunk_size, i16) = @intCast(chunk);
const ajuste_vec: @Vector(chunk_size, i16) = @splat(ajuste);
// Aplicar ajuste com saturação (clamp entre 0 e 255)
const resultado = wide + ajuste_vec;
const max_vec: @Vector(chunk_size, i16) = @splat(255);
const min_vec: @Vector(chunk_size, i16) = @splat(0);
const clamped = @min(@max(resultado, min_vec), max_vec);
// Converter de volta para u8 e salvar
pixels[i..][0..chunk_size].* = @intCast(clamped);
}
// Processar pixels restantes (escalar)
while (i < pixels.len) : (i += 1) {
const val = @as(i16, pixels[i]) + ajuste;
pixels[i] = @intCast(std.math.clamp(val, 0, 255));
}
}
Este padrão — processar blocos com SIMD e tratar o restante escalarmente — é o idioma fundamental de qualquer código SIMD.
Exemplo Prático: Busca de Byte em String
Encontrar um caractere em uma string pode ser drasticamente acelerado com SIMD:
fn encontrarByte(haystack: []const u8, needle: u8) ?usize {
const chunk_size = 16;
var offset: usize = 0;
while (offset + chunk_size <= haystack.len) : (offset += chunk_size) {
const chunk: @Vector(chunk_size, u8) = haystack[offset..][0..chunk_size].*;
const alvo: @Vector(chunk_size, u8) = @splat(needle);
// Comparar todos os 16 bytes de uma vez
const match = chunk == alvo;
// Se algum match, encontrar a posição
if (@reduce(.Or, match)) {
// Converter máscara booleana para encontrar primeiro match
inline for (0..chunk_size) |i| {
if (match[i]) return offset + i;
}
}
}
// Busca escalar nos bytes restantes
for (haystack[offset..], offset..) |byte, idx| {
if (byte == needle) return idx;
}
return null;
}
Em benchmarks, essa abordagem processa 16 bytes por iteração em vez de 1, resultando em speedups de 8-12x em strings longas.
Alinhamento de Memória
Para obter máxima performance SIMD, o alinhamento de memória é crucial. Zig facilita isso:
// Array alinhado a 32 bytes (para AVX)
var dados: [1024]u8 align(32) = undefined;
// Verificar alinhamento em runtime
fn processarAlinhado(ptr: [*]align(16) const u8, len: usize) void {
// Garantido: ptr está alinhado a 16 bytes
// O compilador pode usar instruções alinhadas (movaps vs movups)
_ = ptr;
_ = len;
}
Leituras e escritas alinhadas são significativamente mais rápidas em algumas arquiteturas — e Zig facilita declarar e verificar alinhamento em compile-time.
Auto-Vetorização vs SIMD Explícito
O compilador de Zig (baseado no LLVM) também pode auto-vetorizar loops simples. Mas a vetorização explícita com @Vector oferece vantagens:
| Aspecto | Auto-Vetorização | @Vector Explícito |
|---|---|---|
| Controle | O compilador decide | Você decide |
| Previsibilidade | Pode variar entre builds | Sempre vetorizado |
| Portabilidade | Depende do backend | Portável por design |
| Complexidade | Zero — escreva código normal | Requer raciocínio vetorial |
| Performance | Boa para loops simples | Ótima para qualquer padrão |
Para código crítico de performance, o SIMD explícito é preferível. Para o restante, confie na auto-vetorização.
Portabilidade entre Arquiteturas
Uma grande vantagem da abordagem de Zig sobre C intrinsics é a portabilidade. O mesmo código @Vector funciona em:
- x86/x86_64: SSE, SSE2, SSE4.1, AVX, AVX2, AVX-512
- ARM/AArch64: NEON, SVE
- RISC-V: Vector Extension (RVV)
- WebAssembly: SIMD128 (relevante se você usar Zig com WebAssembly)
Em C, você precisaria de #ifdef para cada arquitetura e intrinsics completamente diferentes (_mm_add_ps vs vaddq_f32). Em Zig, é simplesmente a + b.
Para estratégias avançadas de gerenciamento de memória que complementam SIMD, veja nosso artigo sobre alocação de memória em Zig.
Quando SIMD Vale a Pena?
SIMD não é bala de prata. Use quando:
✅ Sim: operações uniformes sobre arrays grandes (imagens, áudio, dados científicos) ✅ Sim: parsing de dados (JSON, CSV, protobuf) ✅ Sim: criptografia e hashing ✅ Sim: compressão e descompressão
❌ Não: lógica condicional complexa por elemento ❌ Não: dados com acesso aleatório (cache misses dominam) ❌ Não: arrays pequenos (overhead de setup > ganho)
Para garantir que seu código SIMD realmente melhora a performance, combine com ferramentas de profiling e escreva testes robustos para validar a corretude.
Comparação com Outras Linguagens
Em C, SIMD requer intrinsics específicos (<immintrin.h>) que são verbosos e não-portáveis. Em Rust, existe a crate std::simd (nightly) e packed_simd, mas a ergonomia ainda é inferior ao @Vector de Zig. Go praticamente não oferece suporte a SIMD sem assembly inline.
Se você está vindo de outra linguagem de sistemas, veja nossas comparações detalhadas: Zig vs Rust, Zig vs C++ e Zig vs Go.
Para comparar como outras linguagens de sistemas abordam performance em baixo nível, confira também o Rust Lang Brasil e o Go Lang Brasil.
Conclusão
O suporte nativo a SIMD em Zig é um dos recursos mais elegantes da linguagem. Com @Vector, @shuffle, @reduce e @select, você pode escrever código vetorial de alta performance que é portável entre arquiteturas e legível — algo quase impossível com intrinsics de C.
Se você está começando com Zig, recomendamos primeiro entender comptime e error handling antes de mergulhar em otimizações SIMD. E para consultas rápidas, use nosso cheatsheet de arrays e slices que cobre a base de trabalho com dados sequenciais em Zig.