Benchmarks são uma ferramenta essencial para medir o desempenho do seu código em Go. Eles permitem que você quantifique o tempo de execução de funções e identifique gargalos de performance. Nesta aula, você aprenderá a criar benchmarks utilizando o pacote padrão testing, que oferece suporte nativo a essa funcionalidade. Vamos explorar desde a estrutura básica de um benchmark até técnicas avançadas como medir alocações de memória e comparar diferentes implementações.

Dominar benchmarks é crucial para escrever software eficiente, especialmente em sistemas de alta concorrência ou com requisitos de baixa latência. Ao final desta aula, você será capaz de escrever benchmarks claros, interpretar seus resultados e tomar decisões informadas sobre otimizações.

func BenchmarkXxx

Em Go, um benchmark é definido em um arquivo de teste (_test.go) e segue a convenção de nomenclatura BenchmarkXxx, onde Xxx descreve a função que está sendo medida. A função de benchmark recebe um ponteiro para testing.B, que fornece métodos e propriedades para controlar a execução e registrar resultados.

A estrutura básica é simples: você escreve o código que deseja medir dentro do benchmark, e o framework de testes o executa várias vezes, ajustando automaticamente o número de iterações (b.N) para obter uma medição estatisticamente confiável. O loop que envolve o código é essencial: você deve colocar o código a ser medido dentro de um loop que itera b.N vezes, para que o benchmark possa ser executado por um período de tempo suficiente.

Exemplo de um benchmark simples:

package main

import (
    "testing"
)

func Sum(a, b int) int {
    return a + b
}

func BenchmarkSum(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Sum(1, 2)
    }
}

Para executar benchmarks, use o comando go test -bench=. no diretório do pacote. O padrão . corresponde a todos os benchmarks; você também pode usar um padrão específico, como -bench=Sum. O resultado mostrará o número de iterações, o tempo médio por operação e outras métricas.

b.N

A variável b.N é fornecida pelo framework de testes e indica quantas vezes o benchmark deve executar o código para obter uma medição estável. O valor de b.N é ajustado automaticamente: o framework começa com um valor pequeno (como 1) e o aumenta progressivamente até que a execução total dure cerca de 1 segundo (ou o tempo definido pela flag -benchtime). Isso garante que o benchmark tenha significância estatística sem demorar demais.

É importante usar b.N no loop, pois o código dentro do benchmark deve ser executado exatamente b.N vezes. Se você esquecer o loop e executar o código apenas uma vez, o benchmark não será útil, pois o framework não poderá ajustar o número de iterações e os resultados serão imprecisos.

Você também pode controlar o tempo de execução usando a flag -benchtime. Por exemplo, go test -bench=. -benchtime=2s executa cada benchmark por pelo menos 2 segundos. Isso é útil para benchmarks mais lentos que precisam de mais tempo para obter resultados estáveis.

Exemplo de um benchmark que usa b.N corretamente:

func BenchmarkConcat(b *testing.B) {
    s := ""
    for i := 0; i < b.N; i++ {
        s += "a"
    }
    _ = s
}

Nesse exemplo, o benchmark mede a concatenação de strings, que é uma operação que pode ser custosa. O loop garante que a operação seja executada muitas vezes para que o tempo médio seja calculado.

Medindo alocações

Além do tempo de execução, benchmarks em Go também podem medir o número de alocações de memória e a quantidade de memória alocada por operação. Isso é crucial para otimizar código que faz uso intensivo de memória, pois alocações desnecessárias podem impactar o desempenho e a latência.

Para registrar alocações, você usa os métodos ReportAllocs() dentro do benchmark. Com isso, o framework contará o número de alocações e a quantidade total de memória alocada durante a execução do benchmark. Os resultados são exibidos nas colunas allocs/op e B/op na saída do comando go test.

Exemplo:

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

Ao executar, você verá a quantidade de alocações por operação. Para evitar alocações desnecessárias, você pode usar técnicas como reutilização de buffers ou pré-alocação. Medir alocações ajuda a identificar onde essas otimizações são necessárias.

Comparando

Uma das aplicações mais úteis de benchmarks é comparar diferentes implementações para escolher a mais eficiente. Você pode escrever múltiplos benchmarks no mesmo arquivo de teste e executá-los lado a lado.

Para facilitar a comparação, você pode usar a ferramenta benchstat, que é uma ferramenta externa que calcula estatísticas e compara os resultados de execuções de benchmarks. Ela é útil para detectar diferenças significativas entre versões de código.

Exemplo de comparação entre duas funções de concatenação de strings:

func BenchmarkConcatPlus(b *testing.B) {
    s := ""
    for i := 0; i < b.N; i++ {
        s += "a"
    }
    _ = s
}

func BenchmarkConcatBuilder(b *testing.B) {
    var sb strings.Builder
    for i := 0; i < b.N; i++ {
        sb.WriteString("a")
    }
    _ = sb.String()
}

Executando ambos com go test -bench=Concat -benchmem, você verá os resultados lado a lado. Normalmente, o strings.Builder é mais eficiente que a concatenação com += porque evita criar novas strings a cada iteração.

Para uma comparação mais robusta, é recomendável executar os benchmarks várias vezes e usar benchstat para analisar a distribuição dos resultados. Isso ajuda a evitar conclusões precipitadas baseadas em uma única execução.

Boas práticas

Ao escrever benchmarks, considere as seguintes boas práticas:

  • Isole o código: O benchmark deve medir apenas a operação de interesse, sem incluir configurações ou limpeza que possam influenciar os resultados.
  • Evite otimizações do compilador: Use variáveis globais ou atribuições a _ para evitar que o compilador elimine código que não tem efeito observável.
  • Use dados realistas: Se a função lida com slices ou mapas, use tamanhos representativos do uso real.
  • Documente o propósito: Comente o que o benchmark está medindo e por que é importante.

Referências

Exercícios

  1. Escreva um benchmark para uma função que calcula o fatorial de um número de forma recursiva. Use b.N corretamente e execute-o para ver os resultados.
  2. ✓ Resposta:
    package main
    
    import "testing"
    
    func FactorialRecursive(n int) int {
        if n <= 1 {
            return 1
        }
        return n * FactorialRecursive(n-1)
    }
    
    func BenchmarkFactorialRecursive(b *testing.B) {
        for i := 0; i < b.N; i++ {
            FactorialRecursive(10)
        }
    }
    
  3. Crie um benchmark que meça o número de alocações ao converter um slice de inteiros para uma string usando fmt.Sprint. Use b.ReportAllocs() e analise os resultados.
  4. ✓ Resposta:
    package main
    
    import (
        "fmt"
        "testing"
    )
    
    func BenchmarkFmtSprint(b *testing.B) {
        b.ReportAllocs()
        nums := []int{1, 2, 3, 4, 5}
        for i := 0; i < b.N; i++ {
            _ = fmt.Sprint(nums)
        }
    }
    
  5. Implemente duas funções para inverter uma string: uma que use concatenação e outra que use strings.Builder. Escreva benchmarks para ambas e compare os resultados com benchstat (se disponível).
  6. ✓ Resposta:
    package main
    
    import (
        "strings"
        "testing"
    )
    
    func ReverseConcat(s string) string {
        res := ""
        for i := len(s)-1; i >= 0; i-- {
            res += string(s[i])
        }
        return res
    }
    
    func ReverseBuilder(s string) string {
        var sb strings.Builder
        for i := len(s)-1; i >= 0; i-- {
            sb.WriteByte(s[i])
        }
        return sb.String()
    }
    
    func BenchmarkReverseConcat(b *testing.B) {
        s := "hello world"
        for i := 0; i < b.N; i++ {
            _ = ReverseConcat(s)
        }
    }
    
    func BenchmarkReverseBuilder(b *testing.B) {
        s := "hello world"
        for i := 0; i < b.N; i++ {
            _ = ReverseBuilder(s)
        }
    }
    
  7. Escreva um benchmark que meça o desempenho de uma função que soma os elementos de um slice de inteiros. Use tamanhos de slice diferentes (10, 100, 1000) e compare os resultados.
  8. ✓ Resposta:
    package main
    
    import "testing"
    
    func SumSlice(nums []int) int {
        sum := 0
        for _, n := range nums {
            sum += n
        }
        return sum
    }
    
    func BenchmarkSum10(b *testing.B) {
        nums := make([]int, 10)
        for i := range nums {
            nums[i] = i
        }
        b.ResetTimer()
        for i := 0; i < b.N; i++ {
            _ = SumSlice(nums)
        }
    }
    
    func BenchmarkSum100(b *testing.B) {
        nums := make([]int, 100)
        for i := range nums {
            nums[i] = i
        }
        b.ResetTimer()
        for i := 0; i < b.N; i++ {
            _ = SumSlice(nums)
        }
    }
    
    func BenchmarkSum1000(b *testing.B) {
        nums := make([]int, 1000)
        for i := range nums {
            nums[i] = i
        }
        b.ResetTimer()
        for i := 0; i < b.N; i++ {
            _ = SumSlice(nums)
        }
    }
    
  9. Pesquise sobre a ferramenta benchstat e explique como ela pode ser usada para comparar os resultados de múltiplas execuções de benchmarks. Escreva um pequeno parágrafo sobre isso.
  10. ✓ Resposta:

    O benchstat é uma ferramenta da golang.org/x/perf que calcula estatísticas (como média, mediana e desvio padrão) a partir de resultados de benchmarks. Para usá-la, você executa os benchmarks várias vezes, salvando a saída em arquivos, e depois passa esses arquivos para o benchstat. Ele compara as distribuições de tempo e alocações entre diferentes versões ou implementações, indicando se as diferenças são estatisticamente significativas. Isso é útil para evitar conclusões baseadas em ruído de medição. Exemplo: go test -bench=BenchmarkConcat -count=5 > old.txt e depois benchstat old.txt new.txt.

Com esses exercícios, você terá prática na criação e análise de benchmarks, uma habilidade valiosa para otimizar seus programas Go.