When can the Go compiler turn an interface method call into a direct, inlinable call?
answer
- two ways: proof, or prediction
- the concrete type visible at the call site
- inlining can reveal it
- a profile can predict the dominant type
- prediction needs a fallback path
basics
~20 sWhen it can prove which concrete type the interface holds, usually because the value was built nearby or became visible after inlining. A profile-guided build can also guard a hot call site with a type check and call the dominant type directly.
solid answer
~50 sThere are two paths. Static devirtualisation: if the compiler can see the concrete type stored into the interface at that call site — a value constructed a few lines above, or one that becomes visible once an enclosing function is inlined — it replaces the indirect call with a direct one, which then becomes a candidate for inlining. Profile-guided devirtualisation: given a representative CPU profile, the compiler identifies hot call sites where one concrete receiver type dominates, and emits a type comparison plus a direct call to that type's method, falling back to the ordinary indirect call when the type differs. That guarded direct call can be inlined, which is where the actual win comes from — not from the jump. What Go deliberately does not do is whole-program analysis (another package or a test can add an implementation) or run-time inline caches, so a call site the compiler cannot reason about stays indirect.
go deeper
Know that a call through an interface is normally decided at run time, and that the compiler removes that indirection only when it can tell which concrete type is involved.
Explain static devirtualisation from a locally visible concrete type, and that inlining an enclosing function can expose a type that was previously hidden.
Describe the guarded form a profile-guided build emits, why it needs a fallback, and that the payoff is the inlining it enables rather than the branch it removes.
Weigh an optimisation whose input ages: decide when a team leans on it to keep a seam intact, and what it means to depend on a build input that must stay representative.
## Devirtualisation, defined *Devirtualisation* is replacing a call whose target is decided at run time with a call to a specific function decided at compile time. In Go that means turning a call through an interface value into a call to a named method of a named concrete type. The payoff is rarely the jump itself; it is that a direct call can be **inlined**, and once the body is inlined the compiler can propagate constants through it, eliminate bounds checks, keep values in registers and — often the biggest effect — perform real escape analysis on the arguments instead of assuming they are retained. ## Path one: static devirtualisation The compiler devirtualises when it can prove the dynamic type at the call site from the code in front of it. The classic case is local: ```go var r Resizer = boxResizer{quality: 90} return r.Resize(dst, src, 64, 64) // the compiler knows this is boxResizer.Resize ``` This matters more than the toy example suggests, because inlining feeds it. When a small constructor or wrapper is inlined into its caller, a concrete type that used to be hidden behind a function boundary can become visible at the call site, and a call that was indirect one pass earlier becomes direct. What it will *not* do is reason about the program as a whole. Even if exactly one type in your repository implements an interface, the compiler works a package at a time: another package, or a test binary, may add an implementation later. Techniques that rely on seeing every subtype in the program are not available to it. ## Path two: profile-guided devirtualisation When the build is given a representative CPU profile, the compiler gets information it cannot derive statically: which call sites are hot, and which concrete receiver types actually flow through them. If one type dominates a hot interface call site, the compiler can emit a **guarded** direct call — conceptually: ``` if the receiver's dynamic type is boxResizer { call boxResizer.Resize directly // and this can now be inlined } else { ordinary indirect call } ``` Correctness never depends on the guess. A receiver of a different type simply takes the fallback path. That is what makes the technique safe to apply from a profile that may be out of date: a stale prediction costs a comparison and a lost inline, never a wrong answer. ## What the guard buys, and what it costs The win is the inlined body on the dominant path, with all the downstream optimisation that unlocks. The costs are a type comparison per call, some code growth at the site, and a build whose output depends on an input that ages. If the mix of concrete types at that site shifts — a new implementation takes over, a feature flag flips the default — the prediction is simply wrong more often and you drift back towards the un-optimised behaviour. ## How this changes what you write It does not change much, and that is the point. You still keep interfaces where they buy substitutability, and you still keep the inner loop over concrete types where you measured that it matters. Devirtualisation is a reason not to panic about a seam that a profile shows is dominated by one implementation, not a licence to route everything through interfaces and hope. When you want to know whether it happened, the compiler's optimisation output — the same `-gcflags=-m` inlining and escape decisions you would read for any hot function — is the place to check that the callee is now being inlined at the site you care about.
- What happens at that call site when the profile is stale and a different type arrives?Nothing breaks. The generated code compares the receiver's dynamic type: on a match it takes the direct, inlined path; otherwise it falls through to the ordinary indirect call. A stale profile costs a failed comparison and the lost inline at that site, never a wrong result — which is exactly why guarding by type is safe.
- Why doesn't the compiler devirtualise when only one type in the whole program implements the interface?Because it compiles a package at a time and does not perform whole-program analysis. Another package, or the test binary that imports yours, can add an implementation without recompiling the code that holds the call site, so assuming the single known implementation would be unsound.
- Where does the real speedup come from once a call is devirtualised?From inlining, not from the jump. A direct call becomes a candidate for inlining, and an inlined body lets the compiler fold constants, drop bounds checks and run genuine escape analysis on the arguments — which is often what removes a per-call heap allocation. The indirect branch itself was rarely the expensive part.
saying these in an interview costs you the question
- Thinks Go builds run-time inline caches per call site
- Assumes a single implementation is enough to devirtualise
- Believes a wrong profile prediction can produce wrong behaviour
- Credits the speedup to the jump rather than to inlining
- Expects devirtualisation without either local proof or a profile