C# and .NET path · From source to machine code · Topic 2 of 14

The JIT: tiers, PGO, ReadyToRun and Native AOT

Not started

The JIT (RyuJIT) turns a method's IL into machine code the first time the method is called. Doing that well takes time, and most methods are called only a few times, so since .NET Core 3.0 the runtime compiles in tiers: a cheap compilation first, and an expensive, optimised one only for the methods that turn out to matter.

Why it matters: Explains why a .NET service is slower for its first seconds, why micro-benchmarks need BenchmarkDotNet, and what you give up when you publish with Native AOT.

  1. 1Tier 0 first

    A method's first compilation is quick and unoptimised (or uses precompiled ReadyToRun code), so the app starts sooner.

  2. 2Tier 1 when hot

    Methods called about 30 times are recompiled in the background with full optimisation, and calls switch to the new code.

  3. 3Dynamic PGO

    Since .NET 8, tier 0 code is instrumented and the tier 1 compile uses what it saw, for example which concrete type sits behind an interface call.

  4. 4Two ahead-of-time options

    ReadyToRun ships precompiled code but keeps the JIT. Native AOT removes the JIT entirely, at the price of no runtime code generation.

1

How it works

The explanation, with examples you can run.

Tiers

  • Tier 0: the quick JIT compiles with almost no optimisation, or, if the assembly contains ReadyToRun code, the runtime simply uses that. Either way the method can run almost at once.
  • Tier 1: the runtime counts calls. Once start-up activity has settled (a 100 ms timer that resets while new tier 0 compilations are happening) and a method reaches 30 calls, it is queued for an optimised compilation on a background thread. When that finishes, the method's entry point is patched and new calls run the fast version.

Methods with loops are special: a method called once that loops a billion times would never reach 30 calls. Depending on version and platform, the runtime either optimises such methods straight away or, with on-stack replacement (OSR), starts them at tier 0 and switches to optimised code in the middle of the running loop.

Dynamic PGO

Since .NET 8, dynamic profile-guided optimisation is on by default. Tier 0 code for hot methods is instrumented to record what actually happens: which branches are taken and which concrete class sits behind an interface or virtual call. The tier 1 compile uses that, for example by checking for the common type and calling (or inlining) its method directly, with a normal virtual call as the fallback. Microsoft's .NET 8 notes measure about 15% on average across their benchmark suite.

All of this is why a fresh process is slower for its first seconds, and why a loop timed with Stopwatch right after start-up mostly measures the JIT. Use BenchmarkDotNet, which warms up, runs many iterations and reports the distribution.

Ahead of time

Two publish options move compilation to build time:

<PropertyGroup>  <PublishReadyToRun>true</PublishReadyToRun>   <!-- precompiled code plus IL; JIT still present -->  <!-- or -->  <PublishAot>true</PublishAot>                 <!-- one native executable; no JIT at all --></PropertyGroup>

ReadyToRun stores machine code next to the IL. Start-up improves because less is compiled at run time, the app is larger, and hot methods are still recompiled at tier 1, so steady-state speed is unchanged. The framework's own libraries already ship this way.

Native AOT compiles the whole app, the parts of the framework it uses, and a slimmed runtime into one native executable. Start-up is fast and memory use is low, which suits command-line tools, serverless functions and small containers. The trade-offs: no Reflection.Emit, reflection only over what the compiler can see, trimming of unused code, no dynamic PGO, and one build per operating system and architecture. Libraries that are not AOT-ready produce trimming and AOT warnings when you publish. In .NET 10, file-based apps (dotnet run app.cs) enable PublishAot by default; running this path's EF Core example that way failed with "Model building is not supported when publishing with NativeAOT" until #:property PublishAot=false was added.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.A method has been called 5 times since start-up. What code is most likely running?
  2. 2.What does ReadyToRun change?
  3. 3.Which kind of code is a problem for Native AOT?

0 of 3 right so far. Get all three to finish this topic.