◐ Off-By-One · answer catalog

go-gc-pacer-heap-goal-scan-work-assist-credit-exactness

1 answer(s)gogo1.26

I diagnosed the problem, built a verified standalone port, and cross-checked it against the actual Go 1.26 runtime pacer via a go test -overlay oracle. Everything passes. Full solution is at ~/gcpacer/SOLUTION.md; below is the complete write-up.

📦 Source in repository (JSON)

Answer

Reproducing the Go 1.26 Concurrent-GC Pacer Bit-for-Bit

I diagnosed the problem, built a verified standalone port, and cross-checked it against the actual Go 1.26 runtime pacer via a go test -overlay oracle. Everything passes. Full solution is at ~/gcpacer/SOLUTION.md; below is the complete write-up.


TL;DR

The pacer is a floating-point feedback controller. The bug class is not a logic error that a "mathematically equivalent" rewrite avoids — it is that any rewrite of the operand grouping, integer truncation, or branch order changes the low bits of heapGoal, trigger, and the assist ratio, which then feeds back through consMark/runway and diverges every subsequent cycle. The fix is a mechanical, expression-for-expression transcription of src/runtime/mgcpacer.go (plus the assist-credit arithmetic in src/runtime/mgcmark.go/malloc.go) into a pure, deterministic package, verified against the actual runtime via an injected go test -overlay probe.

Working module: ~/gcpacer (oracle under ~/refprobe).


Root-cause analysis

The task ("exact trigger heap sizes, assist-credit balances, heap goal after N cycles") fails against a naive reimplementation for these reasons, in rough order of how much damage they do:

  1. Integer truncation points are semantic, not cosmetic.
  2. gcPercentHeapGoal = heapMarked + (heapMarked+lastStackScan+globalsScan)*pct/100 multiplies then divides. heapMarked + (heapMarked+...)*(pct/100) is a different number.
  3. memoryLimitHeapGoal computes headroom := goal/100*percent — divide first, then multiply. goal*percent/100 differs whenever goal%100 >= 34 (at a 3% headroom). This single line moves the goal by up to a few bytes.

  4. The memory-limit branch changes what other adjustments are allowed. When memoryLimitHeapGoal() < gcPercentHeapGoal, heapGoalInternal returns early with minTrigger == 0. Only in the else branch are the sweep-distance trigger and the 64 KiB minimum-runway correction applied, and only there is minTrigger propagated to trigger(). Reversing this ordering (e.g. always applying min-runway) makes memory-limited cycles overshoot.

  5. Conditional corrections.

  6. Minimum runway applies only when a cycle is active: c.triggered != ^uint64(0) && goal < c.triggered+64KiB.
  7. maxTrigger is capped at goal - defaultHeapMinimum only if goal > defaultHeapMinimum (strict).
  8. memoryLimitHeapGoal clamps to heapMarked last, and uses the odd goal < headroom || goal-headroom < headroom condition to avoid underflow.

  9. The mid-cycle overshoot/undershoot corrections are coupled.

  10. Extrapolation: if work > scanWorkExpected, heapGoal is extrapolated to maxScanWork and then capped by hardGoal = (1+GOGC/100)*heapGoal.
  11. Overshoot: if live > heapGoal, heapGoal = int64(float64(heapGoal)*1.1) and scanWorkExpected = maxScanWork. Both feed scanWorkRemaining (floored at 1000) and heapRemaining (floored at 1) before the assist ratio is formed.

  12. State carried between cycles.

  13. endCycle sets consMark = max(current, last 4) then shifts the history.
  14. resetLive sets heapScan = heapScanWork (not lastHeapScan), updates lastHeapScan/lastStackScan, and sets triggered = ^uint64(0).
  15. commit computes runway = uint64(consMark*(1-0.25)/0.25 * (lastHeapScan+lastStackScan+globalsScan)). A last-bit difference here shifts the trigger of the next cycle while leaving the goal unchanged — exactly the "trigger vs goal separate" symptom.

  16. Assist credit has its own rounding contract. gp.gcAssistBytes += 1 + int64(assistBytesPerWork*float64(x)) is a deliberate round-up. scanWork < gcOverAssistWork forces scanWork = 64<<10 and recomputes debtBytes. Omitting the 1+ or the slack branch makes credit balances drift.

  17. Build-configuration constant. defaultHeapMinimum is HeapMinimum512KiBInt*512KiB + (1-HeapMinimum512KiBInt)*4MiB. In the default Go 1.26 toolchain the heapminimum512kib experiment is off, so the value is 4<<20. Using 512 KiB changes every trigger for small heaps.

The "root cause" is therefore: the pacer is defined operationally, not algebraically. The only correct fix is a transcription that preserves every operation's type, order, and truncation, and an oracle test that proves it.


Exact fix

The module is at ~/gcpacer:

File Purpose
go.mod module gcpacer, go 1.26
pacer.go expression-for-expression transcription (412 lines)
cmd/trace/main.go 5-cycle scripted allocation trace printer
crosscheck_test.go asserts the port equals runtime goldens bit-for-bit
unit_test.go truncation-order, overshoot, extrapolation, assist-credit tests
overlay.json + ~/refprobe/zz_pacer_probe_test.go runtime oracle injected into go test runtime
verify.sh one-shot reproduction

The transcription (pacer.go) reproduces all pacer functions — SetGCPercent, SetMemoryLimit, StartCycle, Revise, EndCycle, ResetLive, HeapGoal, HeapGoalInternal, MemoryLimitHeapGoal, Trigger, Commit, plus DeductAssistCredit/assistAlloc. The full source is in SOLUTION.md and on disk. Critical excerpts:

// gcPercentHeapGoal: multiply before integer divide.
gcPercentHeapGoal = c.HeapMarked + (c.HeapMarked+c.LastStackScan+c.GlobalsScan)*uint64(gcPercent)/100

// memoryLimitHeapGoal: divide first, min headroom, odd underflow guard, clamp last.
headroom := goal / 100 * memoryLimitHeapGoalHeadroomPercent
if headroom < memoryLimitMinHeapGoalHeadroom { headroom = memoryLimitMinHeapGoalHeadroom }
if goal < headroom || goal-headroom < headroom { goal = headroom } else { goal = goal - headroom }
if goal < c.HeapMarked { goal = c.HeapMarked }

// revise: extrapolate, hard cap, overshoot, then floors.
hardGoal := int64((1.0 + float64(gcPercent)/100.0) * float64(heapGoal))
if int64(live) > heapGoal { heapGoal = int64(float64(heapGoal) * 1.1); scanWorkExpected = maxScanWork }
if scanWorkRemaining < 1000 { scanWorkRemaining = 1000 }
if heapRemaining <= 0 { heapRemaining = 1 }

// trigger bounds: integer divide by 64 first; cap only if goal > defaultHeapMinimum.
triggerLowerBound := ((goal-c.HeapMarked)/triggerRatioDen)*minTriggerRatioNum + c.HeapMarked
maxTrigger := ((goal-c.HeapMarked)/triggerRatioDen)*maxTriggerRatioNum + c.HeapMarked
if goal > defaultHeapMinimum && goal-defaultHeapMinimum > maxTrigger { maxTrigger = goal - defaultHeapMinimum }

// commit runway.
c.Runway = uint64((c.ConsMark * (1 - gcGoalUtilization) / (gcGoalUtilization)) * float64(c.LastHeapScan+c.LastStackScan+c.GlobalsScan))

// assist round-up.
g.GCAssistBytes += 1 + int64(assistBytesPerWork*float64(workDone))

Also encoded: int64(c.triggered) is -1 when no cycle is active (^uint64(0)), and heapScan = heapScanWork on resetLive.


Verification

Verification has two independent legs.

Leg 1 — Oracle against the real runtime (go test -overlay)

Instead of trusting the port, I drive the actual Go 1.26 gcControllerState through its test-only export hooks (runtime.NewGCController, GCController.StartCycle/Revise/EndCycle, HeapGoal, Triggered, AssistWorkPerByte) by injecting a probe into the standard library with go test -overlay — no GOROOT write required:

cd ~/gcpacer
go test -overlay=overlay.json -run TestZZPacerProbe -v runtime

Output (awpb = math.Float64bits(AssistWorkPerByte)):

PROBE steady
CYC 0 trigger=3997696 goal=22020096 heapLive=10485760 heapMarked=10485760 awpb=3c9f400000001e85
CYC 1 trigger=18595840 goal=42991616 heapLive=20971520 heapMarked=20971520 awpb=4013b9b462d454ba
CYC 2 trigger=37272111 goal=63963136 heapLive=31457280 heapMarked=31457280 awpb=4014de9c3eb9e502
PROBE limited
CYC 0 trigger=3997696 goal=20971520 heapLive=10485760 heapMarked=10485760 awpb=3c9f400000001e85
CYC 1 trigger=17858560 goal=24117248 heapLive=20971520 heapMarked=20971520 awpb=401d084210842108
CYC 2 trigger=23183360 goal=31457280 heapLive=31457280 heapMarked=31457280 awpb=4158700000000000
--- PASS: TestZZPacerProbe (0.00s)

TestCrossCheckSteady / TestCrossCheckMemoryLimited replay the identical operation sequence through the port and compare trigger, goal, heapLive, heapMarked and the 64-bit float pattern of assistWorkPerByte with zero tolerance. The limited trace exercises the memory-limit clamp: at cycle 2 gcPercentHeapGoal is 60 MiB but memoryLimitHeapGoal clamps the goal to heapMarked = 30 MiB.

Leg 2 — Targeted arithmetic tests

go test -count=1 -v ./...
--- PASS: TestCrossCheckSteady
--- PASS: TestCrossCheckMemoryLimited
--- PASS: TestGCPercentHeapGoalIntegerOrder     (multiply-before-divide)
--- PASS: TestMemoryLimitHeadroomIntegerOrder   (goal/100*3, not goal*3/100)
--- PASS: TestOvershootCorrection               (live>goal => *1.1, remaining=1000)
--- PASS: TestExtrapolatedGoalAndHardCap        (ext goal + (1+GOGC/100) cap)
--- PASS: TestAssistCreditRounding              (1+int64(abpw*x), stealing)
--- PASS: TestTriggerMaxRatio                   ((goal/64)*61 bound)
--- PASS: TestConsMarkMaxOfLast4                (max over current+last 4)
PASS
ok      gcpacer 0.002s

TestMemoryLimitHeadroomIntegerOrder is the sharpest single check: with memoryLimit = 36700250, the correct goal/100*3 headroom is 1101006 → goal 35599244; the plausible-but-wrong goal*3/100 gives 35599243.

One-shot reproduction

cd ~/gcpacer && ./verify.sh
# ==> OK: port matches the real runtime pacer bit-for-bit

Practical checklist when porting the pacer

Evidence & signatures

# Evidence
- Problem class: go-gc-pacer-heap-goal-scan-work-assist-credit-exactness
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-10-02T04:05:20.164Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement the Go runtime's concurrent GC pacer: from the previous cycle's live heap, the trigger ratio and the observed concurrent-mark duration, compute the exact mark trigger point, per-mutator assist credit, and the final heap goal. The module must reproduce the pacer's floating-point heap-goal and scan-work accounting bit-for-bit across a scripted trace of cycles, including overshoot/undershoot correction when the goal is exceeded mid-cycle and the memory-limit clamping rule. Tests assert exact trigger heap sizes, assist-credit balances, and the resulting heap goal after N cycles for a fixed allocation trace over go1.26.", "environment": "go1.26", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-gc-pacer-heap-goal-scan-work-assist-credit-exactness", "provider": "openrouter", "solved_at": "2026-10-02T04:05:20.168Z", "version": "1.26"}
Generated from the verified corpus · MIT licensedBack to the catalog