How Go Allocates Registers: The SSA Backend's Linear Scan Pass

Go's register allocator is a greedy linear scan over the whole function, not a graph colouring algorithm: values move into registers just before use, and the value whose next use is farthest away is the one that gets spilled.

TL;DR: Go does not use graph colouring. Its register allocator is a greedy linear scan: walk the function’s instructions in order, move each value into a register just before it is used, and when every register is busy, evict the value whose next use is farthest in the future. That last rule is the whole spill heuristic in one sentence.

Most compilers that people admire use a graph-colouring register allocator: build an interference graph, colour it, and hope. Go does not. Go’s allocator is a linear scan, and understanding why it is a linear scan — and what it gives up — explains a lot about the shape of Go’s generated code.

This is a walk through how it works, based on reading the Go 1.28 development tree (src/cmd/compile/internal/ssacompile/regalloc.go and src/cmd/compile/internal/ssa/).

Where the code lives

One structural note before diving in, because it is confusing the first time you go looking. The SSA backend used to be one package. It is now split in two:

  • src/cmd/compile/internal/ssa/ holds the data structures: Value, Block, Func, the Location interface, the opcode tables (ssa/ssaop/), and the rewrite rules (ssa/_gen/*.rules).
  • src/cmd/compile/internal/ssacompile/ holds the passes: the whole register allocator lives in ssacompile/regalloc.go (3835 lines), and the pass list is in ssacompile/compile.go.

The old ssa/regalloc.go is now just the design essay — the long comment at the top of the file is the canonical description of the algorithm:

go
// src/cmd/compile/internal/ssa/regalloc.go:5
// Register allocation.
//
// We use a version of a linear scan register allocator. We treat the
// whole function as a single long basic block and run through
// it using a greedy register allocator. Then all merge edges
// (those targeting a block with len(Preds)>1) are processed to
// shuffle data into the place that the target of the edge expects.
//
// The greedy allocator moves values into registers just before they
// are used, spills registers only when necessary, and spills the
// value whose next use is farthest in the future.
//
// The register allocator requires that a block is not scheduled until
// at least one of its predecessors have been scheduled. The most recent
// such predecessor provides the starting register state for a block.
//
// It also requires that there are no critical edges (critical =
// comes from a block with >1 successor and goes to a block with >1
// predecessor).  This makes it easy to add fixup code on merge edges -
// the source of a merge edge has only one successor, so we can add
// fixup code to the end of that block.

“We treat the whole function as a single long basic block.” That is the key sentence and the key simplification. A classical linear-scan allocator (Wimmer, Wegen) works on intervals over a linearised instruction stream and needs the CFG to be reducible. Go’s version sidesteps the whole problem: it does not try to assign a value to a register for its entire live range. It assigns registers late, at the point of use, and deals with the CFG afterwards by shuffling values on merge edges.

The SSA form

Before the allocator runs, the function is in static single assignment form. The package README describes the building blocks:

text
// src/cmd/compile/internal/ssa/README.md:30
#### Values

Values are the basic building blocks of SSA. Per SSA's very definition,
a value is defined exactly once, but it may be used any number of times. A value
mainly consists of a unique identifier, an operator, a type, and some arguments.

An operator or `Op` describes the operation that computes the value. The
semantics of each operator can be found in `_gen/*Ops.go`. For example, `OpAdd8`
takes two value arguments holding 8-bit integers and results in their addition.
Here is a possible SSA representation of the addition of two `uint8` values:

	// var c uint8 = a + b
	v4 = Add8 <uint8> v2 v3

A Value in the code is a struct with an ID, an Op, a type, some arguments, a block, and a position:

go
// src/cmd/compile/internal/ssa/value.go:21
// A Value represents a value in the SSA representation of the program.
// The ID and Type fields must not be modified. The remainder may be modified
// if they preserve the value of the Value (e.g. changing a (mul 2 x) to an (add x x)).
type Value struct {
	// A unique identifier for the value. For performance we allocate these IDs
	// densely starting at 1.  There is no guarantee that there won't be occasional holes, though.
	ID ID

	// The operation that computes this value. See op.go.
	Op ssaop.Op

	// The type of this value. Normally this will be a Go type, but there
	// are a few other pseudo-types, see ../types/type.go.
	Type *types.Type

The Op type is generated from the _gen/*Ops.go tables:

go
// src/cmd/compile/internal/ssa/ssaop/op.go:52
// An Op encodes the specific operation that a Value performs.
// Opcodes' semantics can be modified by the type and aux fields of the Value.
// For instance, OpAdd can be 32 or 64 bit, signed or unsigned, float or complex, depending on Value.Type.
// Semantics of each op are described in the opcode files in _gen/*Ops.go.
// There is one file for generic (architecture-independent) ops and one file
// for each architecture.
type Op int32

And the opcode table, which is the machine-readable description of every operation’s register behaviour:

go
// src/cmd/compile/internal/ssa/ssaop/op.go:60
type OpInfo struct {
	Name              string
	Reg               RegInfo
	AuxType           AuxType
	ArgLen            int32 // the number of arguments, -1 if variable length
	asm               obj.As
	Generic           bool      // this is a generic (arch-independent) opcode
	Rematerializeable bool      // this op is rematerializeable
	Commutative       bool      // this operation is commutative (e.g. addition)
	ResultInArg0      bool      // (first, if a tuple) output of v and v.Args[0] must be allocated to the same register
	ResultNotInArgs   bool      // outputs must not be allocated to the same registers as inputs
	ClobberFlags      bool      // this op clobbers flags register
	NeedIntTemp       bool      // need a temporary free integer register
	Call              bool      // is a function call

Three fields in there matter enormously to the allocator:

  • Reg — the input and output register constraints.
  • Rematerializeable — can this value be recomputed rather than spilled?
  • ResultInArg0 — the output must live in the same register as input 0.

Rematerializeable is a big deal. A constant can be rematerialized for free, so it never needs a spill slot:

go
// src/cmd/compile/internal/ssa/regalloc.go:163
// Rematerializeable reports whether the register allocator should recompute
// a value instead of spilling/restoring it.
func (v *Value) Rematerializeable() bool {
	if !ssaop.OpcodeTable[v.Op].Rematerializeable {
		return false
	}
	for _, a := range v.Args {
		// Fixed-register allocations (SP, SB, etc.) are always available.
		// Any other argument of an opcode makes it not rematerializeable.
		if !ssaop.OpcodeTable[a.Op].FixedReg {
			return false
		}
	}
	return true
}

The output of the pass: Location

When the allocator is done, it has to tell the rest of the compiler where each value lives. The answer is a Location:

go
// src/cmd/compile/internal/ssa/location.go:44
// A place that an ssa variable can reside.
type Location interface {
	String() string // name to use in assembly templates: AX, 16(SP), ...
}

That is the entire interface. One method. A location is either a machine register or a stack slot. The stack slot case is a LocalSlot:

go
// src/cmd/compile/internal/ssa/location.go:35
type LocalSlot struct {
	N    *ir.Name    // an ONAME *ir.Name representing a stack location.
	Type *types.Type // type of slot
	Off  int64       // offset of slot in N

	SplitOf     *LocalSlot // slot is a decomposition of SplitOf
	SplitOffset int64      // .. at this offset.
}

The mapping lives on the function, indexed by value ID:

go
// src/cmd/compile/internal/ssa/func.go:59
	// when register allocation is done, maps value ids to locations
	RegAlloc []Location

and the setter is where the allocator writes its answers:

go
// src/cmd/compile/internal/ssa/stackalloc.go:400
func (f *Func) SetHome(v *Value, loc Location) {
	for v.ID >= ID(len(f.RegAlloc)) {
		f.RegAlloc = append(f.RegAlloc, nil)
	}
	f.RegAlloc[v.ID] = loc
}

Once this array is populated, the backend asks each value where it is:

go
// src/cmd/compile/internal/ssa/value.go:573
// ResultReg returns the result register assigned to v, in cmd/internal/obj/$ARCH numbering.
// It is similar to Reg and Reg0, except that it is usable interchangeably for all Value Ops.
// If you know v.Op, using Reg or Reg0 (as appropriate) will be more efficient.
func (v *Value) ResultReg() int16 {
	reg := v.Block.Func.RegAlloc[v.ID]
	if reg == nil {
		v.Fatalf("nil reg for value: %s\n%s\n", v.LongString(), v.Block.Func)
	}
	if pair, ok := reg.(LocPair); ok {
		reg = pair[0]
	}
	if reg == nil {
		v.Fatalf("nil reg0 for value: %s\n%s\n", v.LongString(), v.Block.Func)
	}
	return reg.(*ssabase.Register).ObjNum
}

The state of the allocator

The allocator keeps a regAllocState, and the interesting fields are the register file and the per-value state:

go
// src/cmd/compile/internal/ssacompile/regalloc.go:172
type regState struct {
	v *ssa.Value // Original (preregalloc) Value stored in this register.
	c *ssa.Value // A Value equal to v which is currently in a register.  Might be v or a copy of it.
	// If a register is unused, v==c==nil
}

Note the two values. v is the original SSA value; c is the value currently in the register, which may be a copy of v. The distinction matters because the allocator inserts copies freely to satisfy register constraints.

The per-value state is in the ssa package, since it is shared between the allocator and other passes:

go
// src/cmd/compile/internal/ssa/regalloc.go:147
// A ValState records the register allocation state for a (pre-regalloc) value.
type ValState struct {
	Regs              ssaop.RegMask // the set of registers holding a Value (usually just one)
	Uses              *Use          // list of uses in this block
	Spill             *Value        // spilled copy of the Value (if any)
	RestoreMin        int32         // minimum of all restores' blocks' sdom.entry
	RestoreMax        int32         // maximum of all restores' blocks' sdom.exit
	NeedReg           bool          // cached value of !v.Type.IsMemory() && !v.Type.IsVoid() && !.v.Type.IsFlags()
	Rematerializeable bool          // cached value of v.rematerializeable()
}

Uses is the linear-scan bookkeeping: a linked list of upcoming uses, each with a distance — how many instructions away the use is:

go
// src/cmd/compile/internal/ssa/regalloc.go:136
type Use struct {
	// distance from start of the block to a use of a value
	//   Dist == 0                 used by first instruction in block
	//   Dist == len(b.Values)-1   used by last instruction in block
	//   Dist == len(b.Values)     used by block's control value
	//   Dist  > len(b.Values)     used by a subsequent block
	Dist int32
	Pos  src.XPos // source position of the use
	Next *Use     // linked list of uses of a value in nondecreasing dist order
}

Dist is the whole basis of the spill heuristic. A value whose next use is 40 instructions away is a much better spill candidate than one used on the next line.

Phase one: liveness

Before allocating anything, the allocator needs to know which values are live at the end of each block, and how far away their next use is. computeLive does that:

go
// src/cmd/compile/internal/ssacompile/regalloc.go:3218
type liveInfo struct {
	ID   ssa.ID   // ID of value
	dist int32    // # of instructions before next use
	pos  src.XPos // source position of next use
}

// computeLive computes a map from block ID to a list of value IDs live at the end
// of that block. Together with the value ID is a count of how many instructions
// to the next use of that value. The resulting map is stored in s.live.
func (s *regAllocState) computeLive() {
	f := s.f
	// single block functions do not have variables that are live across
	// branches
	if len(f.Blocks) == 1 {
		return
	}
	po := f.Postorder()
	s.live = make([][]liveInfo, f.NumBlocks())
	s.desired = make([]desiredState, f.NumBlocks())
	s.loopnest = f.Loopnest()

and the algorithm itself, with the paper it is adapted from:

go
// src/cmd/compile/internal/ssacompile/regalloc.go:3248
	// Liveness analysis.
	// This is an adapted version of the algorithm described in chapter 2.4.2
	// of Fabrice Rastello's On Sparse Intermediate Representations.
	//   https://web.archive.org/web/20240417212122if_/https://inria.hal.science/hal-00761555/file/habilitation.pdf#section.50
	//
	// For our implementation, we fall back to a traditional iterative algorithm when we encounter
	// Irreducible CFGs. They are very uncommon in Go code because they need to be constructed with
	// gotos and our current loopnest definition does not compute all the information that
	// we'd need to compute the loop ancestors for that step of the algorithm.
	//
	// Additionally, instead of only considering non-loop successors in the initial DFS phase,
	// we compute the liveout as the union of all successors. This larger liveout set is a subset
	// of the final liveout for the block and adding this information in the DFS phase means that
	// we get slightly more accurate distance information.

So the liveness is a sparse-map dataflow, with a “distance to next use” attached to each live value. That distance is what makes the spill heuristic smart rather than random.

One special case in there: a function call clobbers everything, so anything live across a call is pushed far into the future:

go
// src/cmd/compile/internal/ssacompile/regalloc.go:3318
				if ssaop.OpcodeTable[v.Op].Call {
					if numCalls != nil {
						numCalls[b.ID]++
					}
					rematIDs = rematIDs[:0]
					c := live.Contents()
					for i := range c {
						c[i].Val += unlikelyDistance
						vid := c[i].Key
						if s.values[vid].Rematerializeable {
							rematIDs = append(rematIDs, vid)
						}
					}
					// We don't spill rematerializeable values, and assuming they
					// are live across a call would only force shuffle to add some
					// (dead) constant rematerialization. Remove them.
					for _, r := range rematIDs {
						live.Remove(r)
					}
				}

unlikelyDistance is 100 instructions. And rematerializable values (constants) are dropped from the live set entirely across a call, because you can always just recompute them.

Phase two: the linear scan

The main loop walks the blocks in a chosen order and, within each block, walks the instructions in order. For each instruction, it moves the operands into the registers the instruction wants.

The shape of the whole pass:

text
    for each block b in visitOrder:
        compute use distances for b (backwards liveness)
        pick a "primary predecessor" and adopt its register state
        assign registers to b's phis
        |
        +-- for each instruction in b:
        |       for each input i:
        |           if v is already in a register: use it
        |           else if v can be rematerialized: recompute it
        |           else: load it from its spill slot
        |       assign the output register
        |
        +-- afterwards: place the spills that were created
        +-- afterwards: shuffle values on merge edges

The state that makes this work is regAllocState, and the fields that matter are the register file, the set of registers in use, and the set of registers that must not be spilled:

go
// src/cmd/compile/internal/ssacompile/regalloc.go:210
	// current state of each register.
	// Includes only registers in allocatable.
	regs []regState

	// registers that contain values which can't be kicked out
	nospill ssaop.RegMask

	// mask of registers currently in use
	used ssaop.RegMask

	// mask of registers used since the start of the current block
	usedSinceBlockStart ssaop.RegMask

	// mask of registers used in the current instruction
	tmpused ssaop.RegMask

Which registers are even eligible? That is decided per architecture at init time, and SP, SB and the g register are always excluded:

go
// src/cmd/compile/internal/ssacompile/regalloc.go:650
	// Figure out which registers we're allowed to use.
	s.allocatable = s.f.Config.GpRegMask.Union(s.f.Config.FpRegMask).Union(s.f.Config.SpecialRegMask).Union(s.f.Config.SimdRegMask).Union(s.f.Config.SimdMaskReg)
	s.allocatable = s.allocatable.RemoveReg(s.SPReg)
	s.allocatable = s.allocatable.RemoveReg(s.SBReg)
	if s.f.Config.HasGReg {
		s.allocatable = s.allocatable.RemoveReg(s.GReg)
	}

Choosing a register

allocReg is the heart of it. Pick an unused register if there is one; otherwise evict somebody:

go
// src/cmd/compile/internal/ssacompile/regalloc.go:360
// allocReg chooses a register from the set of registers in mask.
// If there is no unused register, a Value will be kicked out of
// a register to make room.
func (s *regAllocState) allocReg(mask ssaop.RegMask, v *ssa.Value) ssaop.Register {
	if v.OnWasmStack {
		return noRegister
	}

	mask = mask.Intersect(s.allocatable)
	mask = mask.Minus(s.nospill)
	if mask.Empty() {
		s.f.Fatalf("no register available for %s", v.LongString())
	}

	// Pick an unused register if one is available.
	if !mask.Minus(s.used).Empty() {
		r := s.pickReg(mask.Minus(s.used))
		s.usedSinceBlockStart = s.usedSinceBlockStart.AddReg(r)
		return r
	}

	// Pick a value to spill. Spill the value with the
	// farthest-in-the-future use.
	// TODO: Prefer registers with already spilled Values?
	// TODO: Modify preference using affinity graph.
	// TODO: if a single value is in multiple registers, spill one of them
	// before spilling a value in just a single register.

	// Find a register to spill. We spill the register containing the value
	// whose next use is as far in the future as possible.
	// https://en.wikipedia.org/wiki/Page_replacement_algorithm#The_theoretically_optimal_page_replacement_algorithm
	var r ssaop.Register
	maxuse := int32(-1)
	for t := ssaop.Register(0); t < s.numRegs; t++ {
		if !mask.HasReg(t) {
			continue
		}
		v := s.regs[t].v
		if n := s.values[v.ID].Uses.Dist; n > maxuse {
			// v's next use is farther in the future than any value
			// we've seen so far. A new best spill candidate.
			r = t
			maxuse = n
		}
	}

That comment is worth pausing on: it links to the theoretically optimal page replacement algorithm. This is exactly the Belady optimal algorithm from virtual memory, transplanted to register allocation. The value whose next use is farthest away is the one whose register you can steal with the least harm.

Before actually evicting, the allocator tries to be clever: if the victim could be moved to a free register instead of spilled, it moves it:

go
// src/cmd/compile/internal/ssacompile/regalloc.go:417
	// Try to move it around before kicking out, if there is a free register.
	// We generate a Copy and record it. It will be deleted if never used.
	v2 := s.regs[r].v
	m := s.compatRegs(v2.Type).Minus(s.used).Minus(s.tmpused).RemoveReg(r)
	if !m.Empty() && !s.values[v2.ID].Rematerializeable && countRegs(s.values[v2.ID].Regs) == 1 {
		s.usedSinceBlockStart = s.usedSinceBlockStart.AddReg(r)
		r2 := s.pickReg(m)
		c := s.curBlock.NewValue1(v2.Pos, ssaop.OpCopy, v2.Type, s.regs[r].c)
		if s.f.Pass.Debug > ssa.RegDebug {
			fmt.Printf("copy %s to %s : %s\n", v2, c, &s.registers[r2])
		}
		s.setOrig(c, v2)
		s.assignReg(r2, v2, c)
	}

A copy is cheaper than a spill: no memory traffic, no reload. And note the !s.values[v2.ID].Rematerializeable condition — if the victim can be recomputed, recomputing it is free, so there is no point copying it.

Spilling

makeSpill creates the spill value. Note that it does not know where the spill will go yet:

go
// src/cmd/compile/internal/ssacompile/regalloc.go:449
// makeSpill returns a Value which represents the spilled value of v.
// b is the block in which the spill is used.
func (s *regAllocState) makeSpill(v *ssa.Value, b *ssa.Block) *ssa.Value {
	vi := &s.values[v.ID]
	if vi.Spill != nil {
		// Final block not known - keep track of subtree where restores reside.
		vi.RestoreMin = min(vi.RestoreMin, s.sdom[b.ID].Entry)
		vi.RestoreMax = max(vi.RestoreMax, s.sdom[b.ID].Exit)
		return vi.Spill
	}
	// Make a spill for v. We don't know where we want
	// to put it yet, so we leave it blockless for now.
	spill := s.f.NewValueNoBlock(ssaop.OpStoreReg, v.Type, v.Pos)
	// We also don't know what the spill's arg will be.
	// Leave it argless for now.
	s.setOrig(spill, v)
	vi.Spill = spill
	vi.RestoreMin = s.sdom[b.ID].Entry
	vi.RestoreMax = s.sdom[b.ID].Exit
	return spill
}

OpStoreReg and OpLoadReg are the two pseudo-ops that implement the spill mechanism. They are ordinary SSA ops, generated into the opcode table like everything else:

go
// src/cmd/compile/internal/ssa/ssaop/opGen.go:7213
	OpStoreReg
	OpLoadReg

The StoreReg/LoadReg pair is what turns into an actual store and load in the machine-code backend. On amd64:

go
// src/cmd/compile/internal/amd64/ssa.go:1387:1388
	case ssaop.OpStoreReg:
		if v.Type.IsFlags() {
			v.Fatalf("store flags not implemented: %v", v.LongString())
			return
		}
		r := v.Args[0].Reg()
		if v.Type.IsSIMD() {
			r = simdOrMaskReg(v.Args[0])
		}
		p := s.Prog(storeByRegWidth(r, v.Type.Size(), hasAVX(v)))
		p.From.Type = obj.TYPE_REG
		p.From.Reg = r

The whole spill/restore dance is described in the design essay:

go
// src/cmd/compile/internal/ssa/regalloc.go:34
// pre-regalloc:
//   (1) v = Op ...
//   (2) x = Op ...
//   (3) ... = Op v ...
//
// post-regalloc:
//   (1) v = Op ...    : AX // computes v, store result in AX
//       s = StoreReg v     // spill v to a stack slot
//   (2) x = Op ...    : AX // some other op uses AX
//       c = LoadReg s : CX // restore v from stack slot
//   (3) ... = Op c ...     // use the restored value

Restoring

allocValToReg is the mirror image. It gets a value into a register, either by copying it, rematerializing it, or loading it from its spill slot:

go
// src/cmd/compile/internal/ssacompile/regalloc.go:564:564
	} else {
		// Load v from its spill location.
		spill := s.makeSpill(v, s.curBlock)
		if s.f.Pass.Debug > ssa.LogSpills {
			s.f.Warnl(vi.Spill.Pos, "load spill for %v from %v", v, spill)
		}
		c = s.curBlock.NewValue1(pos, ssaop.OpLoadReg, v.Type, spill)
		sourceMask := s.compatRegs(v.Type)
		if !sourceMask.HasReg(r) && !onWasmStack {

Three ways to get a value into a register, in order of preference:

go
// src/cmd/compile/internal/ssacompile/regalloc.go:521
	// Allocate v to the new register.
	var c *ssa.Value
	if !vi.Regs.Empty() {
		// Copy from a register that v is already in.
		var current *ssa.Value
		if !vi.Regs.Minus(s.allocatable).Empty() {
			// v is in a fixed register, prefer that
			current = v
		} else {
			r2 := s.pickReg(vi.Regs)
			if s.regs[r2].v != v {
				panic("bad register state")
			}
			current = s.regs[r2].c
			s.usedSinceBlockStart = s.usedSinceBlockStart.AddReg(r2)
		}
		c = s.curBlock.NewValue1(pos, ssaop.OpCopy, v.Type, current)
	} else if v.Rematerializeable() {
		// Rematerialize instead of loading from the spill location.
		c = v.CopyIntoWithXPos(s.curBlock, pos)

Copy if it is already in a register, rematerialize if it can be recomputed, load from the spill slot otherwise.

Placing the spill

Now the hard bit: the spill has to be scheduled somewhere. The spill must dominate every restore of the value, and it must be issued at a point where the value is still in a register.

go
// src/cmd/compile/internal/ssa/regalloc.go:53
// What remains is the question of where to schedule the spill.
// During allocation, we keep track of the dominator of all restores of v.
// The spill of v must dominate that block. The spill must also be issued at
// a point where v is still in a register.
//
// To find the right place, start at b, the block which dominates all restores.
//  - If b is v.Block, then issue the spill right after v.
//    It is known to be in a register at that point, and dominates any restores.
//  - Otherwise, if v is in a register at the start of b,
//    put the spill of v at the start of b.
//  - Otherwise, set b = immediate dominator of b, and repeat.

The implementation walks the dominator tree looking for the best place, with a hard cutoff:

go
// src/cmd/compile/internal/ssacompile/regalloc.go:2226
		v := s.orig[i]

		// Walk down the dominator tree looking for a good place to
		// put the spill of v.  At the start "best" is the best place
		// we have found so far.
		// TODO: find a way to make this O(1) without arbitrary cutoffs.
		if v == nil {
			panic(fmt.Errorf("nil v, s.orig[%d], vi = %v, spill = %s", i, vi, spill.LongString()))
		}
		best := v.Block
		bestArg := v
		var bestDepth int16
		if s.loopnest != nil && s.loopnest.B2L[best.ID] != nil {
			bestDepth = s.loopnest.B2L[best.ID].Depth
		}
		b := best
		const maxSpillSearch = 100

and the loop-depth check, which avoids putting a spill inside a deeper loop than necessary:

go
// src/cmd/compile/internal/ssacompile/regalloc.go:2264
			if depth > bestDepth {
				// Don't push the spill into a deeper loop.
				continue
			}

That is a real optimisation: a spill inside a hot loop body would be executed on every iteration.

Phi elimination

Phis are the reason merge edges are hard. Before the allocator runs, the compiler tries to eliminate as many phis as possible by turning them into copies:

go
// src/cmd/compile/internal/ssa/copyelim.go:9
// PhiElimValue tries to convert the phi v to a copy.
func PhiElimValue(v *Value) bool {
	if v.Op != ssaop.OpPhi {
		return false
	}

	// If there are two distinct args of v which
	// are not v itself, then the phi must remain.
	// Otherwise, we can replace it with a copy.
	var w *Value
	for _, x := range v.Args {
		if x == v {
			continue
		}
		if x == w {
			continue
		}
		if w != nil {
			return false
		}
		w = x
	}

A phi with one distinct input is just a copy, and the copy can then be eliminated by copy propagation. That pass runs in a loop until nothing changes:

go
// src/cmd/compile/internal/ssacompile/copyelim.go:105
func phielim(f *ssa.Func) {
	for {
		change := false
		for _, b := range f.Blocks {
			for _, v := range b.Values {
				// This is an early place in SSA where all values are examined.
				// Rewrite all 0-sized Go values to remove accessors, dereferences, loads, etc.
				if t := v.Type; (t.IsStruct() || t.IsArray()) && t.Size() == 0 {
					v.Reset(ssaop.OpEmpty)
				}
				// Modify all values so no arg (including args
				// of OpCopy) is a copy.
				copyelimValue(v)
				change = ssa.PhiElimValue(v) || change
			}
		}
		if !change {
			break
		}
	}
}

What survives is a real phi, and the allocator has to handle it: the phi and all of its inputs must end up in the same register, or the merge edge needs a shuffle.

Merge edges: the shuffle

The linear scan treats the function as one long block, which means at a merge point the predecessors might disagree about what is in which register. shuffle fixes that up, edge by edge:

go
// src/cmd/compile/internal/ssacompile/regalloc.go:2326
// shuffle fixes up all the merge edges (those going into blocks of indegree > 1).
func (s *regAllocState) shuffle(stacklive [][]ssa.ID) {
	var e edgeState
	e.s = s
	e.cache = map[ssa.ID][]*ssa.Value{}
	e.contents = map[ssa.Location]contentRecord{}

The edgeState machinery is the most intricate part of the whole allocator. Its job is: given the register state at the end of the predecessor and the register state wanted at the start of the successor, insert copies to reconcile them.

The pass ordering constraints in the compiler’s pass list encode the requirements:

go
// src/cmd/compile/internal/ssacompile/compile.go:534
	// regalloc requires the removal of all critical edges
	{"critical", "regalloc"},
	// regalloc requires all the values in a block to be scheduled
	{"schedule", "regalloc"},

and further down:

go
// src/cmd/compile/internal/ssacompile/compile.go:548
	// the rules in late lower run after the general rules.
	{"lower", "late lower"},

and:

go
// src/cmd/compile/internal/ssacompile/compile.go:550:548
	// flagalloc needs instructions to be scheduled.
	{"schedule", "flagalloc"},
	// regalloc needs flags to be allocated first.
	{"flagalloc", "regalloc"},

So the ordering is: lower to machine ops, schedule the instructions, allocate the flags register, remove critical edges, then regalloc. Flag allocation has to come first because flags live in their own register on x86 and the main allocator needs to know that register is taken.

The full pass list, for context:

go
// src/cmd/compile/internal/ssacompile/compile.go:469
	{Name: "critical", Fn: critical, Required: true}, // remove critical edges
	{Name: "phi tighten", Fn: phiTighten},            // place rematerializable phi args near uses to reduce value lifetimes
	{Name: "likelyadjust", Fn: likelyadjust},
	{Name: "layout", Fn: layout, Required: true},     // schedule blocks
	{Name: "schedule", Fn: schedule, Required: true}, // schedule values
	{Name: "late nilcheck", Fn: nilcheckelim2},
	{Name: "flagalloc", Fn: flagalloc, Required: true}, // allocate flags register
	{Name: "regalloc", Fn: regalloc, Required: true},   // allocate int & float registers + stack slots
	{Name: "loop rotate", Fn: loopRotate},
	{Name: "trim", Fn: trim}, // remove empty blocks

Stack slots: the other allocator

Register allocation and stack slot allocation are separate. The register allocator spills to a logical spill value; the stack allocator then decides where in the frame that spill physically lives.

And here — unlike the register allocator — Go does build an interference graph:

go
// src/cmd/compile/internal/ssa/stackalloc.go:120
	// Compute liveness info for values needing a slot.
	s.computeLive(spillLive)

	// Build interference graph among values needing a slot.
	s.buildInterferenceGraph()

and the graph is built by backward liveness:

go
// src/cmd/compile/internal/ssa/stackalloc.go:407
func (s *StackAllocState) buildInterferenceGraph() {
	f := s.f
	if n := f.NumValues(); cap(s.interfere) >= n {
		s.interfere = s.interfere[:n]
	} else {
		s.interfere = make([][]ID, n)
	}
	live := f.NewSparseSet(f.NumValues())
	defer f.RetSparseSet(live)
	for _, b := range f.Blocks {
		// Propagate liveness backwards to the start of the block.
		// Two values interfere if one is defined while the other is live.
		live.Clear()
		live.addAll(s.Live[b.ID])
		for i := len(b.Values) - 1; i >= 0; i-- {
			v := b.Values[i]
			if s.values[v.ID].needSlot {
				live.Remove(v.ID)
				for _, id := range live.Contents() {
					// Note: args can have different types and still interfere
					// (with each other or with other values). See issue 23522.
					if s.values[v.ID].typ.Compare(s.values[id].typ) == types.CMPeq || hasAnyArgOp(v) || s.values[id].isArg {
						s.interfere[v.ID] = append(s.interfere[v.ID], id)
						s.interfere[id] = append(s.interfere[id], v.ID)
					}
				}
			}

The interference graph is used to decide which spilled values can share a stack slot. Two values that are never live at the same time can occupy the same memory. That is a straight graph-colouring problem over a small number of “colours” (the byte offsets within the slot), and it is where Go does use the classic technique.

Looking at it yourself

GOSSAFUNC is the tool for this. The SSA README explains it:

text
// src/cmd/compile/internal/ssa/README.md:187
### Playing with SSA

A good way to see and get used to the compiler's SSA in action is via
`GOSSAFUNC`. For example, to see func `Foo`'s initial SSA form and final
generated assembly, one can run:

	GOSSAFUNC=Foo go build

The generated `ssa.html` file will also contain the SSA func at each of the
compile passes, making it easy to see what each pass does to a particular
program.

Adding + writes plain text dumps to stdout instead:

text
// src/cmd/compile/internal/ssa/README.md:221
// If non-HTML dumps are needed, append a "+" to the GOSSAFUNC value
// and dumps will be written to stdout:

	GOSSAFUNC=Bar+ go build

And you can name specific passes:

text
// src/cmd/compile/internal/ssa/README.md:209
// The user can print the Control Flow Graph(CFG) by specifying
// the GOSSAFUNC value in the following format:

	GOSSAFUNC="$FunctionName:$PassName1,$PassName2,..." go build

// For example, the following command will print SSA with CFGs attached to
// the "sccp" and "generic deadcode" pass columns:

	GOSSAFUNC="blah.Foo:sccp,generic deadcode" go build

The regalloc and stackalloc columns in the HTML dump show you exactly which values got which register, and which got spilled.

Trade-offs

What a linear scan gives you: compile speed. A graph-colouring allocator has to build and colour a graph, and graph colouring is super-linear in the worst case. A linear scan is essentially one pass over the instruction stream. For a language that compiles a large program in a couple of seconds, that matters.

What it costs you: quality. A graph-colouring allocator can find any assignment with no conflicts; a linear scan is greedy and makes locally-optimal choices that can be globally bad. The classic case is a loop with high pressure, where the greedy allocator spills a value that a smarter allocator would have kept.

Why “treat the whole function as one long basic block” is a good trade: it removes the need for live-range intervals and holes, which is where most of the complexity of a classical linear scan lives. Go pays for that with the merge-edge shuffle at the end.

Why the Belady heuristic is worth it: evicting the value whose next use is farthest away is provably the best choice for minimising page faults in a FIFO-ish setting, and it is a good approximation here. The alternative — evicting the oldest value — is what you get for free from a naive linear scan, and it is much worse.

Why rematerialization matters so much: a value that can be recomputed never needs a slot, never needs a load, and never causes a spill. Go gets this for free for constants. It is a big reason why Go’s generated code is often better than you would expect from such a simple allocator.

Caveats

The algorithm is not a textbook linear scan. It is a variant, and the comments are careful to say “a version of” a linear scan allocator. The biggest deviation is that allocation happens at the point of use rather than over an interval.

“Treat the whole function as one long basic block” is a simplification, not a description of the implementation. The allocator does maintain per-block register state (endRegs, startRegs) and does respect the block structure; it just does not compute global intervals.

The output is not-quite-SSA. The design essay is explicit about this:

go
// src/cmd/compile/internal/ssa/regalloc.go:92
// Note: regalloc generates a not-quite-SSA output. If we have:
//
//             b1: x = ... : AX
//                 x2 = StoreReg x
//                 ... AX gets reused for something else ...
//                 if ... goto b3 else b4
//
//   b3: x3 = LoadReg x2 : BX       b4: x4 = LoadReg x2 : CX
//       ... use x3 ...                 ... use x4 ...
//
//             b2: ... use x3 ...
//
// If b3 is the primary predecessor of b2, then we use x3 in b2 and
// add a x4:CX->BX copy at the end of b4.
// But the definition of x3 doesn't dominate b2.  We should really
// insert an extra phi at the start of b2 (x5=phi(x3,x4):BX) to keep
// SSA form. For now, we ignore this problem as remaining in strict
// SSA form isn't needed after regalloc. We'll just leave the use
// of x3 not dominated by the definition of x3, and the CX->BX copy
// will have no use (so don't run deadcode after regalloc!).
// TODO: maybe we should introduce these extra phis?

The spill search has an arbitrary cutoff. const maxSpillSearch = 100 bounds the dominator-tree walk. On a function with an enormous dominator tree, the spill might be placed worse than optimal.

WebAssembly is a special case. The wasm backend keeps values on the wasm value stack rather than in registers, and the allocator has explicit branches for v.OnWasmStack throughout, including a comment admitting that the stack optimisation “in practice… breaks” in some cases.

Register pressure is not the only source of spills. A function call clobbers everything, so any value live across a call needs a spill slot regardless of how few registers are in use.

The RegAlloc array is indexed by value ID, so it is as large as the highest value ID. For a function with many values, that array is noticeably wasteful, which is why the compiler caches and reuses it between functions.

Wrapping up

The one thing to remember: Go’s register allocator is greedy and linear, and when it has to spill, it spills the value whose next use is farthest away — Belady’s optimal page replacement, applied to registers. If you want fewer spills, shorten the live ranges, not the function.

The part people get wrong: inlining makes register pressure worse, not better. Every inlined copy of a function has its own live ranges, and the allocator is working on the whole expanded function at once. A function that was register-pressure-bound before inlining can become spill-bound after, which is why the inliner has a “big function” rule.

What to go do about it: run GOSSAFUNC=YourFunc+ go build and look at the regalloc column. If you see StoreReg/LoadReg pairs, count them and look at where the loads are. Spills concentrated right after a call mean the function is holding too much across the call; spills inside a loop body mean the loop body is too big.

Further reading

  • src/cmd/compile/internal/ssa/regalloc.go — the design essay: the algorithm, the spill/restore mechanics, and the phi model.
  • src/cmd/compile/internal/ssacompile/regalloc.go — the implementation: allocReg, allocValToReg, makeSpill, placeSpills, computeLive, and the edge shuffle.
  • src/cmd/compile/internal/ssa/README.md — the SSA model, the Op vocabulary, and how to use GOSSAFUNC.
  • src/cmd/compile/internal/ssa/value.go — the Value struct and the RegAlloc/ResultReg accessors.
  • src/cmd/compile/internal/ssa/location.go — the Location interface and LocalSlot.
  • src/cmd/compile/internal/ssa/ssaop/op.go — Op, OpInfo, and the register constraint structures.
  • src/cmd/compile/internal/ssa/ssaop/regmask.go — RegMask, the register set arithmetic.
  • src/cmd/compile/internal/ssa/copyelim.go — PhiElimValue, phi elimination.
  • src/cmd/compile/internal/ssa/stackalloc.go — the stack slot allocator and its interference graph.
  • src/cmd/compile/internal/ssacompile/compile.go — the pass list and the ordering constraints between passes.
  • src/cmd/compile/internal/ssa/_gen/generic.rules — the architecture independent rewrite rules.
  • src/cmd/compile/internal/amd64/ssa.go — how OpStoreReg and OpLoadReg become real instructions.

GoInternalsSsaRegister allocationCompiler