JIT, tiered compilation and Native AOT

C# · Expert · 14 min

Startup time, peak throughput and deployment size all depend on how your code is compiled.

C# compiles to IL in assemblies. At runtime the JIT (RyuJIT) compiles each method to native code on first call.

Tiered compilation balances startup and speed:

  • Tier 0: a quick, unoptimized compile so the app starts fast.
  • Methods called often (30 calls by default) or loops running long (via on-stack replacement) get recompiled at tier 1 with full optimizations.
  • Dynamic PGO (on by default since .NET 8) instruments tier-0 code, then uses the observed types and branches to devirtualize interface calls, inline hot paths and lay out code better.

The JIT does inlining, bounds-check elimination, loop optimizations, struct promotion, escape analysis for some allocations (expanded in .NET 10), and vectorization through Vector128/256/512.

Ahead-of-time options:

  • ReadyToRun (PublishReadyToRun) precompiles IL into the assembly to cut startup; the JIT can still rejit hot code.
  • Native AOT (PublishAot) compiles the whole app to a single native executable with no JIT: very fast startup, smaller memory, no runtime install. Costs: no dynamic code loading or Reflection.Emit, limited reflection, and everything must be trimmable. The compiler emits warnings (IL2xxx, IL3xxx) for unsafe patterns.

Trimming removes unused code; code that uses reflection must annotate with [DynamicallyAccessedMembers] or use source generators.

Benchmark with BenchmarkDotNet, which handles warm-up and tiering correctly.

Example

<!-- App.csproj: options for how the code is compiled -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>

    <!-- Native AOT: single native binary, no JIT (dotnet publish -r linux-x64) -->
    <PublishAot>true</PublishAot>
    <InvariantGlobalization>true</InvariantGlobalization>

    <!-- Alternatives for JIT-based apps:
    <PublishReadyToRun>true</PublishReadyToRun>
    <TieredPGO>true</TieredPGO>              default since .NET 8
    <TieredCompilation>false</TieredCompilation> fully optimize up front -->

    <!-- treat trim/AOT warnings as errors so problems surface at build -->
    <WarningsAsErrors>$(WarningsAsErrors);IL2026;IL3050</WarningsAsErrors>
  </PropertyGroup>
</Project>

Remember

  • Tier 0 compiles fast; hot methods are recompiled optimized at tier 1
  • Dynamic PGO uses runtime profiles to devirtualize and inline
  • ReadyToRun speeds startup; Native AOT removes the JIT entirely
  • Native AOT requires trimmable, reflection-light code

Watch out for

  • Micro-benchmarking with a Stopwatch over a few iterations, measuring tier-0 code.

Check yourself

  1. What is a key limitation of Native AOT?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/