9

.NET Native AOT: Ecossistema de Compilação em C#

Introdução#

Quando penso em binários nativos com .NET, muitos devs imaginam imediatamente executáveis compactos, ultra-rápidos no startup, prontos para serverless. A realidade é mais nuançada. Native AOT (Ahead-Of-Time compilation) é poderoso — mas nem sempre é a resposta. Você pode compilar C# para nativo, sim. Mas qual é o preço real? Como funciona internamente? E em quais cenários faz realmente sentido investir tempo de compilação, limitações de reflection e binários maiores?

Eu criei este artigo porque vi muitos devs tentando usar Native AOT sem entender o tradeoff: ganhar 200–300ms de startup time pode custar +30MB de binário e remover reflexão do seu código. Neste guia, vou levar você através da stack de compilação completa — desde C# interpretado por Roslyn até o native code saindo do ILCompiler — e mostrar exatamente quando (e quando não) usar Native AOT em produção.

Pré-requisitos#

  • Conhecimento de C# e .NET: experiência com .NET 8+ e familiaridade com conceitos como assemblies, IL (Intermediate Language) e garbage collection.
  • .NET SDK 9.0+: o artigo foca em .NET 9 e .NET 10 (LTS).
  • Noções de compilação: entender diferenças entre JIT (Just-In-Time) e compilação antecipada é útil, mas vou explicar os conceitos conforme necessário.
  • Familiaridade com performance: interpretação de métricas básicas de startup, memory footprint e throughput.

O que é Native AOT#

Native AOT significa compilar seu código C# em binário nativo antes de executar, não durante a execução. Contraste com o modelo tradicional:

JIT (Just-In-Time):

  • Você escreve C#, compila para IL (Intermediate Language)

  • No startup do app, o JIT toma a IL

  • JIT transforma IL em machine code dinamicamente conforme o código roda

  • O .NET runtime (incluindo JIT, GC, todas as bibliotecas) rodam na memória
    Native AOT:

  • Você escreve C#, compila para IL

  • Na máquina de build, o NativeAOT toolchain pega a IL, faz análise estática, remove código desnecessário (trimming)

  • Transforma tudo em machine code nativo (x86-64, ARM, etc.) antes de você executar

  • Resultado é um executável standalone — sem JIT, sem interpreter, sem .NET runtime embutido

💡 Dica: Quando digo “sem .NET runtime”, não é 100% preciso. Você ainda tem partes da biblioteca padrão linkadas (GC, allocator, estruturas de dados). Mas é dramaticamente reduzido comparado ao JIT.

Variações de Compilação#

Existe um espectro entre JIT puro e AOT puro:

  • JIT Padrão (Tier 0): Compila tudo no startup; depois otimiza com Tier 1 conforme uso
  • ReadyToRun (R2R): Compilação antecipada parcial; ainda precisa de runtime e JIT está disponível para código não pré-compilado
  • Native AOT Completo: Tudo pré-compilado; sem JIT (restrito por trimming e análise estática)
    Native AOT é o mais agressivo em tradeoffs: ganhos extremos em startup, mas restrições rígidas em reflection.

Stack de Compilação: Roslyn até Binário Nativo#

Aqui está o coração técnico. Vou descrever exatamente o que acontece quando você roda dotnet publish -c Release --self-contained -r win-x64 /p:PublishAot=true.

1. Análise Estática com Roslyn#

Roslyn é o compilador C# da Microsoft. Ele:

  • .cs files
  • Monta uma árvore sintática (AST)
  • Resolve tipos, namespaces, símbolos
  • Gera IL (Intermediate Language) — um pseudocódigo agnóstico de plataforma
    Para Native AOT, Roslyn não faz nada especial nesta etapa — a emissão de IL é idêntica. A “mágica” acontece depois.

2. IL (Intermediate Language)#

IL é como “bytecode universal” do .NET. Um exemplo simples:

public int Add(int a, int b) => a + b;

Vira IL assim:

.method public int32 Add(int32 a, int32 b) cil managed {
  ldarg.0
  ldarg.1
  add
  ret
}

Isso é agnóstico de plataforma. Qualquer processador pode executar essas instruções (com uma VM ou compilador).

3. ILCompiler & Trimming (Análise de Reachability)#

ILCompiler é o núcleo da magia Native AOT. Ele:

  • Lê a IL gerada por Roslyn
  • Analisa reachability: Começa do entry point (Main), segue cada chamada, cada campo acessado. “Se Main chamar Foo(), e Foo() chamar Bar(), então Bar() é alcançável.”
  • Remove código desnecessário — tree-shaking. Se MetodoX nunca é chamado, é deletado do executável final.
  • Detecta reflection problemática: Se você escreve Type.GetType("MinhaClasse"), o compilador não consegue saber estaticamente qual tipo você quer. Isso é um problema.
  • Emite código nativo via crossgen2 ou diretamente

4. crossgen2 & RyuJIT#

crossgen2 é o componente que pega IL otimizada e transforma em native code (x86-64, ARM64, etc.). Usa parte do RyuJIT (o otimizador do JIT do .NET).

Não é o JIT tradicional (que roda em tempo de execução). É um compilador offline, com mais tempo para otimização.

Output: .obj files (objetos nativos), depois linkados em executável final.

5. Linkedição & Executável Final#

Os .obj files são linkados com:

  • Runtime libraries (.NET GC, allocators, etc.)
  • Stdlib linkada (Collections, I/O, etc.)
  • Seu código compilado
    Resultado: app.exe (ou .so no Linux). Standalone. Nenhuma dependência externa do .NET runtime.

Fluxo Visual Completo#

C# Code
  ↓
[Roslyn Compiler]
  ↓ (emite)
IL (Intermediate Language)
  ↓
[ILCompiler - Análise de Reachability & Trimming]
  ↓ (remove dead code)
Otimized IL
  ↓
[crossgen2 / RyuJIT]
  ↓ (transforma em)
Native Code (.obj files)
  ↓
[Linker]
  ↓ (linkado com runtime)
Standalone Executable (app.exe / app.so)

📝 Exemplo: Um app “Hello World” simples em JIT ocupa ~100MB (runtime + libs). Em AOT, o executável sozinho é ~5MB.

Trimming e Análise Estática: O Grande Obstáculo#

Trimming é onde Native AOT brilha — e onde quebra.

O Problema da Reflection#

Reflection permite código descobrir tipos, campos, métodos em tempo de execução:

// Reflection clássica
Type tipo = Type.GetType("MyApp.MyClass");
MethodInfo method = tipo.GetMethod("MyMethod");
object result = method.Invoke(instance, params);

Por que isso quebra AOT? O compilador estático não consegue saber em tempo de build qual tipo você vai pedir em Type.GetType(). Então, por segurança:

  • Ou compila todos os tipos (aumenta o binário)
  • Ou falha em runtime (crash)

Solução: TrimmerRootAssembly e Atributos#

A Microsoft oferece atributos para “avisar” o compilador:

// Diga ao trimmer: não remova tipos deste assembly
[assembly: TrimmerRootAssembly]

// Ou, mais fino: diga que este método acessa esses tipos
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)]
public void ProcessTypes(Type tipo) { ... }

Isso torna your código “AOT-compliant” mas exige esforço e disciplina.

Análise Conservative vs Aggressive#

  • Conservative: Trimmer presume que qualquer Type.GetType() pode ser usado; mantém tudo (maior binário, mas seguro)
  • Aggressive: Trimmer remove tudo que não é provado alcançável (menor binário, mas pode quebrar em runtime)
    Artigos sobre Native AOT sempre recomendam “teste bastante” — e com razão.

Trade-offs de Performance#

Native AOT não é uma bala de prata. Aqui estão os reais tradeoffs:

1. Startup Time (Vencedor: AOT)#

CenárioJITAOT
Console App simples150–500ms20–100ms
Web API mínima300–800ms50–200ms
Função serverless (cold start)1–3s100–500ms

AOT é claramente vencedor. Em serverless, onde você paga por latência do cold start, é transformador.

⚠️ Atenção: AOT coloca todo o tempo de compilação na máquina de build, não no cliente. Se você demora 15 minutos compilando sua app em AOT, bem… existem tradeoffs.

2. Memory Footprint#

MétricaJITAOT
Runtime + Libs~60MB~2–5MB
App Binário~1MB (IL)5–50MB (native)
Total~61MB~7–55MB

Aqui parece que JIT ganha. Mas há nuances:

  • O runtime JIT cresce com uso (warm-up, optimizations)
  • AOT é fixo — tudo linkado estaticamente
  • Em containers, AOT binário é melhor (smaller image, faster pull/start)

3. Throughput e Otimização#

AspectoJITAOT
TieringSim (Tier 0 → 1 otimização)Não (fixo)
ProfilingJit ajusta conforme usoEstático (sem profiling)
HotpathMelhora com tempoFixo (sem warm-up)

JIT vence em throughput puro, especialmente em long-running apps (servers, workers). JIT tem mais tempo para otimizar hotpaths. AOT é “congelado” em tempo de build.

Resumo: Quando Usar?#

  • Startup-bound (AOT wins): Serverless, containers, CLI tools, batch jobs
  • Throughput-bound (JIT wins): Web servers, long-running workers, real-time systems

Exemplo Prático: CLI Tool em Native AOT#

Vou mostrar um exemplo real: um String Analyzer CLI que conta frequência de caracteres em arquivos.

A razão de escolher CLI: reflection mínima, I/O puro, ideal para AOT.

Estrutura do Projeto#

BlogSamples/
├── NativeAot/
│   ├── CliExample/
│   │   ├── StringAnalyzer.Console.csproj
│   │   ├── Program.cs
│   │   ├── AnalyzerService.cs
│   │   ├── README.md

Arquivo .csproj com AOT Enabled#

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <PublishAot>true</PublishAot>
    <TrimMode>link</TrimMode>
    <RuntimeIdentifiers>win-x64;linux-x64;osx-arm64</RuntimeIdentifiers>
    <SelfContained>true</SelfContained>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="System.CommandLine" Version="2.0.0-beta4.24416.1" />
  </ItemGroup>
</Project>

Destaques:

  • PublishAot=true ativa o compilador Native AOT
  • TrimMode=link ativa trimming agressivo
  • RuntimeIdentifiers lista plataformas alvo
  • System.CommandLine é AOT-friendly (sem reflection para CLI parsing)

Código Exemplo: Program.cs#

using System.CommandLine;
using StringAnalyzer.Console;

var fileArgument = new Argument<FileInfo>(
    name: "file",
    description: "Arquivo para análise");

var rootCommand = new RootCommand("Analisa frequência de caracteres");
rootCommand.AddArgument(fileArgument);

rootCommand.SetHandler((file) =>
{
    if (file == null || !file.Exists)
    {
        Console.WriteLine("Arquivo não encontrado.");
        return;
    }

    var analyzer = new AnalyzerService();
    var result = analyzer.Analyze(file.FullName);
    
    Console.WriteLine($"Arquivo: {file.Name}");
    Console.WriteLine($"Total de caracteres: {result.TotalChars}");
    Console.WriteLine("\nFrequência dos 10 mais comuns:");
    
    foreach (var (ch, count) in result.TopFrequent.Take(10))
    {
        Console.WriteLine($"  '{ch}': {count}");
    }
}, fileArgument);

return await rootCommand.InvokeAsync(args);

Por que isso é AOT-friendly:

  • System.CommandLine é otimizado para AOT
  • Zero reflection dinamicamente (argumentos são resolvidos em compile-time)
  • I/O simples, sem patterns avançados

Compilar e Executar#

# Restore
dotnet restore

# Publish para AOT (windows x64)
dotnet publish -c Release -r win-x64 /p:PublishAot=true

# Executável sai em: bin/Release/net10.0/win-x64/publish/StringAnalyzer.Console.exe
# Tamanho típico: 8–12 MB

# Teste
./StringAnalyzer.Console.exe myfile.txt

📂 Código Fonte: O exemplo completo está disponível no repositório de exemplos do blog:
BlogSamples/NativeAot/CliExample/

Dicas e Boas Práticas#

  • Use System.CommandLine para CLI parsing
    System.CommandLine é otimizado para AOT e não usa reflection. Evite bibliotecas mais antigas que dependem de reflection para atributos de argumentos.

  • Minimize reflection; use source generators quando possível
    Se você precisa de serialização, use System.Text.Json com source generators (JsonSourceGenerationContext). Evite Newtonsoft.Json ou reflection pura em AOT.

  • Teste AOT warnings durante desenvolvimento
    Compile com dotnet build e cheque warnings. Não deixe para descobrir problemas em produção. Use -Werror para converter warnings em erros durante CI.

  • Perfil o tamanho do binário
    Use dotnet publish --analyze-warnings ou dotnet size para entender onde o tamanho está indo. Às vezes, remover uma dependência economiza MB.

  • Considere ReadyToRun como alternativa
    Se você quer startup rápido mas não pode abrir mão de reflection, ReadyToRun é um meio-termo. Compile com PublishReadyToRun=true em vez de PublishAot=true.

  • Teste em ambientes similares ao produção
    AOT binário compilado em Windows pode não rodar em Linux (arquivos Object linkam diferente). Sempre compile/teste para o target runtime específico.

  • Prepare-se para binários maiores
    AOT geralmente resulta em binários 5–50× maiores que IL puro. Se seu deployment é bandwidth-constrained (edge devices, IoT), considere R2R ou JIT.

  • Documente dependências AOT-compatibility
    Se você usa bibliotecas de terceiros, confirme explicitamente que são AOT-compatible. Mantenha isso em seu README de deployment.

Resumo Objetivo#

  • Native AOT — compilação antecipada de C# em binário nativo (x86-64, ARM), eliminando JIT em runtime e criando executáveis standalone sem dependência de .NET runtime.

  • Stack de compilação — Roslyn (análise estática em C#) → IL (pseudocódigo universal) → ILCompiler (análise de reachability & trimming) → crossgen2 (transformação IL → nativo) → Linker (executável final).

  • Trimming — remoção automática de código não alcançável (tree-shaking); quebra com reflection dinâmica (Type.GetType()), requer TrimmerRootAssembly e DynamicallyAccessedMembers para avisar o compilador.

  • Startup time — ganho de 200–500ms em apps pequenas, 1–2s em serverless; vantagem crítica em cold starts e containers.

  • Throughput — JIT vence em long-running apps (warm-up, tiering); AOT é fixo e congelado no build.

  • Cenários ideais — serverless (AWS Lambda, Azure Functions), containers, CLI tools, batch jobs, IoT, edge computing.

  • Tradeoffs — startup 3–10× mais rápido, binário 5–50× maior, sem reflection, tempo de compilação substancial, análise estática rigorosa.

Leia Também#

  • Async/Await em .NET: Fundamentos de Programação Assíncrona
  • Performance em .NET: Profiling, Benchmarking e Otimização
  • Microservices em .NET: Padrões, Comunicação e Resiliência

Referências#

  • Microsoft Docs — Native AOT — documentação oficial, configuração e limitações
  • RyuJIT and Native Code Generation — source code do compilador JIT/AOT
  • Roslyn — C# Compiler — analysis e code generation
  • IL Compiler & Trimming — NativeAOT toolchain
  • System.CommandLine — AOT-Compatible CLI — library para argumentos sem reflection
  • Trimming .NET Applications — guia de trimming e troubleshooting
    📬

📖 Artigo completo com exemplos de código: .NET Native AOT: Ecossistema de Compilação em C#

Carregando publicação patrocinada...
3

Você posta bons artigos, tenho alguns no bookmark e você sabe usar o Tabnews. Obrigado.

De fato, AOT não é para qualquer cenário. Vou questionar alguns pontos, eu não tenho experiência prática, então você pode orientar melhor.

Usando o JIT a aplicação só tem 1MB se você já tiver o .NET instalado, se precisar dele junto, provavelmente passa de 50MB e não tem muito o que fazer. Certo?

Embora o AOT possa bater em 50MB, usado da maneira certa, isso provavelmente não chega nem perto. Certo?

Não existe uma forma de usar um mesmo core quando tem mais de uma aplicação AOT? Assim também poderia ter um binário menor, ainda que não igual ao JIT. Ou DLL não é possível com AOT? Tem no roadmap?

Não dá para usar PGO com AOT e obter, no extremo, até mais performance que o JIT?

O artigo dá a entender que o AOT tem uso bem limitado, basicamente calçado no startup time, oque não me parece que deveria ser a única métrica considerar.

Eu achei que ficou pouco explicativo a conclusão de quando usar cada um. Inclusive JIT para real time sem foi um enorme problema.

Grande consumo de memória pode prejudicar muito certos benchmarks.

AInda acho que depende muito do objetivo da aplicação, mais que o tipo dela. Tirando serverless, que eu já acho uma ideia ruim na maioria dos casos, os outros tipos não é tão preto no branco. Mas reafirmo que minha experiência não é prática, estou usando minha grande experiência geral.

Muitas linguagens são amplamente usadas com zero reflexão. E de fato acho a ideia ruim para sistemas robustos e performáticos. Não considero não poder usar reflexão um ponto negativo do AOT. Sempre considerei reflexão um mecanismo para linguagens dinâmicas, que tem seu valor, mas tem seus problemas.

Raramente o tamanho do deploy é um problema real.

Mais uma vez, cada caso é um caso, importante é saber disso tudo para tomar a melhor decisão, não tem fórmula mágica.

Já testou AOT com Blazor? Especialmente com previews do .NET 11? Não tenho acompanhado em que pé está.

S2


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

2

Oi, maniero! Obrigado pela leitura cuidadosa, pelos elogios e principalmente pelas perguntas. Você acertou no ponto principal: eu simplifiquei demais a conclusão. Native AOT não deve ser escolhido só pelo tempo de startup, nem o tipo da aplicação determina sozinho a escolha. O que manda é o objetivo mensurado: latência inicial e de cauda, throughput sustentado, memória residente, tamanho de distribuição, restrições de ambiente e compatibilidade do código.

Também quero corrigir duas simplificações minhas. Quando escrevi que o Native AOT roda “sem .NET runtime”, a formulação ficou imprecisa: o aplicativo leva uma versão reduzida das partes necessárias do CoreCLR e das bibliotecas, só não leva o JIT nem exige uma instalação prévia do runtime. E o caminho que descrevi com crossgen2 mistura conceitos: o Native AOT é conduzido pelo compilador ILC, que usa o backend do RyuJIT para gerar código nativo; crossgen2 é a ferramenta mais associada ao ReadyToRun.

Tamanho de deploy: framework-dependent, self-contained e AOT

Sim, para uma aplicação JIT publicada como framework-dependent, o artefato da aplicação pode ter algo perto de 1 MB ou poucos MB, desde que a máquina já tenha o runtime compatível instalado. É uma economia real quando há várias aplicações na mesma máquina usando a mesma instalação do .NET.

Se o runtime não estiver presente, a comparação correta não é “JIT de 1 MB versus AOT de X MB”, mas sim:

ModeloRuntime pré-instaladoArtefato da aplicaçãoObservação
JIT framework-dependentSimPequenoCompartilha o runtime instalado na máquina.
JIT self-containedNãoMaiorLeva runtime e bibliotecas junto com a aplicação.
ReadyToRunDepende da publicaçãoMaior que o JIT equivalenteMantém IL e código pré-compilado; o JIT continua disponível.
Native AOTNãoVaria por aplicaçãoLeva o runtime necessário e código nativo, sem JIT em execução.

Então, sim: um Native AOT bem enxuto normalmente não chega perto de 50 MB. Uma CLI pequena pode ficar em poucos megabytes. Mas não é garantia, porque genéricos com muitas instanciações, dependências, recursos, símbolos de depuração e código preservado para reflexão podem aumentar bastante o resultado. Do outro lado, um publish JIT self-contained também não é um artefato de 1 MB. A única resposta séria aqui é comparar os diretórios de publicação e a memória residente da carga real.

Compartilhar um “core” entre apps AOT

Hoje o modelo padrão do Native AOT não é ter várias aplicações gerenciadas compartilhando um runtime AOT como acontece com o runtime instalado de uma aplicação framework-dependent. Cada executável Native AOT é publicado como uma unidade autocontida e inclui as partes de runtime de que precisa. Essa repetição existe tanto em disco quanto no artefato de deploy.

Há duas nuances importantes:

  • O sistema operacional pode compartilhar páginas de código somente leitura entre processos que executam o mesmo binário; isso ajuda memória quando há muitas instâncias da mesma aplicação, mas não transforma dois executáveis AOT diferentes em consumidores de um runtime comum.
  • É possível publicar uma biblioteca Native AOT para interoperabilidade nativa, exportando funções C com UnmanagedCallersOnly. Ela pode ser carregada ou ligada por código nativo. Isso não equivale a criar uma DLL gerenciada AOT compartilhada entre aplicações .NET, nem elimina o runtime necessário dentro do módulo publicado.

Para várias aplicações .NET independentes no mesmo host, o deploy framework-dependent continua sendo o modelo que realmente compartilha o runtime. Eu não encontrei, na documentação atual, um compromisso público que permita tratar um “runtime Native AOT comum” como roadmap; então prefiro não especular sobre isso.

PGO e desempenho sustentado

Aqui a sua provocação é muito boa. O JIT tem uma vantagem forte quando usa tiered compilation e PGO dinâmico: ele observa tipos e caminhos quentes da execução real e recompila métodos relevantes com essa evidência. Native AOT não pode fazer essa etapa durante a execução, pois não há JIT para recompilar o processo em produção.

Isso não significa que AOT seja condenado a perder throughput. Há otimizações estáticas, escolhas de compilação para tamanho ou velocidade e técnicas de PGO de build quando se dispõe de um perfil representativo. Em uma carga estável, esse perfil pode produzir código excelente, e o AOT também elimina custos de JIT, warm-up e certa variabilidade inicial. Mas existe uma condição grande: o perfil precisa representar a produção. Se ele envelhece ou a distribuição de tráfego muda, o JIT com PGO dinâmico pode se adaptar melhor.

Portanto, “JIT sempre vence em throughput” foi categórico demais no artigo. O correto é: o JIT costuma ter vantagem de adaptação em processos longos e com comportamento variável; Native AOT pode empatar ou vencer em cenários específicos, especialmente quando startup, previsibilidade e perfil estável pesam mais. É caso de benchmark, não de dogma.

Startup não é a única métrica

Concordo integralmente. Startup é o benefício mais visível, mas não o único. Eu deveria ter separado os objetivos assim:

  • Tempo até a primeira resposta: Native AOT e ReadyToRun merecem entrar no teste.
  • Throughput após aquecimento: JIT com tiering e PGO dinâmico é uma referência forte; AOT precisa ser medido com a carga real.
  • Memória residente e densidade de processos: Native AOT pode reduzir memória por instância e remover picos de compilação, mas binário maior e páginas efetivamente tocadas também contam.
  • Previsibilidade de latência: evitar JIT em runtime pode ser relevante em processos efêmeros, ambientes restritos e cargas que valorizam comportamento determinístico.
  • Operação e distribuição: runtime já instalado, tempo de build, suporte a plugins, diagnóstico, atualizações e matriz de RIDs mudam bastante a decisão.

Sobre real time: você tem razão em contestar a frase. Eu quis apontar que o JIT pode otimizar caminhos quentes depois do aquecimento, não que ele seja automaticamente a escolha errada para sistemas de tempo real. Para hard real-time, o problema maior é previsibilidade de ponta a ponta: pausas de GC, escalonamento do SO, I/O, locks e alocações costumam importar mais do que a discussão isolada de JIT versus AOT. E para soft real-time, um processo JIT aquecido e bem medido pode funcionar muito bem.

Também concordo que memória pode invalidar uma conclusão obtida só com requisições por segundo. Um benchmark útil precisa registrar pelo menos latência p50/p95/p99, throughput, RSS ou working set, alocações, CPU, tamanho do artefato e tempo de build. Sem isso, o resultado fica bonito e pouco acionável.

Reflection e tamanho do deploy

Estou alinhado com a ideia de que reflexão dinâmica não é uma virtude obrigatória. Muitas aplicações robustas funcionam perfeitamente sem ela, e source generators, serialização gerada e DI mais explícita melhoram previsibilidade e antecipam erros para o build.

Eu ajustaria apenas a palavra “limitação”: não é uma limitação conceitual de toda reflexão. Há uso de metadados e reflexão que pode ser preservado com anotações e testes. O problema real para Native AOT é código dinâmico que o compilador não consegue provar nem preservar corretamente, como carregamento dinâmico de assemblies, Reflection.Emit, mecanismos genéricos de plugin e serialização baseada em descoberta aberta de tipos. Para um produto que deliberadamente evita esses recursos, isso deixa de ser obstáculo e pode até ser um filtro arquitetural saudável.

E sim, tamanho de deploy raramente é o fator decisivo sozinho. Ele vira relevante em edge, atualizações frequentes, distribuição de CLIs, imagens de container e ambientes com muitas réplicas. Fora disso, é só mais uma métrica da tabela, não um veredito.

Blazor e .NET 11

Ainda não rodei um benchmark próprio com os previews do .NET 11, então não quero transformar impressão de release notes em conclusão. No Blazor WebAssembly, vale separar os termos: o AOT do WebAssembly compila assemblies .NET para WebAssembly; ele não é o mesmo modelo de executável Native AOT para Windows, Linux ou macOS.

O trade-off clássico do Blazor WebAssembly continua sendo baixar mais bytes para reduzir trabalho de compilação no navegador e melhorar a execução depois que o app carrega. Para uma aplicação pequena ou acessada uma vez, o custo de download pode não compensar. Para uma aplicação grande, recorrente e com trechos computacionalmente intensos, pode compensar bastante. Eu avaliaria por rota e por dispositivo alvo, medindo tamanho comprimido, LCP, tempo até interação e desempenho do fluxo quente, em vez de habilitar AOT para tudo por padrão.

No Blazor Server, por sua vez, a conversa é mais próxima de qualquer backend ASP.NET Core: Native AOT depende do subconjunto de recursos compatíveis, e o benefício deve ser medido contra o custo de ecossistema e operação. Quando eu testar o .NET 11, a ideia é publicar números reproduzíveis, com projeto, RID, flags de publish e cenário de carga bem explícitos.

Valeu mesmo pelo puxão de orelha técnico. A melhor conclusão é exatamente a sua: não existe fórmula mágica. Eu vou revisar o artigo para trocar regras absolutas por uma matriz de decisão e deixar mais claro que o objetivo da aplicação, acompanhado de métricas, vem antes do rótulo “CLI”, “servidor” ou “serverless”.

1

Eu tinha entendido sobre o runtime, mas fica para quem ainda não sabe como ele funciona.

Pessoalmente acho que se a aplicação usa reflexão dificilmente deveria ser AOT. Se a aplicação é essencialmente dinâmica não é fácil pensar em AOT.

É, PGO em AOT requer um certo compromisso de quem cuida da aplicação, quando é possível.

Estou querendo ver a evolução do Blazor porque sempre achei o modo Wasm dele uma tremenda gambiarra (gambiarras podem ser muito úteis e justificáveis), vamos ver se ele gere todo Wasm, mesmo que não tudo na 11.

Eu não sou muito fã de aplicações web, mas sei que é uma batalha perdida. Já que as pessoas fazem e algumas tem motivos para usar Blazor, eu acho que pelo menos deveria poder ser algo grande. Se é necessário ser pequeno, é quase certo que deveria ser feito em JS/TS, e quem ainda insiste no Blazor que pague o preço de uma decisão ruim :D Tem um pouco de simplificação aqui... Não vou aprofundar porque é outro assunto e para deixar bem claro precisaria de um texto bem longo. Adoro a ideia do Wasm, mas a forma como eles está implmentado me faz entender que quase sempre adotá-lo é uma gambiarra (novamente...)

Não estou respondendo tudo só porque é o que eu já esperava, não sobrou dúvidas ou contestações.

Você demonstra ter um conhecimento completo da computação e capacidade mental ampla, o que costuma ser raro. Esper ote manter por perto ;)

Muito obrigado por responder tão bem. Espero ter ajudado de alguma forma, assim todos aprendem um pouco mais.

2

Excelente artigo! A análise detalhada da pipeline do Roslyn até o ILCompiler e a explicação dos trade-offs reais é o tipo de conteúdo técnico que mais agrega valor.
Essa discussão do Native AOT toca numa mudança de paradigma muito interessante que vai além do ecossistema .NET: a transição de dependência de reflexão em tempo de execução para Source Generators em tempo de compilação.
Embora muitos devs vejam a perda de reflexão dinâmica como um ponto negativo do AOT, do ponto de vista de robustez e determinismo isso é uma evolução arquitetural enorme. Forçar que serializadores (System.Text.Json) e injeções de dependência resolvam seus contratos em tempo de compilação elimina classes inteiras de bugs silenciosos em produção.
O ponto onde o Native AOT brilha de verdade não é apenas o tempo de startup de 10ms em serverless, mas a previsibilidade da pegada de memória (memory footprint estável sem flutuações de compilação JIT Tier 0 / Tier 1 sob carga) e a facilidade de gerar CLIs e binários únicos autocontidos para distribuição sem exigir o runtime instalado na máquina do usuário.
Parabéns pela profundidade técnica do guia e por desmistificar que AOT não é bala de prata, mas sim uma decisão consciente de engenharia baseada em trade-offs!

2

Obrigado pelo comentário, trsthales! Fico muito contente que o artigo tenha te agregado.

Você resumiu muito bem um ponto que eu deveria ter dado mais peso: Native AOT não é só sobre ganhar milissegundos no startup. A previsibilidade de memória e de latência, a eliminação do trabalho de JIT em runtime e a distribuição de um binário autocontido também entram forte nessa conta, principalmente quando há muitas instâncias ou processos curtos.

E concordo bastante com a leitura sobre source generators. Eles não substituem toda reflexão, mas ajudam a deslocar uma parte importante dos problemas para o build, onde fica mais fácil detectar e corrigir. Para serialização e cenários conhecidos de DI, esse determinismo é uma vantagem arquitetural bem concreta.

O comentário do maniero também me fez perceber que deixei a conclusão rígida demais. A escolha precisa considerar o objetivo e as métricas da aplicação, não apenas o rótulo dela. Vou revisar essa parte para deixar isso mais claro e incluir memória, previsibilidade e operação ao lado de startup e throughput.

Valeu por complementar a discussão de um jeito tão certeiro!

2

Não acho que AOT resolve um problema real.

O problema de .NET é portabilidade. Se você quer distribuir um binário feito em Go, ele pesará ~2mb no mínimo, não preciso de nenhum runtime na máquina destino. Distribuir um binário em bun? Pouco mais de 7mb, nenhum runtime no destino, nem mesmo o próprio bun.

Mas .NET decidiu não ser assim.

Quer distribuir algo feito em .NET? Você tem as opções:

  • Exija que seu usuário tenha o .NET correto instalado; ou
  • Empacote seu app com o tal do single file + self-contained, espere um binário de +80mb para um simples hello world. Nota: você pode diminuir um pouco se habilitar o Ready to Run.
  • Adapte toda sua codebase, remova pacotes críticos, esqueça JSON dinâmico, resolva centenas de warnings, e ative o Native AOT para conseguir por seu app dentro de 20mb. E torça para ele funcionar.

Ah, você não pode fazer cross-compilation com Native AOT. Quer compilar para Mac e está no Windows? Esquece.

.NET ainda é péssimo para portabilidade.

AOT não serve para 95% dos projetos feitos com .NET.

Não tem uso real.

1

Eu sempre adorei C#. Nunca fui fã do .NET Framework. Adorei o .NET Core, ainda mais com a promessa do AOT. Mas aí confirmei o que me parecia complicado. Muita coisa da BCL e outras partes já eram grandes demais. A filosofia geral não era de economia e não daria para melhorar muito sem quebrar compatibilidade. Lembremos que ainda foi criado na época que a Microsoft fazia só para Windows e confiava que o gardware iria evoluir e podiam fazer sem pensar em economia. Claro que está ficando perto do ideal, tem gente competente trabalhando nisso, só não estão fazendo o que não tem jeito.

Conversei pessoalmente com o Anders, o Mads, e vários outros (infelizmente não consegui falar co o Eric Lippert), disse o quanto eu gosto da criação deles e quanto eu adoraria que tivesse a chamce, quase zxero, convenhamos, de começar de novo com o sucessor do C# poder ser feito sem compromisso de compatibilidade pensando no que sabemos que é a computação real hoje, como são os concorrentes que vieram depois, considerando a linguagem M# que eles criaram, mas nunca viu a luz do dia, e que era sensacional, que tivesse um pouco mais a pegada de F# de um lado e mais um puco de Rust, Go, Carbon, de outro, sem exageros. Além disso fazer um framework que pudesse ser usado de forma muito mais leve, e sem as gambiarras que foram feitas porque tinha prazo para lançar e a linguagem não estava completa para atender a todos os cenários (melhoraram o que dava, mas tem coisas bem ruins que não tem co que fazer).

Eu adoro Go de um jeito e odeio de outro, mas o ambiente é muito bom. Não é tão perfeito quanto alguns acham, mas é o caminho certo, especialmente se fizer o melhor algumas questões.

Algumas falhas ainda existentes no .NET são resolvíveis, mas tem muitas que realmente vamos ficar reféns para sempre, sem um novo start, que não ocorrerá. Curiosamente se algum gigante resolver, ele poderia fazer o que a Microsoft não tem coragem de fazer. Não rolará de novo com a Google, e tenho críticas a tudo que ela faz, mas foi ela que teve a coragem de investir em Go, Dart, com todas as críticas que faço a ela e agora Carbon, fora abraçar Kotlin. E se não abraçacem Kotlin eu acho que eles fariam uma linguagem enterprise que completaria o portfolio deles, algo que a Microsoft deveria ter completo.

Dito tudo isso, não acho que a portabilidade é tão problema assim, ainda que esteja longe do ideal. O tamanho é, mas não acho um problema na maioria dos cenários. Convenhamos, para fazer o deploy da maioria das aplicações, tanto faz se o tamanho é 8 ou 80MB, virtualmente não muda nada importante.

Confordo parcialmente com

AOT não serve para 95% dos projetos feitos com .NET.

o que é uma pena, mas não com:

Não tem uso real.

até porque contradiz a afirmação anterior.

De qualquer forma ao mesmo yem que um número por volta dos 95% seja factível, também podemos dizer que cerca de uns 95% dos projetos podem se benecificar do AOT. Ou seja, para a maioria dos projetos, tanto faz o que você usa, terá benefícios e malefícios, e como a maioria não tem grandes restrições técnicas, vai no mais fácil, deixe para quem precisa e/ou gosta de mais eficiência usar o AOT.

Mas como sempre, engenheiros analisam tudo e fazem, a melhor escolha possível, considerando todos os aspectos, até mesmo não técnicos.