perf(json): walk plain object members directly, one shape probe per object (#10696) - #11534
Merged
Merged
Conversation
…bject (#10696) JSON.stringify paid ~2,680 instructions per nested object after #10717 and #11397. Callgrind put the bulk in three places: - stringify_object_inner re-derived the receiver's keys through the shape table on every key access: ~5 shape_descriptor_by_id probes per object at 86 Ir each. The walk 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. - every object member went through member_to_json's and stringify_value_depth's guard chains, both of which end at the same walk. plain_object_member now admits an ordinary object with a plain-record class, no meta record, no toJSON-ish own key and a clean Object.prototype verdict, and hands its shape facts and array-index-key answer (one fused key scan) to the member's walk. - the Object.prototype signature was revalidated per object. The stringify-wide proof is now threaded through the walk with the shape template's data_record_global_proof contract, cleared before anything that can run user code. The same admission serves a compact root. The root no longer builds a single-use shape template, and the general root path no longer allocates a heap "" for its toJSON key. Array records publish their index key only where the element's own toJSON can read it, formatted with itoa, and write_number drops a libm trunc call. The object walk moves to json/stringify_object.rs for the file-size gate. No new side table, no version bump. {a:{b:{c:{d:{e:1}}}}}: 15,748 -> 7,338 Ir/op. Per nested object ~2,680 -> ~960. Output identical to Node 26.5.1 on every benchmark shape and on the new test_gap_json_plain_member_walk.ts, which mutates Object.prototype.toJSON mid-walk from every kind of callback.
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (12)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
proggeramlug
force-pushed
the
claude/determined-brown-ffpllq
branch
from
September 27, 2026 13:35
d513b97 to
397f740
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
After #10717 and #11397,
JSON.stringifystill paid ~2,680 instructions per nested object (issue #10696). This PR cuts that to ~960, and{a:{b:{c:{d:{e:1}}}}}from 15,748 to 7,338 instructions per call (−53%). Output is unchanged, except for one fix listed under Changes.No version bump, no new side table, no new cache.
Where the per-object cost was
Measured with callgrind as the (N=20,000 − N=2,000)/18,000 delta, so one-time realm setup cancels out. Runtime built as
releasewithcodegen-units=16, programs built with--march x86-64-v2. Per op on the 5-deep shape:shape_descriptor_by_idmember_to_json+stringify_value_depthdispatchObject.prototypesignature checkChanges
One shape probe per object (
json/stringify_object.rs,object/mod.rs)object_keys_and_live_slot_countreturns the keys and the live slot bound from one probe;object_keysnow delegates to it.toJSON, recursion, or a BigInttoJSON. Before, it re-derived them on every access.Plain object members are walked directly (
stringify_tojson_probe::plain_object_member). It admits a member only when all of these hold:member_to_json);GC_TYPE_OBJECT;class_is_plain_recordholds, which excludes the reserved boxed-primitive and raw-JSON class ids, declared classes, and any class-chaintoJSON;toJSON;Object.prototypehas notoJSON.Every other
stringify_value_deptharm is therefore excluded, and the member goes straight to the walk that path would have reached. It never allocates, so the shape facts and the array-index-key answer are handed to the member's walk. That answer comes from one fused key scan instead ofkeys_array_may_carry_to_jsonplusecma_own_key_order.One
Object.prototypeverdict per callback-free stretch. The stringify-wide half of the proof is threaded through the walk as a&mut bool. It uses the contract the shape template'sdata_record_global_proofalready has: established lazily, cleared before anything that can run user code, and passed into the template itself. Only user code can giveObject.prototypeatoJSON.Root. A compact, no-replacer root that passes the same admission is walked directly, skipping the root
toJSONlookup,stringify_value's dispatch andis_object_pointer. The node-stream probe is kept.""just to carry itstoJSONkey, which is only ever read back as bytes.Arrays of records (
stringify_shape_template.rs)toJSONprobe, its one reader. Previously it was formatted withwrite!for every templated element.set_to_json_key_indexusesitoa.write_numbertests integers with anas i64round trip instead offract(), a libmtrunccall on the baseline x86-64 target.Fix: a literal with 2+ keys and a key spelled like a runtime-internal field (
__perry_collection_backing__) no longer loses that key. Plain records can't carry those fields; a 1-key literal already kept it. Node prints it.The object walk moves to
json/stringify_object.rs, becausestringify.rswould otherwise exceed the 2,000-line gate.Related issue
Fixes #10696
Test plan
Instructions per call (runtime
release+codegen-units=16; ±~2% is codegen-partition noise, e.g.{a:1}below, whose code is untouched):{a:{b:1}}{a:{b:{c:{d:{e:1}}}}}JSON.parse{id,name,email,roles:[..],profile:{..}})toJSON[{a:1},{a:2},{a:3},{a:4}][1]{a:1}/ 4 props (flat emitter, untouched)Per nested object: ~2,680 → ~960. Every benchmark's output was diffed equal to Node 26.5.1.
test-files/test_gap_json_plain_member_walk.ts: byte-identical to Node 26.5.1. It installsObject.prototype.toJSONmid-walk from each kind of callback (member and grandchildtoJSON, getter, nested getter, array element,BigInt.prototype.toJSON) and checks that later members honour it. It also covers every exotic member kind and inherited/accessortoJSON.stringify_tojson_probe_tests.rs:plain_object_member's accept/decline matrix and proof contract;object_keys_and_live_slot_countequals the two separate probes;itoaindex key.RUST_TEST_THREADS=1 cargo test -p perry-runtime --lib: 4644 passed, 0 failed.gc::tests::runtime_roots::json_shape_template::empty_json_stringify_survives_the_initial_prototype_fallback. Its only collection point was the root's heap""key, which is gone, so the path is now allocation-free. The test now asserts exactly that, with a collection armed for the next arena allocation. It then proves the armed collection is live, so the check isn't vacuous.PERRY_SKIP_BUILD=1, Node 26.5.1):--filter json: 74 pass, 1 fail, 2 skipped (server-driven). The failure istest_gap_json_lazy_defineproperty_index, alreadyparity_failingap_snapshot.json(JSON.parse lazy array: Object.defineProperty index accessor is bypassed by reads #10097).--filter tojson: 6/6.--filter stringify: 8/8.cargo fmt --check,check_file_size.sh,gc_runtime_root_holders.py,addr_class_inventory.py, andRUSTFLAGS="-D warnings" cargo check -p perry-runtime --liball pass. Clippy: none of its 912 existing warnings fall on changed lines or in the new file.scripts/run_lint_gates.shwithSKIP_COMPILE_GATES=1: 89 of 92 pass. The 3 failures are environmental or already red onmain: nocargo xwin, no Bun or RSS samples in this sandbox, and public-baseline freshness (which fails identically on a cleanHEAD).test-files/*.tsfixture that callsJSON.stringify(388 of them), base vs. this branch;distprofile.cargo test --workspaceare left to CI.Checklist
perf:prefix conventionGenerated by Claude Code