4

.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.
  • uv para 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:
PerfilRuntime no diretórioCódigo antecipadoJIT em execução
JIT FDD (dependente do framework)Não; usa instalação compatívelBibliotecas do runtime podem conter R2RSim
JIT SCD (autocontido)SimBibliotecas do runtime podem conter R2RSim
R2R SCD (ReadyToRun autocontido)SimAplicação e dependências elegíveisSim
Native AOT (nativo autocontido)Runtime reduzido incluídoAplicação fechada em código nativoNã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.

PerfilPublish limpo medianoPublish incremental mediano
JIT FDD11,589 s11,679 s
JIT SCD28,450 s14,552 s
R2R SCD25,812 s13,787 s
Native AOT98,145 s13,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.

PerfilUnidade comparadaDescompactadoZIP
JIT FDDAplicação, sem runtime instalado165.687 B81.471 B
JIT SCDDistribuição completa109.703.386 B48.156.909 B
R2R SCDDistribuição completa109.745.882 B48.177.185 B
Native AOTDistribuição completa28.306.209 B10.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.

PerfilMediana de startupMADAmostras
JIT FDD587,644 ms100,856 ms30
JIT SCD633,357 ms93,885 ms30
R2R SCD585,283 ms79,038 ms30
Native AOT63,794 ms8,948 ms30

⚠️ 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: UnmanagedCallersOnly pode 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.

PerfilRPS medianaMAD RPSp50p95p99
JIT FDD93213063,936 ms136,854 ms194,880 ms
JIT SCD8038278,570 ms142,288 ms210,696 ms
R2R SCD73424083,483 ms191,594 ms312,714 ms
Native AOT81018679,518 ms145,527 ms210,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.

PerfilCPU máxima medianaWorking set máximo medianoMAD do working set
JIT FDD83%103 MB2 MB
JIT SCD79%101 MB2 MB
R2R SCD86%96 MB2 MB
Native AOT62%41 MB1 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%.

RuntimeMedianaMADAmostrasAlocação por operação
.NET 10 JIT1,034 ms0,133 ms928.984 B
Native AOT 101,638 ms0,161 ms999.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

Carregando publicação patrocinada...
1

Li o artigo completo, excelente.

Já que está tão empenhado nisso, minhas sugestões seriam atualizar quando o .NET mudar algo relevante que entregue resulyados diferentes e quem sabe ver se é viável tentar comparar cenários comparando desempenho e eficiência entre workloads usando JIT e AOT.

S2


Farei algo que muitos pedem para aprender a programar corretamente, gratuitamente (não vendo nada, é retribuição na minha aposentadoria) (links aqui).