Repository navigation
perf(object): JSON.parse receivers have class_id == 0, so every write to parsed data misses both object-write fast paths (55x, identical checksum) #8098
Description
Summary
An object produced by JSON.parse carries class_id == 0. That single fact
disqualifies it from both guarded object-write fast paths — the whole-loop
numeric clone and the static write PIC — so every property write to a parsed
object takes the generic [[Set]] runtime path for the life of the program.
Measured as a controlled pair inside the committed #6812 matrix: 9,560 ms vs
173 ms, a 55x intra-matrix difference, for an identical write count and an
identical checksum.
This is the largest remaining row in docs/object-write-matrix.md and the only
one that has not improved since the matrix was first taken (57.8x on
2026-07-23 → 78.0x now).
Evidence
benchmarks/object-write-6812/matrix.ts already contains the controlled pair.
receiverClassIdZero (:501-515) and keyDot differ in exactly one way — the
2400 receivers come from JSON.parse('{"x":0}') instead of an object literal —
and both perform 120,000,000 writes and produce sink 122876400.
Built from origin/main @ 514ff7c9a (v0.5.1510), compiler plus both static
archives from a clean worktree, PERRY_RUNTIME_DIR pinned, default
auto-optimizing pipeline:
$ PERRY_TRACE_OBJECT_ARRAY_WRITE_GUARD=1 ./bin_matrix receiver_class_id_zero
PERRY_OBJECT_ARRAY_WRITE_GUARD_REJECT: first receiver is not an eligible regular shared-shape object
cell receiver_class_id_zero ms 9560 writes 120000000 sink 122876400
$ PERRY_TRACE_OBJECT_ARRAY_WRITE_GUARD=1 ./bin_matrix key_dot
cell key_dot ms 173 writes 120000000 sink 122876400
Against Node 26.5.1 on the same host, run_matrix.py --samples 3 (the runner
verifies (writes, sink) on every pair and aborts on mismatch):
| cell | node | perry | P/node |
|---|---|---|---|
receiver_class_id_zero |
163 ms | 12,709 ms | 77.97 |
key_dot |
1,054 ms | 742 ms | 0.70 |
receiver_class_id_zero is the worst of 26 cells by 4.3x; the next worst is
rhs_call at 18.3x. Full table:
#6812 (comment)
Where the two rejections are
- Static write PIC —
crates/perry-runtime/src/proxy/put_value.rs:337-341
requiresobject_type == OBJECT_TYPE_REGULARandclass_id != 0before
it will prime a way. A parsed object never primes, so the site permanently
misses. - Whole-loop numeric clone — the preflight's per-element validation
(object_array_numeric_write_slots,proxy/put_value.rs:728) requires every
element to be a regular object withclass_id != 0and one shared
GC_FLAG_SHAPE_SHAREDkeys array. Hence the trace line above.
Why this matters beyond a micro
JSON.parse is how essentially all external data enters a Perry program — HTTP
response bodies, config files, ORM rows, cache reads, IPC payloads. Any code
that parses records and then mutates fields on them is on the generic path
today, and no amount of loop-shape or key-form work in #6812 reaches it,
because the rejection is on the receiver's identity, not the write's shape.
Sequencing — do not race #8067
The fix is to give parsed objects a real shape identity rather than to relax the
class_id != 0 guards, which exist precisely because a class-id-zero object has
no shape token the PIC can compare. #8067 ("make ShapeId authoritative for keys,
live slots, and class-object kind") is doing exactly the work that would supply
one, and #8047 then changes the header ABI. This should land after #8067,
reusing its ShapeId, not by adding a parallel identity scheme.
Note the interaction with lazy parsing: js_json_parse is lazy for 1 KB–16 MB
top-level arrays (json_tape), so any benchmark or test here must either defeat
the tape or say that it did not — see the GC_LAYOUT_POINTER_FREE doc in
crates/perry-runtime/src/gc/layout.rs for the precedent where a lazy tape made
a probe vacuous (#7635).
Acceptance
receiver_class_id_zeroreaches at least parity with thekey_dot/
receiver_anonymousrows (both currently ~0.5–0.7x node) under
benchmarks/object-write-6812/run_matrix.pywith 15 samples on an idle host.- The trace no longer prints
first receiver is not an eligible regular shared-shape objectfor that cell. - No regression on the 14 cells that currently beat node, and identical
(writes, sink)on all 26. - Parity coverage for parsed objects that then get deleted keys, added keys,
frozen/sealed, descriptors installed, or prototypes mutated — the cases the
class_id != 0guard was standing in for. - A tape-defeating (or explicitly tape-acknowledging) fixture, per gc: forcing POINTER_FREE on a pointer-bearing object strands nothing — our zeal/protect instruments do not discriminate the layout-state hazard #7635.
Split out of #6812, which delivered its coverage matrix and twelve slices; this
is the largest of its remaining rows and the only one with no improvement since
the baseline.
Activity
perf(object/json): admit JSON.parse receivers to the object-write fas…
perf(object/json): admit JSON.parse receivers to the object-write fas…