Watching Green Tea move through the Golang heap
- title
- Watching Green Tea move through the Golang heap
- type
- summary
- summary
- Phil Eaton measures Golang 1.26's Green Tea GC with perf, and the sparse pages it can't reclaim
- tags
- golang, garbage-collection, performance, microarchitecture
- sources
- golang-green-tea-gc
- created
- 2026-07-29
- updated
- 2026-09-14
Golang 1.25 shipped Green Tea as an opt-in garbage collector and 1.26 made it the default. Phil Eaton's post is an exercise in seeing that change rather than reading about it: draw the heap, measure with perf, then find the workload where the new collector doesn't help because the problem was never the collector.
Drawing the heap
Golang rounds every allocation up to a size class and places it in a span, a contiguous run of one or more 8 KiB pages holding objects of that class only. The allocator descends from tcmalloc, where size-segregated allocation is standard.
The demo allocates 100 objects picked randomly from three types (Small [32]byte, Medium [64]byte, Large [128]byte), reads each object's address back through reflect.ValueOf(o).Pointer(), sorts by address, and prints one character per 32 bytes of address space. Objects of the same size come out in unbroken runs even though the allocation order was random:
=== pass 0 (base 0xba4841580c0) ===
0xba4841580c0 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
...
0xba48415bcc0 ............................SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS
...
0xba4841add40 ..........................L---L---L---L---L---L---L---L---L-
Pass 1 runs runtime.GC() first and prints byte-for-byte identical output, including the base address. Golang never moves an object. The same program in C# produces interleaved L---M-M-SL--- runs, because .NET does not segregate by size, and in a later experiment its addresses shift after a collection.
What Green Tea changes
The classic mark phase follows each pointer roughly as it encounters it. When an object points to objects of different sizes, or to objects of the same type allocated at very different times, the targets live in unrelated parts of the address space, and the walk turns into random memory access. Green Tea instead scans a whole span for objects and pointers, and queues the spans those pointers land in for later scanning. The unit of work becomes a span rather than an object.
Demonstrating the mark path directly would need patches to the runtime, so Eaton measures the effect instead. The workload allocates 2,000,000 Node structs of four pointers each and wires them either packed (node i points at i+1 through i+4) or scattered (four random indices), then calls runtime.GC() 100 times. The index arrays are generated by a separate Python script and read from stdin as raw uint32, so the scattered case doesn't pay for random-number generation inside the measured program. Both binaries come from the same source; the old collector is built with GOEXPERIMENT=nogreenteagc.
Where the measurement goes wrong first
The wall-clock result is unambiguous. Packed goes from 4.23 s to 2.70 s, scattered from 11.05 s to 6.96 s. The cache numbers from perf stat -e cache-references,cache-misses point the other way:
| binary | input | cache-references | cache-misses |
|---|---|---|---|
| old GC | packed | 1,130,709,755 | 290,434,782 (25.69%) |
| old GC | scattered | 13,247,268,612 | 2,325,799,796 (17.56%) |
| Green Tea | packed | 481,414,281 | 257,894,055 (53.57%) |
| Green Tea | scattered | 3,398,491,016 | 2,195,902,796 (64.61%) |
The miss percentage roughly doubles or triples. Two things are wrong with reading it that way. cache-references and cache-misses in perf usually correspond to L3, which says nothing about L1 or L2 behavior. And the two binaries no longer run for the same length of time, so raw counts aren't comparable — the fix is to normalize against instructions and report misses per kilo-instruction. Doing that still doesn't rescue the story: L3 MPKI goes from 1.59 to 1.81 packed and 12.70 to 15.39 scattered.
The arithmetic explains the inversion. On the scattered workload Green Tea cut L3 references by 74% while absolute L3 misses fell only 5.6%. A ratio whose denominator collapses goes up. The reads that disappeared were the ones L3 was serving, not the ones it was missing.
Getting to the useful number requires L1 counters, and most virtual machines don't expose the PMU counters needed for L1 events, so Eaton moves to a Vultr bare-metal box and adds L1-dcache-loads and L1-dcache-load-misses:
binary ordering elapsed(s) L1miss% L1-MPKI L3miss% L3-MPKI
readorder_oldgc packed 4.47±0.32 0.9 2.23 26.2 1.61
readorder_oldgc scattered 11.44±0.85 12.9 31.57 17.5 12.76
readorder_greentea packed 2.70±0.01 1.0 1.98 53.8 1.81
readorder_greentea scattered 7.01±0.04 7.3 14.06 63.2 15.37
L1 MPKI on the scattered workload drops from 31.57 to 14.06. More of the mark phase's reads are satisfied from L1 (and probably L2) and never reach L3 at all, which is why the L3 rate stopped being informative. This is the same measurement trap as false-sharing-alignment-128, where the question of whether 128-byte padding beats 64 only resolves once you pick a counter that the effect actually shows up in, and it rhymes with golang-maps-swiss-tables, where a Swiss Tables microbenchmark win of 30% turns into 1.5% in production. A number that moves is not automatically the number you were looking for.
The sparse-page problem
Green Tea makes marking cache-friendlier. It does not make the collector moving, and that is where Golang's remaining structural weakness sits.
The second experiment allocates 50,000 objects, drops 90% of the references, then runs runtime.GC() followed by debug.FreeOSMemory(). Live data falls from 3,639.9 KiB to 364.6 KiB. The number of distinct 8 KiB spans holding a live object falls from 464 to 463:
=== pass 1 (5000 live, base 0x10a2674acc00) ===
0x10a2674acc00 L---........................L---............................
0x10a2674ad380 ................L---............................L---........
...
live data 364.6 KiB
spans pinned 463 (46 if the survivors were packed)
runtime HeapInuse 6320.0 KiB | HeapIdle 5552.0 KiB | HeapReleased 5512.0 KiB
Ten percent of the objects survived, spread evenly enough that almost every span still contains at least one of them, and a span with one live object cannot be returned. HeapInuse is 6,320.0 KiB in both passes.
The workaround is to do the compaction by hand. A third program copies each survivor by value into a per-type slice ([]Small, []Medium, []Large), nils the original references, and collects again. Survivors then sit in three dense arrays: spans pinned go from 465 to 48 against an ideal of 47, HeapInuse from 6,232.0 KiB to 2,768.0 KiB, and HeapReleased from 5,672.0 KiB to 9,040.0 KiB. This only works because the objects are values with no identity to preserve; anything holding pointers to them would now hold pointers to garbage. The general form of the trick is arena-allocation — own the region, not the individual objects, and release it wholesale.
The same free-90% experiment in C# needs no manual step. GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive) compacts, and the count of distinct 8 KiB chunks holding live objects drops from 458 to 47, matching the packed ideal exactly. The survivors also land at a completely different base address, which is the visible proof that .NET moved them.
Eaton's framing is that this is Golang's residual bugaboo rather than a Green Tea regression: the new collector fixed the traversal cost, and fragmentation is a separate property of never relocating an object. The pointer-stability that makes the fragmentation unavoidable is also what makes tricks like the unsafe pointer arithmetic in go-bounds-checks-unsafe sound in the first place.
Eaton applies the same reproduce-it-yourself method to the race detector in threadsanitizer-limits.
The same mark phase is reused for leak detection in goroutine-leak-profiler, which marks from unblocked goroutines only and adds blocked ones as roots when a primitive blocking them is reached.