json-as 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -29
- package/README.md +84 -33
- package/assembly/custom/chars.ts +39 -78
- package/assembly/deserialize/index/arbitrary.ts +26 -8
- package/assembly/deserialize/index/float.ts +2 -4
- package/assembly/deserialize/index/integer.ts +2 -4
- package/assembly/deserialize/index/object.ts +6 -1
- package/assembly/deserialize/index/string.ts +2 -7
- package/assembly/deserialize/index/unsigned.ts +2 -4
- package/assembly/deserialize/naive/array/integer.ts +1 -1
- package/assembly/deserialize/naive/array/map.ts +1 -1
- package/assembly/deserialize/naive/array/object.ts +1 -1
- package/assembly/deserialize/naive/array/struct.ts +19 -1
- package/assembly/deserialize/naive/bool.ts +1 -5
- package/assembly/deserialize/naive/date.ts +1 -2
- package/assembly/deserialize/naive/float.ts +2 -7
- package/assembly/deserialize/naive/integer.ts +1 -2
- package/assembly/deserialize/naive/map.ts +5 -6
- package/assembly/deserialize/naive/object.ts +151 -13
- package/assembly/deserialize/naive/raw.ts +1 -5
- package/assembly/deserialize/naive/set.ts +2 -4
- package/assembly/deserialize/naive/staticarray.ts +1 -2
- package/assembly/deserialize/naive/string.ts +6 -9
- package/assembly/deserialize/naive/unsigned.ts +1 -2
- package/assembly/deserialize/simd/array/integer.ts +2 -7
- package/assembly/deserialize/simd/float.ts +3 -5
- package/assembly/deserialize/simd/integer.ts +2 -7
- package/assembly/deserialize/simd/string.ts +5 -22
- package/assembly/deserialize/swar/array/arbitrary.ts +1 -2
- package/assembly/deserialize/swar/array/array.ts +1 -2
- package/assembly/deserialize/swar/array/bool.ts +1 -2
- package/assembly/deserialize/swar/array/box.ts +1 -2
- package/assembly/deserialize/swar/array/float.ts +6 -18
- package/assembly/deserialize/swar/array/generic.ts +1 -2
- package/assembly/deserialize/swar/array/integer.ts +7 -16
- package/assembly/deserialize/swar/array/map.ts +1 -2
- package/assembly/deserialize/swar/array/object.ts +1 -2
- package/assembly/deserialize/swar/array/raw.ts +1 -2
- package/assembly/deserialize/swar/array/shared.ts +6 -13
- package/assembly/deserialize/swar/array/string.ts +3 -8
- package/assembly/deserialize/swar/array/struct.ts +2 -8
- package/assembly/deserialize/swar/array.ts +1 -3
- package/assembly/deserialize/swar/float.ts +4 -9
- package/assembly/deserialize/swar/integer.ts +2 -7
- package/assembly/deserialize/swar/string.ts +13 -15
- package/assembly/deserialize/swar/typedarray.ts +4 -4
- package/assembly/index.d.ts +29 -24
- package/assembly/index.ts +1362 -246
- package/assembly/serialize/index/arbitrary.ts +70 -4
- package/assembly/serialize/index/jsonarray.ts +51 -0
- package/assembly/serialize/index/object.ts +25 -3
- package/assembly/serialize/index/string.ts +1 -2
- package/assembly/serialize/index/typedarray.ts +1 -2
- package/assembly/serialize/index.ts +1 -0
- package/assembly/serialize/naive/array.ts +23 -34
- package/assembly/serialize/naive/bool.ts +0 -1
- package/assembly/serialize/naive/float.ts +16 -25
- package/assembly/serialize/naive/integer.ts +1 -5
- package/assembly/serialize/naive/raw.ts +1 -2
- package/assembly/serialize/naive/set.ts +0 -4
- package/assembly/serialize/naive/staticarray.ts +0 -5
- package/assembly/serialize/naive/string.ts +2 -5
- package/assembly/serialize/naive/typedarray.ts +0 -6
- package/assembly/serialize/simd/string.ts +1 -3
- package/assembly/serialize/swar/string.ts +1 -2
- package/assembly/util/atoi-fast.ts +4 -14
- package/assembly/util/bytes.ts +1 -2
- package/assembly/util/idofd.ts +1 -2
- package/assembly/util/isSpace.ts +1 -2
- package/assembly/util/itoa-fast.ts +6 -9
- package/assembly/util/nextPowerOf2.ts +1 -2
- package/assembly/util/parsefloat-fast.ts +3 -5
- package/assembly/util/ptrToStr.ts +1 -2
- package/assembly/util/scanValueEndSimd.ts +54 -16
- package/assembly/util/scanValueEndSwar.ts +67 -25
- package/assembly/util/scientific.ts +5 -8
- package/assembly/util/snp.ts +1 -2
- package/assembly/util/swar-int.ts +5 -10
- package/assembly/util/swar.ts +2 -4
- package/lib/as-bs.ts +23 -45
- package/package.json +14 -7
- package/transform/lib/index.js +108 -64
- package/transform/lib/types.d.ts +2 -1
- package/transform/lib/types.js +3 -0
- package/assembly/util/dragonbox-cache.ts +0 -445
- package/assembly/util/dragonbox.ts +0 -652
package/CHANGELOG.md
CHANGED
|
@@ -1,24 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 2026-06-10 - v1.5.0
|
|
6
|
+
|
|
7
|
+
- perf(dynamic): **`JSON.Obj`, `JSON.Value`, and `JSON.Arr` are now lazy by default** - a near-alloc-less, simdjson On-Demand-style rework of dynamic parsing. `JSON.parse<JSON.Obj>` no longer eagerly materializes the whole tree: each nested value stores its raw source slice and is parsed only on first access (`.get<T>()` / `.getAs<T>()` / `.at(i)`), then cached - a value you never read is never parsed, and an untouched value re-serializes by copying its original source bytes verbatim. `JSON.Obj` is backed by a `StaticArray<u64>` value-slot buffer plus a length-prefixed key buffer (keys emit straight from their slice - no per-key string materialization), and a new buffer-backed `JSON.Arr` mirrors it (`.at(i)` → `JSON.Value`, `.getAs<T>(i)`, `.push<T>`, `.set<T>`, `.length`). Deferred composites reuse the NaN-boxed `JSON.Value` slot, and the SIMD/SWAR value scanners gained a vectorized composite (`{}`/`[]`) scan. Net for proxy / filter / forward workloads over large payloads: dynamic deserialize is several× faster with far fewer allocations, and untouched round-trips are byte-exact passthrough. A new `dynamic-interop` suite covers `Map`, `Date`, `JSON.Box`, `JSON.Raw`, and nested-struct interop
|
|
8
|
+
- perf(`JSON.Obj`): **faster dynamic key access.** `indexOf` now linear-scans objects with ≤ 6 keys (the common case) instead of allocating and hashing a key index - small objects pay no index build/probe cost, and a dynamic-access workload that touches only a few keys never builds an index it won't reuse; larger objects keep the open-addressed hash index. The per-lookup key comparison (`utf16Equals`, shared by the linear scan and the hash probe) is widened to 8 code units per step on the SIMD build (one `v128`) and 4 per step on naive/swar (one `u64`), each load bounded by the key length so it never over-reads. Warm long-key lookups **+37–133%**, short keys **+10–20%**, no cold-path change
|
|
9
|
+
- perf(swar): the string-field clean pre-scan (`deserializeStringField_SWAR`) tests its first 8-byte word before loading the second - short values and keys close or escape within that first word, so the common case skips the wasted load while a clean run still advances 16 bytes at a time (**+1.5%** on string-heavy eager deserialize)
|
|
10
|
+
- fix(dynamic): hardened the buffer-backed dynamic types against three latent faults found while validating the rework - (1) the open-addressed key index could fill to 100% and spin forever in `indexOf` (a 3rd `set` on a fresh object was enough); it's now invalidated past half load with a wrap-guarded probe. (2) `JSON.Obj.__visit` didn't trace the key-offset array (`_kpos`), so a GC between parse and access freed it and faulted on the next lookup. (3) `__INITIALIZE` seeded a ref lazy field's slot to the materialized state but not its value, so an _absent_ field with a declared default serialized `null as T` and trapped (hit by `github_events` / `gsoc-2018` lazy serialize). Also fixed the SWAR value scanner mis-reading an escaped backslash before a closing quote (`"x\\"`) as still-escaped, which over-scanned past the value and faulted
|
|
11
|
+
- test(dynamic): three robustness suites for the buffer-backed types, each verified to catch its bug class by reverting the fix - `gc-stress` (forces `__collect()` between parse and access across `JSON.Obj`/`Arr`/`Value`, large values, and nested peels; catches an untraced backing buffer), `objindex-property` (random set/delete/lookup vs a reference `Map` straddling the linear↔hash threshold; catches the index spin-loop), and `roundtrip-fuzz` (random valid JSON → `parse<JSON.Value>` → `stringify` must be byte-exact across all three scanners; caught the SWAR escape bug on its first run)
|
|
12
|
+
- feat: **`@optional` decorator** - annotate a field as optional for intent and tooling (documented in the README)
|
|
13
|
+
- deps: float serialization backend swapped to **`xjb-as`** (replaces the previous `zmij-as` dependency). The `dtoa_buffered` / `ftoa_buffered` writer API is byte-identical - UTF-16 written straight into the `bs` buffer, returning the code-unit count - so the swap is a drop-in across the value (`assembly/index.ts`), scalar (`serialize/naive/float.ts`), and array (`serialize/naive/array.ts`) serialize paths; `xjb-as` is the productized successor of the same xjb64 / Schubfach shortest-decimal core. Verified across the full suite (10,767 tests, all three modes) plus a `canada.json` round-trip (`JSON.stringify(JSON.parse(canada)) === canada.min.json`, byte-for-byte) on both the SIMD and SWAR digit kernels
|
|
14
|
+
- bench: object throughput gains **eager-vs-lazy** coverage. `assembly/__benches__/throughput/obj-{serialize,deserialize}.lazy.bench.ts` mirror the eager sweeps with `@json({ lazy: "auto" })` classes (dumped as `obj-lazy-*`): lazy deserialize measures the scan-and-record-ranges fast path (deferred fields never read), lazy serialize measures the raw-passthrough path (parse once, then re-serialize untouched). Wired into `scripts/build-chart{09,10}.ts` as a dashed AS-only series (JS has no lazy mode), and into the per-payload bars of `scripts/build-chart{01,02}.ts` as a distinct faded-copper bar at 0.8 fill opacity to flag that lazy defers work (not strictly apples-to-apples; only the 5 payloads with a `*.lazy.bench.ts` get a bar)
|
|
15
|
+
- bench: sub-1MB throughput sweeps step every **100 kB** instead of 50 kB (1 kB baseline + 100–900 kB + 1–10 MB) - half the sub-1MB points, same coverage shape. Applied across the AS benches (`assembly/__benches__/throughput/{str,obj}-{serialize,deserialize}.bench.ts`), their JS counterparts (`bench/throughput/`), and the chart payload lists (`scripts/build-chart{07,08,09,10}.ts`)
|
|
16
|
+
- fix(bench): restore the lazy **access-pattern** benchmark dropped when `lazy.bench.ts` was split into per-concern files. `assembly/__benches__/lazy/access-pattern.bench.ts` re-emits the `lz-access` suite (eager read-all baseline + lazy read-none / read-one / read-all / passthrough on a medium struct) that `scripts/build-chart15.ts` reads for `lazy-access-pattern.svg` - the chart had been aborting with ENOENT on `lz-access.eager`
|
|
17
|
+
- tooling: `bun run playground:tmp` builds and runs the transform-generated `assembly/playground.tmp.ts` directly (no transform) under the v8 bench runner, for hand-tuning the generated codec. `assembly/playground.ts` is now a fast-path (non-lazy, eager) deserialize micro-bench - a direct `__DESERIALIZE_FAST` into a reused object with a min-over-rounds timer
|
|
18
|
+
- tooling: `npm run bench:all` (`scripts/bench-all.sh`) runs the full benchmark matrix in one shot - root files plus `multilib/`, `throughput/`, `prim/` (AS + JS) and `classic/`, `lazy/` (AS-only) - forwarding flags (`--mode`, `--v8`, `--wavm`) to the AS runner and continuing past a failing category (non-zero exit if any failed). `npm run charts:publish` (`scripts/publish-benchmarks.sh`) now publishes by default even with a dirty/untracked working tree - chart output only ever commits to a separate `docs` worktree, never your main tree - with `PUBLISH_REQUIRE_CLEAN=1` to restore the old refuse-if-dirty guard
|
|
19
|
+
- fix(lazy/dynamic): a `null` value for a **nullable string** field on the lazy/dynamic path was mis-decoded. `JSON.__deserialize` (used by lazy-field materialization, `JSON.parse<JSON.Value>`, and `JSON.Obj`/`Arr` value slots) tested `isString<T>()` - which is `true` for `string | null` - before the `null`-literal check, so a bare `null` was parsed as a quoted string: an abort (`Invalid JSON string: missing surrounding quotes`) under NAIVE, silent garbage under SWAR/SIMD. The `null` check now precedes the string branch, matching the eager `parseInternal` path. Surfaced as a crash materializing absent-as-`null` string fields in the `classic/citm_catalog.lazy` benchmark
|
|
20
|
+
- perf(dynamic): **re-serializing a dynamic string emits via `memory.copy` when it needs no escaping.** A materialized `JSON.Value` / `JSON.Obj` / `JSON.Arr` string caches a 2-bit escape class in two otherwise-unused bits of its NaN-box payload (a wasm32 pointer is 32 of the 45 payload bits); it's classified once on first serialize, then reused - AS strings are immutable, so the verdict never goes stale. A clean string then round-trips as `"` + one `memory.copy` + `"` instead of a per-character escape scan: **~3×** faster re-serialize (3.8 → 11.5 GB/s on a 1 MB string). `JSON.Obj`/`Arr` persist the class back into their flat value slot so the win carries across re-serializes; typed struct `string` fields (no box to cache in) are unchanged
|
|
21
|
+
- perf(dynamic): rebalanced the lazy value-slot packing from **22/22 to 23/21 bits** (offset/length). Object/array fields are usually small while the document can be large, so offset overflow - a field late in a big doc - is the realistic trigger; widening the offset field lifts the compact (no-rescan) range from ~8 MB to **~16 MB** of source, at the cost of single field values over ~4 MB falling back to the existing absolute (scan-on-demand) slot form
|
|
22
|
+
- test(dynamic): `dynamic-string-class` (clean / escaped / surrogate / empty strings round-tripped through `JSON.Value` and re-serialized, plus `JSON.Obj`/`Arr` slot-class caching) and `lazy-slot-encoding` (the packed slot's compact↔absolute boundary, previously untested) suites
|
|
23
|
+
- bench/docs: benchmark chart scripts renamed from `chartNN` to descriptive names (`overview-` / `string-` / `object-` / `primitive-` / `library-{serialize,deserialize}`); every chart now emits both SVG and PNG; the README switched fully to SVG with the real-world payload charts promoted to the top of the Performance section and a "browse the full chart set" link that the publish script re-pins per release. String throughput charts gained a `JSON.Value` (dynamic) series, and the classic charts got vertical value labels
|
|
24
|
+
|
|
3
25
|
## 2026-06-05 - v1.4.0
|
|
4
26
|
|
|
5
|
-
- feat(lazy-fields): **on-demand (lazy) field parsing.** Mark a field `@lazy`, wrap its type as `JSON.Lazy<T>`, or set a class-wide default with `@json({ lazy: "auto" | "all" })` (and opt individual fields back out with `@eager`). A lazy field stores its raw JSON slice at parse time and parses it into the field's type only on first access (then caches); a field you never read is never parsed, and an untouched field round-trips by copying its original source bytes
|
|
6
|
-
- perf(transform): the generated fast-path deserializer (`__DESERIALIZE_FAST`) is split into ~32-field chunk helper methods instead of one unrolled function
|
|
27
|
+
- feat(lazy-fields): **on-demand (lazy) field parsing.** Mark a field `@lazy`, wrap its type as `JSON.Lazy<T>`, or set a class-wide default with `@json({ lazy: "auto" | "all" })` (and opt individual fields back out with `@eager`). A lazy field stores its raw JSON slice at parse time and parses it into the field's type only on first access (then caches); a field you never read is never parsed, and an untouched field round-trips by copying its original source bytes - never re-parsed or re-serialized. `auto` defers the expensive-to-parse fields (strings, arrays, maps/sets, `JSON.Value`/`Obj`/`Raw`, non-trivial nested structs) and keeps cheap ones eager (primitives, enums, `Date`, tiny all-scalar structs); `all` defers every field (best for proxy / filter / forward over large payloads). Skipping or forwarding fields is several× faster than eager and the win grows with payload size. The slot is a single `u64` - `0` = absent, `u64.MAX_VALUE` = materialized, otherwise a packed `(start<<32)|end` source range; a ≤32-bit scalar packs its value into the slot directly (no traced memo field). `@omitnull` / `@omitif` work on lazy fields (null-ness is read from the slot without materializing). A class with a custom `@serializer`/`@deserializer` can't have lazy fields (the transform reports an error). See the Lazy Fields guide for the full API and trade-offs
|
|
28
|
+
- perf(transform): the generated fast-path deserializer (`__DESERIALIZE_FAST`) is split into ~32-field chunk helper methods instead of one unrolled function - for both the straight-line tiers and the `seenAny`-stateful `@omitnull`/`@omitif` tiers (the latter threads `seenAny` across chunk boundaries through the call ABI, packed into the `u64` return alongside `srcStart`). A wide struct previously emitted a single function large enough to crash the Binaryen optimizer ("crashed during optimize") a few hundred fields in; chunked, the fast path stays compilable at any width (verified to 1000 fields) and optimizes faster, since Binaryen's per-function passes are superlinear
|
|
7
29
|
- perf(codegen): dropped `@inline` from the (de)serialize worker functions and removed every `inline.always()` call-site directive. They forced each worker to be copied into every dispatch / call site; as plain shared calls the generated module shrinks (~18% on the all-lazy `large` struct) and the Binaryen `optimize` phase eases. Dispatchers and genuinely small helpers keep `@inline`
|
|
8
|
-
- fix(lazy-fields): the lazy serialize path now `ensureSize`s before the structural key literals, not just the value passthrough. A wide struct (~150 fields, long keys) could overrun the output buffer once a passthrough left it nearly full
|
|
30
|
+
- fix(lazy-fields): the lazy serialize path now `ensureSize`s before the structural key literals, not just the value passthrough. A wide struct (~150 fields, long keys) could overrun the output buffer once a passthrough left it nearly full - `JSON.stringify(JSON.parse<T>(...))` trapped with `unreachable`. The passthrough reserves exactly the slice bytes (unlike eager string serialize, which over-reserves for quotes/escaping), so the key writes that follow needed their own bounds check
|
|
9
31
|
- fix(lazy-fields): a *constructed* (not parsed) instance keeps its declared field defaults. Lazy lowering replaced each field with a slot and dropped its initializer, so `new T()` serialized the type default (`null` / `0`) instead of e.g. `"x"` from `name: string = "x"`; the slot is now seeded in the materialized-default state. `JSON.parse` (which builds via `__new`, skipping field initializers) is unaffected
|
|
10
|
-
- fix(serialize): `@omitnull` / `@omitif` fields emit a **leading** comma gated on a runtime "wrote" flag, not a trailing one. Optional fields sort to the front, so a present field followed by omitted ones
|
|
32
|
+
- fix(serialize): `@omitnull` / `@omitif` fields emit a **leading** comma gated on a runtime "wrote" flag, not a trailing one. Optional fields sort to the front, so a present field followed by omitted ones - or an all-optional struct - left a dangling trailing comma, i.e. invalid JSON like `{"a":"1",}`. Added a regression test (first / last / gap / all-absent / all-present, plus the mixed-with-regular case)
|
|
11
33
|
- feat: `JSON.Value` moved to a NaN-boxed representation, and its value scanning now runs through the SWAR / SIMD scanners (~60% faster on value-heavy input)
|
|
12
34
|
- bench: committed eager-vs-lazy benchmarks under `assembly/__benches__/lazy/` (deserialize / round-trip / serialize) + `lazy:"auto"` variants of small/medium/large/token/vec3, and a `lazy` json-as struct in the multi-library comparison (`__benches__/multilib/`); charting in `scripts/build-chart15.ts` and the multilib charts
|
|
13
35
|
- docs: Lazy Fields guide + performance charts (eager-vs-lazy by payload size and a multi-library throughput comparison), full JSDoc for the decorators in `assembly/index.d.ts`, and a single shared chart colour palette (`scripts/lib/palette.ts`)
|
|
14
36
|
|
|
15
|
-
- feat: `JSON.parse<T>(data, out)`
|
|
16
|
-
- feat: `JSON.stringify<T>(data, out)` now reuses `out` for composite values (structs, arrays, strings), not just the scalar/`Date` fast paths. The reference serialize branches route their final write through a new `bs.outTo<T>(target)` that overwrites the existing string in place
|
|
17
|
-
- perf: the fast path (`__DESERIALIZE_FAST`) is now generated for **NAIVE** mode. It was disabled for naive (`requestedFastPath = USE_FAST_PATH && codegenMode !== JSONMode.NAIVE`), so naive always ran `__DESERIALIZE_SLOW` after `__INITIALIZE` had reallocated every field
|
|
37
|
+
- feat: `JSON.parse<T>(data, out)` - deserialize into an existing object graph instead of allocating a fresh one, symmetric with the existing `JSON.stringify<T>(data, out)`. On the fast path every field is reused in place (nested structs threaded as `dst`, strings `__renew`d only when the byte length changes, arrays keeping capacity), so a steady-state re-parse of the same shape allocates **nothing** after the first call - ~4× on SWAR (~720k → ~2.8M ops/s) and ~4.5× on SIMD on the multilib struct payload, heap `totalDelta` 1,034,240 B → 0. Backward compatible: `out` defaults to a type-correct zero via `__zero<T>()` (branches on `isReference`/`isManaged` at compile time, since a bare `changetype<T>(0)` is a size mismatch for value types like `bool`/`f64`)
|
|
38
|
+
- feat: `JSON.stringify<T>(data, out)` now reuses `out` for composite values (structs, arrays, strings), not just the scalar/`Date` fast paths. The reference serialize branches route their final write through a new `bs.outTo<T>(target)` that overwrites the existing string in place - or `__renew`s when the length differs - instead of `__new`-ing a fresh one via `bs.out`. Zero-alloc serialize on reuse: SWAR ~2.31M → ~2.61M ops/s (~+13%), heap `totalDelta` 882,000 B → 0
|
|
39
|
+
- perf: the fast path (`__DESERIALIZE_FAST`) is now generated for **NAIVE** mode. It was disabled for naive (`requestedFastPath = USE_FAST_PATH && codegenMode !== JSONMode.NAIVE`), so naive always ran `__DESERIALIZE_SLOW` after `__INITIALIZE` had reallocated every field - which meant `parse(data, out)` reuse reallocated the whole graph in naive. Enabling it is purely additive (the slow path remains the fallback for whitespace / reordered / missing-key input): naive fresh parse ~2× (~314k → ~644k ops/s), naive reuse ~6.7× (~316k → ~2.1M) with nested structs/arrays/strings now reused in place. Main suite (10,590) and RFC suite unchanged across all three modes
|
|
18
40
|
- perf: escape-free string **fields** skip the scratch-buffer round-trip in NAIVE. `deserializeStringField_NAIVE` previously copied every char into `bs` then `bs`→field; it now scans for the closing quote without touching `bs` and copies source→field directly when no backslash is present (matching the SWAR/SIMD field paths), diverting to a `bs`-based decode tail only for escaped strings. ~+6% naive fresh parse; SWAR/SIMD already did this
|
|
19
|
-
- fix(build): drop `@inline` from `deserializeStringField_NAIVE`. Once the fast path was enabled for naive (above), this loop-bearing scanner got inlined into every string-field call site inside the `@inline __DESERIALIZE_FAST`, exploding binaryen's optimize phase
|
|
20
|
-
- test: `assembly/__tests__/parseinto.spec.ts`
|
|
21
|
-
- bench: `json-as-struct-{deserialize,stringify}-reuse.bench.ts` under `__benches__/multilib/`
|
|
41
|
+
- fix(build): drop `@inline` from `deserializeStringField_NAIVE`. Once the fast path was enabled for naive (above), this loop-bearing scanner got inlined into every string-field call site inside the `@inline __DESERIALIZE_FAST`, exploding binaryen's optimize phase - `bun run bench:as -- large --mode naive` went to ~118 s in `optimize` alone (the transform itself is ~23 ms). Kept as a single shared function - one call per field - matching the already-non-inline SWAR/SIMD field deserializers. naive `large` build **120.6 s → 8.6 s** asc (full repro 2:17 → ~13.5 s), and it's also ~12% *faster* at runtime (the bloated inline body optimized worse). `__DESERIALIZE_FAST` itself stays `@inline`
|
|
42
|
+
- test: `assembly/__tests__/parseinto.spec.ts` - reuse correctness across all three modes: `parse(data, out)` fully overwrites the target (with pointer-identity asserts that nested structs and arrays are reused, not reallocated), and `stringify(data, out)` reuses the output string in place (same length) and resizes correctly (different length)
|
|
43
|
+
- bench: `json-as-struct-{deserialize,stringify}-reuse.bench.ts` under `__benches__/multilib/` - reuse-path counterparts to the fresh benches, used with `--memory` to confirm the zero-allocation steady state
|
|
22
44
|
|
|
23
45
|
## 2026-06-01 - v1.3.9
|
|
24
46
|
|
|
@@ -26,33 +48,33 @@
|
|
|
26
48
|
|
|
27
49
|
## 2026-06-01 - v1.3.8
|
|
28
50
|
|
|
29
|
-
- feat(strict): the **naive value-path deserializers now reject malformed JSON** instead of silently accepting it (RFC 8259). Numbers (`deserializeFloat_NAIVE`) are validated against the JSON number grammar
|
|
30
|
-
- feat(strict): under `JSON_STRICT=true` the generated `__DESERIALIZE_SLOW` object scanner now rejects malformed key positions
|
|
31
|
-
- test: RFC 8259 accept-side coverage is now complete
|
|
32
|
-
- fix: tier-1 (the exact byte-template fast path) over-matched whitespace after a colon. For input like `{"k": v}` (a space after the colon but otherwise compact
|
|
33
|
-
- fix: add `bs.reset()` to `lib/as-bs.ts`
|
|
51
|
+
- feat(strict): the **naive value-path deserializers now reject malformed JSON** instead of silently accepting it (RFC 8259). Numbers (`deserializeFloat_NAIVE`) are validated against the JSON number grammar - no leading zeros, bare `-`, empty fraction/exponent, `+` sign, hex, or trailing garbage (the `NaN`/`Infinity` extension is preserved). Strings (`deserializeString_NAIVE`) reject unescaped control chars, illegal/incomplete escapes, non-hex `\u`, and missing surrounding quotes. The scalar/typed **array scanners** (`f64[]`/`i*[]`/`u*[]`/`bool[]`/`string[]`) were rewritten as strict state machines: `[`-framed, single-comma separated, no leading/trailing/doubled commas, no inter-value garbage. On the JSONTestSuite `n_` corpus (parsed against concrete types, one spec per case in `assembly/__tests__/rfc/`), naive now **rejects 179/188** reject-cases - up from 55 - the remaining 9 being the intentional `NaN`/`Infinity` extension (5) plus arbitrary-`JSON.Value`/formfeed/struct-trailing edges (4). Non-object rejects compile to uncatchable aborts until try-as traces the value path; object rejects (struct `__DESERIALIZE_SLOW`) are catchable today. Main suite (10,557) and curated rfc suite (801) stay green across all three modes
|
|
52
|
+
- feat(strict): under `JSON_STRICT=true` the generated `__DESERIALIZE_SLOW` object scanner now rejects malformed key positions - an unquoted / single-quoted / numeric key, or garbage after a value with no separating comma - by requiring the first non-space char at each key position to be a string-opening quote, `,`, or `}` (`transform/src/index.ts`). Combined with the existing strict unknown-key-by-value-type guards, this brings RFC 8259 `n_object` coverage to **28/28** (all three modes) in the conformance suite (`assembly/__tests__/rfc/struct-reject.spec.ts`, parsed against one rich all-value-type schema so every guard is generated). Gated on `JSON_STRICT`, so the default lenient build is unchanged
|
|
53
|
+
- test: RFC 8259 accept-side coverage is now complete - all **95** `y_` (must-accept) cases parse via the dynamic `JSON.Value` type (`assembly/__tests__/rfc/accept.spec.ts`) and **31/35** `i_` (impl-defined) cases (`impl.spec.ts`), all green across NAIVE/SWAR/SIMD. The 4 deferred `i_` cases are uncatchable traps (raw UTF-16/BOM byte input, depth-500 nesting). RFC suite now 804 tests across 3 modes
|
|
54
|
+
- fix: tier-1 (the exact byte-template fast path) over-matched whitespace after a colon. For input like `{"k": v}` (a space after the colon but otherwise compact - e.g. Python's `json.dumps` default), tier-1 matched the minified `{"k":` prefix then read the value at a fixed offset that the space shifted, feeding a misaligned pointer to the value deserializer - string fields `abort`ed ("Expected leading quote") and float fields hit `unreachable`. Tier-1 now checks for whitespace after the colon and bails to the (whitespace-tolerant) tier-2 instead of mis-reading. Surfaced via the RFC 8259 conformance work
|
|
55
|
+
- fix: add `bs.reset()` to `lib/as-bs.ts` - restores the shared serialization buffer to a clean state (offset + pause stacks) after a throw aborts a (de)serialize mid-flight, so the next op isn't corrupted by a dangling offset
|
|
34
56
|
- test: begin RFC 8259 conformance coverage from nst/JSONTestSuite as typed specs (`assembly/__tests__/rfc-object.spec.ts`, accept/round-trip cases; see `RFC-DEFERRED.md` for status and the reject-case blocker)
|
|
35
57
|
|
|
36
|
-
- perf: pretty-printed JSON now stays on the fast path instead of collapsing to the naive scalar deserializer (the "canada 10×"). The transform's `__DESERIALIZE_FAST` gained a whitespace-tolerant **tier 2**: when the exact packed-`u64` byte-template (tier 1) misses on inter-token whitespace, instead of dumping the whole object to `__DESERIALIZE_SLOW` (~5× slower) it re-matches per field using the same packed key constants but skips whitespace between every structural token (`{`, key, `:`, value, `,`, `}`). Minified input never reaches tier 2, so tier 1 keeps peak speed. canada *pretty* deserialize **~139 → ~1,180 MB/s (SWAR)** / **~1,150 MB/s (SIMD)**
|
|
37
|
-
- fix: `JSON.parse` / `JSON.__deserialize` accepted a fast-path result only when it consumed *exactly* to `srcEnd`. Pretty files end in a newline, so tier 2 stopped just past the closing `}` and the **entire object silently fell back to the slow path despite a successful parse**
|
|
38
|
-
- perf: optional-field structs (`@omitnull` / `@omitif`) get a whitespace-tolerant tier 2 as well
|
|
58
|
+
- perf: pretty-printed JSON now stays on the fast path instead of collapsing to the naive scalar deserializer (the "canada 10×"). The transform's `__DESERIALIZE_FAST` gained a whitespace-tolerant **tier 2**: when the exact packed-`u64` byte-template (tier 1) misses on inter-token whitespace, instead of dumping the whole object to `__DESERIALIZE_SLOW` (~5× slower) it re-matches per field using the same packed key constants but skips whitespace between every structural token (`{`, key, `:`, value, `,`, `}`). Minified input never reaches tier 2, so tier 1 keeps peak speed. canada *pretty* deserialize **~139 → ~1,180 MB/s (SWAR)** / **~1,150 MB/s (SIMD)** - on par with minified; on a value-heavy struct tier 2 costs ~1% over tier 1 (worst case ~2× on a 16-field all-primitive struct). The SWAR array bodies (`deserializeArrayArrayBody`, `deserializeFloatArrayBody`, the inline string/object array element loops) were made whitespace-tolerant to match
|
|
59
|
+
- fix: `JSON.parse` / `JSON.__deserialize` accepted a fast-path result only when it consumed *exactly* to `srcEnd`. Pretty files end in a newline, so tier 2 stopped just past the closing `}` and the **entire object silently fell back to the slow path despite a successful parse** - this was the real cause of the pretty penalty (tier 2 alone only moved canada 139 → 220 MB/s; this fix took 220 → ~1,180). The fast path is now accepted when only trailing whitespace remains (`skipWhitespace(fastEnd, srcEnd) == srcEnd`)
|
|
60
|
+
- perf: optional-field structs (`@omitnull` / `@omitif`) get a whitespace-tolerant tier 2 as well - a probe-and-commit variant that matches each field's key with a lookahead cursor and only commits past the separator + key + `:` when it matches, so omitted fields are skipped without consuming input. Previously these structs fell to slow on *any* whitespace. ~1.18× of tier-1 on a pretty 7-field optional struct, vs the ~5× slow path it used to hit
|
|
39
61
|
- fix: enforce a uniform whitespace contract across **every** deserialize handler in all three modes (field and non-field): the entry points (`JSON.parse` / `JSON.__deserialize`) skip leading whitespace once, and every handler now assumes `srcStart` points at the first non-whitespace char and never re-skips it. Removed ~17 now-redundant leading-whitespace trims from composite handlers (naive `object`/`map`/`set` + array/staticarray composites; SWAR array bodies); composites still skip whitespace *internally* since they are the caller for their child values/keys
|
|
40
|
-
- fix: Map and Set struct **fields** did not handle internal whitespace
|
|
41
|
-
- fix: `scanValueEnd` (both `util/scanValueEnd.ts` and `swar/array/shared.ts`) now stops a scalar value at trailing whitespace, not just `,`/`]`/`}`. The returned range previously included trailing spaces (e.g. `"1 "`), which the SWAR/SIMD scalar number parsers
|
|
42
|
-
- bench: add `assembly/__benches__/custom/tier-h2h.bench.ts`
|
|
43
|
-
- test: add concrete-struct whitespace coverage to `whitespace.spec.ts`
|
|
44
|
-
- perf: fully vectorize the SIMD string **field** deserializer. `deserializeStringField_SIMD` previously bailed to `deserializeStringField_SWAR` on the first backslash, so SIMD mode got zero vectorization on escaped struct fields. It now runs a HYBRID escaped scanner
|
|
45
|
-
- perf: replace the SWAR string **field** scanner (formerly the run-copy `deserializeEscapedStringScan_SWAR_SplitTuned`) with the same HYBRID strategy, renamed `deserializeEscapedStringField_SWAR`. On the `swar-string-deser-hybrid-h2h` bench: +50–70% dense, +17–22% moderate, +37–48% sparse
|
|
46
|
-
- perf/fix: the standalone whole-value scanners (`deserializeString_SWAR` / `deserializeString_SIMD`) also move to the HYBRID strategy, replacing the older "overflow-pattern" SIMD code. Also fixes a latent out-of-bounds read on short escaped strings
|
|
62
|
+
- fix: Map and Set struct **fields** did not handle internal whitespace - `deserializeMapBody` had no inter-token skips at all, and `deserializeSetDirect`'s skips were gated behind an `allowWhitespace` flag the field path left `false`. Concrete structs with `Map`/`Set` fields parsed from pretty input silently misparsed (only the generic-box slow path that existing tests exercised handled it). Both now skip whitespace unconditionally
|
|
63
|
+
- fix: `scanValueEnd` (both `util/scanValueEnd.ts` and `swar/array/shared.ts`) now stops a scalar value at trailing whitespace, not just `,`/`]`/`}`. The returned range previously included trailing spaces (e.g. `"1 "`), which the SWAR/SIMD scalar number parsers - assuming `[srcStart,srcEnd)` is the exact value - misparsed (a whitespaced map value `{ "p" : 1 }` deserialized to `-6`). Now matches the already-correct `JSON.Util.scanValueEnd`
|
|
64
|
+
- bench: add `assembly/__benches__/custom/tier-h2h.bench.ts` - tier-1 (minified) vs tier-2 (one leading space, isolating pure path overhead) vs tier-2 (fully pretty), across a value-heavy `medium` struct, a 16-field all-primitive worst case, and an optional-field struct (with a non-optional twin to isolate the `seenAny` tier-1 overhead)
|
|
65
|
+
- test: add concrete-struct whitespace coverage to `whitespace.spec.ts` - tier-2 fast-path structs (nested objects, float/string/object arrays), the optional-field probe path, top-level leading whitespace for every scalar/array type, and one struct touching every field-handler family (scalar/string/nested-object/array/Map/Set) parsed from heavily-whitespaced (leading + internal + trailing) input
|
|
66
|
+
- perf: fully vectorize the SIMD string **field** deserializer. `deserializeStringField_SIMD` previously bailed to `deserializeStringField_SWAR` on the first backslash, so SIMD mode got zero vectorization on escaped struct fields. It now runs a HYBRID escaped scanner - v128 scan for `"`/`\`; escape-bearing blocks use a single whole-block v128 store to copy the plain prefix for free, then decode the escape; clean runs stream the first block then bulk-`memcpy` the remainder (bandwidth-optimal on long sparse runs, no large-input cliff). Validated against run-copy and pure-stream variants across escape densities; ~+10–20% on common small/moderate/sparse cases
|
|
67
|
+
- perf: replace the SWAR string **field** scanner (formerly the run-copy `deserializeEscapedStringScan_SWAR_SplitTuned`) with the same HYBRID strategy, renamed `deserializeEscapedStringField_SWAR`. On the `swar-string-deser-hybrid-h2h` bench: +50–70% dense, +17–22% moderate, +37–48% sparse - the prior run-copy scanner read each plain run twice (scan + `memcpy`)
|
|
68
|
+
- perf/fix: the standalone whole-value scanners (`deserializeString_SWAR` / `deserializeString_SIMD`) also move to the HYBRID strategy, replacing the older "overflow-pattern" SIMD code. Also fixes a latent out-of-bounds read on short escaped strings - the unguarded `srcEnd - 16` underflow is now guarded with `srcEnd >= 16`
|
|
47
69
|
- refactor: route string-field deserialization through the `deserialize/index/string.ts` dispatcher (`deserializeStringField`), matching the integer/unsigned/float field helpers, instead of the transform hard-coding `deserializeStringField_SWAR` / `_SIMD` by `codegenMode`. Adds a real `deserializeStringField_NAIVE` (NAIVE builds previously deserialized struct string fields with the SWAR field scanner) and unifies all three mode variants on the 3-param `(srcStart, srcEnd, dstFieldPtr)` signature
|
|
48
70
|
- refactor: rename the `simple/` (de)serialize implementation directories to `naive/` and carry the mode suffix on the source functions (`*_NAIVE` / `*_SWAR` / `*_SIMD`), eliminating the `as X_NAIVE` import aliases at call sites
|
|
49
71
|
- fix(build): drop `@inline` from `deserializeStringField_SIMD`. As an `@inline` entry it let binaryen inline the loop-bearing escaped scanner into every struct string-field call site, exploding `large` SIMD compile ~24× under `--converge` (4 s → 99 s, 221 KB → 555 KB wasm). Kept as a single shared function (matching the already-non-inline `deserializeStringField_SWAR`); `large` SIMD build is back to ~4 s / 138 KB with unchanged runtime
|
|
50
|
-
- bench: add string-perf benches under `assembly/__benches__/custom/`
|
|
72
|
+
- bench: add string-perf benches under `assembly/__benches__/custom/` - `simd-string-deser-scratch-h2h`, `simd-string-deser-variants-h2h`, `simd-string-deser-standalone-h2h`, and `swar-string-deser-hybrid-h2h` (escape-density head-to-heads for the HYBRID scanners); `serialize-string-modes-h2h` (NAIVE/SWAR/SIMD landscape, confirms streaming serialize already beats run-copy 2–6× so no HYBRID change is warranted); and `serialize-string-safety-h2h`, which checks SWAR/SIMD serialize byte-for-byte against NAIVE across 430 adversarial escape/surrogate/boundary-length inputs - confirming the SIMD `store<v128>` overflow stores stay within buffer slack
|
|
51
73
|
|
|
52
74
|
## 2026-05-20
|
|
53
75
|
|
|
54
76
|
- perf: unify the SWAR 4-digit kernel across array entry points. `swar/array/integer.ts` and `swar/array/float.ts` now import `parse4Digits_PairMul` from `util/swar-int.ts` (Lemire-style PairMul) instead of defining a local Baseline variant. `JSON.parse<u32[]>` ~27.4 ms/op @ 64 MiB (up ~17% from ~33 ms) and `JSON.parse<Int32Array>` ~29.5 ms/op @ 64 MiB (up ~17% from ~35.6 ms). Float wins are smaller (~2-4%) since dragonbox dominates
|
|
55
|
-
- perf: `deserializeIntegerArrayInto` (the `Array<int>` struct-field path) inlines `parseSignedIntegerSWAR` / `parseUnsignedIntegerSWAR` directly instead of routing through the per-element `deserializeIntegerField` / `deserializeUnsignedField` dispatchers. The element parser is now identical across all three call sites (top-level `JSON.parse<T[]>`, `swar/typedarray.ts`, struct field). Tuning note: array path uses parse4 + scalar even for unsigned
|
|
77
|
+
- perf: `deserializeIntegerArrayInto` (the `Array<int>` struct-field path) inlines `parseSignedIntegerSWAR` / `parseUnsignedIntegerSWAR` directly instead of routing through the per-element `deserializeIntegerField` / `deserializeUnsignedField` dispatchers. The element parser is now identical across all three call sites (top-level `JSON.parse<T[]>`, `swar/typedarray.ts`, struct field). Tuning note: array path uses parse4 + scalar even for unsigned - parse8 wins for the single-token struct field where the digit run is aligned, but in arrays the `,` separator lands mid-load and forces a wasted validate, costing ~23% on `u32-64mib`
|
|
56
78
|
- refactor: fold byte-identical `deserializeObjectArrayInto` and `deserializeStructArrayInto` into one shared helper in `swar/array/shared.ts`. The two outer wrappers retain their type-specific names and now delegate; net ~130 lines removed
|
|
57
79
|
- bench: add `assembly/__benches__/custom/u32-array-field.bench.ts` covering the unsigned struct-field path (no existing bench targeted `deserializeIntegerArrayInto` for `Array<u32>`); add `uint8array-512mib.bench.ts` for round-trip throughput at the largest payload AS's GC allows (BLOCK_MAXSIZE = (1<<30)-16, so a managed UTF-16 string maxes out near 1 GiB = 512 MiB of UTF-8). Built via a single `__new` + `memory.copy` to avoid concat/slice peaking past the wasm cap; the deserialize bench drops the source string and runs `__collect()` before the serialize bench so the per-op ~1 GiB output fits. SWAR ~1,350 MB/s deserialize, ~1,130 MB/s serialize at 512 MiB
|
|
58
80
|
|
|
@@ -66,7 +88,7 @@
|
|
|
66
88
|
- perf: `JSON.stringify<u8[]>` ~5.1 GB/s, up from ~680 MB/s (7.4×), via a 256-entry UTF-16 LUT that packs the `"DDD,"` representation of each `u8` value into a single u64 plus a parallel byte-count LUT. Trailing comma is overwritten by `]` after the loop. `JSON.stringify<i8[]>` follows the same path with a peeled `-`
|
|
67
89
|
- perf: `JSON.stringify<bool[]>` ~4.6 GB/s, up from ~1.1 GB/s (4.2×), by folding the per-element comma into the element write (`store<u64>(TRUE_COMMA_LO) + store<u16>(0x002c, 8)` for `"true,"`; `store<u64>(FALSE_COMMA_LO) + store<u32>(0x002c_0065, 8)` for `"false,"`)
|
|
68
90
|
- perf: replace AS std's `itoa_buffered` in the integer serialize path with a jeaiii-style forward-writing itoa (`assembly/util/itoa-fast.ts`). The new path uses a 100-entry UTF-16 digit-pair LUT, computes digit count from a width-ladder so `decimalCount32` is no longer a separate call, and `@inline`s into the array loop. `JSON.stringify<i32[]>` ~1.8 GB/s, up from ~1.35 GB/s. Per-width gains range from 1.0× (1-digit) to 3.4× (2-digit) in the head-to-head bench
|
|
69
|
-
- bench: new throughput benches at `assembly/__benches__/custom/{u8,u32,f64,bool}-64mib.bench.ts` for deserialize and `serialize-primitive-arrays.bench.ts` for stringify; head-to-head benches `itoa-h2h.bench.ts` (`itoa_buffered` vs jeaiii) and `parsefloat-h2h.bench.ts` (existing parser vs Lemire-lite at `util/parsefloat-fast.ts`
|
|
91
|
+
- bench: new throughput benches at `assembly/__benches__/custom/{u8,u32,f64,bool}-64mib.bench.ts` for deserialize and `serialize-primitive-arrays.bench.ts` for stringify; head-to-head benches `itoa-h2h.bench.ts` (`itoa_buffered` vs jeaiii) and `parsefloat-h2h.bench.ts` (existing parser vs Lemire-lite at `util/parsefloat-fast.ts` - research, not wired in due to test expectations encoding the existing parser's rounding behaviour)
|
|
70
92
|
- deps: pin `as-test` to `^1.1.10` and `try-as` to `^1.0.1`
|
|
71
93
|
|
|
72
94
|
## 2026-05-14 - 1.3.6
|
|
@@ -485,4 +507,3 @@
|
|
|
485
507
|
- feat: write to a central buffer and reduce memory overhead
|
|
486
508
|
- feat: rewrite the transform to properly resolve schemas and link them together
|
|
487
509
|
- feat: pre-allocate and compute the minimum size of a schema to avoid memory out of range errors
|
|
488
|
-
>>>>>>> cf237fa (chore: release 1.3.0)
|
package/README.md
CHANGED
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
- [Using Custom Serializers or Deserializers](#using-custom-serializers-or-deserializers)
|
|
18
18
|
- [Overriding built-in Container Types](#overriding-built-in-container-types)
|
|
19
19
|
- [Performance](#performance)
|
|
20
|
+
- [Real-World Throughput](#real-world-throughput)
|
|
20
21
|
- [Comparison to JavaScript](#comparison-to-javascript)
|
|
22
|
+
- [Library Comparison](#library-comparison)
|
|
23
|
+
- [Lazy Fields](#lazy-fields)
|
|
21
24
|
- [Performance Tuning](#performance-tuning)
|
|
22
|
-
- [Fast-Path Compatibility Matrix](#fast-path-compatibility-matrix)
|
|
23
|
-
- [Container Compatibility Matrix](#container-compatibility-matrix)
|
|
24
25
|
- [Running Benchmarks Locally](#running-benchmarks-locally)
|
|
25
26
|
- [Debugging](#debugging)
|
|
26
27
|
- [Architecture](#architecture)
|
|
@@ -182,6 +183,22 @@ console.log(JSON.stringify(obj)); // { "name": "Jairus", "age": 99 }
|
|
|
182
183
|
|
|
183
184
|
If age were higher than 18, it would be included in the serialization.
|
|
184
185
|
|
|
186
|
+
**@optional**
|
|
187
|
+
|
|
188
|
+
This decorator marks a field as optional for deserialization: the key may be absent from (or appear in any order in) the input, and the field keeps its default. Unlike `@omitnull` and `@omitif`, it does not affect serialization - the field is always emitted - and it has no nullability requirement. It only opts the field into the order-tolerant fast path on parse.
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
@json
|
|
192
|
+
class Tweet {
|
|
193
|
+
text!: string;
|
|
194
|
+
@optional
|
|
195
|
+
retweeted_status: Retweet | null = null; // key may be absent
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const tweet = JSON.parse<Tweet>('{ "text": "hello" }');
|
|
199
|
+
console.log(tweet.retweeted_status); // null (key was absent)
|
|
200
|
+
```
|
|
201
|
+
|
|
185
202
|
### Using nullable primitives
|
|
186
203
|
|
|
187
204
|
AssemblyScript doesn't support using nullable primitive types, so instead, json-as offers the `JSON.Box` class to remedy it.
|
|
@@ -394,14 +411,14 @@ Custom deserializers should always instantiate and return a new object. They sho
|
|
|
394
411
|
These functions are then wrapped before being consumed by the json-as library:
|
|
395
412
|
|
|
396
413
|
```typescript
|
|
397
|
-
|
|
414
|
+
__SERIALIZE_CUSTOM(): void {
|
|
398
415
|
const data = this.serializer(this);
|
|
399
416
|
const dataSize = data.length << 1;
|
|
400
417
|
memory.copy(bs.offset, changetype<usize>(data), dataSize);
|
|
401
418
|
bs.offset += dataSize;
|
|
402
419
|
}
|
|
403
420
|
|
|
404
|
-
|
|
421
|
+
_DESERIALIZE_CUSTOM(data: string): Point {
|
|
405
422
|
return this.deserializer(data);
|
|
406
423
|
}
|
|
407
424
|
```
|
|
@@ -489,69 +506,103 @@ This same pattern works for subclassable built-ins like `Array`, `Map`, `Set`, a
|
|
|
489
506
|
|
|
490
507
|
## Performance
|
|
491
508
|
|
|
492
|
-
|
|
509
|
+
`json-as` is engineered for **multi-GB/s** serialization and deserialization. Every `@json` schema is compiled to specialized WebAssembly at build time, and bytes are scanned by one of three interchangeable backends:
|
|
493
510
|
|
|
494
|
-
|
|
511
|
+
- **NAIVE** — a portable, branchy scalar scanner. The correctness baseline; needs no special CPU features.
|
|
512
|
+
- **SWAR** — *SIMD-Within-A-Register*: processes 8 bytes at a time with ordinary 64-bit integer math. The default.
|
|
513
|
+
- **SIMD** — true 128-bit vector scanning. Fastest on large and string-heavy payloads; enable with `--enable simd`.
|
|
495
514
|
|
|
496
|
-
The
|
|
515
|
+
The mode is chosen per build via `JSON_MODE` (see [Performance Tuning](#performance-tuning)). Orthogonal to the scan mode, the generated **struct** path can be swapped for **[lazy](#lazy-fields)** fields (defer parsing until first access) or the fully dynamic, schema-less **`JSON.Obj`** path.
|
|
497
516
|
|
|
498
|
-
>
|
|
517
|
+
> All figures below are **end-to-end**: deserialization includes allocating the destination object/array, not just scanning bytes — raw parser throughput is higher. Charts are generated locally and pushed to the [`docs`](https://github.com/JairusSW/json-as/tree/docs) branch, and reflect the **latest release** (older versions may differ).
|
|
499
518
|
>
|
|
500
|
-
>
|
|
501
|
-
|
|
502
|
-
|
|
519
|
+
> Benchmark machine: AMD Ryzen 7 7800X3D (8 cores, 96 MB 3D V-Cache), 32 GB RAM, Zorin OS 18.1 (Linux 6.17). JavaScript baselines run on V8's turboshaft optimizer.
|
|
520
|
+
|
|
521
|
+
📊 **[Browse the full chart set for this release →](https://github.com/JairusSW/json-as/tree/docs/charts/v1.5.0)**
|
|
522
|
+
|
|
523
|
+
### Real-World Throughput
|
|
524
|
+
|
|
525
|
+
The headline benchmark: nine standard JSON payloads — drawn from the [yyjson](https://github.com/ibireme/yyjson) and [`nativejson-benchmark`](https://github.com/miloyip/nativejson-benchmark) corpora — measured in all three scan modes, with the SIMD lazy-struct and dynamic `JSON.Obj` paths shown alongside.
|
|
526
|
+
|
|
527
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/classic-payload-deserialize-v8.svg" alt="Deserialization throughput across nine classic JSON payloads">
|
|
528
|
+
|
|
529
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/classic-payload-serialize-v8.svg" alt="Serialization throughput across nine classic JSON payloads">
|
|
530
|
+
|
|
531
|
+
Each payload stresses a different document shape:
|
|
532
|
+
|
|
533
|
+
| Payload | Size | What it stresses |
|
|
534
|
+
|---------|------|------------------|
|
|
535
|
+
| **Twitter** | 467 KB | API response, fully-modeled struct schema with `@optional` keys |
|
|
536
|
+
| **Canada** | 2.1 MB | GeoJSON — deeply nested arrays of floating-point coordinates |
|
|
537
|
+
| **CITM** | 500 KB | Concert catalog — dynamic-key `Map`s plus uniform struct arrays |
|
|
538
|
+
| **Poet** | 3.3 MB | ~8,900 flat `{desc, name, id}` records — pure struct fast path |
|
|
539
|
+
| **GitHub** | 53 KB | 30 GitHub events — a wide union of per-event-type fields |
|
|
540
|
+
| **GSOC** | 3.1 MB | ~1,264 org records keyed by id (schema.org JSON-LD `Map`) |
|
|
541
|
+
| **Lottie** | 289 KB | Vector-animation doc — structs over deeply variable layer data |
|
|
542
|
+
| **otfcc** | 66.4 MB | OpenType font dump — 15 tables captured as `JSON.Raw` |
|
|
543
|
+
| **FGO** | 48.8 MB | Game-data dump — 193 irregular tables as `Map<string, JSON.Raw>` |
|
|
544
|
+
|
|
545
|
+
The five series in each chart:
|
|
546
|
+
|
|
547
|
+
- **NAIVE / SWAR / SIMD** — the generated struct path under each scan backend, parsing every field into a typed schema.
|
|
548
|
+
- **Lazy (SIMD)** — `@json({ lazy: "auto" })`: each field's raw slice is stored at parse time and decoded only on first access; on serialize, untouched fields stream their original bytes straight back out. Reading or rewriting a subset is dramatically cheaper — see [Lazy Fields](#lazy-fields).
|
|
549
|
+
- **JSON.Obj (SIMD)** — a fully dynamic, schema-less parse into `JSON.Obj`, with no generated per-type code.
|
|
550
|
+
|
|
551
|
+
### Comparison to JavaScript
|
|
552
|
+
|
|
553
|
+
`json-as` against JavaScript's native `JSON`, parsed and stringified in a fresh V8 — as close to apples-to-apples as a Wasm-vs-native comparison gets.
|
|
503
554
|
|
|
504
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/
|
|
555
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/overview-serialize.svg" alt="Performance Chart 1">
|
|
505
556
|
|
|
506
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/
|
|
557
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/overview-deserialize.svg" alt="Performance Chart 2">
|
|
507
558
|
|
|
508
559
|
<details>
|
|
509
560
|
<summary>String serialize charts (click to expand)</summary>
|
|
510
561
|
|
|
511
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/
|
|
562
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/string-serialize.svg" alt="Performance Chart 3">
|
|
512
563
|
|
|
513
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/
|
|
564
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/string-serialize-1mb.svg" alt="Performance Chart 7">
|
|
514
565
|
</details>
|
|
515
566
|
|
|
516
567
|
<details>
|
|
517
568
|
<summary>String deserialize charts (click to expand)</summary>
|
|
518
569
|
|
|
519
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/
|
|
570
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/string-deserialize.svg" alt="Performance Chart 4">
|
|
520
571
|
|
|
521
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/
|
|
572
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/string-deserialize-1mb.svg" alt="Performance Chart 8">
|
|
522
573
|
</details>
|
|
523
574
|
|
|
524
575
|
<details>
|
|
525
576
|
<summary>Object serialize charts (click to expand)</summary>
|
|
526
577
|
|
|
527
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/
|
|
578
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/object-serialize.svg" alt="Performance Chart 5">
|
|
528
579
|
|
|
529
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/
|
|
580
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/object-serialize-1mb.svg" alt="Performance Chart 9">
|
|
530
581
|
</details>
|
|
531
582
|
|
|
532
583
|
<details>
|
|
533
584
|
<summary>Object deserialize charts (click to expand)</summary>
|
|
534
585
|
|
|
535
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/
|
|
586
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/object-deserialize.svg" alt="Performance Chart 6">
|
|
536
587
|
|
|
537
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/
|
|
588
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/object-deserialize-1mb.svg" alt="Performance Chart 10">
|
|
538
589
|
</details>
|
|
539
590
|
|
|
540
591
|
<details>
|
|
541
592
|
<summary>Primitive (de)serialize charts (click to expand)</summary>
|
|
542
593
|
|
|
543
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/
|
|
594
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/primitive-serialize.svg" alt="Primitive serialization performance">
|
|
544
595
|
|
|
545
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/
|
|
596
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/primitive-deserialize.svg" alt="Primitive deserialization performance">
|
|
546
597
|
</details>
|
|
547
598
|
|
|
548
|
-
### Library
|
|
599
|
+
### Library Comparison
|
|
549
600
|
|
|
550
601
|
How `json-as` stacks up against other JSON libraries on a ~5 KiB GitHub-repo payload: JavaScript's native `JSON` and `fast-json` (each in a fresh V8), plus the `assemblyscript-json` package. The `json-as` bars (generated struct, lazy struct, and dynamic `JSON.Obj`) are averaged across the NAIVE / SWAR / SIMD scan modes.
|
|
551
602
|
|
|
552
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/
|
|
603
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/library-deserialize.svg" alt="Library comparison - deserialize throughput">
|
|
553
604
|
|
|
554
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/
|
|
605
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/library-serialize.svg" alt="Library comparison - serialize throughput">
|
|
555
606
|
|
|
556
607
|
### Lazy Fields
|
|
557
608
|
|
|
@@ -559,22 +610,22 @@ Mark a field `@lazy` (or `JSON.Lazy<T>`, or a whole class with `@json({ lazy: "a
|
|
|
559
610
|
|
|
560
611
|
Skipping the deferred fields makes deserialization several times faster, and the win grows with payload size:
|
|
561
612
|
|
|
562
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/lazy-deserialize.svg" alt="Lazy deserialize: eager vs lazy by payload size">
|
|
613
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/lazy-deserialize.svg" alt="Lazy deserialize: eager vs lazy by payload size">
|
|
563
614
|
|
|
564
615
|
The proxy / filter / forward case - parse then re-serialize without reading the deferred fields - copies their raw bytes straight through:
|
|
565
616
|
|
|
566
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/lazy-roundtrip.svg" alt="Lazy round-trip: eager vs lazy by payload size">
|
|
617
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/lazy-roundtrip.svg" alt="Lazy round-trip: eager vs lazy by payload size">
|
|
567
618
|
|
|
568
619
|
Re-emitting a parsed object forwards the untouched fields' raw bytes instead of rebuilding them:
|
|
569
620
|
|
|
570
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/lazy-serialize.svg" alt="Lazy serialize: eager vs lazy by payload size">
|
|
621
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/lazy-serialize.svg" alt="Lazy serialize: eager vs lazy by payload size">
|
|
571
622
|
|
|
572
623
|
<details>
|
|
573
624
|
<summary>Access-pattern comparison (click to expand)</summary>
|
|
574
625
|
|
|
575
|
-
|
|
626
|
+
Lazy cost scales with how much you actually read. For each payload (vec3 → large) the chart compares a lazy parse that reads none, one, half, or all of its deferred fields against the eager full-parse baseline. Reading nothing or a single field is far faster than eager; reading everything approaches it (the work is deferred, not removed). SWAR only, since that's the mode the lazy fast-path is showcased in:
|
|
576
627
|
|
|
577
|
-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/lazy-access-pattern.svg" alt="Lazy access
|
|
628
|
+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/lazy-access-pattern.svg" alt="Lazy mode access patterns: deferred-field reads vs eager baseline">
|
|
578
629
|
</details>
|
|
579
630
|
|
|
580
631
|
### Performance Tuning
|
|
@@ -623,13 +674,13 @@ npm run bench:as
|
|
|
623
674
|
npm run bench:js
|
|
624
675
|
```
|
|
625
676
|
|
|
626
|
-
The AS suite includes **lazy variants**
|
|
677
|
+
The AS suite includes **lazy variants** - `small.lazy.bench.ts`, `medium.lazy.bench.ts`, `large.lazy.bench.ts`, `token.lazy.bench.ts`, and `vec3.lazy.bench.ts` - which mark their structs `@json({ lazy: "auto" })` and dump to `<name>-lazy` logs, so eager and lazy can be compared side by side. Run one on its own with:
|
|
627
678
|
|
|
628
679
|
```bash
|
|
629
680
|
bun run bench:as medium.lazy --mode simd
|
|
630
681
|
```
|
|
631
682
|
|
|
632
|
-
> Note: `large.lazy.bench.ts` stresses the optimizer
|
|
683
|
+
> Note: `large.lazy.bench.ts` stresses the optimizer - `lazy: "auto"` on the ~150-field `Repo` generates a getter and serialize branch per field, which can overrun **stock** Binaryen (it crashes during optimize). Build it with a patched/larger-budget Binaryen, or prefer per-field `@lazy` on very wide schemas.
|
|
633
684
|
|
|
634
685
|
5. Build charts from the latest local logs:
|
|
635
686
|
|
package/assembly/custom/chars.ts
CHANGED
|
@@ -1,85 +1,46 @@
|
|
|
1
1
|
// Characters
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@inline export const CHAR_T = 116;
|
|
22
|
-
// @ts-ignore: Decorator is valid here
|
|
23
|
-
@inline export const CHAR_R = 114;
|
|
24
|
-
// @ts-ignore: Decorator is valid here
|
|
25
|
-
@inline export const CHAR_U = 117;
|
|
26
|
-
// @ts-ignore: Decorator is valid here
|
|
27
|
-
@inline export const CHAR_E = 101;
|
|
28
|
-
// @ts-ignore: Decorator is valid here
|
|
29
|
-
@inline export const CHAR_F = 102;
|
|
30
|
-
// @ts-ignore: Decorator is valid here
|
|
31
|
-
@inline export const CHAR_A = 97;
|
|
32
|
-
// @ts-ignore: Decorator is valid here
|
|
33
|
-
@inline export const CHAR_L = 108;
|
|
34
|
-
// @ts-ignore: Decorator is valid here
|
|
35
|
-
@inline export const CHAR_S = 115;
|
|
36
|
-
// @ts-ignore = Decorator is valid here
|
|
37
|
-
@inline export const CHAR_N = 110;
|
|
38
|
-
// @ts-ignore = Decorator is valid here
|
|
39
|
-
@inline export const CHAR_B = 98;
|
|
2
|
+
export const COMMA = 44;
|
|
3
|
+
export const QUOTE = 34;
|
|
4
|
+
export const BACK_SLASH = 92;
|
|
5
|
+
export const FWD_SLASH = 47;
|
|
6
|
+
export const BRACE_LEFT = 123;
|
|
7
|
+
export const BRACE_RIGHT = 125;
|
|
8
|
+
export const BRACKET_LEFT = 91;
|
|
9
|
+
export const BRACKET_RIGHT = 93;
|
|
10
|
+
export const COLON = 58;
|
|
11
|
+
export const CHAR_T = 116;
|
|
12
|
+
export const CHAR_R = 114;
|
|
13
|
+
export const CHAR_U = 117;
|
|
14
|
+
export const CHAR_E = 101;
|
|
15
|
+
export const CHAR_F = 102;
|
|
16
|
+
export const CHAR_A = 97;
|
|
17
|
+
export const CHAR_L = 108;
|
|
18
|
+
export const CHAR_S = 115;
|
|
19
|
+
export const CHAR_N = 110;
|
|
20
|
+
export const CHAR_B = 98;
|
|
40
21
|
// Strings
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// @ts-ignore: Decorator is valid here
|
|
54
|
-
@inline export const COLON_WORD = ":";
|
|
55
|
-
// @ts-ignore: Decorator is valid here
|
|
56
|
-
@inline export const COMMA_WORD = ",";
|
|
57
|
-
// @ts-ignore: Decorator is valid here
|
|
58
|
-
@inline export const BRACE_RIGHT_WORD = "}";
|
|
59
|
-
// @ts-ignore: Decorator is valid here
|
|
60
|
-
@inline export const BRACKET_RIGHT_WORD = "]";
|
|
61
|
-
// @ts-ignore: Decorator is valid here
|
|
62
|
-
@inline export const QUOTE_WORD = '"';
|
|
63
|
-
// @ts-ignore: Decorator is valid here
|
|
64
|
-
@inline export const EMPTY_QUOTE_WORD = '""';
|
|
22
|
+
export const TRUE_WORD = "true";
|
|
23
|
+
export const FALSE_WORD = "false";
|
|
24
|
+
export const NULL_WORD = "null";
|
|
25
|
+
export const BRACE_LEFT_WORD = "{";
|
|
26
|
+
export const BRACKET_LEFT_WORD = "[";
|
|
27
|
+
export const EMPTY_BRACKET_WORD = "[]";
|
|
28
|
+
export const COLON_WORD = ":";
|
|
29
|
+
export const COMMA_WORD = ",";
|
|
30
|
+
export const BRACE_RIGHT_WORD = "}";
|
|
31
|
+
export const BRACKET_RIGHT_WORD = "]";
|
|
32
|
+
export const QUOTE_WORD = '"';
|
|
33
|
+
export const EMPTY_QUOTE_WORD = '""';
|
|
65
34
|
|
|
66
35
|
// Escape Codes
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
@inline export const NEW_LINE = 10; // \n
|
|
73
|
-
// @ts-ignore: Decorator is valid here
|
|
74
|
-
@inline export const FORM_FEED = 12; // \f
|
|
75
|
-
// @ts-ignore: Decorator is valid here
|
|
76
|
-
@inline export const CARRIAGE_RETURN = 13; // \r
|
|
36
|
+
export const BACKSPACE = 8; // \b
|
|
37
|
+
export const TAB = 9; // \t
|
|
38
|
+
export const NEW_LINE = 10; // \n
|
|
39
|
+
export const FORM_FEED = 12; // \f
|
|
40
|
+
export const CARRIAGE_RETURN = 13; // \r
|
|
77
41
|
|
|
78
42
|
// Pre-encoded u64 constants for common JSON literals
|
|
79
43
|
// These represent the UTF-16 encoded bytes stored as u64 for fast comparison/storage
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
@inline export const TRUE_WORD_U64: u64 = 28429475166421108; // "true" as u64 (t=116, r=114, u=117, e=101)
|
|
84
|
-
// @ts-ignore: Decorator is valid here
|
|
85
|
-
@inline export const FALSE_WORD_U64: u64 = 32370086184550502; // "fals" as u64 (f=102, a=97, l=108, s=115) - first 4 chars of "false"
|
|
44
|
+
export const NULL_WORD_U64: u64 = 30399761348886638; // "null" as u64 (n=110, u=117, l=108, l=108)
|
|
45
|
+
export const TRUE_WORD_U64: u64 = 28429475166421108; // "true" as u64 (t=116, r=114, u=117, e=101)
|
|
46
|
+
export const FALSE_WORD_U64: u64 = 32370086184550502; // "fals" as u64 (f=102, a=97, l=108, s=115) - first 4 chars of "false"
|