# Go Perf Inlining

> 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.

- Skill: `ctoth/go-perf-inlining` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-perf-inlining`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-perf-inlining/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Finance & Business
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-perf-inlining

---


# 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"](https://dave.cheney.net/2020/04/25/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](https://github.com/golang/proposal/blob/master/design/19348-midstack-inlining.md)
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](https://dave.cheney.net/2020/04/25/inlining-optimisations-in-go)).
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](https://pkg.go.dev/cmd/compile)).

```sh
# What can inline, what got inlined, and why something didn't:
go build -gcflags=-m ./...

# More detail (cost accounting, escape reasoning):
go build -gcflags=-m=2 ./...        # same as -gcflags='-m -m'

# One package, keep the noise down:
go build -gcflags='example.com/pkg=-m=2' ./...

# Prove inlining is the variable: disable it and re-measure.
go build -gcflags=-l ./...          # -l: "Disable inlining."
```

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](https://pkg.go.dev/cmd/compile#hdr-Compiler_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](https://dave.cheney.net/2020/04/25/inlining-optimisations-in-go)).

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:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

