DEV Community

Cover image for .NET Native AOT: Ecossistema de Compilação em C#
Lincoln Zocateli
Lincoln Zocateli

Posted on Originally published at zocate.li

.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;
Enter fullscreen mode Exit fullscreen mode

Vira IL assim:

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

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)
Enter fullscreen mode Exit fullscreen mode

📝 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);
Enter fullscreen mode Exit fullscreen mode

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) { ... }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

📂 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 todos os exemplos de código: .NET Native AOT: Ecossistema de Compilação em C#

Top comments (0)