Go's Generics: Dictionary Passing, Shape Stenciling and GC Shape
Go shares one copy of a generic function between instantiations that have the same shape, passes a flat dictionary of runtime types to it, and lets the shape decide the GC pointer map of the shared code.
TL;DR: Go does not monomorphise generics. It writes one copy of a generic function per shape – the underlying type of the type argument – and passes the concrete runtime types to that copy in a flat dictionary of machine words. The shape is also the GC contract: two instantiations may share code only if their shapes agree about where the pointers are.
Generics in Go are the rare language feature whose implementation is mostly not in the runtime. The runtime does not know what a type parameter is. The compiler does all the work, and it does it in three moves: decide which instantiations can share code, emit that shared code once, and hand the remaining type-specific facts to the shared code in a side channel.
This is a walk through how it works in the Go 1.28 development tree, based on
reading src/cmd/compile/internal/noder/, src/cmd/compile/internal/types/,
and src/internal/abi/.
What the runtime knows about generics: nothing
Start with the runtime type descriptor. This is the structure every Go type has at runtime, and it is the first place to look for evidence of type parameters:
// src/internal/abi/type.go:21
type Type struct {
Size_ uintptr
PtrBytes uintptr // number of (prefix) bytes in the type that can contain pointers
Hash uint32 // hash of type; avoids computation in hash tables
TFlag TFlag // extra type information flags
Align_ uint8 // alignment of variable with this type
FieldAlign_ uint8 // alignment of struct field with this type
Kind_ Kind // what kind of type this is (string, int, ...)
// function for comparing objects of this type
// (ptr to object A, ptr to object B) -> ==?
Equal func(unsafe.Pointer, unsafe.Pointer) bool
// GCData stores the GC type data for the garbage collector.
// Normally, GCData points to a bitmask that describes the
// ptr/nonptr fields of the type. The bitmask will have at
// least PtrBytes/ptrSize bits.
// If the TFlagGCMaskOnDemand bit is set, GCData is instead a
// **byte and the pointer to the bitmask is one dereference away.
// The runtime will build the bitmask if needed.
// (See runtime/type.go:getGCMask.)
// Note: multiple types may have the same value of GCData,
// including when TFlagGCMaskOnDemand is set. The types will, of course,
// have the same pointer layout (but not necessarily the same size).
GCData *byte
Str NameOff // string form
PtrToThis TypeOff // type for pointer to this type, may be zero
}There is no field for type arguments. No TypeParams, no Dict, no
TypeArgs. That is the single most important fact about Go’s generics
implementation in this tree, and it drives everything else:
- The runtime cannot recover
Tfrom a value of typeT. reflectcannot ask a type what its type arguments were.- A generic function that needs the concrete type of
Tat run time has to get it from the dictionary the compiler hands it.
What the runtime can do is store a pointer to a type descriptor, and a type descriptor is exactly what a dictionary entry holds.
Shapes
The compiler’s unit of code sharing is called a shape. The compiler’s type package carries two flags for it:
// src/cmd/compile/internal/types/type.go:227
const (
typeNotInHeap = 1 << iota // type cannot be heap allocated
typeNoalg // suppress hash and eq algorithm generation
typeDeferwidth // width computation has been deferred and type is on deferredTypeStack
typeRecur
typeIsShape // represents a set of closely related types, for generics
typeHasShape // there is a shape somewhere in the type
// typeIsFullyInstantiated reports whether a type is fully instantiated generic type; i.e.
// an instantiated generic type where all type arguments are non-generic or fully instantiated generic types.
typeIsFullyInstantiated
typeIsSIMDTag // type is the SIMD marker type
typeIsSIMD // type contains the SIMD marker type
typeMethodsComputed
)“Represents a set of closely related types”. That is the whole idea: a shape is a stand-in for a family of types that the generated code cannot tell apart.
Shapes are not real types. They live in a fake package that the compiler invents:
// src/cmd/compile/internal/types/type.go:1995
// Fake package for shape types (see typecheck.Shapify()).
var ShapePkg = NewPkg("go.shape", "go.shape")and a type declared in that package is a shape by definition:
// src/cmd/compile/internal/types/type.go:1642
func NewNamed(obj Object) *Type {
t := newType(TFORW)
t.obj = obj
sym := obj.Sym()
if sym.Pkg == ShapePkg {
t.SetIsShape(true)
t.SetHasShape(true)
}How a shape is computed
Shapify is the function that decides which type argument becomes which
shape:
// src/cmd/compile/internal/noder/reader.go:960
// Shapify returns the shape type for targ.
//
// If basic is true, then the type argument is used to instantiate a
// type parameter whose constraint is a basic interface.
func Shapify(targ *types.Type, basic bool) *types.Type {and then the rule itself:
// src/cmd/compile/internal/noder/reader.go:987
// When a pointer type is used to instantiate a type parameter
// constrained by a basic interface, we know the pointer's element
// type can't matter to the generated code. In this case, we can use
// an arbitrary pointer type as the shape type. (To match the
// non-unified frontend, we use `*byte`.)
//
// Otherwise, we simply use the type's underlying type as its shape.
//
// TODO(mdempsky): It should be possible to do much more aggressive
// shaping still; e.g., collapsing all pointer-shaped types into a
// common type, collapsing scalars of the same size/alignment into a
// common type, recursively shaping the element types of composite
// types, and discarding struct field names and tags. However, we'll
// need to start tracking how type parameters are actually used to
// implement some of these optimizations.
under := targ.Underlying()
if basic && targ.IsPtr() && !targ.Elem().NotInHeap() {
under = types.NewPtr(types.Types[types.TUINT8])
}Read the TODO. It says out loud what the rule is not doing: it is not
collapsing scalars of the same size, and it is not collapsing pointer-shaped
types in general. The shape of a type argument is its underlying type,
with one exception: if the type parameter’s constraint is a basic interface
(an any-like constraint with no methods), then any pointer argument
collapses to *byte, because in that case the compiler knows the pointee
type cannot affect the generated code.
The shape then gets a name in the fake package, and that name is the shape’s identity:
// src/cmd/compile/internal/noder/reader.go:1007
// Hash long type names to bound symbol name length seen by users,
// particularly for large protobuf structs (#65030).
uls := under.LinkString()
if base.Debug.MaxShapeLen != 0 &&
len(uls) > base.Debug.MaxShapeLen {
h := hash.Sum32([]byte(uls))
uls = hex.EncodeToString(h[:])
}
sym := types.ShapePkg.Lookup(uls)
if sym.Def == nil {
name := ir.NewDeclNameAt(under.Pos(), ir.OTYPE, sym)
typ := types.NewNamed(name)
typ.SetUnderlying(under)
sym.Def = typed(typ, name)
}
res := sym.Def.Type()
assert(res.IsShape())
assert(res.HasShape())
return resSo f[int] and f[int64] have shapes go.shape.int and go.shape.int64:
different shapes, therefore different stenciled code. f[*int] and
f[*string] under an any constraint both have shape go.shape.*byte:
one copy of the code, shared.
This is a real change from the design Go shipped in 1.18, where shapes were
computed by a “gcshape” unification that lumped int, int32 and int64
together because they have the same size and the same GC layout. The
gcshape machinery is gone from this tree; what remains is the underlying
type, plus the pointer collapse.
The identity rule that makes sharing legal
Sharing code is only safe if the compiler can prove the two instantiations behave identically. The compiler’s type-identity check has a special case for shapes:
// src/cmd/compile/internal/types/identity.go:12
// Identical reports whether t1 and t2 are identical types, following the spec rules.
// Receiver parameter types are ignored. Named (defined) types are only equal if they
// are pointer-equal - i.e. there must be a unique types.Type for each specific named
// type. Also, a type containing a shape type is considered identical to another type
// (shape or not) if their underlying types are the same, or they are both pointers.
func Identical(t1, t2 *Type) bool {
return identical(t1, t2, 0, nil)
}“their underlying types are the same, or they are both pointers”. That is the GC-shape rule in one sentence: two types may share code if their memory layouts agree, and pointers always agree with pointers.
And in the body:
// src/cmd/compile/internal/types/identity.go:45
if t1.obj != nil || t2.obj != nil {
if flags&identStrict == 0 && (t1.HasShape() || t2.HasShape()) {
switch t1.kind {
case TINT8, TUINT8, TINT16, TUINT16, TINT32, TUINT32, TINT64, TUINT64, TINT, TUINT, TUINTPTR, TCOMPLEX64, TCOMPLEX128, TFLOAT32, TFLOAT64, TBOOL, TSTRING, TPTR, TUNSAFEPTR:
return true
}Every scalar and every pointer kind is treated as identical when a shape is
involved. That is what lets f[int] and f[int64] be interchangeable as
far as the compiler’s shape analysis is concerned – while the dictionary
keeps the concrete types straight at runtime.
The dictionary
The shape handles everything the generated code can treat as uniform. What is left – the concrete runtime type of the type argument, the itab for a conversion, a method expression on a type parameter – has to be supplied at run time. That supply is the dictionary.
The dictionary is not a runtime type. In this tree it is a compiler-defined global variable, an array of machine words, and the shaped function takes a pointer to it as an extra argument:
// src/cmd/compile/internal/noder/reader.go:1626
// varType returns the type of dict's runtime dictionary variable.
func (dict *readerDict) varType() *types.Type {
return types.NewArray(types.Types[types.TUINTPTR], dict.numWords())
}and the shaped function’s signature gets the dictionary parameter:
// src/cmd/compile/internal/noder/reader.go:4337
// shapeSig returns a copy of fn's signature, except adding a
// dictionary parameter and promoting the receiver parameter (if any)
// to a normal parameter.
//
// The parameter types.Fields are all copied too, so their Nname
// fields can be initialized for use by the shape function.
//
// All signatures returned by shapeSig are marked as shaped.
func shapeSig(fn *ir.Func, dict *readerDict) *types.Type {
sig := fn.Nname.Type()
oldRecv := sig.Recv()
var recv *types.Field
if oldRecv != nil {
recv = types.NewField(oldRecv.Pos, oldRecv.Sym, oldRecv.Type)
}
params := make([]*types.Field, 1+sig.NumParams())
params[0] = types.NewField(fn.Pos(), fn.Sym().Pkg.Lookup(dictParamName), types.NewPtr(dict.varType()))The parameter is a pointer, so under the register ABI it travels in a
register, not on the stack. The parameter’s name is .dict, and the runtime
tooling knows about it:
// src/cmd/compile/internal/noder/reader.go:4330
// dictParamName is the name of the synthetic dictionary parameter
// added to shaped functions.
//
// N.B., this variable name is known to Delve:
// https://github.com/go-delve/delve/blob/cb91509630529e6055be845688fd21eb89ae8714/pkg/proc/eval.go#L28
const dictParamName = typecheck.LocalDictNameThe dictionary’s layout
The dictionary is a flat array of words with four sections:
dictionary (a global [N]uintptr)
+--------------------------------------------------+
| type param method exprs | func pointers |
+--------------------------------------------------+
| subdictionaries | pointers to other dicts|
+--------------------------------------------------+
| rtypes | *runtime._type pointers|
+--------------------------------------------------+
| itabs | *runtime.itab pointers |
+--------------------------------------------------+The section offsets are computed by four tiny functions:
// src/cmd/compile/internal/noder/reader.go:1596
// typeParamMethodExprsOffset returns the offset of the runtime
// dictionary's type parameter method expressions section, in words.
func (dict *readerDict) typeParamMethodExprsOffset() int {
return 0
}
// subdictsOffset returns the offset of the runtime dictionary's
// subdictionary section, in words.
func (dict *readerDict) subdictsOffset() int {
return dict.typeParamMethodExprsOffset() + len(dict.typeParamMethodExprs)
}
// rtypesOffset returns the offset of the runtime dictionary's rtypes
// section, in words.
func (dict *readerDict) rtypesOffset() int {
return dict.subdictsOffset() + len(dict.subdicts)
}
// itabsOffset returns the offset of the runtime dictionary's itabs
// section, in words.
func (dict *readerDict) itabsOffset() int {
return dict.rtypesOffset() + len(dict.rtypes)
}and the writer fills them in, in order, with an assertion that each section starts where the previous one ended:
// src/cmd/compile/internal/noder/reader.go:1534
assertOffset := func(section string, offset int) {
base.AssertfAt(ot == offset*types.PtrSize, pos, "writing section %v at offset %v, but it should be at %v*%v", section, ot, offset, types.PtrSize)
}
assertOffset("type param method exprs", dict.typeParamMethodExprsOffset())
for _, info := range dict.typeParamMethodExprs {
typeParam := dict.targs[info.typeParamIdx]
method := typecheck.NewMethodExpr(pos, typeParam, info.method)
rsym := method.FuncName().Linksym()
assert(rsym.ABI() == obj.ABIInternal) // must be ABIInternal; see ir.OCFUNC in ssagen/ssa.go
ot = objw.SymPtr(lsym, ot, rsym, 0)
}
assertOffset("subdictionaries", dict.subdictsOffset())The rtypes section is the important one. It holds *runtime._type
pointers, and for a shaped function one of those entries is the runtime type
of the type argument itself:
// src/cmd/compile/internal/noder/reader.go:1560
assertOffset("rtypes", dict.rtypesOffset())
for _, info := range dict.rtypes {
typ := pr.typIdx(info, dict, true)
ot = objw.SymPtr(lsym, ot, reflectdata.TypeLinksym(typ), 0)
// TODO(mdempsky): Double check this.
reflectdata.MarkTypeUsedInInterface(typ, lsym)
}The itabs section is the other one. An itab is the runtime table that maps
a concrete type to the code for an interface’s methods, and a generic
function that converts its type argument to an interface needs one:
// src/cmd/compile/internal/noder/reader.go:1569
// For each (typ, iface) pair, we write the *runtime.itab pointer
// for the pair. For pairs that don't actually require an itab
// (i.e., typ is an interface, or iface is an empty interface), we
// write a nil pointer instead. This is wasteful, but rare in
// practice (e.g., instantiating a type parameter with an interface
// type).
assertOffset("itabs", dict.itabsOffset())
for _, info := range dict.itabs {
typ := pr.typIdx(info.typ, dict, true)
iface := pr.typIdx(info.iface, dict, true)
if !typ.IsInterface() && iface.IsInterface() && !iface.IsEmptyInterface() {
ot = objw.SymPtr(lsym, ot, reflectdata.ITabLsym(typ, iface), 0)
} else {
ot += types.PtrSize
}The dictionary’s own name is a global symbol with a fixed prefix:
// src/cmd/internal/objabi/util.go:17
GlobalDictPrefix = ".dict" // prefix for names of global dictionariesso you will see .dict.f[int] and friends in a symbol dump.
How the dictionary reaches the shaped function
A concrete instantiation of a generic function is a thin wrapper. Its body is synthesised as a tail call to the shaped function, passing the dictionary:
// src/cmd/compile/internal/noder/reader.go:1441
// If this function has type parameters and isn't shaped, then we
// just tail call its corresponding shaped variant.
if r.hasTypeParams() && !r.dict.shaped {
r.callShaped(pos)
return true
}and the call itself:
// src/cmd/compile/internal/noder/reader.go:1470
// Construct the arguments list: receiver (if any), then runtime
// dictionary, and finally normal parameters.
//
// Note: For simplicity, shaped methods are added as normal methods
// on their shaped types. So existing code (e.g., packages ir and
// typecheck) expects the shaped type to appear as the receiver
// parameter (or first parameter, as a method expression). Hence
// putting the dictionary parameter after that is the least invasive
// solution at the moment.
var args ir.Nodes
if r.methodSym != nil {
args.Append(params[0])
params = params[1:]
}
args.Append(typecheck.Expr(r.curfn, ir.NewAddrExpr(pos, r.p.dictNameOf(r.dict))))
args.Append(params...)
r.syntheticTailCall(pos, shapedFn, args)So the call chain for f[int] looks like this:
caller
| call f[int] (a wrapper)
v
f[int] <- tail call, dictionary in a register
| call f[go.shape.int] with &.dict.f[int]
v
f[go.shape.int] <- the shared code
| loads .dict.f[int] to find the concrete *runtime._type
v
workThe dictionary is a global, so it costs nothing to pass: one pointer, in a register.
Where the shape and the GC meet
Everything above is about code sharing. What makes it safe to share is that the shape determines the memory layout of the stenciled code, and therefore its GC pointer map.
The shape type is a real named type with a real underlying type, so it has a real GC bitmap. The stenciled function’s frame is laid out according to the shape, and the compiler emits the pointer map for the shape, not for the concrete type argument. That is only correct if every instantiation sharing that shape agrees on where the pointers are – and the shape rules guarantee exactly that:
- Two type arguments with the same underlying type obviously have the same layout.
- Two pointer type arguments both have the same layout: one word, one pointer.
The pointer collapse to *byte is the extreme case of the second rule. When
the constraint is a basic interface, the compiler may not know the pointee
type, but it does know the value is one pointer, so all pointer arguments
collapse to one shape and therefore to one copy of the code.
The compiler’s own type-identity rule encodes the same thing, as we saw: underlying types equal, or both pointers.
Reflect and generics
Because the runtime type descriptor has no type-argument field, reflect
cannot recover the type arguments of an instantiated generic type. What it
can do is hand you the type descriptor of a concrete type argument, and it
does that with a trick:
// src/internal/abi/type.go:192
// TypeFor returns the abi.Type for a type parameter.
func TypeFor[T any]() *Type {
return (*PtrType)(unsafe.Pointer(TypeOf((*T)(nil)))).Elem
}TypeOf((*T)(nil)) boxes a nil *T into an any, and the compiler has
statically emitted the type descriptor for that conversion. The reflect
package wraps it:
// src/reflect/type.go:1395
// TypeFor returns the [Type] that represents the type argument T.
func TypeFor[T any]() Type {
// toRType is safe to use here; type is never nil as T is statically known.
return toRType(abi.TypeFor[T]())
}Note “T is statically known”. reflect.TypeFor is itself generic, so the
compiler instantiates it once per type argument, and each instantiation has
its own statically-known descriptor. There is no dictionary involved: this is
the fully-instantiated path.
For an instantiated generic type – List[int] rather than a type
parameter – the compiler emits a separate type descriptor per instantiation,
because the layouts differ:
// src/cmd/compile/internal/reflectdata/reflect.go:1394
case typ.IsFullyInstantiated():
// Instantiated type; possibly instantiated with unique type arguments.
// Need to emit to be safe (however, see TODO above).
return trueso List[int] and List[string] are distinguishable at runtime by pointer
comparison, even though the runtime cannot enumerate int and string from
the descriptor.
Trade-offs
What shape stenciling buys: one copy of the code for a family of
instantiations. A generic container written once serves []int and
[]string with the same machine code, and the binary does not grow with the
number of instantiations.
What it costs: every operation that depends on the concrete type has to be deferred to run time. Converting the type argument to an interface inside a generic function needs the itab, which is a dictionary lookup; a type switch on the type argument needs the runtime type, which is another dictionary lookup; and the compiler cannot devirtualise a method call on a shape type:
// src/cmd/compile/internal/devirtualize/devirtualize.go:72
// If typ is a shape type, then it was a type argument originally
// and we'd need an indirect call through the dictionary anyway.
// We're unable to devirtualize this call.
if typ.IsShape() {
return
}What monomorphisation would have bought instead: direct calls, direct itabs, direct type descriptors, and no dictionary at all. What it would have cost: a copy of the code per instantiation, which is exactly the explosion Go’s designers were trying to avoid.
The dictionary itself is cheap but not free. It is a global, so it costs binary size (one word per entry) and one pointer-sized argument per call. A generic function that never needs a concrete type at run time ends up with a dictionary of zero words, which is still a pointer-sized argument.
Caveats
The runtime does not know your type arguments. There is no
reflect.Type.TypeArgs, and internal/abi.Type has no field for them. If
your code needs the type arguments at run time, you have to carry them
yourself.
Sharing depends on the shape, and the shape is the underlying type.
f[int] and f[int64] do not share code in this tree. f[*int] and
f[*string] share code only when the type parameter’s constraint is a basic
interface; with a method-bearing constraint the pointer type is kept and the
shapes differ.
A pointer collapse to *byte means the pointee type is invisible. The
compiler only does this when it can prove the pointee does not affect the
generated code, which is why it requires a basic constraint.
A defer in a generic function is still a defer. The shape machinery
does not change how defers, panics or recovers work; the defer’s closure
simply lives in a shared function.
Method calls on a type parameter are indirect. The devirtualiser gives
up on shape types, so x.Method() inside a generic function is a call
through a pointer, not a direct call.
The dictionary’s layout is an implementation detail. It is a flat array of words with four sections, computed by four offset functions, and the sections are filled in by the compiler. Nothing in the runtime knows about it, which means nothing in the runtime can help you debug it.
Instantiated generic types get their own type descriptors. List[int]
and List[string] are different runtime types, with different descriptors
and different names. That is a binary-size cost paid per instantiation.
Wrapping up
The one thing to remember: Go shares generic code per shape, and the
shape of a type argument is its underlying type, with pointer arguments
collapsing to *byte under a basic constraint. f[int] and f[int64]
get separate code; f[*int] and f[*string] share it.
The part people get wrong: the runtime is not involved in generics at
all. There is no runtime dictionary type, no type arguments in
internal/abi.Type, and no way to recover T from a value at run time.
Everything is a compiler decision, communicated to the shared code through a
flat array of machine words.
What to go do about it: read Shapify in
src/cmd/compile/internal/noder/reader.go and the four *Offset functions
just below it. Together they are the entire specification of what shares code
and what does not, and they are much shorter than the design document you
are imagining.
Further reading
src/cmd/compile/internal/noder/reader.go–Shapify,shapeSig,dictNameOf, the four section-offset functions,callShaped,rtype0, anditab.src/cmd/compile/internal/noder/writer.go– the export-data side: the dictionary’s sections, the “basic interface” flag, andtypeParamIndex.src/cmd/compile/internal/types/type.go– thetypeIsShapeandtypeHasShapeflags,ShapePkg,NewNamed, andIsPtrShaped.src/cmd/compile/internal/types/identity.go–Identicaland the rule that lets shapes stand in for concrete types.src/internal/abi/type.go– the runtime type descriptor, with no room for type arguments, plusTypeFor.src/cmd/compile/internal/reflectdata/reflect.go–NeedEmit,TypeCanBeDupok,ITabLsym, andwriteITab, which is where the instantiated types and the itabs the dictionary points at are emitted.src/cmd/compile/internal/devirtualize/devirtualize.go– why a call on a shape type cannot be devirtualised.src/cmd/internal/objabi/util.go–GlobalDictPrefix, the.dictname prefix.src/reflect/type.go–TypeFor, and the generic-friendly entry points inreflect.