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:
| Modo | Otimização | Safety Checks | Tamanho | Uso |
|---|---|---|---|---|
| Debug | Nenhuma | Todas ativas | Grande | Desenvolvimento |
| ReleaseSafe | LLVM -O2 | Ativas | Médio | Produção (padrão recomendado) |
| ReleaseFast | LLVM -O3 | Desativadas | Médio | Performance crítica |
| ReleaseSmall | Tamanho | Desativadas | Pequeno | Sistemas 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.