Interface maps, virtual stub dispatch (indirection cells, dispatch and resolve stubs), default interface methods, constrained calls on structs.
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.
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.
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.
Each interface call site calls through an indirection cell that initially points to a lookup stub:
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.
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]
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.)
| 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:
new in the same method).if (obj.MT == StripeGateway_MT) { inlined call } else { stub call } — very effective because most DI-injected interfaces have exactly one implementation per call site.void Process<T>(T x) where T : IShippable with a struct T compiles to a direct call (constrained call), no stubs, no boxing.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%.
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.
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.
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.
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).
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).
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();
}
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.
What happens: one loop dispatching across many implementations. Why it is wrong: resolve-stub overhead, no inlining. Do instead: group by type, generic specialisation.
What happens: IComparer, IEquatable or custom interfaces receiving structs.
Why it is wrong: boxing on every call.
Do instead: constrained generics.
What happens: treating costs as identical. Why it is wrong: stubs add indirection; polymorphic sites cost more. Do instead: understand VSD behaviour.
What happens: implementations left open. Why it is wrong: fewer devirtualisation opportunities. Do instead: seal.
What happens: measuring before stubs and PGO settle. Why it is wrong: includes resolution and Tier 0 costs. Do instead: warm up.
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.
What happens: repeated is IFoo checks in loops.
Why it is wrong: interface map lookups.
Do instead: check once, store the typed reference.
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.
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.
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.
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>.
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%].
Last updated 31 Oct 2026.