.NET Performance BenchmarkDotNet Otimização Memória Boas Práticas

BenchmarkDotNet: como medir performance no .NET com precisão científica

Guia completo do BenchmarkDotNet para .NET: configuração de benchmarks, MemoryDiagnoser para alocações, comparação de implementações.

N
Neryx Digital Architects
23 de setembro de 2025
13 min de leitura
230 profissionais leram
Categoria: .NET Público: Times de engenharia e produto Etapa: Aprendizado

"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.

Quer transformar esse aprendizado em plano de ação?

Se o tema deste artigo se parece com o momento do seu time, podemos ajudar a decidir o próximo passo com clareza.

Falar com um especialista

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.