Benchmarks com BenchmarkDotNet e Perfil de Alocação
Objective
"Esta versão é mais rápida" e "esta versão aloca menos" são afirmações sobre
números, e um número tirado com um cronômetro em volta de uma chamada quase
nunca é o número que você imagina. BenchmarkDotNet é a ferramenta padrão para
medir código .NET: cuida de aquecimento, tiers do JIT, isolamento de processo e
estatística, e com [MemoryDiagnoser] relata as alocações por operação, que é
o número que costuma explicar um serviço lento. Este concept mostra por que um
cronômetro engana, como escrever um benchmark com baseline e parâmetros, como
ler o relatório, quais armadilhas fazem um benchmark não medir nada, como o
pooling aparece nos números e como acompanhar a alocação em um processo em
execução. Todos os números abaixo foram medidos em uma máquina só (um Apple M4
Max, macOS, arm64, 14 núcleos, BenchmarkDotNet 0.15.8). Trate-os como uma
ilustração do método, não como dado sobre o seu hardware.
Use Cases
- Provar, antes de um pull request, que uma reescrita aloca menos e não é mais lenta.
- Escolher entre duas implementações (
+=contraStringBuilder,newcontraArrayPool) com evidência em vez de folclore. - Ler
Gen0eAllocatedpara descobrir por que um caminho quente dispara muitas coletas. - Conferir que uma atualização de runtime não causou regressão em um método crítico.
- Acompanhar a taxa de alocação de um serviço em execução sem anexar um depurador.
Deep Dive
Por que o cronômetro engana
Um laço com Stopwatch.StartNew() em volta de uma chamada parece bom até você
chamar mais de uma vez. Montar uma string separada por ponto e vírgula a partir
de 100 inteiros com +=, cronometrada em cinco chamadas seguidas em um build
Release:
csharpfor (int i = 0; i < 5; i++)
{
long before = GC.GetAllocatedBytesForCurrentThread();
var sw = Stopwatch.StartNew();
var s = Csv.Plus(values);
sw.Stop();
Console.WriteLine($"{sw.Elapsed.TotalMicroseconds:F1} us, {GC.GetAllocatedBytesForCurrentThread() - before} B");
}| Chamada | Tempo (duas execuções) | Alocado |
|---|---|---|
| 0 | 149 us, 283 us | 41.640 B, 39.736 B |
| 1 | 11 us, 9 us | 30.944 B |
| 2 a 4 | cerca de 4 a 6 us | 30.944 B |
A primeira chamada foi de 25 a 60 vezes mais lenta que o regime estável, porque
inclui compilação JIT e custos de primeiro uso. O regime estável precisa de
várias chamadas para aparecer, a compilação em tiers ainda pode mudar o código
depois, e um número isolado não tem dispersão. O BenchmarkDotNet existe para
eliminar exatamente esses problemas: roda o método em outro processo, calibra
quantas operações executar por medição, descarta o aquecimento, repete e
relata a média com um intervalo de erro. Repare que
GC.GetAllocatedBytesForCurrentThread já deu o número estável (30.944 bytes)
depois da primeira chamada. Bytes são muito mais repetíveis que tempo, e por
isso o lab deste concept faz asserções sobre eles.
Um primeiro benchmark
csharp[MemoryDiagnoser]
public class ConcatBench
{
[Params(10, 1000)] public int N;
private int[] _values = [];
[GlobalSetup] public void Setup() => _values = Enumerable.Range(0, N).ToArray();
[Benchmark(Baseline = true)] public string Plus() => Csv.Plus(_values);
[Benchmark] public string Builder() => Csv.Builder(_values);
}Rode a partir de um projeto console que referencia o pacote BenchmarkDotNet,
em Release, fora do depurador:
csharpBenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);plaintextdotnet run -c Release -- --filter "*ConcatBench*" --job short
--job short roda menos iterações (três aqui), e por isso a coluna de erro
abaixo é larga. Use o job padrão para uma conclusão que você vai defender.
| Método | N | Média | Ratio | Gen0 | Alocado | Alloc Ratio |
|---|---|---|---|---|---|---|
| Plus | 10 | 72,9 ns | 1,00 | 0,0573 | 480 B | 1,00 |
| Builder | 10 | 49,1 ns | 0,67 | 0,0325 | 272 B | 0,57 |
| Plus | 1000 | 145.528 ns | 1,00 | 459,2 | 3.844.104 B | 1,000 |
| Builder | 1000 | 3.005 ns | 0,02 | 1,99 | 16.680 B | 0,004 |
Lendo o relatório
- Baseline e Ratio.
[Benchmark(Baseline = true)]marca o método de referência;Ratioé a média de cada método dividida pela média do baseline (a legenda do relatório diz "[Current]/[Baseline]"), então 0,02 significa cerca de cinquenta vezes mais rápido. Um ratio só faz sentido dentro de um mesmo relatório. [Params]roda cada benchmark uma vez por valor e imprime um bloco por valor, separado por uma linha vazia. A tabela acima mostra o motivo: com N = 10 o builder é 33 por cento mais rápido, com N = 1000 é quase cinquenta vezes mais rápido e aloca 230 vezes menos bytes, porque+=copia a string inteira a cada passo e o custo cresce com o quadrado da entrada. Um tamanho de entrada só teria escondido a história.Gen0,Gen1,Gen2são coletas por 1000 operações: 459 coletas gen0 a cada 1000 chamadas dePlus(1000). É o custo que a média não mostra, porque recai sobre o processo inteiro.Allocatedé a memória gerenciada por operação, incluindo o que o método chama; memória nativa não conta.ErroreStdDevdizem o quanto confiar naMean. Se o erro é uma fração grande da média (a linha N = 10 acima, de uma execução curta), não conclua nada dessa linha.
Armadilhas que fazem um benchmark não medir nada
Quatro métodos que parecem benchmarks, em uma classe com [MemoryDiagnoser]:
csharp[Benchmark] public void Dead() { var a = new byte[1000]; }
[Benchmark] public int Kept() { var a = new byte[1000]; return a.Length + _x; }
[Benchmark] public byte[] Returned() { var a = new byte[1000]; return a; }
[Benchmark] public double Constant() => Math.Sqrt(2.0);| Método | Média | Alocado |
|---|---|---|
| Dead | 0,04 ns | nada |
| Kept | 0,04 ns | nada |
| Returned | 28,5 ns | 1.024 B |
| Constant | cerca de 0 ns | nada |
O BenchmarkDotNet imprimiu um aviso ZeroMeasurement para o primeiro, o
segundo e o quarto: "the method duration is indistinguishable from the empty
method duration". O JIT removeu o array que ninguém usava, mesmo em Kept,
onde só o tamanho foi lido (a análise de escape descrita em Value Types,
Reference Types and Boxing), e dobrou Math.Sqrt(2.0) em uma constante. Só a
versão que devolve o array mediu uma alocação. As regras que decorrem disso:
- Devolva o resultado (ou entregue a um
Consumer/Blackhole) para que o trabalho não possa ser removido. Isso é dead code elimination. - Leia as entradas de campos preenchidos em
[GlobalSetup], nunca de constantes, para o JIT não poder dobrar a conta. - Deixe o setup fora do método. Montar a entrada dentro do benchmark mede o
setup junto;
[GlobalSetup]roda uma vez e[IterationSetup]é último recurso, porque distorce benchmarks curtos. - Só Release. Rodando o mesmo projeto em Debug, o BenchmarkDotNet para antes de medir: "Assembly Demo which defines benchmarks is non-optimized". Rode também fora do depurador e, de preferência, com outros programas pesados fechados.
- Atenção a tiering e aquecimento. O job padrão roda iterações de
aquecimento até o método estabilizar; se você reduzir isso, confira que a
Meannão continua caindo.
Comparando .NET 10 e .NET 11
O BenchmarkDotNet pode rodar o mesmo benchmark em vários runtimes com um job
por runtime. Na 0.15.8, a versão estável na época em que isto foi escrito, isso
é um problema para o .NET 11: o CoreRuntime dela tem Core10_0 e não tem
Core11_0, a linha de comando --runtimes net11.0 é rejeitada ("The provided
runtime "net11.0" is invalid", e a lista termina em net10_0), e um projeto
compilado para net11.0 falha com NotImplementedException: GetRuntimeVersion not implemented for NotRecognized no toolchain padrão. O que
funcionou aqui foi compilar o projeto para os dois target frameworks e rodar
cada build no próprio processo com --inProcess, que mede no runtime
hospedeiro sem a checagem de SDK:
plaintextdotnet run -c Release -f net10.0 -- --filter "*VersionBench*" --inProcess dotnet run -c Release -f net11.0 -- --filter "*VersionBench*" --inProcess
| Método | .NET 10.0.0 | .NET 11.0.0 | Alocado (os dois) |
|---|---|---|---|
LinqSum sobre 1000 inteiros |
1,234 us | 1,218 us | 104 B |
laço com StringBuilder, 1000 inteiros |
2,883 us | 2,770 us | 16.680 B |
Diferenças de 1 a 4 por cento com erros de até 2 por cento: nenhuma conclusão
além de "sem regressão aqui". --inProcess abre mão do isolamento de processo
que protege um benchmark do estado do próprio hospedeiro, então é um
contorno, não um padrão; veja as notas de versão do BenchmarkDotNet para
suporte de primeira classe ao .NET 11 antes de depender disso.
Pooling como correção medida
ArrayPool<T>.Shared e ObjectPool<T> (pacote
Microsoft.Extensions.ObjectPool) trocam alocação por reutilização. Se
compensa é uma pergunta para o relatório:
| Método (ShortRun) | Média | Ratio | Gen0/Gen1/Gen2 | Alocado |
|---|---|---|---|---|
new byte[100_000] |
3.401 ns | 1,000 | 31 / 31 / 31 | 100.045 B |
ArrayPool<byte>.Shared.Rent / Return |
3,96 ns | 0,001 | nenhuma | nada |
new StringBuilder() mais ToString() |
13,2 ns | 0,004 | 0,017 gen0 | 144 B |
pool de StringBuilder Get / Return |
10,2 ns | 0,003 | 0,005 gen0 | 40 B |
O array acima de 85.000 bytes vive no large object heap, então toda chamada
alimenta coletas gen2 (os 31 da coluna Gen2); o pool elimina a alocação por
completo. O caso do builder pequeno é um ganho modesto, e a versão com pool
ainda aloca 40 bytes para a string de resultado. O pooling também tem um custo
de configuração que dá para medir. A política padrão do pool de StringBuilder
descarta builders que passaram de 4.096 caracteres, então com um resultado de
3.890 caracteres você paga o pool mais um builder novo a cada chamada:
| Bytes por chamada (1000 chamadas, uma thread) | 100 valores | 1000 valores |
|---|---|---|
StringBuilder do pool |
609 B | 21.176 B |
new StringBuilder() |
2.064 B | 16.680 B |
new StringBuilder(capacity) |
1.880 B | 19.880 B |
Com 100 valores o pool aloca só o resultado (602 B para uma string de 290
caracteres). Com 1000 valores a versão com pool alocou mais que um builder
simples, porque o builder nunca sobrevivia ao limite de tamanho do pool (o
InitialCapacity da política é 100 e o MaximumRetainedCapacity é 4.096, lidos
de StringBuilderPooledObjectPolicy). Um pool é uma troca, não um ganho de
graça: meça com os seus tamanhos reais. O mesmo vale para o ArrayPool (limpe
dados sensíveis no Return com clearArray: true e nunca toque em um buffer
depois de devolvê-lo).
Alocação em um processo em execução
Quando não dá para anexar um benchmark, o dotnet-counters lê os contadores do
runtime pelo EventPipe. Instalado com dotnet tool install dotnet-counters --tool-path <dir> (versão 10.0.750501 aqui) e apontado para um processo que
chamou o método com += em laço por 25 segundos:
plaintextdotnet-counters collect -p <pid> --counters System.Runtime \ --format csv -o counters --refresh-interval 1 --duration 00:00:08
O CSV teve uma linha por contador por segundo, com os nomes no estilo OpenTelemetry dos runtimes recentes. Os que respondem a uma pergunta de alocação:
| Contador | Valor em 8 segundos |
|---|---|
dotnet.gc.collections de gen0 |
2.459 a 3.053 por segundo |
dotnet.gc.collections de gen1 / gen2 |
0 a 2 por segundo / 0 |
dotnet.gc.heap.total_allocated |
20,6 a 25,5 GB por segundo |
dotnet.gc.last_collection.heap.size do LOH |
24 B |
É uma tempestade só de gen0: tudo morre jovem, nada chega ao LOH, e o processo
passa o tempo alocando e coletando. É a forma do laço com += e 1000 valores
do benchmark acima. Os contadores dizem que um processo aloca demais e em qual
geração; para achar quais métodos e tipos são os responsáveis, use um perfil de
alocação ou um dump com as ferramentas de Diagnosing Production with
dotnet-trace, dump and gcdump, e depois confirme a correção com um benchmark.
Trade-offs
- Stopwatch contra BenchmarkDotNet. Um cronômetro basta para ver uma ordem de grandeza (uma diferença de 50x não é ruído); o BenchmarkDotNet é para decisões, ao preço de minutos por execução e de um projeto separado.
- Job short contra job padrão.
--job shortdá um ciclo de feedback rápido mas produz barras de erro largas, como a linha N = 10 mostra; use o job padrão para números que vão para um pull request. - Tempo contra alocações. Bytes por operação são estáveis entre máquinas e
execuções, tempo não. Para testes de regressão, faça asserções sobre alocação
em faixas (como o lab faz com
GC.GetAllocatedBytesForCurrentThread) e deixe o tempo para benchmarks que você lê.csharpvar before = GC.GetAllocatedBytesForCurrentThread(); Work(); Assert.InRange(GC.GetAllocatedBytesForCurrentThread() - before, 0, 1_000); - Microbenchmark contra produção. Um benchmark em uma máquina, com um tamanho de entrada e cache quente, diz pouco sobre contenção, um heap do GC carregado ou outra CPU. Use-o para comparar duas versões e confirme em produção com contadores.
- Pooling contra alocação simples. Um pool tira pressão do GC em buffers grandes ou quentes, e acrescenta regras de tempo de vida (devolva, não toque depois) e políticas de tamanho. Para objetos pequenos e de vida curta, o GC geracional costuma sair mais barato que a disciplina.