.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:
- Lê
.csfiles - 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.sono 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ário | JIT | AOT |
|---|---|---|
| Console App simples | 150–500ms | 20–100ms |
| Web API mínima | 300–800ms | 50–200ms |
| Função serverless (cold start) | 1–3s | 100–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étrica | JIT | AOT |
|---|---|---|
| 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#
| Aspecto | JIT | AOT |
|---|---|---|
| Tiering | Sim (Tier 0 → 1 otimização) | Não (fixo) |
| Profiling | Jit ajusta conforme uso | Estático (sem profiling) |
| Hotpath | Melhora com tempo | Fixo (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=trueativa o compilador Native AOTTrimMode=linkativa trimming agressivoRuntimeIdentifierslista plataformas alvoSystem.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, useSystem.Text.Jsoncom source generators (JsonSourceGenerationContext). EviteNewtonsoft.Jsonou reflection pura em AOT. -
Teste AOT warnings durante desenvolvimento
Compile comdotnet builde cheque warnings. Não deixe para descobrir problemas em produção. Use-Werrorpara converter warnings em erros durante CI. -
Perfil o tamanho do binário
Usedotnet publish --analyze-warningsoudotnet sizepara 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 comPublishReadyToRun=trueem vez dePublishAot=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()), requerTrimmerRootAssemblyeDynamicallyAccessedMemberspara 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#