Green Tea Garbage Collector

Green Tea is a redesign of the marking phase of Go’s garbage collector that trades the classic object-by-object graph flood for a span-based, memory-aware scan. Instead of chasing pointers wherever they lead — bouncing unpredictably across the heap and stalling the CPU on cache misses — Green Tea queues whole 8 KiB memory blocks (spans), lets reachable objects accumulate on each span while it waits, and then scans the span’s objects in address order in one contiguous pass. Introduced as an opt-in experiment under GOEXPERIMENT=greenteagc in Go 1.25 (Go 1.25 release notes), it became the default collector in Go 1.26 after incorporating production feedback (Go 1.26 release notes). The Go team expects a 10–40 % reduction in GC overhead for GC-heavy programs, with a further ~10 % on newer amd64 CPUs that supply vector-instruction scanning kernels (the Green Tea blog post).

Why the Classic Marker Hurts

To understand Green Tea you first need the problem it solves. Go’s pre-1.26 collector — see Tricolor Mark and Sweep and Go Garbage Collector — implements a tricolor parallel marking algorithm, which at its core is a graph flood: heap objects are graph nodes, pointers are edges, and marking is a breadth/depth traversal of every node reachable from the roots. Each worker keeps a small local stack of object pointers, pops one object, scans it for outbound pointers, marks the targets, and pushes them. Correctness is excellent. Locality is terrible.

The reason is that the graph flood pays no attention to where objects physically live. Two objects adjacent in the traversal order can be megabytes apart in memory; two objects adjacent in memory may be scanned a whole GC cycle apart. The Go team measured the cost precisely: in the official issue (golang/go#73581), they report that on average 85 % of the collector’s CPU time is spent in the scan loop, and over 35 % of the cycles in that scan loop are spent purely stalled on memory accesses — the CPU sitting idle waiting for a cache line that the unpredictable access pattern gave it no chance to prefetch.

Worse, the trend lines all point the wrong way. The issue lays out three hardware pressures: CPU clocks have outrun DRAM clocks, so a cache miss costs proportionally more cycles every year; rising core counts put more load on a physically limited memory bus, shrinking per-core bandwidth; and speed-of-light constraints push designs toward non-uniform memory access (NUMA), where a core’s access to “far” memory is slower than to “near” memory. A graph flood is hostile to all three. As the blog post puts it, “every bit of work is small, unpredictable, and highly dependent on the last, so the CPU is forced to wait on nearly every individual memory fetch” — there is nothing for the out-of-order engine to overlap.

A second, separate problem is work distribution. Each scanner aggressively checks and refills global work lists to keep all cores busy; on many-core machines this constant mutation of shared lists is itself a contention bottleneck.

Mental Model

Think of the classic marker as a courier following a treasure map one clue at a time, sprinting to a random address for each clue. Green Tea instead works building by building: it keeps a to-do list of buildings (spans), and for each building it visits, it handles every outstanding errand inside before leaving. Errands discovered along the way get filed under their building’s entry; the building only goes on the queue once. Because errands pile up on a building while it waits its turn, each visit clears many of them at once — and walking a building room by room is cache-friendly in a way that teleporting between addresses never is.

flowchart LR
    subgraph Classic["Classic graph flood — object queue"]
        O1["obj @ 0x1000"] --> O2["obj @ 0x9F40"]
        O2 --> O3["obj @ 0x0220"]
        O3 --> O4["obj @ 0xB180"]
    end
    subgraph Green["Green Tea — span queue (8 KiB blocks)"]
        S1["span A (8 KiB)\nscan all gray objs\nin address order"]
        S2["span B (8 KiB)\nscan all gray objs\nin address order"]
        S1 --> S2
    end
    Classic -.->|"redesign: queue blocks,\nlet objects accumulate"| Green

Diagram: the classic marker dequeues individual objects and jumps to wherever each lives (top), defeating cache prefetch. Green Tea dequeues 8 KiB spans and scans every pending object inside one span before moving on (bottom). The insight: queuing coarse blocks lets reachable objects accumulate per span, so one dequeue amortizes many scans and the scan walks memory sequentially.

Mechanical Walk-through

Green Tea’s core unit is the span — a memory block that is “always some multiple of 8 KiB, always aligned to 8 KiB, and consists entirely of objects of one size” (issue #73581). This is the same span concept the Go Memory Allocator already uses, so Green Tea is not inventing a new layout — it is reusing the allocator’s structure as GC metadata. The prototype (and 1.26 implementation) focuses on small-object spans: those holding objects from 16 bytes up to 512 bytes inclusive. The 512-byte ceiling is not arbitrary — it is gc.MinSizeForMallocHeader (defined as PtrSize * PtrBits = 8 × 64 = 512 on 64-bit platforms), the size at and below which an object’s pointer/scalar bitmap is stored inline at the end of its span rather than via a per-object malloc header; gcUsesSpanInlineMarkBits(size) returns true exactly for heapBitsInSpan(size) && size >= 16 (mbitmap.go, mgcmark_greenteagc.go). Larger objects are scanned by the old object-centric algorithm — see “Choosing the algorithm per pointer” below.

Per-object metadata. Each span stores two bits per object, mapping directly onto the tricolor abstraction (see Tricolor Mark and Sweep). The runtime source names them the “marks” bits and the “scans” bits (mgcmark_greenteagc.go, spanInlineMarkBits): the marks bit means “seen — reachable and queued to be scanned” (the gray state) and the scans bit means “already scanned” (the black state once both are set). White objects have neither bit set; gray objects have only the marks bit; black objects have both. The blog post calls the same pair a “seen” bit and a “scanned” bit. These bits are stored inline in the span itself (the spanInlineMarkBits struct) for the small-object size classes Green Tea handles, which is why they double as the GC’s queue metadata.

Marking a pointer. When the scan loop finds a pointer into a small object, it sets that object’s gray bit. If the gray bit was not already set, and the object’s span is not already enqueued, the span is pushed onto the work queue. A per-span “is enqueued” flag ensures a span sits on the queue at most once at a time — but, crucially, a span can reappear on the queue across the cycle as new pointers into it are discovered. This is the accumulation mechanism: while span B waits behind span A in the queue, every pointer into B discovered during A’s scan simply flips a gray bit in B’s metadata, costing one bit-set and no extra queue entry.

Scanning a span. When the scan loop dequeues a span it: (1) reads the gray-bit and black-bit bitmaps; (2) computes the difference — objects that are gray but not yet black — which is the set of objects to scan; (3) copies the gray bits onto the black bits (everything gray is now considered scanned); (4) walks those objects in memory order, scanning each for outbound pointers. Because step 4 proceeds left-to-right through one contiguous 8 KiB region, accesses are sequential and the hardware prefetcher works. The blog’s worked example shows Green Tea finishing a sample heap in 4 span scans versus 7 object scans for the graph flood.

Choosing the algorithm per pointer. Not all objects are small. The span allocator maintains a bitmap with one bit per 8 KiB page indicating whether that page is backed by a small-object span. When the scanner encounters a pointer, it checks this bitmap: small-object target → Green Tea path; otherwise → the legacy object-centric scan. That bitmap “fits easily into cache even for very large heaps”, so the dispatch check is nearly free.

Work distribution. Green Tea replaces the contended global lists with a per-worker, work-stealing span queue modelled on the goroutine scheduler’s run queues. Two effects reduce contention: workers steal directly from each other instead of hammering global lists, and because the queue holds spans rather than individual objects, there are simply far fewer items to enqueue. The Go team explored FIFO, LIFO, sparsest-first, densest-first, random, and address-ordered policies; FIFO won because it accumulated the highest average density of gray objects per span by the time the span was dequeued. The runtime source confirms this directly: the span queue uses “a FIFO policy, unlike workbufs which have a LIFO policy. Empirically, a FIFO policy appears to work best for accumulating objects to scan on a span” (mgcmark_greenteagc.go).

The single-object pathology and its fix. Green Tea’s bet is that spans accumulate multiple gray objects before being scanned. When a span is dequeued with only one gray object, Green Tea has done more bookkeeping than the old marker would have for that one object — a regression. The fix uses two tricks (issue #73581): the span records the object that was marked when it was enqueued (its representative), and a per-span hit flag records whether a second object was marked while the span sat queued. On dequeue, if the hit flag is clear, the collector scans only the representative object directly and skips the whole-span machinery. The blog reports a “surprise result”: “scanning a mere 2 % of a page at a time can yield improvements over the graph flood” — the accumulation does not have to be dense to pay off.

SIMD-Accelerated Scanning Kernels

The span layout unlocks something the graph flood never could: vectorized scanning. Because every object in a span is the same size and Go stores a packed pointer/scalar bitmap per small-object span, the per-size-class scan becomes a regular, data-parallel operation.

Go 1.26 ships AVX-512-based scanning kernels for amd64 CPUs that support them — per the release notes, “Intel Ice Lake or AMD Zen 4 and newer” (Go 1.26 release notes). The blog describes the kernel: fetch the gray/black bitmaps (each fits in a 512-bit register), compare them, expand the 1-bit-per-object representation to 1-bit-per-word using affine bit transforms (the VGF2P8AFFINEQB Galois-field instruction — “the bit manipulation Swiss army knife”, which “performs a bit-wise affine transformation, treating each byte in a vector as itself a mathematical vector of 8 bits and multiplying it by an 8x8 bit matrix … all done over the Galois field GF(2), which just means multiplication is AND and addition is XOR”), intersect with the span’s pointer/scalar bitmap to get an active pointer bitmap, then collect pointers 64 bytes at a time (the Green Tea blog post). The 1.26 notes quantify the upside: “Further improvements, on the order of 10 % in garbage collection overhead, are expected when running on newer amd64-based CPU platforms”. The issue’s prototype AVX-512 kernels reduced GC overhead “by another 15–20 % in the benchmarks where we already saw improvements” (issue #73581). SIMD only engages above a minimum object-density threshold — in the Go 1.26 source the dispatch is gated by objsMarked < nelems/8, i.e. fewer than one-eighth of a span’s objects marked falls back to the scalar path (mgcmark_greenteagc.go) — since sparse spans do not give the vector path enough to do.

No arm64 vector kernel exists (as of Go 1.26.1, May 2026). This was a flagged gap; it is now resolved against the source tree. The vectorized span scanner lives in internal/runtime/gc/scan, and the only architecture-specific fast implementation is scan_amd64.go / scan_amd64.s, which dispatches to ScanSpanPackedAVX512 and whose HasFastScanSpanPacked() returns the result of an AVX-512 feature check (avx512ScanPackedReqsMet, requiring AVX512VL, AVX512BW, GFNI, AVX512BITALG, AVX512VBMI, POPCNT). For every non-amd64 target the build-tagged scan_generic.go (//go:build !amd64) supplies a HasFastScanSpanPacked() that simply return falses, with a comment that the generic implementation “isn’t actually fast enough to serve as a general-purpose implementation. The runtime’s alternative of jumping between each object is still substantially better, even at relatively high object densities” (scan_generic.go). So arm64 runs Green Tea’s locality-friendly span queue but uses the scalar object-jumping scan, not SVE or NEON kernels — there is no scan_arm64.s in the tree. The locality and work-distribution wins of Green Tea still apply on arm64; only the additional ~10 % vector bonus is amd64-only.

Enabling, Disabling, and Version Timeline

# Go 1.25 — Green Tea is OFF by default; opt IN at build time:
GOEXPERIMENT=greenteagc go build ./...
 
# Go 1.26 — Green Tea is the DEFAULT; opt OUT at build time:
GOEXPERIMENT=nogreenteagc go build ./...
 
# Confirm what a binary was built with:
go version -m ./mybinary | grep GOEXPERIMENT

Line-by-line: GOEXPERIMENT is a build-time toggle baked into the binary, not a runtime environment variable — it must be set when compiling, and a deployed binary’s choice cannot be changed without rebuilding. In Go 1.25 the collector is experimental and off; greenteagc turns it on but without the vector kernels (Go 1.25 notes). In Go 1.26 it is on by default with the amd64 vector kernels; nogreenteagc reverts to the legacy graph-flood marker. The 1.26 release notes are explicit about the deprecation path: “This opt-out setting is expected to be removed in Go 1.27. If you disable the new garbage collector for any reason related to its performance or behavior, please file an issue.” So nogreenteagc is a transitional escape hatch, not a permanent knob — code that depends on it must plan to live without it by 1.27.

Note this affects only the marking phase. GOGC, GOMEMLIMIT, the pacer, the write barrier, sweeping, and the non-moving/non-generational nature of the collector are all unchanged — see GC Tuning and Observability and GC Pacing and GOGC.

Failure Modes and Common Misunderstandings

“It is always faster.” No. Green Tea improves locality when the application already has locality; it cannot manufacture locality from a heap that has none. The issue’s bleve-index benchmark is the cautionary case: a rapidly mutated low-fanout binary tree whose frequent rotations shuffle the tree across a 100+ MiB heap, so “half of all span scans only scan a single object.” Both Linux perf and CPU profiles showed “a small ~2 % regression in garbage collection overhead” on the 16-core amd64 environment, and a “small overall regression on the 16-core arm64 environment” too (issue #73581) — the single-object optimization narrowed but did not erase it. The benefit generally rises with core count (better many-core scalability), so small machines see less.

The RSS / memory trade-off. Green Tea’s locality-first design tends to retain or touch more memory, and multiple practitioner reports describe a baseline Resident Set Size (RSS) increase alongside the CPU win.

Uncertain

Verify: the specific claim that enabling Green Tea raises baseline RSS by ~8–15 %. Reason: re-checked against primary sources on 2026-05-22 and the figure is still not stated in any of them — neither the Go 1.26 release notes, the blog post, nor issue #73581 quantify any RSS increase; the only memory statement in the primary sources is the opposite (the tile38 benchmark shows “substantial improvements across throughput, latency and memory use”, per the issue). The “8–15 %” number traces only to secondary write-ups (Medium, byteiota, criztec). Treat it as an unverified community estimate, not a Go-team figure. To resolve: benchmark a representative workload with GOEXPERIMENT=nogreenteagc vs default on Go 1.26 and compare runtime/metrics /memory/classes/total:bytes, or await an official Go-team RSS statement. uncertain

Apparent regressions that are not GC. The issue warns of a subtle confound: when the mark phase is active for less time, there is less floating garbage (objects that died but were not yet reclaimed). Floating garbage acts as an accidental memory ballast in some benchmarks; removing it can shift behavior. Separately, spending less time in GC means more time spent in other bottlenecks, which can read as a net regression even though GC itself got cheaper. Always confirm a regression with a CPU profile attributing time to runtime.gcBgMarkWorker before blaming Green Tea — see GC Tuning and Observability.

“It changes when GC runs.” It does not. Green Tea is a marking-phase redesign; pacing — the decision of when to start a cycle — is still governed by GOGC and GOMEMLIMIT.

Alternatives and When to Choose Them

The only real “alternative” is the legacy graph-flood marker, reachable via GOEXPERIMENT=nogreenteagc on Go 1.26 (until removal in 1.27). Choose it only as a temporary mitigation if you measure a regression on your workload — and file an issue, because the Go team is explicitly soliciting such reports. Pinning an older Go toolchain (1.24 or earlier) avoids Green Tea entirely but forfeits every other 1.25/1.26 improvement; that is rarely worth it.

A genuinely different lever is to reduce GC pressure rather than swap markers: cut heap allocations via Escape Analysis, reuse buffers with sync.Pool, replace pointer-heavy structures with index-based ones (fewer edges to flood), and group pointer fields at the start of structs so scanning stops early. These help under either marker. For workloads where pause latency (not CPU) is the concern, GOGC/GOMEMLIMIT tuning (GC Pacing and GOGC, GOMEMLIMIT and the Soft Memory Limit) is orthogonal to and composable with Green Tea.

Production Notes

The Go team states in the tracking issue that Green Tea “is running within Google, and we feel that it is production-ready” (issue #73581), and the blog adds “we’ve rolled Green Tea out inside Google, and we see similar results at scale” (the Green Tea blog post) — the basis for promoting it to default in 1.26. The blog’s headline benchmark is tile38, an in-memory geospatial database whose heap is a high-fanout tree: Green Tea cut GC overhead ~35 % and improved throughput, latency, and memory. The contrasting bleve-index case (above) is the honest counterweight. The Go compiler’s own benchmark suite showed an inconsistent ~0.5 % regression that the team considers noise, possibly from a stale PGO profile.

Practical guidance for a 1.26 upgrade: (1) the marker swap is automatic — no code change; (2) re-baseline GC CPU with a profile and watch runtime.gcBgMarkWorker; (3) watch RSS and, in containerized deployments, recheck memory limits/requests before assuming the old headroom holds (the community RSS-increase reports, even if unverified, are worth pre-empting); (4) if you see a regression, capture a profile, try nogreenteagc, and file an issue — that feedback loop is exactly what the 1.27 removal of the opt-out depends on.

See Also