4

.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...
1

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).