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.