Devirtualization

Object-oriented code is built on a small act of misdirection. When you write shape.area(), the compiler usually cannot say which area will run — a Circle's? a Square's? — because shape's real type is decided at run time. So it emits an indirect call: load the object's method table (its vtable), fetch the right slot, and jump through the pointer. Flexible, but expensive — and, far worse than the jump itself, an indirect call is a wall the optimiser cannot see past. It cannot inline an unknown target, cannot propagate constants into it, cannot reason about its side effects. Devirtualization is the optimisation that tears the wall down: prove (or bet) which method the call really invokes, and replace the indirect call with a direct, inlinable one.

This is why it matters so much for OOP performance. A modern JIT or ahead-of-time optimiser lives and dies by inlining, and a virtual call blocks inlining cold. Turn one virtual call into a direct call and you often unlock a cascade — inline the callee, then constant-fold, then eliminate dead branches inside it. Devirtualization is frequently the first domino.

The cost of an indirect call

A virtual call is not one instruction but a little dance: load the vtable pointer from the object, load the method address from a fixed slot, then an indirect call through that address.

; shape.area() — virtual vt = load [shape] ; the object's vtable pointer fn = load [vt + 8] ; slot for area() call *fn ; INDIRECT: target unknown to the optimiser

Two extra loads, plus an indirect branch the CPU's predictor may miss — but the real damage is what does not happen: call *fn is opaque, so inlining stops here, and every optimisation that would have flowed through the callee is blocked. Compare the prize:

; shape.area() — devirtualized to Circle::area, then inlined area = 3.14159 * shape.r * shape.r ; no call at all — the body is right here

Proving the target: class-hierarchy analysis

The cheapest way to devirtualize is to look at the class hierarchy. If the compiler can see the whole program's set of classes, it knows every type that could possibly be behind a reference of static type Shape — namely Shape and all its subclasses. Class-hierarchy analysis (CHA) asks: among all those types, how many actually override area()? If the answer is exactly one, the call is monomorphic — there is only one possible target — and the compiler can rewrite the indirect call as a direct call with no guard at all.

In the hierarchy above, a call to s.area() where s could be any Shape sees three overriding implementations — polymorphic, so plain CHA cannot devirtualize it. But a call through a reference statically typed Rectangle sees only Square as a further subclass; if Square does not override area(), the call is monomorphic and becomes a direct call to Rectangle::area.

Sharper still: type and points-to information

CHA reasons about declared types; points-to and type-flow analysis reason about what a reference can actually hold. Even when a class has many subclasses, the specific variable at a call site might only ever be assigned a Circle — a fact pointer analysis can discover by tracking allocations through the program. If the points-to set of the receiver is the single type Circle, the call is monomorphic in practice and can be devirtualized even though Shape has many subclasses. This kind of whole-program reasoning is exactly what interprocedural analysis provides, and it feeds a virtuous circle: knowing the target lets you build call-graph edges, which sharpens further analysis.

When you can't prove it: speculative devirtualization

Often the compiler cannot prove monomorphism — a new subclass might be loaded later (Java, C#), or profile data merely shows one type is usually the receiver. Then it speculates: guess the likely type, guard the guess with a cheap check, take a direct (inlinable) fast path when the check passes, and fall back to the original indirect call when it fails.

; speculative devirtualization of shape.area(), betting on Circle if typeof(shape) == Circle: area = 3.14159 * shape.r * shape.r ; FAST PATH: direct + inlined else: vt = load [shape]; fn = load [vt+8] ; SLOW PATH: original indirect call area = call *fn

The guard costs one comparison and a well-predicted branch; in exchange the common case is fully inlined and open to every downstream optimisation. This is the workhorse of JIT compilers — they collect actual receiver types at run time, inline the hot one behind a guard, and rely on deoptimization to bail out to the interpreter if the guard ever fails (say, because a new subclass finally shows up). The bet is that the guard almost never fails, so the fast path almost always runs.

CHA-based devirtualization, in code

Here is class-hierarchy analysis making the call. Given a hierarchy and which classes override a method, we compute, for a call on a given static type, the set of possible targets; if it is a singleton, we devirtualize.

type ClassInfo = { name: string; parent: string | null; overrides: boolean }; // Hierarchy: Shape -> {Circle, Rectangle -> {Square}}. Who overrides area()? const classes: Record<string, ClassInfo> = { Shape: { name: "Shape", parent: null, overrides: false }, Circle: { name: "Circle", parent: "Shape", overrides: true }, Rectangle: { name: "Rectangle", parent: "Shape", overrides: true }, Square: { name: "Square", parent: "Rectangle", overrides: false }, // inherits Rectangle::area }; const subtypesOf = (t: string): string[] => { const out = [t]; for (const c of Object.values(classes)) { let p = c.parent; while (p) { if (p === t) { out.push(c.name); break; } p = classes[p].parent; } } return out; }; // The concrete implementation a type uses = nearest ancestor (incl. self) that overrides. const implFor = (t: string): string => { let c: string | null = t; while (c) { if (classes[c].overrides) return c; c = classes[c].parent; } return "Shape"; // the base definition }; function devirtualize(staticType: string): void { const targets = new Set(subtypesOf(staticType).map(implFor)); if (targets.size === 1) { console.log(`call on ${staticType}: MONOMORPHIC -> direct call to ${[...targets][0]}::area (inline!)`); } else { console.log(`call on ${staticType}: polymorphic (${[...targets].join(", ")}) -> keep indirect / speculate`); } } devirtualize("Shape"); // Circle, Rectangle both possible -> polymorphic devirtualize("Rectangle"); // Rectangle and Square both use Rectangle::area -> monomorphic! devirtualize("Circle"); // only Circle -> monomorphic

The result is exactly the hierarchy's story: a call on Shape is polymorphic and stays indirect, but a call on Rectangle is monomorphic — both Rectangle and its subclass Square resolve to Rectangle::area — so CHA rewrites it as a direct, inlinable call. One walk of the class tree unlocks the inliner.

This is the deep tension in languages like Java: CHA's "only one override exists" can be true today and false a millisecond later when the class loader pulls in a new subclass. The answer is speculative optimisation backed by deoptimization. The JIT devirtualizes and inlines on the current hierarchy, but records an assumption ("no class overriding area below Rectangle exists"). If a class that violates the assumption is ever loaded, the runtime invalidates the compiled code — it throws the optimised version away and deoptimizes back to a safe interpreted or recompiled version. So the compiler gets to bet on a closed world for speed while the language keeps its open world for flexibility. HotSpot calls these "class-hierarchy dependencies," and they are checked on every class load. The optimisation is aggressive precisely because there is a safety net under it.

Speculative devirtualization is a bet, and bets can lose. The type guard adds a comparison and a branch on every call, and it duplicates code (a fast path and a slow path). If the guessed type is wrong most of the time, you pay the guard and still take the indirect call — strictly worse than leaving the call alone, plus the code bloat that can push hot instructions out of the instruction cache. This is why serious speculation is profile-guided: only bet on a type the profiler shows genuinely dominates. And beware megamorphic sites — call sites that see many types (a generic container's compare, say). Guarding one type there is pointless; the mispredict rate is high and the right move is often to leave it virtual, or use an inline cache with a few entries rather than a single guarded target. Devirtualize where the receiver is predictable, not everywhere.