Skip to content

perf(gc): defer the JSON materialiser's per-slot layout notes to one finalize (#7630) - #7633

Merged
proggeramlug merged 4 commits into
mainfrom
perf/7630-materialiser-layout-declare
Aug 8, 2026
Merged

perf(gc): defer the JSON materialiser's per-slot layout notes to one finalize (#7630)#7633
proggeramlug merged 4 commits into
mainfrom
perf/7630-materialiser-layout-declare

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #7630 — the top cost family from the step-zero profile that issue records.

The cost

Parse-built records are born POINTER_FREE; the first string field builds a per-object side-table pointer mask, and from then on every field store pays a hashmap round-trip (layout_note_slot), layout_transfer moves the mask on every promotion, and layout_forget_object drops it at death. At 200k records × ~13 slots that machinery topped the profile (~52 samples, ahead of the old-page family the in-flight deferral PR targets).

The observation that makes it deletable: a pointer mask can never skip anything for this cohort. Every slot the materialiser writes is a NaN-boxed JSValue, and the tracer's tag check rejects non-pointers anyway — the mask machinery only ever pays for itself by skipping raw-f64 slots, which parse objects do not have.

The fix

The materialiser owns each object's whole construction, so its store loops use runtime_store_jsvalue_slot_layout_deferred — bit-for-bit the shared choke-point helper minus the layout note (typed-slot canonicalization, string addref demote, and the write barrier with its SATB shade all kept — the #7602 lesson) — returning the one fact the notes were computing (pointer-bearing). The loop accumulates it and settles the layout state once per object via layout_finish_deferred_boxed_object:

  • no pointer stored → the POINTER_FREE birth state is still the truth, and it keeps its whole-payload trace skip (number-only records lose nothing);
  • any pointer storedGC_LAYOUT_UNKNOWN, the tag-checked scan-all state. Routed through layout_mark_unknown, not a bare state store, so a mask that the shaped path's by-name fallback DID create mid-construction (shape-overflow records) is removed rather than stranded.

The shaped path's finalize runs on the live pointer re-read from the parse root, so a mid-parse collection cannot leave it operating on a stale copy.

Also: barrier.rs crossed the 2000-line cap with the new helper; its slot-store helpers moved to barrier_store.rs (pure move).

Measured — pinned mini, interleaved ×3, hash-identical every round

json_pipeline 200k main this PR
build_out ~934 ms ~730 ms (−22 %)
TOTAL ~1,389 ms ~1,156 ms (−17 %)
GC pause (census) 816 ms 689 ms
peak RSS 451 MB 422 MB

Census structure identical both arms (3 cycles, 0.0 MB survivor-copied, 101 MB promoted — the moved-bytes census is load-independent). Post-fix profile: layout_note_slot, layout_forget_object, layout_only, fixed_slot are gone from the top; the remaining leaders are the old-page/promote family (the deferral PR's target) and remembered-set inserts.

Verification

  • GC ratchet, both arms back to back on one host: every semantic counter identical across 144 medians on all 12 probes; the only differing semantic cell is 12_large_live_set.heap_used_bytes at −504 bytes — the documented sample-dependent ungated cell, well inside its recorded 9,072-byte spread. All other movement is ungated RSS/wall ≤0.44 %.
  • GC zeal + fromspace-protect arm (PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1): output identical — no stranded children under forced evacuation.
  • 19/19 JSON test-files byte-identical to node; perry-runtime suite 1,886 passed / 0 failed; cargo fmt --check and check_file_size.sh clean.
  • Gap suite (full run on this branch, report parity_report_20260808_101857.json): no real regressions. The one reported regression, test_gap_zlib_4917_level pass → compile_fail, is the documented host-local runtime-dir resolution flake, disproven on this branch directly: with PERRY_RUNTIME_DIR pinned to the freshly built archives the test compiles and matches node byte-for-byte. The 10 node_fail → parity_fail status changes are oracle-coverage transitions (pre-existing gaps the pinned Node now reaches — identical set to the previous clean run on an unrelated branch), and test_gap_iterator_helpers_2874 improved to pass.

Summary by CodeRabbit

  • Performance Improvements
    • Improved JSON object materialization by deferring garbage-collection layout tracking until construction is complete.
    • Reduced processing overhead and improved benchmark performance during JSON parsing.
  • Bug Fixes
    • Preserved correct handling of typed values, strings, references, and write barriers.
    • Ensured objects receive accurate garbage-collection layout information after construction.
  • Chores
    • Updated the package version to 0.5.1361.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fc7df359-0f6c-4f37-8a58-9e705d493f96

📥 Commits

Reviewing files that changed from the base of the PR and between e0fc6b1 and 1865edb.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • CLAUDE.md
  • Cargo.toml
  • crates/perry-runtime/src/object/mod.rs

📝 Walkthrough

Walkthrough

The PR moves GC slot-store helpers into barrier_store, adds deferred layout finalization, and updates shaped and untyped JSON object construction to finalize layout after field writes.

Changes

JSON materializer layout optimization

Layer / File(s) Summary
GC store and barrier paths
crates/perry-runtime/src/gc/barrier_store.rs, crates/perry-runtime/src/gc/mod.rs
Slot stores and barrier wrappers move into barrier_store. Typed-slot canonicalization, string handling, layout tracking, and barrier dispatch remain supported.
Deferred layout finalization
crates/perry-runtime/src/gc/layout.rs, crates/perry-runtime/src/object/mod.rs
Deferred stores return pointer-bearing status. Finalization preserves POINTER_FREE objects or marks pointer-bearing objects as GC_LAYOUT_UNKNOWN.
JSON construction integration and release metadata
crates/perry-runtime/src/json/parser.rs, changelog.d/7633-json-materialiser-layout-deferred.md, Cargo.toml, CLAUDE.md
Shaped and untyped JSON construction accumulates pointer state and finalizes layout once. The changelog and package version document the change.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant JSONParser
  participant ObjectStorage
  participant BarrierStore
  participant GCLayout
  JSONParser->>ObjectStorage: Store a JSON object field
  ObjectStorage->>BarrierStore: Write value with deferred layout tracking
  BarrierStore-->>ObjectStorage: Return pointer-bearing status
  ObjectStorage-->>JSONParser: Accumulate pointer state
  JSONParser->>GCLayout: Finalize boxed-object layout
  GCLayout-->>JSONParser: Preserve POINTER_FREE or mark GC_LAYOUT_UNKNOWN
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6831 — Modifies the GC slot-store and write-barrier behavior used by this change.
  • PerryTS/perry#6919 — Shares the typed-slot canonicalization and layout-tracking mechanisms changed here.
  • PerryTS/perry#7536 — Shares deferred or conditional GC bookkeeping for JSValue slot stores.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main optimization: deferring JSON materialiser GC layout notes until finalization.
Description check ✅ Passed The description clearly covers the motivation, implementation, issue reference, performance results, and extensive verification.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/7630-materialiser-layout-declare

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

proggeramlug pushed a commit that referenced this pull request Aug 8, 2026
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit (soundness half) — draft respected, not merged. One finding you need before the gap suite lands.

The design reasoning is right and I checked the parts that are checkable. The
deferred store is materialiser-only — exactly two call sites
(json/parser.rs:437, :688) and two finalizes (:486, :701), same two
functions, and zero early-exit paths between store and finalize in either
(I walked the segments). So no parse path can leave an object with deferred
notes unsettled. Routing the pointer case through layout_mark_unknown rather
than a bare state store is the right call for the shape-overflow mask, and
re-reading js_obj from the parse root before finalize is the correct rooting
discipline.

Runtime suite 1,886/0; JSON gap family 7/7 byte-identical to node.

The finding: I cannot make the unsafe branch fault, and neither does your zeal arm

I sabotaged layout_finish_deferred_boxed_object(…, saw_pointer)
(…, false), i.e. every parse object claims pointer-free even when it holds
pointers
. That is precisely the stranded-live-child hazard #7630's own
soundness note names. Result:

probe plain ZEAL+PROTECT FORCE_EVACUATE
children also held in a separate array identical identical identical
children reachable ONLY via the record's slot identical identical identical

I built the second probe specifically because the first was vacuous (it held
the children directly, so they survived regardless). 4,000 records × 2 pointer
fields, 40 rounds of churn to force promotion, children read back only after
the churn. Sabotage still produces byte-identical correct output, 8 retired
quarantine sets and 16 copying minors observed live.

So one of these is true, and the PR should say which:

  1. Parse-built strings are longlived/interned and cannot be collected or moved
    — in which case the branch is safe but the POINTER_FREE trace-skip is
    also buying nothing on this cohort
    , and the win is entirely the removed
    per-store notes (which is fine, and is what your profile actually shows).
  2. Something else re-marks these objects during construction, so the
    saw_pointer=false branch is not reached in practice for pointer-bearing
    records — in which case the accumulate-and-settle logic has a dead arm.
  3. The hazard is real but needs a shape my probe doesn't produce.

This matters for the PR's evidence, not its correctness: your "GC zeal +
fromspace-protect arm: output identical — no stranded children" line reads as
verification, but a mutation that should strand children also produces
identical output, so that arm is not currently discriminating. Same vacuity
class as the ones caught in #7602's S4 and #7625's fixtures. Either find a
probe where the sabotage faults — that becomes the regression test worth
having — or state plainly that the branch's safety rests on the
materialiser-owns-construction argument rather than on an executed test.

I have no objection to the change itself; I'd merge it on the argument alone
once the gap suite lands. I just won't let the zeal line stand as proof of
something it doesn't discriminate.

Two smaller notes

Ralph Küpper added 4 commits August 8, 2026 12:48
…finalize (#7630)

Step-zero profiling on the pinned mini put the per-slot layout machinery
at the top of json_pipeline's cost families (~52 samples: layout_note_slot,
descriptor visits, layout_transfer, layout_forget_object). The payer is
the parse cohort: records are born POINTER_FREE, the first string field
builds a per-object side-table pointer mask, and every subsequent field
store pays a hashmap round-trip -- then layout_transfer moves the mask on
promotion and layout_forget_object drops it at death.

The materialiser owns each object's whole construction, so its store
loops now use runtime_store_jsvalue_slot_layout_deferred -- bit-for-bit
the shared helper minus the layout note (canonicalization, string addref
demote, and the write barrier with its SATB shade all kept) -- returning
the one fact the notes were computing (pointer-bearing), which the loop
accumulates and settles ONCE via layout_finish_deferred_boxed_object:

- no pointer stored: the POINTER_FREE birth state is still the truth and
  keeps its whole-payload trace skip (number-only records);
- any pointer stored: GC_LAYOUT_UNKNOWN, the tag-checked scan-all state.
  A pointer mask can never skip anything for a cohort whose every slot is
  a NaN-boxed JSValue, so the mask machinery bought nothing here. Routed
  through layout_mark_unknown so a mask created by the shaped path's
  by-name fallback mid-construction is removed, not stranded.

The shaped path's finalize runs on the live pointer re-read from the
parse root, so a mid-parse collection cannot leave it on a stale copy.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merged as v0.5.1361 — on the argument, with the evidence question split out

Gap suite result accepted: the one reported regression is the known host-local
zlib runtime-dir flake (that's #6847, which I reopened yesterday for macOS after
#7599's audit caught it reproducing 3/3), disproven on-branch with a pinned
PERRY_RUNTIME_DIR. The 10 node_fail → parity_fail transitions are
oracle-coverage, and test_gap_iterator_helpers_2874 improving to pass is
#7583's work showing up.

Re-verified here: runtime 1,902/0, cargo check --all-targets clean, JSON gap
family 7/7 byte-identical to node, all six lint scripts + file-size + fmt clean.

Why I merged despite my open finding: the change is sound by argument
the materialiser owns each object end-to-end, finalize is reached on every path
(zero early exits between store and finalize in both parse functions, which I
walked), and the pointer case is conservative (GC_LAYOUT_UNKNOWN via
layout_mark_unknown, not a bare state store). Plus it is strictly more
conservative than what it replaces whenever saw_pointer is true. That is
enough.

But I chased the vacuity question further and the benign explanation is
refuted
, so it is now filed as #7635 rather than left in this thread:

Parse value strings are not longlived or interned — string_storage_alloc
(string/mod.rs:504) uses arena_alloc_gc, the ordinary nursery;
arena_alloc_gc_longlived is a different function. My probe's strings are 6–10
chars, above SHORT_STRING_MAX_LEN = 5, so they are real nursery
StringHeaders — collectable and movable. A record wrongly marked
POINTER_FREE should lose them, and it doesn't, under any instrument, with 8
retired quarantine sets and 16 copying minors observed live.

So the line in this PR's body — "GC zeal + fromspace-protect arm: output
identical — no stranded children under forced evacuation"
— should not be
carried forward into the next PR in this family as evidence. It passes a
mutation that is guaranteed to strand children. #7635 asks for a probe that
faults, and records that "we can't build one" would itself be the finding: it
would mean the POINTER_FREE trace-skip is unobservable, which is worth knowing
before more optimizations are stacked on it.

One data point for #7630 while you are in here: my census on current main reads
113,227,216 bytes / 1,657,966 objects promoted at 200k — measured three
times independently now, most recently during #7624's audit. #7630 records
101 MB and cites "v0.5.1360-era" when main was v0.5.1357. Worth confirming
that step-zero profile was taken on main, because its attribution ordering put
the layout family ahead of the old-page family — and #7624 has since removed the
old-page family entirely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(gc): per-slot layout bookkeeping is the top cost family on json_pipeline — declare-at-birth for the JSON materialiser's cohort

1 participant