Profiling e Benchmarks em Zig: Otimize Seu Código

Uma das grandes promessas do Zig – uma linguagem de programação de sistemas focada em performance e controle explícito – é oferecer performance comparável a C com muito mais segurança e ergonomia. Mas performance não acontece por acaso: é preciso medir, analisar e otimizar com dados concretos. A linguagem Zig fornece ferramentas integradas para medição de tempo, diferentes modos de compilação otimizados e compatibilidade total com ferramentas de profiling do ecossistema Linux como perf e Valgrind.

Neste tutorial, vamos explorar como medir a performance do seu código Zig, identificar gargalos e aplicar otimizações fundamentadas em dados. Desde microbenchmarks simples até profiling avançado com ferramentas externas, você terá todas as técnicas necessárias para escrever código Zig verdadeiramente rápido.

Medindo Tempo com std.time.Timer

O ponto de partida para qualquer benchmark é uma medição precisa de tempo. O std.time.Timer oferece um cronômetro de alta resolução baseado no relógio monotônico do sistema operacional.

const std = @import("std");

pub fn main() !void {
    var timer = try std.time.Timer.start();

    // Código que queremos medir
    var soma: u64 = 0;
    for (0..10_000_000) |i| {
        soma += i;
    }

    const elapsed = timer.read();

    std.debug.print("Resultado: {}\n", .{soma});
    std.debug.print("Tempo: {} ns ({d:.3} ms)\n", .{
        elapsed,
        @as(f64, @floatFromInt(elapsed)) / std.time.ns_per_ms,
    });
}

Para benchmarks mais confiáveis, é essencial executar múltiplas iterações e calcular estatísticas:

const std = @import("std");

fn benchmark(comptime func: anytype, args: anytype, iterations: usize) struct { min: u64, max: u64, avg: u64, median: u64 } {
    var times: [1000]u64 = undefined;
    const n = @min(iterations, 1000);

    for (0..n) |i| {
        var timer = std.time.Timer.start() catch unreachable;
        const result = @call(.auto, func, args);
        _ = result;
        times[i] = timer.read();
    }

    // Ordenar para calcular mediana
    std.mem.sort(u64, times[0..n], {}, std.sort.asc(u64));

    var total: u64 = 0;
    var min: u64 = std.math.maxInt(u64);
    var max: u64 = 0;

    for (times[0..n]) |t| {
        total += t;
        if (t < min) min = t;
        if (t > max) max = t;
    }

    return .{
        .min = min,
        .max = max,
        .avg = total / n,
        .median = times[n / 2],
    };
}

fn somaSequencial(n: usize) u64 {
    var soma: u64 = 0;
    for (0..n) |i| {
        soma += i;
    }
    return soma;
}

pub fn main() void {
    const result = benchmark(somaSequencial, .{1_000_000}, 100);

    std.debug.print("=== Benchmark: somaSequencial(1_000_000) ===\n", .{});
    std.debug.print("  Mínimo:  {d:.3} ms\n", .{@as(f64, @floatFromInt(result.min)) / std.time.ns_per_ms});
    std.debug.print("  Máximo:  {d:.3} ms\n", .{@as(f64, @floatFromInt(result.max)) / std.time.ns_per_ms});
    std.debug.print("  Média:   {d:.3} ms\n", .{@as(f64, @floatFromInt(result.avg)) / std.time.ns_per_ms});
    std.debug.print("  Mediana: {d:.3} ms\n", .{@as(f64, @floatFromInt(result.median)) / std.time.ns_per_ms});
}

Modos de Release: Debug, ReleaseSafe, ReleaseFast, ReleaseSmall

A escolha do modo de compilação tem um impacto enorme na performance. Zig oferece quatro modos, cada um com trade-offs diferentes.

// Compilar com diferentes modos:
// zig build-exe main.zig                      -> Debug (padrão)
// zig build-exe main.zig -O ReleaseSafe       -> Segurança + otimização
// zig build-exe main.zig -O ReleaseFast       -> Máxima performance
// zig build-exe main.zig -O ReleaseSmall      -> Menor binário

As diferenças entre os modos:

ModoOtimizaçãoSafety ChecksTamanhoUso
DebugNenhumaTodas ativasGrandeDesenvolvimento
ReleaseSafeLLVM -O2AtivasMédioProdução (padrão recomendado)
ReleaseFastLLVM -O3DesativadasMédioPerformance crítica
ReleaseSmallTamanhoDesativadasPequenoSistemas embarcados

Para ver o impacto real, meça o mesmo código nos diferentes modos:

const std = @import("std");

// Esta função terá performance MUITO diferente
// entre Debug e ReleaseFast
fn matrixMultiply(a: []const f64, b: []const f64, c: []f64, n: usize) void {
    for (0..n) |i| {
        for (0..n) |j| {
            var sum: f64 = 0;
            for (0..n) |k| {
                sum += a[i * n + k] * b[k * n + j];
            }
            c[i * n + j] = sum;
        }
    }
}

pub fn main() !void {
    const n = 256;
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const a = try allocator.alloc(f64, n * n);
    defer allocator.free(a);
    const b = try allocator.alloc(f64, n * n);
    defer allocator.free(b);
    const c = try allocator.alloc(f64, n * n);
    defer allocator.free(c);

    // Inicializar
    for (a, 0..) |*val, i| val.* = @floatFromInt(i % 100);
    for (b, 0..) |*val, i| val.* = @floatFromInt(i % 50);

    var timer = try std.time.Timer.start();
    matrixMultiply(a, b, c, n);
    const elapsed = timer.read();

    std.debug.print("Multiplicação de matrizes {}x{}: {d:.3} ms\n", .{
        n,
        n,
        @as(f64, @floatFromInt(elapsed)) / std.time.ns_per_ms,
    });
}

Na prática, o salto de Debug para ReleaseFast em código numérico pode ser de 10x a 50x. Sempre faça benchmarks no modo de release que usará em produção. Combinado com avaliação em tempo de compilação via comptime, muitas computações podem ser movidas para o build, eliminando custo em runtime. Para configurar corretamente os modos de otimização no seu projeto, consulte o guia completo do Zig Build System, que explica como definir opções de build e criar steps customizados para benchmarking.

Usando perf com Binários Zig

O perf é a ferramenta padrão de profiling no Linux. Binários Zig são totalmente compatíveis com perf porque geram DWARF debug info e símbolos corretos.

# Compilar com informações de debug mesmo em release
zig build-exe main.zig -O ReleaseFast

# Profiling básico: contadores de hardware
perf stat ./main

# Profiling detalhado: sampling de CPU
perf record -g ./main
perf report

# Anotação por linha de código (requer debug info)
zig build-exe main.zig -O ReleaseSafe
perf record ./main
perf annotate

Saída típica do perf stat:

Performance counter stats for './main':
         12.45 msec task-clock
              3      context-switches
    48,234,567      cycles
    95,678,123      instructions     #  1.98 insn per cycle
     1,234,567      cache-misses     #  2.34% of cache refs

Os contadores mais importantes para otimização são:

  • instructions per cycle (IPC): valores acima de 2.0 indicam boa utilização do pipeline.
  • cache-misses: valores altos indicam problemas de localidade de dados.
  • branch-misses: muitos erros de predição indicam padrões de acesso imprevisíveis.

Profiling de Memória com GeneralPurposeAllocator

O GPA (GeneralPurposeAllocator) de Zig pode detectar vazamentos e rastrear alocações em modo debug:

const std = @import("std");

pub fn main() !void {
    // Ativar rastreamento detalhado
    var gpa = std.heap.GeneralPurposeAllocator(.{
        .enable_memory_limit = true,
        .verbose_log = true,
    }){};
    defer {
        const status = gpa.deinit();
        switch (status) {
            .ok => std.debug.print("\nMemória: OK — sem vazamentos!\n", .{}),
            .leak => std.debug.print("\nATENÇÃO: Vazamento de memória detectado!\n", .{}),
        }
    }

    const allocator = gpa.allocator();

    // Simular alocações
    const dados = try allocator.alloc(u8, 1024);
    const mais_dados = try allocator.alloc(u8, 2048);

    // Usar os dados...
    @memset(dados, 'A');
    @memset(mais_dados, 'B');

    // Liberar corretamente
    allocator.free(dados);
    allocator.free(mais_dados);

    // Se esquecermos de liberar, o GPA reporta na saída
}

Para rastrear o consumo total de memória ao longo do tempo, crie um allocator wrapper:

const std = @import("std");

const TrackingAllocator = struct {
    parent: std.mem.Allocator,
    current_bytes: usize = 0,
    peak_bytes: usize = 0,
    total_allocations: usize = 0,
    total_frees: usize = 0,

    pub fn allocator(self: *TrackingAllocator) std.mem.Allocator {
        return .{
            .ptr = self,
            .vtable = &.{
                .alloc = alloc,
                .resize = resize,
                .free = free,
            },
        };
    }

    fn alloc(ctx: *anyopaque, len: usize, ptr_align: u8, ret_addr: usize) ?[*]u8 {
        const self: *TrackingAllocator = @ptrCast(@alignCast(ctx));
        const result = self.parent.rawAlloc(len, ptr_align, ret_addr) orelse return null;
        self.current_bytes += len;
        self.total_allocations += 1;
        if (self.current_bytes > self.peak_bytes) {
            self.peak_bytes = self.current_bytes;
        }
        return result;
    }

    fn resize(ctx: *anyopaque, buf: []u8, buf_align: u8, new_len: usize, ret_addr: usize) bool {
        const self: *TrackingAllocator = @ptrCast(@alignCast(ctx));
        if (self.parent.rawResize(buf, buf_align, new_len, ret_addr)) {
            self.current_bytes = self.current_bytes - buf.len + new_len;
            if (self.current_bytes > self.peak_bytes) {
                self.peak_bytes = self.current_bytes;
            }
            return true;
        }
        return false;
    }

    fn free(ctx: *anyopaque, buf: []u8, buf_align: u8, ret_addr: usize) void {
        const self: *TrackingAllocator = @ptrCast(@alignCast(ctx));
        self.parent.rawFree(buf, buf_align, ret_addr);
        self.current_bytes -= buf.len;
        self.total_frees += 1;
    }

    pub fn report(self: *const TrackingAllocator) void {
        std.debug.print("\n=== Relatório de Memória ===\n", .{});
        std.debug.print("  Alocações totais: {}\n", .{self.total_allocations});
        std.debug.print("  Liberações totais: {}\n", .{self.total_frees});
        std.debug.print("  Pico de uso: {} bytes ({d:.2} KB)\n", .{
            self.peak_bytes,
            @as(f64, @floatFromInt(self.peak_bytes)) / 1024.0,
        });
        std.debug.print("  Uso atual: {} bytes\n", .{self.current_bytes});
    }
};

Usando Valgrind com Zig

Valgrind funciona perfeitamente com binários Zig compilados para a plataforma nativa:

# Compilar sem otimizações para melhor rastreamento
zig build-exe main.zig

# Verificar vazamentos de memória
valgrind --leak-check=full ./main

# Profiling de cache
valgrind --tool=cachegrind ./main
cg_annotate cachegrind.out.*

# Profiling de chamadas
valgrind --tool=callgrind ./main
callgrind_annotate callgrind.out.*

# Verificar threading
valgrind --tool=helgrind ./main

Importante: Zig em modo Debug inclui safety checks que podem interferir com Valgrind. Para profiling de memória puro, compile em ReleaseSafe.

Comparando Performance Zig vs C

Uma comparação justa requer o mesmo algoritmo, mesmos dados e mesmas flags de otimização:

const std = @import("std");

// Implementação em Zig de soma de array
fn somaArray(data: []const f64) f64 {
    var soma: f64 = 0;
    for (data) |val| {
        soma += val;
    }
    return soma;
}

// Versão otimizada com SIMD hints via @reduce
fn somaArraySIMD(data: []const f64) f64 {
    const vec_size = 4;
    var soma: @Vector(vec_size, f64) = @splat(0);
    var i: usize = 0;

    // Processar em blocos de 4
    while (i + vec_size <= data.len) : (i += vec_size) {
        const vec: @Vector(vec_size, f64) = data[i..][0..vec_size].*;
        soma += vec;
    }

    // Reduzir o vetor a um escalar
    var resultado = @reduce(.Add, soma);

    // Processar elementos restantes
    while (i < data.len) : (i += 1) {
        resultado += data[i];
    }

    return resultado;
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const n = 10_000_000;
    const data = try allocator.alloc(f64, n);
    defer allocator.free(data);

    // Inicializar com valores
    for (data, 0..) |*val, i| {
        val.* = @as(f64, @floatFromInt(i)) * 0.001;
    }

    // Benchmark versão escalar
    var timer = try std.time.Timer.start();
    const resultado1 = somaArray(data);
    const t1 = timer.read();

    // Benchmark versão SIMD
    timer.reset();
    const resultado2 = somaArraySIMD(data);
    const t2 = timer.read();

    std.debug.print("Escalar: {d:.6} em {d:.3} ms\n", .{
        resultado1,
        @as(f64, @floatFromInt(t1)) / std.time.ns_per_ms,
    });
    std.debug.print("SIMD:    {d:.6} em {d:.3} ms\n", .{
        resultado2,
        @as(f64, @floatFromInt(t2)) / std.time.ns_per_ms,
    });
    std.debug.print("Speedup: {d:.2}x\n", .{
        @as(f64, @floatFromInt(t1)) / @as(f64, @floatFromInt(t2)),
    });
}

Na prática, Zig e C compilam para código muito similar via LLVM. As diferenças de performance geralmente são menores que 5%, com Zig ocasionalmente vencendo graças a restrições de aliasing mais rigorosas.

Armadilhas de Performance e Otimizações Comuns

Existem vários padrões que prejudicam a performance em Zig e que são fáceis de corrigir uma vez identificados.

Armadilha 1: Alocações Desnecessárias

// RUIM: aloca string para cada iteração
fn processarRuim(allocator: std.mem.Allocator, items: []const []const u8) !void {
    for (items) |item| {
        const upper = try std.ascii.allocUpperString(allocator, item);
        defer allocator.free(upper);
        // processar upper...
    }
}

// BOM: reutilizar buffer
fn processarBom(items: []const []const u8) void {
    var buf: [1024]u8 = undefined;
    for (items) |item| {
        const len = @min(item.len, buf.len);
        for (item[0..len], 0..) |c, i| {
            buf[i] = std.ascii.toUpper(c);
        }
        const upper = buf[0..len];
        _ = upper;
        // processar upper...
    }
}

Armadilha 2: Layout de Dados Desfavorável ao Cache

// RUIM: Array of Structs — campos não usados poluem o cache
const ParticlaBad = struct {
    x: f64,
    y: f64,
    z: f64,
    nome: [32]u8, // Raramente acessado junto com posição
    cor: u32,
    ativo: bool,
};

// BOM: Struct of Arrays — melhor localidade de cache
const Particulas = struct {
    x: []f64,
    y: []f64,
    z: []f64,
    nomes: [][32]u8,
    cores: []u32,
    ativos: []bool,
    len: usize,

    // Atualizar apenas posições — acesso sequencial em memória
    pub fn atualizarPosicoes(self: *Particulas, dt: f64) void {
        for (0..self.len) |i| {
            self.x[i] += dt;
            self.y[i] += dt;
            self.z[i] += dt;
        }
    }
};

Armadilha 3: Branch Prediction e Dados Ordenados

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const n = 1_000_000;
    const data = try allocator.alloc(i32, n);
    defer allocator.free(data);

    // Preencher com valores aleatórios
    var prng = std.Random.DefaultPrng.init(42);
    const random = prng.random();
    for (data) |*val| {
        val.* = random.intRangeAtMost(i32, 0, 255);
    }

    // Benchmark com dados NÃO ordenados
    var timer = try std.time.Timer.start();
    var soma1: i64 = 0;
    for (data) |val| {
        if (val >= 128) soma1 += val; // Branch imprevisível
    }
    const t1 = timer.read();

    // Ordenar os dados
    std.mem.sort(i32, data, {}, std.sort.asc(i32));

    // Benchmark com dados ORDENADOS
    timer.reset();
    var soma2: i64 = 0;
    for (data) |val| {
        if (val >= 128) soma2 += val; // Branch previsível após ordenação
    }
    const t2 = timer.read();

    std.debug.print("Não ordenado: {d:.3} ms (soma={})\n", .{
        @as(f64, @floatFromInt(t1)) / std.time.ns_per_ms,
        soma1,
    });
    std.debug.print("Ordenado:     {d:.3} ms (soma={})\n", .{
        @as(f64, @floatFromInt(t2)) / std.time.ns_per_ms,
        soma2,
    });
}

Builtins de Otimização: @prefetch e SIMD

Zig expõe builtins que permitem dar dicas ao hardware para melhor performance. Para um mergulho completo em vetorização, veja o guia de SIMD em Zig:

const std = @import("std");

fn somaComPrefetch(data: []const f64) f64 {
    var soma: f64 = 0;
    const prefetch_distance = 16;

    for (0..data.len) |i| {
        // Pré-carregar dados que serão usados em breve
        if (i + prefetch_distance < data.len) {
            @prefetch(&data[i + prefetch_distance], .{
                .rw = .read,
                .locality = 3, // Alta probabilidade de reutilização
                .cache = .data,
            });
        }
        soma += data[i];
    }
    return soma;
}

// SIMD com vetores nativos de Zig
fn distanciaEuclidiana(a: @Vector(4, f32), b: @Vector(4, f32)) f32 {
    const diff = a - b;
    const squared = diff * diff;
    return @sqrt(@reduce(.Add, squared));
}

pub fn main() void {
    const ponto_a = @Vector(4, f32){ 1.0, 2.0, 3.0, 0.0 };
    const ponto_b = @Vector(4, f32){ 4.0, 6.0, 8.0, 0.0 };

    const dist = distanciaEuclidiana(ponto_a, ponto_b);
    std.debug.print("Distância: {d:.4}\n", .{dist});
}

Exemplo Prático: Otimizando um Hot Loop

Vamos otimizar passo a passo uma função de processamento de imagem (conversão para escala de cinza):

const std = @import("std");

const Pixel = struct { r: u8, g: u8, b: u8 };

// Versão 1: Ingênua
fn grayscaleV1(pixels: []const Pixel, output: []u8) void {
    for (pixels, 0..) |p, i| {
        // Conversão clássica com pesos perceptuais
        const gray = @as(f64, @floatFromInt(p.r)) * 0.299 +
            @as(f64, @floatFromInt(p.g)) * 0.587 +
            @as(f64, @floatFromInt(p.b)) * 0.114;
        output[i] = @intFromFloat(gray);
    }
}

// Versão 2: Aritmética inteira (evita conversão float)
fn grayscaleV2(pixels: []const Pixel, output: []u8) void {
    for (pixels, 0..) |p, i| {
        // Pesos multiplicados por 256 para usar shift
        const gray = (@as(u32, p.r) * 77 +
            @as(u32, p.g) * 150 +
            @as(u32, p.b) * 29) >> 8;
        output[i] = @truncate(gray);
    }
}

// Versão 3: SIMD — processar 16 pixels por vez
fn grayscaleV3(pixels: []const Pixel, output: []u8) void {
    var i: usize = 0;
    // Processar em blocos
    while (i + 16 <= pixels.len) : (i += 16) {
        inline for (0..16) |offset| {
            const p = pixels[i + offset];
            const gray = (@as(u32, p.r) * 77 +
                @as(u32, p.g) * 150 +
                @as(u32, p.b) * 29) >> 8;
            output[i + offset] = @truncate(gray);
        }
    }
    // Restante
    while (i < pixels.len) : (i += 1) {
        const p = pixels[i];
        const gray = (@as(u32, p.r) * 77 +
            @as(u32, p.g) * 150 +
            @as(u32, p.b) * 29) >> 8;
        output[i] = @truncate(gray);
    }
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // Simular uma imagem 1920x1080
    const n = 1920 * 1080;
    const pixels = try allocator.alloc(Pixel, n);
    defer allocator.free(pixels);
    const output = try allocator.alloc(u8, n);
    defer allocator.free(output);

    // Preencher com dados
    for (pixels) |*p| {
        p.* = .{ .r = 128, .g = 200, .b = 50 };
    }

    // Benchmark cada versão
    const versions = .{
        .{ "V1 (float)", grayscaleV1 },
        .{ "V2 (inteiro)", grayscaleV2 },
        .{ "V3 (unrolled)", grayscaleV3 },
    };

    inline for (versions) |v| {
        var best: u64 = std.math.maxInt(u64);
        for (0..10) |_| {
            var timer = std.time.Timer.start() catch unreachable;
            v[1](pixels, output);
            const elapsed = timer.read();
            if (elapsed < best) best = elapsed;
        }
        std.debug.print("{s}: {d:.3} ms\n", .{
            v[0],
            @as(f64, @floatFromInt(best)) / std.time.ns_per_ms,
        });
    }
}

Os resultados típicos em ReleaseFast mostram:

  • V1 (float): mais lenta por causa das conversões int->float->int.
  • V2 (inteiro): 2-3x mais rápida, usando apenas aritmética inteira.
  • V3 (unrolled): potencialmente mais rápida se o compilador vetorizar o loop desenrolado.

A lição principal é: meça antes de otimizar, e otimize com base em dados, não em suposições. Use perf para confirmar se o gargalo é CPU, cache ou memória, e escolha a técnica de otimização adequada.

Conclusão

Profiling e benchmarking em Zig combinam as ferramentas do ecossistema Linux (perf, Valgrind, cachegrind) com recursos nativos da linguagem (Timer, GPA com tracking, modos de release). A chave é sempre medir primeiro, identificar o gargalo real e só então otimizar. Zig facilita esse processo ao gerar binários compatíveis com todas as ferramentas de profiling existentes e ao fornecer abstrações de medição de tempo e memória diretamente na biblioteca padrão.

Leia Também

Continue aprendendo Zig

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