Melhorias do JIT (Just-In-Time Compiler) no .NET 10: Um Guia Didático Completo
Como o JIT do .NET 10 deixa seu código mais rápido sem mudar uma linha: alocação na stack, devirtualização, inlining e eliminação de bounds checks.

Neste artigo · 10 seções
O compilador Just-In-Time (JIT) é uma das peças mais fundamentais da plataforma .NET. Ele atua como o tradutor invisível que transforma o código intermediário (IL) que você escreveu em C# em código de máquina nativo que seu processador pode executar diretamente. A cada nova versão do .NET, a equipe de desenvolvimento trabalha arduamente para tornar esse compilador mais inteligente, mais eficiente e mais capaz de extrair o máximo desempenho do seu código sem que você precise mudar uma única linha.
O .NET 10 traz uma coleção impressionante de melhorias no JIT que coletivamente resultam em ganhos significativos de performance. Este artigo explora essas melhorias de forma didática, com exemplos práticos e explicações detalhadas de como cada otimização funciona.
O Que é o Compilador JIT?
Antes de mergulharmos nas melhorias, é importante entender o papel fundamental do JIT no ecossistema .NET. Quando você compila seu código C#, o compilador não gera diretamente código de máquina específico para x64, ARM64 ou qualquer outra arquitetura. Em vez disso, ele produz Intermediate Language (IL), um formato intermediário independente de plataforma.
O JIT entra em ação no momento da execução. Quando seu programa precisa executar um método pela primeira vez, o JIT compila aquele método IL específico em código de máquina nativo otimizado para o processador no qual está sendo executado. Esse código nativo é então armazenado em cache e reutilizado sempre que o método for chamado novamente.
A beleza do JIT é que qualquer melhoria feita nele beneficia todo o ecossistema .NET sem que você precise recompilar seu código ou fazer qualquer alteração. É uma melhoria que "vem de graça" para todos os desenvolvedores.
1. Deabstração: Eliminando a Penalidade da Abstração
Uma das filosofias centrais do C# moderno é permitir que desenvolvedores escrevam código limpo, expressivo e de alto nível usando abstrações como interfaces, delegates, iteradores e LINQ. Historicamente, essas abstrações vinham com um custo de performance conhecido como "penalidade de abstração" - alocações extras, indireções e chamadas virtuais que tornavam o código abstrato mais lento que código manual otimizado.
O .NET 10 dá passos gigantescos para eliminar essa penalidade através de técnicas avançadas de deabstração.
1.1 Alocação de Objetos na Stack (Stack Allocation)
Tradicionalmente, quando você cria um objeto em C#, ele é alocado no heap - uma região de memória gerenciada pelo Garbage Collector (GC). O GC precisa rastrear esses objetos, determinar quando não são mais necessários e eventualmente liberá-los. Esse processo tem um custo.
O .NET 10 introduz análise de escape aprimorada que permite ao JIT determinar se um objeto "escapa" do método onde foi criado. Se o JIT pode provar que um objeto não escapa (não é armazenado em um campo, não é retornado ao chamador, não é passado para uma função que o JIT não consegue analisar), então esse objeto pode ser alocado na stack em vez do heap.
A alocação na stack é dramaticamente mais rápida (apenas um incremento de ponteiro) e o objeto é automaticamente liberado quando o método retorna, sem envolver o GC.
Exemplo 1: Alocação de Arrays Pequenos na Stack
Considere este código simples:
static void Sum()
{
int[] numbers = {1, 2, 3};
int sum = 0;
for (int i = 0; i < numbers.Length; i++)
{
sum += numbers[i];
}
Console.WriteLine(sum);
}
No .NET 9: O array numbers seria alocado no heap, custando 32 bytes de alocação rastreada pelo GC.
No .NET 10: O JIT reconhece que numbers nunca sai do método Sum. Seu tamanho é conhecido em tempo de compilação (3 elementos), e sua vida útil termina quando Sum retorna. Portanto, o array é alocado diretamente na stack, resultando em zero alocações no heap e desempenho 60% mais rápido.
Essa otimização se estende tanto para arrays de tipos de valor quanto para arrays de tipos de referência.
Exemplo 2: Arrays de Strings
static void Print()
{
string[] words = {"Hello", "World!"};
foreach (var str in words)
{
Console.WriteLine(str);
}
}
Surpreendentemente, mesmo sendo string um tipo de referência, o .NET 10 consegue alocar o array words na stack, pois ele não escapa do método.
1.2 Eliminação de Alocação de Delegates
Delegates (como Func<> e Action<>) são fundamentais para programação funcional em C#, mas historicamente vinham com custo de alocação. O .NET 10 agora pode eliminar completamente a alocação de delegates quando eles não escapam.
Exemplo Detalhado
public int Sum(int y)
{
Func<int, int> addY = x => x + y;
return DoubleResult(addY, y);
}
private int DoubleResult(Func<int, int> func, int arg)
{
int result = func(arg);
return result + result;
}
Quando o compilador C# processa esse código, ele cria uma classe de closure para capturar a variável y:
// Aproximadamente o que o compilador C# gera
public int Sum(int y)
{
<>c__DisplayClass0_0 closure = new();
closure.y = y;
Func<int, int> func = new(closure.<Sum>b__0);
return DoubleResult(func, closure.y);
}
private sealed class <>c__DisplayClass0_0
{
public int y;
internal int <Sum>b__0(int x) => x + y;
}
Isso resulta em duas alocações: uma para a closure (24 bytes) e uma para o delegate (64 bytes), totalizando 88 bytes.
No .NET 10, o JIT consegue:
- Fazer inlining do método
DoubleResult - Detectar que o delegate não escapa
- Eliminar completamente a alocação do delegate
Resultado: apenas 24 bytes de alocação (só a closure), com tempo de execução 66% mais rápido.
1.3 Alocação de Arrays Usados com Spans
O .NET 10 estende a análise de escape para entender os campos de structs, incluindo Span<T> e ReadOnlySpan<T>.
private static void Copy3Bytes(int value, Span<byte> dest)
{
BitConverter.GetBytes(value).AsSpan(0, 3).CopyTo(dest);
}
Análise do código:
BitConverter.GetBytes(value)cria um array de 4 bytes.AsSpan(0, 3)cria um span sobre os primeiros 3 bytes.CopyTo(dest)copia para o destino
No .NET 9: O array de 4 bytes é alocado no heap (32 bytes totais incluindo overhead).
No .NET 10: Com GetBytes e AsSpan sendo inlined, o JIT detecta que o array nunca escapa e o aloca na stack, resultando em zero alocações no heap e 91% mais rápido.
2. Devirtualização: Removendo Chamadas Virtuais
Interfaces e métodos virtuais são essenciais para abstrações em .NET, mas chamadas virtuais têm custo adicional comparado a chamadas diretas. Devirtualização é o processo pelo qual o JIT transforma uma chamada virtual em uma chamada direta quando pode determinar em tempo de compilação qual implementação será chamada.
2.1 Devirtualização de Interfaces de Arrays
Um dos avanços mais significativos do .NET 10 é a devirtualização de métodos de interface implementados por arrays.
Arrays em .NET implementam várias interfaces como IEnumerable<T>, IList<T>, ICollection<T>, etc. Porém, essas implementações são manufaturadas pelo runtime de forma diferente de interfaces normais, o que historicamente impediu o JIT de devirtualizá-las.
O Problema Histórico
Considere este código aparentemente simples:
private ReadOnlyCollection<int> _list = new(Enumerable.Range(1, 1000).ToArray());
public int SumEnumerable()
{
int sum = 0;
foreach (var item in _list)
{
sum += item;
}
return sum;
}
public int SumForLoop()
{
int sum = 0;
int count = _list.Count;
for (int i = 0; i < count; i++)
{
sum += _list[i];
}
return sum;
}
Pergunta: Qual método você esperaria ser mais rápido?
A resposta "óbvia" seria SumForLoop, pois ele evita alocar um enumerador. Porém, no .NET 9, acontecia o seguinte:
| Método | Tempo (.NET 9) |
|---|---|
SumEnumerable | 949.5 ns |
SumForLoop | 1,932.7 ns |
O loop for era 2x mais lento! Por quê?
Explicação: ReadOnlyCollection<T> apenas envolve um IList<T> subjacente. Quando esse IList<T> é na verdade um array:
- O enumerador retornado (
List<T>.Enumerator) é uma struct normal que o JIT consegue devirtualizar facilmente - Mas as chamadas de interface no array (
_list.GetEnumerator()e_list[i]) não eram devirtualizadas no .NET 9 - Resultado:
Nchamadas virtuais no loopforvs. apenas 1 chamada virtual + chamadas devirtualizadas noforeach
No .NET 10, o JIT devirtualiza as interfaces de array, e os resultados se invertem:
| Método | .NET 9 | .NET 10 | Melhoria |
|---|---|---|---|
SumEnumerable | 968.5 ns | 775.5 ns | 20% mais rápido |
SumForLoop | 1,960.5 ns | 624.6 ns | 68% mais rápido |
Agora SumForLoop é o mais rápido, como esperado!
2.2 Impacto no LINQ
Essa melhoria tem efeitos cascata. Muitas operações LINQ têm caminhos especializados para IList<T> exatamente porque historicamente era mais rápido usar o indexador do que o enumerador.
private ReadOnlyCollection<int> _list = new(Enumerable.Range(1, 1000).ToArray());
public int SkipTakeSum() => _list.Skip(100).Take(800).Sum();
| Runtime | Tempo | Melhoria |
|---|---|---|
| .NET 9 | 3.525 μs | - |
| .NET 10 | 1.773 μs | 50% mais rápido |
A redução no custo de usar IEnumerable para iterar arrays caiu de 83% de overhead no .NET 9 para apenas 10% no .NET 10.
2.3 Guarded Devirtualization (GDV) Melhorada
Com Dynamic PGO (Profile-Guided Optimization), o JIT pode instrumentar um método durante sua primeira compilação para coletar dados sobre quais tipos são usados em dispatches virtuais. Se um tipo domina, o JIT gera código especializado para aquele tipo, "guardado" por uma verificação de tipo.
private static bool GenericEquals<T>(T a, T b)
=> EqualityComparer<T>.Default.Equals(a, b);
public bool Test() => GenericEquals("abc", "abc");
O .NET 10 expande GDV para funcionar em contextos genéricos compartilhados onde anteriormente não funcionava:
| Runtime | Tempo | Melhoria |
|---|---|---|
| .NET 9 | 2.816 ns | - |
| .NET 10 | 1.511 ns | 46% mais rápido |
3. Inlining Aprimorado
Inlining é a técnica onde o JIT substitui uma chamada de método pelo corpo real desse método, eliminando o overhead da chamada.
3.1 Inlining de Métodos com try-finally
O .NET 10 agora pode fazer inline de métodos que contêm blocos try-finally, o que era impossível em versões anteriores. Isso é particularmente importante porque muitos métodos usam using statements, que são compilados como try-finally.
public void ProcessFile(string path)
{
using (var stream = File.OpenRead(path))
{
// Processar arquivo
}
}
Agora esse método pode ser inlined, permitindo otimizações adicionais no código chamador.
3.2 Inlining Pós-Devirtualização
O JIT agora faz uma segunda passagem de inlining após a devirtualização. Como a devirtualização pode transformar chamadas virtuais em chamadas diretas, isso expõe novas oportunidades de inlining que não eram visíveis antes.
3.3 Inlining Sensível a Profile
Com dados de perfil (PGO), o JIT agora aumenta seu orçamento de inlining para métodos executados frequentemente. Se um método é "quente" (executado muitas vezes), o JIT está disposto a fazer inline de métodos maiores do que normalmente faria.
4. Eliminação de Bounds Checks
C# é uma linguagem memory-safe, o que significa que você não pode acessar memória fora dos limites de um array, string ou span. O runtime garante isso através de bounds checks - verificações inseridas antes de cada acesso para garantir que o índice está dentro dos limites.
private int[] _array = new int[3];
public int Read() => _array[2];
O assembly gerado no .NET 10 é:
; Tests.Read()
push rax
mov rax,[rdi+8] ; Carrega endereço do array
cmp dword ptr [rax+8],2 ; Compara comprimento com índice 2
jbe short M00_L00 ; Se índice >= comprimento, vai para erro
mov eax,[rax+18] ; Acessa elemento
add rsp,8
ret
M00_L00:
call CORINFO_HELP_RNGCHKFAIL ; Lança IndexOutOfRangeException
int 3
O cmp/jbe é o bounds check. Embora essencial para segurança, tem custo de performance.
4.1 Quando Bounds Checks Podem Ser Eliminados
O JIT trabalha constantemente para eliminar bounds checks redundantes quando pode provar por construção que um acesso é seguro.
Exemplo: Array static readonly
private static readonly int[] s_array = new int[3];
public int Read() => s_array[2];
Agora o assembly é simplesmente:
; Tests.Read()
mov rax,705D5419FA20 ; Endereço do array (fixo)
mov eax,[rax+18] ; Acessa elemento diretamente
ret
Sem bounds check! O JIT sabe que:
- O campo
static readonlyé imutável - Arrays não podem ser redimensionados
- O campo é garantido estar inicializado
- O array tem comprimento 3, índice 2 está sempre dentro dos limites
4.2 Novas Eliminações no .NET 10
Shifts que Garantem Range
public int Log2SoftwareFallback(uint value)
{
ReadOnlySpan<byte> Log2DeBruijn = [
00, 09, 01, 10, 13, 21, 02, 29,
11, 14, 16, 18, 22, 25, 03, 30,
08, 12, 20, 28, 15, 17, 24, 07,
19, 27, 23, 06, 26, 05, 04, 31
];
value |= value >> 01;
value |= value >> 02;
value |= value >> 04;
value |= value >> 08;
value |= value >> 16;
return Log2DeBruijn[(int)((value * 0x07C4ACDDu) >> 27)];
}
A expressão (value * 0x07C4ACDDu) >> 27 sempre produz um valor entre 0 e 31. O .NET 10 reconhece isso e elimina o bounds check, tornando o código mais rápido.
Bounds Checks Dependentes
public bool StartAndEndAreSame(int[] ids) => ids[0] == ids[^1];
Se ids[0] for acessado com sucesso, isso prova que o array tem pelo menos 1 elemento. Portanto, ids[^1] (que é ids[ids.Length - 1]) está garantido estar dentro dos limites. O .NET 10 elimina o segundo bounds check.
.NET 9: 2 bounds checks
.NET 10: 1 bounds check (50% menos overhead)
5. Geração de Código para Structs
O .NET 10 melhora drasticamente a geração de código ao passar structs como argumentos.
Physical Promotion
O JIT pode realizar uma otimização chamada physical promotion, onde os membros de uma struct são colocados diretamente em registradores em vez de na stack, eliminando acessos à memória.
struct Point
{
public int X;
public int Y;
public Point(int x, int y)
{
X = x;
Y = y;
}
}
private static void Main()
{
Point p = new Point(10, 20);
Consume(p);
}
Problema Histórico: Em x64, a convenção de chamada requer que os membros de Point sejam passados em um único registrador (já que cada int tem 4 bytes e cabem juntos em um registrador de 8 bytes). Antes do .NET 10, o JIT primeiro armazenava os valores na memória e depois os carregava no registrador.
Solução .NET 10: O JIT agora coloca os membros promovidos diretamente no registrador compartilhado, eliminando operações de memória desnecessárias:
; .NET 10
Program:Main() (FullOpts):
mov rdi, 0x140000000A ; Ambos valores empacotados em um registrador
tail.jmp [Program:Consume(Program+Point)]
6. Inversão de Loops
Inversão de loop é uma transformação onde o JIT converte um loop while em um loop do-while precedido por uma condição:
// Código original
while (condition)
{
// corpo do loop
}
// Após inversão
if (condition)
{
do
{
// corpo do loop
} while (condition);
}
Benefícios:
- Melhor layout de código
- Menos branches para o topo do loop
- Habilita outras otimizações (clonagem de loop, desenrolamento, otimizações de variáveis de indução)
O .NET 10 melhora a inversão de loops mudando de análise lexical para reconhecimento de loops baseado em grafos, que é mais preciso e considera todos os loops naturais (com ponto de entrada único).
7. Layout de Código Melhorado
O JIT agora modela o problema de reordenação de blocos como uma redução do Travelling Salesman Problem Assimétrico e implementa a heurística 3-opt para encontrar um percurso quase ótimo.
Isso resulta em:
- Maior densidade de código quente (hot code próximo na memória)
- Distâncias de branch reduzidas (menos penalidade de predição de branch)
- Melhor uso da cache de instruções
8. Suporte AVX10.2
O .NET 10 introduz suporte para AVX10.2 (Advanced Vector Extensions 10.2) para processadores x64. Essas são novas instruções SIMD (Single Instruction, Multiple Data) que permitem processar múltiplos dados em paralelo com uma única instrução.
As intrínsecos estão disponíveis na classe System.Runtime.Intrinsics.X86.Avx10v2, embora o suporte esteja desabilitado por padrão até que hardware compatível esteja disponível.
Conclusão
As melhorias do JIT no .NET 10 representam anos de trabalho meticuloso para tornar o código C# idiomático tão rápido quanto código manualmente otimizado. Através de técnicas avançadas como análise de escape, devirtualização agressiva, inlining inteligente e eliminação de verificações redundantes, o .NET 10 oferece ganhos de performance substanciais "de graça" - sem que você precise modificar seu código.
Resumo dos Ganhos:
- Alocação de stack: Redução de 60-90% no tempo de execução para cenários específicos
- Eliminação de delegates: Redução de 66% no overhead
- Devirtualização de arrays: Redução de 50-83% no overhead de iteração
- Bounds checks: Eliminação de verificações redundantes em dezenas de cenários
O futuro é promissor, com a equipe do .NET planejando expandir ainda mais essas otimizações em versões futuras, incluindo alocação de stack para closures e análise de escape ainda mais sofisticada.


