Skip to content

perf(json): JSON.stringify costs ~3,000 instructions per object visited (~20x node); array elements and primitives are fine #10696

Description

@proggeramlug

Summary

JSON is one of the two primitives where perry loses to both node and bun (#10695 has the crossover map). The cost is not per-element — arrays scale well. It is a ~3,000-instruction per-object overhead, about 20× node's, paid on every object visited.

perry crosses below node just under N=10,000 round-trips, and below bun before that.

Attribution

One program per shape, size from process.argv[2], cost per operation fitted across N=2,000→20,000 so startup drops out. perf stat -e instructions:u on perrymaster. perry from main 68a545439 + #10611; node v26.8.1; bun 1.3.14. Output checked equal to node on every row.

shape perry node bun
{a:1} 4,708 1,047 786
{a:1,b:2,c:3,d:4} 6,780 989 1,200
16 properties 15,990 3,457 3,332
{a:{b:1}} — 2 objects 7,651 1,491 947
{a:{b:{c:{d:{e:1}}}}} — 5 objects 16,496 1,924 1,586
[1] 2,815 511 1,288
16-element array 4,872 1,793 1,555
[{a:1},{a:2},{a:3},{a:4}] 13,025 1,455 1,884

Fitting the nesting pair (2 objects → 5 objects) isolates the per-object term cleanly:

term perry node ratio
per object visited ~2,950 ~144 ~20×
per property ~750 ~161 ~4.7×
per array element ~137 ~85 ~1.6×

Array elements are fine. Properties are moderately expensive. The per-object term dominates everything and is the defect.

Where I would look

The bisect on #10686 found JSON.stringify on an object reaching compute_object_proto_tojson_state (stringify_tojson_probe.rs:165prototype_chain.rs:570) — the toJSON probe. That is currently notable as one of the operations that force the whole globalThis realm to populate the first time.

A by-name prototype-chain walk to ask "does this object or its prototype have toJSON?", run once per object visited, would account for a per-object constant of this size. That is a hypothesis from the call path, not a profile of the steady state — it should be confirmed with callgrind on the steady-state loop before anyone changes code.

If it is the probe, the fix direction is to answer the question without a by-name walk per object — the overwhelmingly common case is an object whose prototype is the pristine Object.prototype with no toJSON, which is a property of the shape and not of the instance.

Notes on measurement

JSON.stringify and JSON.parse of primitives are healthy — perry beats node on both (stringify(12345) 858 vs 1,118; parse("12345") 363 vs 695). The regression is specific to containers.

node's per-op figure for the smallest shapes is unstable across runs ({a:1} measured 124/op in one run and 1,047/op in another, same program and method), so small-shape ratios against node should not be quoted tightly. The per-object term above is fitted from the nesting pair, where both runtimes were stable.

Related: #10695 (crossover map), #10686 (the one-time realm constant sharing this call path), #10689.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions