From dde9ac7ff57b750b8384e0737fdeec91d6ab9195 Mon Sep 17 00:00:00 2001 From: Tony133 Date: Thu, 22 Oct 2026 10:10:33 +0200 Subject: [PATCH] perf: give plain string arrays their own copy-then-JSON.stringify cutoff --- README.md | 47 ++++++++++++------------ index.js | 15 ++++++-- test/string-array-cutoff.test.js | 62 ++++++++++++++++++++++++++++++++ types/index.d.ts | 3 +- 4 files changed, 100 insertions(+), 27 deletions(-) create mode 100644 test/string-array-cutoff.test.js diff --git a/README.md b/README.md index 9e30e754..8e709af9 100644 --- a/README.md +++ b/README.md @@ -625,13 +625,10 @@ In order to improve that the user can set the `largeArrayMechanism` and - `default` - This option keeps the full feature set of this lib: every element is still coerced to the type declared in `items`, and type errors are still -thrown. Arrays whose `items` schema is a plain `{ type: 'string' }` (no -`format`, `nullable`, `const`, or combinator keyword) are serialized at or -above `largeArraySize` by copying the elements and calling the native -`JSON.stringify` on the copy, which is several times faster than appending one -element at a time. If an element is not a string, that element and the ones -after it go through the regular per-element coercion, so the output is the -same as before. +thrown. Arrays of plain primitives (`string`, `number`, `integer`) use a +copy-then-`JSON.stringify` strategy with their own length cutoffs, see +[Primitive arrays](#primitive-arrays) below; `largeArraySize` does not affect +them. - `json-stringify` - This option will remove support for schema validation within **large arrays** completely. By doing so the overhead previously mentioned is nulled, greatly improving execution time. Mind there's no change @@ -646,27 +643,31 @@ integer-like values, such as: - `'2e4'` - _note this will be converted to `2`, not `20000`_ - `1.5` - _note this will be converted to `1`_ -##### Number and integer arrays + +##### Primitive arrays With `largeArrayMechanism: 'default'`, arrays whose `items` schema is a plain -`{ type: 'number' }` or `{ type: 'integer' }` (no `nullable`, `const`, `$ref`, -or combinator keyword) use the same copy-then-`JSON.stringify` strategy, but -with their own length cutoffs that do not depend on `largeArraySize`. The -cutoffs are the array lengths above which the copy beats the per-element loop, -and they depend on the V8 version: +`{ type: 'string' }`, `{ type: 'number' }` or `{ type: 'integer' }` (no +`format`, `nullable`, `const`, or combinator keyword; a `$ref` to a plain +string schema also qualifies, a `$ref` for `number`/`integer` does not) are +serialized by copying the elements into a fresh array and calling the native +`JSON.stringify` on the copy, which is several times faster than appending one +element at a time. This happens at or above a length cutoff that does not +depend on `largeArraySize`. The cutoffs are the array lengths above which the +copy beats the per-element loop, and they depend on the V8 version: -| Node.js | `number` | `integer` | -|---|---:|---:| -| 22 and later | 128 elements | 512 elements | -| 20 | 300000 elements | 300000 elements | +| Node.js | `string` | `number` | `integer` | +|---|---:|---:|---:| +| 22 and later | 8 elements | 128 elements | 512 elements | +| 20 | 128 elements | 300000 elements | 300000 elements | Below the cutoff the array is serialized element by element as before. Above -it, the elements are copied until one is found that is not already a `number` -(or not already an integer, for `integer`); that element and the ones after it -go through the regular per-element coercion and validation (`null` still -becomes `0`, `rounding` still applies, `NaN` still throws). The output is -byte-identical either way. Arrays of `boolean`, `null`, objects, or any other -`items` schema are not affected. +it, the elements are copied until one is found that is not already of the +declared type (not a string, not a `number`, or not an integer for `integer`); +that element and the ones after it go through the regular per-element coercion +and validation (`null` still becomes `""` or `0`, `rounding` still applies, +`NaN` still throws). The output is byte-identical either way. Arrays of +`boolean`, `null`, objects, or any other `items` schema are not affected. In [standalone mode](#standalone) the cutoffs are baked into the generated code, so the generated module inherits the cutoffs of the Node.js version that diff --git a/index.js b/index.js index d365375e..b119f937 100644 --- a/index.js +++ b/index.js @@ -27,12 +27,21 @@ const NAMED_FRAGMENT_REF = /^#[a-z_][-\w._]*$/i // Node 20: JSON.stringify only beats the loop for every element kind from // ~3e5 (1e5 is machine-dependent), so both kinds wait for 3e5 there. // +// Strings: the generated loop escapes each element in JS (asString), which +// is far slower than the native serializer, so the copy wins almost +// immediately. Node 22+: from 2 elements for every string length and content +// (2-6x faster from 8 elements on). Node 20: from 32 elements for short +// strings, but only from 128 for long (~120 chars) strings without escapes, +// so Node 20 waits for 128. Measured on Node 20, 22, 24 and 26 (arm64) with +// 8, 40 and 120 char strings, with and without characters to escape. +// // Booleans never win and keep the loop. The array lookup instead of a // ternary keeps both branches covered on every Node. // TODO: drop the Node 20 tier (and this version check) once Node 20 is no // longer supported. const nodeMajor = Number.parseInt(process.versions.node, 10) -const PRIMITIVE_FAST_PATH_MIN_LENGTH = [{ integer: 3e5, number: 3e5 }, { integer: 512, number: 128 }][+(nodeMajor >= 22)] +const PRIMITIVE_FAST_PATH_MIN_LENGTH = [{ integer: 3e5, number: 3e5, string: 128 }, { integer: 512, number: 128, string: 8 }][+(nodeMajor >= 22)] +const STRING_ARRAY_FAST_PATH_MIN_LENGTH = PRIMITIVE_FAST_PATH_MIN_LENGTH.string const INTEGER_ARRAY_FAST_PATH = { condition: 'Number.isInteger(v)', minLength: PRIMITIVE_FAST_PATH_MIN_LENGTH.integer } const NUMBER_ARRAY_FAST_PATH = { condition: 'typeof v === \'number\' && v === v', minLength: PRIMITIVE_FAST_PATH_MIN_LENGTH.number } @@ -1043,7 +1052,7 @@ function buildArray (context, location, input) { } else if (plainStringFastPath) { // Copy strings once; the helper resumes ordered coercion on the first miss. functionCode += ` - if (typeof arrayLength === 'number' && arrayLength >= ${context.largeArraySize} && Number.isInteger(arrayLength) && arrayLength <= 0xffffffff) { + if (typeof arrayLength === 'number' && arrayLength >= ${STRING_ARRAY_FAST_PATH_MIN_LENGTH} && Number.isInteger(arrayLength) && arrayLength <= 0xffffffff) { return asPlainStringArray(obj, arrayLength) } ` @@ -1169,7 +1178,7 @@ function buildArray (context, location, input) { } else if (plainStringFastPath) { inlinedCode += ` let fjsPlain_${objVar} - if (typeof arrayLength_${objVar} === 'number' && arrayLength_${objVar} >= ${context.largeArraySize} && Number.isInteger(arrayLength_${objVar}) && arrayLength_${objVar} <= 0xffffffff) fjsPlain_${objVar} = asPlainStringArray(${objVar}, arrayLength_${objVar}) + if (typeof arrayLength_${objVar} === 'number' && arrayLength_${objVar} >= ${STRING_ARRAY_FAST_PATH_MIN_LENGTH} && Number.isInteger(arrayLength_${objVar}) && arrayLength_${objVar} <= 0xffffffff) fjsPlain_${objVar} = asPlainStringArray(${objVar}, arrayLength_${objVar}) if (fjsPlain_${objVar} !== undefined) json += fjsPlain_${objVar} else { ` diff --git a/test/string-array-cutoff.test.js b/test/string-array-cutoff.test.js new file mode 100644 index 00000000..c7d8dc89 --- /dev/null +++ b/test/string-array-cutoff.test.js @@ -0,0 +1,62 @@ +'use strict' + +const { test } = require('node:test') +const build = require('..') + +// Plain string arrays use their own copy-then-JSON.stringify cutoff, independent +// of largeArraySize (which only drives largeArrayMechanism: 'json-stringify'). +const nodeMajor = Number.parseInt(process.versions.node, 10) +const CUTOFF = nodeMajor >= 22 ? 8 : 128 + +const schema = { type: 'array', items: { type: 'string' } } +const nested = { type: 'object', properties: { tags: schema } } +const strings = (n) => Array.from({ length: n }, (_, i) => 'item-' + i) + +test('string array cutoff does not depend on largeArraySize', t => { + t.plan(2) + const code = build(schema, { mode: 'debug', largeArraySize: 5000 }).code + t.assert.ok(code.includes(`arrayLength >= ${CUTOFF} `), 'uses the string cutoff') + t.assert.ok(!code.includes('arrayLength >= 5000'), 'ignores largeArraySize') +}) + +test('string arrays at and above the cutoff are byte-identical to JSON.stringify', t => { + const stringify = build(schema) + const lengths = [CUTOFF - 1, CUTOFF, CUTOFF + 1, CUTOFF * 10] + t.plan(lengths.length) + for (const n of lengths) { + t.assert.equal(stringify(strings(n)), JSON.stringify(strings(n))) + } +}) + +test('nested string arrays use the same cutoff', t => { + t.plan(2) + const code = build(nested, { mode: 'debug', largeArraySize: 5000 }).code + t.assert.ok(code.includes(`>= ${CUTOFF} `)) + t.assert.equal(build(nested)({ tags: strings(CUTOFF) }), JSON.stringify({ tags: strings(CUTOFF) })) +}) + +test('escapes and non-string elements above the cutoff keep per-element semantics', t => { + t.plan(4) + const stringify = build(schema) + const withEscapes = strings(CUTOFF).map((s, i) => i % 2 ? s + '"\n\u0000' : s) + t.assert.equal(stringify(withEscapes), JSON.stringify(withEscapes)) + const mixed = strings(CUTOFF) + mixed[1] = 42 + mixed[2] = null + // asString semantics: non-strings are coerced via toString, null becomes "" + t.assert.equal(stringify(mixed), JSON.stringify(mixed.map((v) => v === null ? '' : String(v)))) + const dates = strings(CUTOFF) + dates[0] = new Date(0) + t.assert.equal(stringify(dates), JSON.stringify(dates.map((v) => v instanceof Date ? v.toISOString() : v))) + const regexps = strings(CUTOFF) + regexps[3] = /a"b/ + regexps[5] = /c/g + t.assert.equal(stringify(regexps), JSON.stringify(regexps.map((v) => v instanceof RegExp ? v.source : v))) +}) + +test('largeArraySize still drives the json-stringify mechanism for strings', t => { + t.plan(2) + const code = build(schema, { mode: 'debug', largeArrayMechanism: 'json-stringify', largeArraySize: 5000 }).code + t.assert.ok(code.includes('arrayLength >= 5000')) + t.assert.ok(!code.includes('asPlainStringArray(obj')) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 5d874fc1..cb3d2b5a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -181,7 +181,8 @@ declare namespace build { /** * Large arrays are defined as arrays containing, by default, `20000` * elements or more. That value can be adjusted via the option parameter - * `largeArraySize`. + * `largeArraySize`. Only used with `largeArrayMechanism: 'json-stringify'`; + * arrays of plain primitives have their own, runtime-dependent cutoffs. * * @default 20000 */