GC shape stenciling in Go generics

Hacker News Top News

Summary

An in-depth explanation of how the Go compiler implements generics using GC shape stenciling, comparing it to Rust's full monomorphization and Java's type erasure.

No content available
Original Article
View Cached Full Text

Cached at: 07/16/26, 04:52 PM

# GC shape stenciling in Go generics Source: [https://rednafi.com/go/gc-shape-stenciling/](https://rednafi.com/go/gc-shape-stenciling/) While going through the[Go generics proposal](https://github.com/golang/proposal/blob/master/design/43651-type-parameters.md#implementation), I got curious about how the compiler implements it\. Compilers usually handle generics in one of two ways: - With[full monomorphization](https://doc.rust-lang.org/book/ch10-01-syntax.html#performance-of-code-using-generics), the compiler turns generic code into concrete, type\-specific code\. It generates a separate version for every set of type arguments the program uses\. Rust works this way, and so do C\+\+ templates\. - With[type erasure](https://docs.oracle.com/en/java/javase/26/docs/specs/jls/jls-4.html#jls-4.6), the compiler keeps one shared version of the generic code and replaces the type parameters with a common type\. Java erases them to`Object`or to their declared bounds\. Full monomorphization gives the compiler exact types for every generated function\. It can optimize each one like ordinary code, and the generic abstraction adds no runtime overhead\. The drawback is that every distinct set of type arguments can add another function body, which increases[compile time and binary size](https://github.com/golang/proposal/blob/master/design/generics-implementation-stenciling.md#risks)\. Erasure is at the opposite end of the spectrum\. There is only one body to compile, but the concrete types are gone at runtime\. The program needs casts and boxing instead\. Go sits between the two with an approach called[GC shape stenciling](https://github.com/golang/proposal/blob/master/design/generics-implementation-gcshape.md)\. It monomorphizes, but only down to a type’s GC shape\. Types with the same shape share one compiled body\. ## Full monomorphization[https://rednafi.com/go/gc-shape-stenciling/#full-monomorphization](https://rednafi.com/go/gc-shape-stenciling/#full-monomorphization) A small Rust program shows how full monomorphization generates all the concrete functions\. It calls the generic`identity`function once with a`u32`and once with a`u64`: ``` #[inline(never)] fn identity<T>(value: T) -> T { value } fn main() { println!("{} {}", identity(42_u32), identity(42_u64)); } ``` Save it as`mono\.rs`\.[`rustc`’s`\-\-emit`option](https://doc.rust-lang.org/rustc/command-line-arguments.html#--emit-specifies-the-types-of-output-files-to-generate)writes the LLVM intermediate representation to`mono\.ll`\. The[`\-C`flags](https://doc.rust-lang.org/rustc/codegen-options/index.html)select optimization level zero, a single codegen unit, and v0 symbol mangling\. Then`rg`keeps only the generated`identity`functions: ``` rustc mono.rs --emit=llvm-ir=mono.ll \ -C opt-level=0 \ -C codegen-units=1 \ -C symbol-mangling-version=v0 rg -A5 '; mono::identity' mono.ll | rg -v 'Function Attrs|^--$' ``` On Rust 1\.96\.1, the relevant output is: ``` ; mono::identity::<u32> define internal i32 @_RINvCs15VVTAbh19D_4mono8identitymEB2_(i32 %value) unnamed_addr #0 { start: ret i32 %value } ; mono::identity::<u64> define internal i64 @_RINvCs15VVTAbh19D_4mono8identityyEB2_(i64 %value) unnamed_addr #0 { start: ret i64 %value } ``` The highlighted signatures show what happened\. One function takes and returns`i32`\. The other uses`i64`\. One generic source function became two concrete function bodies\. C\+\+ templates do this too\. During[template instantiation](https://eel.is/c++draft/temp.inst#5), the compiler creates a specialization for each concrete set of arguments\. When the same specialization appears in several object files,[GCC’s implementation](https://gcc.gnu.org/onlinedocs/gcc/Template-Instantiation.html)emits every copy and leaves it to the linker to collapse the duplicates\. ## Type erasure[https://rednafi.com/go/gc-shape-stenciling/#type-erasure](https://rednafi.com/go/gc-shape-stenciling/#type-erasure) Java keeps one implementation instead\.`Box<String\>`and`Box<Integer\>`both run as the same`Box`class\. An unbounded`T`erases to`Object`, so a field declared as`T`is stored as an`Object`in bytecode\. When code reads that field from a`Box<String\>`,`javac`[inserts a cast](https://docs.oracle.com/javase/tutorial/java/generics/erasure.html)back to`String`\. A bounded parameter like`T extends Number`erases to`Number`instead\. The concrete type argument is[not available at runtime](https://docs.oracle.com/en/java/javase/26/docs/specs/jls/jls-4.html#jls-4.7)to the erased class\. Erasure also means type parameters accept only reference types\. Pass an`int`where a`T`is expected and Java[boxes it as an`Integer`](https://docs.oracle.com/javase/tutorial/java/generics/restrictions.html#instantiate)\. Boxing[entails heap allocation and indirection](https://openjdk.org/projects/valhalla/design-notes/state-of-valhalla/01-background#the-costs-of-boxing)\. Those casts and boxes are runtime work that a specialized version never does\. ## GC shape stenciling[https://rednafi.com/go/gc-shape-stenciling/#gc-shape-stenciling](https://rednafi.com/go/gc-shape-stenciling/#gc-shape-stenciling) Go’s generics proposal left the implementation strategy open\. What shipped is GC shape stenciling\. Here is the same`identity`function in Go: ``` package main import "fmt" type User struct{} type Order struct{} //go:noinline func identity[T any](value T) T { return value } func main() { fmt.Println(identity(42)) fmt.Println(identity(3.14)) fmt.Println(identity(&User{})) fmt.Println(identity(&Order{})) } ``` This program instantiates`identity`with`int`,`float64`,`\*User`, and`\*Order`\. To decide which calls can share compiled code, the compiler maps each type argument to its GC shape\. A*GC shape*is how a type appears to the allocator and the garbage collector: its size, its alignment, and which parts of it contain pointers\. The actual rule is stricter than that\. Per the[Go 1\.18 implementation notes](https://github.com/golang/proposal/blob/master/design/generics-implementation-dictionaries-go1.18.md#gcshapes), two types share a GC shape only when they have the same underlying type, with one exception: all pointer types share a single shape named after`\*uint8`\. So`\*User`and`\*Order`end up in the same group\. The exception covers pointer types only\. A`map\[string\]int`and a`chan int`are each one pointer at runtime, but neither is a pointer type\. They keep their own shapes\. The compiler substitutes each shape for`T`and compiles one version of the function per shape\. That substitution is the*stenciling*part\. The four calls above need only three bodies: one for`int`, one for`float64`, and one shared by the two pointer types\. Sharing that third body loses information\. The compiled code knows it received a pointer, but not whether the call used`\*User`or`\*Order`\. When the body needs the exact type, Go supplies it through a hidden[dictionary](https://github.com/golang/proposal/blob/master/design/generics-implementation-dictionaries-go1.18.md#dictionary-format)argument passed alongside the regular ones\. Despite the name, a*dictionary*is a fixed table that the compiler generates for each concrete instantiation and stores in the binary’s read\-only data\. Inside are the runtime type descriptors and whatever other type\-specific entries the body might need\.`identity`does nothing type\-dependent, so its body ignores the dictionary\. The compiler emits one for every instantiation anyway\. So this program should produce three function bodies and four dictionaries\. The[`//go:noinline`directive](https://pkg.go.dev/cmd/compile#hdr-Function_Directives)stops the compiler from inlining the calls, so the`identity`symbols stay in the binary\. Save it as`main\.go`, then build it and filter the symbol table\.`go tool nm`lists the symbols\.`rg`keeps the`identity`bodies and dictionaries, and`awk`drops the addresses: ``` go build -o /tmp/gcshape main.go go tool nm /tmp/gcshape \ | rg 'main\.(identity|\.dict\.identity)\[' \ | awk '{print $2, $3}' ``` On Go 1\.26\.5 on`darwin/arm64`, I get: ``` R main..dict.identity[*main.Order] R main..dict.identity[*main.User] R main..dict.identity[float64] R main..dict.identity[int] T main.identity[go.shape.*uint8] T main.identity[go.shape.float64] T main.identity[go.shape.int] ``` The first column tells the two groups apart, and the[`go tool nm`docs](https://pkg.go.dev/cmd/nm)explain it: - `R`marks read\-only data\. The first four lines are the dictionaries for`\*Order`,`\*User`,`float64`, and`int`\. - `T`marks the text segment, which holds code\. The last three lines are the compiled bodies\. `int`and`float64`each got a body of their own\. The highlighted`go\.shape\.\*uint8`body is shared by both pointer types, which is why four instantiations produced only three bodies\. Note The grouping is by underlying type, so a named type adds no new body\. Add`type MyInt int`and a call`identity\(MyInt\(7\)\)`\. The body count stays at three:`MyInt`reuses`go\.shape\.int`and only adds a dictionary of its own\. That sharing has a runtime cost\. Some operations in a shared body need the exact type: method calls on values of a type parameter, conversions to an interface, and type assertions and type switches\. The compiler rewrites these operations in a[dictionary pass](https://github.com/golang/proposal/blob/master/design/generics-implementation-dictionaries-go1.18.md#compiler-processing-for-calls-to-generic-functions-and-methods)so they read what they need from the dictionary at runtime\. A fully monomorphized body never does that\. Apart from those dictionary reads, a shared body should mostly compile to the same assembly as fully monomorphized code, per the[proposal’s risk section](https://github.com/golang/proposal/blob/master/design/generics-implementation-gcshape.md#risks)\. The main exception is method calls, which can’t be fully resolved at compile time\. They can also block inlining and make escape analysis more conservative, which can mean extra heap allocations\. Generics did slow the compiler down at first\. The[Go 1\.18 release notes](https://go.dev/doc/go1.18#compiler)said compile speed could be roughly 15% slower than Go 1\.17\.[Go 1\.20](https://go.dev/doc/go1.20#compiler)improved build speeds by up to 10% and brought them back in line with Go 1\.17\. Both figures cover the compiler as a whole rather than stenciling alone, but the 1\.20 notes attribute the earlier regression largely to generics support\.

Similar Articles

Notes from Optimizing CPU-Bound Go Hot Paths

Hacker News Top

The article discusses performance optimization techniques for CPU-bound Go code, highlighting the limitations of generics and interface abstractions due to lack of inlining, and advocates for code duplication in hot paths. It provides examples from a Brotli port and deep-dive benchmarking.

Gossamer: a Rust-flavoured language with real goroutines and pause-free memory

Hacker News Top

Gossamer is a new programming language inspired by Rust that features real goroutines, pause-free deterministic memory management with reference counting and arenas, and a bytecode VM with LLVM compilation. It aims to provide expressive syntax without a borrow checker or garbage collector pauses.

Garbage Collection Without Unsafe Code

Hacker News Top

safe-gc is a new Rust crate that provides a garbage collector implemented entirely without unsafe code, using heap-indexing instead of direct pointer dereferencing to maintain memory safety.

The Edge of Safe Rust

Lobsters Hottest

A TokioConf 2026 talk/blog post explores pushing safe Rust to its limits by implementing tracing garbage collection for complex pointer structures, sharing techniques for circular references and raw-pointer GC design.