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

📚 Interview question

How does virtual method dispatch work in the CLR, and what does a virtual call cost compared with a direct call?

Vtable slots in the MethodTable, the instruction sequence of a virtual call, override versus new, sealed and devirtualisation.

Senior Theory Advanced clr devirtualisation performance virtual-dispatch vtable

🎯 Key points

  • Virtual methods occupy vtable slots in the MethodTable; override replaces a slot, new adds one
  • A virtual call loads the MethodTable, the slot and makes an indirect call
  • The main cost is lost inlining, not the call instructions
  • sealed types or methods and exact known types allow devirtualisation
  • Dynamic PGO adds guarded devirtualisation for dominant types
  • C# emits callvirt for non-virtual calls to get a null check
  • Seal classes not designed for inheritance (CA1852)

⏱ 30-second answer

Each MethodTable has vtable slots for its virtual methods. A derived class copies its parent's slots and replaces the ones it overrides; new methods get a new slot instead. A virtual call loads the object's MethodTable pointer, loads the slot (through a chunk indirection) and makes an indirect call: a few dependent loads and an indirect branch. That is cheap, but it prevents inlining, which is usually the bigger cost. The JIT turns virtual calls into direct calls when it knows the exact type: sealed classes or methods, a just-created object, or guarded devirtualisation from Dynamic PGO.

🗣 Full answer (senior)

How I would answer this in an interview

A virtual call chooses the method implementation at run time from the object's actual type. In the CLR, that's done through the vtable in the object's MethodTable: load the MethodTable pointer, load the slot for the method, and make an indirect call. The dispatch itself costs only a couple of memory loads and an indirect branch — typically around a nanosecond — but the bigger cost is that virtual calls usually can't be inlined, which blocks further optimisation. The JIT recovers much of this with devirtualisation.

1. The mechanism

public abstract class Discount { public abstract decimal Apply(decimal amount); }
public sealed class Percentage(decimal p) : Discount { public override decimal Apply(decimal a) => a * (1 - p); }

Discount d = GetDiscount();
var result = d.Apply(100m);   // virtual call

Roughly, on x64:

mov  rax, [rcx]            ; rcx = d; load MethodTable*
mov  rax, [rax + 0x40]     ; load vtable chunk pointer
call [rax + 0x20]          ; call through the Apply slot
  • Each type's vtable has slots for all its virtual methods: inherited slots first (in the same positions as the base), then new virtuals.
  • An override replaces the base's slot pointer in the derived type's vtable; new (hiding) creates a new slot.
  • Slot index is fixed at JIT time, so the call is just indexed indirection.
  • C# emits callvirt for all instance calls (it also performs the null check), but non-virtual methods are called directly.

2. What it costs vs a direct call

Aspect Direct (non-virtual) call Virtual call
Instructions call target 2 dependent loads + indirect call
Branch prediction Always predictable Indirect branch — predicted well if the target is consistent, mispredictions cost ~15–20 cycles
Inlining Possible Not possible unless devirtualised
Cache Code only MethodTable/vtable cache lines too
Typical cost (monomorphic, hot) ~0–1 ns (often inlined to nothing) ~1–2 ns, plus lost optimisation

The lost inlining matters most: a small inlined method can be folded into the caller's loop, enabling constant propagation, bounds-check removal and vectorisation. A virtual call is an optimisation barrier.

In typical business code (I/O-bound), virtual call overhead is irrelevant. In tight CPU-bound loops over millions of elements, it can matter.

3. Devirtualisation

The JIT turns a virtual call into a direct (inlinable) call when it can prove or guess the target:

  • Sealed class or sealed override → exact target known.
  • Exact type known: the object was created with new in the same method, or returned from an inlined method.
  • Generic specialisation: value types in generic code (constrained calls).
  • static readonly fields at Tier 1: the JIT knows the object's actual type.
  • Guarded devirtualisation (dynamic PGO, default since .NET 8): profile says the site is usually Percentage → emit if (d.MethodTable == Percentage_MT) { inlined body } else { virtual call }.

4. Design implications

  • Seal classes by default (CA1852) — free devirtualisation.
  • Keep hot loops monomorphic where possible (process homogeneous collections).
  • For extreme hot paths, consider generics with struct implementations (zero-cost abstraction).
  • Don't avoid virtual methods in normal code for performance — design clarity matters more; measure before optimising.

5. A production example

A risk calculation iterated 50 million positions calling position.Instrument.Price(scenario) — a virtual method across 6 instrument types, mixed randomly in the list. Profiling showed time spent in indirect calls and no inlining. Sorting positions by instrument type (making each segment of the loop monomorphic so PGO's guard succeeded) and sealing the instrument classes cut the run from ~11 to ~7 minutes. We didn't remove the polymorphic design — just made the hot loop friendlier to the JIT.

🔍 What the interviewer is checking

The interviewer checks you understand how polymorphism is implemented and its real cost.

Green flags: vtable slots in the MethodTable, override versus new, the load sequence, cost is mainly lost inlining, devirtualisation conditions, sealing.

Red flags: thinks virtual calls are very slow; confuses new and override; unaware of devirtualisation.

📚 Learn in depth

1. Vtable details in CoreCLR

  • Vtables are split into chunks of 8 slots; derived types share chunks with parents when no slots in that chunk are overridden, saving memory.
  • The MethodTable contains indirection pointers to these chunks, hence the double load.
  • Non-virtual instance methods also have slots (for the runtime's bookkeeping), but calls to them are direct.
  • Slot assignment happens at type load time; overrides are matched by name and signature (or explicit .override).

2. Virtual calls during construction

The object's MethodTable is the most-derived type from allocation, so virtual calls in base constructors dispatch to derived overrides — before derived constructors run. A correctness trap, not a performance one.

3. callvirt on non-virtual methods

C# uses callvirt for instance methods to get a null check. The JIT emits a direct call plus a null check (often folded into the first memory access). So callvirt in IL doesn't imply vtable dispatch.

4. Guarded devirtualisation details

  • Requires a class profile at the call site (instrumented tier).
  • If the dominant type covers a high percentage of calls, the JIT emits the guard; multiple guards for the top types in recent versions.
  • The fallback path keeps correctness for other types.
  • Guards can be hoisted out of loops when the receiver doesn't change.

5. Measuring

[Benchmark] public decimal Sealed() { decimal s = 0; foreach (var d in _sealedArr) s += d.Apply(1m); return s; }
[Benchmark] public decimal Mixed()  { decimal s = 0; foreach (var d in _mixedArr)  s += d.Apply(1m); return s; }

Compare monomorphic, bimorphic, megamorphic arrays; with TieredPGO on/off; inspect disassembly for guards.

Try it yourself

  1. Inspect disassembly for a virtual call on a sealed vs unsealed type.
  2. Benchmark monomorphic vs megamorphic arrays.
  3. Turn PGO off and compare.
  4. Use dumpmt -md in SOS to view a type's vtable slots.

💻 Code

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<DispatchBench>();

public abstract class Shape { public abstract double Area(); }
public class OpenCircle(double r) : Shape { public override double Area() => Math.PI * r * r; }
public sealed class SealedCircle(double r) : Shape { public override double Area() => Math.PI * r * r; }

[DisassemblyDiagnoser(maxDepth: 2)]
public class DispatchBench
{
    private readonly Shape[] _asBase = Enumerable.Range(1, 1000).Select(i => (Shape)new OpenCircle(i)).ToArray();
    private readonly SealedCircle[] _sealed = Enumerable.Range(1, 1000).Select(i => new SealedCircle(i)).ToArray();

    [Benchmark(Baseline = true)]
    public double SumBase()
    {
        double s = 0;
        foreach (Shape x in _asBase) s += x.Area();   // virtual call (PGO may add a guard)
        return s;
    }

    [Benchmark]
    public double SumSealed()
    {
        double s = 0;
        foreach (SealedCircle x in _sealed) s += x.Area();   // exact type known: direct call, inlined
        return s;
    }
}

⚠ Common mistakes

1. Equating callvirt with virtual dispatch

What happens: assuming all instance calls use the vtable. Why it is wrong: callvirt is also used for null checks on non-virtual methods. Do instead: check whether the method is virtual.

2. Removing polymorphism for imagined performance

What happens: replacing virtual methods with switch statements everywhere. Why it is wrong: worse design; minimal gain in non-hot code. Do instead: measure; optimise only hot loops.

3. Not sealing classes

What happens: leaving leaf classes unsealed. Why it is wrong: misses cheap devirtualisation. Do instead: seal by default.

4. Megamorphic hot loops

What happens: iterating mixed types in performance-critical loops. Why it is wrong: guards fail; no inlining. Do instead: group by type or use generics.

5. Ignoring inlining loss

What happens: measuring only call overhead. Why it is wrong: the main cost is lost optimisation. Do instead: consider what inlining would enable.

6. Virtual calls in constructors

What happens: base constructors call virtual methods. Why it is wrong: overrides run on partially initialised objects. Do instead: avoid (CA2214).

7. Benchmarking without PGO warm-up

What happens: short benchmarks. Why it is wrong: guarded devirtualisation not yet applied. Do instead: proper warm-up with BenchmarkDotNet.

8. Using new (hiding) to avoid virtual cost

What happens: hiding methods instead of overriding. Why it is wrong: changes semantics; calls through base use the base method. Do instead: keep correct semantics; optimise elsewhere.

🏛 Senior insight: trade-offs

Seal by default: it documents intent and helps the JIT.

Choice Design effect Performance effect
sealed class Prevents unplanned inheritance Devirtualisation and inlining
Virtual method Extension point Indirect call, inlining blocked unless devirtualised
Interface Decoupling Interface dispatch (slightly more work)
Generic struct constraint Static polymorphism Specialised direct calls

The .NET team seals internal classes by default, and the analyser CA1852 suggests sealing types that are never derived from.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate contrasts virtual and interface calls Why: Checks interface dispatch mechanics
    How to answer

    A class can implement many interfaces, so the interface method's slot number is not fixed in every implementing type's vtable. CoreCLR has used virtual stub dispatch: each call site calls through an indirection cell that first points to a lookup stub; after the first call it becomes a dispatch stub that checks "is this object's MethodTable the one I saw?" and jumps straight to the cached target, and if call sites see many types it switches to a resolve stub backed by a hash cache. NativeAOT uses a similar cached interface dispatch. Monomorphic interface calls are nearly as fast as virtual calls; megamorphic ones cost more. On structs, calling through the interface boxes, but a generic constrained call does not.

  2. When: Candidate mentions PGO devirtualisation Why: Checks guarded devirtualisation depth
    How to 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.

  3. When: Candidate recommends sealing classes Why: Probes design reasons for sealed
    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.

🛠 Real-world problems: how you handle them

Sealing hot shape classes enabled inlining in a geometry engine
Situation
A CAD export engine summed areas and perimeters of [millions of shapes] per export.
Signals
Profiler showed small virtual methods as hot frames, each with call overhead.
Investigation
Disassembly showed indirect calls in the loops even though lists were typed to concrete shapes.
Resolution
Sealed leaf shape classes, letting the JIT devirtualise and inline the calls.
Prevention
Enabled CA1852 as a warning to seal classes by default.

How to tell it (STAR)

In [our CAD exporter], tiny virtual methods dominated the profile. Our lists were already typed to concrete shapes, so I sealed the leaf classes and the JIT inlined the calls. Export time dropped [X%] and we turned on CA1852.

↩ Usually asked after

More in Runtime Execution

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me