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:165 → prototype_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.
Summary
JSONis 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:uon perrymaster. perry from main68a545439+ #10611; node v26.8.1; bun 1.3.14. Output checked equal to node on every row.{a:1}{a:1,b:2,c:3,d:4}{a:{b:1}}— 2 objects{a:{b:{c:{d:{e:1}}}}}— 5 objects[1][{a:1},{a:2},{a:3},{a:4}]Fitting the nesting pair (2 objects → 5 objects) isolates the per-object term cleanly:
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.stringifyon an object reachingcompute_object_proto_tojson_state(stringify_tojson_probe.rs:165→prototype_chain.rs:570) — thetoJSONprobe. 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.prototypewith notoJSON, which is a property of the shape and not of the instance.Notes on measurement
JSON.stringifyandJSON.parseof 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.