Zig e SIMD: Processamento Vetorial de Alta Performance

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:

AspectoAuto-Vetorização@Vector Explícito
ControleO compilador decideVocê decide
PrevisibilidadePode variar entre buildsSempre vetorizado
PortabilidadeDepende do backendPortável por design
ComplexidadeZero — escreva código normalRequer raciocínio vetorial
PerformanceBoa 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.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.