Function Inlining
Inlining is the compiler optimization that replaces a function call with a copy of the called function’s body, splicing the callee directly into the caller. In the Go
gccompiler it is a middle-end pass over the typed intermediate representation (cmd/compile/internal/inline), gated by a cost budget: a function is annotated as inlinable only if a static traversal of its body costs no more than a fixed budget (inlineMaxBudget = 80“hairy” units), and a call site is then expanded if the callee carries that annotation (inl.go). Inlining matters far beyond the cost of aCALLinstruction it removes: by exposing the callee’s body to the caller’s optimizer, it unlocks escape analysis, constant folding, bounds-check elimination, and devirtualization across what used to be an opaque call boundary.
Mental Model
Think of inlining as trading binary size for cross-function optimization. A call is an optimization firewall: the optimizer cannot see through it, so a value passed to a callee must be treated pessimistically (it might escape, it might be mutated, its range is unknown). Inlining tears down that firewall for small functions, after which every downstream SSA pass operates on a single larger function with full visibility.
The Go inliner works in two distinct steps, and conflating them is the most common source of confusion:
- Analysis (can this function be inlined?) — For every function in the package, a
hairyVisitorwalks the body once and computes a cost. If the cost is within budget and no disqualifying construct is present, the function’sInlfield is populated with the body and its cost. This is a property of the callee. - Expansion (should this call site be inlined?) — Each call site is examined. If the callee has an
Inlannotation and expanding it here is allowed (budget at the call site, recursion limits, caller size), the call node is rewritten into the inlined body. This is a decision per call site.
flowchart TD A[Typed IR for package] --> B[CanInline: hairyVisitor<br/>walks each func body] B -->|cost ≤ budget,<br/>no disqualifier| C[Func.Inl populated:<br/>body + cost stored] B -->|cost > budget or<br/>defer/recover/closure...| D[Not inlinable] C --> E[InlineCalls: visit every<br/>call site bottom-up] E -->|callee has Inl and<br/>site budget allows| F[Splice body into caller<br/>params -> assignments] F --> G[Downstream SSA passes<br/>see one larger function] G --> H[Escape analysis, BCE,<br/>devirtualization, const-fold]
Figure: the two-phase inliner. The insight: inlinability is computed once per callee, but the splice decision is made independently at every call site, bottom-up, so a leaf function inlined into a mid-stack function can itself then be inlined further up — this is “mid-stack inlining”.
Mechanical Walk-through
The cost model
The inliner’s budget is a deliberately crude proxy for “how big is this function”. The hairyVisitor traverses the IR and decrements a counter, budget, starting from inlineMaxBudget = 80 (inl.go). Most IR nodes cost one unit (v.budget--). The interesting cases are the adjustments:
- A call to another function costs
inlineExtraCallCost = 57if the callee is not itself inlinable. This is a large penalty: it means a function with even two non-inlinable calls (114 units) is already over budget. The intent is that inlining a function that itself makes expensive calls yields little — you still pay the call cost, just relocated. - A call to an inlinable callee costs
callee.Inl.Costinstead of the flat 57. Because inlinable callees are cheap by definition (≤ 80, usually far less), a function built from small helpers can stay inlinable. This is what makes mid-stack inlining compose. - A call through a closure parameter costs
inlineParamCallCost = 17— cheaper than a general call, because the indirect call is comparatively predictable. paniccostsinlineExtraPanicCost = 1— almost free, so error-pathpanics do not block inlining.runtime.throwcostsinlineExtraThrowCost = inlineMaxBudget— a full budget, effectively forbidding inlining of functions that callthrowdirectly.- Address-of-field at offset zero (
&s.f) and the matching dereference get a credit (v.budget++) — these compile to no machine code, so the traversal refunds the unit it would otherwise charge.
The number 80 is not derived from first principles; it is an empirically tuned knob, and the constants have been adjusted across releases. You can override it for experimentation with -gcflags=-l=4 (more aggressive) or disable inlining entirely with -gcflags=-l.
What disqualifies a function outright
Some constructs make a function non-inlinable regardless of cost, because the inliner cannot correctly relocate their semantics. Per inl.go and go.dev/wiki/CompilerOptimizations:
- Functions containing
recover()— thehairyVisitor.doNodeswitch rejectsORECOVERwith the reason"call to recover".recoveronly works at a fixed stack depth relative to thepanic; relocating it would change which panic it catches. - Functions containing a
deferstatement —ODEFERis rejected with"unhandled op DEFER". Verified ongo1.26.1: a function with a top-leveldeferreportscannot inline withDefer: unhandled op DEFER. (The deferred closure is itself separately analyzed and may be inlinable; it is the enclosing function’sdeferthat blocks.) - Functions starting a goroutine —
OGOis rejected with"unhandled op GO"; verified ongo1.26.1ascannot inline withGo: unhandled op GO. - Functions ending in a tail call —
OTAILCALLis rejected with"unhandled op TAILCALL". - Functions marked with pragmas:
//go:noinline,//go:norace(under-race),//go:nocheckptr(under-d checkptr),//go:cgo_unsafe_args,//go:uintptrkeepalive,//go:uintptrescapes,//go:yeswritebarrierrec— these are handled byInlineImpossible()rather than the cost walk. - Functions with no body (assembly-implemented functions, since there is no IR to splice).
Note one correction to the folklore: closures and select statements do not unconditionally block inlining in current Go. The hairyVisitor carries an OCLOSURE case that only refuses ("not inlining functions with closures") when closure inlining is disabled; under the default toolchain it allows them, and a function containing a select is judged purely on cost. Verified on go1.26.1: a function whose body builds and immediately calls a closure inlines fine (can inline withClosureSimple with cost 27). The early-gc wiki list (closures, defer, recover, select) is stale on these two — defer, go, recover, and tailcall are the hard structural disqualifiers that survive; select and closures are not.
Mid-stack inlining
Before Go 1.9, only leaf functions (those that call nothing) were inlined. Go 1.9 introduced mid-stack inlining: a function that itself calls inlinable functions can be inlined (issue #19348). Because the inliner runs bottom-up — leaves first — by the time it considers a mid-stack function, its callees may already be inlinable, so their cost contributes as Inl.Cost (small) rather than inlineExtraCallCost (57). This is why a three-layer call chain of tiny accessors can collapse into a single function: each layer is inlined into the next, leaf-first.
Big-function damping
To stop binary size from exploding, the inliner is more conservative when the caller is already large. A caller with more than inlineBigFunctionNodes = 5000 IR nodes will only accept callees whose cost is at most inlineBigFunctionMaxCost = 20 (inl.go). The reasoning: inlining into a huge function gives diminishing returns (the hot path is a small fraction of it) while paying full code-size cost.
PGO-driven inlining
Profile-Guided Optimization (PGO), generally available since Go 1.21 (go.dev/doc/go1.21), raises the budget for hot call sites. When a default.pgo profile is present in the main package directory, the inliner identifies hot callees (candHotCalleeMap) and hot edges (candHotEdgeMap) from the profile, with the hotness cutoff at inlineCDFHotCallSiteThresholdPercent = 99.0 (a call site is “hot” if it falls in the top fraction of edges accounting for 99% of cumulative profile weight). Hot call sites are inlined against inlineHotMaxBudget = 2000 (verified at the go1.26.0 tag in inl.go) — 25× the default inlineMaxBudget = 80 — so a function normally far too “hairy” to inline gets inlined exactly where the profile says it is on the critical path (go.dev/blog/pgo). The blog’s worked example shows mdurl.Parse being PGO-inlined, which then let escape analysis stack-allocate a URL and eliminate 100% of that function’s allocations — the canonical demonstration that inlining is an enabler, not just a call-elimination. This is the precise mechanism by which inlining flips a heap allocation into a stack allocation: erasing the call boundary lets escape analysis prove a value never leaves the (now-merged) frame.
Reading -gcflags=-m
The compiler reports inlining decisions when built with -m. Each additional -m raises verbosity.
go build -gcflags='-m' ./...
# add more m's for detail; -m -m explains *why* something is not inlined
go build -gcflags='-m=2' ./...There are two distinct reasons a function is rejected, and they print different messages — conflating them is a common mistake. A structural disqualifier (a defer, a go, a recover) prints unhandled op … (or call to recover) regardless of how small the function is; a function merely over budget prints function too complex: cost N exceeds budget 80. The following two examples, both verified against go1.26.1, isolate each case.
First, the structural case:
package main
func add(a, b int) int { return a + b } // line 3
func slow(n int) int { // line 5
s := 0
for i := 0; i < n; i++ {
defer func() { _ = i }() // line 8 — defer in body
s = add(s, i)
}
return s
}-gcflags='-m=2' (verified on go1.26.1):
./main.go:3:6: can inline add with cost 4 as: func(int, int) int { return a + b }
./main.go:5:6: cannot inline slow: unhandled op DEFER
./main.go:8:9: can inline slow.func1
./main.go:9:10: inlining call to add
Line by line:
can inline add with cost 4—addwas analyzed; itshairyVisitorcost is 4, well under 80, soFunc.Inlis populated. This is the analysis phase result.cannot inline slow: unhandled op DEFER—slowis rejected structurally, not on cost. Thedeferalone disqualifies it; its actual node cost is irrelevant. This corrects a frequent misreading: adeferdoes not “push the cost over budget,” it short-circuits the cost walk entirely. (The deferredslow.func1closure is separately judged inlinable.)inlining call to add— the expansion phase: the call toaddwas actually spliced intoslow’s body. Every successful splice prints one such line.
Second, a genuine over-budget case (no structural disqualifier, just too big):
func manyOps(a, b, c, d, e int) int {
x := a + b*c - d + e
x = x*2 + a - b + c*d - e
// …a dozen more arithmetic lines…
return x
}-gcflags='-m=2' (verified on go1.26.1) prints:
./c.go:3:6: cannot inline manyOps: function too complex: cost 196 exceeds budget 80
This is the cost verdict — 196 > 80 — the only case where the “exceeds budget” wording appears. To see the running cost and the node that tipped it over, use -m=2. To confirm a function never inlines, mark it //go:noinline; the -m output then reads cannot inline … marked go:noinline.
Failure Modes and Common Misunderstandings
- “Inlining always makes code faster.” False. Inlining inflates instruction-cache footprint; an over-inlined hot loop can thrash the I-cache and run slower. The budget exists precisely to bound this. PGO addresses it by inlining selectively where the profile justifies the code-size cost.
- “My function is tiny, so it inlines.” Tiny source is not tiny cost. A one-line function that calls a non-inlinable function costs at least 57 and may exceed budget. A one-line function with a
recover()is unconditionally rejected. - Inlining and stack traces. When a function is inlined, it no longer has its own stack frame. Go preserves correctness by recording inline trees in the PC-line tables, so
runtime.Caller, panic traces, and profiles still show the logical call stack. But a//go:noinlineis sometimes needed to make a frame reliably observable to a debugger or toruntime.Callers-based instrumentation. - Recursion. Directly recursive functions are not inlined into themselves; the inliner caps expansion depth to prevent unbounded code growth.
- The 80 is a moving target. Do not hard-code assumptions about the budget. The constants in
inl.goare tuned per release; a function that inlines under one Go version may not under the next, and vice versa.
Alternatives and Related Mechanisms
//go:fix inlinesource-level inliner. Go 1.26’s revampedgo fixships a source-level inliner driven by//go:fix inlinedirectives (go.dev/doc/go1.26). This is unrelated to the compiler’s inliner: it rewrites source code to inline a deprecated API’s body at call sites, for API migration, and is permanent and visible — not a per-build optimization.- Manual inlining. Hand-copying a hot function’s body into the caller. Almost always the wrong call: it duplicates code, defeats the budget’s I-cache protection, and is what the compiler does for free.
//go:noinline. The opposite knob — forcibly prevent inlining, used to keep a frame observable, to benchmark call overhead, or to work around a miscompilation.
Production Notes
The PGO blog (go.dev/blog/pgo) reports that profile-guided inlining is the dominant contributor to PGO’s typical 2–7% CPU win, because it is the optimization that unlocks the others. Google reported in the PGO rollout that the largest single gains came from inlining a small number of very hot, normally-too-large functions.
A practical tactic when a hot function refuses to inline: split it. Move the cold path (error handling, the panic, the rare branch) into a separate non-inlinable helper, leaving the hot path small enough to fit the budget. The standard library does this deliberately — many runtime and bytes functions have a tiny inlinable wrapper around a larger ...slow or ...generic helper.
See Also
- Go Compiler Architecture — where the inline pass sits in the pipeline
- Escape Analysis — the optimization most often unlocked by inlining
- Stack vs Heap Allocation — the placement decision inlining flips by erasing the call boundary
- Devirtualization — PGO devirtualization pairs with PGO inlining
- Profile-Guided Optimization — the source of the hot-call-site budget boost
- Bounds Check Elimination — runs after inlining, benefits from cross-call visibility
- Compiler Optimization Diagnostics — reading
-gcflags=-m - Intermediate Representation in the Go Compiler — the IR the inliner rewrites
- Go Internals MOC — parent map