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.

TierCompiled byOptimization levelInstrumented?Typical lifetime
Tier 0RyuJIT (quick JIT) or ReadyToRunMinimalYes, by default (Dynamic PGO)Until the method is promoted or the process exits
Tier 1RyuJIT, on a background threadFullNo (consumes tier-0 profile data)For the rest of the method's lifetime
Tier 1 (OSR)RyuJIT, triggered mid-loopFullNoReplaces a single long-running tier-0 invocation

How the JIT Pipeline Works#

For a typical method call, the runtime moves through this sequence:

  1. 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.
  2. Tier 0 compilation. Otherwise, RyuJIT compiles a minimally optimized version quickly, with instrumentation probes if Dynamic PGO is enabled.
  3. Execution and counting. Every call to the method increments a counter. Loops also hit patchpoints, which count independently of the method-call counter.
  4. 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.
  5. 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:

C#
// 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:

Bash
dotnet build -c Release
DOTNET_JitDisasm="SumSquares" dotnet bin/Release/net10.0/App.dll

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

C#
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.
C#
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:

C#
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:

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

XML
<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_JitDisasmAssemblies restricts disassembly to methods from specific assemblies, useful when a method name is common.
  • DOTNET_JitDisasmSummary prints 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_JitStdOutFile redirects 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.

VariablePurposeDefault
DOTNET_TieredCompilationMaster switch for tiered compilation1 (enabled)
DOTNET_TC_QuickJitForLoopsQuick-JIT methods with loops instead of fully optimizing them up front1 where OSR is available
DOTNET_TieredPGOInstrument tier-0 code and feed the data to tier 1 (Dynamic PGO)1 (enabled)
DOTNET_ReadyToRunUse precompiled ReadyToRun code where present1 (enabled)
DOTNET_JitDisasmPrint JIT-generated assembly for methods matching a patternunset
DOTNET_JitDisasmSummaryPrint a one-line summary for every JIT-compiled methodunset
DOTNET_JitStdOutFileRedirect JIT diagnostic output to a fileunset

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 PublishReadyToRun for 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_JitDisasm against 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_JitDisasm output 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#

StrategyStartup costSteady-state throughputNeeds a JIT at runtime?Typical use
Tiered JIT (default)Low; tier 0 runs almost immediatelyHigh, once hot methods reach tier 1YesMost apps: web services, background workers, desktop apps
DOTNET_TieredCompilation=0High; every method fully optimized up frontHigh from the first callYesDiagnosing tiering-related bugs, not production
PublishReadyToRun (per-assembly or composite)Lower; framework and app start from precompiled codeHigh, same tier-1 ceiling as the defaultYes, for methods that still change tierSelf-contained deployments sensitive to cold start
Native AOTLowest; no JIT warm-up at allHigh immediately, fixed at publish timeNoCLIs, 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_QuickJitForLoops default 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_JitDisasmSummary and the Disasmo extension let you read exactly what the JIT produced, tier by tier.

Further Reading#