Profiling em Zig: perf, Flamegraphs e Gargalos Reais

Otimizar um programa Zig sem profiling é uma forma sofisticada de adivinhar. A linguagem dá controle fino sobre memória, alocadores, chamadas C, layout de dados e modos de compilação, mas esse controle só vira vantagem quando você mede onde o tempo realmente está sendo gasto. Em muitos projetos, o gargalo não está no trecho que parece mais elegante ou mais feio. Está em uma conversão de string repetida, em uma alocação dentro de loop, em uma syscall pequena demais, em parsing redundante, em cache miss ou em uma escolha ruim de modo de build.

Este guia mostra um fluxo prático de profiling em Zig para Linux: preparar um binário útil para análise, capturar amostras com perf, transformar o resultado em flamegraph, cruzar CPU com alocação e decidir uma correção pequena. Ele complementa os textos sobre benchmarking em Zig, observabilidade, servidor HTTP em produção, ferramentas internas para DevOps e systemd em produção. Se o seu time também mantém serviços em Rust, vale comparar a disciplina de medição com referências de ecossistema como rustlang.com.br, porque a regra é a mesma: profile antes de reescrever.

Benchmark não substitui profiling

Benchmark responde “ficou mais rápido ou mais lento?”. Profiling responde “onde o programa está gastando tempo?”. Os dois são complementares.

Um benchmark pequeno é ótimo para comparar duas versões de uma função, validar uma regressão ou proteger um caminho crítico no CI. O problema é que ele pode medir uma situação artificial: dados pequenos demais, cache quente demais, entrada limpa demais ou ausência de concorrência. Já o profiling observa o programa executando uma carga representativa e aponta a distribuição real de tempo entre funções, syscalls e bibliotecas.

Use esta ordem mental:

  1. Defina a carga que importa.
  2. Meça a linha de base.
  3. Rode profiling nessa carga.
  4. Mude uma coisa por vez.
  5. Repita o benchmark para confirmar.

Sem linha de base, qualquer “otimização” vira narrativa. Em Zig isso é especialmente perigoso porque é fácil trocar Debug por ReleaseFast, remover checks, mudar allocator ou usar ponteiros mais agressivos e acreditar que a solução veio da linguagem, quando talvez o problema fosse I/O, formato de dados ou uma chamada externa.

Compile um binário analisável

Para profiling de CPU no Linux, comece com símbolos preservados. Um binário totalmente stripado pode até executar igual, mas deixa o flamegraph quase inútil.

Um ponto de partida razoável:

zig build -Doptimize=ReleaseSafe

ReleaseSafe costuma ser melhor para investigação inicial porque mantém checks relevantes e se aproxima mais de produção que Debug. Se o serviço roda em ReleaseFast, rode também uma captura nesse modo, mas não comece removendo toda proteção antes de entender o gargalo.

Evite comparar resultados de modos diferentes como se fossem a mesma coisa:

ModoUso no profiling
Debugbom para encontrar bugs, ruim para estimar performance real
ReleaseSafebom equilíbrio para investigar gargalo com checks
ReleaseFastútil para validar teto de performance com menos proteção
ReleaseSmallútil quando tamanho de binário/cache de instrução importa

Se o projeto usa build.zig, documente o comando exato no README ou no script de benchmark. O próximo perfil só será comparável se a carga, o modo e a versão do compilador forem conhecidos.

Capture CPU com perf

Em uma máquina Linux, perf é o ponto de partida mais direto. Ele usa amostragem: em vez de instrumentar cada chamada, coleta snapshots periódicos da pilha enquanto o programa roda. Isso reduz overhead e mostra padrões reais.

Exemplo para uma CLI:

perf record -F 99 -g -- zig-out/bin/minha-ferramenta processar dados.jsonl
perf report

Para um serviço já rodando:

pidof minha-api
sudo perf record -F 99 -g -p <PID> -- sleep 60
sudo perf report

O -F 99 define a frequência de amostragem. O -g captura call stacks. O sleep 60 delimita a janela. Em produção, escolha uma janela curta e uma carga conhecida; não rode profiling pesado sem combinar com a operação do serviço.

No relatório, procure funções com peso alto e interprete o contexto. Uma função no topo pode ser problema real, mas também pode ser apenas a função que chama todo o resto. Por isso flamegraphs são mais fáceis de ler que tabelas planas.

Transforme em flamegraph

Flamegraph mostra pilhas como blocos horizontais. Largura significa tempo acumulado. Altura significa profundidade da chamada. O bloco mais largo nem sempre é “culpado”, mas é onde vale investigar primeiro.

Um fluxo comum:

perf script > perf.out
stackcollapse-perf.pl perf.out > folded.out
flamegraph.pl folded.out > flamegraph.svg

Abra o SVG no navegador e procure:

  • torres muito largas em parsing, logging, serialização ou cópia de bytes;
  • chamadas repetidas de allocator dentro de caminho quente;
  • tempo inesperado em libc, regex, compressão, TLS ou syscalls;
  • funções pequenas aparecendo milhares de vezes por causa de loop externo;
  • diferença grande entre caminho feliz e caminho de erro.

Em Zig, é comum descobrir que o problema não é “Zig lento”, mas um desenho de dados. Exemplo: transformar cada linha de um arquivo em várias strings alocadas, quando slices temporários bastariam; criar ArrayList novo para cada item; chamar std.fmt.allocPrint em loop; ou validar JSON completo quando só dois campos determinam o roteamento.

Profile alocação, não só CPU

CPU alta pode ser sintoma de alocação excessiva. Zig ajuda porque alocador é explícito, mas explícito não significa automaticamente barato.

Comece auditando perguntas simples:

  • existe alocação dentro do loop principal?
  • cada request cria buffers novos ou reutiliza arena por request?
  • o GeneralPurposeAllocator está sendo usado em produção sem necessidade?
  • ArrayList cresce com capacidade previsível ou realoca muitas vezes?
  • strings são copiadas quando slices resolveriam?

Para ferramentas e jobs, uma ArenaAllocator por unidade de trabalho pode simplificar liberação e reduzir fragmentação. Para serviços long-running, a arena precisa ter escopo claro: por request, por batch ou por conexão. Arena global que nunca reseta é vazamento com outro nome.

Um padrão útil em handlers é receber allocator de escopo curto:

fn handleRequest(parent_allocator: std.mem.Allocator, body: []const u8) !Response {
    var arena = std.heap.ArenaAllocator.init(parent_allocator);
    defer arena.deinit();

    const allocator = arena.allocator();
    const parsed = try parsePayload(allocator, body);
    return try buildResponse(allocator, parsed);
}

Isso não é “sempre melhor”. É melhor quando a vida dos objetos acompanha a vida da request. Se um objeto precisa sobreviver em cache, fila ou estado global, copie para outro allocator de propósito claro.

Monte uma carga representativa

Profiling com entrada pequena mente. Se o serviço processa JSONL de 2 GB, não profile com 200 linhas. Se a API sofre com payloads inválidos, inclua erros. Se o worker roda em disco lento, não use só /tmp em memória.

Uma boa carga de profiling deve declarar:

  • tamanho dos arquivos ou taxa de requests;
  • proporção de sucesso e erro;
  • concorrência usada;
  • hardware ou container onde rodou;
  • modo de build;
  • commit do binário;
  • tempo de aquecimento antes da captura.

Para API HTTP local, você pode combinar wrk, hey, oha ou outra ferramenta de carga com perf. O importante é não misturar variáveis demais: se mudou payload, concorrência, commit e modo de build ao mesmo tempo, você não sabe qual fator moveu o resultado.

Corrija uma coisa pequena

Depois do flamegraph, resista à tentação de reescrever o módulo inteiro. Bons ganhos costumam vir de mudanças pequenas:

  • mover parsing para fora de loop;
  • reservar capacidade com ensureTotalCapacity;
  • trocar cópia por slice quando o buffer original vive tempo suficiente;
  • reduzir logs síncronos em caminho quente;
  • agrupar writes pequenos;
  • usar arena por request;
  • evitar formatação de string quando o log está desabilitado;
  • trocar estrutura de dados por layout mais cache-friendly.

Cada mudança deve responder: “qual bloco do flamegraph eu espero reduzir?”. Se a resposta é vaga, você está otimizando estética, não performance.

Checklist para profiling em Zig

Antes de declarar vitória, passe por esta lista:

  • o gargalo foi observado em carga representativa;
  • o comando de build foi registrado;
  • o flamegraph ou perf report foi salvo;
  • a mudança foi pequena e revisável;
  • o benchmark comparou antes e depois;
  • os testes ainda passam;
  • o modo de produção continua com safety adequado ao risco;
  • a conclusão diferencia CPU, memória, I/O e rede.

Profiling bom muda a conversa do time. Em vez de “Zig é rápido” ou “precisamos reescrever em C/Rust/Go”, você passa a dizer: “60% do tempo está em serialização, 18% em alocação por request, 9% em syscall de escrita pequena; vamos atacar a primeira causa e medir de novo”. Essa é a vantagem real de Zig em sistemas: controle suficiente para corrigir gargalos, mas só depois que a medição mostrou onde eles existem.

Continue aprendendo Zig

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