Olha esse código. Ele mede duas formas de somar os números de um array e imprime quem ganhou:
| |
Rode isso três vezes. Dependendo da execução, o LINQ ganha. Na próxima, o for ganha. Na terceira, empatam.
Qual dos dois é mais rápido, então?
A resposta honesta: você não sabe. E esse código nunca vai te dizer. Então a pergunta que fica é: como você tem tanta certeza de que os seus benchmarks estão certos? Neste artigo vou te mostrar por que medir performance em .NET é mais traiçoeiro do que parece. E qual é o jeito certo de fazer.
Por que medir é tão difícil?
Quando você roda um trecho de código em C#, várias coisas acontecem nos bastidores que distorcem qualquer medição ingênua. Os quatro principais vilões:
O JIT compila na primeira execução
C# não roda direto na CPU. Seu código é compilado para IL (linguagem intermediária), e só quando um método é chamado pela primeira vez o JIT (Just-In-Time compiler) o traduz para código de máquina. Ou seja: a primeira execução paga o preço da compilação.
Veja na prática:
| |
Na minha máquina:
| |
Mesmo código, quase 6x de diferença. Se o seu benchmark mede a primeira execução, ele está medindo o JIT, não o seu algoritmo.
O Garbage Collector aparece sem avisar
Se uma coleta de lixo acontecer no meio da sua medição, ela adiciona uma pausa que não tem nada a ver com o código que você está testando. Pior: o GC é disparado pelo estado geral da memória do processo, inclusive pelo lixo que outro trecho do seu programa gerou antes. Duas execuções idênticas podem ter resultados completamente diferentes só por causa disso.
Cache da CPU e estado da máquina
A primeira vez que você percorre um array, ele vem da RAM. Na segunda, boa parte já está no cache L1/L2 da CPU, que é ordens de magnitude mais rápido. Some a isso o Windows decidindo indexar arquivos, o Chrome com 40 abas abertas, o antivírus escaneando algo… uma medição única é praticamente um número aleatório.
O compilador apaga código “inútil”
Esse é o mais sorrateiro. O JIT é agressivo em otimização, e se ele percebe que o resultado de um cálculo nunca é usado, ele pode simplesmente eliminar o cálculo inteiro (dead code elimination):
| |
Você acha que mediu um milhão de raízes quadradas. Na prática, pode ter medido nada.
O jeito certo: BenchmarkDotNet
A boa notícia: você não precisa resolver nenhum desses problemas na mão. O BenchmarkDotNet é a biblioteca padrão da comunidade .NET para isso: é a mesma que o time do .NET usa para medir o próprio runtime. Ela faz warm-up (aquece o JIT), roda centenas de iterações, isola o processo, trata estatisticamente os resultados e ainda impede o dead code elimination (desde que você retorne o resultado do método).
Setup
Crie um projeto console e instale o pacote:
| |
O primeiro benchmark
Vamos medir um clássico: concatenar strings com += versus StringBuilder. Todo mundo “sabe” que o StringBuilder é mais rápido. Mas quanto? E a partir de que tamanho isso importa? Benchmark serve exatamente para trocar achismo por número.
| |
Três detalhes importantes nesse código:
[MemoryDiagnoser]faz a biblioteca medir também as alocações de memória. Sempre use. Você vai ver por quê já já.[Params(100, 1000)]roda cada benchmark com os dois valores de N, mostrando como cada abordagem escala.- Os métodos retornam o resultado. Isso impede o JIT de apagar o código que estamos medindo.
Rode em modo Release (isso é obrigatório: em Debug o JIT não otimiza e os números não valem nada):
| |
Os resultados
Depois de um ou dois minutos rodando, o BenchmarkDotNet imprime primeiro o ambiente da medição:
| |
…e a tabela com os resultados:
| Method | N | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|---|
| WithPlus | 100 | 823.8 ns | 14.39 ns | 12.75 ns | 1.00 | 1.5030 | - | 12576 B | 1.00 |
| WithStringBuilder | 100 | 111.9 ns | 1.49 ns | 1.32 ns | 0.14 | 0.0918 | 0.0001 | 768 B | 0.06 |
| WithPlus | 1000 | 39,210.8 ns | 188.15 ns | 166.79 ns | 1.00 | 122.6196 | 0.6104 | 1025976 B | 1.00 |
| WithStringBuilder | 1000 | 741.5 ns | 7.04 ns | 5.88 ns | 0.02 | 0.5465 | 0.0048 | 4576 B | 0.004 |
(Os números exatos variam por máquina e versão do .NET. Rode na sua e compare. Estes são de um Apple M4 Pro, .NET 10.)

Lendo a tabela como gente grande
- Mean é a média do tempo por operação. É o número principal.
- Error e StdDev indicam a confiabilidade da medição. Se o StdDev for grande em relação ao Mean, os resultados oscilaram muito. Desconfie (feche o Chrome e rode de novo).
- Ratio compara com o baseline.
WithStringBuildercom N=1000 mostra 0.02: ele leva 2% do tempo do+=, ou seja, é ~53x mais rápido. - Allocated é onde mora o insight escondido. Com N=1000, o
+=alocou ~1 MB (1.025.976 bytes) para construir uma string de mil caracteres. Por quê? Strings em .NET são imutáveis: cada+=cria uma string nova e copia todo o conteúdo anterior. São mil strings intermediárias viradas lixo, que o GC vai ter que coletar depois, cobrando esse preço em outro lugar do seu programa.
Repare no padrão de escala: multiplicamos N por 10, e o tempo do += multiplicou por ~48, enquanto o do StringBuilder multiplicou por ~6,6. Isso é a diferença entre um algoritmo quadrático e um linear aparecendo nos dados reais, assunto que vamos formalizar no próximo artigo da série.
E o Stopwatch, joga fora?
Não. O Stopwatch continua sendo a ferramenta certa quando:
- A operação demora segundos ou mais (aí o ruído de microssegundos do JIT e do cache vira irrelevante);
- Você está medindo I/O: chamada de API, query no banco, leitura de arquivo;
- Você só quer uma noção grosseira durante o desenvolvimento.
A regra de bolso: microssegundos ou nanossegundos → BenchmarkDotNet; segundos → Stopwatch está ótimo. O erro é usar Stopwatch para comparar duas implementações que rodam em microssegundos. Nessa escala, ele mede mais ruído do que código.
Recapitulando
- Medição ingênua com Stopwatch em código rápido mede JIT, GC e cache, não o seu algoritmo.
- BenchmarkDotNet resolve isso com warm-up, estatística e isolamento; use
[MemoryDiagnoser]sempre e rode em Release. - Tempo não é a única métrica: alocação de memória hoje é pausa de GC amanhã.
No próximo artigo da série, vamos usar essa ferramenta para tirar o Big O do quadro branco: busca linear vs busca binária, com números reais mostrando exatamente onde O(n) perde de O(log n). E onde, surpreendentemente, ele ganha.
Até lá, fica a provocação: aquele benchmark que você usou para decidir uma arquitetura semana passada: será que ele estava certo? Rode os deste artigo na sua máquina e compare. Medir é um hábito: quanto antes começar, melhor.
