Repository navigation
JSON roundtrip at scale: every minor traces the whole live tree because the large stringify result is malloc-tracked (untraced promotion vetoed) #10169
Description
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)
- Born-old large JSON results. Route
json_output_storage_allocabove the threshold to the arena large-object path (born old) instead ofgc_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. - 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.
- 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 thoughin_place=true(they are only skipped whenskip_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
proggeramlug commentedon Sep 13, 2026
Design record for the fix (all numbers: same tree, interleaved best-of-3 unless noted,
PERRY_GC_DIAG=1for the cycle facts; host loaded, ±2 % is noise). The final shape is in the PR that closes this issue.records_array_20m:roundtriprecords_array_20m:stringifyrecords_array_1m:stringifyskip_rememberingfalse forever (malloc=1at every trigger)The GC-diag facts that drove each step: the baseline's minors report
copy_evacuation ≈ 55 mswith 0 objects copied followed bypromotion=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 isuntraced=truein 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_ciprofile): every gated counter (minor/step cycles, copied/promoted objects and bytes, heap used) is bit-identical betweenmainand the fix; peak RSS within ±0.4 %; all correctness pass. The pinned-baselinecheckfails identically for the untouchedmainbuild (01_nursery_churnheap_used +107 %,02_survivor_promotioncopied +8 %,04_dead_after_deep_stackcopied −26 %), so that red predates this work.perf(gc): make the "nothing due" GC check cheap on safepoint polls an…