Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C# & .NET (C# 14, .NET 10 LTS)  Module 17 – Memory, Span and performance

JIT, dynamic PGO, ReadyToRun and SIMD

Watch the .NET JIT compile a hot C# method three times, see what dynamic PGO, ReadyToRun and Native AOT change, and add up floats with SIMD.

  • Advanced
  • 20 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 7 of 20 minutes free
Free: The JIT compiles a hot method again, with what it learned · ReadyToRun and Native AOT compile before the app starts. With a pass: 2 in-depth parts and 3 interview answers, about 13 minutes.

What you will learn

  • Trace how tiered compilation and dynamic PGO take a hot method from quick code to optimized code
  • Choose between JIT compilation, ReadyToRun and Native AOT from start-up time, speed and limits
  • Measure the effect of dynamic PGO on interface calls with BenchmarkDotNet
  • Rewrite a numeric loop with Vector<T> or TensorPrimitives and check that its results still match

Before you start

What you’ll learn · Before you start

What you will learn

  • Trace how tiered compilation and dynamic PGO take a hot method from quick code to optimized code
  • Choose between JIT compilation, ReadyToRun and Native AOT from start-up time, speed and limits
  • Measure the effect of dynamic PGO on interface calls with BenchmarkDotNet
  • Rewrite a numeric loop with Vector<T> or TensorPrimitives and check that its results still match

Before you start

On this page

A one-line C# method, Price, was compiled three times in a program that ran for less than a second: into 76 bytes of quick code, into 108 bytes that recorded what it did, and into 60 bytes of optimized code. That is the normal life of a hot method in .NET, and it is why the first calls of a method are slow and why benchmarks warm up.

This lesson shows those compilations as the JIT reports them, what dynamic PGO adds, and what ReadyToRun and Native AOT change by compiling before the app starts. Then it turns to SIMD: Single instruction, multiple data: processor instructions that do one operation on several values at once, such as adding four floats held in one 128-bit register.: one instruction that adds or multiplies several numbers held side by side in a wide register. C# reaches SIMD through Vector<T> and the ready-made routines of TensorPrimitives.

The JIT compiles a hot method again, with what it learned

tiers/Program.cs adds up the prices of 20 batches of 10,000 items, with a short pause after each batch. The session runs the same build three times with DOTNET_JitDisasmSummary=1, which makes the JIT compiler: The just-in-time compiler of the .NET runtime: it compiles the IL of a method into machine code for the processor it runs on, just before the method first runs. More in “What C# and .NET are, and which version to use” list every method it compiles, with the tier and the size of the code:

tiers/Program.cs C# · tiers/Program.cs
// Twenty batches of 10,000 prices, with a short pause after each. Run it with DOTNET_JitDisasmSummary=1 to see every
// time the JIT compiles one of its methods, and at which tier.
long total = 0;
for (int batch = 0; batch < 20; batch++)
{
    total += Batch(10_000);
    Thread.Sleep(20); // quiet time, in which the runtime's background compiler can work
}
Console.WriteLine(total);

static long Batch(int count)
{
    long sum = 0;
    for (int i = 0; i < count; i++) sum += Price(i);
    return sum;
}

static int Price(int units) => units % 3 == 0 ? units * 2 : units + 7;
Every compilation of the program's methods, three ways Python · tiers_session.py
from session import run

# The same build of tiers/Program.cs run three times: with the runtime's defaults, with dynamic PGO switched off and
# with tiered compilation switched off. DOTNET_JitDisasmSummary=1 makes the JIT list every method it compiles, and
# DOTNET_JitStdOutFile sends that list to a file, which grep reads for the program's own methods.
run("""
cd tiers
dotnet build -c Release -o out -nologo -v q
DOTNET_JitDisasmSummary=1 DOTNET_JitStdOutFile=default.txt dotnet out/tiers.dll
grep Program: default.txt
DOTNET_TieredPGO=0 DOTNET_JitDisasmSummary=1 DOTNET_JitStdOutFile=nopgo.txt dotnet out/tiers.dll
grep Program: nopgo.txt
DOTNET_TieredCompilation=0 DOTNET_JitDisasmSummary=1 DOTNET_JitStdOutFile=fullopts.txt dotnet out/tiers.dll
grep Program: fullopts.txt
""")

Output

$ cd tiers
$ dotnet build -c Release -o out -nologo -v q

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed …
$ DOTNET_JitDisasmSummary=1 DOTNET_JitStdOutFile=default.txt dotnet out/tiers.dll
1334199900
$ grep Program: default.txt
   1: JIT compiled Program:<Main>$(System.String[]) [Instrumented Tier0, IL size=43, code size=224]
   2: JIT compiled Program:<<Main>$>g__Batch|0_0(int) [Instrumented Tier0, IL size=27, code size=188]
   3: JIT compiled Program:<<Main>$>g__Price|0_1(int) [Tier0, IL size=13, code size=76]
   4: JIT compiled Program:<<Main>$>g__Batch|0_0(int) [Tier1-OSR @0x15 with Synthesized PGO, IL size=27, code size=104]
   5: JIT compiled Program:<<Main>$>g__Price|0_1(int) [Instrumented Tier0, IL size=13, code size=108]
   6: JIT compiled Program:<<Main>$>g__Price|0_1(int) [Tier1 with Dynamic PGO, IL size=13, code size=60]
$ DOTNET_TieredPGO=0 DOTNET_JitDisasmSummary=1 DOTNET_JitStdOutFile=nopgo.txt dotnet out/tiers.dll
1334199900
$ grep Program: nopgo.txt
   1: JIT compiled Program:<Main>$(System.String[]) [Tier0, IL size=43, code size=192]
   2: JIT compiled Program:<<Main>$>g__Batch|0_0(int) [Tier0, IL size=27, code size=156]
   3: JIT compiled Program:<<Main>$>g__Price|0_1(int) [Tier0, IL size=13, code size=76]
   4: JIT compiled Program:<<Main>$>g__Batch|0_0(int) [Tier1-OSR @0x15, IL size=27, code size=108]
   5: JIT compiled Program:<<Main>$>g__Price|0_1(int) [Tier1, IL size=13, code size=60]
$ DOTNET_TieredCompilation=0 DOTNET_JitDisasmSummary=1 DOTNET_JitStdOutFile=fullopts.txt dotnet out/tiers.dll
1334199900
$ grep Program: fullopts.txt
   1: JIT compiled Program:<Main>$(System.String[]) [FullOpts, IL size=43, code size=116]
   2: JIT compiled Program:<<Main>$>g__Batch|0_0(int) [FullOpts, IL size=27, code size=104]

This output changes from run to run: when the background compiler finishes each method depends on how busy the computer is, so the order of the compilations of the first run can change from run to run; which compilations happen, and their sizes, do not

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tiers_session.py

This is tiered compilation: The runtime compiling a method more than once: first quickly, with few optimizations, then again with full optimizations on a background thread once the method has been called often enough., on by default since .NET Core 3.0. Price started as quick Tier0 code, because most methods run too few times to repay a slow, careful compilation; once the runtime saw it was hot, it compiled Price again on a background thread, and the program switched to the new code without stopping. Step through its life in the first run:

Interactive · stage by stage How the JIT compiled Price three times Step through one run of tiers/Program.cs with the runtime's defaults. Each step is one compilation the JIT reported, or what led to the next one.
How the JIT compiled Price three times, step 7 of 7. Showing IL of Price, Tier0, Called often, Instrumented Tier0, Tier1 with Dynamic PGO. In focus: Tier1 with Dynamic PGO.a hot loop: OSRIL of Price13 bytes in tiers.dllTier076 bytes, quick codeCalled oftenthe runtime counts callsInstrumented Tier0108 bytes, records a profileTier1 with Dynamic PGO60 bytes, optimized

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. IL only. tiers.dll holds Price as 13 bytes of IL, which no processor can run. The JIT compiles it into machine code just before its first call.
  2. First call: Tier0. The first call gets Tier0 code: 76 bytes, compiled quickly with few optimizations, because most methods run only a few times and quick code gets the program started sooner.
  3. Hot: counted. Price is called 10,000 times in each batch. The runtime counts the calls of hot Tier0 code, and once a method has been called often enough it queues it to be compiled again, on a background thread.
  4. Instrumented Tier0. With dynamic PGO, the next version is instrumented: 108 bytes, larger than Tier0, because it also records what the method does, such as which way its branch goes.
  5. Tier1 with Dynamic PGO. After more calls the JIT compiles Price a third time, with full optimizations and the recorded profile: 60 bytes, the smallest of the three. The run reported it as Tier1 with Dynamic PGO.
  6. A loop: on-stack replacement. Batch is called only 20 times, but its loop runs 10,000 passes in each call. Its first code, 188 bytes of instrumented Tier0, was replaced while the loop ran, at IL offset 0x15, with 104 bytes reported as Tier1-OSR.
  7. PGO off, tiering off. With DOTNET_TieredPGO=0 the instrumented step disappears: Tier0, then Tier1. With DOTNET_TieredCompilation=0 every method is compiled once with full optimizations, FullOpts, and Price is inlined into Batch.

The middle step is dynamic PGO: Dynamic profile-guided optimization: the runtime runs an instrumented version of a hot method that records which branches and types really occur, and the optimizing compiler uses that profile. On by default since .NET 8., profile-guided optimization, on by default since .NET 8: the instrumented version counts which branches are taken and which types arrive at each call, and the final compilation uses the counts. With DOTNET_TieredPGO=0 the second run skipped that step. Batch’s long loop could not wait for another call, so its code was replaced in the middle of the loop, on-stack replacement: Switching a method that is still running, inside a long loop, from its quick first code to optimized code without waiting for the next call. Such compilations show as Tier1-OSR.. With tiering off, each method was compiled once, FullOpts, and Price not at all: the JIT copied its body into Batch, which is inlining: Copying the body of a called method into the method that calls it, which removes the call and lets the compiler optimize the two together..

Checkpoint

Check: Tiered compilation and dynamic PGO

  1. Question 1 of 2 With the runtime's defaults, in which order did the JIT compile Price in tiers_session.py?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Tier0, then Instrumented Tier0, then Tier1 with Dynamic PGO

    A hot method starts with quick Tier0 code; with dynamic PGO, an instrumented version records what happens, and the final Tier1 compilation uses that profile.

  2. Question 2 of 2 Why did Price not appear at all in the run with DOTNET_TieredCompilation=0?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Batch was compiled once with full optimizations, and the JIT inlined Price into it, so Price was never compiled on its own

    Without tiering every method is compiled once, fully optimized, and small methods such as Price are inlined into their callers.

ReadyToRun and Native AOT compile before the app starts

Every Tier0 compilation costs time while the program starts. ReadyToRun: Ahead-of-time compilation at publish time that stores machine code beside the IL of an assembly, so the JIT has less to compile when the app starts. Hot methods can still be recompiled later. compiles the assemblies when you publish the app and keeps that machine code beside the IL, so the JIT has less to do at start-up. The session publishes the same program with -p:PublishReadyToRun=true and runs it with the same list switched on:

The same program published with ReadyToRun Python · r2r_session.py
from session import run

# network: true. Publishing with ReadyToRun needs the runtime pack and the crossgen2 compiler pack for osx-arm64, which
# dotnet publish downloads from nuget.org into the session's own, empty home folder. wc -c prints the sizes in bytes.
run("""
cd tiers
dotnet build -c Release -o out -nologo -v q
dotnet publish -c Release -r osx-arm64 --self-contained false -p:PublishReadyToRun=true -o r2r -nologo -v q
DOTNET_JitDisasmSummary=1 DOTNET_JitStdOutFile=r2r.txt dotnet r2r/tiers.dll
cat r2r.txt
wc -c out/tiers.dll r2r/tiers.dll
""", network=True)

Output

$ cd tiers
$ dotnet build -c Release -o out -nologo -v q

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed …
$ dotnet publish -c Release -r osx-arm64 --self-contained false -p:PublishReadyToRun=true -o r2r -nologo -v q
$ DOTNET_JitDisasmSummary=1 DOTNET_JitStdOutFile=r2r.txt dotnet r2r/tiers.dll
1334199900
$ cat r2r.txt
   1: JIT compiled System.RuntimeType+IGenericCacheEntry`1[System.__Canon]:CreateAndCache(System.RuntimeType) [Instrumented Tier0, IL size=165, code size=1152]
   2: JIT compiled System.Threading.Thread:GetThreadStaticsBase() [Tier0, IL size=18, code size=32]
$ wc -c out/tiers.dll r2r/tiers.dll
    5120 out/tiers.dll
    6144 r2r/tiers.dll
   11264 total

This output changes from run to run: which of the runtime's own methods need the JIT at start-up can change from run to run with the timing of the run, so the lines of r2r.txt can change; the program's own methods never appear in it

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 r2r_session.py

None of the program’s methods appear in r2r.txt: Main, Batch and Price ran as precompiled code, and the JIT compiled only two methods of the runtime’s own libraries. The assembly grew from 5,120 to 6,144 bytes, and the documentation warns of two to three times for real code, because it holds the IL as well as the machine code; it also notes that ReadyToRun code is less optimized than the JIT’s, so tiered compilation still replaces hot ReadyToRun methods. Native AOT: Ahead-of-time publishing: dotnet publish compiles a whole app into one native executable that needs no installed .NET runtime and no JIT, at the cost of run-time code generation and dynamic loading. More in “What C# and .NET are, and which version to use” goes further: one native executable, with no JIT at run time.

JIT (the default) ReadyToRun Native AOT
Compiled at run time, in tiers at publish, then re-tiered at publish only
Start-up slowest faster fastest
Steady speed best, with dynamic PGO the same once hot no dynamic PGO
Size IL only IL and machine code one native file
Gives up nothing needs a runtime identifier dynamic loading, Reflection.Emit

Choose by what you must not lose

Keep the default for long-running services, add ReadyToRun when start-up matters and code is loaded at run time, and choose Native AOT for tools and small services whose libraries all support it.

Checkpoint

Check: ReadyToRun and Native AOT

  1. Question 1 of 2 In the ReadyToRun run, none of the program's methods appeared in r2r.txt. What does that mean?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Main, Batch and Price ran as code compiled when the app was published, so the JIT had nothing of the program to compile

    ReadyToRun code takes the place of Tier0, so a short run of a small program may never call the JIT for its own methods; hot methods would still be recompiled at Tier1 in a longer run.

  2. Question 2 of 2 A command-line tool must start fast, and it loads plug-in assemblies from a folder at run time. Which publishing choice fits?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: ReadyToRun: faster start-up, and the JIT is still there for the assemblies loaded at run time

    Native AOT gives up dynamic loading and Reflection.Emit; ReadyToRun speeds start-up and keeps both.

Dynamic PGO measured: interface calls with one class and with three Pro: opens with a Pro, Premium or Ultimate pass

Dynamic PGO matters most where the code itself cannot say which method a call reaches: interface and virtual calls. This part measures one interface call with PGO on and off, with one class behind it and with three.

In this part:

  • A fare benchmark run twice, with and without the profile
  • What the profile let the JIT do
  • What PGO cannot do, and what .NET 10 added

3 runnable examples (C#, Python) · about 5 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

SIMD by hand and with TensorPrimitives, measured Pro: opens with a Pro, Premium or Ultimate pass

SIMD instructions do one operation on several values held side by side in one wide register, and .NET reaches them through Vector<T> and TensorPrimitives. This part shows how wide the registers are, measures a sum three ways, and leaves you a lab: cosine similarity, the measure behind embedding search.

In this part:

  • Four floats with one instruction
  • One sum, three ways, measured
  • Lab: cosine similarity, one and a half times as fast

4 runnable examples (C#, Python) · about 7 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Practise

New questions on tiering and the three ways to compile, the idea you know least first; a missed question returns at the end:

Practice that adapts to you

Practise this lesson

4 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 4 Why does the JIT compile a method quickly, with few optimizations, the first time it is called?

    Choose one answer.

    Show the answer: Question 1 of 4

    Answer: Most methods run only a few times, so quick code gets the program started sooner and only hot methods pay for full optimization

    Tier0 trades code quality for compile speed, and tiered compilation recompiles only the methods that turn out to be hot.

  2. Question 2 of 4 Batch was called only 20 times but was reported as Tier1-OSR. What happened?

    Choose one answer.

    Show the answer: Question 2 of 4

    Answer: Its loop ran 10,000 passes in each call, so the runtime replaced its code in the middle of the loop, without waiting for another call

    A long loop inside one call would otherwise stay in quick code for its whole run; on-stack replacement swaps in optimized code while it runs.

  3. Question 3 of 4 Why is a ReadyToRun assembly larger than the same assembly built normally?

    Choose one answer.

    Show the answer: Question 3 of 4

    Answer: It holds the machine code as well as the IL, which is still needed

    ReadyToRun keeps the IL, for code that must be compiled again or cannot be precompiled, and adds native code beside it.

  4. Question 4 of 4 Which app gains the most from Native AOT?

    Choose one answer.

    Show the answer: Question 4 of 4

    Answer: A small command-line tool that runs for a moment many times a day and uses no runtime code generation

    Native AOT suits short-lived processes and small services whose libraries all support it; long-running services usually keep the JIT.

Interview questions

Fresher · What is tiered compilation in .NET?

The runtime may compile a method more than once. Its first call gets quick Tier0 code, with few optimizations, so the program starts fast; when the method turns out to be hot, or stuck in a long loop, it is compiled again on a background thread with full optimizations, Tier1, and the program switches to the new code. Since .NET 8 an instrumented step first records a profile for the Tier1 compilation, which is dynamic PGO. DOTNET_TieredCompilation=0 switches tiering off, and every method is then compiled once, fully optimized.

  1. Mid to senior

    After each deployment, the first requests to a .NET service are several times slower than later ones, for about a minute. Explain why, and what you can change to shorten that period.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A refactoring put an interface in front of a hot pricing method, and a benchmark shows it no slower with the runtime's defaults. A colleague says an interface call always costs a virtual dispatch. Who is right, and when would the cost come back?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A hot method is compiled more than once: quick Tier0 code first, then optimized Tier1 code on a background thread; a long loop is switched to optimized code while it runs.
  • Dynamic PGO, on by default since .NET 8, adds an instrumented step whose profile the final compilation uses.
  • ReadyToRun precompiles assemblies so the JIT has less to do at start-up; Native AOT removes the JIT altogether and gives up dynamic loading, Reflection.Emit and dynamic PGO.

Terms in this lesson

dynamic PGO
Dynamic profile-guided optimization: the runtime runs an instrumented version of a hot method that records which branches and types really occur, and the optimizing compiler uses that profile. On by default since .NET 8.
guarded devirtualization
Turning an interface or virtual call into a type check and a direct, often inlined, call to the type the profile saw most, with the ordinary call kept for any other type.
inlining
Copying the body of a called method into the method that calls it, which removes the call and lets the compiler optimize the two together.
on-stack replacement
Switching a method that is still running, inside a long loop, from its quick first code to optimized code without waiting for the next call. Such compilations show as Tier1-OSR.
ReadyToRun
Ahead-of-time compilation at publish time that stores machine code beside the IL of an assembly, so the JIT has less to compile when the app starts. Hot methods can still be recompiled later.
SIMD
Single instruction, multiple data: processor instructions that do one operation on several values at once, such as adding four floats held in one 128-bit register.
tiered compilation
The runtime compiling a method more than once: first quickly, with few optimizations, then again with full optimizations on a background thread once the method has been called often enough.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.