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

graph LR src[".go source"] --> parse["parse +<br/>type check"] parse --> ir["IR"] ir --> inl["inlining +<br/>devirtualization"] inl --> esc["escape analysis"] esc --> ssa["SSA: ~50 passes<br/>(CSE, dead code, BCE,<br/>nil check elim, ...)"] ssa --> lower["lower to<br/>machine ops"] lower --> ra["register<br/>allocation"] ra --> obj["object file<br/>(Plan 9 style asm)"] obj --> link["linker → binary"]

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 index a[i] for i < n.
  • Iterate with range over the slice you index.
  • For a fixed-size chunk, convert to an array pointer: p := (*[8]byte)(b[off:]) — one check, then p[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

  1. Why does inlining run before escape analysis, and what does that imply when a function grows past the inlining budget?
  2. What does PGO change in the compiler's decisions, and where does default.pgo go?
  3. Why does _ = b[3] remove three bounds checks?
  4. What does CSEL GE, R5, R3, R3 do, and why does it make branch prediction irrelevant for that loop?
  5. Why is a small hand-written assembly function often slower than the equivalent Go?