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

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