Benchmarking e profiling de testes em Go
Como escrever benchmarks confiáveis com testing.B, ler métricas de alocação e evitar que o compilador elimine o código medido.
Publicada em · 9 min de leitura
No desenvolvimento de software de alta performance, existe uma máxima clássica formulada por Rob Pike (um dos criadores do Go): “Adivinhar onde o programa passa o tempo quase sempre está errado. Meça antes de otimizar.”
Enquanto muitas linguagens exigem frameworks complexos ou ferramentas externas pagas para medir tempo de execução, o Go possui suporte nativo e de primeira classe a Benchmarks e Profiling integrado ao próprio comando go test. Com apenas algumas linhas de código, você descobre com precisão de nanosegundos quanto tempo uma função consome, quantos bytes de memória ela aloca no Heap e quantas vezes ela força o acionamento do Garbage Collector.
Esta aula ensina como projetar benchmarks confiáveis, como interpretar as métricas de alocação de memória, como evitar que o compilador elimine seu código por otimização de código morto e como utilizar as ferramentas profissionais benchstat e pprof para validar otimizações com rigor estatístico.
O Mapa da Aula
| Parte | Seção | O que você vai dominar |
|---|---|---|
| 1. Fundamentos | 1 e 2 | A assinatura func BenchmarkXxx(b *testing.B), o loop canônico e a mecânica adaptativa do b.N |
| 2. Métricas de Memória | 3 e 4 | Ativação de b.ReportAllocs(), a diferença entre B/op e allocs/op e o ideal de zero alocações |
| 3. Controle de Tempo | 5 e 6 | Isolando setup caro com b.ResetTimer(), StopTimer e benchmarks paralelos com b.RunParallel |
| 4. Armadilhas do Compilador | 7 | Como o otimizador SSA elimina laços de benchmark e como contornar usando variáveis globais |
| 5. Comparação e Diagnóstico | 8 a 10 | Análise estatística com benchstat, geração de perfis de CPU/Heap e inspeção visual com pprof |
Pré-requisitos recomendados: Table-Driven Tests e Paralelismo e internals/PProf.
Parte 1: Fundamentos e a Mecânica do b.N
1. A anatomia básica de um Benchmark
Funções de benchmark residem nos mesmos arquivos de teste terminados em _test.go, mas seguem três regras sintáticas próprias:
- O nome da função deve iniciar com o prefixo
Benchmark(em maiúscula). - A função recebe como único parâmetro um ponteiro
b *testing.B. - O corpo do benchmark contém um laço que itera exatamente
b.Nvezes.
func BenchmarkMinhaFuncao(b *testing.B) {
for i := 0; i < b.N; i++ {
MinhaFuncao()
}
}
Para rodar benchmarks, usamos a flag -bench informando uma expressão regular que filtra os testes desejados (use -bench=. para executar todos):
go test -bench=.
2. Como o runtime do Go ajusta o b.N
A variável b.N não possui um número fixo definido por você. O test runner do Go ajusta o valor de b.N dinamicamente e de forma adaptativa durante a execução para que o benchmark rode por tempo suficiente (por padrão, 1 segundo) até atingir uma amostragem estatística estável:
- O Go inicia com
b.N = 1e mede o tempo da primeira chamada. - Se a chamada levou apenas alguns microssegundos, o runner percebe que 1 execução não é suficiente para eliminar o ruído do sistema operacional.
- Ele recalcula e dispara uma nova rodada aumentando
b.N(ex: 100, 10.000, 1.000.000) até que o tempo total acumulado alcance pelo menos 1 segundo. - Ao final, ele divide o tempo total pelo número final de iterações $b.N$ para reportar a média por operação (
ns/op).
Parte 2: Medindo Memória e Alocações no Heap
3. O relatório de alocações: B/op e allocs/op
Velocidade em Go está intimamente ligada ao gerenciamento de memória. Quando uma função aloca variáveis no Heap em vez da Stack, o Garbage Collector precisa entrar em ação posteriormente, causando pequenas pausas e consumo de ciclos de CPU.
Para medir memória, você pode adicionar a chamada b.ReportAllocs() dentro do código do benchmark ou passar a flag -benchmem no terminal:
go test -bench=. -benchmem
Interpretando a saída do terminal:
BenchmarkProcessar-10 2346918 507.7 ns/op 896 B/op 1 allocs/op
BenchmarkProcessar-10: O nome do benchmark seguido pelo sufixo-10, que representaGOMAXPROCS(quantidade de núcleos lógicos da CPU utilizados).2346918: A quantidade de vezes que o laço rodou durante o teste ($b.N$).507.7 ns/op: A duração média de cada operação em nanosegundos (menor é melhor).896 B/op: A quantidade média de bytes alocados na memória Heap por operação.1 allocs/op: A quantidade média de alocações físicas distintas realizadas no Heap por operação.
4. O objetivo sagrado: 0 allocs/op
Em serviços de alta vazão (gateways de pagamento, proxies reversos, parsers de telemetria), o objetivo dos engenheiros seniores é atingir 0 allocs/op nos caminhos críticos (hot paths).
Isso significa que todas as variáveis locais viveram exclusivamente na pilha de execução (Stack), sem escapar para o Heap e sem onerar o Garbage Collector.
Parte 3: Controle Fino de Tempo e Concorrência
5. Isolando o tempo de setup com b.ResetTimer()
É muito comum precisarmos carregar arquivos, popular coleções em memória ou gerar dados aleatórios antes de iniciar o teste real. Se esse tempo de preparação for computado, ele distorcerá completamente o resultado:
func BenchmarkProcessarLista(b *testing.B) {
// 1. Preparação pesada fora da medição
dados := gerarMassaDeDadosPesada(100_000)
// 2. Zera os cronômetros de tempo e contadores de memória!
b.ResetTimer()
// 3. Mede exclusivamente a função sob teste
for i := 0; i < b.N; i++ {
ProcessarLista(dados)
}
}
Se você precisar pausar temporariamente a medição dentro do próprio loop para resetar o estado entre iterações, use b.StopTimer() e b.StartTimer().
6. Benchmarks Concorrentes com b.RunParallel
Para avaliar como seu código escala quando dezenas de goroutines tentam acessá-lo ao mesmo tempo (testando contenção de mutexes ou estruturas lock-free), o Go oferece o método b.RunParallel:
func BenchmarkCacheConcorrente(b *testing.B) {
cache := NovoCache()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
// Cada goroutine roda este loop até esgotar o tempo
for pb.Next() {
cache.Get("chave-popular")
}
})
}
Parte 4: A Armadilha do Compilador (Eliminação de Código Morto)
7. Como o SSA pode enganar seu benchmark
O compilador moderno do Go possui um otimizador avançado baseado em SSA (Static Single Assignment) que aplica uma técnica agressiva chamada Dead Code Elimination (Eliminação de Código Morto).
Se você chamar uma função que realiza cálculos pesados, mas não armazenar nem utilizar o resultado retornado, o compilador percebe que aquela computação não gera nenhum efeito colateral observável e apaga o código de dentro do loop!
// ⚠️ CUIDADO: Benchmark falso!
func BenchmarkCalculoPesado(b *testing.B) {
for i := 0; i < b.N; i++ {
CalculoPesado(100) // O compilador apaga esta linha!
}
}
Nesse cenário enganoso, o benchmark reportará velocidades absurdas de 0.3 ns/op, mas nenhuma linha de código real foi executada.
Como evitar a eliminação de código:
Declare uma variável global não exportada no pacote de teste e atribua o resultado do cálculo a ela a cada iteração:
// Variável no escopo do pacote de testes
var resultadoGlobal int
func BenchmarkCalculoPesado(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
r = CalculoPesado(100)
}
// Força o compilador a manter a computação viva
resultadoGlobal = r
}
Parte 5: Comparação Estatística com benchstat
Executar um benchmark apenas uma vez pode enganar você: oscilações térmicas da CPU, processos em segundo plano do sistema operacional ou trocas de contexto podem fazer um teste parecer 10% mais rápido ou mais lento por puro acaso.
A ferramenta oficial recomendada pela equipe do Go para validar melhorias de desempenho com rigor estatístico é o benchstat:
8. Como coletar e comparar amostras
- Instale o
benchstat:go install golang.org/x/perf/cmd/benchstat@latest - Colete 10 repetições do seu código atual (versão original):
go test -bench=BenchmarkOperacao -benchmem -count=10 > old.txt - Aplique sua otimização no código Go.
- Colete 10 repetições da versão otimizada:
go test -bench=BenchmarkOperacao -benchmem -count=10 > new.txt - Execute a comparação científica:
benchstat old.txt new.txt
O que o benchstat exibe:
name old time/op new time/op delta
Operacao-10 10.05µs ± 2% 0.51µs ± 1% -94.95% (p=0.000 n=10+10)
name old alloc/op new alloc/op delta
Operacao-10 46.8kB ± 0% 0.9kB ± 0% -98.09% (p=0.000 n=10+10)
name old allocs/op new allocs/op delta
Operacao-10 99.0 ± 0% 1.0 ± 0% -98.99% (p=0.000 n=10+10)
O benchstat calcula a média, a margem de erro ($\pm$) e o valor $p$ (p-value) estatístico (através do teste Mann-Whitney U). Se $p < 0.05$, a melhoria é cientificamente comprovada e não fruto de oscilação aleatória.
Parte 6: Gerando Perfis Visuais com pprof
Quando um benchmark mostra que uma função está lenta, você não precisa chutar quais linhas de código são as culpadas. Você pode instruir o Go a capturar o perfil exato de consumo durante o próprio benchmark:
# Gera o perfil de CPU e o perfil de alocação de memória durante o benchmark
go test -bench=BenchmarkConcatenar -cpuprofile=cpu.out -memprofile=mem.out
Para inspecionar visualmente no navegador:
go tool pprof -http=:8080 cpu.out
Isso abrirá uma interface web interativa com o famoso Gráfico de Chamas (Flame Graph), permitindo identificar visualmente quais funções da call stack ocupam a maior área horizontal de processamento.
Parte 7: Código Prático Completo de Produção
Vamos comparar cientificamente duas abordagens para concatenar uma fatia de 100 palavras:
- Abordagem Ingênua: Usando o operador
+repetidamente (gera dezenas de alocações transitórias). - Abordagem Otimizada: Usando
strings.Buildercom capacidade pré-alocada (builder.Grow).
O Código de Produção (stringutil.go):
package stringutil
import "strings"
// ConcatenarIngenuo concatena N strings usando operador +.
// Ineficiente: a cada iteração cria uma nova string no Heap.
func ConcatenarIngenuo(palavras []string) string {
var s string
for _, p := range palavras {
s += p
}
return s
}
// ConcatenarOtimizado pré-aloca a capacidade exata em um strings.Builder.
// Altamente eficiente: realiza apenas 1 alocação de memória.
func ConcatenarOtimizado(palavras []string) string {
totalBytes := 0
for _, p := range palavras {
totalBytes += len(p)
}
var builder strings.Builder
builder.Grow(totalBytes) // Reserva os bytes necessários de uma só vez
for _, p := range palavras {
builder.WriteString(p)
}
return builder.String()
}
O Arquivo de Benchmark (stringutil_test.go):
package stringutil_test
import (
"strconv"
"testing"
"meu-modulo/stringutil"
)
// Previne eliminação de código morto pelo compilador
var globalResult string
func gerarMassaPalavras(n int) []string {
palavras := make([]string, n)
for i := 0; i < n; i++ {
palavras[i] = "palavra_" + strconv.Itoa(i)
}
return palavras
}
func BenchmarkConcatenarIngenuo(b *testing.B) {
palavras := gerarMassaPalavras(100)
b.ResetTimer()
b.ReportAllocs() // Registra B/op e allocs/op
var s string
for i := 0; i < b.N; i++ {
s = stringutil.ConcatenarIngenuo(palavras)
}
globalResult = s
}
func BenchmarkConcatenarOtimizado(b *testing.B) {
palavras := gerarMassaPalavras(100)
b.ResetTimer()
b.ReportAllocs()
var s string
for i := 0; i < b.N; i++ {
s = stringutil.ConcatenarOtimizado(palavras)
}
globalResult = s
}
O Resultado Real Medido em Terminal:
$ go test -bench=. -benchmem
goos: darwin
goarch: arm64
cpu: Apple M1 Max
BenchmarkConcatenarIngenuo-10 125091 10052 ns/op 46840 B/op 99 allocs/op
BenchmarkConcatenarOtimizado-10 2346918 507.7 ns/op 896 B/op 1 allocs/op
Análise dos Fatos:
- A abordagem com
strings.Builder.Grow()foi quase 20 vezes mais rápida ($507$ ns vs $10.052$ ns). - Reduziu o consumo de memória de 46,8 KB para menos de 1 KB por execução.
- Reduziu as alocações de 99 alocações no Heap para apenas 1 alocação única, aliviando em 99% a carga do Garbage Collector!
Parte 8: Checklist de Boas Práticas
- Sempre use
-benchmemoub.ReportAllocs(): Tempo em nanossegundos sozinho pode mascarar gargalos de coleta de lixo. Olhe sempre paraallocs/op. - Nunca compare benchmarks com uma única execução: Use sempre
benchstatcom-count=10para comprovar que a mudança é estatisticamente significativa. - Sempre use
b.ResetTimer()após operações de setup: Carregamento de fixtures, JSONs ou structs de apoio devem ficar fora do relógio. - Proteja-se contra Dead Code Elimination: Atribua o resultado a uma variável global de pacote de teste.
- Cuidado com CPU Throttling: Não rode benchmarks em notebooks em modo de economia de bateria ou durante compilações pesadas em segundo plano.
Exercício Prático Proposto
- Implemente duas funções de parsing de inteiros em slice:
- Uma usando
appendem slice dinâmico vazio (var nums []int). - Outra pré-alocando a capacidade com
make([]int, 0, n).
- Uma usando
- Escreva benchmarks para fatias de 10.000 elementos.
- Use
-benchmeme compare a redução drástica deallocs/ope tempo total. - Gere o arquivo
mem.oute inspecione comgo tool pprof.