Devirtualization
Devirtualization is the compiler optimization that turns an indirect call — a call through an interface or function value, where the target is decided at runtime — into a direct call to a known concrete function. Go’s
gccompiler does this in thecmd/compile/internal/devirtualizemiddle-end pass, in two flavors: static devirtualization, which fires when the compiler can prove the concrete type behind an interface variable, and profile-guided devirtualization, which uses a PGO profile to emit a conditional fast path to the hottest observed concrete callee with a fallback to the original indirect call (devirtualize.go). The payoff is rarely the cost of the indirect jump itself — it is that a direct call is a known call, so it can then be inlined, and once inlined, escape analysis and bounds-check elimination see through it.
Mental Model
An interface call in Go is a two-step indirection. An interface value is a two-word pair: a pointer to an itab (interface table — type descriptor plus method pointers) and a pointer to the data. Calling r.Read(b) means: load the method slot from the itab, then CALL that address. The CPU cannot predict that target as well as a direct call, and — far more importantly — the compiler cannot see what code runs there, so the call is an optimization firewall (the same firewall described in Function Inlining).
Devirtualization removes the firewall by answering the question “what concrete type is really in this interface?” two different ways:
flowchart TD A["call r.Read(b)<br/>r has interface type io.Reader"] --> B{Can the compiler<br/>prove the concrete type?} B -->|"Yes — local var only ever<br/>assigned *os.File"| C["Static devirtualization<br/>rewrite to (*os.File).Read"] B -->|"No — but a PGO profile<br/>says *os.File is hottest"| D["PGO devirtualization:<br/>emit type test + fast path"] B -->|"No, and no profile"| E[Leave as indirect call] C --> F[Direct call -> eligible for inlining] D --> G["if f, ok := r.(*os.File); ok { f.Read(b) }<br/>else { r.Read(b) }"] G --> F F --> H[Escape analysis / BCE see through it]
Figure: the two devirtualization paths. Insight: static devirtualization is unconditional and free (no runtime test) but only fires when the type is provable; PGO devirtualization always works but inserts a runtime type check, paying a small test to win the inlinable fast path on the common case.
Mechanical Walk-through
Static devirtualization
The static pass walks the typed IR after inlining and looks at every OCALLINTER node — an interface method call. For each, the concreteType() helper tries to determine the single concrete type that the interface variable can hold (devirtualize.go).
concreteType() works by tracing assignments. It follows OCONVIFACE nodes (the IR node for “convert concrete value to interface”) and type assertions, and it examines every assignment to a local variable (ir.PAUTO storage class — auto, i.e. stack-local). It tracks all assignments to the variable: if the variable is assigned values of two different concrete types, devirtualization is unsafe and concreteType() returns nil. There is also a sentinel noType returned for self-assignment cycles (e.g. x = x reachable through a loop), which the caller treats as “give up”.
When concreteType() succeeds, StaticCall performs the rewrite:
- It first rejects call sites inside
goanddeferstatements. The reason is subtle and documented in the source: devirtualization inlines the type-assertion expression (which can panic) into the call site, and that would move a potential panic from where the call executes to thego/deferstatement itself — a semantic change. - It verifies the concrete type actually implements the interface with
typecheck.Implements(). - It constructs a
TypeAssertExprconverting the interface value to the concrete type, retrieves the concrete method viaXDotMethod(), and rewrites the node fromOCALLINTERtoOCALLMETH(a direct method call) — or keeps itOCALLINTERwhen the method is a promoted method from an embedded interface.
Because the static pass runs after the inliner, and because devirtualizing into a direct call makes a previously-uninlinable call inlinable, there is a feedback loop: the pass’s State.InlinedCall() method updates its assignment tracking when a function gets inlined, rewriting placeholder assignments to point at the inlined call’s ReturnVars. This can reveal a concrete type that was hidden behind a call boundary, enabling devirtualization that was impossible before inlining.
PGO-driven devirtualization
When the compiler cannot prove the type, but a default.pgo profile is present, the PGO path takes over. The profile records, for each indirect call site, which concrete callees were observed and how often. For a sufficiently hot site dominated by one callee, the pass rewrites:
// before
r.Read(b)
// after (conceptually)
if f, ok := r.(*os.File); ok {
f.Read(b) // direct — now inlinable
} else {
r.Read(b) // unchanged fallback
}The type assertion r.(*os.File) is a cheap pointer comparison against the *os.File itab. On the hot path it succeeds, and f.Read(b) is a direct call the inliner can then expand. On the cold path the original indirect call runs unchanged, so correctness is preserved for every concrete type — the optimization is purely additive (go.dev/blog/pgo). PGO devirtualization has been generally available since Go 1.21, the release that made PGO production-ready and first devirtualized “some interface method calls, adding a concrete call to the most common callee” (go.dev/doc/go1.21).
How the hottest callee is chosen
There is a recurring misconception that PGO devirtualization uses a percentage cutoff analogous to the inliner’s inlineCDFHotCallSiteThresholdPercent (the 99%-of-cumulative-weight rule from Function Inlining). It does not. Reading cmd/compile/internal/devirtualize/pgo.go at the go1.26.0 tag, the entry point ProfileGuided() walks each indirect call site and, via findHotConcreteInterfaceCallee() / findHotConcreteFunctionCallee(), selects the single hottest observed callee by a straightforward weight comparison — if e.Weight > hottest.Weight (with a lexicographic tie-break on the callee name for determinism). There is no fractional CDF threshold gating which sites get devirtualized in this pass; the gate is instead shouldPGODevirt(), which checks that the candidate concrete callee is itself inlinable — because devirtualizing to a callee that cannot then be inlined buys almost nothing. So the real selection rule is “pick the dominant callee from the profile, but only bother if it can be inlined afterward,” not a percentile.
Interface calls and function-value calls
The same pass handles two node kinds, not one. maybeDevirtualizeInterfaceCall() (gated PGODevirtualize >= 1) rewrites interface method calls (OCALLINTER) using the itab-comparison type assertion shown above. maybeDevirtualizeFunctionCall() (gated PGODevirtualize >= 2) rewrites function-value indirect calls (OCALLFUNC through a func variable): instead of an itab assertion it emits a function-pointer comparison against the hot callee’s FuncPCABIInternal address, with the same fast-path/fallback shape. Function-value PGO devirtualization landed in the Go 1.22 cycle (CL 539699); the Go 1.22 notes record that “PGO builds can now devirtualize a higher proportion of calls than previously possible” and that the compiler now “interleaves devirtualization and inlining” (go.dev/doc/go1.22).
Uncertain
Verify: that function-value PGO devirtualization still excludes closures (function values that carry closure context) as of Go 1.26 — the code comments and open issue golang/go#64675 state CL 539699 was “limited to callees that are standard functions” because passing a closure context requires extra SSA/IR work, and #64675 was still open when checked on 2026-05-28. Reason: the exclusion is described in an open tracking issue, not in a release note, so it could be lifted in a point release without an obvious announcement. To resolve: re-read
findHotConcreteFunctionCallee()incmd/compile/internal/devirtualize/pgo.goat the current tag and check whether #64675 has closed. uncertain
Reading -gcflags=-m
Devirtualization decisions surface in -m output, often as a follow-on to inlining:
go build -gcflags='-m' ./...A static devirtualization typically prints a line like (this exact message format verified on go1.26.1 with a local Reader/*File example):
./main.go:11:16: devirtualizing r.Read to *File
./main.go:11:16: inlining call to (*File).Read
The first line is the devirtualize pass announcing it converted the interface call; the second is the inliner immediately taking advantage of the now-direct call. PGO devirtualization prints a similar line noting the profile-driven concrete callee. If you see devirtualizing not followed by inlining call to, the concrete method was still too “hairy” to fit the inlining budget — devirtualization succeeded but the secondary win did not materialize.
To confirm a call is not devirtualized, look for the absence of any devirtualizing line at that source position; the call remains a plain interface dispatch.
Failure Modes and Common Misunderstandings
- “Devirtualization removes the interface.” No. The interface value still exists; only the call is specialized. For PGO devirtualization the interface dispatch is still present as the fallback branch.
- “It fires for any single-implementer interface.” Static devirtualization needs the concrete type provable at the call site by local assignment tracking — not “this interface has only one implementer in the whole program”. Go’s separate compilation means the compiler does not have whole-program knowledge of all implementers when compiling a package. An interface satisfied by exactly one type, but received as a function parameter, is not statically devirtualizable, because the parameter’s concrete type is unknown to
concreteType(). - Multiple assigned types kill it. If a local interface variable is assigned a
*os.Fileon one branch and a*bytes.Bufferon another,concreteType()returnsniland no static devirtualization happens — even if both implement the interface. go/defercall sites are excluded. A devirtualizable interface call insidego r.Read(b)is deliberately left alone to avoid relocating panics.- PGO devirtualization needs a representative profile. A profile collected from a workload where a different concrete type dominates will devirtualize to the wrong callee — still correct (the fallback handles it) but with no speedup, and a wasted type test on the hot path.
- “PGO devirtualization only handles interfaces.” No — since the Go 1.22 cycle it also specializes indirect function-value calls (
OCALLFUNC), comparing the stored function pointer rather than an itab. The notable gap is that closures (function values carrying captured state) are excluded: per open issue golang/go#64675, passing a closure context through a static call needs extra SSA/IR support that has not landed. So a hotf()wherefis a plain top-level function can be devirtualized; a hotf()wherefcloses over variables generally cannot.
Alternatives and When to Choose Them
- Generics (Generics and Type Parameters). A generic function specialized for a concrete type argument has no interface dispatch at all — the type is a compile-time parameter. Where the abstraction can be expressed with a type parameter instead of an interface, generics give devirtualization-by-construction. The trade-off is Go’s GC-shape stenciling: distinct pointer-shaped types share a single instantiation that still dispatches through a dictionary, so generics are not unconditionally faster than a devirtualized interface call.
- Concrete types in signatures. The simplest “devirtualization” is to not use an interface where a concrete type suffices. Accept
*bytes.Bufferrather thanio.Writerwhen the function genuinely only ever handles that one type. - Manual type switch. Hand-writing the
if f, ok := r.(*os.File); okfast path is exactly what PGO devirtualization automates. Doing it by hand is justified only when you know the dominant type statically and cannot run PGO.
Production Notes
The PGO blog’s markdown-server case study (go.dev/blog/pgo) shows devirtualization and inlining working together: a hot io-style interface call was devirtualized to its concrete *os.File (or equivalent) implementation, then inlined, then escape analysis stack-allocated a value that previously escaped — a chain where devirtualization is the first domino. This is the general pattern: devirtualization’s measured benefit is mostly indirect, realized through the inlining and escape-analysis it unlocks, not the indirect-jump cost it removes. The downstream win is a stack-versus-heap flip: once the call boundary is gone, escape analysis can prove confinement it could not see through the indirect dispatch.
A common surprise in profiles: after enabling PGO, an indirect call site shows both a direct call and the original indirect call in the disassembly. That is not a bug — it is the fast-path/fallback pair that PGO devirtualization emits by design.
See Also
- Interface Internals — the two-word interface value and
itabthat devirtualization specializes - Function Inlining — the optimization devirtualization most often unlocks
- Profile-Guided Optimization — source of the profile that drives PGO devirtualization
- Escape Analysis — frequently improved once a devirtualized call is inlined
- Stack vs Heap Allocation — the downstream placement flip a devirtualize→inline→escape chain produces
- Generics and Type Parameters — the alternative to interface dispatch
- Go Compiler Architecture — where the devirtualize pass sits
- Compiler Optimization Diagnostics — reading
-gcflags=-m - Go Internals MOC — parent map