.NET JIT Compiler Explained: How Tiered Compilation Works
The JIT doesn't optimize everything equally — it bets on which methods are worth the effort, and it usually bets right.
In the pipeline lesson, you saw that the JIT compiles IL to native code "in tiers." That was intentionally the high-level version. Now let's actually answer the question every curious developer eventually asks: if full optimization produces faster code, why doesn't the JIT just always do that?
The honest answer is that optimization itself isn't free — it costs CPU time to compute. And most methods in a typical program run only a handful of times, ever. Spending heavy optimization effort on a method that runs twice is pure waste; spending it on a method that runs ten million times is enormously profitable. The JIT's whole design is built around figuring out, automatically and cheaply, which methods fall into which category.
In this lesson, you'll go deep on exactly how tiered compilation makes that decision, what Dynamic PGO adds on top of it, and why this design is the right trade-off for the vast majority of real applications.
What Is It?
The Simple Explanation
JIT (Just-In-Time) compilation is the CLR translating IL into native machine code, one method at a time, at the moment each method is first actually called — not before. "Tiered" JIT means the runtime doesn't produce just one version of that native code; it can produce a quick, rough version first, and later replace it with a slower-to-produce, faster-to-run version, but only for methods that prove worth the extra investment.
The Technical Definition
Modern .NET's default JIT strategy has two tiers, on by default since .NET Core 3.0:
Tier 0 — QuickJit
- Minimal optimization — no inlining of most calls, simpler register allocation
- Produced very quickly, minimizing time-to-first-execution
- Instrumented with a call counter (and, since .NET 8, lightweight profiling probes for Dynamic PGO)
Tier 1 — Fully Optimized
- Aggressive inlining, better register allocation, loop optimizations
- Slower to produce — but only paid once, for methods that earn it
- Since .NET 8: guided by real profiling data via Dynamic PGO
The promotion from Tier 0 to Tier 1 happens automatically, on a background thread, once a method's observed call count crosses an internal threshold. This is a runtime implementation detail — the exact number isn't a documented, stable contract — but conceptually: a method called once stays at Tier 0 forever; a method called in a tight loop millions of times gets promoted almost immediately.
Why Does It Exist?
The Problem — a real tension, not a false one
Picture the two extremes a JIT design could pick:
- Always fully optimize, on first call. Every method — including the ones called once during startup and never again — pays the full cost of aggressive optimization before it can run at all. For a large application (think: a big ASP.NET Core service with thousands of methods touched during startup), this can add real, measurable seconds to how long it takes to handle the very first request.
- Never optimize — always produce the fastest-to-generate code. Startup is snappy, but a hot loop that runs for the next six hours of the process's life never gets the benefit of inlining, better register allocation, or loop optimizations — leaving real throughput on the table for the entire remaining lifetime of the process.
Neither extreme is right for a typical long-running server application, which wants both fast startup and excellent sustained throughput.
The Solution — pay for optimization only where it's earned
Tiered compilation resolves the tension by not committing to either extreme up front. Every method gets the cheap version first, so nothing blocks startup. Only methods that actually run often enough to matter get the expensive treatment — and by the time that happens, the runtime has already observed real behavior, which it can use to make even smarter decisions than a purely static, ahead-of-time compiler ever could.
Big Picture
- No native code exists yet — the CLR routes the call to the JIT
- Fast to produce, minimally optimized, instrumented with a call counter
- Most methods in a typical app: this is where they live and die
- Since .NET 8, Dynamic PGO instrumentation data collected at Tier 0 feeds this recompilation
- Fully optimized, profile-guided; no further recompilation for this method (barring rare re-JIT scenarios)
How It Works
Step 1 — First call triggers Tier 0
decimal CalculateDiscount(decimal price, bool isMember)
=> isMember ? price * 0.9m : price;
The first time CalculateDiscount is called, the JIT produces Tier 0 code for it — quickly, without spending effort deciding whether to inline the multiplication or how cleverly to allocate CPU registers. The method runs correctly, just not maximally fast yet.
Step 2 — Call count accumulates
If this method sits behind a checkout endpoint hit thousands of times per minute, its call count climbs quickly. The CLR is counting invocations for exactly this purpose — not for any diagnostic or logging reason.
Step 3 — Threshold crossed → background recompilation
Once the call count crosses the internal threshold, the runtime schedules Tier 1 recompilation of CalculateDiscount on a background thread. Your checkout requests keep being served by the existing Tier 0 code while this happens — there's no pause, no blocking, nothing your code observes directly.
Step 4 — Dynamic PGO uses real observed behavior
Starting in .NET 8, Dynamic Profile-Guided Optimization goes a step further: while a method runs at Tier 0, the JIT can instrument it lightly to record real facts about its execution — for example, which side of a branch is actually taken most of the time, or which concrete type actually flows through a call site that looks polymorphic in the IL. When Tier 1 compilation runs, it uses this real data to make better decisions than a purely static analysis of the IL could — for instance, ordering branches so the common case needs no jump at all, or specializing a virtual call site for the one type that's actually ever seen in practice (with a fallback still in place for the rare case it's wrong).
Step 5 — Tier 1 code takes over silently
Once Tier 1 compilation finishes, the CLR swaps the method's entry point to point at the new native code. Calls already "in flight" using the old code finish normally; new calls from that point forward use the optimized version. From your C# code's perspective, absolutely nothing changed — the method just quietly got faster.
Simple Example
You can see the effect of tiering, indirectly, through timing — though this is illustrative, not a rigorous benchmark:
static long SumOfSquares(int n)
{
long total = 0;
for (int i = 0; i < n; i++)
total += (long)i * i;
return total;
}
// First call: includes Tier 0 JIT compile time + Tier 0 execution
var sw = Stopwatch.StartNew();
SumOfSquares(50_000_000);
Console.WriteLine($"First call: {sw.ElapsedMilliseconds} ms");
// Later calls, after enough iterations to trigger Tier 1 promotion,
// may run measurably faster per call — same method, same IL, faster native code
sw.Restart();
SumOfSquares(50_000_000);
Console.WriteLine($"Later call: {sw.ElapsedMilliseconds} ms");
What this demonstrates: the first call's time includes both JIT compilation overhead and execution at the less-optimized Tier 0. A later call — assuming enough prior calls occurred to trigger promotion — runs against genuinely different, more optimized native code, even though you didn't change a single line of C#. This is exactly why proper benchmarking tools like BenchmarkDotNet always include a warm-up phase before measuring.
Real-World Example
Two deployment shapes illustrate why this trade-off is tuned automatically rather than being a single fixed choice:
- A long-running order-processing API — deployed once, running for days or weeks, handling millions of requests. Tiered JIT is close to ideal here: startup is fast, and the handful of genuinely hot methods (JSON deserialization, the order-total calculation, a frequently-hit repository method) reach full Tier 1 optimization, with Dynamic PGO further tuning them based on this specific deployment's actual traffic patterns.
- A short-lived Azure Function invoked to resize an uploaded image — the process might handle a single request and then be recycled. Here, most methods never accumulate enough calls to reach Tier 1 at all — the whole invocation runs on Tier 0 code. This is precisely the scenario where teams evaluate ReadyToRun (to skip Tier 0 JIT compile time by shipping pre-JITted code) since Tier 1's benefits are never realized in such a short-lived process anyway.
Analogy
Imagine an orchestra handed a brand-new piece of music five minutes before curtain. For most passages — played once, briefly, in the background — the musicians simply sight-read them: play competently, on the first pass, with no rehearsal (that's Tier 0). The audience doesn't notice, because these passages don't repeat and don't carry the piece.
But if the conductor notices one particular passage is going to be repeated as the piece's central, recurring theme — played over and over throughout the performance — that passage gets pulled aside for real rehearsal: refined timing, better dynamics, tighter coordination (that's Tier 1). The investment is worth it precisely because that passage will be heard many times.
Dynamic PGO is like the conductor listening carefully during those first sight-read passes and using what was actually heard — which instruments tend to come in early, where the tempo naturally wants to shift — to shape exactly how the rehearsal is run, rather than rehearsing blindly from the sheet music alone.
Under the Hood
- Tier 0-compiled methods contain a small amount of extra native code that increments an internal counter on every call.
- This counting has a real, small runtime cost — one more reason Tier 0 isn't "free," and one more reason methods that never get called often simply aren't worth promoting.
- Tier 1 compilation for a promoted method runs concurrently with your application's normal execution — it is not on the critical path of any request or call that triggered the promotion.
- Once ready, the method's entry point is atomically updated (patched) to redirect future calls to the new code.
- Very small, trivial methods, and certain methods the JIT determines aren't good candidates for tiering, may be compiled directly at a single, optimized level.
- Methods containing loops can also be optimized more eagerly even at lower tiers in some cases, since a long-running loop can itself accumulate enough "work" to justify it — the exact heuristics are internal, tuned implementation details of the runtime.
- While at Tier 0, an instrumented variant can record lightweight statistics: branch outcomes, and which concrete types actually appear at call sites that are virtual or interface calls in the IL.
- Tier 1 compilation reads this data to make choices a purely static compiler cannot — e.g., speculatively devirtualizing a call site because, in practice, only one implementing type has ever shown up, while still keeping a correct fallback path for the rare case another type appears.
- This is enabled by default starting in .NET 8; it's a refinement of tiering, not a separate mechanism.
Common Confusion
1. "Tier 0 means unoptimized garbage code"
Tier 0 code is less optimized than Tier 1, not unoptimized in any absolute sense — it's still correct, still reasonably efficient native machine code, just without the more expensive optimization passes (aggressive inlining, advanced register allocation). For the majority of methods in a typical application — called a handful of times each — Tier 0 code is perfectly adequate and its cost is essentially invisible.
2. "Dynamic PGO is a separate feature you have to turn on"
Dynamic PGO is enabled by default in modern .NET (as of .NET 8) — it's not a separate opt-in optimization mode you configure, but a refinement layered onto the existing tiering pipeline. It's worth knowing it exists mainly so you understand why Tier 1 code can sometimes make surprisingly smart decisions that a plain "recompile with -O3" style optimizer never could — because it's using real, observed runtime behavior, not just static analysis of the IL.
3. "This is basically the same thing as ReadyToRun"
Tiered JIT compilation happens live, at run time, on the machine actually running the app, and it's always adapting based on real observed call counts. ReadyToRun, by contrast, bakes in native code ahead of time, at publish time, before the app has ever run against real traffic — it's a way to skip early Tier-0-equivalent JIT work at startup, not a replacement for the tiering system, which can (and does) still promote hot R2R methods to a fully optimized Tier 1 later, informed by Dynamic PGO.
Common Mistakes
Mistake 1 — Benchmarking without a warm-up phase
Writing a quick Stopwatch-based timing loop and calling the target method only once or twice, then drawing performance conclusions.
Call the method enough times to reach steady state (or use BenchmarkDotNet, which handles this correctly), so you're measuring Tier 1 performance — the number that will actually matter once your app has been running for more than a few seconds.
Mistake 2 — Assuming tiering is something you need to manually configure per method
Hunting for a C# attribute to force a specific method to "skip straight to Tier 1" as routine practice, on the assumption that faster is always better regardless of the method's actual call frequency.
Trust the default heuristics for the overwhelming majority of code — they're tuned against real-world workloads. Advanced knobs for controlling tiering exist for narrow, measured scenarios, not as a first response to a perceived performance problem.
Mistake 3 — Confusing "startup is slow" with "my algorithm is slow"
Profiling a slow startup, seeing time attributed to JIT compilation across hundreds of methods, and concluding your algorithms are inefficient.
Distinguish JIT warm-up cost (compilation time, paid once per method per process) from algorithmic cost (execution time, paid every call). They require completely different fixes — reducing startup-path code for the former, better algorithms or data structures for the latter.
Why Does This Matter for My Code?
Tiered JIT compilation is on by default and self-tuning — there's no "should I use this" decision most teams ever need to make. Where this knowledge pays off is diagnosis and expectation-setting:
- You now know why a load test's first minute of results often looks worse than its later, steady-state results — and that this is expected tiering behavior, not necessarily a red flag.
- You know to warm up code paths before measuring them, and you understand exactly what a benchmarking tool's "warm-up" phase is actually doing under the hood.
- You can reason clearly about when a short-lived process (a CLI tool, a scale-to-zero function) might not benefit much from Tier 1 at all — informing a later, deliberate decision about ReadyToRun or NativeAOT rather than a guess.
- You understand why the same C# code can legitimately run at different speeds at different points in a process's lifetime, without anything in your code changing.
Mental Model
Tier 1 = "make the hot ones fast" — slow to compile, but only paid for methods that prove they're worth it.
Dynamic PGO = "use what actually happened" — Tier 1 gets smarter using real data collected while running at Tier 0.
Remember: the JIT is placing a bet on every method — "will this run often enough to justify optimizing it?" — and it keeps re-evaluating that bet live, based on real call counts, instead of guessing once and being stuck with the guess.
Key Takeaway
- .NET JITs IL to native code lazily, per method, at first call — full ahead-of-time compilation isn't the default because it would slow every startup down for optimization most methods never repay.
- Tiered compilation resolves the startup-vs-throughput trade-off: Tier 0 (QuickJit) gets code running fast; Tier 1 fully optimizes only methods proven hot by a real call count.
- Promotion happens automatically, on a background thread, with no blocking and no code changes required from you.
- Dynamic PGO (default since .NET 8) makes Tier 1 recompilation smarter by using real profiling data gathered during Tier 0 execution, rather than static analysis alone.
- This is why fair benchmarking requires warm-up, why cold starts and steady-state throughput are genuinely different numbers, and why short-lived processes are the scenario where ReadyToRun or NativeAOT become worth evaluating.
Check Your Understanding
You've gone deep on tiered JIT compilation and Dynamic PGO. Let's confirm the mechanics are clear.
1. Why doesn't the JIT simply fully optimize every method the very first time it's called?
Show answer
Correct: B
Why B is correct: This is the core trade-off the whole lesson is built around — optimization has a real computational cost, and most methods are called too rarely to justify paying it. Tiering defers that cost until a method proves, through actual call volume, that it's worth optimizing.
Why A is incorrect: The JIT performs real optimization at run time constantly — that's exactly what Tier 1 compilation is.
Why C is incorrect: There's no such permission barrier; the JIT is free to optimize IL as aggressively as it chooses — the constraint is purely about the cost/benefit of doing so.
Why D is incorrect: Optimized code is just as correct as unoptimized code for generics or any other construct — correctness isn't a factor in the tiering decision at all.
Reinforcement: Tiering exists purely to manage the trade-off between compilation cost and execution benefit — not because full optimization is impossible or unsafe.
2. A method is called exactly 3 times over the entire lifetime of a process. What tier does it most likely run at throughout that lifetime?
Show answer
Correct: B
Why B is correct: The promotion threshold is set high enough that a handful of calls typically won't trigger Tier 1 recompilation — the method simply runs its (very small number of) calls entirely on Tier 0 code, which is more than adequate for that usage pattern.
Why A is incorrect: There's no fixed "third call" trigger — promotion depends on crossing an internal call-count threshold that's considerably higher than three for typical methods.
Why C is incorrect: Every called method must be JIT-compiled at least once (to Tier 0) before it can execute at all — there's no minimum call count required just to compile.
Why D is incorrect: NativeAOT is an entirely separate, unrelated ahead-of-time compilation model, not a tier within the standard JIT pipeline, and Tier 0 has no fixed "at least 10 calls" rule.
Reinforcement: The vast majority of methods in a typical application, like this one, live and die entirely at Tier 0 — and that's by design, not a missed optimization opportunity.
3. What does Dynamic PGO add on top of standard tiered compilation?
Show answer
Correct: B
Why B is correct: Dynamic PGO instruments Tier 0 execution to gather real profiling data — actual branch outcomes, actual types seen at polymorphic call sites — and feeds that data into the Tier 1 recompilation, enabling optimizations a purely static analysis of the IL couldn't discover on its own.
Why A is incorrect: Dynamic PGO relies on Tier 0 execution to gather its profiling data in the first place — it doesn't skip Tier 0; it depends on it.
Why C is incorrect: Dynamic PGO is about code generation optimization, entirely separate from the garbage collector and memory allocation, which are covered in the next lessons.
Why D is incorrect: Dynamic PGO is an automatic, default runtime behavior, not a developer-facing attribute you apply per method.
Reinforcement: Dynamic PGO is best understood as "tiering, made smarter by real observation" — it doesn't change the two-tier structure, it improves the quality of the Tier 1 recompilation using genuine runtime evidence.
4. Why does BenchmarkDotNet (and good benchmarking practice generally) insist on a "warm-up" phase before recording measurements?
Show answer
Correct: B
Why B is correct: The first calls to any method include one-time JIT compilation cost and run against less-optimized Tier 0 code. Warming up lets the method reach Tier 1 (if it's going to be called enough to trigger promotion) before real measurements are recorded, producing numbers representative of how the code will actually perform in steady-state production use.
Why A is incorrect: This is a real-world engineering concern for physical hardware, unrelated to the CLR's tiered JIT compilation process this lesson covers.
Why C is incorrect: Tiered compilation and its warm-up implications apply to ordinary managed code broadly — unsafe code and pointers aren't a special case here.
Why D is incorrect: Warm-up in benchmarking tools is about reaching steady-state JIT tiering, not about triggering garbage collection specifically, though a well-designed benchmark harness may separately account for GC effects too.
Reinforcement: This directly connects back to the "Common Mistakes" section — timing a method's first call conflates one-time JIT costs with the method's true, repeatable execution cost.
5. A team runs a .NET function that processes a single image and then the container is torn down within a couple of seconds. They notice this workload spends a disproportionate share of its time in JIT compilation rather than in the actual image-processing logic. What does this observation most directly suggest?
Show answer
Correct: B
Why B is correct: A process this short-lived never accumulates enough calls for Tier 1 promotion to matter, yet it still fully pays the Tier 0 JIT compilation cost on every fresh start — this is precisely the pattern that motivates evaluating ReadyToRun or NativeAOT, as discussed in the real-world example.
Why A is incorrect: JIT compilation happens once per method per process by design — there's no "infinite recompilation" bug mechanism; a disproportionate JIT time share here is an expected consequence of a short process lifetime, not a defect.
Why C is incorrect: Dynamic PGO is enabled by default in modern .NET; nothing about a short-lived workload disables it, though such a workload simply won't live long enough to benefit much from the Tier 1 recompilation it feeds into.
Why D is incorrect: The garbage collector and the JIT are independent subsystems — GC activity doesn't block or cause JIT compilation, and this scenario's description points squarely at cold-start JIT overhead, not GC pauses.
Reinforcement: This is the exact real-world pattern from earlier in the lesson — short-lived processes pay JIT costs repeatedly without ever recovering the investment through sustained Tier 1 execution, which is the concrete, measurable reason ahead-of-time alternatives exist.
You now understand exactly how and why the JIT decides which methods deserve full optimization — and why that decision is made automatically, based on real behavior.