← Open in the full interactive course (progress tracking, search & more)

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

Tier 1 — Fully Optimized

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:

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

A METHOD'S LIFE THROUGH THE TIERS
METHOD IS FIRST CALLED
TIER 0 (QuickJit) COMPILATION
METHOD RUNS AT TIER 0 — CALL COUNT ACCUMULATES
THRESHOLD CROSSED? → QUEUED FOR TIER 1 (background thread)
TIER 1 NATIVE CODE INSTALLED — FUTURE CALLS USE IT

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:

Analogy

Sight-reading vs. a rehearsed performance

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

TIERING MECHANICS
1. CALL COUNTING IS BUILT INTO TIER 0 CODE ITSELF
2. RECOMPILATION HAPPENS ON A DEDICATED BACKGROUND THREAD
3. SOME METHODS SKIP TIERING ENTIRELY
4. DYNAMIC PGO — INSTRUMENTATION, THEN SPECIALIZATION

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:

Rule of thumb: Don't try to outsmart the tiering system for typical application code — it already knows more about your program's real call patterns than a static analysis (or a guess) ever could. Reach for advanced JIT-tuning knobs only after profiling has identified a specific, measured problem tiering isn't solving well on its own.

Mental Model

Tier 0 = "get it running now" — fast to compile, good enough for most methods.
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


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.


dotnetmadeeasy.com — Learn C# and .NET, the right way.