What is devirtualisation, and why does -O alone not remove a class method’s vtable lookup?

5.2.1 · L5 · Interview

Predict first

With -O but no whole-module flag, what does the call use?

class C { func f() -> Int { 1 } }
@inline(never) func call(_ x: C) -> Int { x.f() }
print(call(C()))
  • class_method
  • a direct call

Predict first

And with -O -wmo?

class C { func f() -> Int { 1 } }
@inline(never) func call(_ x: C) -> Int { x.f() }
print(call(C()))
  • class_method
  • a direct call

Mental model. To skip the receptionist and dial the extension directly, you must be sure nobody has reassigned it. final is a signed promise, and whole-module knowledge is checking every desk yourself.

Devirtualisation replaces an indirect call (a vtable class_method or a witness-table lookup) with a direct one, which can then be inlined. To do it the compiler must prove which implementation will run. For a class method that means proving no subclass overrides it. A final class or method is a proof by declaration. Otherwise the compiler needs to see every class in the program and find that nothing overrides it.

That is why -O on its own often leaves class_method in place: a per-file build cannot rule out a subclass in another file. With whole-module optimisation (-wmo) the compiler sees all files, and for an internal, private or fileprivate class it can conclude there is none, and call the method directly. A public or open class cannot be proven closed, because other modules may subclass it. Mark things final when you mean it; it is the one proof that always works.

A per-file build cannot prove that no subclass in another file overrides f, so the vtable lookup stays.

With every file visible, the compiler sees that no class overrides f and calls it directly.

Common traps

  • Assuming -O devirtualises everything it could. Without whole-module visibility it must stay conservative.
  • Leaving library classes public but never subclassed. Other modules might, so the compiler cannot assume otherwise. Mark them final.

They might follow up with

  • Does devirtualisation need final on each method? final class covers all members; final func covers one.
  • What about protocol witness calls? The equivalent is specialisation, which pins the concrete type.

Related questions

  • 5.2.2 What does whole-module optimisation change, and what does it cost?
  • 5.1.4 How do you read SIL to tell static dispatch from dynamic dispatch?
  • 1.11.2 Why does final (and whole-module optimization) help performance?

What does whole-module optimisation change, and what does it cost? →