BenchmarkDotNet: se é mais rápido, prove
Toda discussão sobre qual abordagem é “mais rápida” em C# eventualmente esbarra na mesma pergunta chata: mais rápido comparado a quê, medido como? Intuição sobre performance erra com uma frequência incômoda, e a única forma confiável de saber é medir de verdade.
Por que Stopwatch manual não é confiável o bastante
Envolver um Stopwatch em volta de um trecho de código parece medição válida, mas carrega problemas sérios: o JIT ainda não compilou o método para código nativo otimizado na primeira execução, o coletor de lixo pode rodar no meio da medição, e uma única execução não diz nada sobre variância. Um método pode parecer rápido só porque rodou numa janela de sorte, sem nenhuma otimização de JIT ainda aplicada nem pressão de memória naquele instante específico.
BenchmarkDotNet existe para eliminar boa parte desse ruído, rodando cada benchmark várias vezes, em fases distintas, e reportando estatística em vez de um número isolado.
Como funciona, na prática
Um benchmark básico marca o método com o atributo [Benchmark] dentro de uma classe pública:
public class ComparacaoDeConcat
{
private readonly string[] _valores = Enumerable.Range(0, 100)
.Select(i => i.ToString())
.ToArray();
[Benchmark(Baseline = true)]
public string ComStringBuilder()
{
var sb = new StringBuilder();
foreach (var valor in _valores)
{
sb.Append(valor);
}
return sb.ToString();
}
[Benchmark]
public string ComConcatenacao()
{
var resultado = string.Empty;
foreach (var valor in _valores)
{
resultado += valor;
}
return resultado;
}
}
A execução acontece via BenchmarkRunner.Run<ComparacaoDeConcat>(), tipicamente chamado a partir de um projeto de console separado, sempre compilado em modo Release.
As fases que tornam a medição confiável
BenchmarkDotNet não executa o método uma vez e reporta o tempo. Ele passa por fases: uma fase de Pilot, que estima quantas invocações são necessárias para uma medição estável, uma fase de Warmup, que existe justamente para deixar o JIT compilar o método e aquecer caches antes de qualquer número contar, e só depois a fase de medição real (workload), repetida em múltiplas iterações.
O resultado reportado inclui média, erro e desvio padrão, não um único número, porque variância entre execuções é esperada e faz parte de reportar performance com honestidade.
[MemoryDiagnoser] e o que ele revela
Tempo de execução não é a única métrica que importa. O atributo [MemoryDiagnoser], adicionado à classe, faz o BenchmarkDotNet reportar também bytes alocados por operação e contagem de coleções de Gen 0, Gen 1 e Gen 2 disparadas durante o benchmark:
[MemoryDiagnoser]
public class ComparacaoDeConcat
{
// ...
}
Isso costuma expor por que uma abordagem “mais lenta” às vezes é preferível: menos alocação significa menos pressão sobre o garbage collector, o que importa em cenários de alto throughput mesmo quando o tempo médio de execução é parecido.
Lendo a tabela sem se enganar
A tabela de resultado do BenchmarkDotNet normalmente traz colunas como Mean, Error, StdDev, Allocated e, quando existe uma baseline marcada, uma coluna Ratio comparando cada método contra ela. Um erro comum é olhar só para Mean e ignorar StdDev: uma diferença pequena entre duas médias, com desvio padrão maior que a própria diferença, não é uma conclusão confiável de que um método é mais rápido que o outro.
Medir antes de otimizar, não depois
A tentação de otimizar por intuição, achando que uma forma de escrever código é obviamente mais rápida, é real e às vezes até acerta. O problema é que “às vezes acerta” não é base suficiente para justificar reescrever código legível por código mais complexo. BenchmarkDotNet transforma “isso deveria ser mais rápido” numa afirmação que pode ser verificada, com número, antes de qualquer troca de legibilidade por performance ser considerada válida.