Skip to content

perf(object/json): admit JSON.parse receivers to the object-write fast paths (#8098) - #8118

Merged
proggeramlug merged 1 commit into
mainfrom
perf/8098-json-parse-write-fast-paths
Aug 15, 2026
Merged

perf(object/json): admit JSON.parse receivers to the object-write fast paths (#8098)#8118
proggeramlug merged 1 commit into
mainfrom
perf/8098-json-parse-write-fast-paths

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Closes #8098.

What was wrong

A JSON.parse object carries class_id == 0. Both guarded object-write fast
paths rejected it on exactly that:

  • the whole-loop numeric clone preflight — object_array_numeric_write_slots
    (proxy/put_value.rs), which is what printed
    PERRY_OBJECT_ARRAY_WRITE_GUARD_REJECT: first receiver is not an eligible regular shared-shape object;
  • the static write PIC prime, the dynamic-key IC prime, and dyn_ic_try_store
    (which also serves the outlined ways 5-8), plus the generated hit paths in
    perry-codegen/src/expr/proxy_reflect.rs, which ANDed class_id != 0 into
    every one of the four inline ways.

So every record.field = … on parsed data took the generic [[Set]] walk for
the life of the program. JSON.parse is how essentially all external data
enters a Perry program, and the rejection was on the receiver's identity, so
no amount of loop-shape or key-form work in #6812 could reach it.

Measurements

Wall clock on the development host is unusable — the same cell measured
11,729 ms, 17,884 ms and 21,098 ms on the same binary. Everything below is
instructions retired (/usr/bin/time -l), which reproduced to within 0.02%.
Both arms are full cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static builds snapshotted with sha256, with PERRY_RUNTIME_DIR
pinned to the snapshot; the compiler, both archives and the emitted benchmark
binary all cmp as different between arms.

The committed #6812 controlled pair (benchmarks/object-write-6812/matrix.ts),
identical 120,000,000 writes and identical sink 122876400:

cell before after
receiver_class_id_zero 150,082,612,917 1,169,725,264 128.3x fewer
key_dot (reference) 1,158,400,311 1,159,068,858 unchanged

After the fix receiver_class_id_zero is 1.009x of key_dot — parity with
the object-literal cell, which is the issue's acceptance bar. The trace line is
gone.

The shape real code actually has is not the clone-matched nest but scattered
.field = writes through a helper, which take the PIC. A fixture doing
24,000,000 such writes over 2000 parsed records (sink 48001997000 on every
arm, node included):

instructions ms
before 30,372,711,862 2291
after 2,508,280,501 165
node 26.5.1 33

12.1x fewer instructions; vs node this goes from 66x to ~5x.

No regression on the other 25 cells

Identical (writes, sink) on all 26. Four cells moved in wall clock by more
than a couple of percent, so they were re-measured in instructions:

cell before after delta
key_alternating_dynamic (dyn-key IC) 17,437,945,198 17,433,977,862 -0.02%
storage_inline 1,159,195,564 1,158,194,473 -0.04%
rhs_allocating 23,102,831,296 23,103,722,336 +0.005%
rhs_pointer (write PIC, 96M writes) 22,067,629,549 22,260,584,119 +0.87%

rhs_pointer's +0.87% is the honest cost and it reconciles exactly: the emitted
guard gains 2 instructions per write, 96e6 x 2 = 1.92e8 = 0.87% of 2.2e10. Cells
that take the whole-loop clone pay nothing — the clone has no per-write guard.

Why not the two obvious fixes

Not a synthetic class_id for parsed objects (the __AnonShape_<hash> +
js_register_anon_shape_class_id mechanism object literals use). class_id != 0
means "class instance" at roughly thirty runtime sites — console.log formatting,
instanceof, getPrototypeOf, json/stringify.rs's fast template which
explicitly requires class_id == 0, url/search_params, symbol/properties
and only about a third of them carry the anon-shape escape. That is the whole
introspection surface of parsed data, plus a JSON-stringify regression.

Not dropping the clause. It was standing in for three per-object exclusions
the generic path still applies verbatim
(field_set_by_name/fast_paths.rs::try_existing_own_data_overwrite):
NATIVE_MODULE_CLASS_ID, Object.prototype, and a URL instance — whose
pathname/search/… own slots are live views whose setters rebuild href
(field_set_by_name/tail.rs). None is derivable from the ShapeId: two objects
share a ShapeId iff they share a keys-array allocation, and the
shape-transition cache deliberately converges distinct objects onto one shared
array, so a prime-time-only exclusion loses to ordering — a plain object primes
the site at t0, a URL that acquires the same keys array at t1 > t0 then hits
it. The generated hit path re-checks only per-object state, so the
discriminator has to be per-object.

What landed

An explicit, opt-in, per-object mark: OBJ_FLAG_PLAIN_ORDINARY, bit 9 of
GcHeader::_reserved, object-only and disjoint from the array-only
GC_ARRAY_ARGUMENTS_OBJECT by obj_type exactly as bits 11 and 12 already are
(array_gc_header refuses any header that is not GC_TYPE_ARRAY). The JSON
direct parser and the tape materializer set it at birth; one
write_fast_path_receiver_kind_ok(obj, flags) replaces the class_id == 0
clause at all four runtime guards, and the two emitted guards OR the bit into
their kind test.

It fails safe — every other class-less receiver is unmarked and keeps the full
[[Set]] walk, so no existing population changes behaviour — and it is free in
the generated guard, because _reserved is already loaded there.

Worth recording: the read PIC has admitted class_id == 0 all along
(field_get_set/ic_miss.rs primes on any regular descriptor-free shaped
receiver; the emitted read guard has no class_id compare at all). Reads of
parsed objects were already on the ShapeId fast path — only writes were not.
#8067/#8086 supplied the rest: js_object_alloc_class_inline_keys birth-stamps
a parsed receiver with a real ShapeId, and PARSE_SHAPE_CACHE gives repeated
parses of one shape a single GC_FLAG_SHAPE_SHARED keys array, so the whole
2400-receiver prefix carries one ShapeId.

Drive-by memory-safety fix

JSON.parse("{}") initialized eight inline field slots into an allocation
that has max(0, INLINE_SLOT_FLOOR) = two of them (the floor dropped 4 -> 2
in #7928) — a 48-byte overwrite past the object on every empty-object parse,
the exact "heap buffer overflow into adjacent arena objects" that
js_object_alloc_with_parent documents. The hand-rolled fill was redundant as
well: the allocator has initialized every slot it allocates since #4717.

No unit test: under a forward bump allocator the overflow lands in
not-yet-allocated space on every path a test can construct, so a deterministic
corruption probe is not available — it only bites at an arena block boundary or
on the malloc path. The arithmetic is in the code comment.

Coverage

Three new runtime unit tests (cargo-test-visible), each sabotage-verified
with a real rebuild that recompiled perry-runtime:

sabotage result
guard ignores the mark (class_id != 0 only) 3 tests RED, including the pre-existing uniform-proof test
delete the parser's mark_object_plain_ordinary call 2 tests RED
OBJ_FLAG_PLAIN_ORDINARY 0x200 -> 0x1000 ABI-pin test RED
restored 4/4 green
  • json_parse_receivers_are_admitted_to_the_whole_loop_write_clone drives the
    shipped js_json_parse end-to-end, asserts the premises (class_id == 0, a
    real shared ShapeId), then clears the mark on one receiver and requires
    the guard to refuse — same objects, same ShapeId, same slots, only the mark
    differs.
  • json_parse_receivers_prime_the_static_write_pic — same discrimination on the
    PIC prime path.
  • plain_ordinary_object_flag_matches_the_emitted_write_pic_literal pins the
    bit against the literal perry-codegen emits.
  • The pre-existing object_array_numeric_write_guard_requires_complete_uniform_proof
    keeps its class-id-zero rejection for an unmarked receiver and gains the
    marked-accepts and native-module-still-rejects halves.

Payloads are a few bytes with an object root, so js_json_parse takes the eager
direct parser and no lazy tape stands between the probes and the objects they
inspect (#7635). The tape is not material to this benchmark — it needs
1 KB <= len <= 16 MB and a [ root — but it IS material to the workloads
the issue is about, which is why json_tape.rs and json_tape/iterative.rs are
marked too.

test-files/test_gap_json_parse_object_writes.ts is parity against node 26.5.1
for the semantics the class_id != 0 clause used to keep parsed objects away
from: deleted keys, added keys, frozen/sealed/non-extensible receivers (strict
TypeErrors), accessor and non-writable descriptors installed over a parsed
slot, prototype mutation with a shadowing setter, null prototypes, dynamic-key
writes, a parsed object used as a prototype, an empty parsed object grown by
name, a polymorphic site mixing parsed objects / literals / class instances, and
__proto__ / constructor as genuine own data keys. It is byte-identical to
node before and after — by design; it is a semantics regression guard for
the newly-enabled fast paths, not a witness for the perf fix, whose witnesses
are the sabotage-verified unit tests above.

Local validation

  • cargo test -p perry-runtime --lib -> 2354 passed, 0 failed, 4 ignored
    (baseline 2351/0/4 + the 3 new tests).
  • cargo test -p perry-codegen --no-fail-fast -> 27 suites, 1434 passed,
    11 failed
    — byte-for-byte the documented pre-existing set (CI: required contexts lint and cargo-test are red for every PR (and lint on main itself), so every merge bypasses them #8092); the
    failing names are the buffer-read / typed-feedback / back-edge-poll cluster,
    none of them touched here.
  • lint: cargo fmt --all -- --check, check_file_size.sh,
    workspace_architecture.py --check, addr_class_inventory.py,
    class_id_collisions.py, gc_store_site_inventory.py,
    gc_runtime_root_holders.py, gc_pin_sites.py, check_gc_env_knobs.py,
    check_gc_doc_claims.py, check_locale_independent_io.py,
    raw_handle_debt.py, local_binding_type_audit.py,
    global_sink_isolation.py, check_test_registration.py — all green.

No version bump (maintainer bumps at merge).

Summary by CodeRabbit

  • Performance

    • Improved property-write performance for objects created by JSON.parse, including repeated, static, and dynamic writes.
    • Optimized handling applies while preserving correct behavior for special object types and unsupported cases.
  • Bug Fixes

    • Fixed an issue affecting initialization of empty parsed objects.
    • Strengthened write behavior across frozen, sealed, non-extensible, accessor, prototype, and inheritance scenarios.
  • Tests

    • Added broad coverage for JSON-parsed object writes, deletions, additions, and edge-case property behavior.

…t paths (#8098)

A `JSON.parse` object carries `class_id == 0`, and both guarded object-write
fast paths — the whole-loop numeric clone and the static/dynamic write PICs —
rejected it on exactly that, so every property write to parsed data took the
generic `[[Set]]` path for the life of the program.

Measured on the committed #6812 controlled pair (`receiver_class_id_zero` vs
`key_dot`, identical 120,000,000 writes and identical `sink 122876400`), in
instructions retired because wall clock on the development host is unusable:
150.08e9 -> 1.169e9, a 128.3x reduction landing at 1.009x of the
object-literal cell. Scattered `record.field =` writes through a helper (the
PIC path, the shape real code has) go 30.37e9 -> 2.50e9, 12.1x.

`class_id != 0` was standing in for three per-object exclusions the generic
path still applies (`NATIVE_MODULE_CLASS_ID`, `Object.prototype`, and a `URL`
instance whose own slots are live views). None is derivable from the ShapeId,
so this adds an explicit opt-in per-object mark instead:
`OBJ_FLAG_PLAIN_ORDINARY`, set at birth by the JSON direct parser and the tape
materializer, re-tested by every generated guard. Unmarked class-less
receivers are unchanged. The bit is free in the emitted guard — `_reserved` is
already loaded there for the blocking-flag test.

Also fixes, in the same file: `JSON.parse("{}")` initialized eight inline field
slots into an allocation that has `max(0, INLINE_SLOT_FLOOR)` = two, a 48-byte
overwrite past the object on every empty-object parse. The allocator has
initialized every slot it allocates since #4717, so the fill was redundant too.
@proggeramlug
proggeramlug force-pushed the perf/8098-json-parse-write-fast-paths branch from 658688a to 663f219 Compare August 14, 2026 23:50
@coderabbitai

coderabbitai Bot commented Aug 14, 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: e18b4db5-3ec8-44b9-8cb8-c15b3fad7ed5

📥 Commits

Reviewing files that changed from the base of the PR and between a997324 and 663f219.

📒 Files selected for processing (10)
  • changelog.d/8118-json-parse-write-fast-paths.md
  • crates/perry-codegen/src/expr/proxy_reflect.rs
  • crates/perry-runtime/src/gc/types.rs
  • crates/perry-runtime/src/json/parser.rs
  • crates/perry-runtime/src/json_tape.rs
  • crates/perry-runtime/src/json_tape/iterative.rs
  • crates/perry-runtime/src/object/alloc.rs
  • crates/perry-runtime/src/proxy.rs
  • crates/perry-runtime/src/proxy/put_value.rs
  • test-files/test_gap_json_parse_object_writes.ts

📝 Walkthrough

Walkthrough

JSON-parsed objects are marked as plain ordinary objects. Static, dynamic, and numeric write fast paths accept marked receivers while preserving native-module and unmarked-object exclusions. Parsing and write semantics gain runtime and regression coverage.

Changes

JSON object write optimization

Layer / File(s) Summary
Plain-ordinary object marking
crates/perry-runtime/src/gc/types.rs, crates/perry-runtime/src/object/alloc.rs, crates/perry-runtime/src/json/...
Adds OBJ_FLAG_PLAIN_ORDINARY and marks objects created by JSON parsing and tape materialization. Empty-object parsing now uses allocator initialization instead of fixed eight-slot initialization.
Write fast-path admission
crates/perry-codegen/src/expr/proxy_reflect.rs, crates/perry-runtime/src/proxy/put_value.rs, crates/perry-runtime/src/proxy.rs, changelog.d/8118-json-parse-write-fast-paths.md
Static, dynamic, and numeric write guards accept marked class-less objects. Native modules and unmarked class-less objects remain excluded.
Runtime and regression coverage
crates/perry-runtime/src/proxy.rs, test-files/test_gap_json_parse_object_writes.ts, changelog.d/8118-json-parse-write-fast-paths.md
Tests cover flag ABI consistency, cache admission, shape sharing, descriptors, prototypes, extensibility, dynamic keys, polymorphism, empty objects, and special property names.

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

Sequence Diagram(s)

sequenceDiagram
  participant JSONParser
  participant ObjectAllocator
  participant PutValue
  participant WritePIC
  JSONParser->>ObjectAllocator: Allocate and mark JSON object
  ObjectAllocator-->>JSONParser: Return marked receiver
  PutValue->>WritePIC: Validate receiver kind and shape
  WritePIC-->>PutValue: Execute optimized property write
Loading

Possibly related issues

  • #8098: Directly tracks the class-less JSON receiver exclusion addressed by this change.
  • #6812: Covers the object-write fast-path matrix that includes the JSON-parsed receiver case.

Possibly related PRs

Suggested labels: parity

Suggested reviewers: andrewtdiz, thehypnoo

✨ 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/8098-json-parse-write-fast-paths

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

Copy link
Copy Markdown
Contributor Author

Verified the two claims I could check without building. Both hold.

The drive-by memory-safety fix is real, and the arithmetic is exact

object/mod.rs:49    pub(crate) const INLINE_SLOT_FLOOR: usize = 2;
object/mod.rs:31    "8 (GcHeader) + 32 (ObjectHeader) + 8 * max(field_count, INLINE_SLOT_FLOOR)"
json/parser.rs:652  js_object_alloc_class_inline_keys(0, 0, 0, keys_arr)   -> max(0, 2) = 2 slots
json/parser.rs:655  for i in 0..8 { std::ptr::write(fields_ptr.add(i), ...) }   -> 8 slots written

6 JSValues × 8 bytes = 48 bytes past the object, on every JSON.parse("{}"). Precisely the "heap buffer overflow into adjacent arena objects" that js_object_alloc_with_parent warns about, and it has been latent since INLINE_SLOT_FLOOR dropped 4 → 2 in #7928.

Your reason for shipping it without a test is the right call and worth keeping in the changelog: under a forward bump allocator the overflow lands in not-yet-allocated space on every constructible path, so it only bites at a block boundary or on the malloc path. A test that cannot fail would be worse than the note.

Worth flagging that this is the second consequence of #7928's floor change to surface today — the #6812 audit found the issue body still asserting the floor is 4 and no matrix cell re-measured after it moved. A change to that constant evidently has a wider blast radius than its own PR.

The class_id diagnosis holds, and the precedent is the convincing part

That the read PIC has admitted class_id == 0 all along — no class_id compare in its emitted guard at all — is the strongest single piece of evidence here. It shows the write-side compare was never load-bearing for shape identity, which is exactly what #8086 demoted class_id for.

Your finding that class_id != 0 was standing in for three unrelated per-object exclusions (NATIVE_MODULE_CLASS_ID, Object.prototype, URL) is the substantive part, and the reason an opt-in OBJ_FLAG_PLAIN_ORDINARY mark is the right shape rather than deriving eligibility from ShapeId: the transition cache converges distinct objects onto one keys array, so a prime-time-only exclusion loses to ordering. That argument is what makes this safe.

Marking json_tape.rs and json_tape/iterative.rs as well as the parser is the difference between fixing the benchmark and fixing the workload — a 7-byte '{"x":0}' never reaches the tape, but the ORM/HTTP-body population this issue is actually about does. A parser-only fix would have measured beautifully and shipped nothing.

What I have not verified

The instruction counts, the sabotage runs, and the suite results — I am relying on your report for those. What I checked is the reasoning and the two source-level claims above.

The +0.87% on rhs_pointer disclosed and reconciled arithmetically (2 extra guard instructions × 96 M writes) is the most credible number in the report, precisely because it is the one that costs something. A change of this size with no reported regression anywhere would have been the thing to distrust.

Leaving it draft pending my own run of the enumerated lint set and the two suites on a landing tree.

@proggeramlug
proggeramlug marked this pull request as ready for review August 15, 2026 00:25
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landing-equivalent validation is clean. Merging.

I merged current main (9aadf1861, which added #8106 and #8109 after this branch's base) and re-ran, because a codegen-guard change and a root-reload-allowlist change landing in the same window is exactly where an interaction would hide:

merge with main                       CLEAN, no conflicts
cargo test -p perry-runtime --lib     2364 passed, 0 failed, 4 ignored   (main's 2361 + your 3)
cargo test -p perry-codegen --no-fail-fast   27 suites, 1434 passed, 11 failed — the #8092 baseline set exactly
cargo fmt --all -- --check            clean
./scripts/check_file_size.sh          clean
34 enumerated lint checks             all green

No version bump, no manifest or lockfile change.

What is landing

128.3× fewer instructions on receiver_class_id_zero (150,082,612,917 → 1,169,725,264), arriving at 1.009× of the key_dot reference — the acceptance bar this issue set. sink 122876400 unchanged everywhere, all 26 matrix cells keeping identical (writes, sink).

And a 48-byte heap overflow on every JSON.parse("{}"), which I verified arithmetically: INLINE_SLOT_FLOOR is 2, the call passes field_count = 0 so max(0, 2) = 2 slots are allocated, and the removed loop wrote 8. Latent since #7928 moved the floor from 4 to 2.

The two things that make this reviewable rather than just fast

The precedent, not the measurement. That the read PIC has admitted class_id == 0 all along — no class_id compare in its emitted guard at all — is what establishes the write-side compare was never load-bearing for shape identity. A 128× number with no such argument would have warranted much more suspicion than this did.

The disclosed cost. rhs_pointer +0.87%, reconciled exactly as 2 extra guard instructions × 96 M writes. A change of this magnitude reporting no regression anywhere would have been the thing to distrust; a change that finds its own 0.87% and explains it arithmetically is the opposite.

Marking the tape paths as well as the parser is what separates fixing the benchmark from fixing the workload — '{"x":0}' is 7 bytes and never reaches the tape, but the ORM/HTTP-body population the issue is actually about does.

Merging with --admin, as every merge currently must (#8092, #8117).

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(object): JSON.parse receivers have class_id == 0, so every write to parsed data misses both object-write fast paths (55x, identical checksum)

1 participant