Skip to content

perf: keep Array.prototype on the primitive-array copy so V8 stays on the JSON.stringify fast path - #903

Merged
mcollina merged 1 commit into
mainfrom
perf/primitive-array-fast-path-prototype
Oct 22, 2026
Merged

mcollina merged 1 commit into
mainfrom
perf/primitive-array-fast-path-prototype

Conversation

@Tony133

@Tony133 Tony133 commented Oct 20, 2026

Copy link
Copy Markdown
Member

Proposal:

Fixes the Node 25+ performance of the primitive-array fast path introduced in #900. Related: #782.

Problem

#900 (shipped in v7.1.2) serializes plain number, integer and string arrays above a length cutoff by copying the elements into a fresh array and calling native JSON.stringify on the copy. To guarantee that a user-defined Array.prototype.toJSON is never invoked, the copy's prototype is set to null.

V8's side-effect-free JSON.stringify fast path (V8 13.8+, i.e. Node 25+; see https://v8.dev/blog/json-stringify) requires the standard Array.prototype. Any non-standard prototype (null, custom, or an Array subclass) falls back to the general-purpose serializer at roughly half the speed, so on Node 26 the copy ends up no faster than the per-item loop it replaces:

Node 26.12, number[1000]
  JSON.stringify(arr)              45.3k ops/s
  JSON.stringify(nullProtoArr)     22.5k ops/s   <- what v7.1.2 does
  slice() + JSON.stringify         40.8k ops/s

On Node 20/22/24 the fast path does not exist, so the regression is invisible there, which is probably why the cutoffs measured in #900 did not catch it.

Fix

Keep the copy (elements are still read exactly once; the "resume per-item coercion on the first miss" behaviour is untouched) but only detach its prototype when Array.prototype.toJSON is actually defined, the only case in which the null prototype changes anything. The check is a single monomorphic property load per call, and the lookup walks to Object.prototype as well.

Applied in both places: the generated code for number/integer arrays in index.js, and asPlainStringArray in lib/serializer.js.

Benchmark

Median of 5 interleaved rounds, output consumed with Buffer.byteLength. string[] run with largeArraySize: 100.

Node 26.12.0 (V8 14.6)

case native before (null proto) after delta
number[128] 473.9k 313.9k 475.1k +51%
number[1000] 60.1k 42.8k 61.6k +44%
number[20000] 3.1k 2.0k 2.8k +40%
integer[512] 572.8k 265.3k 479.2k +81%
integer[1000] 289.6k 137.3k 251.6k +83%
integer[20000] 12.3k 6.2k 9.3k +51%
string[1000] 143.4k 63.2k 96.4k +52%
string[20000] 6.3k 3.1k 5.3k +72%

Node 24.22.0 (V8 13.6)

case before after delta
number[128] 170.4k 180.2k +6%
number[1000] 21.3k 21.6k +2%
number[20000] 0.9k 0.9k +4%
integer[512] 261.7k 298.0k +14%
integer[1000] 133.4k 149.3k +12%
integer[20000] 5.4k 5.4k 0%
string[1000] 60.0k 61.9k +3%
string[20000] 2.6k 2.7k +1%

Node 22.23.1 (V8 12.4): +0% to +7% on every case. Node 20.20.2 (V8 11.3): within ±4% (noise).

No regression on any runtime; the gain scales with how much of the V8 fast path the runtime has.

Tests

  • New test/primitive-array-prototype.test.js covers, for each of number / integer / string:

    • output equals JSON.stringify with a pristine Array.prototype;
    • output is unchanged and the hook is never called when Array.prototype.toJSON is patched;
    • a mid-array miss (one element of the wrong type) still resumes per-item coercion with a patched toJSON.

@Tony133
Tony133 marked this pull request as ready for review October 20, 2026 14:45
@Tony133
Tony133 requested a review from mcollina October 20, 2026 14:53

@gurgunday gurgunday 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

reproduced

@gurgunday

Copy link
Copy Markdown
Member

nice!

@mcollina
mcollina merged commit 2e8cb53 into main Oct 22, 2026
20 checks passed
@mcollina
mcollina deleted the perf/primitive-array-fast-path-prototype branch October 22, 2026 00:30
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.

3 participants