Profile-guided optimisation at run time: instrumentation, guarded devirtualisation, hot/cold layout and how traffic shape affects it.
Dynamic PGO (profile-guided optimisation), on by default since .NET 8, lets the JIT optimise using data gathered from your own process. Hot methods first run in an instrumented tier that records which concrete types reach virtual and interface call sites, which delegates are invoked and which branches are taken. Tier 1 then uses that profile: guarded devirtualisation inserts a cheap type check for the dominant type and inlines its method, falling back to a normal virtual call otherwise; it also lays out hot blocks together and makes better inlining decisions. It is enabled with <TieredPGO> (default true) and depends on tiered compilation.
Dynamic PGO (Profile-Guided Optimisation, on by default since .NET 8) lets the JIT observe how your code actually runs — which branches are taken, which concrete types appear at virtual/interface call sites, which delegates are invoked — and then recompile hot methods using that profile. Its flagship optimisation is guarded devirtualisation (GDV): if a call site almost always sees the same type, the JIT emits a cheap type check and a direct, inlinable call for that type, falling back to normal dispatch otherwise.
MethodTables are seen),All of this happens automatically at run time, on the real workload — no training runs needed.
public interface IShippingCalculator { decimal Cost(Order o); }
public sealed class StandardShipping : IShippingCalculator { public decimal Cost(Order o) => o.Weight * 2.5m; }
decimal Total(IShippingCalculator calc, Order o) => o.Subtotal + calc.Cost(o); // interface call
If the profile shows calc is a StandardShipping 99% of the time, Tier 1 code is roughly:
decimal cost = calc.GetType() == typeof(StandardShipping) // one pointer compare on the MethodTable
? o.Weight * 2.5m // inlined StandardShipping.Cost
: calc.Cost(o); // fallback: interface dispatch
Benefits:
Why it matters: typical .NET code is full of interface calls through DI abstractions (ILogger, repositories, strategies) where each call site usually sees exactly one implementation. GDV recovers most of the cost of those abstractions.
| Dynamic PGO | Static PGO | |
|---|---|---|
| Data source | Collected at run time on the real workload | Collected ahead of time (training run) into MIBC files |
| Used by | Tiered JIT | ReadyToRun/crossgen and NativeAOT |
| Default | On since .NET 8 | Used for the BCL itself (shipped profiles) |
| Adapts to workload changes | Yes (per process) | No |
NativeAOT can't do dynamic PGO (no JIT), so it relies on static profiles and whole-program analysis — one reason JIT-compiled apps can outperform AOT in steady state.
TieredPGO=true (.NET 8+). Disable with <TieredPGO>false</TieredPGO> or DOTNET_TieredPGO=0 to compare.DOTNET_JitDisasm="Type:Method" shows guarded checks (cmp ..., MethodTable) and inlined bodies in Tier 1 code.DOTNET_ReadyToRun=0 can be used to force everything through tiering for experiments.Upgrading an API from .NET 6 to .NET 8 improved throughput by ~15% without code changes. Profiling showed the gains concentrated in our hot request pipeline: interface calls to IPricingRule, IValidator<T> and LINQ delegates were now guarded-devirtualised and inlined. One endpoint didn't improve — its rule engine looped over 40 different rule types at a single call site (megamorphic). Grouping rules by type and processing each group in its own loop made those sites monomorphic, and PGO then optimised them too.
The interviewer checks advanced runtime knowledge and its practical effect on design and benchmarks.
Green flags: instrumented tier, GDV with fallback, default since .NET 8, static versus dynamic PGO, sealed classes, no PGO in NativeAOT.
Red flags: thinks PGO needs manual training; believes devirtualisation can change behaviour; unaware it depends on tiering.
Instrumented Tier 0 code includes:
The overhead is temporary (only while instrumented) and limited to hot methods.
mov rax, [rcx] ; MethodTable of receiver
cmp rax, StandardShipping_MT
jne FALLBACK
; inlined StandardShipping.Cost body
...
FALLBACK:
call [interface dispatch stub]
With multiple guarded types ("multiple GDV", added in later versions), the JIT can chain checks for the top two types. Guards can also be merged/hoisted out of loops when the receiver is loop-invariant.
For Func<T, bool> calls, the JIT compares the delegate's method pointer with the profiled target and inlines that target. LINQ's Where(x => x.IsActive) in a hot loop can thus have its predicate inlined.
R2R code isn't instrumented by default; hot R2R methods are recompiled at Tier 1. A setting allows R2R hot methods to go through an instrumented tier first (TieredPGO_InstrumentOnlyHotCode, TieredPGO_InstrumentedTierAlwaysOptimized options vary by version). Since .NET 8, defaults balance start-up and profiling.
Where with a lambda predicate on .NET 6 vs .NET 8.using System.Runtime.CompilerServices;
IShape[] shapes = new IShape[10_000];
for (int i = 0; i < shapes.Length; i++)
shapes[i] = i % 50 == 0 ? new Square(2) : new Circle(1); // Circle dominates
double total = 0;
for (int round = 0; round < 500; round++)
{
total += TotalArea(shapes);
if (round % 50 == 0) Thread.Sleep(10); // let tiering and PGO complete
}
Console.WriteLine(total);
[MethodImpl(MethodImplOptions.NoInlining)]
static double TotalArea(IShape[] items)
{
double sum = 0;
foreach (IShape s in items)
sum += s.Area(); // Tier 1 + PGO: guarded check for Circle, then inlined body
return sum;
}
interface IShape { double Area(); }
sealed class Circle(double r) : IShape { public double Area() => Math.PI * r * r; }
sealed class Square(double side) : IShape { public double Area() => side * side; }
DOTNET_JitDisasm="TotalArea" dotnet run -c Release
DOTNET_JitDisasm="TotalArea" DOTNET_TieredPGO=0 dotnet run -c Release
What happens: removing abstractions in non-hot code. Why it is wrong: PGO devirtualises monomorphic sites; design suffers for little gain. Do instead: keep good abstractions; optimise measured megamorphic hot paths.
What happens: short benchmarks measure pre-PGO code. Why it is wrong: misses PGO effects. Do instead: use BenchmarkDotNet's warm-up and iterations.
What happens: TieredPGO=0 globally.
Why it is wrong: loses significant steady-state gains.
Do instead: keep it on unless measurements show a problem.
What happens: hot loops dispatch over many types. Why it is wrong: GDV can't help. Do instead: group by type, use generics or sealed types.
What happens: expecting NativeAOT to match JIT steady-state performance. Why it is wrong: no dynamic PGO. Do instead: benchmark both modes for your workload.
What happens: performance tests use mocks or different implementations. Why it is wrong: PGO profiles differ from production. Do instead: test with production-like implementations and data.
What happens: relying entirely on PGO for devirtualisation. Why it is wrong: sealed types devirtualise immediately without guards. Do instead: seal by default.
What happens: hoping runtime optimisation removes allocations. Why it is wrong: PGO helps dispatch and layout; allocation design still matters. Do instead: reduce allocations explicitly.
Dynamic PGO is why many apps got faster on upgrade with no code change, and why benchmark numbers differ between short and long runs.
| Situation | What a senior does |
|---|---|
| Hot interface call with one main implementation | Rely on GDV; mark implementations sealed |
| Traffic mix changes after warm-up | Accept fallback cost or restructure the hot path |
| NativeAOT deployment | No Dynamic PGO; prefer sealed types and generic specialisation |
| Comparing .NET versions | Benchmark long enough to include PGO tiers |
The senior trade-off: PGO rewards monomorphic hot paths, so simple, predictable code often runs faster than clever polymorphism.
Tiered compilation (on by default since .NET Core 3.0) compiles a method first at Tier 0: fast to produce, barely optimised. The runtime counts calls; a method called about 30 times (after a short startup delay) is recompiled at Tier 1 with full optimisation on a background thread and the entry point is swapped. ReadyToRun code counts as a starting tier and is also promoted when hot. On-stack replacement (default on x64 and Arm64 since .NET 7) lets a long-running loop in a Tier 0 method jump into optimised code mid-execution. The result is fast startup and good steady-state performance, but benchmarks must warm up.
A sealed class cannot be inherited, and a sealed override cannot be overridden further. Sealing says "this type was not designed for extension", which protects its invariants. It also helps performance: the JIT can devirtualise and inline calls on sealed types, and type checks and casts to a sealed class are a single method-table comparison. Structs and static classes are already sealed. Analyser CA1852 suggests sealing internal types with no subtypes. The trade-off is that classic mocking libraries cannot mock sealed classes, which is a hint to depend on an interface at that seam. Unsealing later is non-breaking; sealing a public class later is breaking.
The JIT (RyuJIT) compiles IL to native code one method at a time, on first call. Each method initially points at a small stub; the first call enters the runtime, which JIT-compiles the IL, stores the code in the code heap, and patches the stub so later calls go straight to native code. The JIT also produces GC info and unwind info so the GC and exception handling work. With tiered compilation a method may be compiled more than once, first quickly then optimised. You can print the generated assembly with DOTNET_JitDisasm="MethodName", which works on release runtimes since .NET 7.
How to tell it (STAR)
When we upgraded [the rules engine] to .NET 8, throughput jumped [N%]. I used JitDisasm to show it came from guarded devirtualisation of our hottest IRule call. I sealed the implementations and wrote it up so the team understood the gain was real and why.
Last updated 30 Oct 2026.