Depuração e Profiling em Zig: Tracy, Valgrind e Perf

Depuração e Profiling em Zig: Tracy, Valgrind e Perf

Escrever código correto é apenas metade do trabalho — encontrar bugs e otimizar performance são habilidades fundamentais para qualquer desenvolvedor de sistemas. O Zig oferece ferramentas nativas poderosas para depuração, como stack traces detalhados e o GeneralPurposeAllocator com detecção de leaks. Além disso, por gerar binários nativos compatíveis com DWARF, o Zig funciona perfeitamente com ferramentas consagradas como Tracy, Valgrind e perf.

Neste artigo, vamos explorar técnicas práticas de depuração e profiling para programas Zig.

Modos de Build e Seu Impacto

Antes de depurar, é essencial entender os modos de release do Zig. Cada modo afeta o nível de informação disponível para depuração:

const std = @import("std");

pub fn main() !void {
    // Em Debug, verificações de segurança estão ativas:
    // - Bounds checking em slices
    // - Detecção de undefined behavior
    // - Stack traces completos
    var buffer: [5]u8 = .{ 1, 2, 3, 4, 5 };
    const slice = buffer[0..3];

    // Em ReleaseSafe, mantém verificações com otimização
    // Em ReleaseFast, remove verificações para máxima performance
    // Em ReleaseSmall, otimiza para menor binário
    std.debug.print("Slice: {any}\n", .{slice});
}
ModoOtimizaçãoSafety ChecksDebug InfoUso
DebugNenhuma✅ Sim✅ CompletaDesenvolvimento
ReleaseSafeTotal✅ Sim✅ CompletaProdução segura
ReleaseFastTotal❌ Não❌ MínimaMáxima performance
ReleaseSmallTamanho❌ Não❌ MínimaEmbarcados

Para depuração, sempre compile em modo Debug ou ReleaseSafe:

zig build -Doptimize=Debug
zig build -Doptimize=ReleaseSafe

Depuração com GDB e LLDB

O Zig gera informações DWARF completas em modo Debug, permitindo depuração com GDB ou LLDB sem configuração adicional.

Usando GDB

# Compilar com informações de debug
zig build-exe src/main.zig

# Iniciar GDB
gdb ./main

Comandos essenciais do GDB com Zig:

# Definir breakpoint em função
(gdb) break main
(gdb) break src/main.zig:42

# Executar até o breakpoint
(gdb) run

# Inspecionar variáveis
(gdb) print minha_variavel
(gdb) print slice.ptr[0..slice.len]

# Avançar linha por linha
(gdb) next
(gdb) step

# Ver stack trace
(gdb) backtrace

# Continuar execução
(gdb) continue

Inspecionando slices e structs

O Zig usa representações internas para slices e structs. No GDB, um slice aparece como uma struct com .ptr e .len:

const std = @import("std");

const Ponto = struct {
    x: f64,
    y: f64,

    pub fn distancia(self: Ponto, outro: Ponto) f64 {
        const dx = self.x - outro.x;
        const dy = self.y - outro.y;
        return @sqrt(dx * dx + dy * dy);
    }
};

pub fn main() !void {
    const pontos = [_]Ponto{
        .{ .x = 0, .y = 0 },
        .{ .x = 3, .y = 4 },
        .{ .x = 6, .y = 8 },
    };

    // Breakpoint aqui para inspecionar
    for (pontos, 0..) |ponto, i| {
        std.debug.print("Ponto {}: ({d}, {d})\n", .{ i, ponto.x, ponto.y });
    }
}

No GDB, inspecione com:

(gdb) print pontos[0]
$1 = {x = 0, y = 0}
(gdb) print pontos[1].x
$2 = 3

Detecção de Memory Leaks com GeneralPurposeAllocator

O GeneralPurposeAllocator (GPA) do Zig é uma das melhores ferramentas nativas para detectar vazamentos de memória. Ao contrário de ferramentas externas, ele está integrado diretamente na stdlib:

const std = @import("std");

pub fn main() !void {
    // GPA detecta leaks, double-free e use-after-free
    var gpa = std.heap.GeneralPurposeAllocator(.{
        .stack_trace_frames = 8, // Frames no stack trace
        .enable_memory_limit = true, // Limitar memória total
    }){};
    defer {
        const status = gpa.deinit();
        if (status == .leak) {
            std.debug.print("⚠️  Memory leak detectado!\n", .{});
        }
    }

    const allocator = gpa.allocator();

    // Alocação normal — sem leak
    const dados = try allocator.alloc(u8, 1024);
    defer allocator.free(dados);

    // Simulando um leak (sem free)
    const leak = try allocator.alloc(u8, 256);
    _ = leak; // ← Nunca liberado!

    std.debug.print("Programa executado com sucesso.\n", .{});
}

Ao rodar esse programa em modo Debug, o GPA imprime:

Programa executado com sucesso.
⚠️  Memory leak detectado!
error: memory address 0x7f... leaked:
  src/main.zig:22:41: main

O stack trace aponta exatamente a linha da alocação que não foi liberada.

Profiling com perf (Linux)

O perf é a ferramenta de profiling padrão no Linux. Como o Zig gera binários nativos, funciona sem configuração:

# Compilar com ReleaseSafe para ter símbolos + otimização
zig build -Doptimize=ReleaseSafe

# Gravar perfil de execução
perf record -g ./zig-out/bin/meu-programa

# Visualizar relatório interativo
perf report

# Gerar flame graph (requer FlameGraph tools)
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg

Exemplo prático: identificando hotspot

const std = @import("std");

// Função intencionalmente lenta para demonstração
fn processarDados(dados: []const u8) u64 {
    var soma: u64 = 0;
    for (dados) |byte| {
        // Simulando processamento pesado
        var hash: u64 = byte;
        for (0..1000) |_| {
            hash = hash *% 6364136223846793005 +% 1442695040888963407;
        }
        soma +%= hash;
    }
    return soma;
}

fn validarDados(dados: []const u8) bool {
    for (dados) |byte| {
        if (byte == 0) return false;
    }
    return true;
}

pub fn main() !void {
    var prng = std.Random.DefaultPrng.init(42);
    var buffer: [10_000]u8 = undefined;
    prng.fill(&buffer);

    if (validarDados(&buffer)) {
        const resultado = processarDados(&buffer);
        std.debug.print("Resultado: {}\n", .{resultado});
    }
}

O perf report mostraria que processarDados consome ~99% do tempo, enquanto validarDados é negligível. Flame graphs tornam isso visualmente óbvio.

Instrumentação com Tracy

O Tracy é um profiler em tempo real que oferece visualizações detalhadas com timeline, flame charts e estatísticas de alocação. A integração com Zig é feita via a biblioteca zig-tracy.

Configurando Tracy no build.zig.zon

.dependencies = .{
    .tracy = .{
        .url = "https://github.com/nektro/zig-tracy/archive/refs/tags/v0.11.1.tar.gz",
        .hash = "1220...",
    },
},

Configurando no build.zig

const tracy_dep = b.dependency("tracy", .{
    .target = target,
    .optimize = optimize,
    .enable = b.option(bool, "tracy", "Habilitar Tracy profiler") orelse false,
});

exe.root_module.addImport("tracy", tracy_dep.module("tracy"));

Instrumentando o código

const std = @import("std");
const tracy = @import("tracy");

fn processarLote(dados: []const u8) u64 {
    // Marca esta região no profiler
    const zone = tracy.trace(@src());
    defer zone.end();

    var resultado: u64 = 0;
    for (dados) |byte| {
        resultado +%= @as(u64, byte) *% 31;
    }
    return resultado;
}

fn pipeline(allocator: std.mem.Allocator, entrada: []const u8) ![]u8 {
    const zone = tracy.trace(@src());
    defer zone.end();
    zone.setName("Pipeline Principal");

    // Marcar alocações para Tracy rastrear
    const buffer = try allocator.alloc(u8, entrada.len * 2);
    tracy.allocN(buffer.ptr, buffer.len, "buffer-pipeline");

    for (entrada, 0..) |byte, i| {
        buffer[i * 2] = byte;
        buffer[i * 2 + 1] = byte ^ 0xFF;
    }

    return buffer;
}

pub fn main() !void {
    // Frame mark para Tracy
    tracy.frameMark();

    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    const allocator = gpa.allocator();
    const dados = "Zig profiling com Tracy funciona muito bem!";

    const resultado = try pipeline(allocator, dados);
    defer allocator.free(resultado);

    const soma = processarLote(resultado);
    std.debug.print("Soma: {}\n", .{soma});

    tracy.frameMark();
}

Para compilar com Tracy habilitado:

zig build -Dtracy=true

Depois, abra o Tracy Profiler GUI e conecte ao programa em execução para visualizar a timeline em tempo real.

Análise de Memória com Valgrind

O Valgrind é essencial para detectar problemas de memória que escapam do GPA, como acessos inválidos em código interoperando com C:

# Compilar em modo Debug
zig build-exe src/main.zig

# Rodar com Valgrind Memcheck
valgrind --leak-check=full --track-origins=yes ./main

# Para profiling de cache (performance)
valgrind --tool=cachegrind ./main

# Para profiling de chamadas
valgrind --tool=callgrind ./main

Exemplo: detectando acesso inválido

const std = @import("std");
const c = @cImport({
    @cInclude("stdlib.h");
    @cInclude("string.h");
});

pub fn main() void {
    // Alocação via libc (não rastreada pelo GPA)
    const ptr: [*]u8 = @ptrCast(c.malloc(100) orelse {
        std.debug.print("Falha na alocação\n", .{});
        return;
    });

    // Uso normal
    _ = c.memset(ptr, 0, 100);

    // Liberar memória
    c.free(ptr);

    // BUG: use-after-free — Valgrind detecta isso!
    // ptr[0] = 42; // ← Descomente para testar
}

O Valgrind reporta com precisão a linha do acesso inválido, o ponto onde a memória foi liberada e onde foi alocada originalmente.

Nota: Valgrind funciona apenas em Linux (x86_64 e arm64). Para macOS, considere o AddressSanitizer ou o Instruments da Apple. Para mais detalhes sobre alocação de memória em Zig, confira nosso artigo dedicado.

Técnicas de Debug Nativas do Zig

Além de ferramentas externas, o Zig oferece recursos nativos poderosos:

std.debug.print para inspeção rápida

const std = @import("std");

fn fibonacci(n: u32) u64 {
    std.debug.print("fibonacci({}) chamado\n", .{n});

    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

pub fn main() void {
    const resultado = fibonacci(10);
    std.debug.print("Resultado: {}\n", .{resultado});
}

@breakpoint() para debug interativo

fn funcaoCritica(valor: i32) i32 {
    if (valor < 0) {
        // Pausa a execução — GDB/LLDB assume controle
        @breakpoint();
    }
    return valor * 2;
}

Stack traces automáticos

Em modo Debug, o Zig gera stack traces automáticos para erros não tratados e unreachable:

fn processar(dados: ?[]const u8) !void {
    const conteudo = dados orelse return error.DadosNulos;
    // ...
    _ = conteudo;
}

pub fn main() !void {
    try processar(null);
    // Imprime stack trace completo com arquivo e linha
}

Comparativo: Quando Usar Cada Ferramenta

FerramentaMelhor paraPlataformaOverhead
GPA (Zig)Memory leaks, double-freeTodasBaixo
GDB/LLDBBreakpoints, inspeçãoTodasNenhum
perfCPU profiling, flame graphsLinuxMínimo
TracyProfiling em tempo realLinux/WindowsBaixo
ValgrindAnálise profunda de memóriaLinuxAlto (~20x)
CachegrindCache misses, branch predictionLinuxAlto

Conclusão

Depurar e perfilar programas Zig é uma experiência produtiva graças à combinação de ferramentas nativas (GPA, stack traces, @breakpoint()) com ferramentas do ecossistema C/C++ (GDB, perf, Tracy, Valgrind). O fato de o Zig gerar binários nativos com DWARF completo significa que toda ferramenta que funciona com C funciona com Zig — sem adaptadores ou plugins.

Para aprofundar, explore nossos artigos sobre gerenciamento de memória, testes em Zig e error handling. Se você vem de linguagens como Rust ou Go, vai notar que o Zig dá mais controle manual sobre o processo de depuração — e isso é intencional. Em linguagens de sistemas, visibilidade total sobre memória e performance não é luxo, é necessidade.

Continue aprendendo Zig

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