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_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.
- 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 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).
Symptom
benchmarks/json_performancerecords_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):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.sampleon 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), andskip_rememberingrequiresmalloc_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/3on the diag'ssite=safepointlines;malloc=0on 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_usereached 82 MB, arena 146 MB, peak RSS 283 MB vs Node/Bun 261 MB).What V8 does on the same input
V8 pretenures
JSON.parseresults 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)
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.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
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).