Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions changelog.d/11534-json-plain-member-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
### Performance

- `JSON.stringify` visits a nested object for about a third of what it did
(#10696): the per-object cost on nested literals drops from ~2,680 to ~960
instructions, and `{a:{b:{c:{d:{e:1}}}}}` from 15,748 to 7,338 per call.
No new side table and no new cache.
- **One shape probe per object.** The object walk re-derived the receiver's
keys through the shape table on every key access, about five
`shape_descriptor_by_id` probes per object. It now resolves keys and the
live slot bound once (`object_keys_and_live_slot_count`) and re-derives
them only after a call that can run user code or collect.
- **Plain object members are walked directly.** A member that is an
ordinary object with a plain-record class, no meta record, no `toJSON`-ish
own key and a clean `Object.prototype` verdict skips `member_to_json` and
`stringify_value_depth`'s dispatch chains, which both ended at the same
walk. The admission's shape probe and key scan (fused with the
array-index ordering scan) are handed to the member's walk.
- **One `Object.prototype` verdict per stretch of callback-free members.**
The stringify-wide half of the proof is threaded through the walk with
the shape template's existing `data_record_global_proof` contract and
cleared before anything that can run user code, instead of revalidating
the prototype's signature for every object.
- The same admission serves a compact root object, skipping the root
`toJSON` lookup, `stringify_value`'s dispatch and `is_object_pointer`. The
root no longer builds a single-use shape template, and the general root
path no longer allocates a heap `""` to carry its `toJSON` key.
- Arrays of records publish an element's index key only when the element's
own `toJSON` can read it, and format it with `itoa`; `write_number` tests
integers without a libm `trunc` call.

### Fixed

- A two-or-more-key object literal with a key spelled like a runtime-internal
field (`__perry_collection_backing__`, …) no longer drops that key from
`JSON.stringify`: only declared classes can carry those fields, so plain
records are no longer filtered (a one-key literal already kept it).
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,25 @@ fn assert_initial_prototype_lookup_survives(empty: bool) {
});
let output_scope = RuntimeHandleScope::new();
let output = output_scope.root_nanbox_u64(output as u64);
if empty {
// #10696: the one collection point this case ever reached was the root
// `toJSON` key the general fallback allocated — a heap `""` that was
// only ever read back as bytes. That allocation is gone, so the empty
// root's fallback reaches no collection point at all: with one armed
// for the very next arena allocation, it must not fire.
assert_eq!(
gc_collection_count(),
before,
"the empty root's fallback must stay allocation-free"
);
assert_eq!(
input.with_mut_ptr(|input: *mut crate::ObjectHeader| input as usize),
before_address
);
// ...and that verdict is not vacuous: the armed collection is live,
// and the next arena allocation takes it.
let _ = crate::string::js_string_from_bytes(b"armed".as_ptr(), 5);
}
drain_scheduled_minor_gc(before, "initial JSON prototype lookup");
assert_ne!(
input.with_mut_ptr(|input: *mut crate::ObjectHeader| input as usize),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-runtime/src/json/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ mod stringify_escaped_output;
mod stringify_flat;
pub(crate) use stringify_flat::note_completed_malloc_json_output;
mod stringify_nested_records;
mod stringify_object;
mod stringify_primitive_array;
mod stringify_primitive_object;
mod stringify_record_output;
Expand Down
11 changes: 8 additions & 3 deletions crates/perry-runtime/src/json/replacer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1867,6 +1867,10 @@ pub unsafe extern "C" fn js_json_stringify_full(
);
});
}
} else if !use_pretty
&& super::stringify_object::try_stringify_plain_root(value.to_bits(), &mut buf)
{
// No replacer, compact, and a root no `toJSON` can reach (#10696).
} else {
// No replacer. Pre-resolve the ROOT value's own `toJSON` here (same
// `apply_to_json_keyed` the function-replacer branch above uses) so a
Expand All @@ -1877,9 +1881,10 @@ pub unsafe extern "C" fn js_json_stringify_full(
// value-tojson-result's `arr.toJSON = () => {}` case). Arm the
// one-shot suppression guard so the walk below doesn't re-invoke
// `toJSON` on the same (already-resolved) root value.
let empty_str = js_string_from_bytes(b"".as_ptr(), 0);
let empty_key_f64 = nanbox_string_f64(empty_str);
let value_after_to_json = apply_to_json_keyed(value, empty_key_f64);
// The root's `toJSON` key is the empty String; it is only ever read
// back as bytes, so no string needs to be allocated to carry it.
reset_to_json_key();
let value_after_to_json = apply_to_json(value);
let after_bits = value_after_to_json.to_bits();
if after_bits == TAG_UNDEFINED
|| is_closure_value(after_bits)
Expand Down
Loading
Loading