Skip to content

perf(json): walk plain object members directly, one shape probe per object (#10696) - #11534

Merged
proggeramlug merged 2 commits into
mainfrom
claude/determined-brown-ffpllq
Sep 27, 2026
Merged

proggeramlug merged 2 commits into
mainfrom
claude/determined-brown-ffpllq

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

After #10717 and #11397, JSON.stringify still 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 release with codegen-units=16, programs built with --march x86-64-v2. Per op on the 5-deep shape:

cost Ir/op what it was
shape_descriptor_by_id 3,698 (23%) 41 probes at 86 Ir each. The walk re-derived the keys through the shape table on every key access, about 5 probes per object
member_to_json + stringify_value_depth dispatch ~900 / object two guard chains (buffer, typed array, RegExp, boxed primitive, Date, raw JSON, Symbol, Array-subclass backing, …), both ending at the same object walk
Object.prototype signature check ~200 / object a process-wide question re-asked per object, including another shape probe

Changes

  • One shape probe per object (json/stringify_object.rs, object/mod.rs)

    • New object_keys_and_live_slot_count returns the keys and the live slot bound from one probe; object_keys now delegates to it.
    • The walk re-derives its key slots through the rooted object only after a call that can run user code or collect: a getter, toJSON, recursion, or a BigInt toJSON. 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:

    • buffer and typed-array registries rule it out before the header read (same order as member_to_json);
    • the GC header is a validated, unforwarded GC_TYPE_OBJECT;
    • class_is_plain_record holds, which excludes the reserved boxed-primitive and raw-JSON class ids, declared classes, and any class-chain toJSON;
    • there is no meta record, so no recorded prototype and no Array-subclass backing;
    • no own key can carry a toJSON;
    • Object.prototype has no toJSON.

    Every other stringify_value_depth arm 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 of keys_array_may_carry_to_json plus ecma_own_key_order.

  • One Object.prototype verdict 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's data_record_global_proof already has: established lazily, cleared before anything that can run user code, and passed into the template itself. Only user code can give Object.prototype a toJSON.

  • Root. A compact, no-replacer root that passes the same admission is walked directly, skipping the root toJSON lookup, stringify_value's dispatch and is_object_pointer. The node-stream probe is kept.

    • The root no longer builds a shape template that a single call can never reuse.
    • The general root path no longer allocates a heap "" just to carry its toJSON key, which is only ever read back as bytes.
  • Arrays of records (stringify_shape_template.rs)

    • An element's index key is published only right before the element's own toJSON probe, its one reader. Previously it was formatted with write! for every templated element.
    • set_to_json_key_index uses itoa.
    • The element's live slot bound is reused instead of probed again.
  • write_number tests integers with an as i64 round trip instead of fract(), a libm trunc call 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, because stringify.rs would 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):

shape before after
{a:{b:1}} 7,702 4,469 −42%
{a:{b:{c:{d:{e:1}}}}} 15,748 7,338 −53%
10-deep nesting 34,142 12,399 −64%
same 5-deep tree from JSON.parse 14,798 7,183 −51%
16 properties 16,738 10,819 −35%
API payload ({id,name,email,roles:[..],profile:{..}}) 17,050 11,327 −34%
two class-instance members 15,545 11,451 −26%
member with a toJSON 12,118 9,084 −25%
[{a:1},{a:2},{a:3},{a:4}] 10,879 9,290 −15%
8 records × 6 fields 42,756 38,748 −9%
[1] 2,514 2,267 −10%
{a:1} / 4 props (flat emitter, untouched) 1,726 / 2,561 1,752 / 2,587 noise

Per nested object: ~2,680 → ~960. Every benchmark's output was diffed equal to Node 26.5.1.

  • New test-files/test_gap_json_plain_member_walk.ts: byte-identical to Node 26.5.1. It installs Object.prototype.toJSON mid-walk from each kind of callback (member and grandchild toJSON, getter, nested getter, array element, BigInt.prototype.toJSON) and checks that later members honour it. It also covers every exotic member kind and inherited/accessor toJSON.
    • Sabotage: deleting the proof reset on the slow member path makes 4 lines of that test fail.
  • 4 new unit tests in stringify_tojson_probe_tests.rs:
    • the fused key scan equals the two scans it replaces;
    • plain_object_member's accept/decline matrix and proof contract;
    • object_keys_and_live_slot_count equals the two separate probes;
    • the itoa index key.
  • RUST_TEST_THREADS=1 cargo test -p perry-runtime --lib: 4644 passed, 0 failed.
    • One existing test changed: 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.
  • Parity harness (PERRY_SKIP_BUILD=1, Node 26.5.1):
  • cargo fmt --check, check_file_size.sh, gc_runtime_root_holders.py, addr_class_inventory.py, and RUSTFLAGS="-D warnings" cargo check -p perry-runtime --lib all pass. Clippy: none of its 912 existing warnings fall on changed lines or in the new file.
  • scripts/run_lint_gates.sh with SKIP_COMPILE_GATES=1: 89 of 92 pass. The 3 failures are environmental or already red on main: no cargo xwin, no Bun or RSS samples in this sandbox, and public-baseline freshness (which fails identically on a clean HEAD).
  • Still running at the time of opening, results to follow in a comment:
    • an A/B of every other test-files/*.ts fixture that calls JSON.stringify (388 of them), base vs. this branch;
    • the same benchmark table measured with the shipped dist profile.
  • Full gap suite and cargo test --workspace are left to CI.

Checklist

  • I have NOT bumped the workspace version or edited CLAUDE.md / CHANGELOG.md
  • Commit follows the perf: prefix convention

Generated by Claude Code

…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.
@coderabbitai

coderabbitai Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 1bc764b8-c082-4ec4-9cf2-4c006a08d85e

📥 Commits

Reviewing files that changed from the base of the PR and between 4580fb0 and 397f740.

📒 Files selected for processing (12)
  • changelog.d/11534-json-plain-member-walk.md
  • crates/perry-runtime/src/gc/tests/runtime_roots/json_shape_template.rs
  • crates/perry-runtime/src/json/mod.rs
  • crates/perry-runtime/src/json/replacer.rs
  • crates/perry-runtime/src/json/stringify.rs
  • crates/perry-runtime/src/json/stringify_object.rs
  • crates/perry-runtime/src/json/stringify_scalars.rs
  • crates/perry-runtime/src/json/stringify_shape_template.rs
  • crates/perry-runtime/src/json/stringify_tojson_probe.rs
  • crates/perry-runtime/src/json/stringify_tojson_probe_tests.rs
  • crates/perry-runtime/src/object/mod.rs
  • test-files/test_gap_json_plain_member_walk.ts
 ____________________________________________________________________________________________________________________________________________________________
< Don't program by coincidence. Rely only on reliable things. Beware of accidental complexity, and don't confuse a happy coincidence with a purposeful plan. >
 ------------------------------------------------------------------------------------------------------------------------------------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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
proggeramlug force-pushed the claude/determined-brown-ffpllq branch from d513b97 to 397f740 Compare September 27, 2026 13:35
@proggeramlug
proggeramlug merged commit 32bcef8 into main Sep 27, 2026
20 of 21 checks passed
@proggeramlug
proggeramlug deleted the claude/determined-brown-ffpllq branch September 27, 2026 13:35
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(json): JSON.stringify costs ~3,000 instructions per object visited (~20x node); array elements and primitives are fine

2 participants