Understanding Go Interface Devirtualization and its Performance Impact
The real cost of an interface call
Every interface method call in Go is an indirect call. An interface value is two machine words: a pointer to an itab – the table binding the interface’s methods to the concrete type’s method set – and a pointer to the data. To dispatch v.M(), the runtime loads v.tab, indexes the method slot, and jumps through the resulting function pointer. That chain costs three things:
- A dependent load that can stall the pipeline when the
itabis not cached. - A call target the branch predictor has no pattern for.
- An opaque callee, which means the compiler cannot inline the method into the caller.
The third one is the real killer. Inlining is the gateway to everything else – constant propagation, dead-code elimination, escape analysis. Once an interface call appears, the function boundary becomes a wall that none of those optimizations can cross.
Measured on a recent x86 box for a three-line method:
| call shape | ns/op |
|---|---|
| direct call | 1.5 |
| interface dispatch | 3.3 |
| devirtualized + inlined | 0.8 |
That is a 2x hit from the dispatch alone; the larger 4x gap appears when inlining lets the compiler optimize the surrounding loop. None of this matters in a request handler doing one or two interface calls in a millisecond of work. It matters when the call sits in a per-message, per-packet, or per-row loop running tens of millions of iterations a second.
How the compiler devirtualizes
Since Go 1.17 the compiler has a dedicated devirtualization pass (cmd/compile/internal/opt/devirtualize) that runs during inlining. If it can prove the dynamic type of an interface value at a call site, it rewrites v.M(...) into (*T).M(v.data, ...) – a direct call – and then inlines it like any other call.
The provable cases are the boring ones:
- the interface value was constructed from a concrete type in the same inlined chain,
- a type assertion narrowed the value before the call,
- a concrete value was assigned to an interface parameter inside an inlined function.
The pass is deliberately conservative: it bails out the moment analysis fails, because a wrong devirtualization changes program behavior.
A worked example
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main
type Node struct {
b64 []byte
}
type Visitor interface {
Visit(Node) error
}
type renderer struct {
buf []byte
}
func (r *renderer) Visit(n Node) error {
r.buf = append(r.buf, n.b64...)
return nil
}
func render(v Visitor, nodes []Node) error {
for _, n := range nodes {
if err := v.Visit(n); err != nil {
return err
}
}
return nil
}
func main() {
var r renderer
_ = render(&r, make([]Node, 1024))
}
Because render is called with a *renderer, it is inlined into main, and then the compiler can prove v.Visit is (*renderer).Visit. go build -gcflags=-m=2 confirms it:
1
2
./main.go:23:18: devirtualizing renderer.Visit to *main.renderer.Visit
./main.go:23:18: inlining call to main.(*renderer).Visit
That is the whole game: devirtualize, then inline, then let register allocation and escape analysis finish the job. In this loop the interface dispatch disappears entirely.
What blocks it
The classic ways to break devirtualization, all of which I have shipped by accident at some point:
- Type assertions and reflection in the hot loop.
v.(*renderer)is a runtime check; the compiler cannot assume it succeeds. - Passing the interface value through a function boundary that does not get inlined. Big functions,
//go:noinline, and closures defeat the pass. - Values that escape to the heap. If the interface crosses a call the compiler cannot see, all bets are off.
sync.Poolreturningany. Classic: you store a concrete type in a pool, get ananyback, and every method call is now a full interface dispatch. Fix by asserting once and working with the concrete type.
Production lessons
- Do not design interfaces for performance. Design them for seams – testing, plugins, alternate implementations. The compiler claws back the dispatch when it matters; your job is to keep the hot path concrete.
- Verify with
-gcflags=-m=2before and after a change. If a method you expect to be devirtualized is not, the compiler output tells you exactly why. - Measure the call count, not the nanoseconds. Use
pprofwith-call_treeand look for interface-heavy leaves, then decide whether the indirection is worth removing. - Prefer concrete return types.
func New() *Clientdevirtualizes at every call site;func New() Client(an interface) forces dispatch everywhere. - When you must use an interface in a hot loop, narrow to the concrete type once outside the loop instead of asserting per iteration.
In a load-balancer control plane we profiled last year, 8% of CPU was interface dispatch on a per-connection Writer. Narrowing the value once per connection and calling the concrete method cut P99 tail latency about 11% with zero architectural change. The interfaces stayed; only the hot loop went concrete.