PidokuInfra

The Compiler

Advanced 55 min Difficulty 4/5 Topic 02 of 05

Prerequisites III.02, 01

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 -m for 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:

Go
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:

Go
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 below

go 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#

Shell
# 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/server

With 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 doneYour options
Auto-vectorization (SIMD)Assembly, the experimental simd packages, or a library (lesson 04)
Field reorderingOrder fields yourself (II.04)
Heavy loop unrollingUnroll by hand where measured: process 4 or 8 elements per iteration
Removing a bounds check it cannot proveAdd a hint: _ = s[n-1], or reslice to a known length
Cross-module whole-program optimizationKeep 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#

Shell
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 samples

You 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#

FlagEffect
-gcflags=-mInlining and escape decisions
-gcflags='-N -l'Disable optimization and inlining (for debuggers)
-ldflags='-s -w'Strip symbols and debug info: smaller binary
-trimpathRemove local paths: reproducible builds
-race, -msan, -asanSanitizers
GOAMD64=v3Allow 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#

Go
// 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.pgo for a few percent, free.
  • Go does not vectorize or unroll for you. Independent accumulators are the cheapest win in numeric loops.

Try it#

  1. Run compiler.go. Rank the six versions. Which change was worth the most?
  2. Build it with -gcflags=-d=ssa/check_bce/debug=1 and find which functions still contain bounds checks.
  3. Take a CPU profile of a program of yours, save it as default.pgo, rebuild, and compare benchmarks with benchstat.

Check yourself#

  1. Why does inlining help beyond saving the call itself?
  2. How does b = b[:len(a)] remove bounds checks from a loop?
  3. What two things does PGO do with a profile?

↑↓ navigate↵ openesc close