Skip to content

JSON roundtrip at scale: every minor traces the whole live tree because the large stringify result is malloc-tracked (untraced promotion vetoed) #10169

Description

@proggeramlug

Symptom

benchmarks/json_performance records_array_20m:roundtrip (parse 20 MB, stringify, discard) spends 52 % of wall time in copying minors, and each of those minors traces the entire live tree only to promote it in place (PERRY_GC_DIAG=1, 5 iterations, tree from #10168):

minor pause copy phase (trace) promotion in_place untraced survival
1 57 ms 34 ms, 586 k objects copied – no no 998 ‰
2 109 ms 55 ms, 0 objects copied 9 ms, 1.17 M objects / 58 MB yes no 999 ‰
3 107 ms 54 ms, 0 objects copied 10 ms, 1.16 M objects / 58 MB yes no 999 ‰

The parse-only cell on the same tree gets the cheap path: its second minor is untraced=true, promotes 1.74 M objects / 87 MB in 11 ms with a 0 µs trace. So the mechanism exists (#7888) and the survival gate (980 ‰) is met; the roundtrip is vetoed by a different gate.

sample on the roundtrip agrees: 46.5 % of leaf samples are GC (run_copied_minor_attempt, visit_slot_with_parent, classify_arena, remembered_child_needs_tracking, visit_gc_layout_slot_descriptors, rebuild_evacuated_old_to_young_remembered_set, mark_addr, plus 4.3 % _tlv_get_addr), 30 % parse, 10.5 % stringify.

Cause

untraced = promoting_in_place && collector.skip_remembering && … (gc/copying.rs), and skip_remembering requires malloc_registry_empty_at_start — its proof: remembered-set entries are created only for nursery children (none after the retag) or malloc-registry children.

json_output_storage_alloc (string/mod.rs) deliberately puts every JSON result ≥ 512 KB (JSON_MALLOC_OUTPUT_THRESHOLD) into the malloc registry, "so the next minor sweep reclaims it without a whole-old-heap trace". On the roundtrip that registry therefore holds 1–3 objects at every trigger (malloc=1/2/3 on the diag's site=safepoint lines; malloc=0 on the parse cell), the untraced path is vetoed, and reclaiming one dead 20 MB leaf costs a 55 ms trace of 1.17 M live objects plus the three remembering passes.

Note the trade the current design makes: the minor reclaims the dead string every cycle, but the dead trees (promoted in place, 58 MB each) are old-gen garbage that no minor can reclaim; over the 5 iterations no full ran (old_in_use reached 82 MB, arena 146 MB, peak RSS 283 MB vs Node/Bun 261 MB).

What V8 does on the same input

V8 pretenures JSON.parse results for large sources (old-space allocation, no scavenge traffic at all); the tree and the result string die in old space and are reclaimed by the paced, incremental mark-compact. That is why Node's roundtrip is 81 ms/iteration with 261 MB peak: no per-iteration trace of the live tree.

Candidate designs (GC policy; each needs the gc-ratchet corpus before/after, and both CPU and peak RSS on the JSON matrix — never trade one for the other)

  1. Born-old large JSON results. Route json_output_storage_alloc above the threshold to the arena large-object path (born old) instead of gc_malloc. The registry stays empty, the parse trees take the 11 ms untraced promotion, and dead results become old-gen garbage reclaimed by fulls. The question is then purely the old-reclaim pacing (gc_old_reclaim_growth_band_bytes, 50 % of baseline with a 48 MB floor): a full per ~1.5 iterations marks the same live tree the minor marks today, so the win is only real if (a) the full's mark is cheaper than the minor's trace-plus-remembering, or (b) the band lets a full reclaim several dead trees and strings at once without exceeding Node's 261 MB peak. Measure both.
  2. Untraced promotion with a pointer-free malloc registry. Admit the untraced path when every registered malloc object is pointer-free (strings, typed-array backings) and no malloc sweep is due. The lost old→malloc edges from promoted objects make minor-time malloc sweeps unsound afterwards, so malloc objects would be reclaimable only by fulls until one re-derives the edges — which again turns this into design 1's pacing question, with the registry still non-empty.
  3. Cheaper trace. Independently of scheduling, 47 ns per object for a mark pass over a JSON tree is high: per-slot classify_arena + remembered_child_needs_tracking + _tlv_get_addr, and three remembering passes that run even though in_place=true (they are only skipped when skip_remembering, which needs the empty registry). Splitting "remembering is impossible for nursery children" (always true on a promoting cycle) from "…for malloc children" would let the promoting cycle skip the nursery half of those passes even with a non-empty registry.

Reproduce

env PERRY_GC_DIAG=1 <worker> records_array_20m.json roundtrip 4 1 2> diag.txt
grep -E "ran pause_us|site=safepoint" diag.txt   # look at copy_evacuation=, untraced=, malloc=
env PERRY_GC_DIAG=1 <worker> records_array_20m.json parse 4 1 2> diag-parse.txt   # untraced=true on minor 2

Related: #10123 (large-object births and old-gen pacing), #10168 (parser-side pre-scan, landed the 10–13 % parse win this measurement sits on), #7888 (untraced promotion), #7742 (in-place promotion).

Activity

  1. proggeramlug commented on Sep 13, 2026

    @proggeramlug
    ContributorAuthor

    Design record for the fix (all numbers: same tree, interleaved best-of-3 unless noted, PERRY_GC_DIAG=1 for the cycle facts; host loaded, ±2 % is noise). The final shape is in the PR that closes this issue.

    variant rule records_array_20m:roundtrip records_array_20m:stringify records_array_1m:stringify why rejected
    blanket every result ≥ 512 KB born old in the arena CPU 0.66×, RSS 295→144 MiB CPU 5.7× CPU 1.23× the stringify-only input stays live in Eden, no minor ever runs, and every born-old result trips an old-reclaim full that re-marks the tree
    v1 arena iff young ≥ leaf same win same 5.7× regime (7 fulls / 7 iterations) – as above: the gate is true for the persistent input too
    v2 v1 ∧ young ≥ old_in_use roundtrip loses most of the win (370 vs 274 ms) fixed – dead-but-unswept born-old results inflate old_in_use and flip the route back to malloc mid-run; one tracked leaf then vetoes every untraced promotion
    v3 v1 + arm the precise safepoint after a born-old leaf win kept still 7 fulls – the safepoint's trigger order is OldReclaim first; the armed poll just runs the full
    v4 v1 ∧ previous minor measured young as retained (≥ 95 %) win lost (3 traced minors) fixed – the FIRST result is allocated before any minor has measured anything → malloc-tracked → that one stale leaf keeps skip_remembering false forever (malloc=1 at every trigger)
    v5 v1 ∧ ¬measured-dying, + one-time nursery-minor priority over old-reclaim while unmeasured CPU 0.555×, RSS 295→266 flat CPU, RSS 234→195 1.175× with a 3 MB young generation no minor is ever due, so every 1 MB result is born old: 229 MB of fresh arena pages instead of malloc's recycled ones (sys time doubles)
    v6 v5 ∧ young at the adaptive nursery cap win lost again ok fixed after the first fully-live minor the adaptive cap scales 20→47→80 MB, past the 58 MB tree; the second result goes malloc and vetoes the untraced path
    v7 (final) v5 ∧ young ≥ one base nursery cap CPU 0.552×, RSS 295→266 flat CPU, RSS 234→195 1.007× –

    The GC-diag facts that drove each step: the baseline's minors report copy_evacuation ≈ 55 ms with 0 objects copied followed by promotion=1.17M objects in 9 ms (in_place=true untraced=false), i.e. the cost is the trace, not the copy; the parse-only cell's second minor is untraced=true in 11 ms because its malloc registry is empty; the roundtrip's registry holds 1–3 objects at every trigger (malloc=1/2/3), the 20 MB results.

    gc-ratchet corpus (14 probes, 7 repeats, shared_ci profile): every gated counter (minor/step cycles, copied/promoted objects and bytes, heap used) is bit-identical between main and the fix; peak RSS within ±0.4 %; all correctness pass. The pinned-baseline check fails identically for the untouched main build (01_nursery_churn heap_used +107 %, 02_survivor_promotion copied +8 %, 04_dead_after_deep_stack copied −26 %), so that red predates this work.

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