fix(gc): #745 — JSON polyglot RSS regression (v0.5.900) - #750
Conversation
227108d to
939ab29
Compare
…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.
|
Follow-up commit pushed ( What broke: the v1 fix made GC fire during What changed: split tiny-parse vs medium-or-larger parse handling.
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 ( Validation:
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 ( 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.
a1063f6 to
54bf885
Compare
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:
Wall time was flat or slightly faster the whole time — this was purely a memory-footprint regression.
Root cause
Commit
56818086added a per-parse bytes-trigger bump ingc_bump_malloc_triggerto defer GC during the post-parse iterate pass ofjson_pipeline_full(single 108 MB parse + 70 MB iterate). The bump usesbytes_now + GC_STEP_BYTESwith "only raise — never lower" semantics — correct for the single-shot pattern, pathological for a loop ofJSON.parse + discard: each iteration ratcheted the trigger anotherstephigher, and with productive-sweep step-doubling (post-73a48cedECS optimization)stepgrew toward 1 GB across cycles.PERRY_GC_DIAG=1on 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:GC_TRIGGER_BUMPED—gc_bump_malloc_triggermay raise the bytes-trigger only once per GC cycle. Flag set on successful raise, cleared at the top ofgc_collect_innerso all collection entry points (full GC, minor GC viagc_collect_minor, manualgc(), malloc-count trigger path) re-arm it.GC_PRE_SUPPRESS_BYTES—gc_suppresssnapshotsarena_total;gc_bump_malloc_triggerskips the bump ifbytes_now - pre_suppressis below 32 MB. Cleanly separates thejson_pipeline_fullworkload (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_CEILING64 → 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/0cargo test --release -p perry-runtime --lib gc— 40/0/0Files changed
crates/perry-runtime/src/gc.rs— +70/-0CHANGELOG.md— new v0.5.900 entryCLAUDE.md/Cargo.toml— version 0.5.898 → 0.5.900Cargo.lock— version propagation