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(); } 

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); } 

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. 

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. 

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; } 

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

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.