Otimização Real: Estudo de Caso em Zig — De 200ms para 12ms

Teoria sem pratica nao otimiza nada. Neste artigo final da serie, vamos otimizar um projeto real passo a passo — um processador de logs que analisa milhoes de linhas — aplicando todas as tecnicas que aprendemos: benchmarking, cache-friendly layout, SIMD e profiling. Cada etapa inclui medicoes antes e depois.

Continuacao do artigo sobre ferramentas de profiling.

O Projeto: Analisador de Logs

Nosso projeto e um analisador de logs de acesso HTTP que precisa:

  1. Ler linhas de log do formato: IP METODO URL STATUS TEMPO_MS
  2. Contar requisicoes por status code (200, 404, 500, etc.)
  3. Calcular tempo medio de resposta
  4. Encontrar os IPs com mais requisicoes

Meta: processar 10 milhoes de linhas o mais rapido possivel.

Versao Inicial: Ingênua

const std = @import("std");

const LogEntry = struct {
    ip: []const u8,
    metodo: []const u8,
    url: []const u8,
    status: u16,
    tempo_ms: f64,
};

const Estatisticas = struct {
    total_requests: u64 = 0,
    soma_tempo: f64 = 0,
    contagem_status: std.AutoHashMap(u16, u64),
    contagem_ips: std.StringHashMap(u64),
    allocator: std.mem.Allocator,

    pub fn init(allocator: std.mem.Allocator) Estatisticas {
        return .{
            .contagem_status = std.AutoHashMap(u16, u64).init(allocator),
            .contagem_ips = std.StringHashMap(u64).init(allocator),
            .allocator = allocator,
        };
    }

    pub fn deinit(self: *Estatisticas) void {
        self.contagem_status.deinit();
        // Liberar chaves alocadas
        var it = self.contagem_ips.iterator();
        while (it.next()) |entry| {
            self.allocator.free(entry.key_ptr.*);
        }
        self.contagem_ips.deinit();
    }

    pub fn registrar(self: *Estatisticas, entry: LogEntry) !void {
        self.total_requests += 1;
        self.soma_tempo += entry.tempo_ms;

        // Contagem por status
        const status_count = try self.contagem_status.getOrPut(entry.status);
        if (!status_count.found_existing) status_count.value_ptr.* = 0;
        status_count.value_ptr.* += 1;

        // Contagem por IP
        const ip_count = try self.contagem_ips.getOrPut(
            try self.allocator.dupe(u8, entry.ip),
        );
        if (!ip_count.found_existing) ip_count.value_ptr.* = 0;
        ip_count.value_ptr.* += 1;
    }

    pub fn tempoMedio(self: Estatisticas) f64 {
        if (self.total_requests == 0) return 0;
        return self.soma_tempo / @as(f64, @floatFromInt(self.total_requests));
    }
};

/// Versao V1: implementacao ingenua
fn parseLine(linha: []const u8) !LogEntry {
    var partes = std.mem.splitScalar(u8, linha, ' ');

    const ip = partes.next() orelse return error.FormatoInvalido;
    const metodo = partes.next() orelse return error.FormatoInvalido;
    const url = partes.next() orelse return error.FormatoInvalido;
    const status_str = partes.next() orelse return error.FormatoInvalido;
    const tempo_str = partes.next() orelse return error.FormatoInvalido;

    return .{
        .ip = ip,
        .metodo = metodo,
        .url = url,
        .status = try std.fmt.parseInt(u16, status_str, 10),
        .tempo_ms = try std.fmt.parseFloat(f64, tempo_str),
    };
}

fn analisarV1(dados: []const u8, allocator: std.mem.Allocator) !Estatisticas {
    var stats = Estatisticas.init(allocator);

    var linhas = std.mem.splitScalar(u8, dados, '\n');
    while (linhas.next()) |linha| {
        if (linha.len == 0) continue;
        const entry = parseLine(linha) catch continue;
        try stats.registrar(entry);
    }

    return stats;
}

Benchmark V1

V1 (Ingênua): 10M linhas em 4.200 ms
  - Parse: 1.800 ms
  - Estatísticas: 2.400 ms
  - Memória: 890 MB

Otimizacao 1: Reduzir Alocacoes

O maior gargalo e a alocacao de strings para cada IP. Vamos usar um string intern pool:

const StringPool = struct {
    strings: std.StringHashMap(void),
    allocator: std.mem.Allocator,

    pub fn init(allocator: std.mem.Allocator) StringPool {
        return .{
            .strings = std.StringHashMap(void).init(allocator),
            .allocator = allocator,
        };
    }

    pub fn deinit(self: *StringPool) void {
        var it = self.strings.keyIterator();
        while (it.next()) |key| {
            self.allocator.free(key.*);
        }
        self.strings.deinit();
    }

    /// Retorna ponteiro estavel para string interned
    pub fn intern(self: *StringPool, str: []const u8) ![]const u8 {
        if (self.strings.getKey(str)) |existing| {
            return existing; // Ja existe, reutilizar
        }
        const owned = try self.allocator.dupe(u8, str);
        try self.strings.put(owned, {});
        return owned;
    }
};

/// V2: com string interning
fn analisarV2(dados: []const u8, allocator: std.mem.Allocator) !Estatisticas {
    var pool = StringPool.init(allocator);
    defer pool.deinit();

    var stats = Estatisticas.init(allocator);

    var linhas = std.mem.splitScalar(u8, dados, '\n');
    while (linhas.next()) |linha| {
        if (linha.len == 0) continue;
        var entry = parseLine(linha) catch continue;
        entry.ip = try pool.intern(entry.ip);
        try stats.registrar(entry);
    }

    return stats;
}
V2 (String interning): 10M linhas em 2.800 ms (-33%)
  - Memória: 320 MB (-64%)

Otimizacao 2: Parser Otimizado

Em vez de usar std.fmt.parseFloat, que e generico, escrevemos um parser especializado para nosso formato:

/// Parser rapido de inteiros — sem tratamento de erros generico
fn parseIntFast(str: []const u8) u16 {
    var resultado: u16 = 0;
    for (str) |c| {
        resultado = resultado * 10 + @as(u16, c - '0');
    }
    return resultado;
}

/// Parser rapido de float simples (formato: DDDD.DD)
fn parseFloatFast(str: []const u8) f64 {
    var parte_inteira: u64 = 0;
    var parte_decimal: u64 = 0;
    var casas_decimais: u32 = 0;
    var no_decimal = false;

    for (str) |c| {
        if (c == '.') {
            no_decimal = true;
            continue;
        }
        if (no_decimal) {
            parte_decimal = parte_decimal * 10 + (c - '0');
            casas_decimais += 1;
        } else {
            parte_inteira = parte_inteira * 10 + (c - '0');
        }
    }

    var divisor: f64 = 1.0;
    for (0..casas_decimais) |_| divisor *= 10.0;

    return @as(f64, @floatFromInt(parte_inteira)) +
        @as(f64, @floatFromInt(parte_decimal)) / divisor;
}

/// V3: parser otimizado com busca manual de delimitadores
fn parseLineV3(linha: []const u8) !LogEntry {
    // Buscar espacos manualmente — mais rapido que split iterator
    var espacos: [4]usize = undefined;
    var count: usize = 0;

    for (linha, 0..) |c, i| {
        if (c == ' ') {
            if (count >= 4) break;
            espacos[count] = i;
            count += 1;
        }
    }

    if (count < 4) return error.FormatoInvalido;

    return .{
        .ip = linha[0..espacos[0]],
        .metodo = linha[espacos[0] + 1 .. espacos[1]],
        .url = linha[espacos[1] + 1 .. espacos[2]],
        .status = parseIntFast(linha[espacos[2] + 1 .. espacos[3]]),
        .tempo_ms = parseFloatFast(linha[espacos[3] + 1 ..]),
    };
}
V3 (Parser otimizado): 10M linhas em 1.600 ms (-43%)
  - Parse: 450 ms (-75% vs V1)

Otimizacao 3: Contagem com Array Fixo

Para status codes (que sao um conjunto pequeno e previsivel), substituir HashMap por array direto:

const EstatisticasV4 = struct {
    total_requests: u64 = 0,
    soma_tempo: f64 = 0,
    // Array direto indexado por status code (100-599)
    contagem_status: [600]u64 = [_]u64{0} ** 600,
    contagem_ips: std.StringHashMap(u64),
    allocator: std.mem.Allocator,

    pub fn registrar(self: *EstatisticasV4, entry: LogEntry) !void {
        self.total_requests += 1;
        self.soma_tempo += entry.tempo_ms;

        // Acesso direto — O(1), sem hash, sem colisao
        self.contagem_status[entry.status] += 1;

        // IPs ainda usam hashmap
        const result = try self.contagem_ips.getOrPut(entry.ip);
        if (!result.found_existing) result.value_ptr.* = 0;
        result.value_ptr.* += 1;
    }
};
V4 (Array para status): 10M linhas em 1.200 ms (-25%)

Otimizacao 4: Processamento SIMD para Busca de Newlines

Encontrar quebras de linha com SIMD:

/// Encontrar proxima newline usando SIMD
fn encontrarNewline(dados: []const u8) ?usize {
    const vec_len = 32;
    const newline_vec: @Vector(vec_len, u8) = @splat('\n');

    var i: usize = 0;
    while (i + vec_len <= dados.len) : (i += vec_len) {
        const bloco: @Vector(vec_len, u8) = dados[i..][0..vec_len].*;
        const mask = bloco == newline_vec;

        // Encontrar primeiro bit setado
        const bits: u32 = @bitCast(mask);
        if (bits != 0) {
            return i + @ctz(bits);
        }
    }

    // Tail loop
    while (i < dados.len) : (i += 1) {
        if (dados[i] == '\n') return i;
    }

    return null;
}

/// V5: split de linhas com SIMD
fn analisarV5(dados: []const u8, allocator: std.mem.Allocator) !EstatisticasV4 {
    var stats = EstatisticasV4{
        .contagem_ips = std.StringHashMap(u64).init(allocator),
        .allocator = allocator,
    };

    var pos: usize = 0;
    while (pos < dados.len) {
        const restante = dados[pos..];
        const fim_linha = encontrarNewline(restante) orelse restante.len;

        if (fim_linha > 0) {
            const entry = parseLineV3(restante[0..fim_linha]) catch {
                pos += fim_linha + 1;
                continue;
            };
            try stats.registrar(entry);
        }
        pos += fim_linha + 1;
    }

    return stats;
}
V5 (SIMD newline scan): 10M linhas em 850 ms (-29%)

Resultados Consolidados

VersaoTempoSpeedupMemoriaTecnica
V14.200 ms1.0x890 MBIngênua
V22.800 ms1.5x320 MBString interning
V31.600 ms2.6x320 MBParser otimizado
V41.200 ms3.5x315 MBArray para status
V5850 ms4.9x315 MBSIMD newline scan

Resultado final: 4.9x mais rapido, 65% menos memoria.

Quando Parar de Otimizar

Sinais de que Voce Deve Parar

  1. O codigo esta rapido o suficiente para os requisitos
  2. O gargalo mudou para I/O, rede ou banco de dados
  3. A legibilidade esta comprometida demais
  4. Os ganhos sao marginais (otimizar de 850ms para 820ms raramente justifica)

Trade-offs

// Legivel mas lento (V1):
var partes = std.mem.splitScalar(u8, linha, ' ');
const ip = partes.next() orelse return error.FormatoInvalido;

// Rapido mas menos legivel (V3):
var espacos: [4]usize = undefined;
var count: usize = 0;
for (linha, 0..) |c, i| {
    if (c == ' ') { espacos[count] = i; count += 1; if (count >= 4) break; }
}

A decisao de qual versao usar depende do contexto:

  • Script de uso ocasional: V1 e perfeito
  • Servico de producao com alto volume: V5 justifica a complexidade
  • Biblioteca publica: V3 oferece bom equilibrio

Checklist de Otimizacao

  1. Medir — Profile primeiro, identifique o hotspot real
  2. Algoritmo — A complexidade algoritmica e correta?
  3. Alocacoes — Pode reutilizar memoria em vez de alocar?
  4. Layout — Os dados estao cache-friendly (SoA)?
  5. Branches — Pode eliminar condicionais do hot path?
  6. SIMD — O loop processa dados independentes?
  7. Medir de novo — A otimizacao realmente melhorou?

Conclusao

Otimizacao de performance e um processo iterativo e mensuravel. Nesta serie, aprendemos a medir corretamente com benchmarking, organizar dados para maximizar cache hits, usar SIMD para processamento paralelo, e encontrar gargalos com ferramentas de profiling. O estudo de caso demonstrou que melhorias de 5x sao alcancaveis com tecnicas sistematicas em Zig.

Serie Completa

Esta e a conclusao da serie Otimizacao de Performance em Zig:

  1. Tecnicas de Benchmarking
  2. Codigo Cache-Friendly
  3. SIMD e Vetorizacao
  4. Ferramentas de Profiling
  5. Otimizacao Real: Estudo de Caso (este artigo)

Conteudo Relacionado

Continue aprendendo Zig

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