The idea in one minute#
The Go compiler favours fast builds over aggressive optimization, but it does a specific set of things well, and knowing them tells you which code shapes are fast. Inlining replaces a call to a small function with its body. Escape analysis keeps values off the heap (III.02). Bounds-check elimination removes the index checks it can prove unnecessary. Devirtualization turns interface calls into direct ones. Profile-guided optimization (PGO) uses a CPU profile from production to do more of all of these where it matters.
What it does not do: auto-vectorize loops, reorder struct fields, or unroll aggressively. In Go you get those by writing the code that way.
An analogy#
A good editor working to a deadline. They will tighten sentences, remove obvious repetition and fix what is clearly wrong — quickly, every time. They will not restructure your argument. If you hand them notes on which chapters readers actually read (a profile), they spend their effort there.
A picture#
flowchart TB SRC["Go source"] --> PARSE["Parse and type-check"] PARSE --> IR["Intermediate form"] IR --> INL["Inline small functions<br/>budget: 80 'nodes'"] INL --> ESC["Escape analysis<br/>stack or heap"] ESC --> DEV["Devirtualize<br/>interface call to direct call"] DEV --> SSA["SSA optimizations<br/>bounds-check elimination,<br/>dead code, constant folding"] SSA --> GEN["Machine code<br/>register allocation"] PGO["default.pgo<br/>a CPU profile"] -.->|"raises the inline budget on hot paths,<br/>guides devirtualization"| INL PGO -.-> DEV class SRC neutral class PARSE,IR,SSA,GEN compute class INL,ESC,DEV queue class PGO memory
How it really works#
Inlining#
A call costs a few nanoseconds: save registers, jump, set up a frame, return. More importantly, a call is a wall the optimizer cannot see past. Inlining removes the call and lets escape analysis, constant folding and bounds-check elimination work across what used to be two functions.
- Each function has a cost measured in syntax nodes; functions under the budget (80) are inlinable. Mid-stack inlining means non-leaf functions qualify too.
- Not inlined: functions that are too large, that
recover, or that are marked//go:noinline; calls through an interface or function value whose target is unknown. - See decisions with
go build -gcflags=-m(can inline f,inlining call to f); add a second-mfor the cost.
Practical use: keep hot-path helpers small. A common trick is the fast-path/slow-path split — a tiny function that handles the common case inline and calls a separate, larger function for the rare case:
func (b *Buf) WriteByte(c byte) {
if b.n < len(b.data) { // inlined fast path
b.data[b.n] = c
b.n++
return
}
b.grow(c) // out-of-line slow path
}Bounds-check elimination#
Every s[i] is checked: if uint(i) >= uint(len(s)) { panic }. A predictable branch is cheap,
but in a tight loop it is still several instructions per element and it blocks other
optimizations. The compiler drops the check when it can prove the index is in range:
for i := range s { _ = s[i] } // proven: no check
func dot(a, b []float32) (s float32) {
b = b[:len(a)] // one check here tells the compiler len(b) >= len(a)
for i := range a {
s += a[i] * b[i] // no checks in the loop
}
return
}
_ = buf[7] // one check up front...
x := uint64(buf[0]) | uint64(buf[1])<<8 /* ... buf[7] */ // ...none belowgo build -gcflags=-d=ssa/check_bce/debug=1 prints every bounds check that remains.
Devirtualization#
If the compiler can see the concrete type behind an interface value, it calls the method directly — and can then inline it. This happens automatically when a value is created and used in the same function (after inlining).
Profile-guided optimization#
# 1. Take a CPU profile from a representative production instance (30 s is enough).
curl -o default.pgo 'http://prod-host:6060/debug/pprof/profile?seconds=30'
# 2. Put default.pgo in the main package directory and commit it.
# 3. Build as usual. go build picks it up automatically (-pgo=auto is the default).
go build ./cmd/serverWith a profile, the compiler:
- inlines hot call sites that exceed the normal budget;
- devirtualizes hot interface calls by guessing the most common concrete type and adding a quick type check with a direct, inlinable call behind it;
- lays out code so hot paths are contiguous.
Typical gains are 2–14% CPU for no code change. The profile does not need to be fresh or exact: it is matched by function name and tolerates source drift. Refresh it now and then as part of the release process.
What Go does not optimize for you#
| Not done | Your options |
|---|---|
| Auto-vectorization (SIMD) | Assembly, the experimental simd packages, or a library (lesson 04) |
| Field reordering | Order fields yourself (II.04) |
| Heavy loop unrolling | Unroll by hand where measured: process 4 or 8 elements per iteration |
| Removing a bounds check it cannot prove | Add a hint: _ = s[n-1], or reslice to a known length |
| Cross-module whole-program optimization | Keep hot paths in one package; PGO helps across packages |
Manual 4-way unrolling with separate accumulators matters for floating-point loops in
particular: s += a[i]*b[i] is a chain where each addition waits for the previous one. Four
independent accumulators let the CPU run four additions at once — often a 2–3× speed-up with no
SIMD at all. Module VI uses this for its dot product.
Reading the generated code#
go build -gcflags=-S . 2>&1 | less # assembly as the compiler emits it
go tool objdump -s 'main\.dot' ./app # disassemble one function from a binary
go tool pprof -disasm=dot cpu.out # per-instruction CPU samplesYou rarely need to. The exceptions are a hot inner loop where you want to confirm the bounds checks are gone, or that a call was inlined.
Build flags worth knowing#
| Flag | Effect |
|---|---|
-gcflags=-m | Inlining and escape decisions |
-gcflags='-N -l' | Disable optimization and inlining (for debuggers) |
-ldflags='-s -w' | Strip symbols and debug info: smaller binary |
-trimpath | Remove local paths: reproducible builds |
-race, -msan, -asan | Sanitizers |
GOAMD64=v3 | Allow AVX2/BMI/FMA instructions on x86-64 (default v1 is the 2003 baseline) |
GOARM64=v8.2 etc. | Likewise for ARM64 |
GOAMD64=v3 is a free few percent for numeric code on any server built in the last decade —
at the price of the binary refusing to run on older CPUs.
Code#
// compiler.go — inlining, bounds-check hints and manual unrolling, each measured.
package main
import (
"fmt"
"testing"
)
var (
sinkI uint32
sinkF float32
)
//go:noinline
func mulNoInline(a, b uint32) uint32 { return a * b }
func mulInline(a, b uint32) uint32 { return a * b }
// The compiler knows i < len(a) but not i < len(b): b[i] is checked on every iteration.
func dotChecked(a, b []uint32) (s uint32) {
for i := range a {
s += a[i] * b[i]
}
return
}
// One reslice proves len(b) >= len(a); the loop has no bounds checks.
func dotBCE(a, b []uint32) (s uint32) {
b = b[:len(a)]
for i := range a {
s += a[i] * b[i]
}
return
}
func dotInlinedCall(a, b []uint32) (s uint32) {
b = b[:len(a)]
for i := range a {
s += mulInline(a[i], b[i])
}
return
}
func dotRealCall(a, b []uint32) (s uint32) {
b = b[:len(a)]
for i := range a {
s += mulNoInline(a[i], b[i])
}
return
}
//go:noinline
func dotFuncValue(a, b []uint32, mul func(uint32, uint32) uint32) (s uint32) {
b = b[:len(a)]
for i := range a {
s += mul(a[i], b[i])
}
return
}
// Floating point: one accumulator is a chain in which every add waits for the previous one.
func fdot1(a, b []float32) (s float32) {
b = b[:len(a)]
for i := range a {
s += a[i] * b[i]
}
return
}
// Four independent accumulators: the CPU overlaps four chains.
func fdot4(a, b []float32) float32 {
b = b[:len(a)]
var s0, s1, s2, s3 float32
i := 0
for ; i+4 <= len(a); i += 4 {
s0 += a[i] * b[i]
s1 += a[i+1] * b[i+1]
s2 += a[i+2] * b[i+2]
s3 += a[i+3] * b[i+3]
}
for ; i < len(a); i++ {
s0 += a[i] * b[i]
}
return s0 + s1 + s2 + s3
}
func main() {
const n = 4096
a, b := make([]uint32, n), make([]uint32, n)
fa, fb := make([]float32, n), make([]float32, n)
for i := range a {
a[i], b[i] = uint32(i%7), uint32(i%5)
fa[i], fb[i] = float32(i%7), float32(i%5)
}
run := func(name string, f func()) {
r := testing.Benchmark(func(tb *testing.B) {
for i := 0; i < tb.N; i++ {
f()
}
})
fmt.Printf(" %-36s %6d ns (%.2f ns per element)\n", name, r.NsPerOp(), float64(r.NsPerOp())/n)
}
fmt.Println("integer dot product, 4096 elements")
run("bounds check on b[i]", func() { sinkI = dotChecked(a, b) })
run("bounds checks eliminated", func() { sinkI = dotBCE(a, b) })
run("via an inlinable function", func() { sinkI = dotInlinedCall(a, b) })
run("via a non-inlined function", func() { sinkI = dotRealCall(a, b) })
run("via a function value", func() { sinkI = dotFuncValue(a, b, mulInline) })
fmt.Println("float32 dot product, 4096 elements")
run("one accumulator", func() { sinkF = fdot1(fa, fb) })
run("four accumulators", func() { sinkF = fdot4(fa, fb) })
}Read the integer rows first: a real call per element costs several times the arithmetic it wraps, an inlined one costs nothing, and removing the remaining bounds check is worth a few percent. The float rows show the dependency chain: same operations, half the time.
Remember this#
- Inlining removes call overhead and unlocks other optimizations; keep hot helpers small.
- Bounds checks vanish when the compiler can prove the index; a reslice or an early access gives it the proof.
- PGO: drop a production CPU profile in as
default.pgofor a few percent, free. - Go does not vectorize or unroll for you. Independent accumulators are the cheapest win in numeric loops.
Try it#
- Run
compiler.go. Rank the six versions. Which change was worth the most? - Build it with
-gcflags=-d=ssa/check_bce/debug=1and find which functions still contain bounds checks. - Take a CPU profile of a program of yours, save it as
default.pgo, rebuild, and compare benchmarks withbenchstat.
Check yourself#
- Why does inlining help beyond saving the call itself?
- How does
b = b[:len(a)]remove bounds checks from a loop? - What two things does PGO do with a profile?