Repository navigation
Unify lazy JSON arrays into GC_TYPE_ARRAY: laziness as element state, not an object type #10098
Description
Activity
- addedenhancementNew capability or improvementNew capability or improvement
on Sep 12, 2026 Investigated: the RSS payoff is real, and the blocker is narrower and harder than this issue assumed
Measured on
records_array_16k:scanwithPERRY_GC_DIAG=1, against current main:survival_permille=996 copied_objects=0 freed_bytes=0 promoted_objects=232046 promoted_bytes=11370856 old_in_use: 506K -> 12.4M -> 24.9M -> 48.4M (monotonic)Every minor promotes essentially the whole nursery, copies nothing, frees nothing. Old-gen climbs without bound while the arena rebaselines its own trigger upward (134M→268M→536M→1073M), so the full collection that would reclaim it never arrives. That is the 205 MiB against Node's 62 MiB, and it confirms R17's diagnosis precisely: a dead lazy cluster holds its entire element graph live through the remembered set.
The single allocator call behind it
LazyArrayHeaderusesarena_alloc_gc_old_born_tenured. I initially read that as vestigial — the allocator's own doc says the header only reached the large-object arm because its tape used to be inline, and #7539 shrank it to ~88 bytes — so I tried the targeted fix: decide the cluster's generation by cache size, and decide it once for header + cache + bitmap so #7546's one-generation invariant still holds. Small clusters become nursery-resident and a minor reclaims the lot.That builds and most of the JSON suite passes. It is not safe, and
gc/tests/lazy_tape_side_alloc.rssays why:json_tape_storekeys a tape by its owner's address. A header that moves silently orphans its tape registration.gc_type_is_movable(GC_TYPE_LAZY_ARRAY)isfalse— yet a nursery-allocated header does get relocated by the copying minor. Allocation site and movability policy disagree, which is worse than either choice alone.- There is a recorded reproducer:
try_stringify_lazy_arrayreadsblob_bytesoff a raw header and then allocates the result string; when the header became nursery-resident the minor relocated it out from under that read andJSON.stringify(parsed)emitted a string of NUL bytes on 3 of 60 iterations.
So the pinning is load-bearing, not leftover. I reverted the change rather than ship a latent non-determinism.
What the unification actually has to do first
The element-storage redesign in the issue body is still the right end state, but it cannot start from the storage layout. The ordering is:
- Re-key
json_tape_storeoff the owner address — a stable owner id, or an owner slot the collector rewrites. Until this lands nothing about the header can move. - Make
GC_TYPE_LAZY_ARRAYmovable and flip the allocation to size-decided, keeping header/cache/bitmap in one generation. - Root the raw-header holders across allocations — 20 sites across 4 files, and
json_tape.rsalready uses rooted handles in 20 places, so this is bounded.try_stringify_lazy_arrayis the known one; the audit needs--trace llvmplusPERRY_GC_PROTECT_FROMSPACE+PERRY_GC_SCHEDULE_SEEDover the JSON fixtures, since a stale header deref is exactly the GC: evacuating minor drops an old-to-young field[1] edge, crashing with 'value is not a function' #7154 class that surfaces cycles later asTypeError: value is not a function. - Only then the
TAG_UNMATERIALIZEDstorage change.
Step 1 is the real gate and deserves its own issue; steps 2–3 are where the RSS win is actually collected, and they may be enough on their own without step 4.
Two tests encode the current invariant and will need rewriting, not deleting
test_lazy_header_is_small_but_stays_old_gen_and_immovableand the twocallback_scannershandle tests assert immovability directly. Their replacement assertion is that the rooted handle resolves to wherever the collector left the header, and thatalloc_lazy_arrayreturns the refreshed address rather than the stale one — a stronger property than "it never moves", and the one callers actually need.- added a commit that references this issue
on Sep 13, 2026
The problem
Laziness is encoded as an object type. A
JSON.parseresult isGC_TYPE_LAZY_ARRAY(9) with aLazyArrayHeader, notGC_TYPE_ARRAY(1) with anArrayHeader. So every consumer of arrays has to know that a second array-shaped thing exists, and each one bolts on its own branch.86 sites across 60 files currently key on
GC_TYPE_LAZY_ARRAY. They fall into three shapes:obj_type == GC_TYPE_ARRAY || obj_type == GC_TYPE_LAZY_ARRAY— "treat both as an array" (the majority)if obj_type == GC_TYPE_LAZY_ARRAY { force_materialize_lazy(...) }— the materialize gateif obj_type != GC_TYPE_LAZY_ARRAY { ... }— a fast path that declines lazy receiversThe failure mode is forgetting one, and it is silent until it is loud:
JSON.stringify's tag dispatch omitsGC_TYPE_LAZY_ARRAY, so the unknown-object fallback reinterprets aLazyArrayHeaderas aStringHeaderand readsLAZY_ARRAY_MAGIC(0x4C5A5841) as a string length → 14 of 18 diagnostic cases SIGSEGV. The generic array serializer materializes correctly; only the dispatch is missing an arm.parsed[i]fell through four layers of re-classification — ~237 retired instructions forrows[7].id. Fixed symptomatically by adding two more tiers to the cache (branchperf/json-lazy-array-index-ic), i.e. by making the decision tree bigger.Object.definePropertyon an index of a lazy array is installed and then ignored by reads (JSON.parse lazy array: Object.defineProperty index accessor is bypassed by reads #10097).The header is already half-admitting the design is wrong.
cached_lengthsits at offset 0 so codegen's inline.lengthload works on it, andmagicsits at offset 4 soclean_arr_ptr'slength > capacitycheck passes. It is impersonating anArrayHeaderfor two fields. The right design doesn't impersonate — it is one.Proposal: laziness as a state of element storage
What collapses:
materialized_elements,materialized_bitmapand the bitmap-is-authoritative rule become "the element slot holds the object now." The identity invariant (parsed[i] === parsed[i]) is then a property of the storage rather than a cache that has to be kept coherent.materializeddisappears, and with it the three-state header machine andresolve_materialized_array.cached_lengthstops being a mirror..lengthis the real length, so the staleness problem — and the freshness guard the new IC tier needs — goes away.perf/json-lazy-array-index-icget deleted.Array.isArray,typeof, species,instanceof, GC element tracing, heap snapshots — all the "treat both as an array" sites become unconditional.Memory is neutral-to-better: the sparse cache already allocates 8 B/element plus a bitmap on demand, and the pre-allocated slots replace both. For the 20 MiB fixture that is ~11 MB of slots against an 18 MB input, less than the tape.
Second-order win. The R17 memory diagnosis traced the 505 MiB (16 KiB scan) and 300 MiB (1 MiB scan) RSS blowups to "lazy headers stay old and immovable, and their materialized/cache edges can keep young graphs alive until full collection" — the first copying minor attributes ~12.1 MB of promoted objects to
remembered_set/lazy_array. A unified lazy array is an ordinary young array with a malloc'd side buffer; that pinning stops existing by construction rather than being tuned.Decision 1: the sentinel
Use a distinct
TAG_UNMATERIALIZEDin the 0x7FFC singleton namespace, notTAG_HOLE.The namespace has room and the precedent is exact —
TAG_HOLE(0x7FFC_0000_0000_0010) and the TDZ sentinel are both "a value that lives in slots and is translated at read boundaries, and must never be observable." TDZ is the closer model: it deliberately does not coerce toundefined, because silently coercing is how you lose the bug.Reusing
TAG_HOLEwould mean zero IC changes, but a leaked sentinel would then be indistinguishable from a genuine hole — it would read asundefined, be skipped byObject.keys/in/forEach, and produce a silently short array. A distinct tag makes the same leak a loud, greppable wrong value, and costs one masked compare in the IC's existing hole test (both are0x7FFC_...singletons, so(bits & ~0x18) == TAG_HOLE-style folding covers both in one branch).Decision 2: eager slot allocation
Allocate
length × 8at parse. Sizing is already known from the tape'sARR_START. This is what makes the array a real array from the first instant, so nothing downstream needs a "not yet sized" state.Touch list
Must change (the gate becomes a flag, not a type):
json_tape.rs—alloc_lazy_array,install_materialized,force_materialize_lazy, the header struct,json_tape/layout.rs's offset pinsjson_tape/cached_read.rs—lazy_getbecomes the miss helper: materialize one element, store into the slotjson_tape/mutation.rs—resolve_materialized_arraydeletedgc/layout_slot_visit.rs,gc/types.rs,gc/verify.rs,gc/verify_diag.rs—GcRewriteDescriptorKind::LazyArraybecomes ordinary array element tracing plus one side-buffer edgegc/oldgen.rs— theremembered_set/lazy_arraypromotion patharray/header.rs,array/alloc.rs,array/indexing.rs,array/is_array.rs,array/iterator.rs,array/species.rs,array/generic.rs,array/flat_clone.rs,array/concat_reverse.rs,array/subclass_packed_index.rs,array/iter_object.rs— the dual-tag tests become unconditionaljson/stringify_api.rs,json/reviver.rs— including the missing-arm SIGSEGVperry-codegenexpr/index_get.rs,expr/index_get/inline_dyn_typed_array.rs,expr/array_methods.rs— delete both tiers fromperf/json-lazy-array-index-icand the!= GC_TYPE_ARRAYexclusionsSweep (the ~50 remaining "treat both as an array" sites):
typed_feedback.rs,value/dyn_index.rs,object/shapes.rs,object/polymorphic_index.rs,object/prototype_chain.rs,object/from_entries.rs,builtins/formatting.rs,node_stream_readwrite.rs,child_process/v8_serde.rs,gc/heap_snapshot.rs,proxy.rs,thread.rs,dyn_eval/bridge.rs,perry-stdlibworker_threads.rs/fetch_blob.rs, and the rest of the 60-file list.Deletable once done:
LAZY_ARRAY_MAGICand every defensive magic check, thecached_length-at-offset-0 contract, themagic-as-fake-capacity contract,json_tape/layout.rs.Validation this needs (and why it can't ride along with a perf PR)
TAG_UNMATERIALIZEDis never observable from TS across the whole gap suite, not just the JSON fixtures.forEach, spread,Object.keys,for..in,JSON.stringify, structured clone, worker/thread serialization, heap snapshot.PERRY_GC_SCHEDULE_SEED+PERRY_GC_PROTECT_FROMSPACEacross the JSON fixtures, plusgc_root_dominanceand the runtime-root-holder inventory, since a side buffer holding a heap pointer is exactly the invisible-cache class in CLAUDE.md.Order
This lands after the indexed-cache tiers (
perf/json-lazy-array-index-ic), and it should be a subtraction: the diff deletes both tiers, the three-state machine,resolve_materialized_array, the offset pins, and the class of bug where a new consumer forgets an arm. If it doesn't come out net-negative in lines, the design didn't land.Refs #10097 (
definePropertyon a lazy index — subsumed by this), #793.