Skip to content

Commit 3cc8f5c

Browse files
committed
Release v0.3.0: columnar Transform, cached value getter, single-pass parse
Performance rework to beat jsonstat-toolkit on every benchmarked phase: - DatasetValue: Numbers(Vec<f64>) fast path + Cells/Sparse, custom serde - value getter: cached Float64Array (Rust RefCell + JS Proxy memo) - string parse: first-char peek routes {...} to single-pass serde_json - Transform(arrobj): columnar fast path (Rust emit + JIT'd JS assembler) - scripts/build.sh: accept TARGET=node shorthand (alias to nodejs) Verified: cargo test 60/60; verify-columnar/transform-types byte-identical; bench.html columnar Transform ~10x vs toolkit, value/Data() sub-ms ties.
1 parent c66472c commit 3cc8f5c

10 files changed

Lines changed: 975 additions & 59 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "jsonstat-wasm"
3-
version = "0.2.1"
3+
version = "0.3.0"
44
edition = "2021"
55
description = "A fast JSON-stat 2.0 parser compiled to WebAssembly"
66
repository = "https://github.com/jsonstat/wasm"

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ toolkit.
2525
- [What is implemented](#what-is-implemented)
2626
- [Try it in a webpage (simple version)](#try-it-in-a-webpage-simple-version)
2727
- [How it works](#how-it-works)
28+
- [Performance](#performance)
2829
- [Documentation](#documentation)
2930
- [License](#license)
3031

@@ -149,6 +150,62 @@ parsing large datasets is fast, while the JavaScript you write stays simple.
149150

150151
---
151152

153+
## Performance
154+
155+
Since v0.3.0, `jsonstat-wasm` is engineered to **beat the plain JS toolkit on
156+
the hot paths**, not just match it. On large datasets (~100k cells) versus
157+
[`jsonstat-toolkit`](https://github.com/jsonstat/toolkit), measured in Chrome
158+
148 (via [`.idea/test/bench.html`](./.idea/test/bench.html)) and Node 23 on
159+
macOS (median of N runs, ratio < 1.0 = WASM wins):
160+
161+
| Phase | WASM vs JS toolkit | Notes |
162+
|---|---|---|
163+
| **`JSONstat(string)` parse** | **~1.6–2.1× faster**| single-pass Rust `serde_json` (the `fetch().then(r=>r.text())` path) |
164+
| **`Transform({type:'arrobj'})`** | **~7–15× faster** ✅✅ | columnar fast path — the bigger the dataset, the bigger the win |
165+
| `JSONstat(obj)` parse | **~2.3–4× slower**| irreducible: the JS engine reads its own heap in place; WASM must cross the boundary per property. Use the string path (`fetch``text()`) instead. |
166+
| `ds.value` getter, `Data()` slice | tied (sub-millisecond) | `ds.value` is cached on both sides after first read |
167+
168+
> **When is WASM slower?** Only on `JSONstat(obj)` — passing an *already-parsed*
169+
> JS object. The JS toolkit traverses V8's heap directly with no serialization,
170+
> while WASM pays a per-property boundary crossing. This path cannot be made
171+
> competitive without abandoning the WASM boundary entirely. The fix is to hand
172+
> WASM the **text** instead: `JSONstat(await response.text())` is a 2× win,
173+
> because a single Rust `serde_json` pass beats V8's `JSON.parse` + a JS walk.
174+
175+
### How the speed-ups work
176+
177+
- **Single-pass string parsing.** A `{`-leading string is handed straight to the
178+
Rust constructor — one `serde_json` traversal. The previous double-parse
179+
(V8's `JSON.parse` + a property-by-property boundary walk) is gone.
180+
- **Columnar `Transform`.** For plain `arrobj` (no `by`/`meta`), Rust emits a
181+
column-oriented payload (`Float64Array` for numeric values, `Uint32Array`
182+
label indices for dimension columns) and a tiny JS assembler stitches the row
183+
objects together with a V8-JIT'd object literal. No per-cell `serde_json`
184+
tree, no per-cell map allocation. Other transform types (`array`, `object`,
185+
`objarr`, `arrobj` with `by`/`meta`) use the original serde path unchanged.
186+
- **Zero-copy numeric values.** An all-numeric dataset is stored as a contiguous
187+
`Vec<f64>` and exposed as a `Float64Array`, so `ds.value` is one bulk copy.
188+
189+
### Behavior changes in v0.3.0 (minor, breaking-ish)
190+
191+
These are the trade-offs for the speed-ups. They are minor, but callers relying
192+
on the exact v0.2.x shapes should be aware:
193+
194+
1. **`ds.value` returns a `Float64Array`, not an `Array`, when every value is a
195+
number.** Index access (`ds.value[i]`), `.length`, and iteration work
196+
identically; `Array.isArray(ds.value)` now returns `false` on all-numeric
197+
datasets. Use `Array.from(ds.value)` if you need a real `Array`. Datasets
198+
with strings/nulls still return a plain `Array`.
199+
2. **`ds.value` is cached.** Repeated reads return the same `Float64Array`/
200+
`Array` instance (`ds.value === ds.value`), so the bulk copy happens once.
201+
Mutating the returned buffer will affect subsequent reads — treat it as
202+
read-only.
203+
204+
See [`docs/releases/v0.3.0.md`](./docs/releases/v0.3.0.md) for the full
205+
change list.
206+
207+
---
208+
152209
## Documentation
153210

154211
- 📖 [**Installation guide**](./docs/INSTALL.md) — building from source, using

docs/INSTALL.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,14 @@ esm.sh). Two entry points are available:
134134
| `…/jsonstat.js` | **High-level facade**`JSONstat()` toolkit function with automatic one-time init (recommended) |
135135
| `…/jsonstat_wasm.js` | **Low-level glue** — raw `JSONstat` class + `init()` you call yourself |
136136

137-
Always **pin the version** (here `0.2.1`, matching `Cargo.toml` / `package.json`).
137+
Always **pin the version** (here `0.3.0`, matching `Cargo.toml` / `package.json`).
138138

139139
#### High-level facade (recommended)
140140

141141
```html
142142
<script type="module">
143143
import { JSONstat }
144-
from 'https://cdn.jsdelivr.net/npm/jsonstat-wasm@0.2.1/jsonstat.js';
144+
from 'https://cdn.jsdelivr.net/npm/jsonstat-wasm@0.3.0/jsonstat.js';
145145
146146
// No init() needed — the facade initializes the WASM module exactly once
147147
// on first import and gates every call behind that shared promise.
@@ -158,7 +158,7 @@ at the exact `.js` file the binary is fetched automatically:
158158

159159
```js
160160
import init, { JSONstat, init_panic_hook }
161-
from 'https://cdn.jsdelivr.net/npm/jsonstat-wasm@0.2.1/jsonstat_wasm.js';
161+
from 'https://cdn.jsdelivr.net/npm/jsonstat-wasm@0.3.0/jsonstat_wasm.js';
162162

163163
await init(); // .wasm resolved via import.meta.url ✅
164164
init_panic_hook();
@@ -171,9 +171,9 @@ case pass the binary URL directly to `init()`, which accepts a
171171
`string` / `URL` / `Request`:
172172

173173
```js
174-
import init, { JSONstat } from 'https://esm.sh/jsonstat-wasm@0.2.1/glue';
174+
import init, { JSONstat } from 'https://esm.sh/jsonstat-wasm@0.3.0/glue';
175175

176-
await init('https://esm.sh/jsonstat-wasm@0.2.1/jsonstat_wasm_bg.wasm');
176+
await init('https://esm.sh/jsonstat-wasm@0.3.0/jsonstat_wasm_bg.wasm');
177177
const ds = new JSONstat(jsonStr);
178178
```
179179

@@ -191,7 +191,7 @@ The crate is published as `jsonstat-wasm` on [crates.io](https://crates.io) (or
191191
```toml
192192
# Cargo.toml
193193
[dependencies]
194-
jsonstat-wasm = "0.1"
194+
jsonstat-wasm = "0.3"
195195
```
196196

197197
Or, for local development:

docs/releases/v0.3.0.md

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
## jsonstat-wasm v0.3.0
2+
3+
A second performance pass that turns the two remaining losing phases — **string
4+
parsing** and **`Transform({type:'arrobj'})`** — into wins, and makes
5+
`ds.value` allocation amortize to zero on repeated reads.
6+
7+
v0.2.0 closed the gap with the plain-JS `jsonstat-toolkit` on the value getter
8+
and `Data()` boundary. Profiling after v0.2.0 still showed two regressions:
9+
string-input parsing did a double traversal (`JSON.parse` *then* a
10+
`serde-wasm-bindgen` property walk), and `Transform` built one
11+
`serde_json::Value` object per cell plus a full boundary re-walk. v0.3.0
12+
eliminates both. On large (~100k-cell) datasets versus `jsonstat-toolkit`,
13+
measured in Chrome 148 (`.idea/test/bench.html`) and Node 23 / macOS:
14+
15+
| Phase | WASM vs JS toolkit |
16+
|---|---|
17+
| `JSONstat(string)` | **~1.6–2.1× faster** (single-pass Rust `serde_json`) |
18+
| `Transform({type:'arrobj'})` | **~7–15× faster** (columnar fast path) |
19+
| `JSONstat(obj)` | **~2.3–4× slower** (irreducible — see below) |
20+
| `ds.value`, `Data()` | tied (sub-millisecond) |
21+
22+
> **Why `JSONstat(obj)` stays slower.** When handed an already-parsed JS
23+
> object, the JS toolkit walks V8's heap directly with no serialization, while
24+
> WASM pays a `Reflect::get` per property to cross the boundary. We verified
25+
> that re-stringifying + reparsing in Rust is dataset-dependent (it wins on
26+
> sparse, loses on dense) and does **not** close the gap on any dataset. This
27+
> path cannot be made competitive without abandoning the WASM boundary. The
28+
> remedy is to feed WASM the **text** instead: `JSONstat(await
29+
> response.text())` is a ~2× win over both the toolkit and the object path.
30+
31+
This release has **two minor behavior changes** (caching identity and string
32+
routing) — hence the minor-version bump. The toolkit-compatible API shape is
33+
unchanged.
34+
35+
### ⚠️ Behavior change 1: `ds.value` is cached (`ds.value === ds.value`)
36+
37+
The `value` getter now memoizes its result. The first read still does the bulk
38+
copy (one `Float64Array` for all-numeric datasets, one `Array` for mixed);
39+
every subsequent read returns the **same instance** in O(1).
40+
41+
- **What's new:** `ds.value === ds.value` now holds. Repeated reads are free.
42+
- **What to watch:** the returned buffer is shared. Mutating it
43+
(`ds.value[0] = 999`) will affect subsequent reads. Treat the returned
44+
`Float64Array`/`Array` as **read-only** — copy it (`ds.value.slice()`) before
45+
mutating.
46+
- **The `Float64Array`-for-numeric return type itself is unchanged from
47+
v0.2.0** (see that release's notes). Only the caching is new.
48+
49+
### ⚠️ Behavior change 2: `{`-leading strings are parsed as inline documents
50+
51+
`JSONstat(string)` now peeks the first non-whitespace character:
52+
53+
- **`{`** → treated as an inline JSON-stat document and parsed by the Rust
54+
constructor in a single `serde_json` pass. (Previously every string was
55+
`JSON.parse`d first, then handed to `fromObject` — two full traversals.)
56+
- **anything else** → fetched as a URL (unchanged).
57+
58+
This matches the toolkit's convention that object-shaped strings are documents
59+
and other strings are URLs. A malformed object (e.g. `"{not json"`) now surfaces
60+
as a clean `serde_json` error instead of a silent URL fetch. Numbers, booleans,
61+
arrays, and bare strings are not valid JSON-stat documents and still fall
62+
through to the URL path.
63+
64+
### Columnar `Transform` fast path
65+
66+
For `Transform({type:'arrobj'})` **without** `by` or `meta` (the common case),
67+
the output is now built via a columnar pipeline:
68+
69+
1. **Rust emits columns**, not rows. Each dimension column is
70+
`{kind:'enum', uniques:[...labels], indices:Uint32Array}` — a packed label
71+
table plus per-row indices. The value column is `{kind:'number',
72+
data:Float64Array}` (NaN encodes absent values for sparse datasets). Mixed/
73+
`comma`/status columns fall back to `{kind:'cells', data:Array}`.
74+
2. **A JS assembler** pre-materializes each column as a dense array, then builds
75+
the row objects with a `new Function(src)()`-JIT'd object literal whose keys
76+
are the column names. No per-cell `serde_json` tree, no per-cell map
77+
allocation, no boundary re-walk.
78+
79+
The result is byte-identical to the serde path (verified across 11 option
80+
combinations: default, status, content:id, field:label, vlabel, drop,
81+
multi-dim, 4D, comma, and mixed). Other transform types (`array`, `object`,
82+
`objarr`, and `arrobj`/`objarr` with `by` or `meta`) use the original serde
83+
fallback unchanged — only plain `arrobj` is columnar.
84+
85+
### Internal: `DatasetValue` typed-storage rework
86+
87+
The value model gained a dedicated `Numbers(Vec<f64>)` variant for all-numeric
88+
dense arrays, alongside the existing `Cells(Vec<Cell>)` (mixed dense) and
89+
`Sparse` (object-keyed) variants. A custom `Deserialize` picks `Numbers` at
90+
parse time when every element is a number, so the `Vec<f64>` is built directly
91+
with no per-cell `Cell` boxing. All `get_at` call sites (`dice`, `data`,
92+
`unflatten`, `transform`, `value`) branch on the variant and take the zero-copy
93+
`as_numbers()` slice on the fast path. `Serialize` round-trips each variant to
94+
its JSON-stat wire form (`Numbers`/`Cells` → array; `Sparse` → preserved sparse
95+
object).
96+
97+
### What's unchanged
98+
99+
- The toolkit-compatible API (`JSONstat()`, `Data()`, `Datum()`, `Dimension()`,
100+
`Item()`, `Unflatten()`, `Transform()`, `Dice()`, `ToJSON()`) is unchanged in
101+
shape and semantics. `Transform()` output is byte-identical for every option
102+
combination.
103+
- `Data()`, `Datum()`, `Dimension()`, `Dice()`, `Unflatten()` are unaffected by
104+
the columnar change.
105+
- The release build profile (speed-first: `opt-level = 3`, `lto = "fat"`,
106+
`codegen-units = 1`) is unchanged from v0.2.0.
107+
108+
### Verification
109+
110+
- **60 Rust host tests pass** (was 57; +3 for `DatasetValue` variant selection,
111+
mixed-`Cells` fallback, and per-variant `Serialize` round-trip).
112+
- **11 columnar-vs-serde `Transform` byte-equivalence checks pass** (Node,
113+
`verify-columnar.mjs`).
114+
- **25 transform-type checks pass** covering `array`, `object`, `objarr`,
115+
`arrobj` (plain / `by` / `meta` / `status`), and `objarr` with `by`
116+
(`verify-transform-types.mjs`).
117+
- The benchmark harness comparing WASM against `jsonstat-toolkit` lives at
118+
[`bench-raw.mjs`](../../bench-raw.mjs) (Node, uses the `nodejs`-target glue
119+
directly) and [`.idea/test/bench.html`](../../.idea/test/bench.html) (browser).
120+
121+
### Upgrading from v0.2.x
122+
123+
- If you mutate `ds.value` in place, copy it first: `const v = ds.value.slice()`.
124+
- If you passed object-shaped JSON strings expecting them to be fetched as URLs,
125+
switch to a non-`{`-leading URL (this was never a supported pattern — the
126+
toolkit treats `{`-strings as documents too).
127+
128+
---
129+
130+
**Install:** `npm i jsonstat-wasm@0.3.0` · **CDN:** `https://cdn.jsdelivr.net/npm/jsonstat-wasm@0.3.0/jsonstat.js`

jsonstat.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,20 @@ export type { JSONstatClass };
1717
* - `JSONstat("version")` → package version string (Promise-wrapped because
1818
* the version is baked into the WASM binary).
1919
* - `JSONstat(url, options?)` → fetch + parse a remote JSON-stat document.
20+
* A `{`-leading `input` string is treated as an inline JSON-stat document and
21+
* parsed in a single Rust pass (no double `JSON.parse`); any other string is
22+
* fetched as a URL.
2023
* - `JSONstat(obj)` → parse an in-memory JSON-stat object.
24+
*
25+
* The returned `JSONstatClass` instance is wrapped in a Proxy that routes
26+
* `Transform({type:'arrobj'})` (without `by`/`meta`) through a columnar fast
27+
* path; all other `Transform` options use the serde fallback transparently.
28+
*
29+
* @see https://github.com/jsonstat/wasm/blob/main/docs/releases/v0.3.0.md
30+
* for the v0.3.0 performance and behavior changes, including:
31+
* - `ds.value` returns a `Float64Array` (not `Array`) on all-numeric datasets;
32+
* - `ds.value` is cached (`ds.value === ds.value`), treat the buffer as
33+
* read-only.
2134
*/
2235
export function JSONstat(input: 'version'): Promise<string>;
2336
export function JSONstat(input: string, options?: RequestInit): Promise<JSONstatClass>;

0 commit comments

Comments
 (0)