← Voltar para o blog

Benchmarks em Go: testing.B, benchmem e pprof

Aprenda benchmarks em Go com testing.B, b.Loop, go test -bench, -benchmem, sub-benchmarks, alocações, pprof e armadilhas que distorcem resultados em produção.

Micro-otimização sem medição é opinião. Em Go, o caminho idiomático para medir um trecho de código é o benchmark nativo do pacote testing: funções BenchmarkXxx(b *testing.B), o comando go test -bench e, quando a pergunta muda de “quanto custa?” para “onde custa?”, o pprof.

Este guia cobre o fluxo completo de benchmarks em Go em 2026: como escrever com testing.B e b.Loop, como ler ns/op, B/op e allocs/op, como isolar setup, como evitar dead-code elimination, como gerar perfis a partir do próprio benchmark e quais armadilhas distorcem resultado em laptops e em CI. Ele complementa testes em tabela, fuzzing nativo, sync.Pool, PGO, go tool trace e o guia de testes.

Resposta rápida

PerguntaFerramenta
Quanto custa esta função por chamada?go test -bench
Quantas alocações ela faz?-benchmem ou b.ReportAllocs()
Onde a CPU/heap se concentra?pprof (a partir do benchmark ou do serviço)
Quando a latência explode no tempo?go tool trace / Flight Recorder
A implementação está correta sob entradas ruins?fuzz + testes de tabela

Regra prática: se você não consegue nomear a operação que está medindo (“marshal de Pedido com 3 itens”, “parse de JWT”, “encode JSON de resposta 2 KB”), ainda não está pronto para benchmark. Escreva o teste de correção primeiro; meça depois.

O menor benchmark possível

Coloque o arquivo no mesmo pacote (ou no pacote _test externo) e siga a convenção de nome:

package jsonutil_test

import (
	"encoding/json"
	"testing"
)

type Pedido struct {
	ID     string  `json:"id"`
	Total  float64 `json:"total"`
	Itens  int     `json:"itens"`
	Status string  `json:"status"`
}

func BenchmarkMarshalPedido(b *testing.B) {
	p := Pedido{
		ID:     "ped-42",
		Total:  199.90,
		Itens:  3,
		Status: "pago",
	}

	b.ReportAllocs()
	for b.Loop() {
		if _, err := json.Marshal(p); err != nil {
			b.Fatal(err)
		}
	}
}

Rode:

go test -bench=BenchmarkMarshalPedido -benchmem ./internal/jsonutil

Saída típica:

BenchmarkMarshalPedido-8    2500000    480.2 ns/op    128 B/op    2 allocs/op

Leitura:

  • -8: GOMAXPROCS no momento da execução (aqui, 8).
  • 2500000: iterações que o runner executou.
  • 480.2 ns/op: tempo médio por operação.
  • 128 B/op: bytes alocados por operação.
  • 2 allocs/op: alocações por operação.

Em APIs Go de produção no Brasil — checkout, PIX, webhooks, BFF — alocação costuma ser o primeiro número útil. Menos allocs/op significa menos pressão de GC sob carga. O Green Tea GC e o runtime moderno ajudam, mas não substituem não alocar à toa.

b.N versus b.Loop

A API clássica ainda aparece em muito código:

func BenchmarkConcatClassico(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = "go" + "lang"
	}
}

Funciona, mas tem armadilhas históricas: setup acidental dentro do loop, compilador eliminando trabalho sem efeito observável e dificuldade de expressar “reset timer depois do prepare”.

Desde o Go 1.24, a forma preferida é testing.B.Loop:

func BenchmarkConcatLoop(b *testing.B) {
	for b.Loop() {
		_ = "go" + "lang"
	}
}

Com b.Loop():

  1. O runner controla quantas vezes o corpo executa.
  2. O timing fica mais robusto em relação a setup e finalização.
  3. Várias classes de dead-code elimination e de contagem errada de b.N deixam de ser armadilhas manuais.

Se o seu módulo ainda está em versões anteriores, use b.N com disciplina: prepare dados antes do loop, chame b.ResetTimer() se o setup for caro e force o compilador a observar o resultado (veja adiante). Em 2026, para código novo, prefira b.Loop().

Setup caro, ResetTimer e StopTimer

Quando o custo de preparar dados é grande, não deixe isso contaminar o ns/op:

func BenchmarkParseGrande(b *testing.B) {
	raw := gerarJSONGrande() // caro: fica fora da medição
	b.ReportAllocs()
	b.ResetTimer()

	for b.Loop() {
		if _, err := ParsePedido(raw); err != nil {
			b.Fatal(err)
		}
	}
}

Padrões úteis:

  • b.ResetTimer(): zera o relógio depois do setup.
  • b.StopTimer() / b.StartTimer(): pausa a medição no meio (por exemplo, para recriar um arquivo entre iterações).
  • b.ReportAllocs(): equivale a sempre rodar com -benchmem naquele benchmark.

Evite I/O de rede dentro do microbenchmark “puro”. Se a operação real é “HTTP + JSON + banco”, você está medindo um cenário, não uma função — e aí faz mais sentido um teste de integração com Testcontainers, métricas e pprof sob carga.

Sub-benchmarks: compare opções no mesmo arquivo

Use b.Run para comparar implementações sem espalhar cinco funções soltas:

func BenchmarkEncode(b *testing.B) {
	payload := map[string]any{
		"id":     "abc",
		"total":  10.5,
		"active": true,
	}

	b.Run("encoding/json", func(b *testing.B) {
		b.ReportAllocs()
		for b.Loop() {
			if _, err := json.Marshal(payload); err != nil {
				b.Fatal(err)
			}
		}
	})

	b.Run("prealloc-buffer", func(b *testing.B) {
		b.ReportAllocs()
		buf := make([]byte, 0, 256)
		for b.Loop() {
			buf = buf[:0]
			var err error
			buf, err = json.Marshal(payload) // exemplo didático; em produção use Encoder
			if err != nil {
				b.Fatal(err)
			}
			sink = buf
		}
	})
}

var sink []byte

Filtro:

go test -bench=BenchmarkEncode -benchmem ./...
go test -bench=BenchmarkEncode/encoding -benchmem ./...

Sub-benchmarks são o equivalente de testes em tabela para performance: mesma entrada, várias estratégias, um relatório comparável. Combine com o hábito de table-driven tests no restante da suíte.

Dead-code elimination: o benchmark que mente

O compilador de Go é agressivo. Se o resultado de uma função não é observado, o trabalho pode sumir do binário de teste e seu ns/op vira ficção:

// RUIM: o compilador pode eliminar Encode se o retorno não for usado
func BenchmarkEncodeRuim(b *testing.B) {
	for b.Loop() {
		Encode(pedido) // retorno ignorado
	}
}

Formas de forçar observação:

var globalSink any

func BenchmarkEncodeBom(b *testing.B) {
	var local any
	for b.Loop() {
		local = Encode(pedido)
	}
	globalSink = local
}

Ou, quando há erro:

for b.Loop() {
	out, err := Encode(pedido)
	if err != nil {
		b.Fatal(err)
	}
	globalSink = out
}

Checklist anti-mentira:

  1. Use o retorno (atribuir a sink global ou verificar erro).
  2. Não meça só o caminho que aloca se a versão “rápida” só parece rápida porque foi otimizada para zero.
  3. Rode mais de uma vez; desvio grande entre execuções é sinal de ambiente barulhento, não de vitória.

-count, -benchtime e estabilidade

Um único run no notebook com CPU em modo economia de energia não é evidência. Use:

go test -bench=BenchmarkMarshalPedido -benchmem -count=10 ./internal/jsonutil

Interprete a mediana e a dispersão, não só a melhor linha. Para cargas longos:

go test -bench=BenchmarkMarshalPedido -benchtime=3s -benchmem ./internal/jsonutil

Ou por iterações fixas:

go test -bench=BenchmarkMarshalPedido -benchtime=1000000x -benchmem ./internal/jsonutil

Dicas de ambiente:

  • Feche o navegador com 40 abas e o Docker build paralelo.
  • Prefira máquina plugada na tomada (sem thermal throttle agressivo de bateria).
  • Em CI, trate benchmark como detector de regressão grosseira, não como medidor de nanossegundo absoluto entre runners diferentes.
  • Pinne GOMAXPROCS se quiser comparar runs: GOMAXPROCS=4 go test -bench=....

Ferramentas da comunidade como benchstat ajudam a comparar dois dumps (old.txt vs new.txt) com significância estatística. O fluxo típico de PR:

go test -bench=BenchmarkEncode -benchmem -count=10 ./... > old.txt
# ... muda o código ...
go test -bench=BenchmarkEncode -benchmem -count=10 ./... > new.txt
benchstat old.txt new.txt

Bytes processados: b.SetBytes

Para parsers, compressores e codecs, reporte throughput:

func BenchmarkParse(b *testing.B) {
	raw := []byte(`{"id":"x","total":1}`)
	b.SetBytes(int64(len(raw)))
	b.ReportAllocs()

	for b.Loop() {
		if _, err := ParsePedido(raw); err != nil {
			b.Fatal(err)
		}
	}
}

A coluna extra MB/s aparece no relatório e facilita comparar implementações que processam entradas de tamanhos diferentes.

Do benchmark ao pprof

Quando o número surpreende — “por que 20 allocs?” — perfil a partir do próprio benchmark:

go test -bench=BenchmarkMarshalPedido -benchmem \
  -cpuprofile=cpu.pprof \
  -memprofile=mem.pprof \
  ./internal/jsonutil

go tool pprof -http=:0 cpu.pprof
go tool pprof -http=:0 mem.pprof

Leitura combinada:

  1. Benchmark diz quanto.
  2. pprof de CPU diz quais funções.
  3. pprof de memória diz quem aloca.
  4. Se ainda faltar a dimensão temporal (scheduler, GC, syscalls), use go tool trace.

Esse pipeline é o mesmo que times de plataforma usam em incidentes: métricas apontam o sintoma, benchmark isola o hot path, pprof explica o custo. Para instrumentação contínua em produção, o par natural é OpenTelemetry + Prometheus no ecossistema Go.

Parallel benchmarks: b.RunParallel

Para medir contenda (mutex, pool, cache), use:

func BenchmarkCacheGet(b *testing.B) {
	c := NewCache()
	c.Set("k", "v")

	b.ReportAllocs()
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			_, _ = c.Get("k")
		}
	})
}

Cuidados:

  • b.RunParallel distribui iterações entre goroutines; o total ainda é controlado pelo runner.
  • Contenda artificial demais (todos no mesmo mutex sem carga realista) produz números pessimistas.
  • Contenda de menos (cada goroutine com chave exclusiva) produz números otimistas.
  • Modele o padrão de acesso do seu serviço: 1 chave quente? muitas chaves? leitura 95% / escrita 5%?

Se o objetivo é reutilizar buffers entre requisições, meça com e sem sync.Pool no mesmo sub-benchmark. Pool sem prova de allocs/op é otimização cosmética.

Armadilhas que distorcem resultado

1. Medir o GC alheio

Um benchmark curto demais pode cair em fase de GC de outro teste. Use -count e, se necessário, isole o pacote. Evite rodar a suíte inteira de integração no mesmo processo se o objetivo é microbenchmark estável.

2. Comparar máquinas diferentes como se fossem iguais

ns/op no seu Mac M-series não é o mesmo no runner Linux da CI nem no pod c5.large. Compare relativo (A vs B no mesmo host), não absoluto entre ambientes.

3. Otimizar o benchmark, não o produto

Há quem reescreva o teste até o número ficar bonito: entrada minúscula, sem erro path, sem validação, sem contexto cancelável. O usuário real manda payload sujo, header estranho e timeout. Meça o caminho que a API realmente executa — inclusive validação e middleware.

4. Esquecer alocações

Uma versão 10% mais rápida com 30× mais allocs/op pode ser pior sob carga. Em serviços com worker pool e alta concorrência, GC vira latência de cauda. Olhe B/op e allocs/op sempre.

5. Misturar correção e performance

Benchmark não substitui teste. Se a versão “rápida” erra em 1% dos casos, você não ganhou performance — ganhou incidente. Mantenha testes de tabela + fuzz no mesmo pacote.

6. Timer em volta de log e I/O acidental

fmt.Printf de debug dentro do loop, gravação de arquivo, abertura de conexão: tudo isso vira o custo dominante. Deixe o corpo do loop enxuto.

Exemplo prático: JSON vs buffer reutilizável

Cenário comum em API REST Go: serializar a mesma struct em todo response. Compare alocação ingênua com encoder reutilizável:

func BenchmarkRespostaJSON(b *testing.B) {
	type Resp struct {
		OK   bool   `json:"ok"`
		Msg  string `json:"msg"`
		Code int    `json:"code"`
	}
	r := Resp{OK: true, Msg: "criado", Code: 201}

	b.Run("marshal-each-time", func(b *testing.B) {
		b.ReportAllocs()
		for b.Loop() {
			out, err := json.Marshal(r)
			if err != nil {
				b.Fatal(err)
			}
			sink = out
		}
	})

	b.Run("encoder-buffer", func(b *testing.B) {
		b.ReportAllocs()
		var buf bytes.Buffer
		enc := json.NewEncoder(&buf)
		for b.Loop() {
			buf.Reset()
			if err := enc.Encode(r); err != nil {
				b.Fatal(err)
			}
			// Encode adiciona '\n'; copie se precisar reter bytes
			out := make([]byte, buf.Len())
			copy(out, buf.Bytes())
			sink = out
		}
	})
}

Não assuma vencedor: meça no seu tipo e tamanho de payload. Em alguns casos, json.Marshal + pool de buffers ganha; em outros, codegen ou encoding/json/v2 experimental muda o quadro — o guia de JSON em Go aprofunda o tema de APIs e streaming.

Onde encaixar benchmarks no dia a dia

  1. Hot path identificado por métricas (p95 da rota /checkout subiu).
  2. Escreva/ajuste teste de correção do trecho.
  3. Adicione Benchmark... no mesmo pacote.
  4. Rode com -benchmem -count=10.
  5. Se não souber por quê, pprof.
  6. Só então mude o código (pool, prealloc, outra estrutura, menos cópia).
  7. Re-rode e guarde o benchstat no PR se a mudança for de performance.

Para times que usam PGO, o benchmark local não substitui perfil de produção, mas valida que a mudança de código não regrediu o caminho quente antes do deploy. Veja PGO em Go.

Benchmarks e vagas Go no Brasil

Descrições de backend Go em empresas brasileiras pedem com frequência: APIs, concorrência, PostgreSQL, Docker/Kubernetes, observabilidade e “preocupação com performance”. Saber explicar como você mediu — não só que “deixou mais rápido” — diferencia em entrevista técnica.

Um portfólio sólido mostra:

Se está buscando oportunidade, cruze com as vagas Go e com ideias de projetos para portfólio. Para comparar como outras stacks falam de performance em entrevistas e vagas, o portal eu.dev.br agrega aberturas de várias linguagens.

Perguntas frequentes

Como rodar só benchmarks, sem testes unitários?

go test -run=^$ -bench=. -benchmem ./...

O -run=^$ não casa com nenhum teste; só os benchmarks executam.

Preciso de framework de benchmark?

Não. O pacote testing cobre a maior parte dos casos. Frameworks externos raramente valem a pena só para microbenchmark de biblioteca. Invista em cenários realistas e em perfis.

Benchmark em CI deve falhar o build?

Depende da maturidade. Comece coletando e publicando o artefato (benchstat em comentário de PR). Falhar o build por 2% de ruído em runner compartilhado gera alerta-fadiga. Falhar por regressão grande e estável (por exemplo +50% ns/op ou +10 allocs/op no hot path) pode fazer sentido com limiares folgados.

Posso benchmarkar código com time.Now ou rand?

Sim, mas controle a fonte. Injete relógio e gerador (interfaces pequenas — o mesmo espírito de DI sem framework) para o benchmark ser determinístico. Caso contrário, parte da variância é do próprio pseudoaleatório.

Qual a relação com fuzzing?

Fuzz encontra entradas que quebram correção; benchmark mede custo de entradas representativas. Use os dois: o corpus do fuzz às vezes vira ótimo input de benchmark para parsers.

Checklist final

  • Nome do benchmark descreve a operação real.
  • Correção coberta por teste antes da medição.
  • Preferir for b.Loop() em código novo.
  • Setup caro fora do loop + ResetTimer se necessário.
  • Resultado observado (anti dead-code elimination).
  • -benchmem ou ReportAllocs sempre no hot path.
  • -count≥5 (ideal 10) antes de cravar conclusão.
  • Comparar A vs B no mesmo ambiente.
  • Surpresa → pprof (CPU/mem) → trace se for temporal.
  • Não otimizar alloc zero no teste se a produção aloca por validação/log/middleware.

Benchmark bom é chato e honesto: entrada realista, ambiente estável, números com alocação, e mudança de código só depois da prova. Em Go, a ferramenta já vem na standard library — o diferencial é disciplina para não se enganar com um ns/op bonito.

Próximos passos naturais: instrumentar o serviço com OpenTelemetry, expor métricas Prometheus, e quando o p95 apitar, voltar a este fluxo com um Benchmark no pacote culpado. Se quiser praticar com um serviço completo, siga o tutorial de API REST em Go e adicione benchmarks nos handlers de serialização e nos clients HTTP críticos.