Benchmarking em Zig: Medir Performance na Prática

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:

ModoOtimizaçãoSafety ChecksUso Típico
DebugNenhumaTodosDesenvolvimento
ReleaseSafeMáximaMantidosProdução conservadora
ReleaseFastMáximaRemovidosPerformance máxima
ReleaseSmallTamanhoRemovidosEmbarcados

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 performance no 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:

Continue aprendendo Zig

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