Skip to content

Old-gen garbage from untraced-promoted transient JSON trees is never reclaimed inside a parse/scan loop (8m:scan peak RSS 1.5–1.7× Node) #10182

Description

@proggeramlug

Symptom

benchmarks/json_performance records_array_8m:scan (parse 8 MB, iterate every record, drop; 10 parses) peaks at 188 MiB RSS against Node/Bun's 110 on current main (1.7×; 163 MiB = 1.48× on the combined parity branch). PERRY_GC_DIAG=1 over the whole run:

Same shape on records_array_20m parse/scan/sparse (peak 256 vs 220–225, 1.14×) and on records_object_8m:parse.

Cause

Each iteration's tree is promoted whole and dies one iteration later, in old gen. Nothing then reclaims it:

  • credit_promoted_bytes_to_old_baseline adds every promoted byte to GC_LAST_OLD_RECLAIM_IN_USE_BYTES (perf(gc): main regressed the retain cluster 2.2-4.8x — retain now runs 2 full collections where it ran none (suspect #7901/#7902) #7965, deliberately — a pinned baseline degenerates the proportional band into a quadratic constant-band pacer on retain). So old_in_use − baseline stays ≈ 0 and the proportional arm of old_reclaim_pressure_due never fires.
  • The absolute first-crossing arm (old_in_use ≥ 48 MB && baseline < 48 MB) is exempted while GC_MAJOR_PACING_RETAINING is set, and a young generation that survives at 999 ‰ sets it on every minor.
  • The three instruments that bound the untraced cohort act on evidence this workload never produces: untraced_promotion_budget_bytes (128 MB floor) forces a measuring minor, which measures the current young generation (again ~100 % live, so no contradiction); implied_dead_bytes charges promoted × (1000 − 980)/1000 = 2 % against the 32 MB PROMOTED_DEAD_BUDGET_BYTES, i.e. a full after 1.6 GB of promotions; request_old_reclaim_for_untraced_promotions needs a contradicting measurement.

So the predictor is right about survival (the tree is live at the minor) and wrong about lifetime (it dies right after), and no instrument observes lifetime. The garbage is reclaimed only when arena-growth escalation trips.

Candidate design (GC policy; gc-ratchet corpus before/after, then the cc rows on perrymaster, and both CPU and peak RSS on the JSON matrix — never trade the CPU lead for RSS)

Bound the unverified old-gen cohort: track promoted_since_last_full (bytes promoted by untraced or in-place promotions since the last full) and make old-reclaim due when it exceeds max(floor, k × old_live_at_last_full) with, e.g., floor 64 MB and k = 2. Rationale: the cohort's liveness is assumed, not measured; a full re-establishes it at a cost proportional to the verified live set, so bounding the unverified part by a multiple of the verified part keeps total major work linear (the #7592 argument) while capping residency.

Expected on the rows (from the diag numbers): 8m:scan live after a full ≈ 31 MB → a full every ~2.8 iterations → peak ≈ 31 + 64 + 23 ≈ 118 MiB (Node 110) at roughly +5 ms per iteration on a cell that is currently 6 % ahead on CPU. 20m family: live ≈ 78 MB → band 156 MB → a full every ~2.7 iterations, CPU still ahead of the 208 ms best, RSS roughly unchanged — the 20 MB rows cannot reach RSS parity this way (one dead 58 MB tree resident is already the gap), only a materially cheaper full mark can (a JSON tree marks at ~47 ns/object today; see #10169's design 3).

Not in scope, recorded here so it is not re-diagnosed: small_record:parse peaks at 80 vs 60 MiB because minors fire every ~50 MB — the tiny-parse guard's 48 MB floor from #9831/#9838, which exists to stop the cc minor storms; lowering it is a cc-rig decision, not a JSON-row one.

Reproduce

env PERRY_GC_DIAG=1 /usr/bin/time -l <worker> records_array_8m.json scan 8 2 2> diag.txt
grep -cE "ran pause_us" diag.txt; grep -c "kind=old_reclaim" diag.txt; grep -oE "old_in_use=[0-9]+" diag.txt | tail -1

Related: #10169 / #10177 (the stringify-result half of the same picture), #10123, #7965, #7902, #7888.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions