Compiler, Inlining, and Assembly¶
Why look under the source¶
Performance work often ends with a question only the generated code
can answer: did the compiler do what I think it did?
Was the function inlined? Is there a bounds check in the inner loop?
Did that if become a branch or a conditional move? Go's toolchain
makes every one of these questions answerable with a flag. This page
covers the pipeline, the decisions that matter for performance, and
enough assembly to read the output.
Measurements and output: Go 1.26.4, darwin/arm64.
The pipeline¶
The order matters for reasoning: inlining happens before escape analysis, so inlining changes what escapes (Escape Analysis); and SSA optimizations like bounds-check elimination see the code after inlining, so a check that cannot be removed in a small function may disappear once it is inlined into a loop that proves the index safe.
Inlining¶
Inlining replaces a call with the callee's body. The direct saving is the call overhead (a few ns); the bigger saving is what it enables: constant propagation into the body, escape analysis in the caller's context, bounds-check elimination across the boundary.
The compiler inlines a function if its body's cost (roughly the
number of IR nodes) is at most 80. -gcflags=-m=2 prints the
cost:
bce.go:3:6: can inline LoadBE32 with cost 26 as: func([]byte) uint32 { ... }
bce.go:7:6: can inline LoadBE32Hint with cost 31 as: func([]byte) uint32 { ... }
and, for functions that are not inlined, the reason, for example
cannot inline f: function too complex: cost 112 exceeds budget 80.
Things that historically blocked inlining (loops, defer, closures,
select) have been relaxed release by release; the cost budget is what
remains. Inlining is mid-stack: a non-leaf function can be inlined
too.
Directives:
| Directive | Effect |
|---|---|
//go:noinline |
Never inline. Used in benchmarks to measure a call as written |
//go:nosplit |
Omit the stack-growth check in the prologue; runtime-internal, can crash if the stack overflows |
//go:noescape |
On a function declaration without a body (assembly): its pointer arguments do not escape |
//go:linkname |
Bind to an unexported symbol in another package; increasingly restricted since Go 1.23 — avoid |
Devirtualization converts an interface method call into a direct (inlinable) call when the compiler can see the concrete type:
esc.go:51:15: devirtualizing z.Size to S
esc.go:51:15: inlining call to S.Size
Profile-guided optimization¶
With PGO, the compiler reads a CPU profile of the real workload and uses it to inline hot call sites beyond the normal budget and to devirtualize hot interface calls to their most common concrete type (with a type check and a fallback):
curl -o default.pgo 'http://prod-host:6060/debug/pprof/profile?seconds=30'
mv default.pgo ./cmd/server/ # next to the main package
go build ./cmd/server # -pgo=auto picks up default.pgo
The Go team reports typical gains of 2–14% CPU on real services. Commit the profile to the repository and refresh it periodically; a stale profile still works, it just optimizes yesterday's hot paths.
Bounds-check elimination¶
Every s[i] must panic if i is out of range, so the compiler emits a
compare-and-branch before the access unless it can prove the index
is in range. -d=ssa/check_bce reports the checks that remain:
go build -gcflags='-d=ssa/check_bce/debug=1' ./bce
func LoadBE32(b []byte) uint32 {
return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
} // 4 checks: Found IsInBounds × 4
func LoadBE32Hint(b []byte) uint32 {
_ = b[3] // 1 check here proves b[0..3] are valid
return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
} // 1 check
func SumRange(a []int) (s int) {
for i := range a { s += a[i] } // 0 checks: i < len(a) by construction
return
}
func SumEvery(a []int, idx []int) (s int) {
for _, i := range idx { s += a[i] } // 1 check per iteration: i is data
return
}
The _ = b[3] hint is exactly what encoding/binary does. Other
idioms that help the prover:
- Re-slice once before the loop:
a = a[:n], then indexa[i]fori < n. - Iterate with
rangeover the slice you index. - For a fixed-size chunk, convert to an array pointer:
p := (*[8]byte)(b[off:])— one check, thenp[0]..p[7]are free.
Bounds checks are cheap when they are predictable branches; remove them in measured inner loops, not everywhere.
Reading the assembly¶
go build -gcflags=-S ./pkg 2>&1 | less # compiler output per function
go tool objdump -s 'pkg.FuncName$' ./binary # disassemble a built binary/test
GOSSAFUNC=FuncName go build ./pkg # writes ssa.html: every SSA pass, side by side
Real objdump output for a loop that sums values ≥ 128:
func sumBig(d []int32) int64 {
var s int64
for _, v := range d {
if v >= 128 { s += int64(v) }
}
return s
}
MOVW (R0)(R2<<2), R4 ; v = d[i] (load int32, scaled index)
MOVD R4, R5
ADD R5, R3, R5 ; tmp = s + v
ADD $1, R2, R2 ; i++
CMPW $128, R4 ; v >= 128 ?
CSEL GE, R5, R3, R3 ; s = (v >= 128) ? tmp : s ← no branch
CMP R2, R1 ; i < len(d) ?
BGT -7(PC) ; loop
There is no conditional branch for the if: the compiler computed
both outcomes and picked one with a conditional select (CSEL on
arm64, CMOV on amd64). That is why, in
CPU and Cache Efficiency,
this loop runs at the same speed on sorted and random data — there is
nothing to mispredict — while a slightly more complex body that the
compiler keeps as branches runs 3x slower on random data.
Go assembly in 60 seconds¶
Go uses a Plan 9-derived syntax shared across architectures:
| Element | Meaning |
|---|---|
MOVD src, dst |
Operands are source, destination (opposite of Intel syntax) |
R0–R30 (arm64), AX, BX, ... (amd64) |
Real registers |
SB |
Static base pseudo-register: globals and functions are name(SB) |
FP, SP |
Pseudo frame pointer / stack pointer for arguments and locals in hand-written asm |
TEXT ·Add(SB), NOSPLIT, $0-24 |
Function Add in this package, no stack check, 0 bytes of frame, 24 bytes of args+results |
Since Go 1.17 (amd64) and 1.18 (arm64), functions use a register-based calling convention (ABIInternal): the first arguments and results go in registers. Hand-written assembly uses the older stack-based ABI0 and is called through a small wrapper, so assembly functions are never inlined — for small operations, pure Go that the compiler optimizes is often faster than a hand-written assembly routine.
When to write assembly: almost never in application code. The
standard library uses it for crypto, hashing, bytes.IndexByte-style
SIMD, and runtime internals. Before reaching for it, check whether the
standard library already has a vectorized primitive (bytes.Index,
bytes.Equal, crc32.Update with hardware CRC, slices.Sort) — most
"I need SIMD" problems are solved by calling one.
Compiler and linker flags worth knowing¶
| Flag | Use |
|---|---|
-gcflags='-m' / -m=2 |
Escape and inlining decisions |
-gcflags='-d=ssa/check_bce' |
Remaining bounds checks |
-gcflags='all=-N -l' |
Disable optimizations and inlining everywhere — for debugging with Delve, never for benchmarks |
-gcflags=-S |
Assembly listing |
-ldflags='-s -w' |
Strip symbol table and DWARF (smaller binary; breaks debuggers and symbolized perf, keeps Go stack traces) |
-ldflags='-X main.version=1.2.3' |
Set a string variable at link time |
-trimpath |
Remove local paths from the binary (reproducible builds) |
-race |
Race detector instrumentation (5–10x slower, more memory) |
-pgo=file.pprof |
Profile-guided optimization |
GOAMD64=v3 |
Allow AVX2/BMI2 etc. on amd64 (default v1 = baseline x86-64) |
Common pitfall¶
Benchmarking code that the compiler optimized differently from
production. A developer extracts a function into a micro-benchmark,
sees 0.3 ns/op, and concludes it is free. In the benchmark, the
function was inlined with constant arguments and its result unused, so
the compiler deleted the work entirely (measured in
Benchmarking Methodology:
0.31 ns/op for a hash that really costs ~1–2 ns). In production the
same function is called through an interface, is not inlined, and its
argument escapes. Before trusting a micro-benchmark number, look at
-gcflags=-m for both contexts, and when the number looks too good to
be true, read the disassembly of the benchmark loop — an empty loop
body is easy to spot.
Review questions¶
- Why does inlining run before escape analysis, and what does that imply when a function grows past the inlining budget?
- What does PGO change in the compiler's decisions, and where does
default.pgogo? - Why does
_ = b[3]remove three bounds checks? - What does
CSEL GE, R5, R3, R3do, and why does it make branch prediction irrelevant for that loop? - Why is a small hand-written assembly function often slower than the equivalent Go?