Skip to content

perf: speed up build() and large arrays of numbers, integers, and strings - #900

Merged
Tony133 merged 7 commits into
fastify:mainfrom
DanielMcSheehy:perf/build-and-large-arrays
Oct 19, 2026
Merged

Tony133 merged 7 commits into
fastify:mainfrom
DanielMcSheehy:perf/build-and-large-arrays

Conversation

@DanielMcSheehy

@DanielMcSheehy DanielMcSheehy commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

build() was doing work most schemas do not need, and large arrays of plain numbers, integers, and strings were written one element at a time. Both are faster now. The JSON text is unchanged.

Compiling a schema

build() always created an Ajv validator, and it always walked the schema looking for a recursive $ref. Ajv is only needed when the schema uses anyOf, oneOf, or if. A schema with no $ref does not need that walk. On a tiny schema, the Ajv setup was about 92% of build().

Skipping that work is why compilation is several times faster. A tiny schema is about 10× faster on Node 24 (912%). An array-of-objects schema is about 4× (302%). Deep, wide, and standalone schemas are about 80–100% faster. A schema with many $refs is about 23% faster. The function build() returns still writes the same JSON.

Writing a large array

For an array whose items are plain numbers, integers, or strings, the old generated code looped and appended one element at a time. Copying that array and calling JSON.stringify is faster, and the text matches the old loop.

If an element is not already that type, the old loop takes over from that element. Coercion stays the same (null in a number array is still 0, null in a string array is still ""), and errors stay the same (NaN still throws).

This only starts once the array is long enough to pay for the copy:

  • Node 22 and 24: numbers from 32 elements, integers from 512
  • Node 20: both from 100,000 elements. Below that, Node 20's JSON.stringify was slower on integer arrays, so those arrays keep the old loop.

Booleans keep the old loop. JSON.stringify was slower for them at every size we measured.

Nullable items, const, $ref, string formats, and objects also keep the old loop. The shortcut was either wrong for those or not faster.

On Node 24, a million short strings is about 4.7× faster (374%). A million numbers is 168% faster, a million long strings 66%, a million integers 58%. The existing bench.js cases for a short array of numbers and integers are 32% and 35% faster. On Node 20 those short cases are unchanged, because the shortcut waits until 100,000 elements.

Objects

The {, the comma, and the "key": used to be appended as separate strings. They are now one string, so there are fewer appends. That is a small gain. Arrays of objects barely move: about 9% for a million flat objects and 13% for a million nested ones. The time there is spent allocating the output, not in the generated loop.

Results

CPU-pinned containers, this branch against main, alternating runs, median of the rounds. Node 24 is 5 rounds. A blank cell was not measured on that runtime.

Case Node 24 Node 22 Node 20
build() tiny schema +912% +1288% +1364%
build() array-of-objects +302% — —
build() deep / wide / standalone / refs +102% / +87% / +82% / +23% +73% / — / — / +25% +93% / — / — / +32%
1e6 short strings +374% +453% +299%
1e6 numbers +168% +268% +81%
1e6 integers +58% +375% +172%
1e6 long strings +66% +95% +139%
1e6 flat / nested objects +8.6% / +12.6% — —
1e3 integers / numbers +30% / +93% +27% / +93% neutral (cutoff 1e5)
bench.js short array of numbers / integers +32% / +35% +64% / +83% ±0%

No Node 24 row was more than 2% slower. The existing bench.js suite (30 cases) had none either.

What did not pay

Rewriting the loop, building the output with different string tricks, and sending object arrays through JSON.stringify automatically were measured and were not faster.

Two open PRs overlap this area and are not this change. #868 combines more of the generated object text and inlines nested objects. #886 is about turning object arrays into plain objects and then calling JSON.stringify. This PR does not do either of those.

Check

593 unit tests, 100% coverage, and the TypeScript checks pass on Node 24.

npm test
node benchmark/bench-large-array.js

Keep output byte-identical to the generated loop. Lazy-compile the Ajv
validator, skip the recursion scan when the schema has no $ref, and walk
schemas with fewer allocations. Fold object key prefixes into one append.
For plain number, integer, and string item arrays, copy into a
null-prototype array and JSON.stringify, falling back to the original loop
at the first non-conforming element. Booleans stay on the loop. Integer and
number cutoffs are per runtime: Node 22+ uses 512 / 32; Node 20 uses 1e5.
build() timing does not belong next to the serialization suite.
The measured compile numbers stay in the pull request.
The coverage gate still needs the string-array miss path and the item
schemas that must stay on the old loop. Those cases are in array.test.js.
@DanielMcSheehy DanielMcSheehy changed the title perf: faster large-array serialization and build() compilation perf: speed up build() and large arrays of numbers, integers, and strings Oct 17, 2026
@DanielMcSheehy

Copy link
Copy Markdown
Contributor Author

@Tony133

@Tony133

Tony133 commented Oct 18, 2026

Copy link
Copy Markdown
Member

Thanks, nice work. I ran the tests, a differential check against main (~100 edge cases, Node 20/22/24/26, output identical) and the benchmarks: the build() gains and the array gains reproduce, including above the 32/512 cutoffs, with no regressions on large inputs.

Before merging:

  1. Add tests for the number/integer fallback. The "copy, then resume the loop on the first bad element" path is in generated code, so coverage doesn't see it. Please add a test with a bad element at the start, middle and end, for both integer and number.
  2. Update the README. The "Large Arrays" section says default uses Array.join and that arrays below largeArraySize are unaffected; with this PR plain number arrays switch to JSON.stringify from 32 elements, regardless of largeArraySize.
  3. Per-Node cutoffs. The thresholds pay off on 22 and 24, but on 26 the number cutoff at 32 is already marginal (40 numbers −6%, 1e3 numbers +9%, vs +47%/+52% on 22; integers are fine). That's the maintenance problem with version-keyed cutoffs: every V8 bump moves them. The only reason for the process.versions.node check is Node 20 (cutoff 1e5), and with Fastify 6 moving to Node 24+ the next major of this lib will drop 20 anyway. Two options: (a) land this in the next major with the Node 22+ cutoffs only and no version check, or (b) keep it in FJS v7 but add a comment/TODO to drop the Node 20 branch when 20 is dropped. Either way the cutoffs and the standalone caveat (generated code inherits the cutoffs of the Node that built it) should be documented.

One open question, not a blocker: this bundles three independent changes (build(), object key prefixes, array fast path) and the last two overlap with #868 and #886. Would it be easier to split them, so the build() part can land on its own? I'm fine either way; I'd like to hear what the other maintainers think before we decide.


For reference, my numbers (same machine, delta vs main):

case Node 20.20 Node 22.23 Node 24.22 Node 26.12
build() tiny schema +1274% +805% +769% +1392%
build() with $refs +603% +392% +380% +504%
1e6 numbers +93% +308% +165% +244%
1e6 integers +336% +433% +431% +614%
1e6 short strings +347% +528% +380% +451%
1e6 booleans +2% +2% 0% 0%
1e4 integers 0% +16% +65% +91%
1e3 numbers 0% +52% +18% +9%
1e3 integers −5% +43% +93% +72%
600 integers 0% +41% +80% +71%
40 numbers 0% +47% +14% −6%
10 numbers (below cutoff) 0% 0% 0% 0%
1e3 numbers, non-number at index 0 0% 0% −3% −3%
1e3 integers, all floats 0% −3% +51%* −1%
1e5 flat objects +29% +40% +149% +188%
small object +1% +49% 0% 0%

* likely noise: this case falls back to the old loop at index 0, and the same row was −3% on Node 24.19.

Method: tinybench, main (5f69709) and this branch (395a410) loaded side by side from two checkouts, 6 rounds per case alternating the order, median ops/s per side. Node 20 is flat below 1e5 elements, as expected from the cutoffs, and only matters for FJS v7: once Node 20 is dropped in the next major, only the 22+ cutoffs apply. The fallback path (non-conforming element) costs nothing measurable. Tests, lint and the differential check pass on all four.

@Tony133
Tony133 requested a review from mcollina October 18, 2026 10:57
@Tony133

Tony133 commented Oct 18, 2026

Copy link
Copy Markdown
Member

Follow-up on the build() numbers from the description, same method as above (6 alternating rounds, median ops/s, delta vs main):

case Node 20.20 Node 22.23 Node 24.22 Node 26.12
tiny schema +1222% +775% +741% +1318%
array-of-objects +476% +307% +269% +429%
deep (12 nested levels) +146% +108% +90% +127%
wide (60 properties) +184% +134% +133% +138%
standalone, tiny schema +1104% +706% +680% +1222%
20 chained $refs +26% +23% +24% +22%
anyOf (Ajv still needed, control case) +1% +2% +2% +3%

The compile gains reproduce and match the description closely (tiny, array-of-objects and $refs within a few points). The standalone case here uses the tiny schema, so it's not directly comparable with the +82% in the description. The anyOf control stays flat, as expected, since Ajv is still created there: the whole gain comes from skipping Ajv and the recursion scan when they're not needed.

The copy-then-resume path lives in generated code, so coverage does not
see it. Exercise a non-conforming element at the start, middle and end of
a large array for both integer and number, plus rounding, NaN and
read-once getters across the fallback.
… cutoffs

Measured against main on GitHub Actions x64 runners for Node 20, 22, 24
and 26 (8 alternating rounds, median ops/s). Integer-valued elements under
a number schema were below break-even on Node 26 until ~100 (-25% at 32),
so numbers now wait for 128. Node 20 at 1e5 depended on the runner, while
3e5 is +200% on every runner, so Node 20 waits for 3e5. Integers stay at
512. No row on any runner is below noise with these cutoffs.

Document the cutoffs, the fallback behaviour and the standalone caveat in
the README, and leave a TODO to drop the Node 20 tier with Node 20.
@DanielMcSheehy

DanielMcSheehy commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor Author

@Tony133 :) Thanks for the thorough review. All three items are in:

  1. Fallback tests — b4e1f27: a non-conforming element at the start, middle and end of a large array, for both integer and number, plus rounding, NaN and read-once getters across the fallback. Mutation-checked: breaking the resume path fails them.
  2. README — a8618e6: "Large Arrays" rewritten (no more Array.join claim), new "Number and integer arrays" subsection with the cutoff table, the fallback semantics, and the standalone caveat (generated code inherits the cutoffs of the Node that ran build()); the same caveat is under Standalone Mode.
  3. Cutoffs — went with (b), and re-measured on clean VMs rather than my laptop: GitHub Actions ubuntu-latest runners, Node 20/22/24/26, A/B against main (5f69709) with two checkouts loaded side by side, 8 alternating rounds, median ops/s, output consumed with Buffer.byteLength. Run: https://github.com/DanielMcSheehy/fast-json-stringify-faster/actions/runs/39127192586 (script: benchmark/ci/ab.js on the bench/cutoffs-ci branch of my fork).

Your Node 26 observation reproduces. With the PR as reviewed (395a410), integer-valued elements under a number schema were −25% at 32, −19% at 48, −14% at 64 on Node 26, break-even at ~100. Node 20 at 1e5 also turned out runner-dependent (+16%/+45% on one EPYC, −10%/−18% on another). So:

Node number integer
22+ 128 (was 32) 512 (unchanged)
20 3e5 (was 1e5) 3e5 (was 1e5)

One version check remains (>= 22) with a TODO to drop it with Node 20.

Results, a8618e6 vs main, x64 runners

Cases: number schema with integer values (intnum), with floats (float), with a 50/50 mix (mixed), and integer schema. Below the cutoff every row is within ±3.5% (noise); nothing on any runner is below that.

xychart-beta
    title "number schema, integer-valued elements: branch vs main (%)"
    x-axis [32, 48, 64, 100, 256, 512, 1000, 10000, 100000, 300000, 1000000]
    y-axis "delta %" -10 --> 420
    line "Node 22" [0, 0, -3.5, 0, 12.9, 11.8, 22.8, 41.2, 16.6, 247.6, 416.8]
    line "Node 24" [0, -1.2, 0.9, -0.6, 110.1, 116.3, 97.1, 79.9, 45.6, 61.8, 96]
    line "Node 26" [-1.5, 0, 0.1, -0.6, 4.5, 7.2, 19.5, 25.3, 13.7, 180.7, 275.1]
Loading
xychart-beta
    title "integer schema: branch vs main (%)"
    x-axis [32, 48, 64, 100, 256, 512, 1000, 10000, 100000, 300000, 1000000]
    y-axis "delta %" -10 --> 400
    line "Node 22" [3.5, 1.1, 1, -0.5, 0.2, 42.3, 16.8, 77.3, 83.5, 317.9, 391]
    line "Node 24" [0, 0, -0.4, 0, 0, 150.2, 154.7, 117.5, 80.4, 93.5, 48.7]
    line "Node 26" [1, -2.2, 0.9, -2.2, 0.3, 54, 69.2, 79.9, 65.7, 211.4, 330.2]
Loading
Node 22.23.3 (AMD EPYC 9V74)
kind size main ops/s branch ops/s delta
intnum 32 1751313 1751313 0.0%
intnum 48 1203369 1203369 0.0%
intnum 64 941620 908265 -3.5%
intnum 100 567215 567215 0.0%
intnum 256 232721 262812 12.9%
intnum 512 115969 129668 11.8%
intnum 1000 55565 68250 22.8%
intnum 10000 5677 8014 41.2%
intnum 100000 404 471 16.6%
intnum 300000 44 154 247.6%
intnum 1000000 13 66 416.8%
float 32 382555 379651 -0.8%
float 48 250250 250878 0.3%
float 64 192419 192382 -0.0%
float 100 121907 122070 0.1%
float 256 48189 88755 84.2%
float 512 24008 44377 84.8%
float 1000 12251 22621 84.6%
float 10000 1247 2358 89.1%
float 100000 112 209 86.8%
float 300000 25 70 186.5%
float 1000000 7 21 212.2%
integer 32 1692047 1751314 3.5%
integer 48 1203369 1216545 1.1%
integer 64 907441 916590 1.0%
integer 100 554631 551876 -0.5%
integer 256 221877 222370 0.2%
integer 512 85558 121773 42.3%
integer 1000 54741 63963 16.8%
integer 10000 4247 7532 77.3%
integer 100000 298 547 83.5%
integer 300000 44 185 317.9%
integer 1000000 13 63 391.0%
mixed 32 557724 557724 -0.0%
mixed 48 360490 356506 -1.1%
mixed 64 267738 270563 1.1%
mixed 100 172741 173370 0.4%
mixed 256 69483 125913 81.2%
mixed 512 34998 61671 76.2%
mixed 1000 18079 31212 72.6%
mixed 10000 1756 2858 62.8%
mixed 100000 125 222 77.9%
mixed 300000 26 70 168.5%
mixed 1000000 7 22 219.7%
Node 24.21.0 (AMD EPYC 7763)
kind size main ops/s branch ops/s delta
intnum 32 924214 924214 0.0%
intnum 48 627746 619963 -1.2%
intnum 64 466418 470810 0.9%
intnum 100 301568 299760 -0.6%
intnum 256 119104 250188 110.1%
intnum 512 58716 126984 116.3%
intnum 1000 31707 62504 97.1%
intnum 10000 3685 6630 79.9%
intnum 100000 337 491 45.6%
intnum 300000 111 180 61.8%
intnum 1000000 32 63 96.0%
float 32 297089 314861 6.0%
float 48 211461 211015 -0.2%
float 64 160720 160720 0.0%
float 100 102902 102902 0.0%
float 256 40507 74599 84.2%
float 512 20099 37594 87.0%
float 1000 9685 16972 75.2%
float 10000 968 1653 70.7%
float 100000 93 153 65.0%
float 300000 31 58 88.8%
float 1000000 7 17 149.8%
integer 32 941620 941620 0.0%
integer 48 627746 627746 0.0%
integer 64 375235 373832 -0.4%
integer 100 240500 240558 0.0%
integer 256 119689 119689 0.0%
integer 512 47103 117841 150.2%
integer 1000 24774 63091 154.7%
integer 10000 2760 6004 117.5%
integer 100000 261 471 80.4%
integer 300000 87 167 93.5%
integer 1000000 31 46 48.7%
mixed 32 445434 443656 -0.4%
mixed 48 295247 296121 0.3%
mixed 64 217912 216967 -0.4%
mixed 100 141383 141784 0.3%
mixed 256 58817 108719 84.8%
mixed 512 30155 53865 78.6%
mixed 1000 15150 27573 82.0%
mixed 10000 1465 2467 68.4%
mixed 100000 118 200 69.6%
mixed 300000 39 66 70.2%
mixed 1000000 7 20 194.0%
Node 26.12.0 (AMD EPYC 7763)
kind size main ops/s branch ops/s delta
intnum 32 1536098 1512859 -1.5%
intnum 48 1108647 1108647 0.0%
intnum 64 867303 868056 0.1%
intnum 100 580383 577034 -0.6%
intnum 256 237643 248324 4.5%
intnum 512 119818 128452 7.2%
intnum 1000 58955 70442 19.5%
intnum 10000 6115 7661 25.3%
intnum 100000 600 682 13.7%
intnum 300000 66 185 180.7%
intnum 1000000 15 57 275.1%
float 32 470810 470810 0.0%
float 48 327225 327225 0.0%
float 64 247097 247036 -0.0%
float 100 153092 159185 4.0%
float 256 63251 104297 64.9%
float 512 31348 52535 67.6%
float 1000 15610 27406 75.6%
float 10000 1544 2862 85.4%
float 100000 152 271 78.1%
float 300000 37 93 151.2%
float 1000000 8 28 263.8%
integer 32 1018330 1028807 1.0%
integer 48 1108647 1084599 -2.2%
integer 64 852515 860585 0.9%
integer 100 573394 560852 -2.2%
integer 256 228363 228938 0.3%
integer 512 78162 120395 54.0%
integer 1000 38929 65885 69.2%
integer 10000 3916 7047 79.9%
integer 100000 384 637 65.7%
integer 300000 55 172 211.4%
integer 1000000 15 66 330.2%
mixed 32 616143 619963 0.6%
mixed 48 412371 404204 -2.0%
mixed 64 309981 309981 0.0%
mixed 100 194212 199203 2.6%
mixed 256 85529 149880 75.2%
mixed 512 44107 74599 69.1%
mixed 1000 22112 39035 76.5%
mixed 10000 1870 3145 68.2%
mixed 100000 159 248 56.0%
mixed 300000 42 87 105.8%
mixed 1000000 7 24 255.3%
Node 20.20.2 (AMD EPYC 9V45) — measured with the 1e5 cutoff; 3e5 and above is what a8618e6 uses
kind size main ops/s branch ops/s delta
intnum 100000 491 443 -9.8%
intnum 300000 46 141 204.3%
intnum 1000000 13 40 219.5%
float 100000 131 172 31.7%
float 300000 24 54 127.2%
float 1000000 6 12 92.2%
integer 100000 526 433 -17.7%
integer 300000 43 146 239.2%
integer 1000000 13 39 208.6%
mixed 100000 110 210 90.5%
mixed 300000 25 70 183.3%
mixed 1000000 6 21 238.9%

Rows below 1e5 are all 0% ±4% (loop on both sides). The same run on an EPYC 7763 had 1e5 at +16%/+27%/+45%/+93%, hence 3e5.

Tests, lint, 100% coverage and the TypeScript checks pass on 20/22/24/26.

On splitting: happy to. The build() change is independent and I can open it as its own PR right away if that's the maintainers' preference.

@Tony133

Tony133 commented Oct 18, 2026

Copy link
Copy Markdown
Member

Thanks, looks good.

One thing: the NaN test uses the string 'not a number', so a real NaN element in a large number array is never tested. Could you add that case too?

NaN is typeof 'number', so only the v === v half of the fast-path
condition rejects it; the existing case used a non-numeric string and
never reached that branch. Check NaN at the start, middle and end.
@DanielMcSheehy

Copy link
Copy Markdown
Contributor Author

Good catch — a real NaN is typeof 'number', so it's the only input that reaches the v === v half of the fast-path condition; the string case never did. Added in 3c60b08: NaN at the start, middle and end of a large number array, each asserting the same The value "NaN" cannot be converted to a number. error as the loop. Kept the string case alongside it.

@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

@DanielMcSheehy

Copy link
Copy Markdown
Contributor Author

@Tony133 mind merging it if you get a chance? :) thanks

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

@Tony133
Tony133 merged commit 78e8abe into fastify:main Oct 19, 2026
17 checks passed
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