Skip to content

SIMD e desempenho

Esta é a área em que Zig se destaca e o que atrai quem vem de motores de consulta, bancos colunares e código numérico. Não porque Zig tenha um truque secreto de velocidade, mas porque ele remove as camadas que ficam entre o seu código e o processador, e devolve a você o controle de layout, alinhamento e instruções vetoriais que uma linguagem com runtime pesado esconde. Desempenho, aqui, é menos sobre otimização esperta e mais sobre não pagar por abstrações que você não pediu.

SIMD, “uma instrução, muitos dados”, é a capacidade do processador de aplicar a mesma operação a um punhado de valores de uma vez, num único ciclo. Zig expõe isso com o tipo @Vector, uma abstração portátil que o compilador mapeia para as instruções SIMD do alvo, SSE e AVX no x86, NEON no ARM, e para código escalar comum onde não houver suporte.

vetor.zig
const V = @Vector(4, f32);
pub fn main() void {
const a: V = .{ 1, 2, 3, 4 };
const b: V = .{ 10, 20, 30, 40 };
const soma = a + b; // uma instrução, quatro somas
const total = @reduce(.Add, soma); // 4+... reduzido a um escalar
std.debug.print("{d}\n", .{total});
}

O que impressiona é que os operadores comuns funcionam: a + b soma as quatro pistas ao mesmo tempo, e não é um laço disfarçado, é uma instrução de máquina. Em volta dos operadores há um punhado de builtins que formam o vocabulário de SIMD em Zig:

BuiltinO que faz
a + b, a * b, …operam em todas as pistas de uma vez
@splat(x)transforma um escalar num vetor com todas as pistas iguais a x
@reduce(.Add, v)colapsa o vetor num escalar por uma operação associativa
@shuffle(...)reorganiza as pistas de um ou dois vetores numa ordem nova
@select(pred, a, b)escolhe pista a pista entre dois vetores, sem if

É esse vocabulário que permite processar uma coluna inteira de um banco quatro, oito ou dezesseis valores por vez, e é a razão de motores como DuckDB extraírem tanto de tão pouco.

O ganho de SIMD não é automático, e é aqui que os assuntos anteriores da trilha voltam a cobrar. As instruções vetoriais esperam dados contíguos e frequentemente alinhados, o alinhamento que a linguagem deixa você controlar. Se os valores que você quer somar estão espalhados pela memória, dentro de structs entremeados com outros campos, o processador gasta mais tempo juntando-os do que somando-os. É a diferença entre guardar os dados como um array de structs e como um struct de arrays: para percorrer uma coluna, o segundo layout é o que permite o SIMD render. Zig não escolhe o layout por você, e essa é justamente a alavanca.

Cache e desvios: o que faz rápido ser rápido

Section titled “Cache e desvios: o que faz rápido ser rápido”

Boa parte do desempenho moderno não é sobre fazer menos contas, é sobre alimentar o processador sem que ele espere. A hierarquia de cache, o custo de um acesso à memória principal e o preço de uma previsão de desvio errada estão descritos no nível de sistema, e valem a leitura porque governam o que otimizar. Dados contíguos e percorridos em ordem mantêm a cache quente; saltos aleatórios pela memória a esfriam e param o processador. Um desvio condicional que o preditor erra descarta trabalho já adiantado, e é por isso que código sem ramos, usando @select para escolher sem if, às vezes vence código “mais inteligente” cheio de condicionais. A regra prática é uma só: meça, não adivinhe. O std.time.Timer e um benchmark honesto revelam gargalos que a intuição erra com frequência desconcertante, porque o custo real mora na cache e no preditor, não na contagem de operações.

Concorrência: threads, atômicos e o novo I/O

Section titled “Concorrência: threads, atômicos e o novo I/O”

A concorrência de baixo nível em Zig é enxuta e explícita, coerente com o resto. std.Thread.spawn cria uma thread do sistema operacional, std.Thread.Mutex protege memória compartilhada, e std.atomic.Value(T) mais os builtins como @atomicRmw dão as operações atômicas com as quais se constrói coordenação sem lock. A teoria por trás disso, o que é uma race condition, por que um mutex resolve e o que um atômico garante, é o assunto da trilha de concorrência, que também posiciona Zig no mapa dos modelos; não vale reescrevê-la aqui.

O que vale registrar é o estado do modelo assíncrono, porque ele mudou de forma instrutiva. Zig teve async e await como palavras-chave, e os removeu: a conclusão foi que amarrar a linguagem a corrotinas sem pilha não servia à gama de alvos que Zig quer atender, do microcontrolador ao servidor de milhões de conexões. O rumo atual, que aparece parcialmente na linha 0.15 e ganha corpo na 0.16, é tratar I/O como se trata alocação. Assim como uma função recebe um Allocator, ela passa a receber um std.Io, e é esse objeto que decide se a espera é bloqueante, num pool de threads, ou em corrotinas. O efeito é dissolver o problema das “funções coloridas”: o mesmo código roda síncrono ou assíncrono conforme o Io que você entrega, sem duas versões de cada função. É o princípio de “não esconder nada” levado à espera: a I/O deixa de ser um poder ambiente e vira um parâmetro, exatamente como a memória.

Conceitos: SIMD, vetorização, alinhamento, layout de memória, array de structs vs struct de arrays, cache, previsão de desvio, código sem ramos, medir vs adivinhar Linguagem: @Vector, @reduce, @splat, @shuffle, @select, std.time.Timer Concorrência: std.Thread, std.Thread.Mutex, std.atomic.Value, @atomicRmw, std.Io, funções coloridas Hardware: SSE, AVX, NEON, hierarquia de cache, preditor de desvio