Go

How does inlining work in the Go compiler, how do you inspect it, and what prevents a function from being inlined?

Question 388HardGo 1.22 to 1.25

The compiler inlines functions whose body cost is under a budget (~80 AST nodes). Inlining removes call overhead, but the bigger win is that it enables further optimization at the call site: escape analysis can keep values on the stack, constants propagate, bounds checks disappear. Since Go 1.12 mid-stack inlining allows inlining non-leaf functions.

go build -gcflags='-m' ./...        # "can inline X", "inlining call to X"
go build -gcflags='-m=2' ./...      # includes reasons: "cost 95 exceeds budget 80"

Things that block or hinder inlining:

  • Body too large (cost > 80); each call inside the body costs ~57 extra nodes.
  • Statements the inliner doesn't handle: recover, defer, go, select ("unhandled op" in -m=2); the //go:noinline directive; //go:norace (only when building with -race); cgo wrapper functions.
  • Calls through interfaces or function values (dynamic dispatch) — unless devirtualized, e.g. via PGO.
  • Recursive functions.

A classic trick is fast-path/slow-path splitting: keep the common case tiny so it inlines, and move the rare case to a separate non-inlined function.

func (b *Buf) WriteByte(c byte) error {
    if len(b.data) < cap(b.data) { // fast path: inlinable
        b.data = append(b.data, c)
        return nil
    }
    return b.writeByteSlow(c)
}

func (b *Buf) writeByteSlow(c byte) error {
    b.grow(1)
    b.data = append(b.data, c)
    return nil
}

Gotcha: the exact budget and rules change between releases — measure, don't guess.

More on Performance, Profiling & Testing

All 38 Performance, Profiling & Testing questions