Skip to content

gc: the JSON tape's large per-parse block interacts badly with the generational collector (3.2x RSS, 25x variance vs mark-sweep) #7539

Description

@proggeramlug

Split out of #7478's decomposition (see #7537, merged). This is now the dominant term in field_access, and it is not the tape's intrinsic cost.

The measurement

The public baseline's idiomatic row flips two switches at once (PERRY_JSON_TAPE=0 and PERRY_GEN_GC=0), so "tape on, mark-sweep" had never been measured. Decomposing all four combinations on the pinned quiet host (M1 mini, 11 runs, taskpolicy -t 0 -l 0) at origin/main @ fc5e56e:

variant tape + genGC tape + markSweep
parse+scan 2729 ms · σ 214.9 · 208 MB 2957 ms · σ 8.8 · 66 MB
field_access 2942 ms · σ 101.8 · 222 MB 3247 ms · σ 7.7 · 67 MB

Identical tape, different collector. σ collapses from 214.9 to 8.8 and RSS from 208 MB to 66 MB — a 3.2× memory difference and a 25× variance difference, with the tape doing exactly the same work.

Neither factor alone explains it: tape-off under gen-GC is σ 17.6. It is specifically the interaction.

Mechanism

Each parse allocates header + tape as one ~2.4 MB block (200,002 TapeEntrys for this fixture). That rounds up to a dedicated oversized arena block and is retained; the resulting old-gen growth fires full collections at data-dependent points, which is both the variance and the retained footprint.

Why it matters more after #7537

#7537 took field_access from 2981 ms to 2016 ms by flipping scans to the batch parser early, and parse+scan from 2729 to 1339. With the element-wise materializer no longer the bottleneck, this interaction is what is left: full - scan is 707 ms under gen-GC but only 309 ms under mark-sweep for the identical tree.

The strongest single data point: tape + markSweep field_access after #7537 is 1759 ms at σ 2.1 and 76 MB — within 282 ms of the idiomatic floor and completely stable. The same build under gen-GC is 2016 ms at σ 219 and 193 MB. The tape is not the problem any more; the collector's handling of its large per-parse block is.

Suggested direction

Treat a large, immediately-tenured, pointer-free block as a special case rather than as ordinary old-gen growth — it never needs scanning (a tape is TapeEntrys, not JSValues) and never needs copying. Options worth measuring: allocate it outside the arena entirely; mark the block pointer-free so full collections skip it; or exempt oversized single-allocation blocks from the trigger arithmetic that is currently firing fulls.

Acceptance: field_access under the default (gen-GC) configuration within noise of the tape + markSweep number — i.e. ~1760 ms at low σ — without regressing roundtrip (201 ms, the memcpy path).

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