Go Passes Arguments in Registers: The Register ABI and Its Transparent Wrappers

Since Go 1.17 every Go function passes arguments and results in machine registers, and the compiler silently inserts wrappers so that assembly code can keep using the old stack-based ABI0.

TL;DR: Go has two calling conventions. ABI0 is the old stack-based one that assembly code must use, and it is frozen forever. ABIInternal is the one every function written in Go actually uses: arguments and results go in machine registers, with a fixed register holding the current goroutine pointer and another fixed to zero. Because both worlds have to coexist, the compiler generates transparent wrappers that translate one convention into the other — you never write them, but they are why a hand-written assembly routine can still be called from Go without costing you a stack shuffle.

Every Go program you have ever written calls functions through an ABI you have probably never thought about. And for most of Go’s life, that ABI put every argument on the stack.

Then Go 1.17 changed the rules. The change is invisible from Go source — no syntax changed, no semantics changed — but it is one of the largest performance-relevant redesigns the language has ever done, and the source tree documents it in unusual detail.

Two ABIs, and why there are two

The distinction is baked into the object file format. From src/cmd/internal/obj/link.go:

go
// src/cmd/internal/obj/link.go:843
// ABI is the calling convention of a text symbol.
type ABI uint8

const (
	// ABI0 is the stable stack-based ABI. It's important that the
	// value of this is "0": we can't distinguish between
	// references to data and ABI0 text symbols in assembly code,
	// and hence this doesn't distinguish between symbols without
	// an ABI and text symbols with ABI0.
	ABI0 ABI = iota

	// ABIInternal is the internal ABI that may change between Go
	// versions. All Go functions use the internal ABI and the
	// compiler generates wrappers for calls to and from other
	// ABIs.
	ABIInternal

	ABICount
)

Read that comment carefully, because it contains a whole design constraint in four lines: ABI0 must have the value zero, because the linker and the assembler need to tell “this symbol is a data symbol” apart from “this symbol is a text symbol with the default ABI”. Anything else would require an extra bit in every relocation.

So the two ABIs have asymmetric properties on purpose:

  • ABI0 is stable. It is the contract with hand-written assembly, with the runtime’s own assembly, with the debugger, and with anything that pokes at a Go stack frame. It will not change. Ever.
  • ABIInternal is unstable. It is free to change between Go releases because only the compiler and the runtime, which ship together, depend on it.

The tree says exactly this in src/cmd/compile/abi-internal.md:

This document describes Go’s internal application binary interface (ABI), known as ABIInternal. Go’s ABI defines the layout of data in memory and the conventions for calling between Go functions. This ABI is unstable and will change between Go versions. If you’re writing assembly code, please instead refer to Go’s assembly documentation, which describes Go’s stable ABI, known as ABI0.

The register assignment algorithm

ABIInternal is a common ABI across all architectures — the same algorithm runs everywhere, only the register sequences differ. The algorithm, straight out of the spec, breaks arguments and results down recursively into base values and hands them out in order:

  1. If T is a boolean or integral type that fits in an integer register, assign V to register I and increment I.
  2. If T is an integral type that fits in two integer registers, assign the least significant and most significant halves of V to registers I and I+1, respectively, and increment I by 2
  3. If T is a floating-point type and can be represented without loss of precision in a floating-point register, assign V to register FP and increment FP.
  4. If T is a complex type, recursively register-assign its real and imaginary parts.
  5. If T is a pointer type, map type, channel type, or function type, assign V to register I and increment I.
  6. If T is a string type, interface type, or slice type, recursively register-assign V’s components (2 for strings and interfaces, 3 for slices).
  7. If T is a struct type, recursively register-assign each field of V.
  8. If T is an array type of length 0, do nothing.
  9. If T is an array type of length 1, recursively register-assign its one element.
  10. If T is an array type of length > 1, fail.
  11. If I > NI or FP > NFP, fail.
  12. If any recursive assignment above fails, fail.

Two details there are worth dwelling on.

A string costs two registers. A slice costs three. Not because the pointer and the length are physically separate, but because the ABI treats them as separate base values and each gets its own register. The same is true of an interface: the itab pointer and the data pointer are two independent register assignments.

An array of more than one element fails. A [2]uintptr cannot be register-assigned at all, so it goes on the stack — the entire array, not just its second word. This is a subtle and frequently surprising rule. It also means an array is not a first-class citizen in the ABI: it is either entirely in registers (length 1) or entirely on the stack.

The failure mode is graceful. If any recursive assignment fails, the whole value is reassigned to the stack:

  1. Try to register-assign V.
  2. If step 3 failed, reset I and FP to the values from step 1, add T to the stack sequence S, and assign V to this field in S.

Note the “reset I and FP” — a failed attempt does not burn registers.

The stack frame layout

Arguments that could not be register-assigned live on the stack, and the layout is not the obvious one. From the spec:

The final stack sequence looks like: stack-assigned receiver, stack-assigned arguments, pointer-alignment, stack-assigned results, pointer-alignment, spill space for each register-assigned argument, pointer-alignment.

So a call frame looks like this, with the arrow pointing at lower addresses:

text
    +------------------------------+
    |             . . .            |
    | 2nd reg argument spill space |
    | 1st reg argument spill space |
    | <pointer-sized alignment>    |
    |             . . .            |
    | 2nd stack-assigned result    |
    | 1st stack-assigned result    |
    | <pointer-sized alignment>    |
    | 2nd stack-assigned argument  |
    | 1st stack-assigned argument  |
    | stack-assigned receiver      |
    +------------------------------+

Two things jump out.

Results are stored on the stack too, below the arguments. The caller has to reserve space for the results before making the call, because it does not know the callee’s stack frame size. So the caller reserves the result slots, and the callee writes into them.

Every register-assigned argument gets a spill slot. The caller reserves space for these but leaves them uninitialized. The callee may spill an argument to its own frame, but if it wants to spill to the caller’s frame (the classic “callee-saved register” trick) the space is already there. This is why the ABI has no callee-save registers at all:

There are no callee-save registers, so a call may overwrite any register that doesn’t have a fixed meaning, including argument registers.

That is a genuinely radical choice. It removes the entire save/restore choreography from the call sequence, at the cost of the caller having to reserve spill space it might never use. On a modern OoO core the spilled slot is usually still in L1 by the time it is needed, so the trade usually wins.

Fixed registers: the goroutine pointer and the zero register

On amd64 the integer register sequence is nine registers:

The amd64 architecture uses the following sequence of 9 registers for integer arguments and results:

RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11

It uses X0 – X14 for floating-point arguments and results.

Nine, not fifteen. On x86-64 there are fifteen usable GPRs, but RSP is the stack pointer, RBP is the frame pointer, and three more are reserved as scratch. The scratch reservation is explained in the spec:

Registers R12 and R13 are permanent scratch registers. R15 is a scratch register except in dynamically linked binaries.

Rationale: Some operations such as stack growth and reflection calls need dedicated scratch registers in order to manipulate call frames without corrupting arguments or results.

That is a real constraint, not a stylistic one: runtime.morestack has to move the whole argument area of the frame it is growing, and it has to do so without a scratch register it is allowed to clobber.

And then there are the two famous fixed registers:

RegisterCall meaningReturn meaningBody meaning
R14Current goroutineSameSame
X15Zero value (*)SameScratch

R14 always holds the current goroutine pointer. This is the single most important consequence of the register ABI for everyday Go code. Before Go 1.17, every function that touched getg() had to make a TLS lookup — a memory load, potentially a function call, definitely a cache miss on the TLS block. Now g is just a register.

Look at how the runtime maintains it. When the runtime switches to a different goroutine’s stack it reloads R14:

asm
// src/runtime/asm_amd64.s:1086
	MOVQ	SI, R14 // set the g register, as required by ABIInternal.
	XORPS	X15, X15 // clear X15, as required by ABIInternal.

Two instructions on every goroutine switch. That is the entire cost of the fixed-register convention, and it replaces a TLS lookup in every function that needs g.

X15 is always zero. The rationale is given:

Rationale: We designate X15 as a fixed zero register because functions often have to bulk zero their stack frames, and this is more efficient with a designated zero register.

Go zeroes stack frames when a function has pointer-containing locals that the GC might otherwise see as garbage. With a dedicated zero register, the zeroing becomes a MOVUPS from a known-zero source rather than a PXOR against itself plus a store — one fewer instruction on the dependency chain, and the store can be a plain vector store.

The wrappers: how two worlds coexist

Here is the interesting engineering. Assembly code in the runtime (and in third-party packages like github.com/bytedance/sonic) is written against ABI0. Go code calls it. The compiler bridges the gap automatically.

From src/cmd/compile/internal/ssagen/abi.go:

go
// src/cmd/compile/internal/ssagen/abi.go:286
	// ABI0-to-ABIInternal wrappers will be mainly loading params from
	// stack into registers (and/or storing stack locations back to
	// registers after the wrapped call); in most cases they won't
	// need to allocate stack space, so it should be OK to mark them
	// as NOSPLIT in these cases. In addition, my assumption is that
	// functions written in assembly are NOSPLIT in most (but not all)
	// cases. In the case of an ABIInternal target that has too many
	// parameters to fit into registers, the wrapper would need to
	// allocate stack space, but this seems like an unlikely scenario.
	// Hence: mark these wrappers NOSPLIT.
	//
	// ABIInternal-to-ABI0 wrappers on the other hand will be taking
	// things in registers and pushing them onto the stack prior to
	// the ABI0 call, meaning that they will always need to allocate
	// stack space. If the compiler marks them as NOSPLIT this seems
	// as though it could lead to situations where the linker's
	// nosplit-overflow analysis would trigger a link failure. On the
	// other hand if they not tagged NOSPLIT then this could cause
	// problems when building the runtime (since there may be calls to
	// asm routine in cases where it's not safe to grow the stack). In
	// most cases the wrapper would be (in effect) inlined, but are
	// there (perhaps) indirect calls from the runtime that could run
	// into trouble here.
	// FIXME: at the moment all.bash does not pass when I leave out
	// NOSPLIT for these wrappers, so all are currently tagged with NOSPLIT.
	fn.Pragma |= ir.Nosplit

That comment is a fossil of a real debugging session, and it tells you the whole story: the wrapper-generation code has subtleties that were discovered by running the full test suite.

Then there is the tail-call optimisation, with an architecture-specific carve-out:

go
// src/cmd/compile/internal/ssagen/abi.go:328
	if base.Ctxt.Arch.Name == "amd64" && wrapperABI == obj.ABIInternal {
		// cannot tailcall from ABIInternal to ABI0 on AMD64, as we need
		// to special registers (X15) when returning to ABIInternal.
		tailcall = false
	}

A tail call would be perfect: no frame, no return address, minimal cost. But on amd64, returning from an ABI0 function to an ABIInternal caller requires restoring X15 (the zero register) to zero, and you cannot do that after a JMP because there is no return point to execute it at. So the wrapper has to be a real call with a real frame.

The same file has a similar carve-out for ppc64 with dynamic linking, where the link register R2 must be restored.

RegArgs: the assembly-facing view

Assembly code that wants to participate in ABIInternal needs to know where the registers are. That is the job of internal/abi.RegArgs:

go
// src/internal/abi/abi.go:21
type RegArgs struct {
	// Values in these slots should be precisely the bit-by-bit
	// representation of how they would appear in a register.
	//
	// This means that on big endian arches, integer values should
	// be in the top bits of the slot. Floats are usually just
	// directly represented, but some architectures treat narrow
	// width floating point values specially (e.g. they're promoted
	// first, or they need to be NaN-boxed).
	Ints   [IntArgRegs]uintptr  // untyped integer registers
	Floats [FloatArgRegs]uint64 // untyped float registers

	// Fields above this point are known to assembly.

	// Ptrs is a space that duplicates Ints but with pointer type,
	// used to make pointers passed or returned  in registers
	// visible to the GC by making the type unsafe.Pointer.
	Ptrs [IntArgRegs]unsafe.Pointer

	// ReturnIsPtr is a bitmap that indicates which registers
	// contain or will contain pointers on the return path from
	// a reflectcall. The i'th bit indicates whether the i'th
	// register contains or will contain a valid Go pointer.
	ReturnIsPtr IntArgRegBitmap
}

The Ptrs field is a small, very clever hack. The GC scans the argument registers of a goroutine’s stack when the goroutine is scanned or when it transitions to a syscall. Those registers hold integers of unknown provenance. If the GC treated every register as an integer, it would miss pointers passed in registers and could collect live objects.

The trick: RegArgs has two copies of the integer registers — Ints as plain uintptr, and Ptrs as unsafe.Pointer. The Ptrs copy has pointer type, so the GC’s stack map sees it as pointers and scans it correctly. The Ints copy is what the code actually reads. Two arrays, one extra field per register, and the GC problem disappears.

The comment “Fields above this point are known to assembly” is the compiler-ABI contract: the assembly in src/runtime/asm_amd64.s and the RegArgs struct layout must agree, and the compiler enforces it.

How many registers, per architecture

The register counts are per-architecture constants:

go
// src/internal/abi/abi_amd64.go:7
const (
	// See abi_generic.go.

	// RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11.
	IntArgRegs = 9

	// X0 -> X14.
	FloatArgRegs = 15

	// We use SSE2 registers which support 64-bit float operations.
	EffectiveFloatRegSize = 8
)
go
// src/internal/abi/abi_arm64.go:7
const (
	// See abi_generic.go.

	// R0 - R15.
	IntArgRegs = 16

	// F0 - F15.
	FloatArgRegs = 16

	EffectiveFloatRegSize = 8
)

And for platforms that did not get the register ABI, there is a graceful degradation path:

go
// src/internal/abi/abi_generic.go:5
//go:build !goexperiment.regabiargs && !amd64 && !arm64 && !loong64 && !ppc64 && !ppc64le && !riscv64 && !s390x

package abi

const (
	// ABI-related constants.
	//
	// In the generic case, these are all zero
	// which lets them gracefully degrade to ABI0.

	// IntArgRegs is the number of registers dedicated
	// to passing integer argument values. Result registers are identical
	// to argument registers, so this number is used for those too.
	IntArgRegs = 0

	// FloatArgRegs is the number of registers dedicated
	// to passing floating-point argument values. Result registers are
	// identical to argument registers, so this number is used for
	// those too.
	FloatArgRegs = 0

	// EffectiveFloatRegSize describes the width of floating point
	// registers on the current platform from the ABI's perspective.
	//
	// Since Go only supports 32-bit and 64-bit floating point primitives,
	// this number should be either 0, 4, or 8. 0 indicates no floating
	// point registers for the ABI or that floating point values will be
	// passed via the softfloat ABI.
	//
	// For platforms that support larger floating point register widths,
	// such as x87's 80-bit "registers" (not that we support x87 currently),
	// use 8.
	EffectiveFloatRegSize = 0
)

Zero registers means “everything goes on the stack” — ABI0 behaviour, with no separate code path. The whole ABI machinery collapses to a no-op on platforms that did not adopt it. That is the kind of design decision that makes a big change shippable: the fallback is not a maintenance burden, it is the absence of one.

Why the register ABI was worth doing

The register ABI is not just about saving a few stores. It removes an entire class of work from every call:

  • No argument shuffling through the stack. An old-style call wrote each argument to a stack slot, then the callee read it back. With registers, that round trip disappears for the first nine integer arguments.
  • No callee-save register discipline. Because there are no callee-save registers, there is no push/pop prologue epilogue for the compiler to emit.
  • g in a register. The goroutine pointer is available without a TLS lookup. This is the single biggest win: it makes runtime.getg — called by the scheduler, by the allocator, by anything that touches the heap — essentially free.
  • A fixed zero register. Frame zeroing gets cheaper.
  • Better register allocation. The SSA backend’s register allocator starts with the argument registers already live, which gives it a natural place to put incoming values.

The costs are equally real:

  • Assembly became harder to write. You now have to know which ABI you are targeting, and mixing them requires the wrappers.
  • The ABI can change. ABIInternal is explicitly unstable. A Go release can change how arguments are passed, and your assembly — if it targets ABIInternal — breaks. This is why the tree pushes assembly authors to ABI0.
  • Debuggers have to know about it. A stack frame in ABIInternal is not laid out like an ABI0 frame, so unwinding code has to be ABI-aware.

Trade-offs

Register ABI vs stack ABI. Registers win on the call overhead and on g access. The stack wins on ABI stability and on debuggability. Go took both: registers for Go code, stack for assembly.

Common ABI vs platform ABI. Go invented its own convention rather than using the platform’s (System V AMD64, AAPCS64). The cost is that Go assembly is not compatible with anything else, and every call crossing the Go/C boundary needs a wrapper. The benefit is uniformity: one spec, one implementation, and the same register-assignment algorithm everywhere, so a compiler change fixes all platforms at once.

No callee-save registers. This is the most counter-intuitive choice in the whole design, and the one that pays off the most on modern hardware. The cost is reserved spill space in every frame — memory that is often never touched. The benefit is a call sequence with nothing to save and nothing to restore.

Fixed registers. R14 for g and X15 for zero cost two registers out of the general pool on amd64, which is why only nine integer registers remain for arguments. The benefit is that the two most-frequently-needed values are free.

Caveats

You almost certainly should not write assembly. If you do, target ABI0, not ABIInternal. ABI0 is stable across Go releases; ABIInternal is not. The tree says so explicitly, and the //go:linkname “hall of shame” comments in src/runtime/map.go are a good illustration of how much trouble hand-written linkage causes.

Register assignment is not free of surprises. Arrays longer than one element go on the stack. Structs are flattened field-by-field, so a struct with a [2]uintptr field is stack-assigned entirely. A function with ten integer arguments spills the last one. If you are reasoning about the ABI by hand, read the algorithm in src/cmd/compile/abi-internal.md rather than guessing.

The Ptrs duplication in RegArgs exists for the GC. If you write assembly that manipulates RegArgs, you must keep the Ints and Ptrs copies consistent, or the GC will miss a live pointer.

ABI changes are visible in profiles. When Go changed the ABI, function entry costs moved. A profile captured before and after a Go upgrade is not comparable at the level of individual function entry costs, even though the code is identical.

Wrapping up

The one thing to remember: Go has two ABIs. ABI0 (stack-based, stable, for assembly) and ABIInternal (register-based, unstable, for Go code), and the compiler generates transparent wrappers between them so you never have to think about it.

The part people get wrong: the fixed registers. On amd64, R14 holds the goroutine pointer and X15 is always zero, in every function. That is why the runtime can switch goroutines with two instructions and why frame zeroing is cheap. If you are reading runtime assembly and wondering why XORPS X15, X15 shows up after every context switch, that is the answer.

What to go do about it: if you are writing assembly, read src/cmd/compile/abi-internal.md end to end and target ABI0. If you are just writing Go, the practical takeaways are that calls are cheaper than they used to be, g is free, and the only reason to care about any of this is when you are benchmarking across Go versions.

Further reading

  • src/cmd/compile/abi-internal.md — the full ABIInternal specification: memory layout, the register assignment algorithm, per-architecture register maps, and the rationale for every choice.
  • src/cmd/internal/obj/link.go — the ABI type, ABI0, ABIInternal, and the comment explaining why ABI0 must be zero.
  • src/internal/abi/abi.go — the RegArgs struct, the assembly-facing view of the register file, including the Ptrs GC trick.
  • src/internal/abi/abi_amd64.go, abi_arm64.go, abi_generic.go — the per-architecture register counts and the ABI0 fallback.
  • src/cmd/compile/internal/ssagen/abi.go — ABI wrapper generation, the NOSPLIT discussion, and the tail-call carve-outs.
  • src/runtime/asm_amd64.s — the runtime’s own assembly, and a live example of the <ABIInternal> symbol suffix.
  • src/cmd/compile/internal/noder/lex.go — where the //go:registerparams pragma is parsed, and the TODO noting it is not yet honoured.

GoInternalsAbiCompilerPerformance