# Watching Green Tea move through the Golang heap

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.
