📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How does tiered compilation work in .NET, including Tier 0, Tier 1 and on-stack replacement (OSR)?

Tiered JIT: quick unoptimised code first, call counting, background Tier 1 recompilation, OSR for loops and the settings that control it.

Senior Theory Advanced clr jit osr performance tiered-compilation

🎯 Key points

  • Tier 0 compiles quickly with few optimisations; Tier 1 recompiles hot methods fully optimised
  • Promotion happens after about 30 calls, with a startup delay, on a background thread
  • ReadyToRun code is a starting tier and hot R2R methods are rejitted at Tier 1
  • OSR (default on x64 and Arm64 since .NET 7) moves hot loops in Tier 0 methods to optimised code mid-execution
  • Tier 1 benefits from initialised statics and Dynamic PGO data
  • Disabling tiering usually hurts startup and can hurt steady state; benchmarks need warm-up

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. The tiers

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

2. How promotion works

  1. A method starts at Tier 0 (or R2R).
  2. After a start-up delay (~100 ms with no new tier-0 activity, configurable), the runtime installs call counters.
  3. When a method reaches the call threshold (default 30), it's queued for background re-compilation at Tier 1 (with dynamic PGO, it may first go through an instrumented tier to gather profile data).
  4. Once Tier 1 code is ready, the method's entry point is updated; subsequent calls run optimised code.

The background JIT thread does the recompilation so the application keeps running.

3. Why Tier 0 is fast but slow

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.

4. On-stack replacement (OSR)

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):

  1. Tier 0 code for methods with loops includes patchpoints with counters at loop back-edges.
  2. When a loop iterates enough (default ~1,000s of iterations), the runtime JIT-compiles an OSR version: optimised code that starts from the middle of the method, taking over the current frame's state (locals, stack).
  3. Execution transitions into the optimised code without returning.

OSR code is typically very close to Tier 1 quality; the next call to the method uses the regular Tier 1 version.

5. Knobs and when to use them

<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.

  • Keep defaults for most apps.
  • Disable tiering only for niche cases (some latency-critical apps preferring predictable performance from the first call, accepting slower start-up) — or better, use R2R/AOT.
  • Benchmarks must warm up so they measure Tier 1 code (BenchmarkDotNet handles this).

6. Interaction with ReadyToRun

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.

7. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Call counting implementation

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.

2. Instrumented tier and PGO

With dynamic PGO, hot methods get an instrumented Tier 0 version collecting:

  • block counts (which branches are taken),
  • class profiles at virtual/interface call sites (which types appear),
  • method profiles for delegate calls.

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).

3. OSR mechanics

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.

4. Observing tiering

  • DOTNET_JitDisasmSummary=1 lists methods with their tier (Tier0, Instrumented Tier0, Tier1, OSR).
  • dotnet-counters shows JIT counters (methods-jitted-count, time-in-jit).
  • EventPipe JIT events (MethodJittingStarted, MethodLoadVerbose with tier info) in PerfView/dotnet-trace.

5. Effects on benchmarking and profiling

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.

Try it yourself

  1. Run an app with DOTNET_JitDisasmSummary=1 and watch methods move from Tier0 to Tier1.
  2. Write a Main with a 100-million-iteration loop and compare run time with OSR on/off (DOTNET_TC_OnStackReplacement=0).
  3. Compare start-up with TieredCompilation enabled vs disabled.
  4. Publish with R2R and observe fewer Tier 0 methods at start-up.

💻 Code

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

⚠ Common mistakes

1. Measuring performance without warm-up

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.

2. Disabling tiered compilation globally

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.

3. Assuming methods with loops stay slow

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).

4. Ignoring cold-start effects in autoscaling

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.

5. Reading disassembly from Tier 0

What happens: analysing first-call code. Why it is wrong: not representative. Do instead: confirm the tier in the listing header.

6. Tuning thresholds without data

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.

7. Expecting R2R to be fully optimised

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.

8. Confusing tiering with AOT

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions Tier 1 recompilation Why: Checks how the JIT produces and swaps code
    How to answer

    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.

  2. When: Candidate links tiering to startup latency Why: Checks the wider picture of CLR startup work
    How to answer

    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.

🛠 Real-world problems: how you handle them

Disabling tiered compilation slowed startup and steady state
Situation
A team set `<TieredCompilation>false</TieredCompilation>` on a pricing API after reading that Tier 0 code is slow.
Signals
Pod startup time roughly tripled, readiness probes timed out during deploys, and throughput dropped slightly under load tests.
Investigation
Compared `DOTNET_JitDisasmSummary` output and load-test results with tiering on and off; Tier 1 with PGO produced better code for hot interface calls.
Resolution
Restored the default, added ReadyToRun to cut startup, and added warm-up requests before readiness.
Prevention
Documented that JIT settings changes need a startup and throughput benchmark before merge.

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.

↩ Usually asked after

More in CLR Fundamentals

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me