Tiered JIT: quick unoptimised code first, call counting, background Tier 1 recompilation, OSR for loops and the settings that control it.
Tiered compilation (on by default since .NET Core 3.0) compiles a method first at Tier 0: fast to produce, barely optimised. The runtime counts calls; a method called about 30 times (after a short startup delay) is recompiled at Tier 1 with full optimisation on a background thread and the entry point is swapped. ReadyToRun code counts as a starting tier and is also promoted when hot. On-stack replacement (default on x64 and Arm64 since .NET 7) lets a long-running loop in a Tier 0 method jump into optimised code mid-execution. The result is fast startup and good steady-state performance, but benchmarks must warm up.
Tiered compilation lets the JIT compile methods more than once: first quickly (Tier 0) to start fast, then optimised (Tier 1) once a method proves hot — using profile data collected in the meantime (dynamic PGO). On-stack replacement (OSR) solves the case of a long-running loop stuck in slow Tier 0 code by switching to optimised code mid-execution. I'd explain each tier, how promotion works, OSR, ReadyToRun's role, and the knobs.
| Tier | When | Code quality | Purpose |
|---|---|---|---|
| Tier 0 | First call of a method (no R2R code) | Minimal optimisation, fast to produce | Fast start-up |
| Tier 0 instrumented | Hot methods (with dynamic PGO, .NET 8 default) | Tier 0 + counters/probes | Collect profile data (branch frequencies, types at call sites) |
| Tier 1 | After ~30 calls (and a back-off delay) | Fully optimised, PGO-guided | Steady-state performance |
| R2R code | Precompiled in ReadyToRun images | Optimised but version-resilient, no PGO | Fast start-up without JIT; hot methods later re-JIT to Tier 1 |
| OSR | Loops running long in Tier 0 code | Optimised continuation of the method | Avoid being stuck in slow code |
The background JIT thread does the recompilation so the application keeps running.
Tier 0 skips most optimisations: no inlining, minimal register allocation, locals kept on the stack, no loop optimisations, static readonly not treated as constants. Code is several times slower than Tier 1 for compute-heavy methods — fine for code run a few times (start-up, configuration), bad for hot paths until promotion.
Problem: a method called once that contains a long loop (e.g. Main processing a file, a background loop) never reaches the call threshold, so it would run forever in Tier 0.
Prior to OSR (.NET Core 3.x), methods with loops were compiled straight to optimised code (TC_QuickJitForLoops=false), losing start-up benefits. Since .NET 7, OSR is on by default (x64/Arm64):
OSR code is typically very close to Tier 1 quality; the next call to the method uses the regular Tier 1 version.
<TieredCompilation>true</TieredCompilation> <!-- default true; false = compile everything optimised (slower start-up) -->
<TieredPGO>true</TieredPGO> <!-- default true since .NET 8 -->
<TieredCompilationQuickJitForLoops>true</TieredCompilationQuickJitForLoops> <!-- default true with OSR -->
Environment equivalents: DOTNET_TieredCompilation, DOTNET_TieredPGO, DOTNET_TC_QuickJitForLoops, DOTNET_OSR_HitLimit, DOTNET_TC_CallCountThreshold.
With R2R images, start-up uses precompiled code (no Tier 0 JIT for those methods). Hot R2R methods are still re-JIT-ed at Tier 1 (with PGO), getting the best of both. Methods not in R2R images (some generics, dynamic code) go through Tier 0.
An API's p99 latency was poor for the first ~30 seconds after each deployment (Azure Container Apps scaled out frequently). Traces showed lots of Tier 0 code and JIT activity during the first requests. Publishing with ReadyToRun cut cold-start JIT work, a short warm-up endpoint hit critical paths before the replica joined the load balancer (readiness probe), and tiering then promoted hot paths in the background. First-minute latency dropped close to steady state.
The interviewer checks depth on JIT behaviour that affects startup, benchmarks and tuning.
Green flags: Tier 0 then Tier 1 by call count, background recompilation, OSR for loops, R2R as a starting tier, PGO link, benchmarking implications, keeps defaults.
Red flags: disables tiering by reflex; does not know benchmarks need warm-up; thinks code is compiled once forever.
The runtime uses call-counting stubs placed in front of tier-0 code after the start-up delay. Each call decrements a counter; at zero, the method is queued for promotion and the stub is removed. The delay (TC_CallCountingDelayMs, default 100 ms) resets while new tier-0 methods are still being compiled, so start-up activity doesn't trigger premature promotions.
With dynamic PGO, hot methods get an instrumented Tier 0 version collecting:
Tier 1 compilation then uses these for guarded devirtualisation, block layout (hot code together), and inlining decisions. R2R methods that become hot can also go through an instrumented tier before Tier 1 (configurable).
Patchpoints are inserted at loop heads in Tier 0 code. Each patchpoint has a counter; when it trips, the runtime compiles an OSR method version specific to that IL offset. The OSR method's prolog reconstructs locals from the Tier 0 frame (the Tier 0 frame stays on the stack beneath it). Since the OSR version is specific to an entry point, a method may get multiple OSR versions for different loops.
DOTNET_JitDisasmSummary=1 lists methods with their tier (Tier0, Instrumented Tier0, Tier1, OSR).dotnet-counters shows JIT counters (methods-jitted-count, time-in-jit).MethodJittingStarted, MethodLoadVerbose with tier info) in PerfView/dotnet-trace.Micro-benchmarks without warm-up measure Tier 0 code. Profiles taken early show JIT overhead. Always warm up and confirm the tier when reading disassembly.
DOTNET_JitDisasmSummary=1 and watch methods move from Tier0 to Tier1.Main with a 100-million-iteration loop and compare run time with OSR on/off (DOTNET_TC_OnStackReplacement=0).TieredCompilation enabled vs disabled.using System.Diagnostics;
using System.Runtime.CompilerServices;
var sw = Stopwatch.StartNew();
long acc = 0;
// Called many times: Tier 0 -> (instrumented) -> Tier 1
for (int i = 0; i < 10_000; i++)
{
acc += Hot(i);
if (i % 1_000 == 0) Thread.Sleep(20); // give the background Tier 1 compile time to finish
}
// Called once with a long loop: OSR moves it to optimised code mid-loop
acc += LongLoop(200_000_000);
Console.WriteLine($"{acc} in {sw.ElapsedMilliseconds} ms");
[MethodImpl(MethodImplOptions.NoInlining)]
static long Hot(int x) => (long)x * x % 7;
[MethodImpl(MethodImplOptions.NoInlining)]
static long LongLoop(int n)
{
long s = 0;
for (int i = 0; i < n; i++) s += i & 3;
return s;
}
DOTNET_JitDisasmSummary=1 DOTNET_JitStdOutFile=tiers.txt dotnet run -c Release
What happens: timing the first few calls. Why it is wrong: measures Tier 0 code and JIT time. Do instead: warm up or use BenchmarkDotNet.
What happens: turning it off to get "optimised code immediately". Why it is wrong: slower start-up and loss of PGO benefits. Do instead: keep defaults; use R2R/warm-up for cold start.
What happens: concerns about long loops in Tier 0. Why it is wrong: OSR transitions to optimised code. Do instead: understand OSR (and verify with JIT summaries).
What happens: new replicas serve traffic immediately. Why it is wrong: first requests run Tier 0/JIT-heavy code. Do instead: readiness probes, warm-up, R2R.
What happens: analysing first-call code. Why it is wrong: not representative. Do instead: confirm the tier in the listing header.
What happens: changing call-count thresholds blindly. Why it is wrong: can worsen start-up or steady state. Do instead: measure with traces before tuning.
What happens: assuming R2R code is Tier 1 quality. Why it is wrong: R2R code is less optimised and lacks PGO. Do instead: rely on tiering to promote hot R2R methods.
What happens: expecting NativeAOT apps to tier. Why it is wrong: AOT has no JIT or tiering. Do instead: choose based on start-up vs steady-state needs.
Tiered compilation is a trade-off between time-to-first-response and steady-state speed, and the defaults are tuned well.
| Workload | Recommended setup |
|---|---|
| Long-running web API | Defaults plus ReadyToRun for startup |
| Short CLI tool | ReadyToRun or NativeAOT; tiering matters less |
| Latency-sensitive warm service | Defaults plus warm-up before taking traffic |
| Serverless with cold starts | NativeAOT if the app is compatible |
A senior answer also notes that Tier 1 compilation uses background CPU, which appears in CPU profiles during the first minutes after a deploy.
The JIT (RyuJIT) compiles IL to native code one method at a time, on first call. Each method initially points at a small stub; the first call enters the runtime, which JIT-compiles the IL, stores the code in the code heap, and patches the stub so later calls go straight to native code. The JIT also produces GC info and unwind info so the GC and exception handling work. With tiered compilation a method may be compiled more than once, first quickly then optimised. You can print the generated assembly with DOTNET_JitDisasm="MethodName", which works on release runtimes since .NET 7.
The CLR is the virtual machine that runs .NET code. It loads assemblies, verifies and JIT-compiles IL into machine code, lays out types in memory, manages the heap with the garbage collector, dispatches exceptions, manages threads and the thread pool, and handles interop with native code. In modern .NET the runtime is CoreCLR (Mono still powers some mobile and WebAssembly targets). Your C# compiler produces IL plus metadata; the CLR turns that into a running process.
How to tell it (STAR)
On [our pricing API], someone disabled tiered compilation. I measured startup going from [1.2 s] to [over 3 s] and throughput dropping [N%]. I showed that Tier 1 with PGO beat single-shot optimised JIT, restored the default and used ReadyToRun for startup instead.
Last updated 30 Oct 2026.