.NET Native AOT: Parte 2 — JIT, R2R e AOT em Benchmarks
Introdução#
Não existe um vencedor universal na comparação entre .NET Native AOT, JIT e ReadyToRun. Cada modelo de publicação otimiza uma parte diferente do sistema: Native AOT elimina o JIT em execução e reduz muito o tempo até a primeira resposta; ReadyToRun antecipa parte da compilação sem abandonar o runtime dinâmico; uma aplicação JIT framework-dependent pode compartilhar o runtime instalado com outros processos; e uma publicação JIT self-contained prioriza isolamento operacional. Escolher apenas pelo rótulo “CLI”, “servidor” ou “serverless” ignora as restrições que realmente controlam o resultado.
A decisão precisa começar pelo objetivo mensurável da aplicação. Tempo de startup importa para processos efêmeros, mas throughput aquecido, p95 e p99, CPU, memória residente, tamanho de distribuição, frequência de atualização, compatibilidade com código dinâmico e tempo de publicação também podem decidir o projeto. Neste artigo, eu comparo os quatro modelos com o mesmo código em .NET 10 e linux-x64, preservando os resultados brutos. A conclusão não é uma regra pronta: é uma matriz que relaciona métricas e restrições ao contexto de operação.
Este texto complementa o guia .NET Native AOT: Parte 1 — Ecossistema de Compilação. No guia conceitual, eu expliquei Roslyn, IL, ILC, trimming e compatibilidade. Aqui, o foco é corrigir simplificações comuns com um experimento reproduzível: separar aplicação framework-dependent de distribuição autocontida, separar startup de throughput e não confundir alocação gerenciada com RSS.
ℹ️ Informação: todos os números deste artigo vieram dos CSVs e JSONs do laboratório. Valores absolutos descrevem este workload, este hardware e este ambiente; as relações entre perfis são hipóteses para você testar, não garantias para outra aplicação.
Pré-requisitos#
- .NET SDK 10.0.400 ou uma imagem Docker equivalente.
- Docker Desktop com containers Linux para reproduzir o ambiente Ubuntu 24.04 e o RID
linux-x64. - Conhecimento básico de publicação framework-dependent, self-contained, ReadyToRun e Native AOT.
uvpara executar o consolidador determinístico dos CSVs e JSONs.- Microsoft Crank e Bombardier para repetir a carga HTTP.
O que os comentários revelaram sobre a decisão AOT#
Uma discussão técnica sobre o artigo conceitual expôs a principal falha das recomendações baseadas apenas no tipo da aplicação. Dizer que Native AOT serve para CLI e serverless, enquanto JIT serve para servidores longos, parece útil porque cabe em uma frase. O problema é que essa frase omite o objetivo. Uma CLI usada uma vez por mês pode não justificar o esforço de compatibilidade; um backend replicado centenas de vezes pode valorizar densidade e startup; um worker duradouro pode depender de plugins; um serviço estável de CPU pode ter um resultado diferente de um serviço com distribuição de tráfego variável.
Startup também não é sinônimo de cold start de provedor. O laboratório mede o intervalo entre iniciar um processo e receber uma resposta válida de /ready, com cache do sistema de arquivos aquecido. Um cold start de Azure Functions, AWS Lambda ou outra plataforma inclui decisões do provedor, criação de sandbox, pull de imagem, rede, configuração e inicialização da aplicação. Eu não medi esse caminho e, portanto, não atribuo ao Native AOT um ganho universal em serverless gerenciado.
Outro ponto importante é a previsibilidade. Remover o JIT evita compilação de métodos durante a execução, mas não remove pausas de GC, escalonamento do sistema operacional, contenção, I/O ou page faults. Da mesma forma, source generators reduzem descoberta dinâmica e deslocam erros para o build, mas não tornam automaticamente qualquer arquitetura mais rápida. São propriedades relevantes para a decisão, não selos de superioridade.
Minha regra revisada é mais exigente: defina uma meta, escolha as métricas capazes de refutá-la e compare distribuições equivalentes. Se o objetivo é reduzir primeira resposta, meça processos novos. Se é aumentar densidade, meça RSS sob a carga representativa. Se é sustentar throughput, aqueça a aplicação e registre latência de cauda e CPU junto com requisições por segundo.
Metodologia: como tornar a comparação justa#
O experimento usa Ubuntu 24.04 em Docker Desktop, processador Intel Core i5-1245U x64, aproximadamente 8 GB de memória disponíveis, SDK 10.0.400 e runtime 10.0.11. Todos os perfis executam em Release, ambiente Production, sem providers de logging e com o mesmo payload. O código não acessa banco, disco ou serviço externo durante a medição.
O workload recebe Seed=42, ItemCount=2048 e Iterations=8. Cada execução aluga um array com ArrayPool<int>, gera valores determinísticos, ordena os dados, calcula média e percentil 95 e produz um SHA-256. A Minimal API serializa a resposta com System.Text.Json source generation. Essa combinação exerce CPU, alocação, genéricos e serialização sem deixar I/O externo dominar.
Os nomes combinam o modo de compilação com o modelo de distribuição:
- JIT (Just-In-Time) compila IL em código nativo durante a execução.
- FDD (Framework-Dependent Deployment) publica somente a aplicação e exige um runtime .NET compatível instalado no host.
- SCD (Self-Contained Deployment) inclui o runtime .NET no diretório publicado.
- R2R (ReadyToRun) antecipa a compilação de assemblies elegíveis, mas preserva runtime e JIT.
- Native AOT (Native Ahead-Of-Time) gera antes da execução um binário nativo autocontido, sem JIT em produção.
Os quatro perfis são:
| Perfil | Runtime no diretório | Código antecipado | JIT em execução |
|---|---|---|---|
| JIT FDD (dependente do framework) | Não; usa instalação compatível | Bibliotecas do runtime podem conter R2R | Sim |
| JIT SCD (autocontido) | Sim | Bibliotecas do runtime podem conter R2R | Sim |
| R2R SCD (ReadyToRun autocontido) | Sim | Aplicação e dependências elegíveis | Sim |
| Native AOT (nativo autocontido) | Runtime reduzido incluído | Aplicação fechada em código nativo | Não |
O harness restaura dependências antes do cronômetro, executa três pares de publish limpo e incremental e inicia cada perfil 30 vezes em ordem randomizada. Antes de medir startup, ele copia os publishes para o filesystem Linux local do container; executar binários grandes diretamente no bind mount do Windows adicionou quase dez segundos artificiais em testes preliminares.
Para estado aquecido, o BenchmarkDotNet usa jobs out-of-process reais para .NET 10 JIT e Native AOT. A carga HTTP usa Microsoft Crank com Bombardier, 64 conexões, 15 segundos de warm-up e 30 segundos de medição. Aplicação e gerador ficam em containers separados, mas na mesma máquina física. Isso contamina a capacidade absoluta, por isso eu uso os resultados apenas como comparação relativa deste ambiente.
Exemplo Prático: laboratório reproduzível em .NET 10#
A API foi construída com o host reduzido do ASP.NET Core. O marcador escrito após Start() permite ao Crank sincronizar a carga sem habilitar logs diferentes entre perfis:
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args);
builder.Logging.ClearProviders();
builder.Services.Configure<JsonOptions>(options =>
options.SerializerOptions.TypeInfoResolverChain.Insert(
0,
DecisionLabJsonContext.Default));
WebApplication app = builder.Build();
app.MapGet("/ready", () => TypedResults.Ok(new ReadyResponse("ready")));
app.MapPost("/work", (WorkloadRequest request) =>
TypedResults.Ok(WorkloadProcessor.Process(request)));
app.Start();
Console.WriteLine("Application started.");
app.WaitForShutdown();
📂 Código Fonte: O exemplo completo está disponível no repositório de exemplos do blog:
BlogSamples/NativeAot/DecisionLab/
O comando principal publica, mede tamanho e executa os 120 startups dentro da imagem fixada:
docker run --rm \
--volume "$PWD:/workspace" \
--volume decision-lab-nuget:/root/.nuget/packages \
decision-lab:10.0.400 \
run --project src/BlogSamples/NativeAot/DecisionLab/DecisionLab.Harness \
--configuration Release -- --repository-root /workspace
O manifesto registra commit, estado do worktree, fingerprint dos fontes e ambiente. O fingerprint é necessário porque um hash de HEAD não identifica alterações ainda não commitadas. O consolidador calcula mediana, mínimo, máximo, desvio absoluto mediano (MAD) e razão contra JIT FDD; diferenças de até 3% são marcadas como dentro do limiar operacional de ruído, sem fingir significância estatística.
Publish, tamanho e startup de processo#
Native AOT deslocou custo para o build. O publish limpo mediano levou 98,145 segundos, contra 11,589 segundos do JIT FDD, 28,450 segundos do JIT SCD e 25,812 segundos do R2R SCD. No publish incremental, a distância diminuiu: Native AOT ficou em 13,398 segundos; JIT FDD, 11,679; JIT SCD, 14,552; e R2R SCD, 13,787.
| Perfil | Publish limpo mediano | Publish incremental mediano |
|---|---|---|
| JIT FDD | 11,589 s | 11,679 s |
| JIT SCD | 28,450 s | 14,552 s |
| R2R SCD | 25,812 s | 13,787 s |
| Native AOT | 98,145 s | 13,398 s |
O tamanho exige uma comparação cuidadosa. Os 165.687 bytes do JIT FDD representam somente os arquivos da aplicação; a instalação compartilhada do .NET fica fora do diretório. JIT SCD e R2R SCD incluem runtime e bibliotecas e ficaram perto de 109,7 MB. Native AOT incluiu o runtime reduzido necessário e chegou a 28,3 MB. Compactados, os diretórios ocuparam 81.471 bytes, 48,2 MB, 48,2 MB e 10,9 MB, respectivamente.
| Perfil | Unidade comparada | Descompactado | ZIP |
|---|---|---|---|
| JIT FDD | Aplicação, sem runtime instalado | 165.687 B | 81.471 B |
| JIT SCD | Distribuição completa | 109.703.386 B | 48.156.909 B |
| R2R SCD | Distribuição completa | 109.745.882 B | 48.177.185 B |
| Native AOT | Distribuição completa | 28.306.209 B | 10.865.327 B |
No startup de processo, Native AOT apresentou mediana de 63,794 ms e MAD de 8,948 ms. JIT FDD registrou 587,644 ms; JIT SCD, 633,357 ms; e R2R SCD, 585,283 ms. R2R e JIT FDD ficaram dentro do limiar operacional de 3%, portanto este experimento não sustenta declarar R2R vencedor no startup.
| Perfil | Mediana de startup | MAD | Amostras |
|---|---|---|---|
| JIT FDD | 587,644 ms | 100,856 ms | 30 |
| JIT SCD | 633,357 ms | 93,885 ms | 30 |
| R2R SCD | 585,283 ms | 79,038 ms | 30 |
| Native AOT | 63,794 ms | 8,948 ms | 30 |
⚠️ Atenção: esses números medem processo novo com cache de filesystem aquecido. Eles não medem boot da máquina, pull de container nem cold start de um provedor serverless.
Runtime compartilhado e unidades autocontidas#
Framework-dependent é uma escolha eficiente quando várias aplicações usam uma instalação compatível do .NET no mesmo host. O custo do runtime não desaparece; ele é instalado, atualizado e potencialmente compartilhado fora do artefato da aplicação. Em containers isolados ou hosts sem runtime, essa premissa muda.
Cada publicação Native AOT é uma unidade autocontida e leva as partes do runtime necessárias. Não existe, no modelo de publicação documentado, um runtime Native AOT comum consumido por vários executáveis gerenciados independentes. O sistema operacional pode compartilhar páginas somente leitura entre processos do mesmo binário, mas isso é um comportamento de memória virtual e não transforma executáveis diferentes em clientes de um runtime comum.
ℹ️ Informação:
UnmanagedCallersOnlypode exportar entry points C de um módulo Native AOT para interoperabilidade nativa. Isso não cria uma DLL gerenciada AOT compartilhada entre aplicações .NET.
Throughput, latência e memória sob carga#
A campanha HTTP usa o mesmo endpoint e payload nos quatro perfis. Eu registro mediana e dispersão de requisições por segundo, p50, p95, p99, CPU máxima do processo e working set máximo em cinco rodadas randomizadas por perfil. Como aplicação e Bombardier compartilham a mesma máquina física, os números não representam a capacidade máxima de produção.
As 20 rodadas terminaram com zero respostas inválidas. JIT FDD apresentou a maior mediana, 932 RPS, seguido por Native AOT com 810 RPS, JIT SCD com 803 RPS e R2R SCD com 734 RPS. A dispersão foi alta: o MAD variou de 82 RPS no JIT SCD a 240 RPS no R2R SCD, e os intervalos observados se sobrepõem. Neste host compartilhado, a ordem das medianas não sustenta uma regra geral de throughput.
| Perfil | RPS mediana | MAD RPS | p50 | p95 | p99 |
|---|---|---|---|---|---|
| JIT FDD | 932 | 130 | 63,936 ms | 136,854 ms | 194,880 ms |
| JIT SCD | 803 | 82 | 78,570 ms | 142,288 ms | 210,696 ms |
| R2R SCD | 734 | 240 | 83,483 ms | 191,594 ms | 312,714 ms |
| Native AOT | 810 | 186 | 79,518 ms | 145,527 ms | 210,021 ms |
O sinal mais nítido apareceu em recursos. Native AOT registrou mediana de 41 MB para o working set máximo, contra 103 MB no JIT FDD, 101 MB no JIT SCD e 96 MB no R2R SCD. A CPU máxima mediana foi 62% no Native AOT, 83% no JIT FDD, 79% no JIT SCD e 86% no R2R SCD. São máximos amostrados pelo Crank, não consumo médio nem memória privada.
| Perfil | CPU máxima mediana | Working set máximo mediano | MAD do working set |
|---|---|---|---|
| JIT FDD | 83% | 103 MB | 2 MB |
| JIT SCD | 79% | 101 MB | 2 MB |
| R2R SCD | 86% | 96 MB | 2 MB |
| Native AOT | 62% | 41 MB | 1 MB |
ℹ️ Informação: nesta campanha, Native AOT usou cerca de 40% do working set máximo mediano do JIT FDD. O resultado é específico deste workload AOT-friendly e não prevê o RSS de outra aplicação.
JIT, Dynamic PGO e Native AOT no código quente#
ReadyToRun não elimina o JIT. Com tiered compilation habilitada, métodos usados com frequência podem ter o código R2R substituído por código produzido pelo optimizing JIT. Dynamic PGO trabalha junto com esse mecanismo: instrumenta a execução, observa tipos e caminhos quentes e usa esses dados para recompilar métodos relevantes.
Native AOT não dispõe de JIT em produção, então não pode adaptar o código durante a execução da mesma forma. Isso é uma diferença de capacidade, não uma prova de que JIT sempre vence. Código AOT evita warm-up de compilação e pode empatar ou superar o JIT em workloads específicos; o JIT pode se beneficiar quando o processo dura o suficiente e a distribuição observada orienta otimizações úteis.
No SDK 10.0.400 e nas fontes públicas consultadas, eu não confirmei um fluxo estável e documentado de PGO de build para Native AOT que pudesse ser recomendado como procedimento de produção neste laboratório. Por isso, a comparação usa os defaults suportados e não inclui opções experimentais. Mesmo quando um perfil offline estiver disponível, ele precisa representar produção: mudanças de tipos, rotas ou distribuição de tráfego podem envelhecer a evidência coletada.
Os dois jobs concluídos pelo mesmo BenchmarkDotNet produziram uma mediana de 1,034 ms para JIT e 1,638 ms para Native AOT. Neste workload aquecido, Native AOT levou 1,585 vez o tempo do baseline JIT. As alocações gerenciadas ficaram próximas: 8.984 bytes por operação no JIT e 9.370 bytes no Native AOT, diferença de 4,3%.
| Runtime | Mediana | MAD | Amostras | Alocação por operação |
|---|---|---|---|---|
| .NET 10 JIT | 1,034 ms | 0,133 ms | 92 | 8.984 B |
| Native AOT 10 | 1,638 ms | 0,161 ms | 99 | 9.370 B |
O BenchmarkDotNet marcou a distribuição Native AOT como bimodal (mValue = 3,25) e removeu um outlier; no JIT, removeu oito outliers. Portanto, o resultado refuta a hipótese de empate neste ensaio, mas não demonstra que JIT sempre vence código quente. Ele mostra apenas que, para esta carga, neste ambiente e com os defaults suportados, a adaptação disponível no runtime JIT coincidiu com menor duração mediana.
Source generators e reflection mudam o custo#
O workload usa System.Text.Json source generation. O contexto JSON registra os contratos usados pela API, permitindo que o compilador veja quais metadados precisam ser preservados. Isso reduz descoberta baseada em reflection e torna o cenário deliberadamente compatível com Native AOT.
Reflection não é um bloco único. Acesso analisável a metadados ou membros preservados pode funcionar com anotações e testes. Os problemas mais fortes aparecem quando o conjunto de código só é conhecido em execução: Assembly.LoadFile, Reflection.Emit, plugins abertos e serializers que descobrem tipos arbitrários. O trimmer precisa enxergar o grafo no build; caso contrário, ele não consegue provar o que pode remover.
Evitar código dinâmico pode ser uma decisão arquitetural saudável, principalmente quando source generators e contratos explícitos antecipam erros. Também pode ser inviável para um produto cujo valor depende de extensibilidade aberta. Compatibilidade deve ocupar uma linha própria na matriz de decisão; não deve ser tratada como detalhe resolvido por um atributo genérico que preserva tudo.
📖 Artigo completo com exemplos de código: .NET Native AOT: Parte 2 — JIT, R2R e AOT em Benchmarks