| name | go-perf-gc-tuning |
| description | Guides Go GC tuning — GOGC (ratio, default 100) vs GOMEMLIMIT (1.19 soft memory limit), the GOGC=off+limit pattern and thrashing risk when too tight, reading GODEBUG=gctrace, SetGCPercent/SetMemoryLimit, ballast-is-obsolete, Green Tea GC (1.25 experiment, 1.26 default), and the primary lever — lower the allocation rate first. Fires on "tune the GC", "set GOMEMLIMIT", "reduce GC overhead", "GOGC", "too much time in GC". Routes which-allocs to go-perf-pprof-profiling, tail latency to go-perf-tail-latency. |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
Go GC Tuning
Whether GC is even your bottleneck is a go-perf-methodology (USE) question; which objects allocate
is a go-perf-pprof-profiling heap-profile question. This skill owns the layer after: the knobs —
GOGC, GOMEMLIMIT, the pacer — when to turn them, and why the first move almost never is one. All Go
below is gofmt/go vet/go test-clean on Go 1.25 / 1.26.
1. The #1 lever is the allocation rate, not a knob
Before any knob, lower how much you allocate. The GC's cost is paced by allocation: "GC frequency =
(Allocation rate) / (New heap memory)", and "the total CPU cost of the garbage collector depends on
the total number of GC cycles" (GC Guide). Halve the bytes allocated per
second and you halve GC frequency — a CPU and tail-latency win no knob delivers, because "reducing GC
frequency may also lead to latency improvements," applying "not only to … modifying tuning parameters …
but also … the optimizations" that cut allocation (GC Guide). A knob
trades memory for CPU; fewer allocations is free on both axes. Find them with a heap profile
(-alloc_space) → go-perf-pprof-profiling; cut them with go-perf-escape-analysis,
go-perf-sync-pool, go-perf-slices — and tune only once the rate is as low as it gets.
2. GOGC — the heap-growth ratio
GOGC (default 100) sets how far the heap may grow between collections:
Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100
so at the default the GC fires when the heap roughly doubles the live set. It "determines the
trade-off between GC CPU and memory," and "doubling GOGC will double heap memory overheads and roughly
halve GC CPU cost, and vice versa" (GC Guide). GOGC=200 → fewer,
larger GCs (more RAM, less CPU, more throughput); GOGC=50 → more frequent GCs. GOGC=off (or
SetGCPercent(-1)) disables the collector — "equivalent to setting GOGC to a value of infinity, as the
amount of new memory before a GC is triggered is unbounded" — but only "provided the [memory limit] does
not apply" (GC Guide).
3. GOMEMLIMIT — the soft memory limit (Go 1.19)
GOMEMLIMIT caps total runtime-managed memory — it "includes the Go heap and all other memory managed
by the runtime, and excludes external memory sources such as mappings of the binary itself, memory
managed in other languages, and memory held by the operating system" — a byte value with a
B/KiB/MiB/GiB/TiB suffix, default math.MaxInt64/off (runtime env vars).
It is soft by design: "The Go runtime makes no guarantees that it will maintain this memory limit
under all circumstances; it only promises some reasonable amount of effort"
(GC Guide). As the heap nears the limit the GC runs more often — the
runtime spends more CPU rather than exceed it. That CPU is bounded: it's capped at "roughly 50%, with a
2 * GOMAXPROCS CPU-second window," and past that the runtime lets "memory use surpass the limit to
avoid spending too much time in the GC" (GC Guide) — the escape hatch
that stops a soft limit becoming a stall (§5).
4. The GOGC=off + GOMEMLIMIT pattern
The two knobs compose. GOGC=off plus a memory limit is the canonical container-sizing config:
"even when GOGC is set to off, the memory limit is still respected … this … represents a maximization
of resource economy because it sets the minimum GC frequency required to maintain some memory limit,"
letting "the heap size rise to meet the memory limit" (GC Guide). Use it
for a service with a hard container limit and bursty live-heap: no wasteful early GCs, but a hard-ish
cap. Leave 5–10% headroom — "leave an additional 5–10% of headroom to account for memory sources the
Go runtime is unaware of" (GC Guide). Prefer the GOMEMLIMIT / GOGC
env vars so ops can retune without a rebuild; use the API only when the limit is computed at startup:
func init() {
debug.SetGCPercent(-1)
debug.SetMemoryLimit(900 << 20)
}
5. The thrashing / death-spiral risk
A limit you can't honor is dangerous. When the live heap alone nears GOMEMLIMIT, the GC runs
"constantly … to maintain an impossible memory limit" and total run time "grow[s] in an unbounded
manner" — thrashing, which "effectively stalls the program." The soft-limit relaxation (§3)
mitigates but doesn't cure it: "a large enough transient heap spike can cause a program to stall
indefinitely," and "an indefinite stall is worse than an out-of-memory condition, which tends to result
in a much faster failure" (GC Guide). The guide's blunt rule: "Don't
set a memory limit to avoid out-of-memory conditions when a program is already close to its environment's
memory limits" (GC Guide); the API agrees that "a limit … lower than the
amount of memory used by the Go runtime may cause the garbage collector to run nearly continuously"
(SetMemoryLimit). Do not configure a memory limit
you can't honor. A small GOGC floor (not off) keeps the GC pacing below the limit so the cap is a
backstop, not a treadmill.
6. The pacer, mark-assist, and reading gctrace
The GC is concurrent but not free of the mutator: when allocation outruns the background mark workers,
the pacer makes the allocating goroutine itself do GC work — mark assist — so it can't
out-allocate the collector. Rising assist time is the tell that allocation is taxing latency; the fix is
§1 (allocate less) or a higher GOGC, never a lower one → go-perf-tail-latency. You read all
this from GODEBUG=gctrace=1, which "emit[s] a single line to standard error at each collection"
(runtime env vars):
gc 1 @0.012s 2%: 0.018+1.2+0.004 ms clock, 0.14+0.31/1.1/0+0.032 ms cpu, 4->4->2 MB, 5 MB goal, 0 MB stacks, 0 MB globals, 8 P
gc 1 GC number; @0.012s time since start; 2% — percentage of total time spent in GC since
program start (the number to watch — high and rising means GC-bound).
... ms clock — phases: STW sweep-termination + concurrent mark/scan + STW mark-termination. The two
STW figures are your pauses — typically sub-millisecond.
... ms cpu — the mark/scan triple is assist / background / idle (assist = the mutator tax above).
4->4->2 MB — heap at GC start → end → live; 5 MB goal — the §2 target. Trailing (forced) =
a runtime.GC() call.
For monitoring prefer runtime/metrics (/gc/heap/goal:bytes, /sched/pauses/total/gc:seconds,
/gc/gogc:percent) over scraping this text or the stop-the-world runtime.ReadMemStats —
catalog → go-perf-godebug-and-metrics.
7. SetGCPercent / SetMemoryLimit — the programmatic knobs
runtime/debug mirrors the env vars at runtime. SetGCPercent(percent int) int "sets the garbage
collection target percentage … returns the previous setting"; "a negative percentage effectively
disables garbage collection, unless the memory limit is reached"
(SetGCPercent). SetMemoryLimit(limit int64) int64
"provides the runtime with a soft memory limit" in bytes, "respected even if GOGC=off"; a negative
input reads the current limit without changing it, and math.MaxInt64 disables it
(SetMemoryLimit).
8. Ballast is obsolete
Before GOMEMLIMIT (Go 1.19), the trick for a service that GC'd too often at a tiny live heap was a
ballast — a big pointer-free allocation that inflates the heap so GOGC's doubling rule fires later:
ballast := make([]byte, 10<<30)
_ = ballast
It worked because the slice has no pointers (it "marks in O(1)") and stays virtual until touched, so it
cost little real RAM (Twitch, 2019 — superseded).
But it's a fragile proxy for "don't GC until ~N bytes"; GOMEMLIMIT states that intent directly,
even with GOGC=off (§3–4). In 2026, set a memory limit, never a ballast.
9. Green Tea GC (1.25 experiment → 1.26 default)
Green Tea is a redesigned mark/scan working on memory pages, not individual objects, with "somewhere
between a 10–40% reduction in garbage collection overhead in real-world programs that heavily use the
garbage collector," plus ~10% more on newer amd64 via vector scanning
(Go 1.26). The version status, precisely:
- Go 1.25: opt-in experiment — "may be enabled by setting
GOEXPERIMENT=greenteagc at build time"
(Go 1.25).
- Go 1.26: on by default — "previously available as an experiment in Go 1.25, is now enabled by
default after incorporating feedback." Opt out with
GOEXPERIMENT=nogreenteagc; that opt-out "is
expected to be removed in Go 1.27" (Go 1.26).
No new tunables — it shifts the GC CPU baseline, so re-baseline benchmarks after a 1.25→1.26 upgrade.
10. Don't
- Don't tune a knob before lowering the allocation rate — fewer allocations cuts GC CPU and
latency for free; a knob only trades memory for CPU (§1).
- Don't confuse
GOGC (relative growth ratio) with GOMEMLIMIT (absolute byte cap) — GOGC=100
means "double the live heap"; GOMEMLIMIT is a fixed ceiling in bytes (§2–3).
- Don't set a
GOMEMLIMIT you can't honor — a too-tight limit thrashes, and "an indefinite stall is
worse than an out-of-memory condition" (§5, GC Guide).
- Don't run
GOGC=off with no memory limit on an unbounded-heap service — that's GOGC=∞, an OOM
waiting to happen; and don't add a memory ballast in 2026 — superseded by GOMEMLIMIT (§2, §8).
- Don't assume Go STW pauses are long — sub-millisecond; the tail lever is allocation rate, and to
monitor it use
runtime/metrics, not gctrace-scraping or runtime.ReadMemStats (§6).
- Don't read a GC delta across a 1.25→1.26 upgrade without re-baselining — Green Tea moved the
baseline (§9).
11. Routing to Related Skills
This plugin (go-perf-* depth):
go-perf-pprof-profiling — heap profile (-alloc_space) to find which allocations to cut.
go-perf-allocator-internals — what an allocation costs; scan vs no-scan spans; size classes.
go-perf-sync-pool, go-perf-slices, go-perf-escape-analysis — the levers that lower allocation rate.
go-perf-tail-latency — p99/p999, coordinated omission, allocation-rate→GC-frequency framing.
go-perf-godebug-and-metrics — the runtime/metrics GC catalog and GODEBUG knobs.
go-perf-methodology — USE method: confirm GC is the bottleneck before tuning.
Parent / sibling marketplace (go-* correctness): go-performance — the measure-first overview
this deepens; go-version-feature-map — GOMEMLIMIT floor (1.19), Green Tea default (1.26).
12. Reference Files
GC-tuning anti-patterns in LLM-generated Go, each wrong/right with citations:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml