# Green Tea cut my GC time 63%, and raised it 39%

> One heap shape lost 63% of its collector CPU and gained 14% throughput. Another gained 39% collector CPU and lost nothing. Same flag, same binary pair, same machine.

- Published: 2026-10-11
- Tags: gc, memory, profiling, performance
- Source: https://gopheria.com/blog/green-tea-changed-your-gc-profile/
- Language: en-US
- Author: Nolan Keir

---
The same flag cut one workload's collector CPU from 18.9% to 6.9% and pushed
another's from 2.8% to 3.9%.

Both are Green Tea, both on `go1.27.0`, both measured seven times on an idle
machine — and the only thing that differs between them is the shape of the heap.
Go 1.26's release notes promise "somewhere between a 10–40% reduction in garbage
collection overhead in real-world programs that heavily use the garbage
collector". A team at DoltHub measured their database and reported that "the
Green Tea collector doesn't make any difference in real-world performance
numbers". Neither of them is wrong.

## What is being measured

All numbers: `go1.27.0 darwin/arm64`, Apple M4 Pro, 12 cores, macOS 26.6.2. The
harness is
[`gclab/`](https://github.com/CognatePress/experiments.gopheria.com/tree/main/gclab),
the same steady allocator behind
[the GOGC and GOMEMLIMIT comparison](/blog/gogc-and-gomemlimit-are-different-knobs/).
Program runs rather than benchmarks, so every configuration ran **seven times**
and every figure is the median with the observed range beside it. Where two
ranges overlap, the post reports no result.

The A/B is a same-binary comparison because the escape hatch still exists:

```bash
go build -o /tmp/gclab ./gclab
GOEXPERIMENT=nogreenteagc go build -o /tmp/gclab-nogt ./gclab
go version -m /tmp/gclab-nogt | grep GOEXPERIMENT
#   build GOEXPERIMENT=nogreenteagc
```

That is worth stating plainly, because it was supposed to be gone. Go 1.26's
notes said the opt-out "is expected to be removed in Go 1.27"; Go 1.27's release
notes do not mention Green Tea or the flag at all, and it builds.

One caveat before the shapes. This is one allocating goroutine on twelve Ps, so
most of the mark work lands on Ps that had nothing else to run — the state
[the scheduler puts a P in](/blog/go-scheduler-blocking-goroutine/) when its
goroutine is not runnable. It inflates every absolute figure below and mostly
cancels in the ratios, which is why the comparison survives it and the raw
numbers do not travel.

Three heap shapes, each holding the same 128 MiB of requested live objects:

- **graph** — 4 million 32-byte nodes, each carrying two pointers into a
  separate 64 MiB pool of long-lived nodes chosen by hash. Many small objects of
  one size, all packed into the same size class.
- **pointer** — 768-byte objects, each 96 pointer slots addressing one shared
  contiguous array. Pointer-dense, but the targets are trivial to reach.
- **scalar** — 768-byte objects with no pointers in them at all. The same bytes,
  the same allocation count, nothing for the mark phase to follow.

## The shape it was built for

```text
$ for rep in 1 2 3 4 5 6 7; do
    /tmp/gclab      -mode=graph -live=134217728 -dur=6s
    /tmp/gclab-nogt -mode=graph -live=134217728 -dur=6s
  done
```

```text
graph                M alloc/s (median, n=7)   gc cpu %   mark dedicated
Green Tea              16.84  (16.44-17.18)     6.9  (6.6-7.1)     1.57s
no Green Tea           14.70  (13.82-15.20)    18.9 (18.0-19.3)    4.35s
```

**63.5% off the collector's CPU share, and 14.5% more application throughput.**
No range overlaps its counterpart on any of the three columns. That is past the
top of the 10–40% the release notes lead with, on a workload built to be the
best case.

The mechanism is in the Go team's own design post, and it is not about garbage
volume:

> Instead of scanning objects we scan whole pages. Instead of tracking objects
> on our work list, we track whole pages.

An 8 KiB page holding 256 32-byte nodes that all need marking is one unit of
work with excellent locality. The same page under the old design is 256
separate visits, each following a pointer somewhere else. The post names the
winning case exactly: "objects of the same size at a similar depth in the object
graph". That is the `graph` mode's definition.

## The shape where it does almost nothing

```text
pointer              M alloc/s (median, n=7)   gc cpu %   mark dedicated
Green Tea               3.53   (3.43-3.78)     27.0 (26.4-27.2)    6.29s
no Green Tea            3.43   (3.29-3.74)     28.2 (28.0-28.9)    6.40s
```

The collector's CPU share fell by 4.3%, and the ranges do not overlap, so that
is real. Everything else is not: the throughput ranges overlap, and so do the
dedicated mark seconds. A 1.2-point improvement in a share that the application
never got back is not something anyone will see on a dashboard.

This shape is pointer-dense — 96 pointers per object, forty-eight times what
the graph nodes carry — and it gains almost nothing. Pointer density is the intuition
people reach for and it is the wrong axis. What matters is how much of a page
the collector can do at once, and a heap of 768-byte objects gives it ten per
page instead of 256.

## The shape where it costs a little

```text
scalar               M alloc/s (median, n=7)   gc cpu %   mark dedicated
Green Tea               7.03   (6.79-7.07)      3.9  (3.4-4.0)     1.73s
no Green Tea            7.00   (6.81-7.11)      2.8  (2.8-3.0)     1.68s
```

**The collector's share went up 39.3%**, and the ranges do not overlap. This is
the case the design post warns about:

> some workloads that often require us to scan only a single object per page at
> a time. This is potentially worse than the graph flood.

And it cost nothing. Throughput was 7.03 against 7.00 with overlapping ranges;
the dedicated mark seconds overlap too. A 39% increase on a 2.8% base is 1.1
points of a program the collector was never the bottleneck for.

That gap between a large relative change and no absolute consequence is the
single most useful thing in this table, because it is what a monitoring
dashboard shows you and what a latency graph does not.

:::note
All three GC CPU shares here are read from `/cpu/classes/gc/total`, which
includes mark work done on Ps that had nothing else to run. On this
under-subscribed machine that inflates every row — the `graph` figures are 6.9%
and 18.9% by that measure and 2.3% and 6.1% with idle-time marking removed. The
ratio between the two builds barely moves either way, which is why the
comparison survives the accounting; the absolute numbers do not travel.
:::

## Why both published reports are right

DoltHub ran `sysbench oltp_read_write` against Dolt in September 2025, with Go
1.25's `GOEXPERIMENT=greenteagc`, and found no change in throughput or latency —
while noting that "Green Tea is consistently spending more CPU time during mark
at every garbage collection". Read next to the table above, that is the `scalar`
row: a small increase in mark CPU that does not reach the application.

The release notes' 10–40% is the `graph` row's neighbourhood, and the design
post gives 10% as "roughly the modal improvement". A database's heap is a mixture
of shapes, and the mixture is what decides where in that range a program lands —
or whether it lands outside it, in either direction.

So the question to ask of your own service is not how much garbage it makes.
[The GC frequency that a `sync.Pool` feels](/blog/sync-pool-under-gc-pressure/)
does not change here at all: the cycle counts are 16 against 15 in `graph`, 148
against 144 in `pointer`. What changed is the cost of each mark, and that is
decided by how many objects the collector can retire per page.

## What this does not measure

The amd64 path, which should be better than anything here. The Go 1.26 notes
promise a further ~10% on Ice Lake, Zen 4 and newer, where the collector uses
vector instructions for scanning small objects. This is `arm64` and cannot take
that path, so the graph result is the base improvement without it.

Pause time, which barely moved and was never the claim. Real heaps, which are
mixtures rather than one shape — three synthetic modes bracket the behaviour,
they do not model a service.

And the comparison is Green Tea against the collector it replaced *inside
go1.27.0*, not against Go 1.25. The old mark implementation still ships behind
the flag, but it has had two releases of unrelated runtime changes around it, so
this is not the same experiment as upgrading a service from 1.25 to 1.26.

One measurement I could not make honestly the first time: the first A/B run was
taken while this machine was also making network requests, and the `pointer`
throughput range widened from ±1% to ±10%. The `graph` result would have survived
it — a 2.6-fold difference does not care — but a widened range manufactures
"no difference" for free, and two of the three shapes here report exactly that.
The run was repeated on an idle machine, and the discarded one is in the harness
repository next to the one that counts.
