Benchmarks em Go

1. Introdução aos Benchmarks em Go

Benchmarks são ferramentas essenciais para medir o desempenho de código em Go. Enquanto testes unitários verificam a correção funcional, benchmarks medem tempo de execução, alocações de memória e eficiência computacional. Em Go, os benchmarks são integrados ao pacote testing e seguem uma estrutura padronizada.

A principal diferença entre um teste unitário e um benchmark é que o primeiro executa uma verificação de resultado, enquanto o segundo executa repetidamente uma operação para coletar métricas estatísticas. A assinatura básica de uma função de benchmark é:

func BenchmarkXxx(b *testing.B) {
    // código a ser medido
}

O parâmetro b *testing.B gerencia a execução e fornece métodos para controlar o temporizador, relatar alocações e configurar iterações.

2. Escrevendo seu Primeiro Benchmark

A anatomia de um benchmark gira em torno do loop b.N. O framework ajusta automaticamente b.N para garantir que a execução seja estatisticamente significativa, normalmente entre 1 e 100 segundos de execução total.

Vamos criar um benchmark para comparar diferentes formas de concatenar strings:

// concat.go
package main

func ConcatWithPlus(a, b string) string {
    return a + b
}

func ConcatWithSprintf(a, b string) string {
    return fmt.Sprintf("%s%s", a, b)
}
// concat_test.go
package main

import (
    "testing"
    "fmt"
)

func BenchmarkConcatWithPlus(b *testing.B) {
    a := "Hello"
    c := "World"
    for i := 0; i < b.N; i++ {
        ConcatWithPlus(a, c)
    }
}

func BenchmarkConcatWithSprintf(b *testing.B) {
    a := "Hello"
    c := "World"
    for i := 0; i < b.N; i++ {
        ConcatWithSprintf(a, c)
    }
}

Para executar os benchmarks:

go test -bench=.

Saída típica:

BenchmarkConcatWithPlus-8        1000000000    0.2436 ns/op
BenchmarkConcatWithSprintf-8     50000000      28.45 ns/op

O sufixo -8 indica 8 CPUs lógicas usadas. O número antes do resultado (ex: 1000000000) é o valor de b.N que o framework escolheu.

3. Controlando a Execução de Benchmarks

Podemos filtrar benchmarks específicos com expressões regulares:

go test -bench=ConcatWithPlus

Para limitar o tempo de execução:

go test -bench=. -benchtime=5s

Para testar com diferentes números de CPUs:

go test -bench=. -cpu=1,2,4

Para benchmarks paralelos, usamos b.SetParallelism():

func BenchmarkParallelConcat(b *testing.B) {
    b.SetParallelism(4)
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            ConcatWithPlus("Hello", "World")
        }
    })
}

4. Métricas e Resultados de Benchmark

A saída padrão mostra:
- ns/op: nanossegundos por operação (menor é melhor)
- allocs/op: alocações de memória por operação
- bytes/op: bytes alocados por operação

Para incluir métricas de alocação:

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

Controlar o temporizador é útil quando você precisa de setup antes da medição:

func BenchmarkWithSetup(b *testing.B) {
    data := make([]byte, 1024*1024)
    b.ResetTimer() // ignora o tempo de alocação inicial

    for i := 0; i < b.N; i++ {
        processData(data)
    }
}

Use b.StopTimer() e b.StartTimer() para pausar/retomar a medição durante operações auxiliares.

5. Benchmarks Avançados e Configurações

Benchmarks com entradas variáveis permitem testar diferentes tamanhos de dados:

func BenchmarkSort(b *testing.B) {
    sizes := []int{10, 100, 1000, 10000}
    for _, size := range sizes {
        b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
            data := generateRandomSlice(size)
            b.ResetTimer()
            for i := 0; i < b.N; i++ {
                sortSlice(data)
            }
        })
    }
}

Benchmarks paralelos com b.RunParallel são ideais para testar concorrência:

func BenchmarkParallelSum(b *testing.B) {
    nums := make([]int, 1000000)
    for i := range nums {
        nums[i] = i
    }

    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            sum(nums)
        }
    })
}

6. Evitando Armadilhas Comuns

Dead code elimination: O compilador pode otimizar código que não produz resultados observáveis. Para evitar isso, atribua resultados a variáveis globais:

var globalResult int

func BenchmarkSum(b *testing.B) {
    nums := []int{1, 2, 3}
    for i := 0; i < b.N; i++ {
        globalResult = sum(nums)
    }
}

Inicialização no loop: Colocar alocações caras dentro do loop pode distorcer resultados. Prefira inicializar fora:

// Ruim: aloca a cada iteração
func BenchmarkBad(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := make([]byte, 1024)
        _ = len(s)
    }
}

// Bom: aloca uma vez
func BenchmarkGood(b *testing.B) {
    b.StopTimer()
    s := make([]byte, 1024)
    b.StartTimer()
    for i := 0; i < b.N; i++ {
        _ = len(s)
    }
}

Variação de resultados: Para medições estáveis, execute múltiplas vezes com -benchtime=10x ou use benchstat para análise estatística.

7. Ferramentas de Análise e Comparação

benchstat compara resultados entre execuções:

go test -bench=. -count=5 > old.txt
# após alterações no código
go test -bench=. -count=5 > new.txt
benchstat old.txt new.txt

Gere perfis de CPU e memória:

go test -bench=. -cpuprofile=cpu.prof -memprofile=mem.prof

Analise com go tool pprof:

go tool pprof cpu.prof
(pprof) top10
(pprof) web

8. Boas Práticas e Padrões

  • Representatividade: Use dados realistas, não apenas tamanhos pequenos
  • Documentação: Comente benchmarks complexos explicando o que está sendo medido
  • CI/CD: Integre benchmarks em pipelines para detectar regressões:
# Exemplo de workflow GitHub Actions
- name: Run benchmarks
  run: |
    go test -bench=. -benchmem -count=5 > bench.txt
    benchstat bench.txt
  • Versionamento: Mantenha resultados de benchmark em um arquivo separado para histórico
  • Foco: Cada benchmark deve medir uma única operação ou comportamento

Referências