Modern .NET does not compile a method once. Most methods start as fast, lightly optimized tier 0 code and are recompiled later as fully optimized tier 1 code once the runtime has watched them run for a while, guided by real execution data through Dynamic PGO. This guide is for developers who already have a mental model of how the CLR loads and executes code and want to understand the JIT pipeline specifically: RyuJIT, tier 0 and tier 1, On-Stack Replacement, Dynamic PGO, ReadyToRun and crossgen2. You will learn how the pieces fit together, what each one trades off, and how to read the machine code the JIT actually produces for your methods.
What Is Tiered Compilation?#
RyuJIT is the just-in-time compiler built into CoreCLR. It is the only JIT modern .NET ships; it replaced the separate 32-bit and 64-bit JITs from .NET Framework and runs on every supported architecture. Left alone, a JIT has an awkward choice to make for every method: compile quickly with few optimizations, so the app starts fast, or spend more time optimizing, so the method runs fast once it is hot. Tiered compilation removes that trade-off by compiling twice:
- Tier 0 ("quick JIT") produces code as fast as possible. It skips loop optimizations and most inlining, keeps register allocation simple, and β since Dynamic PGO is on by default β adds lightweight counters that record how the method actually behaves.
- Tier 1 is what RyuJIT would have produced without tiering: full inlining, loop optimizations, better register allocation, and, when profile data is available, decisions informed by it. Tier 1 compilation happens on a background thread, so it never blocks the calling thread.
A method is promoted from tier 0 to tier 1 once the runtime judges it "hot" β called often enough that the extra optimization time pays for itself. Cold methods, such as one-time startup code, may run tier-0 code for the entire life of the process and never pay for tier 1 compilation at all. This is the core idea behind tiering: spend optimization effort where it matters, not everywhere.
| Tier | Compiled by | Optimization level | Instrumented? | Typical lifetime |
|---|---|---|---|---|
| Tier 0 | RyuJIT (quick JIT) or ReadyToRun | Minimal | Yes, by default (Dynamic PGO) | Until the method is promoted or the process exits |
| Tier 1 | RyuJIT, on a background thread | Full | No (consumes tier-0 profile data) | For the rest of the method's lifetime |
| Tier 1 (OSR) | RyuJIT, triggered mid-loop | Full | No | Replaces a single long-running tier-0 invocation |
How the JIT Pipeline Works#
For a typical method call, the runtime moves through this sequence:
- First call. The method's entry point is a small stub (precode) that routes to the JIT. If the assembly ships ReadyToRun code for the method, the runtime uses that instead of invoking the JIT at all.
- Tier 0 compilation. Otherwise, RyuJIT compiles a minimally optimized version quickly, with instrumentation probes if Dynamic PGO is enabled.
- Execution and counting. Every call to the method increments a counter. Loops also hit patchpoints, which count independently of the method-call counter.
- Promotion. Once a counter crosses its threshold, the runtime schedules a tier-1 compilation on a background thread, feeding it any profile data tier 0 collected, then atomically repoints the method to the new code.
- On-Stack Replacement (OSR). A tier-0 invocation stuck in a long-running loop does not wait for the next call to benefit from tier 1; a patchpoint hit lets the runtime swap it for optimized code in the middle of that same invocation.
This pipeline runs identically whether the method started as JIT-compiled tier 0 or as precompiled ReadyToRun code β both are "tier 0" from the promotion logic's point of view, and both can be replaced by a freshly JIT-compiled tier 1 version.
Getting Started: Watching a Method Change Tiers#
The DOTNET_JitDisasm environment variable prints the code RyuJIT generates for methods whose names match a given pattern. Start from a method worth watching:
// Program.cs
static long SumSquares(int[] values)
{
var total = 0L;
for (var i = 0; i < values.Length; i++)
{
total += (long)values[i] * values[i];
}
return total;
}
var data = Enumerable.Range(1, 2_000).ToArray();
long result = 0;
for (var iteration = 0; iteration < 200; iteration++)
{
result += SumSquares(data);
}
Console.WriteLine(result);Run it against a Release build with the disassembly switch enabled:
dotnet build -c Release
DOTNET_JitDisasm="SumSquares" dotnet bin/Release/net10.0/App.dllWith tiered compilation on, SumSquares is JIT-compiled twice: once quickly as tier 0, and again as tier 1 once the call-count threshold is crossed. Each listing in the output is headed by a comment block that names the method and the tier, for example ; Tier0 versus ; optimized code with a note that profile data was consulted β that heading is the fastest way to confirm which tier you are looking at before reading the instructions below it. Comparing the two shows tier 1 doing more: better register allocation, and, if the loop runs long enough within one call, an OSR transition partway through.
On-Stack Replacement: Escaping Long-Running Loops#
Methods with loops are exactly the ones tiering used to handle badly: a method with a for loop that runs for seconds would, without OSR, execute the whole thing in slow tier-0 code, because the method-call counter never gets another chance to fire mid-call. OSR fixes this by adding patchpoints at loop back edges β places where control returns to the top of a loop with an empty IL stack, which the runtime can safely use as a transition point.
Each patchpoint carries its own counter. Once it fires, the runtime JIT-compiles an OSR version of the method specialized to resume from that exact point, complete with the original frame's live state, and the running loop jumps into it. The rest of the method β everything after the loop β then also benefits from tier-1 code.
static double MonteCarloPi(int samples)
{
var random = Random.Shared;
var inside = 0;
// A long-running loop like this is exactly what OSR targets: instead of
// running the whole thing in quick tier-0 code, the runtime promotes it
// to optimized code mid-loop once the patchpoint counter trips.
for (var i = 0; i < samples; i++)
{
var x = random.NextDouble();
var y = random.NextDouble();
if (x * x + y * y <= 1.0)
{
inside++;
}
}
return 4.0 * inside / samples;
}Because OSR exists, the JIT can afford to quick-JIT methods with loops instead of always fully optimizing them up front β which is exactly what the DOTNET_TC_QuickJitForLoops setting controls, and why it defaults to on wherever OSR is available. Without it, every loop-containing method would need a slow, fully optimized tier-0 compilation before it could run at all, which would hurt startup on any code path that loops even once.
Dynamic PGO: Profile-Guided Optimization by Default#
Dynamic PGO is what makes tier-0 instrumentation worth having. While a method runs as tier 0, lightweight probes record two things: how often each block of code executes, and, at virtual and interface call sites, which concrete type showed up. The instrumentation uses a sparse, spanning-tree scheme so it does not need a counter on every basic block, which keeps the overhead low enough to run by default.
When the method is promoted, tier 1 consumes that data instead of guessing. Two optimizations benefit the most:
- Better inlining decisions, because the JIT knows which call sites and branches are actually hot rather than relying on static heuristics alone.
- Guarded devirtualization, where the JIT inserts an explicit type check ahead of a virtual or interface call and inlines the call for the type the profile says is overwhelmingly common, falling back to a normal virtual dispatch otherwise.
public interface IPaymentGateway
{
Task<bool> ChargeAsync(decimal amountUsd, CancellationToken cancellationToken);
}
public sealed class StripeGateway : IPaymentGateway
{
public Task<bool> ChargeAsync(decimal amountUsd, CancellationToken cancellationToken) =>
Task.FromResult(true); // real implementation calls out to Stripe
}
// If almost every call through this site sees a StripeGateway instance, Dynamic PGO
// notices from the tier-0 type histogram and has tier 1 test for that type, inlining
// ChargeAsync for the common case instead of always paying for interface dispatch.
public static Task<bool> ProcessAsync(
IPaymentGateway gateway, decimal amountUsd, CancellationToken cancellationToken) =>
gateway.ChargeAsync(amountUsd, cancellationToken);Dynamic PGO has been enabled by default since .NET 8; earlier versions required opting in with DOTNET_TieredPGO=1. It is worth turning off only in narrow cases, such as an extremely short-lived CLI tool where the process exits before any method is ever promoted, so the instrumentation overhead in tier 0 never pays for itself. For anything long-running β web services, workers, desktop apps β leave it on.
Inlining, Devirtualization and Bounds-Check Elimination#
Most of the optimizations developers associate with "the JIT being smart" are tier-1-only. Tier 0 inlines only trivial, forced cases so it stays fast to produce; tier 1 applies the JIT's full inlining heuristics, weighing call-site frequency (from PGO data when available), callee size and call depth.
Devirtualization removes the cost of a virtual or interface call outright. If the JIT can prove the exact type at a call site β the receiver is sealed, the method itself is non-virtual, or the object was just constructed β it turns a virtual call into a direct call and considers inlining it. Guarded devirtualization, covered above, extends this to call sites where the type is merely likely rather than provable.
Bounds-check elimination (also called range-check elimination) removes the implicit bounds check the runtime inserts on every array access. When the JIT can prove an index always falls inside [0, array.Length) β the classic case being a for loop bounded by array.Length β it drops the per-element check entirely:
static long Sum(int[] values)
{
var sum = 0L;
// Tier 1 proves once that i stays within [0, values.Length) for the whole loop,
// then removes the redundant per-iteration bounds check on values[i].
for (var i = 0; i < values.Length; i++)
{
sum += values[i];
}
return sum;
}All three optimizations are why microbenchmarks that skip warm-up are misleading: the first handful of calls to any method run tier-0 code with none of this applied, which is one of the reasons tools such as BenchmarkDotNet run a dedicated warm-up phase before measuring.
ReadyToRun, Crossgen2 and Composite R2R#
ReadyToRun (R2R) is native code, precompiled ahead of time and stored alongside a method's IL, so the runtime can start executing it immediately instead of invoking the JIT on first call. It is produced by crossgen2, which uses RyuJIT itself as its code generator, so R2R code is not a different or lesser code path β it is ordinary JIT output computed early. The shared framework ships as R2R, which is why little of it needs JIT compilation during startup.
R2R code acts as tier 0 from tiering's perspective: it runs immediately, but a method that turns out to be hot is still replaced by a freshly compiled, profile-guided tier-1 version, exactly like JIT-compiled tier-0 code. Crossgen2 can also go further and bake a previously recorded execution profile into the R2R image itself, so even a method's very first call runs code informed by real usage rather than static heuristics β distinct from, and complementary to, the runtime's own Dynamic PGO.
Application code is not R2R-compiled by default; you opt in for a self-contained, single-RID publish:
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>By default, PublishReadyToRun emits one R2R image per assembly, each still carrying its own metadata for version resilience. Composite R2R instead compiles the app and all of its dependencies into a single R2R image:
<PropertyGroup>
<PublishReadyToRun>true</PublishReadyToRun>
<PublishReadyToRunComposite>true</PublishReadyToRunComposite>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
</PropertyGroup>Composite images give up the ability to update one assembly independently of the others, in exchange for tighter code generation across assembly boundaries β the same trade-off single-file, fully self-contained deployments already accept. When startup must be as close to instant as possible and you do not need any JIT at runtime at all, compare this against Native AOT, which removes the JIT entirely rather than just precompiling ahead of it.
Inspecting JIT Output: DOTNET_JitDisasm and Disasmo#
Beyond DOTNET_JitDisasm, a few related switches help narrow down what you are looking at:
DOTNET_JitDisasmAssembliesrestricts disassembly to methods from specific assemblies, useful when a method name is common.DOTNET_JitDisasmSummaryprints one line per JIT-compiled method instead of full assembly, which is enough to see how many methods were compiled at each tier without drowning in output.DOTNET_JitStdOutFileredirects the JIT's diagnostic output to a file, which is easier to diff between two runs than console output.
For interactive use, Disasmo is a community-built Visual Studio extension that shows a method's JIT-generated assembly and IL from inside the editor, without setting environment variables by hand. It supports diffing code generation across two configurations or two runtime builds, includes a dedicated mode for inspecting how Dynamic PGO instrumented a method, and can show JIT dumps and flow graphs for deeper investigations. For counting how much JIT work a running process has done rather than reading individual method bodies, JitInfo.GetCompiledMethodCount() in System.Runtime and the diagnostics tools covered in the .NET diagnostics toolkit guide are the better fit.
Configuration Knobs#
These environment variables (set as DOTNET_<Name>, or COMPlus_<Name> on older runtimes) control the pipeline described above. Most apps never need to touch them; they matter mainly for diagnosing a startup or steady-state performance question, or reproducing a specific tier for a bug report.
| Variable | Purpose | Default |
|---|---|---|
DOTNET_TieredCompilation | Master switch for tiered compilation | 1 (enabled) |
DOTNET_TC_QuickJitForLoops | Quick-JIT methods with loops instead of fully optimizing them up front | 1 where OSR is available |
DOTNET_TieredPGO | Instrument tier-0 code and feed the data to tier 1 (Dynamic PGO) | 1 (enabled) |
DOTNET_ReadyToRun | Use precompiled ReadyToRun code where present | 1 (enabled) |
DOTNET_JitDisasm | Print JIT-generated assembly for methods matching a pattern | unset |
DOTNET_JitDisasmSummary | Print a one-line summary for every JIT-compiled method | unset |
DOTNET_JitStdOutFile | Redirect JIT diagnostic output to a file | unset |
Setting DOTNET_TieredCompilation=0 forces every method straight to tier-1-equivalent, fully optimized code, which is occasionally useful for isolating whether a bug or a performance regression is tiering-related, but it also removes OSR and Dynamic PGO along with it, so treat it as a diagnostic tool rather than a production setting.
Best Practices#
- Warm up before you measure. Call the method enough times to reach tier 1 before timing it, or use BenchmarkDotNet, which does this for you.
- Leave Dynamic PGO on. It is the default for a reason; only disable it for processes so short-lived that no method is ever promoted.
- Reach for
PublishReadyToRunfor latency-sensitive, self-contained deployments, not as a default for every app β it adds publish time and disk size in exchange for faster first calls. - Prefer sealed types and concrete parameter types on hot paths so the JIT can devirtualize without needing a guard, complementing what guarded devirtualization already does for the cases it cannot prove statically.
- Reach for Native AOT instead of R2R when you need the fastest possible startup and can live with its reflection and dynamic-codegen restrictions.
- Use
DOTNET_JitDisasmagainst Release builds only. Debug builds disable most JIT optimizations regardless of tier, so their disassembly tells you nothing about tier 1 behavior.
Common Pitfalls#
- Benchmarking without warm-up, which measures tier-0 (or R2R) performance and understates what the code does in steady state.
- Assuming ReadyToRun means the JIT never runs. R2R only supplies tier-0-equivalent code; hot methods are still recompiled at tier 1.
- Disabling tiered compilation or PGO from outdated advice. Guidance written before .NET 8, when Dynamic PGO was opt-in, no longer matches today's defaults.
- Confusing OSR with normal tier promotion. OSR replaces one long-running invocation mid-loop; promotion changes the entry point for future calls.
- Chasing a specific instruction sequence in
DOTNET_JitDisasmoutput across CPUs or .NET versions. Codegen details shift between releases and architectures; treat the output as a guide to what optimizations applied, not a stable contract.
Choosing a Compilation Strategy#
| Strategy | Startup cost | Steady-state throughput | Needs a JIT at runtime? | Typical use |
|---|---|---|---|---|
| Tiered JIT (default) | Low; tier 0 runs almost immediately | High, once hot methods reach tier 1 | Yes | Most apps: web services, background workers, desktop apps |
DOTNET_TieredCompilation=0 | High; every method fully optimized up front | High from the first call | Yes | Diagnosing tiering-related bugs, not production |
PublishReadyToRun (per-assembly or composite) | Lower; framework and app start from precompiled code | High, same tier-1 ceiling as the default | Yes, for methods that still change tier | Self-contained deployments sensitive to cold start |
| Native AOT | Lowest; no JIT warm-up at all | High immediately, fixed at publish time | No | CLIs, serverless, containers; trades away reflection and runtime codegen |
Frequently Asked Questions#
What is the difference between tier 0 and tier 1 in .NET?#
Tier 0 is quick, lightly optimized code the JIT produces as fast as possible so a method can run the first time it is called; it also carries Dynamic PGO instrumentation by default. Tier 1 is fully optimized code β full inlining, loop optimizations, devirtualization β compiled on a background thread once the runtime decides the method is hot, informed by the data tier 0 collected.
Is Dynamic PGO enabled by default in .NET?#
Yes, since .NET 8. It instruments tier-0 code with lightweight counters and type histograms, then uses that data to guide tier-1 inlining and guarded devirtualization decisions. Earlier versions required setting DOTNET_TieredPGO=1 to opt in.
What problem does On-Stack Replacement solve?#
Without OSR, a method stuck in a long-running loop would run entirely in slow tier-0 code, because tiering normally only replaces a method's entry point for its next call. OSR adds patchpoints at loop back edges so the runtime can swap a single in-progress invocation over to optimized code partway through, without waiting for the loop to finish.
Does ReadyToRun mean the JIT never runs for my app?#
No. ReadyToRun supplies precompiled code that acts as tier 0: it lets a method start running immediately instead of waiting on the JIT, but any method that later turns out to be hot is still JIT-compiled at tier 1, exactly as it would be without R2R.
How do I see the actual machine code the JIT generated for a method?#
Set DOTNET_JitDisasm to a method name pattern and run a Release build; the runtime prints the assembly for matching methods, tier by tier. DOTNET_JitDisasmSummary gives a higher-level, one-line-per-method view, and the Disasmo Visual Studio extension offers the same information, plus diffing, from inside the IDE.
Should I disable tiered compilation before benchmarking my code?#
No β disabling it removes OSR and Dynamic PGO along with tiering, which changes the code you are measuring rather than isolating it. Warm up the method under test instead, or use BenchmarkDotNet, which handles warm-up and steady-state measurement for you; reserve DOTNET_TieredCompilation=0 for narrowly diagnosing whether tiering itself is the source of a bug.
Summary#
- Tiered compilation compiles hot methods twice: a fast, lightly optimized tier 0, then a fully optimized tier 1 once the runtime decides a method is worth the extra compile time.
- On-Stack Replacement lets a single long-running loop escape slow tier-0 code mid-invocation, which is what lets
DOTNET_TC_QuickJitForLoopsdefault to on. - Dynamic PGO instruments tier 0 with block counts and type histograms and has been the default since .NET 8, improving inlining and enabling guarded devirtualization at tier 1.
- ReadyToRun and composite R2R precompile tier-0-equivalent code with crossgen2 to cut startup cost; Native AOT goes further by removing the runtime JIT altogether.
DOTNET_JitDisasm,DOTNET_JitDisasmSummaryand the Disasmo extension let you read exactly what the JIT produced, tier by tier.
Further Reading#
- Inside the CLR: How .NET Loads, Compiles and Executes Your Code
- Native AOT and Trimming in .NET: A Practical Guide
- .NET Diagnostics Toolkit: Counters, Traces and Dumps
- Benchmarking .NET Code with BenchmarkDotNet
- JIT, Tiered Compilation and AOT Interview Questions
- Tiered compilation design document (dotnet/runtime)
- On-Stack Replacement design document (dotnet/runtime)