Skip to content
gohacks.dev
Performance

Inlining in Go and How to See What Got Inlined or Not

See which Go functions the compiler inlines with gcflags=-m=2: costs against the budget of 80, what blocks inlining, and how little a call costs

9 min read
Summarize in AI
Flat illustration of a flame graph with one hot path.

Inlining is the compiler replacing a call with the body of the function being called. It is the most common optimisation in a Go build, and it is mostly invisible: you write small functions for clarity and the compiler flattens them. When you care about performance, though, you want to know which calls were inlined, what stopped the others, and, more usefully, how much it matters. This article reads the compiler's inlining decisions for a dozen functions, benchmarks calls with and without inlining, and finds that the usual claim ("function calls are expensive in Go, so inline by hand") is mostly wrong on current hardware, while the real benefit of inlining is somewhere you may not look.

Tested with Go 1.25.6 on Apple M1 Max (10 cores, 32 GB), macOS, median of 5 runs of 200 ms each, 1000 calls per iteration

It pairs with escape analysis, where inlining decides whether a value is allocated, and with bounds check elimination. Profile guided optimization raises the inlining budget for hot calls (PGO). The cornerstone is the Go performance guide.

The budget

The compiler gives each function a cost: roughly the number of nodes in its syntax tree after simplification, with extra weight for calls and some constructs. A function whose cost is at most 80 is inlinable. A call to an inlinable function is replaced by its body at the call site. Calls to non inlinable functions stay as calls. The budget is a compile time setting, and for call sites that a PGO profile marks as hot, it is raised sharply.

go build -gcflags=-m=2 prints the decision for every function. The lab keeps only the lines about its own functions, ordered by source line.

The functions

funcs.goGo
package main

import "errors"

var errNeg = errors.New("negative")

// small: cheap, inlined.
func add(a, b int) int { return a + b }

// abs: a branch, still cheap.
func abs(x int) int {
	if x < 0 {
		return -x
	}
	return x
}

// withLoop: a for loop no longer blocks inlining.
func sumTo(n int) int {
	s := 0
	for i := 1; i <= n; i++ {
		s += i
	}
	return s
}

// big: more work than the budget allows.
func mix(a, b, c, d int) int {
	a ^= b << 3
	b += c >> 2
	c ^= d * 31
	d += a & 0xff
	a += b ^ c
	b ^= c + d
	c += d - a
	d ^= a | b
	a ^= d >> 1
	b += a << 2
	c ^= b >> 3
	d += c << 1
	a += d ^ b
	b ^= a + c
	if a > b {
		a, b = b, a
	}
	if c > d {
		c, d = d, c
	}
	return a*b + c*d
}

// withDefer: defer used to prevent inlining.
func withDefer(x int) (r int) {
	defer func() { r++ }()
	return x
}

// withRecover: recover must see the real stack frame, so it is never inlined.
func withRecover() (r int) {
	defer func() { recover() }()
	return 1
}

// withSelect: a select with a default case is cheap enough to inline in current releases.
func withSelect(c chan int) int {
	select {
	case v := <-c:
		return v
	default:
		return -1
	}
}

// checked calls a small function and returns an error: the error path costs budget.
func checked(x int) (int, error) {
	if x < 0 {
		return 0, errNeg
	}
	return add(x, 1), nil
}

//go:noinline
func addNoInline(a, b int) int { return a + b }

type counter struct{ n int }

func (c *counter) inc() { c.n++ }
func (c *counter) incBy(d int) {
	if d < 0 {
		panic("negative")
	}
	c.n += d
}
main.goGo
package main

import (
	"fmt"
	"testing"
)

var x int

func main() {
	cases := []struct {
		name string
		f    func(b *testing.B)
	}{
		{"1000 calls of add (inlined)", func(b *testing.B) {
			for b.Loop() {
				s := 0
				for i := 0; i < 1000; i++ {
					s += add(i, 3)
				}
				x += s
			}
		}},
		{"1000 calls of add, //go:noinline", func(b *testing.B) {
			for b.Loop() {
				s := 0
				for i := 0; i < 1000; i++ {
					s += addNoInline(i, 3)
				}
				x += s
			}
		}},
		{"1000 calls of abs (inlined)", func(b *testing.B) {
			for b.Loop() {
				s := 0
				for i := 0; i < 1000; i++ {
					s += abs(i - 500)
				}
				x += s
			}
		}},
		{"1000 calls of sumTo(8) (inlined)", func(b *testing.B) {
			for b.Loop() {
				s := 0
				for i := 0; i < 1000; i++ {
					s += sumTo(8)
				}
				x += s
			}
		}},
		{"1000 calls of mix (too big)", func(b *testing.B) {
			for b.Loop() {
				s := 0
				for i := 0; i < 1000; i++ {
					s += mix(i, 2, 3, 4)
				}
				x += s
			}
		}},
		{"1000 calls of withDefer", func(b *testing.B) {
			for b.Loop() {
				s := 0
				for i := 0; i < 1000; i++ {
					s += withDefer(i)
				}
				x += s
			}
		}},
		{"1000 calls of counter.inc (inlined)", func(b *testing.B) {
			c := &counter{}
			for b.Loop() {
				for i := 0; i < 1000; i++ {
					c.inc()
				}
			}
			x += c.n
		}},
	}
	for _, c := range cases {
		bench(c.name, c.f)
	}
	fmt.Println(x != 0)
}
bench.goGo
package main

import (
	"flag"
	"fmt"
	"sort"
	"testing"
)

func init() {
	testing.Init()
	flag.Set("test.benchtime", "200ms")
}

// bench runs f five times and prints the median, which damps the noise of a laptop.
func bench(name string, f func(b *testing.B)) {
	const runs = 5
	ns := make([]float64, 0, runs)
	bs := make([]int64, 0, runs)
	as := make([]int64, 0, runs)
	for i := 0; i < runs; i++ {
		r := testing.Benchmark(f)
		ns = append(ns, float64(r.T.Nanoseconds())/float64(r.N))
		bs = append(bs, r.AllocedBytesPerOp())
		as = append(as, r.AllocsPerOp())
	}
	sort.Float64s(ns)
	sort.Slice(bs, func(i, j int) bool { return bs[i] < bs[j] })
	sort.Slice(as, func(i, j int) bool { return as[i] < as[j] })
	fmt.Printf("%-38s %12.1f ns/op %9d B/op %6d allocs/op\n", name, ns[runs/2], bs[runs/2], as[runs/2])
}
Shell
set -e
echo "== what the compiler says about each function (go build -gcflags=-m=2)"
go build -gcflags='-m=2' -o /dev/null . 2>&1 | grep -E 'funcs.go' | grep -E 'can inline|cannot inline' | sed -E 's/^\.\/funcs.go:([0-9]+):[0-9]+: /line \1: /' | sed -E 's/ as: .*$//' | sort -t: -k1.6 -n | uniq
echo
echo "== what it costs"
go run .
Text
== what the compiler says about each function (go build -gcflags=-m=2)
line 8: can inline add with cost 4
line 11: can inline abs with cost 9
line 19: can inline sumTo with cost 22
line 28: cannot inline mix: function too complex: cost 96 exceeds budget 80
line 53: cannot inline withDefer: unhandled op DEFER
line 54: can inline withDefer.func1 with cost 3
line 59: cannot inline withRecover: unhandled op DEFER
line 60: cannot inline withRecover.func1: call to recover
line 65: can inline withSelect with cost 14
line 75: can inline checked with cost 17
line 83: cannot inline addNoInline: marked go:noinline
line 87: can inline (*counter).inc with cost 4
line 88: can inline (*counter).incBy with cost 11

== what it costs
1000 calls of add (inlined)                   975.2 ns/op         0 B/op      0 allocs/op
1000 calls of add, //go:noinline              951.1 ns/op         0 B/op      0 allocs/op
1000 calls of abs (inlined)                  1110.1 ns/op         0 B/op      0 allocs/op
1000 calls of sumTo(8) (inlined)             5017.9 ns/op         0 B/op      0 allocs/op
1000 calls of mix (too big)                  2749.5 ns/op         0 B/op      0 allocs/op
1000 calls of withDefer                      2493.1 ns/op         0 B/op      0 allocs/op
1000 calls of counter.inc (inlined)          2033.6 ns/op         0 B/op      0 allocs/op
true

What the compiler said

FunctionDecision
addcan inline, cost 4
abscan inline, cost 9
sumTo (a for loop)can inline, cost 22
mix (many statements)cannot inline: function too complex: cost 96 exceeds budget 80
withDefercannot inline: unhandled op DEFER
withRecovercannot inline: unhandled op DEFER; its closure: call to recover
withSelect (select with default)can inline, cost 14
checked (returns an error)can inline, cost 17
addNoInlinecannot inline: marked go:noinline
(*counter).inccan inline, cost 4
(*counter).incBy (with a panic)can inline, cost 11

Observations worth knowing:

  • Loops are fine now. Older advice said that a function with a for loop could not be inlined. sumTo with a loop was inlined at cost 22. The same is true of select, switch and closures in current releases.
  • defer and recover still block inlining. defer needs a real stack frame to run its calls when the function returns, and recover must see the frame of the deferred call. Moving a defer out of a tiny function (a lock/unlock pair into the caller) is one of the few worthwhile hand edits, and only if a profile shows it.
  • Cost is not lines. mix has 14 statements and cost 96; checked has a branch and a call and cost 17. Calls cost extra because the callee's cost is added when it is inlined into the caller. Panics, type switches and large literals are expensive too.
  • The budget is a heuristic. A function at 80 is inlined and at 81 is not. Small edits move a function across the line, and the compiler gives no warning. The way to catch it is a benchmark or a test that checks the decision (below).

What it cost

Each benchmark ran 1000 calls inside one iteration, so the loop overhead is amortised:

1000 calls ofns per 1000per call
add, inlined975.20.98 ns
add, //go:noinline951.10.95 ns
abs, inlined1110.11.1 ns
sumTo(8), inlined5017.95.0 ns
mix, not inlinable2749.52.75 ns
withDefer2493.12.5 ns
(*counter).inc, inlined2033.62.0 ns

The first two rows are the point. A real function call, with the arguments passed in registers and a return, cost about the same as the inlined version. The difference, 24 ns over 1000 calls, is noise. On this processor (an Apple M1 Max), a predictable call and return is a few cycles that the out of order core overlaps with the surrounding work. The claim that every call costs ten nanoseconds is a memory of older machines and older calling conventions (Go has passed arguments in registers since 1.17).

So do not hand inline tiny functions. You will make the code worse and gain nothing measurable.

Where inlining pays

It removes allocations. In the escape analysis lab, a helper returning &point{...} cost 13.7 ns and one allocation when it was called, and 2.2 ns and none when it was inlined, because after inlining the compiler could see that the pointer never left the caller. Inlining lets escape analysis see through the abstraction. This is the largest practical benefit of keeping hot helpers small enough to inline.

It enables other optimisations. Constant folding, dead code elimination and bounds check elimination work within a function. Inlining makes more code visible to them. A call with a constant argument to a branchy helper can collapse to a single path once inlined.

It matters most for very small functions in the hottest loops, where the call is a measurable fraction of the work. Getters, comparison functions in sort callbacks and tiny method calls on hot paths are the usual cases. slices.SortFunc with a small comparison is faster than sort.Slice partly because generics let the comparison be inlined (sorting benchmarks).

Making inlining visible in a project

Because the budget is a cliff, a function that matters can fall off it unnoticed. Two practices catch that:

  1. A benchmark with -benchmem on the hot path. An allocation appearing is the signal, as in the escape lab.
  2. A test that runs the compiler. go build -gcflags=-m=2 and grep "can inline hotHelper" in CI fails a change that makes hotHelper too expensive. It is a few lines of shell and it states an intent that otherwise lives in nobody's head. Pin it to the toolchain version, since costs change between releases.

//go:noinline is useful in benchmarks (to stop the compiler removing the work you want to measure) and for functions you want to appear in stack traces and profiles. There is no directive that forces inlining.

How to reduce a function's cost

If a hot function is just over the line and a profile says the call matters:

  • Move the rare path out. Put the error construction or the slow branch in a separate function (marked //go:noinline if needed) and keep the fast path small. This is how the standard library keeps append helpers and sync.Mutex.Lock inlinable.
  • Avoid defer in tiny hot functions.
  • Prefer simple control flow and fewer calls inside. Each call adds the callee's cost.
  • Check again with -m=2.

Checklist

  • go build -gcflags=-m=2 prints cost and reasons; the budget is 80.
  • defer, recover and //go:noinline block inlining; loops and select do not.
  • Do not hand inline: a call cost the same as inlined code in the lab.
  • Keep hot helpers inlinable because it removes allocations and enables other optimisations.
  • Guard important decisions with a benchmark or a compile check in CI.

The behaviour is described in the Go compiler inlining source and the PGO guide.

Also inTooling
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.