Skip to content

The compiler says what it inlines

Level: 301 · anyone who has wondered whether a small function costs a call

One line: go build -gcflags=-m prints can inline add for every function the compiler may paste into its callers and inlining call to add at every call it did paste, and says nothing about refusals; -m=2 adds the reason for each one — unhandled op DEFER, call to recover, function too complex: cost … exceeds budget 80, marked go:noinline — and that budget is a constant in the compiler that profile-guided optimisation raises to 2000 for hot calls.

One file, five functions and main: a two-line add, a sumTo with a for loop, a safeDivide with a defer whose closure calls recover, a big with seventeen statements, and a keepOut that is small enough but carries //go:noinline. The script builds it three ways and keeps only the inlining lines:

Verified output of the_compiler_says_what_it_inlines_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go mod init example
$ go build -gcflags=-m . 2>&1 | grep inlin
./main.go:4:6: can inline add
./main.go:7:6: can inline sumTo
./main.go:52:13: inlining call to add
./main.go:52:26: inlining call to sumTo
exit status 0
$ go build -gcflags=-m . 2>&1 | grep -c 'cannot inline'
0
$ go build -gcflags=-m=2 . 2>&1 | grep 'cannot inline'
./main.go:16:6: cannot inline safeDivide: unhandled op DEFER
./main.go:17:8: cannot inline safeDivide.func1: call to recover
./main.go:26:6: cannot inline big: function too complex: cost (a number) exceeds budget 80
./main.go:49:6: cannot inline keepOut: marked go:noinline
./main.go:51:6: cannot inline main: function too complex: cost (a number) exceeds budget 80
exit status 0

Reading the output

-m: the yes list. can inline add and can inline sumTo name the functions the compiler is willing to paste into callers. A for loop has not been an obstacle since Go 1.16, whose release notes ↗ say the compiler can now inline functions with non-labeled for loops, method values and type switches; Go 1.18 ↗ added range loops and labeled for loops. The two inlining call to lines at 52:13 and 52:26 are the calls in main that were replaced by the bodies: the compiled main holds no call instruction for add or sumTo.

-m never says no. grep -c 'cannot inline' over the same output finds nothing. The reasons a function is not inlined only appear at -m=2 (or -m -m), which is the flag to reach for when a function you expected to see is missing from the yes list.

The five refusals at -m=2. safeDivide: unhandled op DEFER — a function containing a defer is not inlined. Its closure safeDivide.func1: call to recover — recover works only in the deferred call itself, and pasting that closure elsewhere would break the rule. big: function too complex, a cost over the budget of 80. keepOut: marked go:noinline, the directive ↗ that forbids it. main: too complex as well, which costs nothing, since nobody calls main.

The blanked cost, and the budget. The cost is the compiler's weighted count of the function's nodes, computed in inl.go ↗: inlineMaxBudget = 80 is the line every function is measured against, and a call to a function that is not itself inlined adds inlineExtraCallCost = 57 on its own. The numbers are an estimate, and estimates change between compiler versions, so the key replaces them; the demo prints them as this compiler computed them:

Real runs — go1.25.5, x86-64 Mac, 1 run, 2026-09-23
$ go build -gcflags=-m=2 . 2>&1 | grep inlin
./main.go:4:6: can inline add with cost 4 as: func(int, int) int { return a + b }
./main.go:7:6: can inline sumTo with cost 22 as: func(int) int { total := 0; for loop; return total }
./main.go:16:6: cannot inline safeDivide: unhandled op DEFER
./main.go:17:8: cannot inline safeDivide.func1: call to recover
./main.go:26:6: cannot inline big: function too complex: cost 116 exceeds budget 80
./main.go:49:6: cannot inline keepOut: marked go:noinline
./main.go:51:6: cannot inline main: function too complex: cost 226 exceeds budget 80
./main.go:52:13: inlining call to add
./main.go:52:26: inlining call to sumTo

add costs 4 and sumTo 22; big, with its seventeen assignments, costs 116. Run bash demo/inline_full.sh from this folder to see the numbers your compiler gives.

PGO raises the budget for hot calls. The same file declares inlineHotMaxBudget int32 = 2000: when the build has a profile, call sites the profile marks as hot are measured against that budget instead of 80, so a big that a CPU profile shows in the hot path can be inlined after all. Profile-guided optimization ↗ describes the workflow — collect a profile with runtime/pprof as A CPU profile names the hot function does, save it as default.pgo in the main package's directory, and go build uses it by default since Go 1.21 ↗. It is not run here. (Not machine-checked here.)

What b.Loop does with this. B.Loop ↗'s documentation says that keeping arguments and results alive inside a benchmark loop is implemented by disabling inlining of the functions called there — the benchmark lesson relies on it. A benchmark of add therefore measures a call, which is the honest thing to measure.

the_compiler_says_what_it_inlines_sh.sh

the_compiler_says_what_it_inlines_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# The compiler reports its inlining decisions under -gcflags=-m: which
# functions it can inline and which calls it inlined. The reason a function is
# NOT inlined only appears at -m=2. This script builds one file with six
# functions and prints both views. The cost numbers in the -m=2 reasons are the
# compiler's own estimate and can move between compiler versions, so the
# script blanks them; demo/inline_full.sh prints them.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1

cat >main.go <<'GO'
package main

// A few nodes: well under the budget.
func add(a, b int) int { return a + b }

// A for loop is no obstacle since Go 1.16.
func sumTo(n int) int {
    total := 0
    for i := 1; i <= n; i++ {
        total += i
    }
    return total
}

// defer and recover each rule a function out.
func safeDivide(a, b int) (quotient int, ok bool) {
    defer func() {
        if recover() != nil {
            ok = false
        }
    }()
    return a / b, true
}

// Too many nodes for the budget of 80.
func big(x int) int {
    a := x * 3
    b := a + 7
    c := b ^ 0x55
    d := c << 2
    e := d - x
    f := e * e
    g := f % 1000
    h := g + a
    i := h ^ b
    j := i + c
    k := j * d
    l := k - e
    m := l + f
    n := m ^ g
    o := n + h
    p := o * i
    return p - j
}

// Small enough, but told not to.
//
//go:noinline
func keepOut(a, b int) int { return a * b }

func main() {
    println(add(2, 3), sumTo(10), big(4), keepOut(6, 7))
    println(safeDivide(7, 0))
}
GO

say() { printf '$ %s\n' "$*"; }

say 'go mod init example'
go mod init example >/dev/null 2>&1 || exit 1

say 'go build -gcflags=-m . 2>&1 | grep inlin'
go build -gcflags=-m . 2>&1 | grep inlin
echo "exit status ${PIPESTATUS[0]}"

say "go build -gcflags=-m . 2>&1 | grep -c 'cannot inline'"
go build -gcflags=-m . 2>&1 | grep -c 'cannot inline'

say "go build -gcflags=-m=2 . 2>&1 | grep 'cannot inline'"
go build -gcflags=-m=2 . 2>&1 | grep 'cannot inline' | sed -E 's/cost [0-9]+ exceeds/cost (a number) exceeds/'
echo "exit status ${PIPESTATUS[0]}"

What to do

  • Ask the compiler; do not guess. go build -gcflags=-m=2 ./... 2>&1 | grep 'cannot inline' on a package lists every refusal with its reason, in seconds.
  • Keep hot small functions free of defer and recover. Move the defer into a wrapper that the hot function calls, or that calls the hot function, so the tight part can still be inlined.
  • Use //go:noinline for experiments, rarely for production. It is the right tool to keep a function visible in a profile or a benchmark, and the wrong one to second-guess the inliner in shipping code.
  • Let a profile raise the budget, not a rewrite: PGO is the supported way to get a big hot function inlined.
  • Escape analysis rides along. The same -m output names the variables that move to the heap — Escape analysis decides stack or heap.

Practice

A warehouse program has three functions: clamp(weight, limit int) int, totalWeight(weights []int) int with a range loop, and closeAll(files []*os.File) with a defer f.Close() inside its loop. Say which of the three the compiler inlines and why not the others — by asking it: write the file to a temporary directory, run go build -gcflags=-m=2 on it from your program with os/exec, and print one verdict per function.

Solution

the_compiler_says_what_it_inlines_kata_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// Kata: three functions from a warehouse program -- a clamp, a loop that
// totals weights, and a function that closes every file in a slice with a
// deferred call inside the loop. Say which the compiler inlines and why not
// the others, by asking it: write the file, run `go build -gcflags=-m=2`, and
// read the verdicts back.
//
//  go build the_compiler_says_what_it_inlines_kata_go.go && ./the_compiler_says_what_it_inlines_kata_go
package main

import (
    "fmt"
    "os"
    "os/exec"
    "path/filepath"
    "regexp"
    "strings"
)

const source = `package main

import "os"

func clamp(weight, limit int) int {
    if weight > limit {
        return limit
    }
    return weight
}

func totalWeight(weights []int) int {
    total := 0
    for _, w := range weights {
        total += w
    }
    return total
}

func closeAll(files []*os.File) {
    for _, f := range files {
        defer f.Close()
    }
}

func main() {
    println(clamp(50, 40), totalWeight([]int{1, 2, 3}))
    closeAll(nil)
}
`

func main() {
    dir, err := os.MkdirTemp("", "inline-kata")
    if err != nil {
        panic(err)
    }
    defer os.RemoveAll(dir)
    src := filepath.Join(dir, "warehouse.go")
    if err := os.WriteFile(src, []byte(source), 0o644); err != nil {
        panic(err)
    }

    cmd := exec.Command("go", "build", "-gcflags=-m=2", "-o", filepath.Join(dir, "warehouse"), src)
    cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
    report, err := cmd.CombinedOutput()
    if err != nil {
        panic(fmt.Sprintf("%v\n%s", err, report))
    }

    // "./warehouse.go:5:6: can inline clamp with cost 9 as: ..." or
    // "./warehouse.go:20:6: cannot inline closeAll: unhandled op DEFER"
    verdict := regexp.MustCompile(`^\S+ (can|cannot) inline (\w+)(?:: (.*?))?(?: with cost \d+ as: .*)?$`)
    for _, name := range []string{"clamp", "totalWeight", "closeAll"} {
        for _, line := range strings.Split(string(report), "\n") {
            m := verdict.FindStringSubmatch(line)
            if m == nil || m[2] != name {
                continue
            }
            if m[1] == "can" {
                fmt.Printf("%-12s inlined: yes\n", name)
            } else {
                fmt.Printf("%-12s inlined: no  (%s)\n", name, m[3])
            }
        }
    }
}

Verified output of the_compiler_says_what_it_inlines_kata_go.go — regenerated by tools/run_examples.py, never hand-typed.

clamp        inlined: yes
totalWeight  inlined: yes
closeAll     inlined: no  (unhandled op DEFER)

closeAll is refused for its defer; that the defer sits in a loop is a second problem, Defer in a loop runs at function end, which the inliner does not need to mention.

In other languages

  • Rust has no budget flag to read, but the #[inline] and #[inline(never)] attributes ↗ are hints and orders to LLVM's inliner in the way //go:noinline is an order to Go's; the Rust library's What the optimizer does ↗ shows what LLVM makes of a small function once it is allowed to.
  • C's inline is a hint the compiler may ignore; GCC's -Winline ↗ warns when a function declared inline could not be inlined, and __attribute__((noinline)) is the counterpart of //go:noinline.

Sources