← All posts

What Go’s source-level inliner changes for hot paths

Go’s new source-level inliner changes how you approach performance work in hot Go paths. The win is not the missing call itself, it is the compiler visibility you gain across the boundary, and the maintenance cost you take on in return.

Go 1.24 changes a part of the toolchain most teams never inspect until build time or latency starts to hurt. The Go blog post, “//go:fix inline and the source-level inliner”, describes a new source-level inliner driven by the //go:fix inline directive. One quoted fact matters to practitioners: the tool rewrites call sites in source, before the compiler’s usual optimization stages.

Why care. Because inlining in Go is no longer only a compiler-side heuristic hidden inside one release. You now have a way to make specific call boundaries disappear in checked-in code, with behavior you can review in diffs, benchmark in CI, and reason about across compiler versions. For teams building low-latency services, SDKs, blockchain nodes, or cryptographic hot paths, this shifts optimization from folklore to an engineering decision.

The right response is not to inline everything. It is to inspect where call overhead, escape behavior, bounds checks, interface dispatch, and allocation patterns interact. Source-level inlining gives you a sharper tool. It also gives you a new way to make code harder to read, harder to test, and easier to break if you use it without measurement.

Inspect the boundary, not the function

Most Go performance work starts with the wrong unit of analysis. Teams look for a slow function. Inlining asks a different question. What changes if this call boundary disappears.

That boundary affects several compiler decisions:

  • Escape analysis n- Devirtualization opportunities
  • Constant propagation
  • Dead-code elimination
  • Bounds-check elimination
  • Stack growth checks and register pressure

A small wrapper around sha256.Sum256, a state transition helper in a blockchain execution path, or a generic helper in a Merkle proof verifier might look cheap in isolation. The cost often sits in what the compiler fails to see across the call.

A practical review starts with profiles and compiler diagnostics:

go test -bench=. -benchmem ./...
go test -run=^$ -bench=BenchmarkHotPath -cpuprofile=cpu.out ./pkg
go tool pprof -http=:0 cpu.out

go build -gcflags='all=-m=2' ./... 2> inline.txt

Read -m=2 output for three things:

  • Functions already inlined by the compiler
  • Calls blocked from inlining, and why
  • Variables escaping to the heap near wrapper layers

If a helper already inlines, the new source-level feature buys you little. If a helper does not inline because of structure, and the missed optimization sits on a hot path, the directive becomes worth testing.

This matters in blockchain systems because hot paths repeat at scale. Signature verification, trie navigation, encoding, hashing, transaction prechecks, and mempool filters run many times per block or request. A tiny per-call cost multiplies fast.

Verify the effect in generated code and benchmarks

Do not stop at wall-clock benchmarks. Source-level inlining changes code shape. You want proof of what changed.

Start with microbenchmarks around the boundary you plan to remove. Keep them narrow.

func BenchmarkVerifyStep(b *testing.B) {
    input := makeTestInput()
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        _ = verifyStep(input)
    }
}

Then compare three states:

  1. Baseline code
  2. Compiler-only inlining on current Go
  3. Source rewritten with the inline fix

What to measure:

  • ns/op
  • B/op
  • allocs/op
  • Binary size
  • Instruction counts in assembly for the hot section

Useful commands:

benchstat old.txt new.txt

go tool compile -S file.go > file.s

go build -o app ./cmd/node
size app

In many cases, the main win is not the call itself. It is the second-order effect. A local temporary stops escaping. An interface value becomes concrete. A loop loses bounds checks. A short branch folds away because a constant argument is visible at the call site.

The opposite also happens. Inlined source grows enough to hurt instruction-cache locality. A neat helper turns into repeated code across many call sites. Benchmarks improve in isolation and regress under whole-program load.

For networked systems and blockchain nodes, always test both micro and macro behavior. A benchmark on one verifier function is useful. A sync benchmark, block import benchmark, or end-to-end RPC latency run is what protects you from local wins that become system losses.

Read where inlining commonly goes wrong

Source-level inlining is a maintenance trade. The failure mode is rarely correctness on day one. It is code quality drift over time.

Common trouble spots:

Generic helpers

Generics often hide useful abstractions in Go services and protocol code. Inlining a generic helper into several call sites might improve one monomorphized case and bloat others. Inspect code growth and keep an eye on compile times.

Error handling wrappers

Many teams wrap validation or decoding steps in helpers for consistency. Inline those carelessly and you duplicate error construction, logging context, or branch-heavy code. This tends to hurt readability more than it helps speed.

Crypto and constant-time code

In cryptography, source transformations deserve extra review. You are not only chasing throughput. You are protecting invariants around memory access and timing behavior. Inlining by itself does not imply a side-channel issue, but any manual source rewrite in crypto-adjacent code should trigger code review with constant-time concerns in scope.

Concurrency helpers

Tiny helpers around atomics, locks, and memory pools look like inlining candidates. Sometimes they are. Sometimes the helper boundary preserves a useful invariant. If the source rewrite spreads lock or pool logic across multiple call sites, future edits get riskier.

API stability inside libraries

If you maintain an SDK or shared internal package, helper functions often carry semantic meaning beyond speed. Rewriting call sites into expanded source can make later behavior changes harder to propagate cleanly.

A good rule is simple. Inline where the boundary blocks a measurable optimization in a hot path. Leave structure alone where the gain is speculative or readability is the main casualty.

Use source control as part of the optimization process

The largest practical benefit of source-level inlining is reviewability. Compiler heuristics change across releases. A source rewrite is explicit.

Treat it like any other performance-sensitive refactor:

  • Require a benchmark before and after
  • Store benchmark results in the change
  • Review assembly only for the narrow hot path, not the whole package
  • Tag changes which depend on current compiler behavior
  • Re-run on each Go upgrade

This is where the source-level approach is stronger than folklore around compiler flags. You get diffs. You get blame history. You get a stable artifact to test in CI.

For larger teams, add two checks:

  1. A size budget for binaries or critical packages
  2. A performance regression job on realistic workloads

This matters in node software, indexers, and API services built in Go. These systems often sit near latency or throughput ceilings, and they upgrade the toolchain regularly for security and runtime fixes. If a source-level inline rewrite helps on Go 1.24 and hurts on 1.26, you want a tight feedback loop.

Build a shortlist of good candidates

Most code should never see this directive. A small fraction deserves inspection.

Good candidates often share these traits:

  • Called in a tight loop
  • Small body
  • Stable logic
  • Arguments expose constants or concrete types useful to the compiler
  • Sits on a hot path confirmed by profile data
  • Produces allocation or bounds-check changes when expanded

Examples in real systems:

  • Byte-slice validation in protocol decoders
  • RLP, SSZ, or protobuf field access helpers
  • Trie-node navigation helpers
  • Small wrappers over hash-state updates
  • Fixed-width parsing in networking code
  • Mempool admission checks with short fast paths

Poor candidates usually look like this:

  • Business logic with many branches
  • Helpers chosen for readability, not speed
  • Error-heavy code paths
  • Functions called rarely
  • Public APIs whose body changes often

The discipline is to rank candidates by measured impact per unit of code complexity. If a three-line helper saves one allocation in a path executed millions of times, that is a useful trade. If a ten-branch helper saves two nanoseconds in setup code, it is noise.

What to watch next

The Go team has moved part of optimization into a form you can inspect in source. Expect follow-on work around how source rewrites interact with generics, debug info, profiling, and future compiler heuristics.

For your codebase, watch three metrics after adopting it in any hot path: latency distribution, allocation rate, and binary size. Re-check all three on each Go upgrade. If you run blockchain or AI infrastructure in Go, keep the focus narrow. Optimize the boundaries your profiles keep pointing at, and leave the rest readable.