Skip to content

perf: faster serialize, stringify and deserialize, same output - #2

Open
genesiscz wants to merge 9 commits into
mainfrom
perf/faster-serialize
Open

genesiscz wants to merge 9 commits into
mainfrom
perf/faster-serialize

Conversation

@genesiscz

Copy link
Copy Markdown
Owner

Faster serialize, stringify and deserialize, with unchanged output.

On 200 benchmark cases, serialize is 3.5x faster, stringify 2.8x and deserialize 1.8x (geomean, unthrottled Chrome). Every case returns the same bytes as main, and the same values after deserializing.

Results

Unthrottled Chrome, 200 cases, one run. Speedup over main. Identical output in 200 of 200 cases.

Op Geomean Median Min Max Identical
serialize 3.54x 3.69x 1.01x 8.00x 40/40
stringify 2.75x 2.64x 0.99x 7.32x 40/40
deserialize 1.84x 1.58x 1.13x 6.05x 40/40
parse 1.08x 1.09x 0.96x 1.31x 40/40
roundtrip 2.84x 2.56x 1.46x 7.05x 40/40
all 2.23x 2.32x 0.96x 8.00x 200/200

Throttled Chrome (Brave at 3.79x CPU), 10 independent runs. Mean ± sd of ms per call. v2.2.3 is the release col-web uses. Every branch output is identical to main in every run.

Fixture Op origin/main v2.2.3 This branch Speedup vs main Identical CV of branch
deep nested dates (100×20) stringify 54.05 ± 1.22 54.49 ± 1.70 20.25 ± 0.84 2.67x yes 4.2%
deep nested dates (100×20) serialize 47.10 ± 0.91 47.73 ± 1.16 12.91 ± 0.35 3.65x yes 2.7%
deep nested dates (100×20) parse 20.60 ± 0.16 20.36 ± 0.23 15.69 ± 0.22 1.31x yes 1.4%
deep nested dates (100×20) deserialize 19.53 ± 0.25 19.99 ± 0.52 13.42 ± 0.46 1.45x yes 3.4%
1000 users with 2 Dates each stringify 24.26 ± 0.47 24.00 ± 0.44 8.39 ± 0.15 2.89x yes 1.8%
1000 users with 2 Dates each serialize 19.39 ± 0.34 19.33 ± 0.52 4.89 ± 0.10 3.97x yes 2.0%
1000 users with 2 Dates each parse 4.99 ± 0.17 4.99 ± 0.12 4.06 ± 0.13 1.23x yes 3.1%
1000 users with 2 Dates each deserialize 5.86 ± 0.13 5.76 ± 0.12 2.84 ± 0.04 2.06x yes 1.3%
shared refs: 1000 → 10 objects stringify 2.77 ± 0.05 2.76 ± 0.05 1.07 ± 0.01 2.60x yes 1.1%
shared refs: 1000 → 10 objects serialize 2.57 ± 0.03 2.56 ± 0.03 0.87 ± 0.02 2.94x yes 2.1%
shared refs: 1000 → 10 objects parse 0.80 ± 0.01 0.80 ± 0.01 0.77 ± 0.01 1.03x yes 1.1%
shared refs: 1000 → 10 objects deserialize 1.35 ± 0.01 1.36 ± 0.01 0.66 ± 0.01 2.03x yes 0.9%
tRPC-like page: 50 posts stringify 4.78 ± 0.09 4.87 ± 0.11 1.67 ± 0.01 2.87x yes 0.8%
tRPC-like page: 50 posts serialize 4.06 ± 0.12 4.08 ± 0.14 0.98 ± 0.02 4.15x yes 1.7%
tRPC-like page: 50 posts parse 1.50 ± 0.02 1.50 ± 0.02 1.22 ± 0.02 1.22x yes 1.5%
tRPC-like page: 50 posts deserialize 1.41 ± 0.01 1.41 ± 0.02 0.92 ± 0.01 1.53x yes 1.1%
RQ persisted cache ~400KB (col-web) stringify 34.63 ± 0.65 34.76 ± 0.57 8.09 ± 0.17 4.28x yes 2.1%
RQ persisted cache ~400KB (col-web) serialize 29.37 ± 0.55 29.55 ± 0.55 4.59 ± 0.09 6.39x yes 1.9%
RQ persisted cache ~400KB (col-web) parse 7.49 ± 0.15 7.42 ± 0.17 5.74 ± 0.15 1.30x yes 2.6%
RQ persisted cache ~400KB (col-web) deserialize 8.12 ± 0.09 8.20 ± 0.17 4.66 ± 0.06 1.74x yes 1.4%

Official benchmark.js on Node 26, 3 interleaved pairs. Median of ops/s.

Case origin/main median (ops/s) This branch median (ops/s) Speedup
toy example 320,347 771,962 2.41x
user graph 48,928 130,240 2.66x
deep nested 38.85 103.0 2.65x

Method: each run starts a fresh browser. A fixed JS loop measures the real CPU throttle before the run. Each case alternates between the versions in short rounds, so drift affects all of them alike. The harness and the commands are on the perf/benchmarks branch.

What changed

  • Type checks: isPrimitive and isSymbol no longer call Object.prototype.toString. transformValue dispatches by typeof, and empty registries are skipped.
  • Walker: paths are linked nodes and become arrays only for objects seen twice. Each visited object creates one record. Plain containers skip the built-in rules. Flattened annotation keys are joined once.
  • Paths: parsePath uses an indexOf loop when a key has no backslash. escapeKey and validatePath have fast paths.
  • Dates: toISOString is built in JS, and a strict ISO parser reads the value back. Anything outside the strict YYYY-MM-DDTHH:mm:ss.sssZ shape goes to the native call. date.test.ts compares both against the native functions on 60,000 inputs.
  • deserialize without inPlace: a local deep copy replaces copy-anything. It gives the same result as copy-anything 4.0.5.

Correctness

  • npm test passes on Node 18, 20, 22, 24 and 26: 88 passed, 1 skipped, 1 todo (main has 86 passing). Each commit passes on its own.
  • Equivalence against main: 20,000 random values and 40,000 malformed payloads. Output is identical on Bun and on Node.
  • copy-anything stays in dependencies, as on main. This branch no longer imports it. Removing it is a follow-up.

Behaviour note. copy-anything 4.0.5 and 4.1.x copy differently when the input to deserialize contains shared object references. package-lock.json pins 4.0.5, but consumers who installed 4.1.x got 4.1.x behaviour. The local copy follows 4.0.5 in every install, so the output no longer depends on the installed version. JSON that comes from parse has no shared references, so this matters only for objects passed to deserialize directly.

Open PRs ravionhq#344, ravionhq#351, ravionhq#352, ravionhq#355, ravionhq#360 and ravionhq#363 change some of the same files. This branch does not depend on them.

Related

Relates to ravionhq#68 ("Find Performance Bottleneck"). Covered here:

Not covered here: skipping the deep copy in deserialize (the copy is cheaper, not removed), and optional slow features (ravionhq#85).

isPrimitive runs on every value. Its last check, isSymbol, called
Object.prototype.toString(...).slice(8, -1) for every object. A typeof switch
plus a read of @@toStringTag gives the same answer: toString reports "Symbol"
for an object only through that tag. NaN stays non-primitive, as before.
…tePath

forEach no longer allocates an [key, value] pair per entry. escapeKey skips
both regex replaces when the key has no dot and no backslash. validatePath
scans the path once and only runs the three includes() checks (which pick
the error message) when a suspicious segment exists.
Without a backslash, both path modes reduce to splitting on dots. An
indexOf + slice loop does that without String#split, which V8 runs slowly on
short keys from JSON.parse.
transformValue tried 4 composite and 10 simple rules in a row for every value.
Each simple rule accepts one typeof only, so a switch on typeof keeps the same
order and the same result. The class, symbol and custom rules are skipped
when their registry is empty. A custom transformer is looked up once per
value instead of three times, from a cached list instead of Object.values().
The walker copied the whole path ([...path, index]) and the ancestor list
([...objectsInThisPath, object]) for every child, and kept two maps per
object (identities and seen). Now paths are linked nodes that become arrays
only for objects seen twice, one map holds identity and result, an
unfinished entry marks an ancestor (the cycle check), and annotations pass
through a context instead of a result object per value. The exported
walker() keeps its signature.
deserialize() without inPlace deep-copies the json first. copy-anything 4.0.5 calls
Object.prototype.toString twice per value (is-what) and reduce()s over getOwnPropertyNames and
getOwnPropertySymbols. The local copy gives the same result: arrays and plain objects are cloned
(enumerable own keys and symbols, __proto__ skipped), everything else is returned by reference.
copy-anything 4.1.x keeps shared references in the copy, so the output matches 4.0.5, the version
pinned in package-lock.json. This branch no longer imports copy-anything. Its entry in
package.json is unchanged.
Date#toISOString costs about 470 ns in V8; the same string built from
getUTC* calls costs about 128 ns. new Date(string) costs about 206 ns;
Date.UTC on the parsed digits costs about 151 ns. Both fall back to the
native call outside the strict YYYY-MM-DDTHH:mm:ss.sssZ shape with a
4-digit year and in-range fields. date.test.ts compares both against the
native functions on 60,000 inputs.
…ules

Each visited object allocated a path node, a seen entry, a paths array and a
result object. Now one Seen object is the first path node and holds the
cached result. Plain objects and arrays with Array.prototype skip the
typed-array and instanceof rules, because none of them can match.
A plain container copied every annotation of its children into its own
record with a longer key, so an annotation at depth d was copied d times
(about 17,000 copies for a 2,519-annotation React Query cache). Children
now hand up a pending list and keys are joined once, at the root or at
the nearest transformed ancestor. The list keeps object key order: array
indices ascending first, then insertion order.
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.

1 participant