📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is Dynamic PGO in .NET, and how does guarded devirtualisation make interface and virtual calls faster?

Profile-guided optimisation at run time: instrumentation, guarded devirtualisation, hot/cold layout and how traffic shape affects it.

Senior Theory Expert clr devirtualisation jit performance pgo

🎯 Key points

  • Dynamic PGO collects profile data in-process and is on by default since .NET 8
  • Hot methods run an instrumented tier before Tier 1 uses the profile
  • Guarded devirtualisation checks for the dominant type, inlines it and keeps a virtual-call fallback
  • Profiles also drive hot/cold layout, inlining and delegate devirtualisation
  • Static PGO is baked into ReadyToRun images; Dynamic PGO adapts to your workload
  • NativeAOT has no Dynamic PGO; sealed types help devirtualisation everywhere

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. How dynamic PGO works

  1. A method starts at Tier 0 (or from ReadyToRun code).
  2. When it becomes hot, the runtime compiles an instrumented version that records:
    • block counts (branch frequencies, loop trip counts),
    • class profiles at virtual and interface call sites (which MethodTables are seen),
    • method profiles at delegate call sites,
    • value profiles for some operations (e.g. sizes for memmove/copy in newer versions).
  3. After enough executions, the method is recompiled at Tier 1 using that data.

All of this happens automatically at run time, on the real workload — no training runs needed.

2. Guarded devirtualisation

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:

  • No indirect call in the common case (better branch prediction, no stub dispatch).
  • Inlining of the target, which unlocks further optimisations (constant folding, removing allocations, loop optimisations).
  • Works for virtual calls, interface calls and delegate calls ("delegate GDV"), which is why LINQ predicates and callbacks got faster in .NET 8.
  • Can guard multiple likely types (when a site sees two dominant types).

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.

3. Other PGO-driven optimisations

  • Hot/cold block layout: frequently executed code placed together; cold paths (error handling) moved out of the way.
  • Better inlining decisions based on actual call frequency.
  • Loop optimisations informed by trip counts.
  • Casting and type-check optimisations using observed types.
  • Profile-driven register allocation improvements.

4. Static vs dynamic PGO

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.

5. Observing and controlling it

  • Default: TieredPGO=true (.NET 8+). Disable with <TieredPGO>false</TieredPGO> or DOTNET_TieredPGO=0 to compare.
  • See it in disassembly: DOTNET_JitDisasm="Type:Method" shows guarded checks (cmp ..., MethodTable) and inlined bodies in Tier 1 code.
  • Benchmarking: BenchmarkDotNet runs enough iterations for PGO; compare with PGO off to measure impact.
  • DOTNET_ReadyToRun=0 can be used to force everything through tiering for experiments.

6. Design implications

  • Interfaces with one dominant implementation per call site cost little in steady state.
  • Megamorphic sites (many types at one call site, e.g. generic visitor/dispatch loops) don't benefit; consider restructuring hot loops to be monomorphic (process items grouped by type) or using sealed types/generics.
  • Sealing classes still helps: the JIT can devirtualise without guards, even before PGO kicks in.
  • PGO behaviour depends on the workload; a test environment with different implementations may show different performance.

7. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Instrumentation details

Instrumented Tier 0 code includes:

  • Edge/block counters: incremented on branch edges (with some sparse instrumentation to reduce overhead).
  • Class probes: at each virtual/interface call, record the receiver's MethodTable into a small reservoir sample (handles multiple types).
  • Delegate probes: record the target method of delegate invocations.

The overhead is temporary (only while instrumented) and limited to hot methods.

2. GDV codegen

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.

3. Delegate GDV

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.

4. Interaction with R2R

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.

5. Limits

  • Profiles are per process and per method; no persistence across restarts (static PGO covers that for the BCL).
  • Rare paths aren't optimised specially.
  • Changing workloads after Tier 1 compilation don't trigger recompilation (no deoptimisation in CoreCLR), so a guard that becomes wrong just falls back to normal dispatch.

Try it yourself

  1. Benchmark an interface call loop with PGO on and off.
  2. Inspect Tier 1 disassembly for a monomorphic and a megamorphic call site.
  3. Benchmark a LINQ Where with a lambda predicate on .NET 6 vs .NET 8.
  4. Seal the implementation class and compare code before PGO kicks in.

💻 Code

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

⚠ Common mistakes

1. Avoiding interfaces purely for performance

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.

2. Benchmarking without enough iterations

What happens: short benchmarks measure pre-PGO code. Why it is wrong: misses PGO effects. Do instead: use BenchmarkDotNet's warm-up and iterations.

3. Disabling PGO to "stabilise" performance

What happens: TieredPGO=0 globally. Why it is wrong: loses significant steady-state gains. Do instead: keep it on unless measurements show a problem.

4. Ignoring megamorphic call sites

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.

5. Assuming AOT gets the same optimisation

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.

6. Testing with unrepresentative implementations

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.

7. Not sealing classes

What happens: relying entirely on PGO for devirtualisation. Why it is wrong: sealed types devirtualise immediately without guards. Do instead: seal by default.

8. Expecting PGO to fix allocation problems

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions the instrumented tier Why: Checks how PGO fits into tiered compilation
    How to answer

    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.

  2. When: Candidate says sealed classes help devirtualisation Why: Probes when to seal classes and why
    How to answer

    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.

  3. When: Candidate says they would inspect the Tier 1 code Why: Checks they can read JIT output
    How to answer

    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.

🛠 Real-world problems: how you handle them

Rules engine got faster after a .NET upgrade with no code change
Situation
A pricing rules engine evaluated [millions of rules per minute] through an `IRule` interface.
Signals
After moving from .NET 6 to .NET 8, throughput rose by [about N%]; the team wanted to know why before trusting it.
Investigation
Compared JIT disassembly of the evaluation loop on both versions and found GDV checks for the dominant rule type in .NET 8 Tier 1 code.
Resolution
Documented the cause, sealed the rule classes to help the JIT further, and kept PGO enabled.
Prevention
Added a long-running BenchmarkDotNet test so future upgrades are compared at steady state.

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.

↩ Usually asked after

More in CLR Fundamentals

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me