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.

Workload único gera quatro publishes medidos por harness, BenchmarkDotNet e Crank antes da decisã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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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:

1
2
3
4
5
6
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.

Onde as conclusões não se aplicam diretamente

Hard real-time exige previsibilidade de ponta a ponta. Ausência de JIT não controla pausas de GC, escalonamento do sistema operacional, interrupções, I/O, locks ou page faults. Soft real-time, por outro lado, pode funcionar com um processo JIT aquecido e medido. Este laboratório não mede deadlines nem usa um sistema operacional de tempo real.

Blazor WebAssembly AOT é outro pipeline. Ele compila código .NET para WebAssembly executado pelo navegador e normalmente troca um download maior por melhor desempenho em trechos intensivos de CPU. Não é o mesmo modelo de executável Native AOT para Linux, Windows ou macOS. Blazor Server se aproxima mais da decisão de um backend ASP.NET Core, mas recursos e dependências precisam ser avaliados separadamente.

Também não há resultados de preview do .NET 11, bibliotecas Native AOT exportadas, provedores serverless ou aplicações com banco de dados. Incluir esses cenários sem medi-los aumentaria o alcance aparente e reduziria a utilidade da conclusão.

Matriz de decisão orientada ao objetivo

Objetivo ou restriçãoPergunta de mediçãoOpções que merecem baseline
Primeira respostaQuanto leva processo novo até responder?Native AOT, R2R e JIT
Throughput aquecidoQual perfil sustenta RPS com p95/p99 aceitáveis?JIT com tiering/PGO e Native AOT
Memória e densidadeQual é o RSS sob carga por réplica?Native AOT, JIT FDD e JIT SCD
DistribuiçãoQuantos bytes são transferidos e atualizados?FDD se runtime existe; AOT/SCD se não existe
Runtime compartilhadoVárias aplicações usam o mesmo host administrado?JIT FDD
Plugins e código dinâmicoO grafo de código é conhecido no build?JIT; validar trimming antes de AOT
Tempo de buildA frequência de entrega tolera publish AOT?JIT/R2R como baseline
OperaçãoFerramentas de diagnóstico cobrem o modo escolhido?Validar por ambiente e incidente esperado

Uma CLI distribuída para máquinas sem .NET pode valorizar Native AOT por startup e empacotamento. Muitas aplicações em um host administrado podem aproveitar FDD. Um backend duradouro e variável pode se beneficiar da adaptação do JIT. Edge e IoT podem priorizar bytes, memória e restrições de execução. Esses exemplos orientam quais testes executar; nenhum substitui a medição.

Limitações do experimento

O hardware é um notebook com Docker Desktop 4.81.0, e aplicação e gerador de carga disputam a mesma CPU. O RID é apenas linux-x64, o sistema é Ubuntu 24.04 e o plano de energia Equilibrado foi observado durante a campanha. Isso não controla frequência, temperatura nem tarefas de fundo do host, que podem aumentar a dispersão. A campanha usa cache de filesystem aquecido e não reinicia o sistema operacional entre amostras.

O workload é sintético, determinístico e deliberadamente AOT-friendly. Ele não representa acesso a banco, rede externa, plugins, descoberta aberta de tipos nem uma base de código grande. A memória reportada pelo Crank é working set máximo, enquanto o BenchmarkDotNet registra alocação gerenciada por operação; essas métricas respondem perguntas diferentes.

Três publishes e 30 startups permitem observar dispersão operacional, mas não transformam o laboratório em estudo universal. As cinco rodadas HTTP ajudam a detectar instabilidade, e o limiar de 3% evita anunciar diferenças pequenas como vencedores. Ainda assim, o limiar não é um teste formal de significância.

Dicas e Boas Práticas

  • Comece com um baseline simples. Compare primeiro a publicação padrão que sua equipe já opera. Native AOT e ReadyToRun precisam melhorar uma métrica relevante o suficiente para pagar o custo de compatibilidade, build e manutenção.

  • Execute performance em Release e Production. Debug e ambiente Development adicionam trabalho e configuração que distorcem resultados. Mantenha logging, GC, payload e funcionalidade iguais entre os perfis sempre que o modo de publicação permitir.

  • Meça distribuições, não uma única execução. Use processos novos para startup, warm-up explícito para steady state e ordem randomizada. Registre mediana e dispersão para não confundir ruído térmico, cache ou tarefa de fundo com efeito do compilador.

  • Separe tamanho de aplicação e tamanho de distribuição. FDD app-only pressupõe runtime instalado; SCD e Native AOT incluem runtime. Declare a unidade comparada e, quando transferência importar, meça também o artefato compactado.

  • Preserve dados brutos e ambiente. CSV, JSON, manifesto, commit e fingerprint dos fontes tornam cada tabela auditável. Copiar números manualmente para Markdown sem rastreabilidade facilita erros de unidade e arredondamento.

  • Trate respostas inválidas como falha do teste. RPS alto com HTTP 400, 500 ou timeout não é desempenho da aplicação. Automatize um gate que exija zero respostas ruins antes de consolidar a rodada.

  • Acompanhe regressões no CI com tolerância ao ruído. Um benchmark isolado não impede que dependências ou mudanças de workload alterem o resultado. Use uma máquina estável e só alerte quando a diferença superar a variabilidade histórica do cenário.

Resumo Objetivo

  • Decisão entre JIT, ReadyToRun e Native AOT — não existe vencedor universal; primeira resposta, throughput, latência de cauda, CPU, memória, distribuição e compatibilidade respondem perguntas diferentes.
  • Startup Native AOT neste ambiente — a mediana foi 63,794 ms em 30 processos, contra 587,644 ms no JIT FDD; a medição usa cache de filesystem aquecido e não representa cold start de provedor.
  • Publish Native AOT neste ambiente — o publish limpo mediano levou 98,145 s, enquanto o incremental levou 13,398 s; o custo mais alto concentrou-se na compilação limpa.
  • Tamanho JIT FDD — os 165.687 bytes medidos cobrem somente a aplicação e pressupõem um runtime compatível instalado, portanto não são diretamente comparáveis às distribuições autocontidas completas.
  • Tamanho Native AOT — a distribuição completa ficou em 28.306.209 bytes descompactados e 10.865.327 bytes em ZIP, incluindo as partes necessárias do runtime sem JIT.
  • ReadyToRun — mantém IL, runtime, JIT e tiered compilation; neste startup, R2R SCD e JIT FDD ficaram dentro do limiar operacional de 3%.
  • Carga HTTP neste ambiente — JIT FDD teve mediana de 932 RPS, Native AOT 810, JIT SCD 803 e R2R SCD 734; Native AOT registrou 41 MB de working set máximo mediano contra 103 MB no JIT FDD, mas a alta dispersão impede generalizar a capacidade.
  • Código quente neste workload — a mediana foi 1,034 ms no JIT e 1,638 ms no Native AOT; o relatório marcou a distribuição AOT como bimodal, portanto a conclusão permanece restrita ao ensaio.

Leia Também

Referências