Compiler Intrinsics

A compiler intrinsic is a standard-library function that the Go gc compiler does not call but instead recognizes by name and replaces in place with a small sequence of SSA operations — typically a single hardware instruction. The math/bits population-count function bits.OnesCount64, for example, is not compiled into a CALL; on an amd64 CPU with the POPCNT feature it becomes one POPCNTQ instruction inlined directly into the caller. The mechanism lives in cmd/compile/internal/ssagen/intrinsics.go, which maintains a table mapping (architecture, package, function name) to a builder that emits the replacement SSA (intrinsics.go). Intrinsics are how Go gets access to CPU instructions — atomic compare-and-swap, byte-swap, square-root, count-trailing-zeros — that have no Go-language syntax, without the overhead of a real function call.

Mental Model

Intrinsics solve a tension. Go has no language syntax for “atomic compare-and-swap” or “count leading zeros” — these are CPU instructions, not operators. The portable solution is to expose them as ordinary functions in packages like sync/atomic, math/bits, and math. But an ordinary function call has overhead — argument marshalling, a CALL, a RET — that dwarfs the one-instruction operation it wraps.

The intrinsic mechanism resolves this: the function exists as real Go (so gccgo, TinyGo, an architecture without the instruction, and go doc all work), but the gc compiler special-cases it. When gc sees a call to a known intrinsic on an architecture that supports it, it discards the call and substitutes the operation directly.

flowchart TD
    A["call bits.OnesCount64(x)"] --> B{Is package.function in the<br/>intrinsicBuilders table<br/>for this GOARCH?}
    B -->|"Yes — amd64 has POPCNT"| C[Run the builder:<br/>emit SSA OpPopCount64]
    C --> D[SSA op lowers to POPCNTQ —<br/>no CALL, no frame]
    B -->|"No — arch lacks the instruction"| E[Ordinary call to the<br/>pure-Go implementation in math/bits]
    E --> F[Function executes the<br/>fallback Go algorithm]

Figure: the intrinsic dispatch. Insight: the same math/bits source serves both paths — the pure-Go body is the correctness reference and the fallback; the intrinsic is a compiler-side shortcut that simply never reaches that body.

Mechanical Walk-through

The intrinsic table

intrinsics.go (as of Go 1.26.3) builds a package-level intrinsics map of type intrinsicBuilders, keyed by an intrinsicKey struct with three fields (intrinsics.go @ go1.26):

  • arch — a *sys.Arch (one of sys.AMD64, sys.I386, sys.ARM, sys.ARM64, sys.Loong64, sys.MIPS, sys.MIPS64, sys.PPC64, sys.RISCV64, sys.S390X, sys.Wasm).
  • pkg — the import path of the function’s package ("math/bits", "internal/runtime/atomic", "internal/runtime/sys", "internal/runtime/math", "math", "runtime").
  • fn — the function name ("OnesCount64", "TrailingZeros32", "Sqrt").

Note that encoding/binary is not in the intrinsic table — binary.LittleEndian.Uint64 and friends are made fast by inlining and ordinary load-coalescing in SSA, not by an intrinsic lowering. The covered packages are exactly the runtime-adjacent and numeric ones above.

The table is populated at compiler-init time by helper functions:

  • add(pkg, fn, builder, archs ...*sys.Arch) — registers a builder for one named architecture (or a small list).
  • addF(pkg, fn, builder, archs ...*sys.Arch) — same signature, but conventionally used to register a family of architectures in a single call (the F is for “family”); both add and addF are present in the current file and the distinction is largely organizational.
  • alias(pkg, fn, targetPkg, targetFn, archs ...*sys.Arch) — points one (pkg, fn) at an already-registered intrinsic, used heavily so that sync/atomic.LoadInt32 aliases internal/runtime/atomic.Load, sync/atomic.CompareAndSwapInt64 aliases internal/runtime/atomic.Cas64, and so on across the full atomic surface.

The builder

The value in the table is an intrinsicBuilder — a function func(s *state, n *ir.CallExpr, args []*ssa.Value) *ssa.Value. When the SSA-generation phase (ssagen) encounters a call expression, it looks up (GOARCH, package, function). On a hit, it does not generate the call. Instead it:

  1. Evaluates the call’s arguments into SSA values (args).
  2. Invokes the builder with those SSA values.
  3. The builder emits one or a few SSA ops — e.g. s.newValue1(ssa.OpPopCount64, types.Types[types.TINT], args[0]) — and returns the resulting SSA value.
  4. That returned value is the call’s result; standard call-generation is skipped entirely.

The emitted SSA op then flows through the normal backend: the lower pass turns the generic OpPopCount64 into the architecture-specific machine op (OpAMD64POPCNTQ), and code generation emits the actual POPCNTQ instruction. Because the result is an ordinary SSA value, it participates in constant folding, common-subexpression elimination, and register allocation like any other operation.

What is intrinsified

The principal intrinsified domains, per the Go 1.26 intrinsics.go:

  • math/bitsOnesCount* (population count → POPCNT/POPCNTQ), TrailingZeros* (→ BSF/TZCNT), LeadingZeros* (handled implicitly through Len*), RotateLeft*, ReverseBytes* (→ BSWAP), Len*, plus the multi-word arithmetic helpers Add64, Sub64, Mul64, and (AMD64-only) Div64. The package documentation states outright that “functions in this package may be implemented directly by the compiler, for better performance. For those functions the code in this package will not be used” (pkg.go.dev/math/bits).
  • sync/atomic and internal/runtime/atomicLoad, Store, Swap, CompareAndSwap, Add, in their typed widths, mapped to the architecture’s atomic instructions (LOCK XADD, CMPXCHG on amd64; LDAR/STLR and the LDADDAL/SWPAL family on arm64). The sync/atomic symbols are registered as aliases of internal/runtime/atomic entries, so the user-facing API and the runtime-internal API share a single intrinsic lowering.
  • mathSqrt (→ SQRTSD), Abs, Floor, Ceil, Trunc, Round, RoundToEven, FMA (fused multiply-add). The rounding family is unconditional on arm64, ppc64, s390x, and wasm but on amd64 is gated through makeRoundAMD64 on an SSE4.1 runtime-feature check (the SSE4.1 ROUNDSD instruction is the lowering); FMA is unconditional on arm64/loong64/ppc64/riscv64/s390x but on amd64 and arm is gated on a runtime CPU-feature check (FMA on amd64, VFPv4 on arm) with a fallback to the pure-Go body when absent.
  • runtime, internal/runtime/sys, internal/runtime/math — low-level helpers: runtime.KeepAlive, runtime.memequal (intrinsified at least on arm64 via the OpMemEq SSA op), getcallerpc, getcallersp, multi-word multiplication helpers, and the cycle-counter helpers in internal/runtime/sys.

The fallback

Crucially, every intrinsified function still has a real, pure-Go body. If the target architecture lacks the instruction — or the compiler is gccgo/TinyGo, which do not implement Go’s intrinsic table — the ordinary function is compiled and called. math/bits.OnesCount64’s fallback is the classic SWAR (SIMD-within-a-register) bit-twiddling algorithm. This is why intrinsics never compromise portability: the worst case is a normal function call running a correct, if slower, algorithm.

Runtime feature gating versus baseline assumption

A second layer of conditionality sits inside the intrinsic. For some (arch, instruction) pairs the compiler emits not a bare instruction but a feature-test branch that checks internal/cpu’s capability flags at process startup and falls back to the pure-Go body when absent. The principal gated cases in the Go 1.26 table are:

  • amd64 math/bits.OnesCount* — gated on internal/cpu.X86.HasPOPCNT. POPCNT post-dates the original SSE2-era amd64 baseline; a binary built with the default GOAMD64=v1 must work on CPUs without it. Building with GOAMD64=v2 or higher raises the baseline and (in practice) avoids the branch because the feature is statically known to be present.
  • amd64 math.Floor/Ceil/Trunc/Round/RoundToEven — gated on SSE4.1 (the ROUNDSD instruction), via makeRoundAMD64.
  • amd64 math.FMA — gated on the FMA CPUID bit; arm FMA is gated on VFPv4.
  • loong64 and riscv64 OnesCount*/TrailingZeros*/RotateLeft*/Len* — gated on the LSX (Loong64) and Zbb (RISC-V) extensions, with the RISC-V RotateLeft/Len/TrailingZeros lowering also conditional on goriscv64 >= 22 (the GORISCV64 profilerva22 enables Zbb in the baseline).
  • ppc64 ReverseBytes* — has a Power10 (BREV instruction) fast path with fallback for older Power CPUs.

Other architectures (arm64, s390x, wasm) emit the instruction unconditionally because their Go baselines already mandate it. The matrix changes every release; consult intrinsics.go at the exact release tag for the version-specific list.

Reading the diagnostics

Intrinsics do not appear in -gcflags=-m (that flag is escape analysis and inlining). Two ways to see them:

# 1. Dump the assembly and look for the instruction, not a CALL.
go build -gcflags='-S' ./... 2>&1 | grep -A2 OnesCount
# you should see POPCNTQ, not CALL math/bits.OnesCount64
 
# 2. Disassemble the built binary.
go tool objdump -s 'main.popcount' ./yourbinary

There is also a dedicated debug flag:

go build -gcflags='-d=intrinsics=1' ./...

-d=intrinsics=1 makes the compiler print a line each time it applies an intrinsic, e.g. intrinsic substitution for OnesCount64. If you expect an intrinsic and this line does not appear, the call is going through the slow path — usually because the architecture is unsupported or the function is not (yet) in the table.

For a quick sanity check, compile this and inspect the assembly:

package main
 
import "math/bits"
 
func popcount(x uint64) int { return bits.OnesCount64(x) }   // line 5

go build -gcflags='-S' on amd64 shows the body of popcount as essentially a single POPCNTQ plus the return — no CALL. Mark it //go:noinline and you still see POPCNTQ, confirming the substitution is the intrinsic, not inlining of the math/bits body.

Failure Modes and Common Misunderstandings

  • “Intrinsics are the same as inlining.” No. Function Inlining copies a function’s Go body into the caller. An intrinsic discards the body and substitutes hand-written SSA. A function can be both inlined and intrinsified, or intrinsified on one arch and merely inlined on another.
  • POPCNT is always emitted on amd64.” Not at the default GOAMD64=v1 baseline. The compiler emits a runtime feature-test branch (internal/cpu.X86.HasPOPCNT) and dispatches between the bare POPCNTQ instruction and the pure-Go SWAR fallback. Building with GOAMD64=v2 (or higher) raises the baseline so the branch can be eliminated. The same shape of gate applies to SSE4.1-only math.Round*, to FMA on amd64/arm, and to the Zbb-gated bit ops on riscv64.
  • “My helper does the same bit-twiddling, so it’s just as fast.” Hand-written SWAR code is not an intrinsic — the compiler does not pattern-match arbitrary bit-twiddling back to POPCNT. Call math/bits.OnesCount64 to get the instruction; a manual loop will not.
  • gccgo/TinyGo. These toolchains do not share gc’s intrinsic table. Code that relies on bits.OnesCount being one instruction for performance will be a real call there.
  • Intrinsics and the race detector. Atomic intrinsics interact with the race detector’s instrumentation; under -race some atomic operations are routed through instrumented runtime calls rather than the bare intrinsic so the detector can observe them.

Alternatives and When to Choose Them

  • Plan 9 assembly (Go Plan 9 Assembly). Before a function is intrinsified, or for instructions the compiler will never expose, the escape hatch is a hand-written .s file. This is how math/big, crypto, and parts of runtime reach SIMD and carry-flag operations. The cost is one assembly file per architecture and no inlining across the boundary.
  • cgo to a C intrinsic. Calling a C function that uses a compiler intrinsic. Almost never worth it — the cgo call overhead is far larger than the instruction it would reach.
  • Just use the standard library. The right default. math/bits, sync/atomic, and math are intrinsified precisely so that ordinary, portable Go gets hardware speed for free. Reaching for assembly or cgo to do what an intrinsic already does is a regression.

Production Notes

The practical rule is: prefer the standard-library function over a hand-rolled equivalent, because the standard-library version is the one the compiler knows how to intrinsify. Daniel Lemire’s and Dave Cheney’s writeups on Go bit-manipulation both make the same point — math/bits is fast not because the Go fallback is clever but because the compiler replaces it (dave.cheney.net). The math/bits package itself was added in Go 1.9 specifically to give the compiler a stable surface to intrinsify (issue #18616); before it, programmers wrote their own slower bit tricks. When micro-optimizing a hot path, check go build -gcflags=-S to confirm the instruction you expect is actually emitted — an unexpected CALL to math/bits is a sign your target architecture is not covered, and the fix is rarely to write assembly but to confirm the build’s GOARCH/GOAMD64 settings.

See Also