DEV Community

Cover image for Performance no .NET com IA: medir, otimizar e provar com BenchmarkDotNet
Henrique Mauri
Henrique Mauri

Posted on

Performance no .NET com IA: medir, otimizar e provar com BenchmarkDotNet

A IA virou uma ótima companheira para revisar código. Peça para ela "melhorar a performance" de um método e, em segundos, você recebe uma lista de sugestões escritas com total confiança: use Span<T>, troque LINQ por for, adicione stackalloc, paralelize com Parallel.ForEach.

O problema é que confiança não é medição. Boa parte dessas sugestões não muda nada, algumas pioram e outras mudam o comportamento do sistema sem ninguém perceber.

Neste artigo, vou mostrar o método que uso para otimizar código .NET com ajuda da IA: medir antes, otimizar uma mudança por vez e provar depois. Tudo com um exemplo real, que saiu de 1,5 segundo para 12 milissegundos e de 33 MB para 0,23 MB de alocação.

O cenário

Um relatório de vendas comum: recebemos linhas de pedidos no formato PEDIDO;CLIENTE;SKU;QUANTIDADE;PRECO, somamos o total por cliente, ignoramos clientes bloqueados e retornamos os 10 maiores.

Esta é a versão original. Ela funciona, é legível e passaria tranquilamente em muitos code reviews:

public static List<ItemRelatorio> Gerar(string[] linhas, List<string> clientesBloqueados)
{
    var pedidos = new List<Pedido>();

    foreach (var linha in linhas)
    {
        var partes = linha.Split(';');

        pedidos.Add(new Pedido(
            Numero: partes[0],
            Cliente: partes[1],
            Sku: partes[2],
            Quantidade: int.Parse(partes[3], CultureInfo.InvariantCulture),
            PrecoUnitario: decimal.Parse(partes[4], CultureInfo.InvariantCulture)));
    }

    var resultado = new List<ItemRelatorio>();

    foreach (var cliente in pedidos.Select(p => p.Cliente).Distinct())
    {
        if (clientesBloqueados.Contains(cliente))
            continue;

        var total = pedidos
            .Where(p => p.Cliente == cliente)
            .Sum(p => p.Quantidade * p.PrecoUnitario);

        resultado.Add(new ItemRelatorio(cliente, total));
    }

    return resultado
        .OrderByDescending(r => r.Total)
        .ThenBy(r => r.Cliente, StringComparer.Ordinal)
        .Take(10)
        .ToList();
}
Enter fullscreen mode Exit fullscreen mode

Com 100 mil linhas e 1.000 clientes, esse método leva mais de 1,5 segundo. Se você já sabe onde está o problema, ótimo. Mas o ponto aqui é o método, não o palpite.

Passo 1: medir antes de tocar no código

Antes de pedir qualquer coisa para a IA, crie um benchmark com o BenchmarkDotNet. Ele cuida de aquecimento, múltiplas execuções e estatística, coisas que um Stopwatch solto não resolve:

[MemoryDiagnoser]
public class RelatorioBenchmark
{
    private string[] _linhas = [];
    private List<string> _bloqueadosLista = [];
    private HashSet<string> _bloqueadosSet = [];

    [Params(10_000, 100_000)]
    public int Linhas { get; set; }

    [GlobalSetup]
    public void Setup()
    {
        _linhas = GeradorDeDados.GerarLinhas(Linhas, clientes: 1_000, seed: 42);
        _bloqueadosLista = GeradorDeDados.GerarBloqueados(50);
        _bloqueadosSet = new HashSet<string>(_bloqueadosLista, StringComparer.Ordinal);
    }

    [Benchmark(Baseline = true)]
    public List<ItemRelatorio> Original() => RelatorioOriginal.Gerar(_linhas, _bloqueadosLista);

    [Benchmark]
    public List<ItemRelatorio> Otimizado() => RelatorioOtimizado.Gerar(_linhas, _bloqueadosSet);
}
Enter fullscreen mode Exit fullscreen mode

Dois detalhes importantes: o [MemoryDiagnoser] mostra quanto cada execução aloca de memória (em APIs com muita carga, alocação vira pressão no Garbage Collector), e o benchmark só vale em Release.

Passo 2: dar contexto para a IA, não só o código

A diferença entre uma sugestão genérica e uma análise útil está no contexto. No repositório, deixo um arquivo CLAUDE.md com as regras de performance do projeto:

## Regras para otimizações de performance
- Nunca otimize sem medir antes. Todo gargalo precisa de um benchmark no BenchmarkDotNet.
- Proponha uma mudança por vez e explique a complexidade (Big O) antes e depois.
- O comportamento não pode mudar: a validação em Program.cs precisa continuar passando.
- Considere alocações de memória, não só tempo ([MemoryDiagnoser]).
- Não use unsafe, stackalloc ou paralelismo sem um benchmark que justifique.
Enter fullscreen mode Exit fullscreen mode

E o pedido para a IA vai junto com o resultado do benchmark:

Analise o método RelatorioOriginal.Gerar. Segue o resultado do BenchmarkDotNet.
Liste as hipóteses de gargalo ordenadas por impacto estimado, com a complexidade
de cada trecho. Proponha UMA mudança por vez. O resultado do relatório precisa
continuar idêntico.
Enter fullscreen mode Exit fullscreen mode

Repare que não pedi "deixe mais rápido". Pedi hipóteses ordenadas, complexidade e uma mudança por vez. A IA responde muito melhor quando o pedido tem critério.

O que a análise encontrou

Três problemas, em ordem de impacto:

  1. Complexidade O(clientes × pedidos). Para cada cliente distinto, o Where percorre todos os pedidos de novo. Com 1.000 clientes e 100 mil pedidos, são cerca de 100 milhões de comparações para algo que pode ser feito em uma única passada.
  2. List.Contains para os bloqueados. Cada consulta é uma busca linear. Com um HashSet, a busca passa a ser O(1).
  3. Split em todas as linhas. Cada Split cria um array e uma string por campo, inclusive para campos que nem usamos, como número do pedido e SKU.

Passo 3: uma mudança por vez, medindo cada uma

Aqui está a parte que mais gente pula. Aplicar tudo de uma vez até funciona, mas você não sabe qual mudança trouxe o ganho, e fica impossível justificar a complexidade extra de cada uma.

Etapa 1: uma única passada com Dictionary. Em vez de agrupar depois, acumulamos o total por cliente enquanto lemos as linhas. O algoritmo cai de O(clientes × pedidos) para O(pedidos).

Etapa 2: HashSet para os bloqueados. Uma linha de mudança, ganho garantido.

Etapa 3: parsing com ReadOnlySpan<char> e busca sem alocar strings. Em vez de Split, percorremos a linha como um span. E com o alternate lookup, disponível a partir do .NET 9, conseguimos consultar o HashSet e o Dictionary usando o próprio span, sem criar uma string para cada linha:

public static List<ItemRelatorio> Gerar(string[] linhas, HashSet<string> clientesBloqueados)
{
    if (!clientesBloqueados.TryGetAlternateLookup<ReadOnlySpan<char>>(out var bloqueados))
        throw new ArgumentException("Crie o HashSet com StringComparer.Ordinal.", nameof(clientesBloqueados));

    var totais = new Dictionary<string, decimal>(StringComparer.Ordinal);
    var totaisPorCliente = totais.GetAlternateLookup<ReadOnlySpan<char>>();

    foreach (var linha in linhas)
    {
        // Formato: PEDIDO;CLIENTE;SKU;QUANTIDADE;PRECO
        ReadOnlySpan<char> resto = linha;

        ProximoCampo(ref resto); // número do pedido (não usado no relatório)
        var cliente = ProximoCampo(ref resto);
        ProximoCampo(ref resto); // SKU (não usado no relatório)
        var quantidade = int.Parse(ProximoCampo(ref resto), NumberStyles.Integer, CultureInfo.InvariantCulture);
        var preco = decimal.Parse(resto, NumberStyles.Number, CultureInfo.InvariantCulture);

        if (bloqueados.Contains(cliente))
            continue;

        // Busca pelo span: a string do cliente só é criada na primeira vez que ele aparece
        ref var total = ref CollectionsMarshal.GetValueRefOrAddDefault(totaisPorCliente, cliente, out _);
        total += quantidade * preco;
    }

    return totais
        .Select(t => new ItemRelatorio(t.Key, t.Value))
        .OrderByDescending(r => r.Total)
        .ThenBy(r => r.Cliente, StringComparer.Ordinal)
        .Take(10)
        .ToList();
}

private static ReadOnlySpan<char> ProximoCampo(ref ReadOnlySpan<char> resto)
{
    var fim = resto.IndexOf(';');
    var campo = resto[..fim];
    resto = resto[(fim + 1)..];
    return campo;
}
Enter fullscreen mode Exit fullscreen mode

O CollectionsMarshal.GetValueRefOrAddDefault devolve uma referência direta para o valor dentro do dicionário. Assim, fazemos a busca e a atualização em uma única operação, em vez de buscar, somar e gravar de novo.

Repare também no que não mudou: o LINQ no final continua lá. Ele roda sobre 1.000 itens, não sobre 100 mil. LINQ não é o vilão. Algoritmo ruim é.

Passo 4: provar

Primeiro, garantir que nada quebrou. Antes de rodar o benchmark, o Program.cs compara o relatório de todas as versões com o original e interrompe a execução se houver qualquer diferença:

var esperado = RelatorioOriginal.Gerar(linhas, bloqueados);
var bloqueadosSet = new HashSet<string>(bloqueados, StringComparer.Ordinal);

var versoes = new[]
{
    RelatorioEtapas.Etapa1UmaPassada(linhas, bloqueados),
    RelatorioEtapas.Etapa2HashSet(linhas, bloqueadosSet),
    RelatorioOtimizado.Gerar(linhas, bloqueadosSet)
};

if (versoes.Any(v => !esperado.SequenceEqual(v)))
    throw new InvalidOperationException("As versões produziram relatórios diferentes. A otimização mudou o comportamento.");
Enter fullscreen mode Exit fullscreen mode

Depois, os números. Medições com 100 mil linhas e 1.000 clientes, no .NET 10:

Versão Tempo médio Memória alocada
Original 1.553 ms 33,1 MB
Etapa 1: uma passada com Dictionary 40 ms 24,9 MB
Etapa 2: HashSet para bloqueados 26 ms 24,9 MB
Etapa 3: Span + alternate lookup 11,9 ms 0,23 MB

Agora dá para enxergar o que cada mudança entregou:

  • A Etapa 1 foi responsável por quase todo o ganho de tempo (de 1,5 s para 40 ms). Uma mudança de algoritmo, sem nenhuma técnica avançada.
  • A Etapa 2 reduziu mais um terço do tempo com uma única linha alterada.
  • A Etapa 3 praticamente zerou as alocações: de 25 MB para 0,23 MB. Em um método chamado milhares de vezes por minuto, isso significa muito menos trabalho para o Garbage Collector.

Os valores absolutos vão mudar na sua máquina. As proporções, não. O repositório tem o BenchmarkDotNet configurado para você reproduzir.

Onde a IA mais erra em performance

Usando IA todos os dias, esses são os erros que mais vejo nas sugestões:

  • Otimizar o que não é gargalo. Trocar o LINQ final por um for não muda nada quando ele roda sobre 1.000 itens.
  • Micro-otimização antes do algoritmo. Span e stackalloc não salvam um código O(n²). Primeiro o algoritmo, depois as alocações.
  • Mudar comportamento sem perceber. Remover o CultureInfo.InvariantCulture "para simplificar" quebra o parsing de 19.90 em um servidor configurado em português.
  • Paralelizar por reflexo. Parallel.ForEach em um laço que já leva 12 ms costuma piorar o resultado e ainda introduz problemas de concorrência.

Conclusão

A IA acelera muito o trabalho de performance: encontra hipóteses em segundos, explica complexidade e escreve a primeira versão do código otimizado. Mas ela não substitui o método.

Meça antes, peça com critério, mude uma coisa por vez e prove com números. É assim que a IA deixa de ser um gerador de palpites e vira uma ferramenta de engenharia.

O código completo, com o benchmark, a validação e o CLAUDE.md, está no meu GitHub: github.com/hgmauri/sample-performance-ia

E você, já pegou a IA sugerindo uma "otimização" que piorou o código? Conta nos comentários.

Top comments (0)