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.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.
Go deeper
- Compilation config settings: tiered compilation, quick JIT, PGO (.NET documentation) ↗learn.microsoft.com
- ReadyToRun deployment overview (.NET documentation) ↗learn.microsoft.com
- Native AOT deployment (.NET documentation) ↗learn.microsoft.com
- What's new in the .NET 8 runtime: dynamic PGO on by default ↗learn.microsoft.com