Skip to content

Unify lazy JSON arrays into GC_TYPE_ARRAY: laziness as element state, not an object type #10098

Description

@proggeramlug

The problem

Laziness is encoded as an object type. A JSON.parse result is GC_TYPE_LAZY_ARRAY (9) with a LazyArrayHeader, not GC_TYPE_ARRAY (1) with an ArrayHeader. 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 gate
  • if obj_type != GC_TYPE_LAZY_ARRAY { ... } — a fast path that declines lazy receivers

The failure mode is forgetting one, and it is silent until it is loud:

  • JSON.stringify's tag dispatch omits GC_TYPE_LAZY_ARRAY, so the unknown-object fallback reinterprets a LazyArrayHeader as a StringHeader and reads LAZY_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.
  • The codegen indexed inline cache never knew about the tag at all, so every parsed[i] fell through four layers of re-classification — ~237 retired instructions for rows[7].id. Fixed symptomatically by adding two more tiers to the cache (branch perf/json-lazy-array-index-ic), i.e. by making the decision tree bigger.
  • Object.defineProperty on 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_length sits at offset 0 so codegen's inline .length load works on it, and magic sits at offset 4 so clean_arr_ptr's length > capacity check passes. It is impersonating an ArrayHeader for two fields. The right design doesn't impersonate — it is one.

Proposal: laziness as a state of element storage

today:  LazyArrayHeader { cached_length, magic, root_idx, tape_len, tape,
                          blob_str, materialized, materialized_elements,
                          materialized_bitmap, walk_idx, walk_tape_pos,
                          cumulative_walk_steps, sequential_streak }
        + a separately allocated element cache + bitmap + maybe an ordinary array

after:  ArrayHeader { length, capacity } [slot0 slot1 ... slotN]   ← GC_TYPE_ARRAY
        unmaterialized slots hold TAG_UNMATERIALIZED
        tape/blob/walk state in ObjectMeta (tape is not a GC edge, as today)

What collapses:

  • The sparse cache disappears. materialized_elements, materialized_bitmap and 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.
  • materialized disappears, and with it the three-state header machine and resolve_materialized_array.
  • cached_length stops being a mirror. .length is the real length, so the staleness problem — and the freshness guard the new IC tier needs — goes away.
  • The ordinary-array IC tier serves every materialized read with no new code. It already sends a non-value slot to the miss helper; the miss helper materializes one element from the tape and stores it into the slot. Both tiers on perf/json-lazy-array-index-ic get 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_UNMATERIALIZED in the 0x7FFC singleton namespace, not TAG_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 to undefined, because silently coercing is how you lose the bug.

Reusing TAG_HOLE would mean zero IC changes, but a leaked sentinel would then be indistinguishable from a genuine hole — it would read as undefined, be skipped by Object.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 are 0x7FFC_... singletons, so (bits & ~0x18) == TAG_HOLE-style folding covers both in one branch).

Decision 2: eager slot allocation

Allocate length × 8 at parse. Sizing is already known from the tape's ARR_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):

  1. json_tape.rs — alloc_lazy_array, install_materialized, force_materialize_lazy, the header struct, json_tape/layout.rs's offset pins
  2. json_tape/cached_read.rs — lazy_get becomes the miss helper: materialize one element, store into the slot
  3. json_tape/mutation.rs — resolve_materialized_array deleted
  4. gc/layout_slot_visit.rs, gc/types.rs, gc/verify.rs, gc/verify_diag.rs — GcRewriteDescriptorKind::LazyArray becomes ordinary array element tracing plus one side-buffer edge
  5. gc/oldgen.rs — the remembered_set/lazy_array promotion path
  6. array/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 unconditional
  7. json/stringify_api.rs, json/reviver.rs — including the missing-arm SIGSEGV
  8. perry-codegen expr/index_get.rs, expr/index_get/inline_dyn_typed_array.rs, expr/array_methods.rs — delete both tiers from perf/json-lazy-array-index-ic and the != GC_TYPE_ARRAY exclusions

Sweep (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-stdlib worker_threads.rs / fetch_blob.rs, and the rest of the 60-file list.

Deletable once done: LAZY_ARRAY_MAGIC and every defensive magic check, the cached_length-at-offset-0 contract, the magic-as-fake-capacity contract, json_tape/layout.rs.

Validation this needs (and why it can't ride along with a perf PR)

  • A sentinel-leak gate: assert TAG_UNMATERIALIZED is never observable from TS across the whole gap suite, not just the JSON fixtures.
  • Every materialize gate re-proved: forEach, spread, Object.keys, for..in, JSON.stringify, structured clone, worker/thread serialization, heap snapshot.
  • GC: the rewrite descriptor change wants PERRY_GC_SCHEDULE_SEED + PERRY_GC_PROTECT_FROMSPACE across the JSON fixtures, plus gc_root_dominance and the runtime-root-holder inventory, since a side buffer holding a heap pointer is exactly the invisible-cache class in CLAUDE.md.
  • The full 50-row matrix plus the access rows — this should be RSS-positive on the scan rows, and that claim needs measuring, not assuming.

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 (defineProperty on a lazy index — subsumed by this), #793.

Activity

  1. proggeramlug commented on Sep 12, 2026

    @proggeramlug
    ContributorAuthor

    Investigated: the RSS payoff is real, and the blocker is narrower and harder than this issue assumed

    Measured on records_array_16k:scan with PERRY_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

    LazyArrayHeader uses arena_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.rs says why:

    • json_tape_store keys a tape by its owner's address. A header that moves silently orphans its tape registration.
    • gc_type_is_movable(GC_TYPE_LAZY_ARRAY) is false — 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_array reads blob_bytes off a raw header and then allocates the result string; when the header became nursery-resident the minor relocated it out from under that read and JSON.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:

    1. Re-key json_tape_store off the owner address — a stable owner id, or an owner slot the collector rewrites. Until this lands nothing about the header can move.
    2. Make GC_TYPE_LAZY_ARRAY movable and flip the allocation to size-decided, keeping header/cache/bitmap in one generation.
    3. Root the raw-header holders across allocations — 20 sites across 4 files, and json_tape.rs already uses rooted handles in 20 places, so this is bounded. try_stringify_lazy_array is the known one; the audit needs --trace llvm plus PERRY_GC_PROTECT_FROMSPACE + PERRY_GC_SCHEDULE_SEED over 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 as TypeError: value is not a function.
    4. Only then the TAG_UNMATERIALIZED storage 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_immovable and the two callback_scanners handle tests assert immovability directly. Their replacement assertion is that the rooted handle resolves to wherever the collector left the header, and that alloc_lazy_array returns the refreshed address rather than the stale one — a stronger property than "it never moves", and the one callers actually need.

  2. proggeramlug commented on Sep 13, 2026

    @proggeramlug
    ContributorAuthor

    Closed by #10136 ("Closes #10098"), landed via merge train #10188 (main 5cec2fbbc9).

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

    enhancementNew capability or improvement

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions