MÓDULO 06 · CONCEITO 06 DE 12

Benchmarking micro & macro

BenchmarkDotNet, go test -bench, pytest-benchmark, hyperfine. Warmup, JIT, ruído ambiental, statistical significance — o que separa benchmark sério de número que enganou três times sucessivos.

Tempo de leitura ~22 min Pré-requisito Conceito 05 (profiling) Próximo CPU cache, memory locality, false sharing

Em 2013, Andrey Akinshin — então engenheiro russo contribuindo para o ecossistema .NET — lançou BenchmarkDotNet, uma biblioteca que mudou a maturidade de medição de performance no .NET. Antes de BenchmarkDotNet, programadores escreviam loops com Stopwatch ao redor do código e tiravam conclusões. O resultado era ruim por uma série de razões: warmup do JIT não considerado, dead code eliminado pelo compilador, GC ativando em momento arbitrário, ruído de máquina não controlado, contagem de iterações inadequada. Akinshin formalizou um framework que tratava cada uma dessas armadilhas, e escreveu um livro inteiro — Pro .NET Benchmarking (Apress, 2019) — sistematizando o que ele chamou de "ciência da medição de performance".

Benchmarking — medir performance de uma porção de código sob carga controlada — é a outra metade da disciplina iniciada com profiling no conceito 05. Profiling responde "onde o tempo é gasto?" no sistema em execução; benchmarking responde "qual implementação é mais rápida nessa operação específica?" sob condições controladas. As duas se complementam — você usa profiling para encontrar o gargalo, e benchmark para decidir entre alternativas de otimização do gargalo.

A maturidade da disciplina importa porque benchmarking é onde mais facilmente se mente sem perceber. Um benchmark mal escrito pode mostrar que A é 50× mais rápido que B quando, em produção real, A e B têm performance idêntica — porque o compilador eliminou A como dead code, ou porque o JIT não estava aquecido para B, ou porque o GC capturou B no momento errado. O time escreve PR baseado no benchmark, mergeia, e descobre 6 meses depois que a otimização não existia. A disciplina de benchmarking é justamente o conjunto de práticas que evita esse caminho.

Este conceito articula a ciência da medição (Akinshin), distingue micro de macro benchmarks, apresenta as ferramentas idiomáticas em três ecossistemas, e enuncia as armadilhas mais comuns — dead code elimination, JIT warmup, GC interference, ruído ambiental, e a regressão estatística de números sem confiança. Fechar o conceito é o caminho para escrever benchmark que sobrevive a revisão crítica.

Micro vs macro — duas escalas, dois propósitos

Há duas escalas em benchmarking, e a confusão entre elas leva a decisões erradas.

Microbenchmark mede uma operação pequena, isolada — uma função, uma comparação entre duas implementações, o custo de uma alocação. Tipicamente a operação é mais curta que 1 microsegundo, e o framework executa milhões de iterações para extrair sinal do ruído. BenchmarkDotNet, go test -bench, pytest-benchmark são para microbenchmarks. O resultado é "operação X custa Y nanosegundos individuais".

Macrobenchmark mede o sistema como um todo sob carga — uma API responde a tantas requests por segundo, com qual latência. As ferramentas são diferentes: k6, Locust, JMeter, Gatling. Conceito 12 do módulo trata desses em profundidade. O resultado é "sistema sustenta X RPS com P99 de Y ms".

Os dois respondem perguntas diferentes. Microbenchmark responde "qual implementação é mais rápida?"; macrobenchmark responde "como o sistema se comporta sob carga real?". Otimização de microbenchmark frequentemente não muda macrobenchmark — porque o gargalo do sistema não é o que o microbenchmark mede. Por isso a regra: profile primeiro (descobre onde o tempo vai), microbench depois (decide entre alternativas no gargalo identificado), macrobench valida (confirma que mudança de fato ajudou no sistema).

As armadilhas que arruínam microbenchmarks

Akinshin catalogou em Pro .NET Benchmarking uma lista de armadilhas. Vale conhecer cada uma — todas aparecem em código de equipes inexperientes, e reconhecer o sintoma é trabalho de senior em revisão.

Dead code elimination

O compilador moderno elimina código que não tem efeito observável. Se você escreve for (int i = 0; i < N; i++) MyMethod(); e o resultado nunca é usado, o compilador pode remover a chamada inteira — benchmark mede tempo de loop vazio, não da função. O resultado é "função infinitamente rápida", obviamente errado. Defesa: usar o resultado da função ("consume" em BenchmarkDotNet via [Benchmark] retornando valor; em Go, atribuir a variável global var sink T; em Python, functools.reduce ou similar).

JIT warmup

Em runtimes JIT (.NET, JVM, modern JavaScript), as primeiras iterações são interpretadas; em algum ponto, o JIT decide compilar para native. As iterações antes da compilação são muito mais lentas que as posteriores. Se o benchmark roda 100 iterações e mede tudo, o resultado mistura performance JIT-aquecida com não-aquecida. Defesa: usar framework que faz warmup automático (BenchmarkDotNet faz; go test -bench não precisa porque Go é AOT compilado), ou descartar manualmente as primeiras N iterações.

GC interference

Garbage collection acontece em momento determinado pelo runtime, e quando acontece, congela parte ou todo o programa. Benchmark com janela curta tem chance grande de pegar GC em uma das iterações, o que distorce o resultado. Defesa: rodar muitas iterações para diluir efeito; reportar mediana em vez de média; forçar GC entre runs; medir alocações separadamente (BenchmarkDotNet [MemoryDiagnoser]; Go b.ReportAllocs()).

Ruído ambiental

Antivírus, daemon de update, throttling térmico, outros processos no sistema — tudo introduz ruído. Em laptop sob bateria, performance varia 2–3× dependendo de power state. Benchmark sob essas condições é praticamente inutilizável. Defesa: rodar em máquina dedicada, sob carga elétrica, sem outros processos disputando recursos. Em CI, Codspeed ou similar usam instrumentation determinística (contagem de instruções) em vez de tempo real.

Statistical significance

Um único número não significa nada. Diferenças pequenas entre runs podem ser ruído ou sinal — sem teste estatístico, você não sabe. Defesa: framework que reporta intervalo de confiança ou desvio-padrão, e que executa múltiplos invocations (BenchmarkDotNet, por padrão, executa cada benchmark em ~15 invocations e usa Welch's t-test para comparar). Diferença de 5% raramente é significativa; diferença de 50% provavelmente é, mas ainda merece checagem.

Cache effects

Microbenchmark frequentemente roda repetidamente sobre os mesmos dados, que ficam em cache de CPU. Performance medida é com cache 100% quente — em produção real, cache miss é comum. Resultado: benchmark otimista por ordem de magnitude. Defesa: benchmark com conjuntos de dados maiores que cache; warmup com escolha aleatória; consciência da hierarquia (conceito 07 do módulo).

Frameworks idiomáticos por linguagem

Cada linguagem tem framework canônico que trata as armadilhas acima por padrão. Vale conhecer o idiomático.

C# — BenchmarkDotNet
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]                    // mede alocações
[SimpleJob(warmupCount: 3, iterationCount: 10)]
public class StringConcat
{
    private readonly string[] parts;

    public StringConcat()
    {
        parts = Enumerable.Range(0, 100)
            .Select(i => $"part_{i}").ToArray();
    }

    [Benchmark(Baseline = true)]
    public string PlusEquals()
    {
        var s = "";
        foreach (var p in parts) s += p;
        return s;                    // RETORNA — evita dead code
    }

    [Benchmark]
    public string StringBuilderImpl()
    {
        var sb = new StringBuilder();
        foreach (var p in parts) sb.Append(p);
        return sb.ToString();
    }

    [Benchmark]
    public string StringJoin() => string.Join("", parts);
}

public static class Program
{
    public static void Main() => BenchmarkRunner.Run<StringConcat>();
}

BenchmarkDotNet trata warmup, GC, dead code, statistical significance automaticamente. Output inclui mean, median, stddev, alocações por operação, e razão vs baseline. É o padrão inquestionável em .NET.

Python — pytest-benchmark + hyperfine
# pytest-benchmark (microbench em código Python)
def test_concat_plus(benchmark):
    parts = [f"part_{i}" for i in range(100)]
    def run():
        s = ""
        for p in parts: s += p
        return s
    result = benchmark(run)
    assert result.endswith("part_99")

def test_concat_join(benchmark):
    parts = [f"part_{i}" for i in range(100)]
    benchmark(lambda: "".join(parts))

# saída: pytest --benchmark-only
# mostra min, max, mean, stddev, median, IQR, ops/sec

# hyperfine (CLI level — programa inteiro)
# útil para comparar duas versões de script, ou linguagens diferentes
# bash $ hyperfine 'python v1.py' 'python v2.py' --warmup 3 --runs 50

# pyperf (mais rigoroso, recomendado para CI)
import pyperf
def bench():
    parts = [f"part_{i}" for i in range(100)]
    return "".join(parts)

runner = pyperf.Runner()
runner.bench_func("concat_join", bench)

pytest-benchmark é convivência com test framework; hyperfine (Rust-based) é universal e excelente para comparar comandos; pyperf (do CPython team) faz JIT warmup em PyPy. Comunidade Python é mais fragmentada que .NET aqui.

Go — testing.B (built-in)
// concat_test.go — Go vai criar este arquivo automaticamente para benchmarks
package main

import (
    "fmt"
    "strings"
    "testing"
)

var sink string                       // global pra evitar dead code

func BenchmarkConcat(b *testing.B) {
    parts := make([]string, 100)
    for i := range parts { parts[i] = fmt.Sprintf("part_%d", i) }

    b.Run("plus", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            s := ""
            for _, p := range parts { s += p }
            sink = s                  // força uso
        }
    })

    b.Run("builder", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            var sb strings.Builder
            for _, p := range parts { sb.WriteString(p) }
            sink = sb.String()
        }
    })

    b.Run("join", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            sink = strings.Join(parts, "")
        }
    })
}

// rodar: go test -bench=. -benchmem -count=10
// usar benchstat para comparar runs:
//   go install golang.org/x/perf/cmd/benchstat@latest
//   go test -bench=. -count=10 > old.txt
//   # mudou código...
//   go test -bench=. -count=10 > new.txt
//   benchstat old.txt new.txt

Go tem framework de benchmark embutido na biblioteca padrão. b.N é ajustado automaticamente até a duração ser significativa. benchstat (golang.org/x/perf) compara runs com t-test estatístico. Verboso mas extremamente preciso.

Lendo output de benchmark — o que importa

Output de benchmark moderno tem várias colunas. Vale saber o que cada uma significa para tirar conclusões corretas.

Mean (média) — tempo médio por operação. Suscetível a outliers (cauda alonga a média).

Median (mediana) — valor do meio. Mais robusto a outliers que média. Para microbenchmarks, frequentemente o número que importa.

StdDev (desvio-padrão) — quanto os valores variam. Alto desvio significa benchmark instável; resultado precisa ser interpretado com cautela. Em benchmark sério, stddev tipicamente fica abaixo de 5% da média.

Min / Max — valores extremos. Min tipicamente representa o caso "tudo em cache, sem GC, sem ruído"; max representa o pior cenário capturado. Sêniores às vezes preferem reportar min — argumento: "é o que o hardware é capaz de fazer".

Allocated — bytes alocados por operação. Crucial para análise de GC pressure. BenchmarkDotNet com [MemoryDiagnoser] e Go com -benchmem reportam.

Ratio (vs baseline) — quão mais rápido em comparação ao baseline. Frameworks como BenchmarkDotNet calculam automaticamente quando você marca um benchmark com [Benchmark(Baseline = true)].

Statistical significance — quando a diferença importa

Se mudei o código e o benchmark passou de 100ns para 95ns, isso é melhora real ou ruído? Sem teste estatístico, não dá para saber. A regra prática embutida em ferramentas modernas:

BenchmarkDotNet: usa Welch's t-test automaticamente quando você compara baseline com alternativas. Reporta razão e intervalo de confiança. Regressão de 1–3% tipicamente fica dentro do intervalo (não significativa); regressão de 10%+ tipicamente é significativa.

benchstat (Go): usa Mann-Whitney U-test por padrão. Reporta delta percentual com p-valor. p < 0.05 é convenção para significância.

Hyperfine: roda múltiplos --warmup e mede CV (coeficiente de variação). Alerta se CV é alto (sistema instável).

A regra prática: rode 5–10 vezes cada variante, use benchstat ou equivalente, e só aceite mudança como "significativamente melhor" se o p-valor é baixo e o delta é grande o suficiente para justificar o ônus de manutenção. Mudança de 2% raramente vale o risco de bug; mudança de 30% provavelmente vale.

Macrobenchmarks — quando microbench não basta

Microbenchmarks medem operações isoladas. Performance do sistema completo depende de interação entre componentes — caches, conexões de banco, locks, threadpools. Para validar que mudança "ajudou de verdade", precisa de macrobenchmark.

k6 (criado pela Load Impact em 2017, adquirido pela Grafana Labs em 2021) é a ferramenta moderna recomendada. Scripts em JavaScript ES6+, execução em Go (alto throughput), relatórios em Grafana. Conceito 12 detalha.

Locust (Python, 2011) é alternativa pythônica — scripts em Python, distribuído via master/worker, UI web embutida. Bom para times Python.

JMeter (Apache, ~2000) é o veterano — ferramenta GUI Java, recursos pesados, tradição em testes corporativos. Ainda usado, mas k6 e Gatling tomaram tração em times modernos.

Gatling (Scala/Java, 2012) é o que muitos times consideram o melhor — DSL elegante, relatórios HTML excelentes, performance do runtime JVM.

O fluxo padrão é: identificar via profiling onde o gargalo está, microbench para escolher entre alternativas, mergir, macrobench em ambiente staging para confirmar que P99 melhorou no sistema completo. Sem o macrobench, você só sabe que o microbench melhorou — mas o sistema pode ter outros gargalos que dominam.

armadilha em produção

Microbenchmark vence, sistema regrede. Cenário comum: equipe identifica que JsonSerializer.Serialize é hot path. Faz microbench mostrando que System.Text.Json com source-generated contracts é 30% mais rápido. Mergeia. Em produção, P99 não muda (talvez piora). Investigação revela que o gargalo real era outra coisa — alocação de List<T> em loop, por exemplo —, e a otimização atacou os 5% errados. Defesa: profile antes do micro, macrobench depois. Microbenchmark sozinho mente sobre o impacto sistêmico.

Continuous benchmarking em CI

A prática moderna em times maduros é integrar benchmark em CI: cada PR que toca em hot path conhecido roda benchmark, compara com baseline (main branch), falha o build se regrediu além de threshold (5%, por exemplo). Sem isso, regressões silenciosas se acumulam — cada PR adiciona 1% que ninguém mede, e em 100 PRs o sistema ficou 100% mais lento.

Codspeed usa instrumentation determinística (contagem de instruções via Valgrind + sysgrind) — funciona em CI compartilhado sem ruído de outros builds. Suporta Rust (cargo bench), Python (pytest-benchmark), JS, e outros.

Bencher e Touca são alternativas comerciais com integração de visualização e histórico.

BenchmarkDotNet tem --exporters json que permite armazenar resultados ao longo do tempo e plotar evolução. Times sem orçamento para tooling pago frequentemente constroem o próprio com isso.

Quando NÃO benchmarkar

Benchmark tem custo — escrever benchmarks corretos é trabalho, mantê-los é mais. Há casos onde benchmark não vale o esforço, e reconhecê-los é maturidade.

Código que não está no hot path. Se o profiling mostra que função X ocupa 0.5% do tempo, otimizar não traz retorno mensurável. Não vale benchmarkar.

Operações com I/O dominante. Se a operação envolve query de banco que custa 10ms, qualquer otimização CPU-side é ruído. Microbench não faz sentido — o que importa é o macrobench que captura I/O.

Mudanças cosméticas. Refactor de legibilidade ou organização não merece benchmark, a menos que o fluxo seja crítico. A maioria dos PRs cai aqui.

Quando o resultado não vai mudar a decisão. Se o microbench mostrar que A é 5% mais rápido que B, mas você vai escolher B por legibilidade, o benchmark foi gasto inútil. Decida primeiro o critério, mensure depois.

heurística do sênior

Antes de escrever benchmark, articule: "qual decisão esse benchmark vai informar?". Se a resposta é vaga ("queria saber qual é mais rápido"), o benchmark provavelmente é exercício acadêmico — pode ser legítimo, mas reconheça. Se a resposta é específica ("preciso decidir entre A e B para um hot path identificado, e quero saber qual ganho compensa o ônus"), o benchmark é trabalho útil. E se você não puder relatar um intervalo de confiança ou desvio-padrão sobre o número, o benchmark é inválido — número sem incerteza articulada não é benchmark, é anedota.

Por que importa para a sua carreira

Benchmarking é onde sêniores se distinguem de plenos claramente. Em entrevistas técnicas, "você conhece BenchmarkDotNet (ou pprof, ou pytest-benchmark)?" é perguntinha frequente — e a resposta forte vai além ("sim, e sei lidar com warmup, dead code elimination, statistical significance"). Em revisão de PR de "otimização", exigir resultado de benchmark com desvio-padrão e comparação rigorosa é serviço ao time — captura otimizações ilusórias antes de mergir. Em pos-mortem de regressão, ter histórico de benchmarks em CI permite identificar exatamente qual PR introduziu a regressão. Em discussão de design entre alternativas, "vamos benchmarkar antes de decidir" é frase de senior — substitui opinião por dado.

Como praticar

  1. Reproduzir um benchmark conhecido. Pegue o caso clássico de "+= vs StringBuilder vs String.Join" (que vimos em três linguagens neste conceito). Implemente em sua linguagem principal usando o framework idiomático. Compare com tamanho variável (10, 100, 1000, 10000 strings). Plote o resultado. Anote o ponto onde cada implementação ganha. Esse é benchmark "didático", mas o exercício de fazê-lo certo (warmup, retorno do valor para evitar DCE, statistical analysis) consolida a disciplina.
  2. Benchmark como discussão. Em algum projeto seu, pegue uma decisão que você tomou por opinião (escolha de biblioteca, de estrutura de dados, de algoritmo). Escreva benchmark que compara sua escolha com pelo menos uma alternativa. Rode com rigor (10+ iterações, statistical significance). Documente o resultado em ADR ou comentário no código. Esse exercício transforma decisão por instinto em decisão defensável — e às vezes revela que a intuição estava errada.
  3. CI com benchmark. Em algum projeto, configure benchmark para rodar em PR e falhar o build se regredir mais de X%. Use Codspeed (gratuito para open source), ou faça caseiro com BenchmarkDotNet+JSON+CI script. Aplique a um hot path identificado por profile. Esse é o setup que captura regressões silenciosas — e é o tipo de coisa que time sênior valoriza.

Referências para aprofundar

  1. livro Pro .NET Benchmarking — Andrey Akinshin (Apress, 2019). A bíblia da medição de performance em .NET. Akinshin é autor de BenchmarkDotNet. Os capítulos sobre statistical analysis e armadilhas de medição são universais — valem mesmo para quem usa outras linguagens.
  2. livro Systems Performance: Enterprise and the Cloud (2ª ed.) — Brendan Gregg (Pearson, 2020). Cap. 12 (Benchmarking) cobre armadilhas em macrobenchmarks com profundidade. "Benchmarking sins" é seção memorável.
  3. livro The Art of Computer Systems Performance Analysis — Raj Jain (Wiley, 1991). Clássico universitário sobre benchmarking estatístico. Datado em ferramentas, mas a estatística e os princípios sobrevivem.
  4. docs BenchmarkDotNet Documentation. benchmarkdotnet.org — Documentação oficial completa. Includes seção dedicada a "Good practices" que vale ler na íntegra.
  5. docs Go testing — Benchmarks. pkg.go.dev/testing#hdr-Benchmarks e blog post "Profiling Go Programs" (go.dev/blog/pprof). Cobertura oficial do framework embutido.
  6. docs pytest-benchmark. pytest-benchmark.readthedocs.io — Documentação oficial. Cobre fixtures, comparação entre runs, integração com pytest.
  7. docs hyperfine. github.com/sharkdp/hyperfine — README muito didático. Para benchmark de programas inteiros (CLI), é a ferramenta canônica.
  8. docs benchstat. pkg.go.dev/golang.org/x/perf/cmd/benchstat — Ferramenta para comparar runs Go com t-test. Lê output padrão de go test -bench.
  9. artigo How to write a benchmark you won't regret — Andrey Akinshin (várias palestras 2018–2024). Akinshin tem várias palestras com esse título no NDC, dotNext, e Microsoft Tech Days. YouTube. Material denso, vale assistir e re-assistir.
  10. artigo Always Measure One Level Deeper — John Ousterhout (CACM, 2018). cacm.acm.org/magazines/2018/7 — Ousterhout argumenta que benchmark de alto nível esconde a causa real; sempre vale instrumentar uma camada abaixo. Conexão com profiling.
  11. artigo The Hazards of Software Performance Measurement — Mytkowicz et al. (ASPLOS, 2009). Paper acadêmico que mostra como pequenas mudanças no ambiente (variáveis de ambiente, ordem de link) afetam benchmark dramaticamente. Chamativo: o número que você medindo pode estar mais relacionado a hash da PATH que a sua mudança.
  12. vídeo Performance Matters — Emery Berger (Strange Loop, 2019). YouTube. Berger (autor de scalene) argumenta que benchmarks tradicionais são tão problemáticos que ferramentas como stabilizer (que randomiza o ambiente) são necessárias. Provocador e revelador.