Skip to content

perf: project array items and let V8 stringify them - #886

Open
mcollina wants to merge 1 commit into
mainfrom
perf/array-projection-fast-stringify
Open

mcollina wants to merge 1 commit into
mainfrom
perf/array-projection-fast-stringify

Conversation

@mcollina

Copy link
Copy Markdown
Member

Follow-up to the measurements in #782. V8 13.8 (Node.js 25) added a fast path to JSON.stringify that outruns the string concatenation we generate, and our own throughput is flat across V8 versions — on Node 26 a 20k-object array serializes at 0.42x the speed of plain JSON.stringify, and a 1k-object array at 0.53x.

That fast path only applies to plain fast-mode objects: no accessors, no toJSON, no Date values. Measured on Node 26, JSON.stringify drops from 6.7M ops/cpu-s on a plain object to 1.4M with a getter, 1.7M on a null-prototype object and 2.2M on a class with toJSON, so the objects users actually hand us often miss it. But the objects we could build from them always qualify.

So on a supporting V8, arrays of two or more items are now serialized by projecting each item into a new object holding exactly the schema's properties, already coerced, and handing the resulting array to JSON.stringify. Schema filtering and coercion are unchanged and the output is byte-identical. The concatenation path takes over for anything the projection does not model: anyOf/oneOf/allOf/if, $ref, patternProperties, truthy additionalProperties, const, default, tuple items, the unsafe string format, integer-like and __proto__ property names, and objects nested more than two levels deep. BigInt values cannot be modelled statically, so they are detected at runtime and abandon the projection for that call.

Paired CPU-time A/B against main on Node 26, median of 11 alternating rounds, each case asserting identical output before timing:

Case Baseline ops/cpu-s Patched ops/cpu-s Speedup
array of 2 objects 3.71M 4.66M 1.24x
array of 4 objects 1.80M 2.73M 1.51x
array of 32 objects 231,707 383,531 1.65x
array of 100 objects 73,886 131,126 1.77x
array of 1k objects 7,208 13,109 1.80x
array of 20k objects 329 603 1.83x
array of 1k numbers 25,938 50,975 2.03x
array of 1k strings 25,508 60,726 2.37x
array of 1k nested objects 1,493 1,630 1.08x
array with anyOf, declined 11,107 11,303 1.01x
single object, untouched 7.92M 8.00M 1.01x

Against JSON.stringify itself the 20k-object array goes from 0.42x to 0.79x and the 1k-object array from 0.53x to 0.89x; the rest of that gap is the schema filtering JSON.stringify does not do.

The two thresholds came out of measurement. Arrays shorter than two items are faster to concatenate, and projecting objects more than two levels deep allocates more than it saves — at depth 4 the nested-array case regressed to 0.94x, which is why the limit is 2.

On Node 24 and older the generated code is byte-identical to main, verified by comparing mode: 'debug' output, and the A/B puts every case within noise of 1.00x there.

A differential harness builds every schema shape under both paths and compares output including which errors are thrown: 189,090 schema/value/length combinations, zero mismatches, on Node 24, 25 and 26.

Two things worth deciding in review. The behaviour is detected from process.versions.v8 and exposed as a new arrayProjection option — that is partly a real escape hatch and partly because c8 --100 cannot pass on a Node matrix when a codegen branch only executes on newer V8, since whichever runtime CI uses the other path is dead. If we would rather not add public API for it, the alternative is relaxing the coverage gate. And single objects are deliberately untouched: they still lose (0.60x on a 15-property object, 0.55x nested), but a side experiment put the same projection at 1.66x for a wide or nested object and 0.78x for a small one with optional properties, so it needs its own shape-based heuristic rather than the array length threshold used here.

Benchmarked on Node 24.18.0 (V8 13.6), 25.2.1 (V8 14.1) and 26.5.1 (V8 14.6), pinned with taskset, measuring CPU time rather than wall time.

V8 13.8 (Node.js 25) added a fast path to JSON.stringify that outruns the
string concatenation we generate. It only applies to plain fast-mode objects
with no accessors, no toJSON and no Date values, so a user's own objects rarely
qualify — measured on Node 26, JSON.stringify drops from 6.7M ops/cpu-s on a
plain object to 1.4M with a getter, 1.7M on a null-prototype object and 2.2M on
a class with toJSON. But the objects we could build from them always qualify.

So on a supporting V8, arrays of two or more items are now serialized by
projecting each item into a new object holding exactly the schema's properties,
already coerced, and handing the resulting array to JSON.stringify. Schema
filtering and coercion are unchanged, and the concatenation path takes over for
anything the projection does not model: anyOf/oneOf/allOf/if, $ref,
patternProperties, additionalProperties, const, default, tuples, unsafe strings,
integer-like and __proto__ keys, objects nested more than two levels deep, and
BigInt values, which are detected at runtime and abandon the projection.

Paired CPU-time measurements on Node 26, median of 11 alternating rounds:

  array of 4 objects     1.51x    array of 1k numbers    2.03x
  array of 32 objects    1.65x    array of 1k strings    2.37x
  array of 1k objects    1.80x    array of 1k nested     1.08x
  array of 20k objects   1.83x    single object          1.01x

Against JSON.stringify itself, the 20k-object array goes from 0.42x to 0.79x
and the 1k-object array from 0.53x to 0.89x; the rest of the remaining gap is
the schema filtering JSON.stringify does not do.

Node 24 and older generate byte-identical code to before. A differential test
over 189090 schema/value/length combinations confirms the two paths agree
byte-for-byte, including which errors are thrown.

The detection reads process.versions.v8 and can be overridden with the new
`arrayProjection` option, which also lets the tests exercise both paths on
every runtime.

@Tony133 Tony133 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🚀

  1. Keep arrayProjection public: it's a real escape hatch, and relaxing the c8 --100 gate would cost more than one boolean. Maybe note in the README that in mode: 'standalone' the V8 check runs at build time and is baked into the output.

  2. Agreed on leaving single objects out; the array-length threshold clearly doesn't carry over, so that needs its own heuristic in a follow-up.

Two quick questions:

  • Is gating on largeArrayMechanism === 'default' deliberate? With 'json-stringify' the arrays below largeArraySize are still concatenated and could be projected too.
  • Should the recursive-schema comment in buildObjectProjectionFunction sit next to the depth check rather than the additionalProperties one?

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.

2 participants