Go Removes Interface Calls With PGO: Devirtualization and Inlining Hot Call Sites
Give the Go compiler a CPU profile and it will find your hottest polymorphic call sites and rewrite them into a guarded direct call, which then becomes eligible for inlining.
TL;DR: An interface method call is an indirect call through a function pointer, and the CPU cannot predict it. PGO gives the compiler a profile showing that at one call site, 95% of the time the receiver is a
*bytes.Buffer. The compiler rewrites that call into “assert to*bytes.Buffer, call it directly if that works, otherwise fall back to the interface call” — and now the direct call is a candidate for inlining too.
Every Go interface call is a small performance cliff. The compiler has
known the concrete type at compile time in some cases, but in the general
case it does not: w.Write(p) might be a *bytes.Buffer, a
*bufio.Writer, an *os.File, or one of a hundred types in a plugin
system. The call goes through an itab, and the branch predictor throws up
its hands.
Profile-guided optimization gives the compiler the answer empirically. And the transformation it performs is surprisingly small.
This is a walk through how it works, based on reading the Go 1.28
development tree (src/cmd/compile/internal/devirtualize/,
src/cmd/compile/internal/pgoir/, src/cmd/internal/pgo/).
What a PGO profile actually is
First, the input. A PGO profile is not a special format — it is an
ordinary pprof CPU profile, the same one go tool pprof reads. The
compiler parses it with the standard internal/profile package:
// src/cmd/internal/pgo/pprof.go:18
// FromPProf parses Profile from a pprof profile.
func FromPProf(r io.Reader) (*Profile, error) {
p, err := profile.Parse(r)
if errors.Is(err, profile.ErrNoData) {
// Treat a completely empty file the same as a profile with no
// samples: nothing to do.
return emptyProfile(), nil
} else if err != nil {
return nil, fmt.Errorf("error parsing profile: %w", err)
}
if len(p.Sample) == 0 {
// We accept empty profiles, but there is nothing to do.
return emptyProfile(), nil
}
valueIndex, err := sampleValueIndex(p)
if err != nil {
return nil, err
}
g := profile.NewGraph(p, &profile.Options{
SampleValue: func(v []int64) int64 { return v[valueIndex] },
})One wrinkle: the compiler is picky about the sample type, because a heap profile or a mutex profile passed by mistake would produce nonsense optimizations:
// src/cmd/internal/pgo/pprof.go:64
// sampleValueIndex returns the index of the sample value to use as the PGO
// weight. Non-Go CPU profiles (e.g., from Linux perf) use arbitrary sample
// types, so we accept the default sample type unless it indicates a Go
// non-CPU profile (heap, mutex, block, or goroutine) passed by mistake.
func sampleValueIndex(p *profile.Profile) (int, error) {
if len(p.SampleType) == 0 {
return 0, fmt.Errorf("profile has no sample types")
}and then it rejects the wrong kinds outright:
// src/cmd/internal/pgo/pprof.go:93
switch t := p.SampleType[index].Type; t {
case "alloc_objects", "alloc_space", "inuse_objects", "inuse_space":
return 0, fmt.Errorf("profile must be a CPU profile (default sample type %q indicates that this is a Go heap profile)", t)
case "contentions", "delay":
return 0, fmt.Errorf("profile must be a CPU profile (default sample type %q indicates that this is a Go mutex or block profile)", t)
case "goroutine":
return 0, fmt.Errorf("profile must be a CPU profile (default sample type %q indicates that this is a Go goroutine profile)", t)
}
return index, nil
}So: CPU profile or nothing.
From samples to call edges
A pprof profile is a set of stacks with counts. The compiler turns those into a weighted call graph, and the weight of an edge is the number of samples that passed through it.
The key structure is the edge map:
// src/cmd/internal/pgo/pgo.go:22
// NamedCallEdge identifies a call edge by linker symbol names and call site
// offset.
type NamedCallEdge struct {
CallerName string
CalleeName string
CallSiteOffset int // Line offset from function start line.
}
// NamedEdgeMap contains all unique call edges in the profile and their
// edge weight.
type NamedEdgeMap struct {
Weight map[NamedCallEdge]int64
// ByWeight lists all keys in Weight, sorted by edge weight from
// highest to lowest.
ByWeight []NamedCallEdge
}The CallSiteOffset is the interesting field. A profile records where
in a function a sample was, as a line number. To match that against the
compiler’s IR, the profile’s absolute line number is converted to an
offset from the function’s start line:
// src/cmd/internal/pgo/pprof.go:108
func createNamedEdgeMap(g *profile.Graph) (edgeMap NamedEdgeMap, totalWeight int64, err error) {
seenStartLine := false
// Process graph and build various node and edge maps which will
// be consumed by AST walk.
weight := make(map[NamedCallEdge]int64)
for _, n := range g.Nodes {
seenStartLine = seenStartLine || n.Info.StartLine != 0
canonicalName := n.Info.Name
// Create the key to the nodeMapKey.
namedEdge := NamedCallEdge{
CallerName: canonicalName,
CallSiteOffset: n.Info.Lineno - n.Info.StartLine,
}
for _, e := range n.Out {
totalWeight += e.WeightValue()
namedEdge.CalleeName = e.Dest.Info.Name
// Create new entry or increment existing entry.
weight[namedEdge] += e.WeightValue()
}
}
if !seenStartLine {
// TODO(prattmic): If Function.start_line is missing we could
// fall back to using absolute line numbers, which is better
// than nothing.
return NamedEdgeMap{}, 0, fmt.Errorf("profile missing Function.start_line data (Go version of profiled application too old? Go 1.20+ automatically adds this to profiles)")
}
return postProcessNamedEdgeMap(weight, totalWeight)
}So the edges are keyed by a triple, and the result looks like this for a
function main.handle that calls (*bytes.Buffer).Write and
(*os.File).Write from the same line:
(caller, callee, line offset) weight
---------------------------------------------------
(main.handle, (*bytes.Buffer).Write, 12) 900
(main.handle, (*os.File).Write, 12) 100That seenStartLine check is a real constraint: profiles from Go 1.20 and
later carry Function.start_line; older ones do not, and the compiler
refuses to use them rather than guessing.
Note that an edge is identified by (caller, callee, call-site offset). So the same function calling the same callee from two different lines produces two different edges. That is what makes the analysis per-call-site rather than per-function.
From edges to IR
The profile’s edges are in terms of linker symbol names. The compiler
needs them in terms of IR, so it builds an IRGraph that maps names to
ir.Func nodes:
// src/cmd/compile/internal/pgoir/irgraph.go:67
type IRGraph struct {
// Nodes of the graph. Each node represents a function, keyed by linker
// symbol name.
IRNodes map[string]*IRNode
}
// IRNode represents a node (function) in the IRGraph.
type IRNode struct {
// Pointer to the IR of the Function represented by this node.
AST *ir.Func
// Linker symbol name of the Function represented by this node.
// Populated only if AST == nil.
LinkerSymbolName string
// Set of out-edges in the callgraph. The map uniquely identifies each
// edge based on the callsite and callee, for fast lookup.
OutEdges map[pgo.NamedCallEdge]*IREdge
}and the edge:
// src/cmd/compile/internal/pgoir/irgraph.go:94
// IREdge represents a call edge in the IRGraph with source, destination,
// weight, callsite, and line number information.
type IREdge struct {
// Source and destination of the edge in IRNode.
Src, Dst *IRNode
Weight int64
CallSiteOffset int // Line offset from function start line.
}The AST == nil case is important: it means “this callee is not visible
from the package being compiled”. The callee might be in a transitive
dependency, in which case the compiler can look it up in export data, or
it might be genuinely unavailable (in a plugin, or in a package built
without the right metadata).
Direct calls are discovered by walking the IR:
// src/cmd/compile/internal/pgoir/irgraph.go:209
func createIRGraphEdge(fn *ir.Func, callernode *IRNode, name string, namedEdgeMap pgo.NamedEdgeMap, g *IRGraph) {
ir.VisitList(fn.Body, func(n ir.Node) {
switch n.Op() {
case ir.OCALLFUNC:
call := n.(*ir.CallExpr)
// Find the callee function from the call site and add the edge.
callee := DirectCallee(call.Fun)
if callee != nil {
addIREdge(callernode, name, n, callee, namedEdgeMap, g)
}
case ir.OCALLMETH:
call := n.(*ir.CallExpr)
// Find the callee method from the call site and add the edge.
callee := ir.MethodExprName(call.Fun).Func
addIREdge(callernode, name, n, callee, namedEdgeMap, g)
}
})
}But the interesting edges for devirtualization are the indirect ones — the interface calls, which by definition do not appear in the IR as direct calls. Those come from the profile, and are added separately:
// src/cmd/compile/internal/pgoir/irgraph.go:284
// addIndirectEdges adds indirect call edges found in the profile to the graph,
// to be used for devirtualization.
//
// N.B. despite the name, addIndirectEdges will add any edges discovered via
// the profile. We don't know for sure that they are indirect, but assume they
// are since direct calls would already be added. (e.g., direct calls that have
// been deleted from source since the profile was taken would be added here).
//
// TODO(prattmic): Devirtualization runs before inlining, so we can't devirtualize
// calls inside inlined call bodies. If we did add that, we'd need edges from
// inlined bodies as well.
func addIndirectEdges(g *IRGraph, namedEdgeMap pgo.NamedEdgeMap) {So the only way the compiler learns “at this call site, the receiver is
usually a *bytes.Buffer” is from the profile. There is no static
inference at all in the PGO path.
Finding the hot callee
Now the actual analysis. findHotConcreteCallee looks at all the
out-edges of the caller that share this call site’s offset, and picks the
heaviest:
// src/cmd/compile/internal/devirtualize/pgo.go:654
// findHotConcreteCallee returns the *ir.Func of the hottest callee of a call,
// if available, and its edge weight. extraFn can perform additional
// applicability checks on each candidate edge. If extraFn returns false,
// candidate will not be considered a valid callee candidate.
func findHotConcreteCallee(p *pgoir.Profile, caller *ir.Func, call *ir.CallExpr, extraFn func(callerName string, callOffset int, candidate *pgoir.IREdge) bool) (*ir.Func, int64) {
callerName := ir.LinkFuncName(caller)
callerNode := p.WeightedCG.IRNodes[callerName]
callOffset := pgoir.NodeLineOffset(call, caller)
if callerNode == nil {
return nil, 0
}
var hottest *pgoir.IREdge
// Returns true if e is hotter than hottest.
//
// Naively this is just e.Weight > hottest.Weight, but because OutEdges
// has arbitrary iteration order, we need to apply additional sort
// criteria when e.Weight == hottest.Weight to ensure we have stable
// selection.
hotter := func(e *pgoir.IREdge) bool {
if hottest == nil {
return true
}
if e.Weight != hottest.Weight {
return e.Weight > hottest.Weight
}and the tie-breaking rules, which exist to make the build reproducible:
// src/cmd/compile/internal/devirtualize/pgo.go:682:683
// Now e.Weight == hottest.Weight, we must select on other
// criteria.
// If only one edge has IR, prefer that one.
if (hottest.Dst.AST == nil) != (e.Dst.AST == nil) {
if e.Dst.AST != nil {
return true
}
return false
}
// Arbitrary, but the callee names will always differ. Select
// the lexicographically first callee.Then the loop over the candidate edges:
// src/cmd/compile/internal/devirtualize/pgo.go:699
for _, e := range callerNode.OutEdges {
if e.CallSiteOffset != callOffset {
continue
}
if !hotter(e) {
// TODO(prattmic): consider total caller weight? i.e.,
// if the hottest callee is only 10% of the weight,
// maybe don't devirtualize? Similarly, if this is call
// is globally very cold, there is not much value in
// devirtualizing.
if base.Debug.PGODebug >= 2 {
fmt.Printf("%v: edge %s:%d -> %s (weight %d): too cold (hottest %d)\n", ir.Line(call), callerName, callOffset, e.Dst.Name(), e.Weight, hottest.Weight)
}
continue
}Note the TODO: there is no threshold on how dominant the hot callee
has to be. If the profile says 51% *A and 49% *B, the compiler
devirtualizes to *A and adds a type assertion. Whether that is a win
depends on how expensive the type assertion is versus the indirect call.
The TODO acknowledges this.
The type check that makes it safe
The one thing that must be true is that the hot callee actually
implements the interface at that call site. Otherwise you would be
rewriting a call to *bytes.Buffer.Write in a place where the receiver
might be something else entirely.
// src/cmd/compile/internal/devirtualize/pgo.go:756
// findHotConcreteInterfaceCallee returns the *ir.Func of the hottest callee of an
// interface call, if available, and its edge weight.
func findHotConcreteInterfaceCallee(p *pgoir.Profile, caller *ir.Func, call *ir.CallExpr) (*ir.Func, int64) {
inter, method := interfaceCallRecvTypeAndMethod(call)
return findHotConcreteCallee(p, caller, call, func(callerName string, callOffset int, e *pgoir.IREdge) bool {
ctyp := methodRecvType(e.Dst.AST)
if ctyp == nil {
// Not a method.
// TODO(prattmic): Support non-interface indirect calls.
if base.Debug.PGODebug >= 2 {
fmt.Printf("%v: edge %s:%d -> %s (weight %d): callee not a method\n", ir.Line(call), callerName, callOffset, e.Dst.Name(), e.Weight)
}
return false
}
// If ctyp doesn't implement inter it is most likely from a
// different call on the same line
if !typecheck.Implements(ctyp, inter) {and the method name check, which handles the case of two different calls on the same line:
// src/cmd/compile/internal/devirtualize/pgo.go:792:791
// If the method name is different it is most likely from a
// different call on the same line
if !strings.HasSuffix(e.Dst.Name(), "."+method.Name) {
if base.Debug.PGODebug >= 2 {
fmt.Printf("%v: edge %s:%d -> %s (weight %d): callee is a different method\n", ir.Line(call), callerName, callOffset, e.Dst.Name(), e.Weight)
}
return false
}
return true
})Two filters, both necessary because call sites are identified by line offset, not by column. If you have two interface calls on the same line, the profile cannot tell them apart, so the compiler checks that the hot callee’s method name matches the method being called at this site.
The rewrite
Now the transformation. The compiler does not simply replace the call — it inserts a guarded direct call, falling back to the original interface call if the type assertion fails.
// src/cmd/compile/internal/devirtualize/pgo.go:489
// rewriteInterfaceCall devirtualizes the given interface call using a direct
// method call to concretetyp.
func rewriteInterfaceCall(call *ir.CallExpr, curfn, callee *ir.Func, concretetyp *types.Type) ir.Node {
if base.Flag.LowerM != 0 {
fmt.Printf("%v: PGO devirtualizing interface call %v to %v\n", ir.Line(call), call.Fun, callee)
}The comment in the source describes the generated code precisely:
// src/cmd/compile/internal/devirtualize/pgo.go:496
// We generate an OINCALL of:
//
// var recv Iface
//
// var arg1 A1
// var argN AN
//
// var ret1 R1
// var retN RN
//
// recv, arg1, argN = recv expr, arg1 expr, argN expr
//
// t, ok := recv.(Concrete)
// if ok {
// ret1, retN = t.Method(arg1, ... argN)
// } else {
// ret1, retN = recv.Method(arg1, ... argN)
// }
//
// OINCALL retvars: ret1, ... retN
//
// This isn't really an inlined call of course, but InlinedCallExpr
// makes handling reassignment of return values easier.So the generated code for w.Write(p) where w is usually a
*bytes.Buffer is:
t, ok := w.(*bytes.Buffer)
if ok {
t.Write(p) // direct call
} else {
w.Write(p) // original interface call
}The receiver and arguments are copied to temporaries first, because they are evaluated twice now:
// src/cmd/compile/internal/devirtualize/pgo.go:409
// copyInputs copies the inputs to a call: the receiver (for interface calls)
// or function value (for function value calls) and the arguments. These
// expressions are evaluated once and assigned to temporaries.
//
// The assignment statement is added to init and the copied receiver/fn
// expression and copied arguments expressions are returned.
func copyInputs(curfn *ir.Func, pos src.XPos, recvOrFn ir.Node, args []ir.Node, init *ir.Nodes) (ir.Node, []ir.Node) {And the conditional itself is built by condCall, which wraps the two
calls in an if and returns an InlinedCallExpr so that the return
value plumbing works:
// src/cmd/compile/internal/devirtualize/pgo.go:451
// condCall returns an ir.InlinedCallExpr that performs a call to thenCall if
// cond is true and elseCall if cond is false. The return variables of the
// InlinedCallExpr evaluate to the return values from the call.
func condCall(curfn *ir.Func, pos src.XPos, cond ir.Node, thenCall, elseCall *ir.CallExpr, init ir.Nodes) *ir.InlinedCallExpr {Note nif.Likely = true — the compiler marks the direct-call branch as
the likely one, so the branch predictor and the block layout both favour
it.
The gating conditions
Before any of this happens, maybeDevirtualizeInterfaceCall checks a
list of conditions:
// src/cmd/compile/internal/devirtualize/pgo.go:182
// Devirtualize interface call if possible and eligible. Returns the new
// ir.Node if call was devirtualized, and if so also the callee and weight of
// the devirtualized edge.
func maybeDevirtualizeInterfaceCall(p *pgoir.Profile, fn *ir.Func, call *ir.CallExpr) (ir.Node, *ir.Func, int64) {
if base.Debug.PGODevirtualize < 1 {
return nil, nil, 0
}
// Bail if we do not have a hot callee.
callee, weight := findHotConcreteInterfaceCallee(p, fn, call)
if callee == nil {
return nil, nil, 0
}
// Bail if we do not have a Type node for the hot callee.
ctyp := methodRecvType(callee)
if ctyp == nil {
return nil, nil, 0
}
// Bail if we know for sure it won't inline.
if !shouldPGODevirt(callee) {
return nil, nil, 0
}
// Bail if de-selected by PGO Hash.
if !base.PGOHash.MatchPosWithInfo(call.Pos(), "devirt", nil) {
return nil, nil, 0
}
return rewriteInterfaceCall(call, fn, callee, ctyp), callee, weight
}Five gates:
PGODevirtualizedebug level at least 1 (it defaults to 2).- There must be a hot callee with a known concrete receiver type.
- The callee must be a method (have a receiver).
- The callee must be inlinable. This is the key one, and it is explained here:
// src/cmd/compile/internal/devirtualize/pgo.go:297
// shouldPGODevirt checks if we should perform PGO devirtualization to the
// target function.
//
// PGO devirtualization is most valuable when the callee is inlined, so if it
// won't inline we can skip devirtualizing.
func shouldPGODevirt(fn *ir.Func) bool {The whole point of devirtualizing is to enable inlining. If the callee will not be inlined, devirtualizing just replaces an indirect call with a type assertion plus a direct call, which is often not a win.
- The
PGOHashcheck, which is a debug mechanism for selecting a subset of call sites to devirtualize.
The PGOHash mechanism is worth knowing about, because it lets you
experiment with the effect of devirtualization on a subset of call sites
without changing any code. -d=PGOHash=... selects call sites by their
position hash.
Function value calls too
The same machinery handles calls through a func value, not just
interface methods. The transformation is different — it compares function
pointers rather than doing a type assertion:
// src/cmd/compile/internal/devirtualize/pgo.go:557
// rewriteFunctionCall devirtualizes the given OCALLFUNC using a direct
// function call to callee.
func rewriteFunctionCall(call *ir.CallExpr, curfn, callee *ir.Func) ir.Node {
if base.Flag.LowerM != 0 {
fmt.Printf("%v: PGO devirtualizing function call %v to %v\n", ir.Line(call), call.Fun, callee)
}with the generated shape spelled out:
// src/cmd/compile/internal/devirtualize/pgo.go:564
// We generate an OINCALL of:
//
// var fn FuncType
//
// var arg1 A1
// var argN AN
//
// var ret1 R1
// var retN RN
//
// fn, arg1, argN = fn expr, arg1 expr, argN expr
//
// fnPC := internal/abi.FuncPCABIInternal(fn)
// concretePC := internal/abi.FuncPCABIInternal(concrete)
//
// if fnPC == concretePC {
// ret1, retN = concrete(arg1, ... argN) // Same closure context passed (TODO)
// } else {
// ret1, retN = fn(arg1, ... argN)
// }
//
// OINCALL retvars: ret1, ... retNThis path is gated at a higher debug level — PGODevirtualize < 2 — and
has more exclusions, because closures need a context register that the
compiler does not plumb through:
// src/cmd/compile/internal/devirtualize/pgo.go:232
// TODO(go.dev/issue/61577): Closures need the closure context passed
// via the context register. That requires extra plumbing that we
// haven't done yet.
if callee.OClosure != nil {
if base.Debug.PGODebug >= 3 {
fmt.Printf("callee %s is a closure, skipping\n", ir.FuncName(callee))
}
return nil, nil, 0
}Static devirtualization: the other half
PGO devirtualization is the exciting one, but the compiler also does
static devirtualization, with no profile at all. StaticCall replaces
an interface call with a direct call when the concrete type is provable
from the source:
// src/cmd/compile/internal/devirtualize/devirtualize.go:5
// Package devirtualize implements two "devirtualization" optimization passes:
//
// - "Static" devirtualization which replaces interface method calls with
// direct concrete-type method calls where possible.
// - "Profile-guided" devirtualization which replaces indirect calls with a
// conditional direct call to the hottest concrete callee from a profile, as
// well as a fallback using the original indirect call.The static version needs to know the concrete type of the receiver, which
means tracking assignments to interface-typed variables. That is what
concreteType does:
// src/cmd/compile/internal/devirtualize/devirtualize.go:181
// concreteType determines the concrete type of n, following OCONVIFACEs and type asserts.
// Returns nil when the concrete type could not be determined, or when there are multiple
// (different) types assigned to an interface.
func concreteType(s *State, n ir.Node) (typ *types.Type) {and it walks assignments:
// src/cmd/compile/internal/devirtualize/devirtualize.go:314
// Methods are only declared on named types, and each named type
// is represented by a unique [*types.Type], thus pointer comparison
// is fine here.
//
// The only scenario where [types.IdenticalStrict] could help here is with
// unnamed struct types that embed another type (e.g. foo = struct { Impl }{}).
// However, such patterns are uncommon and not worth the additional complexity
// in the devirtualizer.
if typ != nil && typ != t {
return nil // assigned with a different type
}
typ = tSo if a variable is assigned two different concrete types at different points in the function, the static devirtualizer gives up. It is a whole-function, flow-insensitive-ish analysis.
The static rewrite is simpler than the PGO one — no fallback, because the type is known:
// src/cmd/compile/internal/devirtualize/devirtualize.go:120
dt := ir.NewTypeAssertExpr(sel.Pos(), sel.X, typ)
if go126ImprovedConcreteTypeAnalysis {
// Consider:
//
// var v Iface
// v.A()
// v = &Impl{}
//
// Here in the devirtualizer, we determine the concrete type of v as being an *Impl,
// but it can still be a nil interface, we have not detected that. The v.(*Impl)
// type assertion that we make here would also have failed, but with a different
// panic "pkg.Iface is nil, not *pkg.Impl", where previously we would get a nil panic.
// We fix this, by introducing an additional nilcheck on the itab.
// Calling a method on a nil interface (in most cases) is a bug in a program, so it is fine
// to devirtualize and further (possibly) inline them, even though we would never reach
// the called function.
dt.UseNilPanic = true
dt.SetPos(call.Pos())
}There are also several hard exclusions. Generics are the big one:
// src/cmd/compile/internal/devirtualize/devirtualize.go:72
// If typ is a shape type, then it was a type argument originally
// and we'd need an indirect call through the dictionary anyway.
// We're unable to devirtualize this call.
if typ.IsShape() {
return
}and go/defer statements are skipped entirely, because inlining a
type assertion into a go statement can move a panic:
// src/cmd/compile/internal/devirtualize/devirtualize.go:29
// For promoted methods (including value-receiver methods promoted
// to pointer-receivers), the interface method wrapper may contain
// expressions that can panic (e.g., ODEREF, ODOTPTR,
// ODOTINTER). Devirtualization involves inlining these expressions
// (and possible panics) to the call site. This normally isn't a
// problem, but for go/defer statements it can move the panic from
// when/where the call executes to the go/defer statement itself,
// which is a visible change in semantics (e.g., #52072). To prevent
// this, we skip devirtualizing calls within go/defer statements
// altogether.
if call.GoDefer {
return
}Interleaving with inlining
The two optimisations are not independent. Devirtualizing an interface call creates a direct call, and a direct call can be inlined. So the compiler runs them together, in an interleaved pass:
// src/cmd/compile/internal/inline/interleaved/interleaved.go:20
// DevirtualizeAndInlinePackage interleaves devirtualization and inlining on
// all functions within pkg.
func DevirtualizeAndInlinePackage(pkg *ir.Package, profile *pgoir.Profile) {and inside, the PGO devirtualization runs as a separate sweep first:
// src/cmd/compile/internal/inline/interleaved/interleaved.go:30
if profile != nil && base.Debug.PGODevirtualize > 0 {
// TODO(mdempsky): Integrate into DevirtualizeAndInlineFunc below.
ir.VisitFuncsBottomUp(typecheck.Target.Funcs, func(list []*ir.Func, recursive bool) {
for _, fn := range list {
devirtualize.ProfileGuided(fn, profile)
}
})
}and then the static devirtualization happens interleaved with the inlining, at each call site:
// src/cmd/compile/internal/inline/interleaved/interleaved.go:195
// resolve attempts to resolve a call to a potentially inlineable callee
// and updates use counts on the callees. Returns the call site count
// for that callee.
func (s *inlClosureState) resolve(state *devirtualize.State, i int) (*ir.Func, int) {
p := s.parens[i]
if i < len(s.resolved) {
if callee := s.resolved[i]; callee != nil {
return callee, s.useCounts[callee]
}
}
n := p.X
call, ok := n.(*ir.CallExpr)
if !ok { // previously inlined
return nil, -1
}
devirtualize.StaticCall(s.fn, state, call)Devirtualize first, then resolve the (now direct) call for inlining. The loop then iterates to a fixed point, because inlining a callee can expose new interface calls that are themselves devirtualizable.
The measured gains
The Go release notes are specific about this. Go 1.21, where PGO graduated from preview:
Profile-guide optimization (PGO), added as a preview in Go 1.20, is now ready for general use. … Performance improvements vary depending on application behavior, with most programs from a representative set of Go programs seeing between 2 and 7% improvement from enabling PGO.
PGO builds can now devirtualize some interface method calls, adding a concrete call to the most common callee. This enables further optimization, such as inlining the callee.
Go 1.22 improved it further:
Profile-guided Optimization (PGO) builds can now devirtualize a higher proportion of calls than previously possible. Most programs from a representative set of Go programs now see between 2 and 14% improvement at runtime from enabling PGO.
The compiler now interleaves devirtualization and inlining, so interface method calls are better optimized.
So: 2 to 7% in Go 1.21, 2 to 14% in Go 1.22, from PGO as a whole, of which devirtualization is one part. The devirtualization-specific number is not broken out separately in the release notes, which is worth knowing before you attribute a speedup to it.
The mechanism for the interleaving was experimental in Go 1.22:
Go 1.22 also includes a preview of an enhanced implementation of the compiler’s inlining phase that uses heuristics to boost inlinability at call sites deemed “important” (for example, in loops) and discourage inlining at call sites deemed “unimportant” (for example, on panic paths). Building with GOEXPERIMENT=newinliner enables the new call-site heuristics.
Using it
The mechanics are simple:
# Collect a CPU profile from a representative run.
go test -cpuprofile=cpu.out ./...
# or
go build -o myapp && ./myapp -cpuprofile=cpu.out
# Build with the profile.
go build -pgo=cpu.outSince Go 1.21, if a file named default.pgo is present in the main
package’s directory, the go command uses it automatically. That is the
easiest way to set this up: drop default.pgo next to main.go.
To see what happened:
go build -gcflags='-m=2' 2>&1 | grep -i devirtThe messages come from the two places in the source:
PGO devirtualizing interface call w.Write to (*bytes.Buffer).Writeand, for the static path:
devirtualizing w.Write to (*bytes.Buffer).WriteThe -d=PGOHash=... debug flag lets you select a subset of call sites,
which is how the Go team A/B-tested the transformation. The
PGOHash.MatchPosWithInfo call in maybeDevirtualizeInterfaceCall is
the hook.
Trade-offs
What devirtualization buys you: the interface call becomes a direct call, which means (a) the branch predictor can learn it, (b) the callee becomes eligible for inlining, and (c) the call can be devirtualised further by the optimiser.
What it costs you: a type assertion and a branch on every execution of that call site, plus code size. If the hot callee is not actually dominant at runtime — say the profile is stale, or the workload shifted between profiling and building — the assertion always fails and you have paid for nothing.
Why the “must be inlinable” gate matters: devirtualizing a call to a function that will not be inlined replaces an indirect call with an assertion plus a direct call. The direct call is cheaper (no itab load, predictable target), but you have added a branch. Without inlining to pay for it, the win is marginal and sometimes negative. The compiler takes the conservative route.
Why the type check is on the concrete type and not the itab: a full type assertion checks the itab pointer, which is exactly what the interface call would have done. The compiler’s version is a plain type assertion to the concrete type, which is a comparison against a known itab.
Why the PGO path is guarded but the static path is not: the static path has type information, so it is always correct; the PGO path is correct only because the fallback preserves semantics. The PGO path’s worst case is a slower program, never a wrong one.
Caveats
A profile is a snapshot of one workload. If you profile a test suite and deploy to production, the hot call sites may be entirely different. PGO devirtualization is only as good as the profile.
There is no dominance threshold. The compiler devirtualizes to the
hottest callee even if it is only 40% of the weight. The TODO in
findHotConcreteCallee acknowledges this.
The profile must be a CPU profile. Heap, mutex, block and goroutine profiles are rejected with an error, not silently ignored.
Older profiles do not work. Profiles without Function.start_line —
from Go 1.19 and earlier — are rejected, because the call-site offsets
cannot be computed.
Call sites are identified by line offset, not column. Two calls on the same line are indistinguishable. The compiler mitigates this by checking the method name matches, but it is a real limitation.
Generic code is mostly excluded. Shape types cannot be devirtualized at all, and shaped interfaces are excluded too, because the itab pointer identity differs between the shaped and unshaped interface.
go and defer statements are never devirtualized, in either the
static or the PGO path.
The static devirtualizer requires the concrete type to be consistent across the whole function. Assign two different concrete types to the same interface variable and it gives up on that variable entirely, even if each assignment is in a separate branch.
Devirtualization happens before inlining, so calls inside inlined bodies
are not devirtualized. The TODO in addIndirectEdges says so
explicitly.
Method values and method expressions are not devirtualized in the
PGO path, for reasons spelled out in a long comment in
maybeDevirtualizeFunctionCall about how types.Identical treats
receivers.
Wrapping up
The one thing to remember: PGO devirtualization is a guarded rewrite, not a replacement. The compiler keeps the interface call as a fallback and only takes the direct path when the type assertion succeeds, so semantics never change — only speed.
The part people get wrong: the win is not from removing the indirect call. It is from enabling inlining of the concrete callee. A devirtualized call to a function that will not be inlined is barely faster than the interface call it replaced, which is why the compiler refuses to devirtualize in that case.
What to go do about it: profile a representative run, build with
-pgo=cpu.out, and look for PGO devirtualizing lines in
-gcflags=-m=2 output. If you see none, either your hot call sites are
not polymorphic, or the callees are too big to inline — and the second
case is the one worth acting on.
Further reading
src/cmd/compile/internal/devirtualize/pgo.go— the PGO devirtualizer:ProfileGuided,findHotConcreteCallee,findHotConcreteInterfaceCallee,rewriteInterfaceCall,rewriteFunctionCall, andshouldPGODevirt.src/cmd/compile/internal/devirtualize/devirtualize.go— the static devirtualizer:StaticCall,concreteType, and theStatethat tracks assignments to interface variables.src/cmd/compile/internal/pgoir/irgraph.go— the profile-to-IR graph:IRGraph,IRNode,IREdge,addIREdge,addIndirectEdges, andNodeLineOffset.src/cmd/internal/pgo/pprof.go— profile parsing:FromPProf,sampleValueIndex,createNamedEdgeMap.src/cmd/internal/pgo/pgo.go—Profile,NamedCallEdge,NamedEdgeMap.src/cmd/internal/pgo/serialize.goanddeserialize.go— the compact serialised profile format the go command passes to the compiler.src/cmd/compile/internal/inline/interleaved/interleaved.go— the pass that interleaves devirtualization and inlining.src/cmd/compile/internal/inline/inl.go—inlineCostOKand the hot call-site budget that PGO devirtualization feeds into.src/cmd/compile/internal/base/flag.go— thePGODevirtualizedebug level and thePGOHashselection mechanism.src/cmd/compile/internal/typecheck/—Implements, the check that a hot callee’s receiver type actually satisfies the interface.