Pular para o conteúdo

Trilha de Go · parte 15 de 37

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

Tamanho da fonte:
Largura da coluna:

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

ParteSeçãoO que você vai dominar
1. Fundamentos1 e 2A assinatura func BenchmarkXxx(b *testing.B), o loop canônico e a mecânica adaptativa do b.N
2. Métricas de Memória3 e 4Ativação de b.ReportAllocs(), a diferença entre B/op e allocs/op e o ideal de zero alocações
3. Controle de Tempo5 e 6Isolando setup caro com b.ResetTimer(), StopTimer e benchmarks paralelos com b.RunParallel
4. Armadilhas do Compilador7Como o otimizador SSA elimina laços de benchmark e como contornar usando variáveis globais
5. Comparação e Diagnóstico8 a 10Aná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:

  1. O nome da função deve iniciar com o prefixo Benchmark (em maiúscula).
  2. A função recebe como único parâmetro um ponteiro b *testing.B.
  3. O corpo do benchmark contém um laço que itera exatamente b.N vezes.
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:

mecanica b n benchmark
figura 1 · mecanica b n benchmark
  1. O Go inicia com b.N = 1 e mede o tempo da primeira chamada.
  2. 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.
  3. 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.
  4. 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 representa GOMAXPROCS (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:

pipeline benchstat pprof
figura 2 · pipeline benchstat pprof

8. Como coletar e comparar amostras

  1. Instale o benchstat:
    go install golang.org/x/perf/cmd/benchstat@latest
  2. Colete 10 repetições do seu código atual (versão original):
    go test -bench=BenchmarkOperacao -benchmem -count=10 > old.txt
  3. Aplique sua otimização no código Go.
  4. Colete 10 repetições da versão otimizada:
    go test -bench=BenchmarkOperacao -benchmem -count=10 > new.txt
  5. 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:

  1. Abordagem Ingênua: Usando o operador + repetidamente (gera dezenas de alocações transitórias).
  2. Abordagem Otimizada: Usando strings.Builder com 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

  1. Sempre use -benchmem ou b.ReportAllocs(): Tempo em nanossegundos sozinho pode mascarar gargalos de coleta de lixo. Olhe sempre para allocs/op.
  2. Nunca compare benchmarks com uma única execução: Use sempre benchstat com -count=10 para comprovar que a mudança é estatisticamente significativa.
  3. Sempre use b.ResetTimer() após operações de setup: Carregamento de fixtures, JSONs ou structs de apoio devem ficar fora do relógio.
  4. Proteja-se contra Dead Code Elimination: Atribua o resultado a uma variável global de pacote de teste.
  5. 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

  1. Implemente duas funções de parsing de inteiros em slice:
    • Uma usando append em slice dinâmico vazio (var nums []int).
    • Outra pré-alocando a capacidade com make([]int, 0, n).
  2. Escreva benchmarks para fatias de 10.000 elementos.
  3. Use -benchmem e compare a redução drástica de allocs/op e tempo total.
  4. Gere o arquivo mem.out e inspecione com go tool pprof.

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?