Skip to content

fix(gc): #745 — JSON polyglot RSS regression (v0.5.900) - #750

Merged
proggeramlug merged 2 commits into
mainfrom
worktree-fix-745-json-rss
May 13, 2026
Merged

fix(gc): #745 — JSON polyglot RSS regression (v0.5.900)#750
proggeramlug merged 2 commits into
mainfrom
worktree-fix-745-json-rss

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Summary

Closes #745. The 2026-05-13 polyglot refresh showed a 2-4× peak-RSS regression on the JSON benchmarks vs the v0.5.279 baseline:

v0.5.279 v0.5.898 (regressed) After fix Δ
roundtrip, lazy tape (default) 85 MB 250 MB 215 MB -14% vs regressed
parse-and-iterate, lazy tape (default) 100 MB 407 MB 246 MB -39% vs regressed
roundtrip wall time 75 ms 85 ms 82 ms unchanged / faster than baseline
parse-and-iterate wall time 466 ms 397 ms 403 ms within noise

Wall time was flat or slightly faster the whole time — this was purely a memory-footprint regression.

Root cause

Commit 56818086 added a per-parse bytes-trigger bump in gc_bump_malloc_trigger to defer GC during the post-parse iterate pass of json_pipeline_full (single 108 MB parse + 70 MB iterate). The bump uses bytes_now + GC_STEP_BYTES with "only raise — never lower" semantics — correct for the single-shot pattern, pathological for a loop of JSON.parse + discard: each iteration ratcheted the trigger another step higher, and with productive-sweep step-doubling (post-73a48ced ECS optimization) step grew toward 1 GB across cycles.

PERRY_GC_DIAG=1 on the 50-iter polyglot roundtrip showed zero GC cycles — the trigger had been pushed hundreds of MB above the actual live set (~5 MB) before allocation could ever catch up.

Fix

Two coordinated guards in crates/perry-runtime/src/gc.rs:

  1. GC_TRIGGER_BUMPEDgc_bump_malloc_trigger may raise the bytes-trigger only once per GC cycle. Flag set on successful raise, cleared at the top of gc_collect_inner so all collection entry points (full GC, minor GC via gc_collect_minor, manual gc(), malloc-count trigger path) re-arm it.

  2. GC_PRE_SUPPRESS_BYTESgc_suppress snapshots arena_total; gc_bump_malloc_trigger skips the bump if bytes_now - pre_suppress is below 32 MB. Cleanly separates the json_pipeline_full workload (108 MB per parse → still bumps) from the polyglot workload (~5 MB per parse → doesn't bump).

The ECS optimization from commit 73a48ced (GC_TRIGGER_ABSOLUTE_CEILING 64 → 128 MB) is untouched — that ceiling is what accounts for the residual ~2.5× gap vs v0.5.279 on the lazy-path RSS, and is deliberate.

Trade-off worth flagging

The PERRY_JSON_TAPE=0 (no-lazy, opt-in debug knob) path moves 265 → 356 MB. Firing GC during the loop exposes BLOCK_PERSIST_WINDOW conservative-scan pinning that the unfixed "never GC, grow linearly" behavior had been hiding. The lazy-tape default — the path the issue title named — is the headline metric and moves the right direction.

Test plan

  • cargo test --release -p perry-runtime --lib — 250/0/0
  • cargo test --release -p perry-runtime --lib gc — 40/0/0
  • Polyglot roundtrip + field-access RSS measured 5× per cell, GC diag traced (2 GCs per run vs 0 pre-fix)
  • Pipeline-full-style probe (200 k records, ~26 MB parse + iterate+rebuild) — bytes-bump still fires, runs in ~220 ms
  • Functional correctness: byte-equal checksums against pre-fix output

Files changed

  • crates/perry-runtime/src/gc.rs — +70/-0
  • CHANGELOG.md — new v0.5.900 entry
  • CLAUDE.md / Cargo.toml — version 0.5.898 → 0.5.900
  • Cargo.lock — version propagation

@proggeramlug
proggeramlug force-pushed the worktree-fix-745-json-rss branch from 227108d to 939ab29 Compare May 13, 2026 09:30
proggeramlug added a commit that referenced this pull request May 13, 2026
…ards)

CI run on PR #750 surfaced a regression in
test_memory_json_churn.ts: under all three GC profiles (default,
mark-sweep, gen-gc+wb) the test went from PASS at ~120 MB pre-fix
to FAIL at 461 MB (limit 250 MB). The churn test allocates ~13 KB
per iteration across 5k iterations into a deliberately fragmented
arena where every block holds both live and dead objects — so a
GC cycle sweeps 91-95% of bytes dead but reclaims *zero* blocks,
step-doubles on the productive sweep heuristic, raises the trigger,
and the cascade compounds. The original bytes-bump correctly
deferred GC indefinitely on that shape; my v0.5.900 fix made GC
fire and exposed the fragmentation cascade.

Revised the guards to gate on the suppressed window's actual arena
growth and split tiny-parse vs medium-or-larger parse handling:

  (1) `GC_PRE_SUPPRESS_BYTES` — gc_suppress snapshots arena_total
      so gc_bump_malloc_trigger can compute parse_growth.

  (2) `GC_TRIGGER_BUMPED` — once-per-cycle flag, applied only when
      parse_growth >= 1 MB (the json_pipeline_full and
      json_polyglot shapes). Cleared at the top of gc_collect_inner
      so all collection entry points re-arm. Tiny parses (< 1 MB,
      the churn shape) bypass the flag and bump every call, which
      preserves the pre-fix deferred-GC behavior.

  (3) Step cap at `GC_THRESHOLD_INITIAL_BYTES` (64 MB) — bound the
      bump's effective step so post-73a48ced step-doubling can't
      grant hundreds of MB of headroom per bump.

Validation after revision:

  benchmarks/json_polyglot (lazy-tape default):
    roundtrip   peak RSS: 250 → 223 MB (-11%, was -14% under v1)
    field-access peak RSS: 407 → 305 MB (-25%, was -39% under v1)

  scripts/run_memory_stability_tests.sh:
    18/18 PASS (was 15/18 PASS, 3/18 FAIL under v1)
    test_memory_json_churn lands at 209 MB (limit 250)

  Pipeline-full-style probe: ~135 ms, no mid-iterate GC, 217 MB —
  the json_pipeline_full optimization is preserved because the
  108 MB parse blows past the 1 MB tiny-parse threshold and the
  once-per-cycle flag still allows the first bump.

  perry-runtime tests: 250/0/0.

Versioning: rebased onto current main (v0.5.902 → v0.5.903) since
#743 took v0.5.900 and #749 took v0.5.901/v0.5.902 while this PR
was in CI. Updated changelog entry text to match the revised
approach.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Follow-up commit pushed (a1063f60) addressing the failing compile-smoke test.

What broke: the v1 fix made GC fire during test_memory_json_churn (5k iters × small+large parses into a fragmented arena). The churn workload's block layout interleaves live and dead objects, so a GC cycle sweeps 91-95% bytes dead but reclaims zero blocks, then step-doubles on the productive-sweep heuristic, raises the trigger, and the cascade pushes RSS to 461 MB (limit 250). The original bytes-bump correctly deferred GC indefinitely on that shape — my fix exposed the fragmentation cascade.

What changed: split tiny-parse vs medium-or-larger parse handling.

Guard Tiny parses (parse_growth < 1 MB) Medium+ parses (>= 1 MB)
GC_TRIGGER_BUMPED once-per-cycle flag bypassed (bumps every call) applied (one bump per GC cycle)
Step cap at GC_THRESHOLD_INITIAL_BYTES applied applied

The 1 MB threshold cleanly separates the churn shape (~13 KB per parse) from the polyglot (~5 MB per parse) and pipeline_full (~108 MB per parse) shapes. Pre-suppress snapshot (GC_PRE_SUPPRESS_BYTES) provides the parse-growth signal.

Validation:

Workload Pre-fix v1 (broken churn) v2 (this commit)
polyglot roundtrip (lazy) 250 MB 215 MB 223 MB
polyglot field-access (lazy) 407 MB 246 MB 305 MB
test_memory_json_churn ~120 MB ❌ 461 MB ✅ 209 MB
pipeline-full-style probe n/a 220 ms / 287 MB 135 ms / 217 MB

./scripts/run_memory_stability_tests.sh: 18/18 PASS (was 15/18 under v1).
cargo test --release -p perry-runtime --lib: 250/0/0.

Some headline RSS reduction on polyglot is traded for keeping churn passing — the lazy-tape default still moves the right direction (-11% / -25%) and preserves both prior optimizations (json_pipeline_full bump + ECS ceiling).

Rebased onto current main (v0.5.902 → v0.5.903) since #743 took v0.5.900 and #749 took v0.5.901/v0.5.902 ahead of this PR.

`gc_bump_malloc_trigger` was ratcheting the bytes-trigger up on every
gc-suppressed parse. For a single 108 MB parse (json_pipeline_full,
v0.5.279's original optimization target) that's correct. For a 50-iter
loop of `JSON.parse + discard` (json_polyglot's ~5 MB parses) it
suppressed GC entirely — the trigger climbed hundreds of MB above the
actual live set before allocation could catch up.

Two guards in gc.rs:

1. GC_TRIGGER_BUMPED — bump the trigger at most once per GC cycle.
   Flag is cleared at the top of gc_collect_inner (covers full,
   minor, manual gc(), and malloc-count trigger paths).

2. GC_PRE_SUPPRESS_BYTES — gc_suppress snapshots arena_total_bytes()
   so the bump can compute actual parse growth. Skip the bump if
   growth is below 32 MB. This cleanly separates json_pipeline_full
   (108 MB parse, bumps) from json_polyglot (~5 MB parse, no bump).

Validation: lazy-roundtrip 250 → 215 MB (-14%), lazy-field-access
407 → 246 MB (-39%) at unchanged wall time. GC fires 2× per polyglot
run vs 0× pre-fix. perry-runtime tests 250/0/0.
…ards)

CI run on PR #750 surfaced a regression in
test_memory_json_churn.ts: under all three GC profiles (default,
mark-sweep, gen-gc+wb) the test went from PASS at ~120 MB pre-fix
to FAIL at 461 MB (limit 250 MB). The churn test allocates ~13 KB
per iteration across 5k iterations into a deliberately fragmented
arena where every block holds both live and dead objects — so a
GC cycle sweeps 91-95% of bytes dead but reclaims *zero* blocks,
step-doubles on the productive sweep heuristic, raises the trigger,
and the cascade compounds. The original bytes-bump correctly
deferred GC indefinitely on that shape; my v0.5.900 fix made GC
fire and exposed the fragmentation cascade.

Revised the guards to gate on the suppressed window's actual arena
growth and split tiny-parse vs medium-or-larger parse handling:

  (1) `GC_PRE_SUPPRESS_BYTES` — gc_suppress snapshots arena_total
      so gc_bump_malloc_trigger can compute parse_growth.

  (2) `GC_TRIGGER_BUMPED` — once-per-cycle flag, applied only when
      parse_growth >= 1 MB (the json_pipeline_full and
      json_polyglot shapes). Cleared at the top of gc_collect_inner
      so all collection entry points re-arm. Tiny parses (< 1 MB,
      the churn shape) bypass the flag and bump every call, which
      preserves the pre-fix deferred-GC behavior.

  (3) Step cap at `GC_THRESHOLD_INITIAL_BYTES` (64 MB) — bound the
      bump's effective step so post-73a48ced step-doubling can't
      grant hundreds of MB of headroom per bump.

Validation after revision:

  benchmarks/json_polyglot (lazy-tape default):
    roundtrip   peak RSS: 250 → 223 MB (-11%, was -14% under v1)
    field-access peak RSS: 407 → 305 MB (-25%, was -39% under v1)

  scripts/run_memory_stability_tests.sh:
    18/18 PASS (was 15/18 PASS, 3/18 FAIL under v1)
    test_memory_json_churn lands at 209 MB (limit 250)

  Pipeline-full-style probe: ~135 ms, no mid-iterate GC, 217 MB —
  the json_pipeline_full optimization is preserved because the
  108 MB parse blows past the 1 MB tiny-parse threshold and the
  once-per-cycle flag still allows the first bump.

  perry-runtime tests: 250/0/0.

Versioning: rebased onto current main (v0.5.902 → v0.5.903) since
#743 took v0.5.900 and #749 took v0.5.901/v0.5.902 while this PR
was in CI. Updated changelog entry text to match the revised
approach.
@proggeramlug
proggeramlug force-pushed the worktree-fix-745-json-rss branch from a1063f6 to 54bf885 Compare May 13, 2026 11:24
@proggeramlug
proggeramlug merged commit b2cea69 into main May 13, 2026
9 checks passed
@proggeramlug
proggeramlug deleted the worktree-fix-745-json-rss branch May 13, 2026 11:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RSS regression on JSON polyglot: 85→254 MB roundtrip, 100→411 MB parse-and-iterate (v0.5.279 → v0.5.891)

1 participant