Medir performance de forma confiável é uma das habilidades mais valiosas para quem trabalha com programação de sistemas. Em Zig, a combinação de controle fino sobre a memória, comptime e modos de compilação otimizados cria um ambiente ideal para benchmarks reprodutíveis — sem depender de frameworks externos.
Neste guia, vamos construir benchmarks práticos do zero, entender como evitar armadilhas do compilador e integrar ferramentas de profiling como Tracy para identificar gargalos reais.
Por que Benchmark em Zig é Diferente
Diferente de linguagens com runtime pesado, Zig compila diretamente para código de máquina sem garbage collector, overhead de abstração ou JIT. Isso significa que seus benchmarks medem o código real — não artefatos do runtime. Mas também significa que o otimizador pode eliminar código que parece “sem efeito colateral”, invalidando seus resultados.
A biblioteca padrão de Zig oferece primitivas específicas para contornar esse problema, como veremos a seguir.
Medindo Tempo com std.time.Timer
O ponto de partida para qualquer benchmark é a medição precisa de tempo. O std.time.Timer usa o relógio monotônico do sistema operacional:
const std = @import("std");
pub fn main() !void {
var timer = try std.time.Timer.start();
// Código a ser medido
var soma: u64 = 0;
for (0..1_000_000) |i| {
soma += i;
}
const elapsed = timer.read();
std.debug.print("Resultado: {}\n", .{soma});
std.debug.print("Tempo: {d:.3}ms\n", .{@as(f64, @floatFromInt(elapsed)) / 1_000_000.0});
}
Execute com o modo de release desejado:
zig build-exe benchmark.zig -OReleaseFast
./benchmark
O Timer retorna nanosegundos, oferecendo resolução suficiente para microbenchmarks. Sempre imprima o resultado da computação para evitar que o compilador elimine o cálculo inteiro.
Evitando Armadilhas do Otimizador
O maior erro em benchmarks é medir código que o compilador removeu silenciosamente. Se o resultado de uma computação não é usado, o otimizador em -OReleaseFast pode eliminar todo o loop. A solução é std.mem.doNotOptimizeAway:
const std = @import("std");
fn fibonacci(n: u32) u64 {
if (n <= 1) return n;
var a: u64 = 0;
var b: u64 = 1;
for (2..n + 1) |_| {
const temp = a + b;
a = b;
b = temp;
}
return b;
}
pub fn main() !void {
var timer = try std.time.Timer.start();
for (0..100_000) |_| {
const result = fibonacci(30);
std.mem.doNotOptimizeAway(result);
}
const elapsed = timer.read();
std.debug.print("Média por iteração: {d:.1}ns\n", .{
@as(f64, @floatFromInt(elapsed)) / 100_000.0,
});
}
A função doNotOptimizeAway cria uma barreira de otimização sem custo real em runtime — ela apenas informa ao compilador que o valor é “utilizado”, impedindo a eliminação de código morto.
Estruturando Benchmarks Reprodutíveis
Para resultados confiáveis, um benchmark precisa de aquecimento, múltiplas iterações e análise estatística. Veja uma estrutura robusta:
const std = @import("std");
fn runBenchmark(comptime func: anytype, args: anytype, iterations: u32) struct { min: u64, max: u64, mean: u64 } {
var min: u64 = std.math.maxInt(u64);
var max: u64 = 0;
var total: u64 = 0;
// Aquecimento — 10% das iterações
const warmup = iterations / 10;
for (0..warmup) |_| {
const r = @call(.auto, func, args);
std.mem.doNotOptimizeAway(r);
}
// Medição real
for (0..iterations) |_| {
var timer = std.time.Timer.start() catch unreachable;
const r = @call(.auto, func, args);
std.mem.doNotOptimizeAway(r);
const elapsed = timer.read();
total += elapsed;
if (elapsed < min) min = elapsed;
if (elapsed > max) max = elapsed;
}
return .{
.min = min,
.max = max,
.mean = total / iterations,
};
}
fn ordenarArray() [1000]u32 {
var arr: [1000]u32 = undefined;
for (&arr, 0..) |*v, i| {
v.* = @intCast(1000 - i);
}
std.mem.sort(u32, &arr, {}, std.sort.asc(u32));
return arr;
}
pub fn main() !void {
const result = runBenchmark(ordenarArray, .{}, 10_000);
std.debug.print("Ordenação de 1000 elementos:\n", .{});
std.debug.print(" Min: {d:.1}us\n", .{@as(f64, @floatFromInt(result.min)) / 1000.0});
std.debug.print(" Max: {d:.1}us\n", .{@as(f64, @floatFromInt(result.max)) / 1000.0});
std.debug.print(" Média: {d:.1}us\n", .{@as(f64, @floatFromInt(result.mean)) / 1000.0});
}
Essa abordagem com comptime genérico permite reusar a mesma estrutura para qualquer função que precise ser medida.
Comparando Release Modes
Uma das vantagens do build system de Zig é a facilidade para alternar entre modos de compilação. Cada modo afeta diretamente a performance:
| Modo | Otimização | Safety Checks | Uso Típico |
|---|---|---|---|
| Debug | Nenhuma | Todos | Desenvolvimento |
| ReleaseSafe | Máxima | Mantidos | Produção conservadora |
| ReleaseFast | Máxima | Removidos | Performance máxima |
| ReleaseSmall | Tamanho | Removidos | Embarcados |
Para comparar os modos diretamente via build.zig:
zig build -Doptimize=Debug && ./zig-out/bin/benchmark
zig build -Doptimize=ReleaseSafe && ./zig-out/bin/benchmark
zig build -Doptimize=ReleaseFast && ./zig-out/bin/benchmark
zig build -Doptimize=ReleaseSmall && ./zig-out/bin/benchmark
A diferença entre Debug e ReleaseFast pode ser de 10x a 100x dependendo do tipo de cálculo. Operações intensivas em SIMD se beneficiam enormemente da autovetorização habilitada em ReleaseFast.
Integração com Tracy para Profiling Visual
Para ir além de microbenchmarks e identificar gargalos em aplicações completas, o Tracy é a ferramenta de escolha no ecossistema Zig. A integração é nativa via std.debug:
const std = @import("std");
const tracy = @import("tracy");
pub fn processarDados(dados: []const u8) !void {
const zone = tracy.trace(@src());
defer zone.end();
// Código instrumentado — Tracy mede automaticamente
var checksum: u64 = 0;
for (dados) |byte| {
checksum = checksum *% 31 +% byte;
}
std.mem.doNotOptimizeAway(checksum);
}
O profiler Tracy gera visualizações interativas com flamegraphs, histogramas de latência e uso de memória por allocator. Isso complementa benchmarks pontuais com uma visão sistêmica do comportamento da aplicação.
Benchmark Prático: Hashing
Vamos comparar duas implementações de hash para medir o impacto real de diferentes abordagens:
const std = @import("std");
fn hashSimples(data: []const u8) u64 {
var h: u64 = 0;
for (data) |byte| {
h = h *% 31 +% byte;
}
return h;
}
fn hashFnv1a(data: []const u8) u64 {
var h: u64 = 0xcbf29ce484222325;
for (data) |byte| {
h ^= byte;
h *%= 0x100000001b3;
}
return h;
}
pub fn main() !void {
const dados = "benchmark de hashing em zig para comparar implementações" ** 100;
// Benchmark hash simples
var timer1 = try std.time.Timer.start();
for (0..100_000) |_| {
std.mem.doNotOptimizeAway(hashSimples(dados));
}
const t1 = timer1.read();
// Benchmark FNV-1a
var timer2 = try std.time.Timer.start();
for (0..100_000) |_| {
std.mem.doNotOptimizeAway(hashFnv1a(dados));
}
const t2 = timer2.read();
std.debug.print("Hash simples: {d:.2}ms\n", .{@as(f64, @floatFromInt(t1)) / 1_000_000.0});
std.debug.print("FNV-1a: {d:.2}ms\n", .{@as(f64, @floatFromInt(t2)) / 1_000_000.0});
std.debug.print("Razão: {d:.2}x\n", .{@as(f64, @floatFromInt(t1)) / @as(f64, @floatFromInt(t2))});
}
Esse padrão de comparação lado a lado é fundamental para avaliar trade-offs entre simplicidade e performance — algo recorrente quando se trabalha com error handling e abstrações.
Comparando com C e Outras Linguagens
Um benchmark relevante é comparar Zig com implementações equivalentes em C. Graças à interoperabilidade nativa, você pode até chamar código C diretamente no mesmo benchmark:
const c = @cImport({
@cInclude("string.h");
});
fn benchmarkMemcpy(dst: [*]u8, src: [*]const u8, len: usize) void {
_ = c.memcpy(dst, src, len);
}
Essa capacidade de comparação direta é uma vantagem significativa do ecossistema Zig. Em nosso comparativo entre Zig, Rust e Go, os resultados de benchmarks em operações de baixo nível mostram Zig consistentemente próximo ao C puro.
Boas Práticas para Benchmarks Confiáveis
Para garantir resultados que reflitam a realidade:
- Desative frequency scaling: use
cpupower frequency-set -g performanceno Linux - Feche outros processos: isolamento reduz variância entre execuções
- Use múltiplas iterações: nunca confie em uma única medição
- Reporte min, max e média: outliers revelam problemas de medição
- Teste com dados realistas: benchmarks sintéticos podem otimizar caminhos irreais
- Compare modos de compilação: cada release mode tem perfil diferente
- Verifique resultados: sempre valide que o código medido produz saída correta
Se você vem de Rust, já conhece a importância do benchmarking com Criterion. Em Zig, a filosofia é similar, mas sem dependências externas — as primitivas da biblioteca padrão são suficientes para a maioria dos cenários.
Para quem trabalha com benchmarks em Go, a diferença principal é que Zig não tem garbage collector, então não há pausas de GC contaminando as medições.
Próximos Passos
Com benchmarks confiáveis em mãos, você pode otimizar aplicações reais. Considere explorar:
- Processamento vetorial com SIMD para acelerar operações em arrays
- Testes automatizados para validar que otimizações não quebram funcionalidade
- Criação de CLIs profissionais para expor suas ferramentas de benchmark como utilitários de linha de comando
- Cross-compilation para testar performance em diferentes arquiteturas
- Concorrência avançada para benchmarks de throughput multithread