Skip to content

Commit 31120a4

Browse files
authored
Merge pull request #203 from JairusSW/jairus/v1.5.0-prep
fix(dynamic): nullable-string null + dynamic-string/lazy-slot perf, c…
2 parents 7421980 + 2acff79 commit 31120a4

32 files changed

Lines changed: 985 additions & 209 deletions

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Unreleased
44

5-
## 2026-06-09 - v1.5.0
5+
## 2026-06-10 - v1.5.0
66

77
- 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
88
- 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
@@ -16,6 +16,11 @@
1616
- 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`
1717
- 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
1818
- 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: **~** 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
1924

2025
## 2026-06-05 - v1.4.0
2126

README.md

Lines changed: 59 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@
1717
- [Using Custom Serializers or Deserializers](#using-custom-serializers-or-deserializers)
1818
- [Overriding built-in Container Types](#overriding-built-in-container-types)
1919
- [Performance](#performance)
20+
- [Real-World Throughput](#real-world-throughput)
2021
- [Comparison to JavaScript](#comparison-to-javascript)
22+
- [Library Comparison](#library-comparison)
23+
- [Lazy Fields](#lazy-fields)
2124
- [Performance Tuning](#performance-tuning)
22-
- [Fast-Path Compatibility Matrix](#fast-path-compatibility-matrix)
23-
- [Container Compatibility Matrix](#container-compatibility-matrix)
2425
- [Running Benchmarks Locally](#running-benchmarks-locally)
2526
- [Debugging](#debugging)
2627
- [Architecture](#architecture)
@@ -505,79 +506,103 @@ This same pattern works for subclassable built-ins like `Array`, `Map`, `Set`, a
505506

506507
## Performance
507508

508-
The `json-as` library is engineered for **multi-GB/s processing speeds**, leveraging SIMD and SWAR optimizations along with highly efficient transformations. The charts below highlight key performance metrics such as build time, operations-per-second, and throughput.
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:
509510

510-
### Comparison to JavaScript
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`.
511514

512-
The following charts compare JSON-AS against JavaScript's native `JSON` implementation. It's as fair as possible and runs on V8's turboshaft optimizer. The published charts are generated locally and pushed to the `docs` branch.
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.
513516

514-
> Note: Benchmarks reflect the **latest version**. Older versions may show different performance.
515-
>
516-
> Current local benchmark machine: Apple M4 Max (16 cores - 12 performance + 4 efficiency), 64 GB RAM, macOS 26.
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).
517518
>
518-
> Benchmark results include normal end-to-end work such as allocating the destination object or array before deserializing into it. Raw parser throughput is higher than the published figures because these numbers intentionally include that allocation/setup cost.
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:
519546

520-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/chart01.svg" alt="Performance Chart 1">
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.
521550

522-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/chart02.svg" alt="Performance Chart 2">
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.
554+
555+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/overview-serialize.svg" alt="Performance Chart 1">
556+
557+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/overview-deserialize.svg" alt="Performance Chart 2">
523558

524559
<details>
525560
<summary>String serialize charts (click to expand)</summary>
526561

527-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/chart03.png" alt="Performance Chart 3">
562+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/string-serialize.svg" alt="Performance Chart 3">
528563

529-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/chart07.png" alt="Performance Chart 7">
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">
530565
</details>
531566

532567
<details>
533568
<summary>String deserialize charts (click to expand)</summary>
534569

535-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/chart04.png" alt="Performance Chart 4">
570+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/string-deserialize.svg" alt="Performance Chart 4">
536571

537-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/chart08.png" alt="Performance Chart 8">
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">
538573
</details>
539574

540575
<details>
541576
<summary>Object serialize charts (click to expand)</summary>
542577

543-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/chart05.png" alt="Performance Chart 5">
578+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/object-serialize.svg" alt="Performance Chart 5">
544579

545-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/chart09.png" alt="Performance Chart 9">
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">
546581
</details>
547582

548583
<details>
549584
<summary>Object deserialize charts (click to expand)</summary>
550585

551-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/chart06.png" alt="Performance Chart 6">
586+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/object-deserialize.svg" alt="Performance Chart 6">
552587

553-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/chart10.png" alt="Performance Chart 10">
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">
554589
</details>
555590

556591
<details>
557592
<summary>Primitive (de)serialize charts (click to expand)</summary>
558593

559-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/chart11.svg" alt="Primitive serialization performance">
560-
561-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/chart12.svg" alt="Primitive deserialization performance">
562-
</details>
563-
564-
<details>
565-
<summary>Real-world payloads — eager / lazy / dynamic (click to expand)</summary>
566-
567-
Throughput across nine classic JSON payloads (Twitter, Canada, CITM, Poet, GitHub events, GSOC, Lottie, otfcc, FGO), each in the NAIVE / SWAR / SIMD scan modes, with the SIMD lazy-struct and dynamic `JSON.Obj` paths shown alongside.
568-
569-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/classic-payload-deserialize-v8.png" alt="Classic payload deserialize throughput">
594+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/primitive-serialize.svg" alt="Primitive serialization performance">
570595

571-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/classic-payload-serialize-v8.png" alt="Classic payload serialize throughput">
596+
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/primitive-deserialize.svg" alt="Primitive deserialization performance">
572597
</details>
573598

574-
### Library comparison
599+
### Library Comparison
575600

576601
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.
577602

578-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/chart14.png" alt="Library comparison - deserialize throughput">
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">
579604

580-
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/chart13.png" alt="Library comparison - serialize throughput">
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">
581606

582607
### Lazy Fields
583608

assembly/__benches__/lazy/access-pattern.bench.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { bench, blackbox, dumpToFile, utf8ByteLength } from "../lib/bench";
1212
// Fields are marked deferrable with explicit `@lazy` (per the maintainer's
1313
// request) rather than class-level `lazy: "auto"`, so each payload has a known
1414
// deferred-field count regardless of the auto threshold. The dedicated chart
15-
// (build-chart15.ts) reads the SWAR logs only - lazy is showcased in SWAR.
15+
// (build-lazy.ts) reads the SWAR logs only - lazy is showcased in SWAR.
1616
//
1717
// Dumps: lzap-<payload>.{base,none,one,half,all}.
1818

0 commit comments

Comments
 (0)