Go Admin

Como Usar Benchmarks e Profiling em Go: testing.B e pprof na Prática em Produção Já leu

Introdução aos Benchmarks em Go Quando desenvolvemos software em Go, é comum nos deparar com a necessidade de otimizar nosso código. Mas como saber se nossas otimizações realmente funcionam? A resposta está em medir. Go oferece ferramentas nativas e poderosas para isso, e começamos com o pacote , que não apenas executa testes, mas também nos permite criar benchmarks robustos e confiáveis. Um benchmark é essencialmente um teste que executa uma função repetidamente e mede quanto tempo leva, quantas alocações de memória ocorrem e outras métricas. Diferentemente de um teste unitário que verifica se algo está correto, um benchmark responde à pergunta: "quão rápido isso é?". Isso é fundamental quando você está otimizando um algoritmo crítico ou decidindo entre duas implementações. Testing.B: Escrevendo Benchmarks Profissionais Estrutura Básica de um Benchmark Um benchmark em Go é uma função que segue o padrão . A interface nos fornece um loop que executa nosso código várias vezes enquanto coleta informações sobre tempo e

Introdução aos Benchmarks em Go

Quando desenvolvemos software em Go, é comum nos deparar com a necessidade de otimizar nosso código. Mas como saber se nossas otimizações realmente funcionam? A resposta está em medir. Go oferece ferramentas nativas e poderosas para isso, e começamos com o pacote testing, que não apenas executa testes, mas também nos permite criar benchmarks robustos e confiáveis.

Um benchmark é essencialmente um teste que executa uma função repetidamente e mede quanto tempo leva, quantas alocações de memória ocorrem e outras métricas. Diferentemente de um teste unitário que verifica se algo está correto, um benchmark responde à pergunta: "quão rápido isso é?". Isso é fundamental quando você está otimizando um algoritmo crítico ou decidindo entre duas implementações.

Testing.B: Escrevendo Benchmarks Profissionais

Estrutura Básica de um Benchmark

Um benchmark em Go é uma função que segue o padrão BenchmarkNomeDoTeste(b *testing.B). A interface *testing.B nos fornece um loop que executa nosso código várias vezes enquanto coleta informações sobre tempo e memória. O mais importante é usar b.N — uma variável que Go ajusta automaticamente para garantir que o benchmark rode tempo suficiente (geralmente alguns segundos).

Vamos começar com um exemplo prático. Imagine que temos duas funções para buscar o maior número em um slice:

package main

import (
    "testing"
)

// Implementação 1: iteração simples
func FindMaxSimple(nums []int) int {
    if len(nums) == 0 {
        return 0
    }
    max := nums[0]
    for _, num := range nums {
        if num > max {
            max = num
        }
    }
    return max
}

// Implementação 2: com early return (não é melhor neste caso, mas ilustra)
func FindMaxOptimized(nums []int) int {
    if len(nums) == 0 {
        return 0
    }
    max := nums[0]
    for i := 1; i < len(nums); i++ {
        if nums[i] > max {
            max = nums[i]
        }
    }
    return max
}

// Benchmark da implementação simples
func BenchmarkFindMaxSimple(b *testing.B) {
    nums := make([]int, 10000)
    for i := 0; i < len(nums); i++ {
        nums[i] = i
    }

    b.ResetTimer() // Reseta o timer, ignorando o tempo de setup
    for i := 0; i < b.N; i++ {
        FindMaxSimple(nums)
    }
}

// Benchmark da implementação otimizada
func BenchmarkFindMaxOptimized(b *testing.B) {
    nums := make([]int, 10000)
    for i := 0; i < len(nums); i++ {
        nums[i] = i
    }

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        FindMaxOptimized(nums)
    }
}

Para executar esses benchmarks, você usa:

go test -bench=. -benchmem

A flag -bench=. executa todos os benchmarks, e -benchmem mostra alocações de memória. A saída será algo como:

BenchmarkFindMaxSimple-8      500000    2150 ns/op      0 B/op    0 allocs/op
BenchmarkFindMaxOptimized-8   500000    2140 ns/op      0 B/op    0 allocs/op

Isso significa que cada operação leva aproximadamente 2150 nanossegundos, e nenhuma alocação de memória ocorre.

Técnicas Avançadas com testing.B

Às vezes você quer fazer benchmarks mais sofisticados. A interface testing.B oferece vários métodos úteis além de ResetTimer(). Vamos explorar um cenário real: benchmarking de diferentes estratégias de parsing de JSON.

package main

import (
    "encoding/json"
    "testing"
)

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

var jsonData = `{"id":1,"name":"João Silva","email":"joao@example.com"}`

// Benchmark básico com alocação no loop
func BenchmarkUnmarshalBasic(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var user User
        json.Unmarshal([]byte(jsonData), &user)
    }
}

// Benchmark reutilizando o slice de bytes
func BenchmarkUnmarshalOptimized(b *testing.B) {
    data := []byte(jsonData)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        var user User
        json.Unmarshal(data, &user)
    }
}

// Sub-benchmarks para diferentes tamanhos de JSON
func BenchmarkUnmarshalVariousSizes(b *testing.B) {
    tests := []struct {
        name string
        json string
    }{
        {"Small", `{"id":1,"name":"A","email":"a@a.com"}`},
        {"Medium", `{"id":1,"name":"João da Silva","email":"joao.silva@company.com"}`},
        {"Large", `{"id":1,"name":"Maria Clara da Silva Santos","email":"maria.clara.silva.santos@very-long-company-name.com.br"}`},
    }

    for _, test := range tests {
        b.Run(test.name, func(b *testing.B) {
            data := []byte(test.json)
            for i := 0; i < b.N; i++ {
                var user User
                json.Unmarshal(data, &user)
            }
        })
    }
}

Quando você executa go test -bench=BenchmarkUnmarshalVariousSizes -benchmem, verá:

BenchmarkUnmarshalVariousSizes/Small-8       500000    2410 ns/op     288 B/op    3 allocs/op
BenchmarkUnmarshalVariousSizes/Medium-8      500000    2680 ns/op     336 B/op    3 allocs/op
BenchmarkUnmarshalVariousSizes/Large-8       500000    3050 ns/op     432 B/op    3 allocs/op

Este padrão é excelente porque permite comparar o desempenho sob diferentes condições sem duplicar código. Note também que b.ResetTimer() é essencial quando você faz setup que não quer medir — como criar slices ou dados de entrada.

Profiling com pprof: Descobrindo Gargalos Reais

CPU Profiling: Encontrando Onde o Tempo Vai

Benchmarks nos dão números, mas pprof nos mostra exatamente onde nosso programa está gastando tempo. O profiler de CPU é a ferramenta mais comum para começar. Vamos criar um exemplo que simula um problema real: processamento ineficiente de strings.

package main

import (
    "strings"
)

// Função ineficiente: cria muitas strings intermediárias
func ProcessStringsInefficient(texts []string) []string {
    result := make([]string, len(texts))
    for i, text := range texts {
        // Cada operação de string cria uma nova alocação
        processed := text
        processed = strings.ToUpper(processed)
        processed = strings.TrimSpace(processed)
        processed = strings.ReplaceAll(processed, "A", "X")
        result[i] = processed
    }
    return result
}

// Função eficiente: operações em pipeline
func ProcessStringsEfficient(texts []string) []string {
    result := make([]string, len(texts))
    for i, text := range texts {
        // Reutiliza a string através de operações encadeadas
        result[i] = strings.ReplaceAll(
            strings.TrimSpace(strings.ToUpper(text)),
            "A",
            "X",
        )
    }
    return result
}

Para fazer CPU profiling, você executa:

go test -bench=BenchmarkProcess -cpuprofile=cpu.prof

Isso gera um arquivo cpu.prof. Então você analisa com:

go tool pprof cpu.prof

Isso abre um prompt interativo. Digite top para ver as funções que usam mais CPU:

(pprof) top
Showing nodes accounting for 1250ms, 95.5% of 1310ms total
      flat  flat%   sum%        cum   cum%
     800ms 61.1% 61.1%      850ms 64.9%  strings.(*Builder).grow
     250ms 19.1% 80.2%      250ms 19.1%  runtime.memclr
     150ms 11.5% 91.7%      950ms 72.5%  strings.(*Builder).WriteRune
      50ms  3.8% 95.5%     1250ms 95.5%  main.ProcessStringsInefficient

Você também pode gerar um gráfico (se tiver Graphviz instalado):

go tool pprof -http=:8080 cpu.prof

Isso abre uma interface web mostrando as relações de chamadas e quanto tempo cada uma leva.

Memory Profiling: Encontrando Vazamentos e Alocações Desnecessárias

Às vezes o problema não é CPU, mas memória. Go oferece memory profiling que detecta alocações excessivas. Vamos criar um benchmark que gera bastante lixo:

package main

import (
    "testing"
)

// Aloca muitas strings temporárias
func BuildStringInefficient(n int) string {
    result := ""
    for i := 0; i < n; i++ {
        result = result + "item" // Cria uma nova string a cada iteração
    }
    return result
}

// Usa strings.Builder, muito mais eficiente
func BuildStringEfficient(n int) string {
    var builder strings.Builder
    for i := 0; i < n; i++ {
        builder.WriteString("item")
    }
    return builder.String()
}

func BenchmarkBuildStringInefficient(b *testing.B) {
    for i := 0; i < b.N; i++ {
        BuildStringInefficient(1000)
    }
}

func BenchmarkBuildStringEfficient(b *testing.B) {
    for i := 0; i < b.N; i++ {
        BuildStringEfficient(1000)
    }
}

Execute com:

go test -bench=BuildString -benchmem -memprofile=mem.prof

Analise com:

go tool pprof mem.prof
go tool pprof -http=:8080 mem.prof

A saída mostrará algo como:

Showing nodes accounting for 25.5MB, 98.1% of 26MB total
      flat  flat%   sum%        cum   cum%
      24MB 92.3% 92.3%       24MB 92.3%  main.BuildStringInefficient
    1.5MB  5.8% 98.1%      1.5MB  5.8%  main.BuildStringEfficient

A função ineficiente está alocando 24MB enquanto a eficiente usa apenas 1.5MB — uma diferença colossal! Isso ilustra por que profiling é essencial: algumas otimizações têm impacto real.

Análise Combinada: CPU + Memória

Em cenários do mundo real, você quer ambas as informações. Aqui está como executar e analisar um benchmark completo:

# Executa benchmarks com profiling de CPU e memória
go test -bench=. -benchtime=5s -cpuprofile=cpu.prof -memprofile=mem.prof

# Analisa CPU
go tool pprof cpu.prof

# Dentro do pprof, você pode:
# list <funcname> - mostra o código com tempo por linha
# web - gera um gráfico (precisa de graphviz)
# top - mostra as top funções

# Para comparar dois profiles (útil para antes/depois de otimização):
go test -bench=. -benchmem > before.txt
# ... faz suas otimizações ...
go test -bench=. -benchmem > after.txt
benchstat before.txt after.txt

O benchstat é particularmente poderoso:

go install golang.org/x/perf/cmd/benchstat@latest
benchstat before.txt after.txt

Saída esperada:

name                        old time/op    new time/op    delta
BuildStringInefficient-8    5.42ms ± 2%    0.18ms ± 1%  -96.68%  (p=0.000 n=10)
BuildStringEfficient-8      0.17ms ± 1%    0.17ms ± 1%   -0.29%  (p=0.562 n=10)

name                        old alloc/op   new alloc/op   delta
BuildStringInefficient-8    18.2MB ± 0%    18.2MB ± 0%     0.00%  (p=1.000 n=10)
BuildStringEfficient-8      1.51MB ± 0%    1.51MB ± 0%     0.00%  (p=1.000 n=10)

Isso quantifica exatamente a melhoria — 96.68% mais rápido neste caso!

Integração com CI/CD e Boas Práticas

Automatizando Benchmarks na Pipeline

Em um projeto profissional, você quer acompanhar benchmarks ao longo do tempo. Uma abordagem comum é manter histórico de resultados:

#!/bin/bash
# benchmark.sh

TIMESTAMP=$(date +%Y%m%d_%H%M%S)
OUTPUT_DIR="benchmark_results"

mkdir -p "$OUTPUT_DIR"

# Executa benchmarks e salva resultado
go test -bench=. -benchmem -run=^$ > "$OUTPUT_DIR/result_${TIMESTAMP}.txt"

# Se você tem um resultado anterior, compara
if [ -f "$OUTPUT_DIR/baseline.txt" ]; then
    benchstat "$OUTPUT_DIR/baseline.txt" "$OUTPUT_DIR/result_${TIMESTAMP}.txt"
fi

# Atualiza baseline
cp "$OUTPUT_DIR/result_${TIMESTAMP}.txt" "$OUTPUT_DIR/baseline.txt"

Adicione isso ao seu Makefile:

.PHONY: bench
bench:
    go test -bench=. -benchmem -benchtime=3s

.PHONY: profile
profile:
    go test -bench=. -cpuprofile=cpu.prof -memprofile=mem.prof
    go tool pprof -http=:8080 cpu.prof

.PHONY: bench-compare
bench-compare:
    @if [ ! -f /tmp/bench_before.txt ]; then \
        echo "Running baseline benchmark..."; \
        go test -bench=. -benchmem > /tmp/bench_before.txt; \
    fi
    @go test -bench=. -benchmem > /tmp/bench_after.txt
    @echo "Comparison results:"
    @benchstat /tmp/bench_before.txt /tmp/bench_after.txt

Padrões de Benchmark Realistas

Um erro comum é fazer benchmarks que não refletem uso real. Aqui está um exemplo de benchmark bem feito para uma cache:

package cache

import (
    "testing"
)

type Cache struct {
    data map[string]interface{}
}

func NewCache() *Cache {
    return &Cache{data: make(map[string]interface{})}
}

func (c *Cache) Set(key string, value interface{}) {
    c.data[key] = value
}

func (c *Cache) Get(key string) (interface{}, bool) {
    val, ok := c.data[key]
    return val, ok
}

// Benchmark realista: mistura de reads e writes (80/20)
func BenchmarkCacheRealistic(b *testing.B) {
    cache := NewCache()

    // Pré-popula com dados
    for i := 0; i < 1000; i++ {
        cache.Set("key"+string(rune(i)), i)
    }

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        if i%5 == 0 {
            // 20% writes
            cache.Set("key_new", i)
        } else {
            // 80% reads
            cache.Get("key500")
        }
    }
}

// Benchmark paralelo: simula concorrência real
func BenchmarkCacheParallel(b *testing.B) {
    cache := NewCache()

    for i := 0; i < 1000; i++ {
        cache.Set("key"+string(rune(i)), i)
    }

    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        i := 0
        for pb.Next() {
            if i%5 == 0 {
                cache.Set("key_new", i)
            } else {
                cache.Get("key500")
            }
            i++
        }
    })
}

Execute com:

go test -bench=Cache -benchmem -benchtime=5s

O RunParallel é crucial — ele simula múltiplas goroutines usando a mesma função, o que é muito mais realista que um benchmark sequencial.

Conclusão

Dominar benchmarking e profiling em Go é essencial para escrever software robusto e performático. Primeiro, use testing.B para medir quantitativamente o desempenho do seu código — é simples, nativo e oferece resultados confiáveis. Segundo, quando números não bastam, use pprof para descobrir exatamente onde o tempo e a memória estão sendo gastos, revelando gargalos que não são óbvios. Terceiro, integre essas práticas na sua pipeline de CI/CD e sempre compare antes/depois de otimizações usando benchstat — isso transforma profiling de uma atividade ocasional em uma disciplina contínua.

Referências


Artigos relacionados