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 gc compiler does this in the cmd/compile/internal/devirtualize middle-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:

  1. It first rejects call sites inside go and defer statements. 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 the go/defer statement itself — a semantic change.
  2. It verifies the concrete type actually implements the interface with typecheck.Implements().
  3. It constructs a TypeAssertExpr converting the interface value to the concrete type, retrieves the concrete method via XDotMethod(), and rewrites the node from OCALLINTER to OCALLMETH (a direct method call) — or keeps it OCALLINTER when 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() in cmd/compile/internal/devirtualize/pgo.go at 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.File on one branch and a *bytes.Buffer on another, concreteType() returns nil and no static devirtualization happens — even if both implement the interface.
  • go/defer call sites are excluded. A devirtualizable interface call inside go 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 hot f() where f is a plain top-level function can be devirtualized; a hot f() where f closes 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.Buffer rather than io.Writer when the function genuinely only ever handles that one type.
  • Manual type switch. Hand-writing the if f, ok := r.(*os.File); ok fast 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