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

📚 Interview question

How does interface method dispatch work in the CLR, and why can it be slower than virtual dispatch?

Interface maps, virtual stub dispatch (indirection cells, dispatch and resolve stubs), default interface methods, constrained calls on structs.

Senior Theory Expert clr interface-dispatch performance virtual-stub-dispatch

🎯 Key points

  • Interface methods do not have a fixed vtable slot across implementing types
  • CoreCLR uses per-call-site stubs: lookup, monomorphic dispatch stub, then resolve stub with a hash cache
  • Monomorphic interface calls are close to virtual-call cost; megamorphic sites cost more
  • Calling an interface method on a struct through the interface boxes it
  • Generic constrained calls on structs are direct and inlinable
  • Dynamic PGO can devirtualise dominant interface implementations
  • Default interface methods are resolved via the interface map at dispatch

⏱ 30-second 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.

🗣 Full answer (senior)

How I would answer this in an interview

With a virtual call, the method's slot index is the same in every derived type, so dispatch is a fixed vtable lookup. With an interface call, that's impossible: a class can implement many interfaces in any order, and unrelated classes implement the same interface at different slot positions. CoreCLR therefore uses virtual stub dispatch (VSD): each interface call site goes through a small stub that caches the target for the types it sees. Monomorphic sites end up almost as fast as virtual calls; polymorphic/megamorphic sites need hash-table lookups and are slower.

1. Why interfaces can't use fixed slots

class A : IShippable, IAuditable { ... }   // IShippable methods might be slots 4-5
class B : IAuditable, IShippable { ... }   // here they might be slots 6-7
class C : Base, IShippable { ... }          // different again

The caller only knows "call IShippable.Ship on this object" — it doesn't know where Ship lives in that object's vtable. The runtime must map (object's type, interface method) → implementation, using the type's interface map.

2. Virtual stub dispatch

Each interface call site calls through an indirection cell that initially points to a lookup stub:

  1. Lookup stub (first call): calls into the runtime to resolve the target for the object's type (walking the interface map), then patches the cell.

  2. Dispatch stub (monomorphic): compares the object's MethodTable with the cached type; if equal, jumps directly to the cached target; else falls to the resolve path.

    mov  rax, [rcx]                  ; object's MethodTable
    cmp  rax, [cached_MT]
    jne  RESOLVE
    jmp  [cached_target]
    
  3. Resolve stub (polymorphic): if a site keeps missing (several types), the cell is backpatched to a resolve stub that looks up (MT, interface method) in a global hash cache, falling back to the runtime on a miss.

Stubs are shared per interface method and the cache adapts over time. (NativeAOT uses a similar "cached interface dispatch" approach with per-call-site caches.)

3. Why it can be slower than virtual dispatch

Virtual call Interface call (monomorphic) Interface call (megamorphic)
Steps Load MT, load slot, indirect call Load MT, compare with cache, indirect jump Load MT, hash lookup in resolve cache, indirect jump
Extra indirection No Stub jump Stub + hash computation
First call Slot already populated Runtime resolution Runtime resolution per new type
Relative cost Baseline ~Same or slightly more Noticeably slower (several ns)

Plus the shared cost with virtual calls: no inlining unless devirtualised.

Additional costs:

  • Structs via interfaces: calling an interface method on a struct through an interface reference requires boxing.
  • Default interface methods add resolution complexity (most specific implementation).
  • Generic interfaces with variance can need extra checks during casting.

4. How the JIT avoids the cost

  • Devirtualisation when the concrete type is known (sealed classes, new in the same method).
  • Guarded devirtualisation via dynamic PGO: if (obj.MT == StripeGateway_MT) { inlined call } else { stub call } — very effective because most DI-injected interfaces have exactly one implementation per call site.
  • Generic constraints with value types: void Process<T>(T x) where T : IShippable with a struct T compiles to a direct call (constrained call), no stubs, no boxing.

5. Practical guidance

  • For normal application code (DI services, repositories), interface dispatch overhead is negligible — and PGO removes most of it.
  • For hot inner loops over interface-typed elements:
    • make sites monomorphic (group by type),
    • seal implementations,
    • use generic methods constrained to the interface with struct implementations,
    • or restructure to operate on concrete types/spans.
  • Avoid passing structs as interfaces in hot paths (boxing).

6. A production example

A telemetry pipeline processed millions of events per second through IEnumerable<IEventEnricher> with ~15 enricher implementations per event, invoked at one call site — megamorphic interface dispatch in the hottest loop. Profiles showed resolve-stub overhead and no inlining. We changed the pipeline to a generic EnricherPipeline<TEnricher1, TEnricher2, ...>-style composition for the fixed core enrichers (struct implementations, constrained calls), keeping the interface-based extension point for rare custom enrichers. Throughput improved ~35%.

🔍 What the interviewer is checking

The interviewer checks advanced runtime knowledge and measured judgement.

Green flags: why slots differ per type, stub-based caching (monomorphic versus megamorphic), boxing of structs, constrained calls, PGO help, measured trade-offs.

Red flags: claims interfaces are always slow; unaware of boxing through interfaces; recommends removing interfaces wholesale.

📚 Learn in depth

1. Interface maps and resolution

Each MethodTable has an interface map: list of implemented interfaces (with their MethodTables). For each interface method, the runtime finds the implementing method via the type's method impl table (explicit implementations), name/signature matching, or default interface methods. Results are cached in the dispatch caches.

2. Stub types in CoreCLR

  • Lookup stub: entry for unresolved sites.
  • Dispatch stub: monomorphic check + jump; created per (interface method, expected MT).
  • Resolve stub: generic resolver with a global cache (hash of MT and token).
  • Back-patching thresholds control when a site transitions from dispatch to resolve (after repeated misses).
  • Newer runtime versions experiment with cached interface dispatch (per-cell small caches holding several MT/target pairs), already used in NativeAOT, reducing reliance on global hash lookups.

3. Casting to interfaces

obj is IFoo checks the interface map (with a cast cache for repeated checks). Variance (IEnumerable<string> as IEnumerable<object>) requires more complex checks. Casting cost is separate from dispatch cost but often appears together.

4. DIM dispatch

If the class doesn't implement a default interface method, the runtime resolves the most specific default implementation. Calling a DIM on a struct through an interface reference involves boxing; through constrained generic calls, the runtime may still need to box to call the default implementation (since it operates on this as the interface).

5. Measuring

Benchmark monomorphic vs polymorphic interface calls; observe with DOTNET_JitDisasm whether PGO guards appear; use perf/PerfView to see time in stubs (ResolveWorker, VSD_ResolveWorker).

Try it yourself

  1. Benchmark an interface call loop with 1, 2 and 10 implementations in the array.
  2. Compare with a generic struct-constrained version.
  3. Inspect disassembly for guarded devirtualisation with PGO on.
  4. Pass a struct as an interface and measure allocations.

💻 Code

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<InterfaceBench>();

public interface IShape { double Area(); }
public sealed class A : IShape { public double Area() => 1; }
public sealed class B : IShape { public double Area() => 2; }
public sealed class C : IShape { public double Area() => 3; }
public sealed class D : IShape { public double Area() => 4; }
public readonly struct Unit : IShape { public double Area() => 1; }

[MemoryDiagnoser]
public class InterfaceBench
{
    private readonly IShape[] _mono = Enumerable.Range(0, 1000).Select(_ => (IShape)new A()).ToArray();
    private readonly IShape[] _mega = Enumerable.Range(0, 1000).Select(i => (IShape)(i % 4 switch
        { 0 => new A(), 1 => new B(), 2 => new C(), _ => new D() })).ToArray();

    [Benchmark(Baseline = true)] public double Monomorphic() => Sum(_mono);
    [Benchmark] public double Megamorphic() => Sum(_mega);

    [Benchmark]
    public double BoxedStruct()
    {
        double s = 0;
        for (int i = 0; i < 1000; i++) { IShape boxed = new Unit(); s += boxed.Area(); }  // allocates a box each time
        return s;
    }

    [Benchmark]
    public double ConstrainedStruct()
    {
        double s = 0;
        for (int i = 0; i < 1000; i++) s += AreaOf(new Unit());  // constrained call: no box, inlined
        return s;
    }

    private static double Sum(IShape[] items)
    {
        double s = 0;
        foreach (IShape x in items) s += x.Area();
        return s;
    }

    private static double AreaOf<T>(T shape) where T : IShape => shape.Area();
}

⚠ Common mistakes

1. Avoiding interfaces in all code for performance

What happens: dropping DI interfaces to save nanoseconds. Why it is wrong: negligible cost in I/O-bound code; PGO mitigates. Do instead: optimise only measured hot paths.

2. Megamorphic hot call sites

What happens: one loop dispatching across many implementations. Why it is wrong: resolve-stub overhead, no inlining. Do instead: group by type, generic specialisation.

3. Structs through interfaces

What happens: IComparer, IEquatable or custom interfaces receiving structs. Why it is wrong: boxing on every call. Do instead: constrained generics.

4. Assuming interface calls equal virtual calls

What happens: treating costs as identical. Why it is wrong: stubs add indirection; polymorphic sites cost more. Do instead: understand VSD behaviour.

5. Not sealing implementations

What happens: implementations left open. Why it is wrong: fewer devirtualisation opportunities. Do instead: seal.

6. Benchmarking only the first calls

What happens: measuring before stubs and PGO settle. Why it is wrong: includes resolution and Tier 0 costs. Do instead: warm up.

7. Overusing default interface methods in hot paths

What happens: DIMs used as primary implementation in tight loops. Why it is wrong: extra resolution and potential boxing. Do instead: implement members on the class for hot paths.

8. Ignoring the cost of casting

What happens: repeated is IFoo checks in loops. Why it is wrong: interface map lookups. Do instead: check once, store the typed reference.

🏛 Senior insight: trade-offs

Interfaces are the right design tool in most code; the performance concern is narrow.

Hot-path pattern Better alternative
One call site over many handler types Group by type or use typed delegates
Struct passed as interface Generic with constraint
`IEnumerable<T>` parameter in tight loop Span or `IReadOnlyList<T>`
Interface needed only for testing Keep it; JIT and PGO handle monomorphic sites

A senior answer refuses to remove useful abstractions without profiler evidence.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says virtual slots are fixed per hierarchy Why: Checks virtual dispatch basics
    How to 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.

  2. When: Candidate mentions default or static abstract interface members Why: Probes modern interface features
    How to answer

    An interface is a contract: a set of members a type promises to provide, with no instance state. A class or struct can implement many interfaces. Since C# 8 an interface can carry default implementations (used only through the interface, and they need .NET Core 3.0+), plus static, private and protected members. Since C# 11 it can declare static abstract / static virtual members, which power generic math (INumber<T>, T.Zero). Explicit implementation (void IDisposable.Dispose()) hides a member unless you cast. Calls through an interface use interface dispatch, which the JIT can often devirtualise. Casting a struct to an interface boxes it; a generic constraint (where T : IComparable<T>) avoids the box.

  3. When: Candidate says structs box through interfaces Why: Checks boxing cost awareness
    How to answer

    Boxing wraps a value type in a new heap object so it can be used as object or an interface; unboxing checks the object's type and copies the value back, and it must be the exact type or you get InvalidCastException. Every box is a heap allocation, so in hot paths it creates GC pressure. Hidden boxing comes from non-generic APIs like ArrayList and params object[], passing structs as interfaces, calling GetType() or non-overridden Equals/GetHashCode on structs, and enumerating a List<T> through IEnumerable<T>. I find it with BenchmarkDotNet's MemoryDiagnoser or an allocation profiler and fix it with generics, constraints and IEquatable<T>.

🛠 Real-world problems: how you handle them

Megamorphic handler call site in a message router
Situation
A message router dispatched [about 60] message types through one `IHandler.Handle` call site.
Signals
CPU profiles showed dispatch helper frames and resolve-stub overhead on the routing path.
Investigation
BenchmarkDotNet reproduced the gap between a monomorphic and megamorphic call site.
Resolution
Registered typed delegates per message type in a frozen dictionary so each handler's call site was monomorphic.
Prevention
Kept a routing benchmark in CI and documented the pattern for new handlers.

How to tell it (STAR)

Our [message router] sent [about 60] types through one interface call. The profile showed dispatch overhead. I switched to typed delegates per message type, making each call site monomorphic, which reduced routing CPU by [X%].

↩ Usually asked after

More in Runtime Execution

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me