Benchmarks são uma ferramenta essencial para medir o desempenho do seu código Go. Eles permitem que você avalie o tempo de execução e o uso de memória de funções específicas, ajudando a identificar gargalos e a validar otimizações. Nesta aula, vamos mergulhar nos fundamentos dos benchmarks em Go, desde a sintaxe básica até técnicas avançadas para comparar diferentes implementações.

O pacote padrão testing fornece suporte integrado para benchmarks, tornando fácil escrevê-los e executá-los juntamente com seus testes. Você verá como criar funções de benchmark, interpretar os resultados e usar ferramentas como go test -bench para obter métricas precisas. Vamos começar!

func BenchmarkXxx

Para criar um benchmark em Go, você define uma função que começa com a palavra Benchmark e recebe um parâmetro do tipo *testing.B. Essa função deve conter o código que você deseja medir. O nome da função deve seguir o padrão BenchmarkNome, onde Nome descreve o que está sendo testado. Por exemplo, BenchmarkSoma para medir uma função de soma.

Dentro da função, você usa o loop for i := 0; i < b.N; i++ para executar o código repetidamente. O valor b.N é ajustado automaticamente pelo framework para obter medições estáveis. O benchmark é executado várias vezes, com b.N aumentando até que a medição seja confiável.

package main

import (
    "testing"
)

// Função que queremos testar
func Soma(a, b int) int {
    return a + b
}

// Benchmark da função Soma
func BenchmarkSoma(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Soma(1, 2)
    }
}

Para executar o benchmark, use o comando go test -bench=. no diretório do pacote. O argumento -bench aceita uma expressão regular para filtrar quais benchmarks rodar. Por exemplo, go test -bench=Soma executa apenas o benchmark BenchmarkSoma. A saída mostrará o número de iterações e o tempo médio por iteração.

$ go test -bench=.
BenchmarkSoma-8    	1000000000	         0.2520 ns/op
PASS
ok  	_/tmp	0.337s

O número 8 após o nome indica o número de CPUs usadas (GOMAXPROCS). O primeiro número é o total de iterações, e o segundo é o tempo por operação (ns/op). Quanto menor, melhor.

b.N

O campo b.N é um inteiro que representa o número de iterações que o benchmark deve executar. O framework de testes ajusta automaticamente esse valor para garantir que o benchmark tenha duração suficiente para obter uma medição precisa. Ele começa com um valor pequeno (geralmente 1) e aumenta progressivamente até que o tempo de execução seja estável.

Você não deve modificar b.N manualmente, mas pode usá-lo para controlar o loop. Em benchmarks mais complexos, você pode precisar inicializar dados dentro do loop, mas cuidado para não incluir a inicialização na medição. Se a inicialização for significativa, é comum usar b.ResetTimer() para zerar o timer após a preparação.

func BenchmarkComplexo(b *testing.B) {
    // Preparação (não medido)
    data := make([]int, 1000)
    for i := range data {
        data[i] = i
    }

    b.ResetTimer() // Zera o timer

    for i := 0; i < b.N; i++ {
        // Código medido
        _ = processa(data)
    }
}

Além disso, você pode usar b.StopTimer() e b.StartTimer() para pausar e retomar a medição, útil quando você precisa fazer operações que não devem ser contabilizadas, como alocar grandes estruturas.

Medindo alocações

Além do tempo, é crucial medir o número de alocações de memória, pois alocações excessivas podem impactar o desempenho e a garbage collection. O Go fornece o método b.ReportAllocs() para incluir estatísticas de alocação nos resultados do benchmark.

func BenchmarkComAlocacao(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        _ = make([]int, 100)
    }
}

Ao executar com go test -bench=., a saída incluirá uma coluna adicional B/op (bytes por operação) e allocs/op (alocações por operação).

BenchmarkComAlocacao-8    	100000000	       15.3 ns/op	      800 B/op	       1 allocs/op

Para evitar alocações desnecessárias, você pode usar b.ReportAllocs() e analisar os resultados. Uma prática comum é pré-alocar buffers e reutilizá-los, ou usar tipos que não causam alocação no heap.

Comparando

Muitas vezes, você precisa comparar o desempenho de diferentes implementações para decidir qual usar. O Go oferece a ferramenta benchstat para comparar resultados de benchmarks entre execuções ou entre versões de código. Primeiro, instale a ferramenta:

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

Em seguida, execute os benchmarks e salve os resultados em arquivos, depois use benchstat para compará-los.

# Executa e salva resultados
$ go test -bench=. -count=5 > old.txt
$ go test -bench=. -count=5 > new.txt

# Compara
$ benchstat old.txt new.txt
name          old time/op    new time/op    delta
Soma-8         0.25ns ± 1%    0.25ns ± 1%    ~     (p=0.954 n=5+5)

O benchstat calcula a diferença percentual e a significância estatística. Isso é essencial para validar melhorias de desempenho.

Além disso, você pode usar go test -benchmem para incluir estatísticas de memória diretamente na saída, sem precisar chamar b.ReportAllocs() em cada função. A flag -benchmem adiciona colunas de alocação a todos os benchmarks.

$ go test -bench=. -benchmem
BenchmarkSoma-8    	1000000000	         0.25 ns/op	       0 B/op	       0 allocs/op

Boas práticas

Ao escrever benchmarks, siga estas recomendações:

  • Evite otimizações prematuras: meça primeiro, otimize depois.
  • Use b.ResetTimer() para excluir a inicialização da medição.
  • Use b.RunParallel() para benchmarks que testam concorrência.
  • Execute benchmarks várias vezes (-count=5) para obter resultados estáveis.
  • Compare com benchstat para decisões baseadas em dados.

Referências

Exercícios

  1. Escreva um benchmark para a função ConcatString que concatena duas strings usando +. Execute-o e observe o tempo.
  2. ✓ Resposta:
    func ConcatString(a, b string) string {
        return a + b
    }
    
    func BenchmarkConcatString(b *testing.B) {
        for i := 0; i < b.N; i++ {
            _ = ConcatString("hello", "world")
        }
    }
  3. Modifique o benchmark anterior para usar strings.Builder e compare os resultados usando benchstat.
  4. ✓ Resposta:
    func ConcatBuilder(a, b string) string {
        var sb strings.Builder
        sb.WriteString(a)
        sb.WriteString(b)
        return sb.String()
    }
    
    func BenchmarkConcatBuilder(b *testing.B) {
        for i := 0; i < b.N; i++ {
            _ = ConcatBuilder("hello", "world")
        }
    }
    
    // Execute: go test -bench=Concat -benchmem -count=5 > old.txt
    // e depois compare com benchstat.
  5. Adicione b.ReportAllocs() a um benchmark e explique o que as colunas B/op e allocs/op significam.
  6. ✓ Resposta: B/op é o número médio de bytes alocados por operação, e allocs/op é o número médio de alocações por operação. Exemplo:
    func BenchmarkAloca(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            _ = make([]int, 10)
        }
    }
  7. Crie um benchmark que use b.ResetTimer() para excluir a inicialização de um slice grande.
  8. ✓ Resposta:
    func BenchmarkProcessa(b *testing.B) {
        data := make([]int, 10000)
        for i := range data {
            data[i] = i
        }
        b.ResetTimer()
        for i := 0; i < b.N; i++ {
            _ = processa(data)
        }
    }
  9. Use go test -benchmem para medir alocações de um benchmark e identifique se há alocações desnecessárias.
  10. ✓ Resposta: Execute go test -benchmem e observe a saída. Se allocs/op for alto, você pode investigar o código para reduzir alocações, por exemplo, reutilizando buffers ou usando ponteiros. Exemplo de saída:
    BenchmarkExemplo-8    	1000000	      1234 ns/op	  512 B/op	   3 allocs/op