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.
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:
// 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; 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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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:
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
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
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.
-
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.
-
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?
-
Senior to staff
A search service compares a query embedding of 1,536 floats with 200,000 stored ones by cosine similarity, one float at a time. How would you speed it up with SIMD in .NET, and how do you keep the results right?
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
- Runtime configuration options for compilation (Microsoft Learn)
- Tiered compilation (design notes) (.NET runtime repository)
- Viewing JIT dumps (JitDisasmSummary and JitStdOutFile) (.NET runtime repository)
- What's new in .NET 8 runtime: dynamic PGO (Microsoft Learn)
- What's new in .NET 10 runtime: JIT compiler improvements (Microsoft Learn)
- What's new in .NET 7: on-stack replacement (Microsoft Learn)
- ReadyToRun deployment overview (Microsoft Learn)
- Native AOT deployment overview (Microsoft Learn)
- Use SIMD and hardware intrinsics in .NET (Microsoft Learn)
- Vector<T> struct (Microsoft Learn)
- TensorPrimitives class (Microsoft Learn)
- System.Numerics.Tensors 10.0.12 package (NuGet Gallery)
- MethodImplOptions enum (AggressiveOptimization) (Microsoft Learn)
- IEEE 754-2019: Standard for Floating-Point Arithmetic (IEEE)
Report a problem with this lesson
Kept only in this browser. Your Learn progress