"Ficou mais rápido" não é uma medição. "Ficou 3,2x mais rápido, com 87% menos alocações no heap" é. A diferença entre intuição de performance e evidência de performance é o BenchmarkDotNet — a ferramenta padrão da indústria .NET para medir o que realmente acontece quando seu código roda.
Este guia vai além do "adiciona o atributo [Benchmark] e roda". Você vai ver como configurar benchmarks que medem o que você quer medir, como interpretar os resultados sem cometer erros clássicos, e como integrar benchmarks no seu fluxo de desenvolvimento para detectar regressões antes de chegarem em produção.
Por que não usar Stopwatch para medir performance
O Stopwatch tem dois problemas sérios para benchmarking: não controla o warm-up do JIT (as primeiras execuções de um método são mais lentas porque o CLR ainda está compilando para código nativo), e não isola interferências do Garbage Collector (um GC que roda durante sua medição pode inflar o tempo por dezenas de milissegundos sem que você perceba).
O BenchmarkDotNet resolve ambos: roda múltiplas iterações de warm-up antes de medir, controla quando o GC roda, executa cada benchmark em um processo separado para isolar interferências, e aplica análise estatística (média, desvio padrão, intervalos de confiança) para dar resultados confiáveis.
Setup e primeiro benchmark
dotnet add package BenchmarkDotNet
// Estrutura mínima de um benchmark
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
// Executar a partir do Main com configuração de release
// IMPORTANTE: sempre roda em modo Release — nunca Debug
BenchmarkRunner.Run<StringConcatBenchmarks>();
[MemoryDiagnoser] // mostra alocações no heap
[SimpleJob(RuntimeMoniker.Net90)] // especifica o runtime
public class StringConcatBenchmarks
{
private const int Iterations = 1000;
private static readonly string[] Words = Enumerable
.Range(0, Iterations)
.Select(i => $"word{i}")
.ToArray();
[Benchmark(Baseline = true)] // referência para comparação relativa
public string ConcatOperator()
{
var result = "";
for (int i = 0; i < Iterations; i++)
result += Words[i];
return result;
}
[Benchmark]
public string StringBuilder()
{
var sb = new System.Text.StringBuilder();
for (int i = 0; i < Iterations; i++)
sb.Append(Words[i]);
return sb.ToString();
}
[Benchmark]
public string StringJoin()
=> string.Join("", Words);
[Benchmark]
public string StringConcat()
=> string.Concat(Words);
}
// Resultado típico:
| Method | Mean | Ratio | Allocated | Alloc Ratio |
|--------------- |------------:|------:|-----------:|------------:|
| ConcatOperator | 1,823.45 μs | 1.00 | 3,914.1 KB | 1.00 |
| StringBuilder | 14.32 μs | 0.01 | 16.3 KB | 0.00 |
| StringJoin | 12.87 μs | 0.01 | 16.3 KB | 0.00 |
| StringConcat | 11.94 μs | 0.01 | 16.3 KB | 0.00 |
MemoryDiagnoser: alocações importam tanto quanto tempo
Em aplicações .NET de alta performance, reduzir alocações no heap é frequentemente mais impactante do que reduzir tempo de CPU. Cada alocação é uma possível pressão no GC — e uma coleta do GC pode pausar sua aplicação por milissegundos.
[MemoryDiagnoser]
[HideColumns("StdDev", "Median")] // simplifica a saída
public class JsonParsingBenchmarks
{
private static readonly string JsonPayload = File.ReadAllText("sample-order.json");
private static readonly byte[] JsonBytes = File.ReadAllBytes("sample-order.json");
[Benchmark(Baseline = true)]
public Order? JsonDeserializeString()
=> System.Text.Json.JsonSerializer.Deserialize<Order>(JsonPayload);
[Benchmark]
public Order? JsonDeserializeBytes()
=> System.Text.Json.JsonSerializer.Deserialize<Order>(JsonBytes);
[Benchmark]
public Order? JsonDeserializeUtf8Reader()
{
var reader = new System.Text.Json.Utf8JsonReader(JsonBytes);
return System.Text.Json.JsonSerializer.Deserialize<Order>(ref reader);
}
// Span-based: zero cópia, mínimo de alocações
[Benchmark]
public Order? JsonDeserializeSpan()
=> System.Text.Json.JsonSerializer.Deserialize<Order>(
JsonBytes.AsSpan());
}
Parâmetros: testando múltiplos cenários de uma vez
// [Params] executa o benchmark para cada valor automaticamente
// Útil para encontrar onde a implementação escala melhor/pior
[MemoryDiagnoser]
public class CollectionSearchBenchmarks
{
[Params(10, 100, 1_000, 10_000, 100_000)]
public int Size { get; set; }
private List<int> _list = null!;
private HashSet<int> _hashSet = null!;
private int[] _array = null!;
private int _target;
[GlobalSetup]
public void Setup()
{
_list = Enumerable.Range(0, Size).ToList();
_hashSet = new HashSet<int>(_list);
_array = _list.ToArray();
_target = Size / 2; // busca pelo elemento do meio
}
[Benchmark(Baseline = true)]
public bool ListContains()
=> _list.Contains(_target);
[Benchmark]
public bool HashSetContains()
=> _hashSet.Contains(_target);
[Benchmark]
public bool ArrayIndexOf()
=> Array.IndexOf(_array, _target) >= 0;
[Benchmark]
public bool LinqAny()
=> _list.Any(x => x == _target);
}
// Resultado para Size = 100_000:
// | Method | Size | Mean | Ratio | Allocated |
// |--------------- |------- |-----------:|------:|----------:|
// | ListContains | 100000 | 234.5 μs | 1.00 | - B |
// | HashSetContains| 100000 | 8.2 ns | 0.00 | - B |
// | ArrayIndexOf | 100000 | 112.3 μs | 0.48 | - B |
// | LinqAny | 100000 | 251.8 μs | 1.07 | 40 B |
GlobalSetup e GlobalCleanup: preparando dados sem contaminar a medição
// Setup roda uma vez antes do benchmark — não é medido
// IterationSetup roda antes de cada iteração (use com cuidado — é medido)
[MemoryDiagnoser]
public class DatabaseQueryBenchmarks
{
private IDbConnection _connection = null!;
private IOrderRepository _efRepository = null!;
private IOrderRepository _dapperRepository = null!;
[GlobalSetup]
public void Setup()
{
// Configura conexão real com banco de teste
var connectionString = "Host=localhost;Database=bench_db;...";
_connection = new NpgsqlConnection(connectionString);
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(connectionString)
.Options;
_efRepository = new EfOrderRepository(new AppDbContext(options));
_dapperRepository = new DapperOrderRepository(_connection);
// Seed: garante dados existentes para a query
SeedDatabase(_connection);
}
[Benchmark(Baseline = true)]
public async Task<IReadOnlyList<Order>> EfCoreWithIncludes()
=> await _efRepository.GetRecentOrdersWithItemsAsync(limit: 100);
[Benchmark]
public async Task<IReadOnlyList<Order>> DapperMultiMapping()
=> await _dapperRepository.GetRecentOrdersWithItemsAsync(limit: 100);
[Benchmark]
public async Task<IReadOnlyList<OrderDto>> EfCoreProjection()
=> await _efRepository.GetRecentOrderDtosAsync(limit: 100);
[GlobalCleanup]
public void Cleanup()
{
_connection.Dispose();
}
}
Comparando versões: detectar regressões de performance
// Compara a implementação atual com a nova proposta
// Útil em code reviews de mudanças com impacto de performance
[MemoryDiagnoser]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
public class DateTimeParsingBenchmarks
{
private static readonly string[] Dates = Enumerable
.Range(0, 1000)
.Select(i => DateTime.UtcNow.AddDays(i).ToString("yyyy-MM-ddTHH:mm:ssZ"))
.ToArray();
// Implementação atual — usa DateTime.Parse (cultura-dependente, mais lento)
[Benchmark(Baseline = true)]
public DateTime[] CurrentImplementation()
=> Dates.Select(d => DateTime.Parse(d)).ToArray();
// Nova implementação — usa TryParseExact (cultura-independente, mais rápido)
[Benchmark]
public DateTime[] NewImplementation()
{
var results = new DateTime[Dates.Length];
for (int i = 0; i < Dates.Length; i++)
DateTime.TryParseExact(
Dates[i],
"yyyy-MM-ddTHH:mm:ssZ",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out results[i]);
return results;
}
// Versão com span: zero alocação adicional
[Benchmark]
public DateTime[] SpanImplementation()
{
var results = new DateTime[Dates.Length];
for (int i = 0; i < Dates.Length; i++)
results[i] = DateTime.ParseExact(
Dates[i].AsSpan(),
"yyyy-MM-ddTHH:mm:ssZ",
System.Globalization.CultureInfo.InvariantCulture);
return results;
}
}
Armadilhas clássicas que invalidam resultados
Rodar em modo Debug: o compilador Debug desabilita otimizações que o JIT faria em produção. Um benchmark em Debug pode mostrar código 5-10x mais lento que em Release. O BenchmarkDotNet já força modo Release, mas se você estiver usando Stopwatch manualmente, isso é um erro silencioso.
Eliminar código morto: o JIT é inteligente o suficiente para eliminar computações cujo resultado nunca é usado. Um benchmark que computa algo mas não retorna o valor pode ter todo o código otimizado para fora. Sempre retorne o resultado do benchmark ou use BenchmarkDotNet.Engines.Consumer para "consumir" o resultado.
Não controlar o estado entre iterações: se seu benchmark modifica uma coleção (adiciona elementos, remove, ordena), a segunda iteração mede uma coleção diferente da primeira. Use [IterationSetup] para resetar o estado — mas saiba que esse atributo adiciona overhead que é medido, então use com moderação.
Heap muito pequeno ou muito grande: benchmarks que rodam em processos com poucos dados podem ter todos os objetos no cache L1/L2 do CPU, parecendo mais rápidos do que seriam em produção com dados reais. Tente usar volumes de dados próximos aos de produção.
Integração com CI/CD: detectar regressões automaticamente
# .github/workflows/benchmarks.yml
# Roda benchmarks a cada PR e comenta com comparação de performance
name: Performance Benchmarks
on:
pull_request:
paths:
- 'src/**/*.cs'
- 'benchmarks/**/*.cs'
jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # precisa do histórico para comparar
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Run benchmarks
run: |
dotnet run --project benchmarks/MyApp.Benchmarks \
--configuration Release \
-- --exporters json \
--artifacts ./benchmark-results
- name: Store benchmark result
uses: benchmark-action/github-action-benchmark@v1
with:
tool: 'benchmarkdotnet'
output-file-path: benchmark-results/results/*.json
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: true
# Falha o CI se houver regressão de mais de 10%
alert-threshold: '110%'
comment-on-alert: true
fail-on-alert: true
Exportando resultados para análise
// Configuração com múltiplos exporters para análise posterior
var config = DefaultConfig.Instance
.AddExporter(RPlotExporter.Default) // gráficos R para publicação
.AddExporter(CsvExporter.Default) // dados brutos para análise
.AddExporter(HtmlExporter.Default) // relatório HTML navegável
.AddDiagnoser(MemoryDiagnoser.Default)
.AddDiagnoser(ThreadingDiagnoser.Default); // contention em código paralelo
BenchmarkRunner.Run<MeusBenchmarks>(config);
// Execução via linha de comando com filtros:
// dotnet run -c Release -- --filter "*JsonParsing*"
// dotnet run -c Release -- --filter "*" --job short (execução rápida)
// dotnet run -c Release -- --list flat (lista todos os benchmarks)
BenchmarkDotNet é uma ferramenta de precisão — use quando tiver uma hipótese de melhoria específica para validar ou quando precisar de evidência para uma decisão de arquitetura. Não é necessário benchmarkar todo o código; foque nos hot paths identificados via profiler (dotnet-trace, Visual Studio Profiler, JetBrains dotTrace) primeiro.
Otimização de performance sem medição é adivinhação. Se você precisa de uma análise de performance do seu sistema .NET com identificação de hot paths e plano de otimização, a Neryx pode ajudar.