Is GOGC a shippable default, or a trap?

A trap, as a bare setting — and the answer is GOMEMLIMIT, with GOGC as the pacer underneath it.

The prompting result: GOGC=800 bought +33% throughput on docs/perf/runs/gomaxprocs-scaling-20260816/, level-independent at GOMAXPROCS 8/4/1, justified with "RSS is only 107 MB loaded". That reading was taken with SKY_LIVE_STORE=memory at n=100. This run repeats it on the PostgreSQL store across n ∈ {100, 300, 500}, which is where the memory cost actually lives, and three things change.

34 arms, zero rejected.

Verdict

Does the +33% transfer to postgres?No. Ceiling is +24%, and only at n=500. At n=100, GOGC=800 is slower than GOGC=400.
Is there a setting that keeps most of it inside an e2-small at n=500?Yes — GOGC=400 + GOMEMLIMIT ≈ 750 MiB: +19% at 759 MB, bounded.
Does GOMEMLIMIT beat GOGC?Yes, on the axis that matters. Not throughput-per-byte — it bounds, and GOGC cannot.
What should ship?GOMEMLIMIT derived from detected machine memory at startup, GOGC=400. Sketch in What should ship.

Do not ship a bare GOGC default. GOGC=800 at n=500 measured 1,827 MB — more than an e2-small has.

The grid

PostgreSQL store, two counterbalanced blocks (block B is block A reversed, so no cell sits at the same sequence position twice). Both runs shown; throughput is tight at GOGC ≤ 400 and the spread at 800 is itself a finding.

nGOGCint/s (2 runs)peak RSS MB (2 runs)
1001002,791 – 2,799136 – 140
1002003,108 – 3,137204 – 215
1004003,375 – 3,375312 – 342
1008003,060 – 3,521550 – 773
3001002,696 – 2,827268 – 268
3002003,183 – 3,188412 – 415
3004003,409 – 3,423650 – 708
3008003,201 – 3,5301,148 – 1,926
5001002,793 – 2,838400 – 403
5002003,182 – 3,191626 – 654
5004003,217 – 3,4101,016 – 1,170
5008003,457 – 3,5351,674 – 1,979

1. The +33% does not survive the postgres store

Best case here is +24% (GOGC=800, n=500). At n=100 the curve turns over: GOGC=400 reads 3,375 and GOGC=800 reads 3,060–3,521 — no better, and at four times the memory. The published +33% is a property of the memory-store workload, not of the collector, and should not be quoted for a postgres app.

2. GOGC multiplies the per-session slope — this is the trap

The marginal cost of a session, measured n=100 → n=500 (the corpus's OLS-free method, so the fixed load-time growth is not charged to one level's sessions):

GOGCkB per session
100660
2001,075
4001,915
8002,913

GOGC is a multiplier on live heap, so it does not raise the baseline and leave the slope alone — it scales the slope with it. At GOGC=800 every session costs 4.4× what it costs at the default, which divides any capacity table by the same factor. A sizing guide that adopts GOGC=800 and keeps its sessions-per-instance column is wrong by 4.4×.

3. At GOGC=800 the memory is not merely high, it is unpredictable

Two identical n=300 runs read 1,148 MB and 1,926 MB — a 68% spread, against ≤6% for every arm at GOGC ≤ 400. Peak RSS at a relaxed pacer depends on where the heap happened to be when the window sampled it. An operator cannot provision against that, which disqualifies it as a default independently of the mean.

GOMEMLIMIT, and why it wins

n=500 configint/svs defaultpeak RSS
GOGC=100 (default)2,816402 MB
GOGC=2003,187+13%640 MB
GOGC=4003,314+18%1,093 MB
GOGC=8003,496+24%1,827 MB
GOGC=off + limit 750MiB3,074+9%766 MB
GOGC=off + limit 1500MiB3,406+21%1,518 MB
GOGC=100 + limit 750MiB2,840+1%404 MB
GOGC=400 + limit 750MiB3,345+19%759 MB

Three results, in order of importance:

Does it fit the instance?

RSS here is macOS/arm64. The capacity run measured the same workload on both and found x86 uses 15–19% LESS (16 kB pages against 4 kB) — so these figures are divided by 1.17 for a Linux estimate. That factor is borrowed, not re-measured here.

An e2-small is 1.93 GiB. Budget after the OS (~250 MB) and PostgreSQL (~106–140 MB): ~1,590 MB for the app.

n=500 configest. Linux RSSe2-small (1,590 MB budget)e2-medium
GOGC=100~344 MB22% — fitsfits
GOGC=400 + 750MiB~633 MB40% — fits, boundedfits
GOGC=400 alone~934 MB59% — fits, unboundedfits
GOGC=800~1,520 MB96% — no headroom, ±18%fits

GOGC=800 does not fit an e2-small. 96% of budget with an 18% run-to-run spread is an OOM waiting for a traffic peak, and the mandate's own rule applies: an app that dies at n=500 on an e2-small is a worse product than one that is 33% slower.

What should ship

GOMEMLIMIT derived from detected machine memory at startup, with GOGC=400. Not a sky.toml knob as the primary mechanism, and not documentation only — the failure it prevents is an OOM kill, which an operator cannot debug from the outside and will not pre-empt by reading a doc.

The machinery already exists and is already correct. runtime-go/rt/pg_embed_conf.go sizes PostgreSQL's shared_buffers from detectRAMBytes(), which consults cgroup v2 → cgroup v1 → /proc/meminfo → macOS sysctl, in that order, and its comment explains why the order matters: /proc/meminfo is not namespaced, so a 512 MB container on a 64 GB node reads 64 GB. A GC limit derived from /proc/meminfo would inherit exactly that bug.

// at startup, before the first allocation-heavy work
if ram := detectRAMBytes(); ram > 0 {
    // The app is not alone: PostgreSQL and the OS want their share, and
    // pg_embed_conf already claims 15% of RAM for shared_buffers.
    debug.SetMemoryLimit(int64(float64(ram) * 0.55))
}

Constraints that must hold, each of which this run supports:

What would falsify this

Conditions

Commit628c08c5 on feat/embedded-postgres, worktree branch perf/gogc-locks
HostM1 Mac, 8 physical cores (no SMT), 16 GB, macOS 25.5 — generator co-resident over loopback
Appforumbenchexamples/19-skyforum + the init-only view-size lever, FORUM_POSTS=5, 94 sky-id elements counted from the served HTML in every arm
Storepostgres, the app's own embedded cluster (--embed), PostgreSQL 14.21 Homebrew, fsync=on — matching gcp-x86-capacity-20260816, not the fsync=off throwaway of the M1 rebaseline
Loadclosed loop, -think 0, 20 s ramp, 8 s warmup, 45 s window, 100/300/500 sessions
Design12-cell grid × 2 blocks, block B the reverse of block A; GOMEMLIMIT and combined arms single
Generatortools/skyliveload at this commit; generator_possibly_saturated false in every arm (4.1–6% of machine)

Method guards

Every arm refuses rather than reports: the port is free before start and the listening pid is the pid launched; the store is read from the app's own banner (session store: postgres) because Sky.Live's dev fallback silently degrades to memory on an unreachable store; GOGC/GOMEMLIMIT are read back from the live process environment via ps -E, never trusted from the launch line; the view is 94 elements; and a patching self-check runs as a precondition before the window opens. An RSS watchdog aborts an arm above 5 GB so a relaxed pacer cannot take a 16 GB host down — it never fired.

-max-error-rate 1.0 follows the corpus convention so a transient blip does not discard an otherwise good arm; the error rate is asserted at analysis instead (analyse.sh), and every arm read error_rate 0, patch_rate 1.0, 2 patches per interaction, sessions established = requested.

Cross-validation against the existing corpus, which is what licenses comparing these numbers to it: idle RSS 35.2 MB against the capacity run's M1 34.8 MB; per-session slope at GOGC=100 685 kB against the published M1 615 and x86 625–650.

Two harness defects, both caught

Not measured — named, not assumed

Layout

results.tsv     one row per arm, with the analysis-time validity verdict
runs/<tag>/     per-arm load.json, acct.txt, 1 Hz rss.tsv
harness/        every script, including the mutation prover