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.
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.
# 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.
// 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.
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.
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
-
Reproduzir um benchmark conhecido.
Pegue o caso clássico de "
+=vsStringBuildervsString.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. - 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.
- 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
- livro Pro .NET Benchmarking — Andrey Akinshin (Apress, 2019).
- livro Systems Performance: Enterprise and the Cloud (2ª ed.) — Brendan Gregg (Pearson, 2020).
- livro The Art of Computer Systems Performance Analysis — Raj Jain (Wiley, 1991).
- docs BenchmarkDotNet Documentation.
- docs Go testing — Benchmarks.
- docs pytest-benchmark.
- docs hyperfine.
- docs benchstat.
- artigo How to write a benchmark you won't regret — Andrey Akinshin (várias palestras 2018–2024).
- artigo Always Measure One Level Deeper — John Ousterhout (CACM, 2018).
- artigo The Hazards of Software Performance Measurement — Mytkowicz et al. (ASPLOS, 2009).
- vídeo Performance Matters — Emery Berger (Strange Loop, 2019).