| name | go-perf-inlining |
| description | Guides Go inlining as a performance lever — the ~80-node cost budget, mid-stack inlining of non-leaf functions, what blocks it (over budget, defer, go, recover, non-devirtualized interface calls), reading "can inline"/"inlining call" from -gcflags=-m and -m=2, //go:noinline, and how inlining unlocks escape analysis and bounds-check elimination. Fires on "why isn't this inlined", "make this inline", "reduce call overhead", "is this function inlined". Routes PGO to go-perf-pgo, escape to go-perf-escape-analysis. |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
Go performance: inlining
Inlining replaces a call with the callee's body. It pays off twice: it
removes the call's overhead, and it lets the compiler "more effectively
apply other optimisation strategies" — constant folding, dead-code
elimination, bounds-check elimination and escape analysis across the old call
boundary (Cheney, "inlining optimisations in Go").
Cheney's worked example drops a max call from 2.24 ns/op to 0.514 ns/op
(a 77.96% delta) purely by allowing it to inline (same source).
This skill owns why a function does or doesn't inline and how to read the
decision. Route the downstream wins it unlocks to their own skills (§8).
1. The cost budget
The inliner walks the callee's AST and charges each node against a budget.
The constant is inlineMaxBudget = 80 (nodes), defined in
src/cmd/compile/internal/inline/inl.go (the budget block at line ~48-60 of
that file). When the running budget goes negative the function is rejected
with function too complex: cost N exceeds budget 80 (inl.go, the
v.reason = fmt.Sprintf("function too complex: cost %d exceeds budget %d", …)
branch).
Rough costs from inl.go:
- Most nodes cost 1.
- A call inside the callee costs
inlineExtraCallCost = 57 — this is the
mid-stack tax (inl.go: inlineExtraCallCost = 57 // 57 was benchmarked to provide most benefit). One nested call ≈ 70% of the whole budget, so a
function with two or three calls usually will not inline.
- A closure literal costs 15 (
v.budget -= 15).
panic costs 1; append costs 0; conversions that emit no code are free.
Two boosted budgets exist: a hot call site under PGO gets
inlineHotMaxBudget = 2000 (inl.go), and a closure called exactly once gets
inlineClosureCalledOnceCost = 10 * inlineMaxBudget (= 800). The first is
go-perf-pgo's domain.
2. Mid-stack inlining
Originally Go only inlined leaf functions (no calls of their own).
Mid-stack inlining — inlining functions that themselves contain calls — was
accepted as proposal 19348
and enabled by default in Go 1.12. That is why the inlineExtraCallCost tax
above exists: non-leaf functions are candidates, they just spend budget fast.
This is what makes small wrapper/accessor layers free.
Inlining is also recursive: once a callee is inlined, calls it contained
become candidates in the caller's frame, subject to the same budget
(Cheney).
So a thin two-layer wrapper can collapse entirely, but a deep call chain
exhausts the budget partway down — read -m to see exactly where it stops.
3. What blocks inlining
Two categories. Impossible regardless of size (InlineImpossible in
inl.go): marked go:noinline, no function body (pure-assembly or external
funcs), go:norace under -race, and a few go: safety pragmas.
Rejected while walking the body (the hairyVisitor cases in inl.go):
recover() → "call to recover".
go, defer, and tail calls → "unhandled op" (the
case ir.OGO, ir.ODEFER, ir.OTAILCALL branch). defer still blocks
inlining today — open-coded defer (1.14) made defer cheap at runtime but
did not make deferring functions inlinable. Do not repeat the folklore that
this was only a pre-1.14 issue.
- Closures can inline since the
InlFuncsWithClosures debug flag defaults to
1 (src/cmd/compile/internal/base/flag.go:
Debug.InlFuncsWithClosures = 1); they merely cost 15. They only hard-block
if that flag is set to 0.
- Going over the 80-node budget →
"function too complex".
Type switches, select, and big switch/range bodies are not special-cased
blockers in current Go — they fail (when they fail) by spending budget, not
by a hard rule. Verify with -m, do not assume.
A call through an interface is not inlined unless the compiler can prove
the concrete type (devirtualization); otherwise the target is unknown at
compile time. PGO devirtualizes measured-hot interface calls — see
go-perf-pgo.
4. Seeing the decision
-m is documented as "Print optimization decisions. Higher values or
repetition produce more detail" (cmd/compile).
go build -gcflags=-m ./...
go build -gcflags=-m=2 ./...
go build -gcflags='example.com/pkg=-m=2' ./...
go build -gcflags=-l ./...
Read the lines literally:
can inline f with cost 42 as: ... — f is a candidate (cost shown under
-m=2).
inlining call to f — a specific call site was inlined.
cannot inline f: function too complex: cost 96 exceeds budget 80 — over
budget.
cannot inline g: marked go:noinline / unhandled op DEFER — a hard
blocker, with the exact reason.
5. //go:noinline
//go:noinline "must be followed by a function declaration … specifies that
calls to the function should not be inlined, overriding the compiler's usual
optimization rules" (cmd/compile directives).
It is a diagnostic/benchmark tool, not a speedup: pin a function
non-inlinable to A/B its call cost, or to keep a benchmark honest. There is no
//go:inline to force the other direction — to make a hot function inline you
reduce its cost (split it, hoist the rare/slow path into a separate noinline
helper, drop a defer) until -m reports can inline, then measure
(go-perf-methodology).
6. Why it matters downstream
Inlining is the enabler, not just the call saver:
- Escape analysis: a returned
*T that would escape across a real call can
stay on the caller's stack once inlined → go-perf-escape-analysis.
- BCE: with the bound and the index in one frame, the prover can drop the
check →
go-perf-bounds-check-elimination.
- Constant folding / DCE:
max(-1, i) folds to i once inlined
(Cheney).
The tradeoff is code size: inlining duplicates the body at every call
site, which can bloat the binary and pressure the instruction cache. That is
why the budget exists and why blindly chasing inlining everywhere is wrong —
inline the hot path the profile proves, not every accessor.
7. Don't
- Don't claim a function is inlined without
-m output — it is the only
evidence.
- Don't put a
defer, go, or recover in a hot leaf you need inlined; each
hard-blocks (inl.go).
- Don't assume small means inlinable: one nested call costs 57 of the 80
budget.
- Don't reach for
//go:noinline expecting a speedup; it only prevents
inlining.
- Don't hand-split a function "to force inlining" and stop there — re-check
-m and benchmark; the split may not have helped.
- Don't expect interface calls to inline unless devirtualized (
go-perf-pgo).
8. Routing to Related Skills
- Measure first / is this worth it →
go-perf-methodology. Inlining a
cold call moves nothing; confirm the path is hot before tuning it.
- PGO-driven inlining of measured-hot calls + devirtualization →
go-perf-pgo. PGO is the main reason to care about hot interface calls.
- What escapes / stack-vs-heap reasoning →
go-perf-escape-analysis.
Inlining can keep a returned *T on the caller's stack; that skill owns the
escape verdict.
- Bounds-check elimination →
go-perf-bounds-check-elimination. Inlining
is often the precondition that lets the prover see the bound.
- Register ABI / raw call overhead →
go-perf-compiler-intrinsics.
9. Reference Files
Inlining anti-patterns in LLM-generated Go, each with wrong/right contrast and citations:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml