@lazily-hub/lazily-js 0.5.0 → 0.6.1
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/BENCHMARKS.md +91 -0
- package/README.md +37 -28
- package/bench/context.bench.mjs +179 -0
- package/bench/harness.mjs +144 -0
- package/package.json +9 -2
- package/src/index.d.ts +390 -0
- package/src/index.js +960 -0
- package/src/lossless-tree-crdt.d.ts +52 -0
- package/src/lossless-tree-crdt.js +537 -0
- package/src/utf8-offsets.js +37 -0
package/BENCHMARKS.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# lazily-js Benchmark Results
|
|
2
|
+
|
|
3
|
+
Generated benchmark data for the
|
|
4
|
+
[`@lazily-hub/lazily-js`](https://www.npmjs.com/package/@lazily-hub/lazily-js)
|
|
5
|
+
reactive primitives library.
|
|
6
|
+
|
|
7
|
+
## Benchmark Results
|
|
8
|
+
|
|
9
|
+
The suite is a 1:1 port of the single-threaded `Context` benchmarks in
|
|
10
|
+
lazily-rs's `benches/context.rs` — the same group/case names and the same
|
|
11
|
+
widths/depths (`FAN_OUT_WIDTHS=[32,256]`, `MEMO_CHAIN_DEPTH=32`,
|
|
12
|
+
`BATCH_STORM_CELLS=64`, `SET_CELL_INVALIDATION_FAN_OUT=512`) so JS and Rust
|
|
13
|
+
numbers are directly comparable.
|
|
14
|
+
|
|
15
|
+
JS runs on a single event-loop thread, so the lazily-rs `ThreadSafeContext`
|
|
16
|
+
multi-worker contention benchmarks (`thread_safe_contention`,
|
|
17
|
+
`thread_safe_effect_contention`, `thread_safe_graph_propagation`) have no
|
|
18
|
+
like-for-like single-process counterpart here and are intentionally omitted.
|
|
19
|
+
|
|
20
|
+
<!-- benchmark-results:start -->
|
|
21
|
+
|
|
22
|
+
Generated for package `@lazily-hub/lazily-js` version `0.6.0`.
|
|
23
|
+
|
|
24
|
+
Environment: Node.js `26.4.0` on `linux x64`.
|
|
25
|
+
|
|
26
|
+
Refresh command:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
node scripts/run-benchmarks.mjs
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Mean wall-clock time per iteration; 95% CI half-width from the standard error.
|
|
33
|
+
|
|
34
|
+
| Group | Case | Mean | 95% CI | p75 | p99 | Samples |
|
|
35
|
+
|---|---|---:|---:|---:|---:|---:|
|
|
36
|
+
| cached_reads | context | 57.798 ns | ± 20.941 ns | 46.398 ns | 124.744 ns | 100 |
|
|
37
|
+
| cold_first_get | context | 669.220 ns | ± 8.630 ns | 670.000 ns | 802.610 ns | 100 |
|
|
38
|
+
| dependency_fan_out | context / 32 | 7.448 us | ± 824.968 ns | 6.783 us | 34.205 us | 100 |
|
|
39
|
+
| dependency_fan_out | context / 256 | 43.766 us | ± 1.064 us | 43.270 us | 49.513 us | 100 |
|
|
40
|
+
| set_cell_invalidation | high_fan_out / 512 | 10.737 us | ± 376.524 ns | 10.113 us | 17.141 us | 100 |
|
|
41
|
+
| memo_equality_suppression | context | 2.387 us | ± 193.801 ns | 2.310 us | 2.864 us | 100 |
|
|
42
|
+
| effect_flushing | context | 211.187 ns | ± 20.150 ns | 221.482 ns | 524.491 ns | 100 |
|
|
43
|
+
| batch_storms | context / 64 | 30.430 us | ± 1.295 us | 30.815 us | 49.775 us | 99 |
|
|
44
|
+
| typed_cache_reads | context_cell | 31.325 ns | ± 1.052 ns | 30.255 ns | 58.102 ns | 100 |
|
|
45
|
+
| typed_cache_reads | context_slot | 120.262 ns | ± 4.805 ns | 118.099 ns | 220.409 ns | 100 |
|
|
46
|
+
|
|
47
|
+
<!-- benchmark-results:end -->
|
|
48
|
+
|
|
49
|
+
## Suite
|
|
50
|
+
|
|
51
|
+
| Group | Case | Parity with lazily-rs | What it measures |
|
|
52
|
+
|-------|------|-----------------------|------------------|
|
|
53
|
+
| `cached_reads` | `context` | `bench_cached_reads` / context | Steady-state cached `ctx.get(slot)` (no recompute). |
|
|
54
|
+
| `cold_first_get` | `context` | `bench_cold_first_get` / context | First (uncached) read of a freshly built slot. |
|
|
55
|
+
| `dependency_fan_out` | `context / {32,256}` | `bench_dependency_fan_out` / context | One root cell invalidates N dependents, then all are re-read. |
|
|
56
|
+
| `set_cell_invalidation` | `high_fan_out / 512` | `bench_set_cell_invalidation` / high_fan_out | Cost of `setCell` invalidating a 512-wide fan-out (no recompute). |
|
|
57
|
+
| `memo_equality_suppression` | `context` | `bench_memo_equality_suppression` / context | Memo chain that stays equal downstream (guard suppresses recompute). |
|
|
58
|
+
| `effect_flushing` | `context` | `bench_effect_flushing` / context | Effect re-runs on every cell change. |
|
|
59
|
+
| `batch_storms` | `context / 64` | `bench_batch_storms` / context | Coalesced batched writes to 64 cells (one effect flush). |
|
|
60
|
+
| `typed_cache_reads` | `context_slot`, `context_cell` | `bench_typed_cache_reads` / context_* | Direct `ctx.get(slot)` vs `ctx.getCell(cell)`. |
|
|
61
|
+
|
|
62
|
+
### Running
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm run bench # run the suite, print a markdown table
|
|
66
|
+
node scripts/run-benchmarks.mjs # run + refresh this file
|
|
67
|
+
node scripts/run-benchmarks.mjs --check # CI gate: exit 1 if stale
|
|
68
|
+
make bench # via the Makefile
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Harness
|
|
72
|
+
|
|
73
|
+
`bench/harness.mjs` is a zero-dependency criterion-style harness
|
|
74
|
+
(`node:perf_hooks` only). It offers two measurement modes:
|
|
75
|
+
|
|
76
|
+
- **`bench(group, case, fn)`** — auto-batched timing for *idempotent* routines
|
|
77
|
+
(each sample times N calls, N auto-scaled so one sample lasts ~5 ms). Use for
|
|
78
|
+
steady-state reads / repeated-equal-cost work.
|
|
79
|
+
- **`bench.batched(group, case, setup, routine)`** — `iter_batched` timing
|
|
80
|
+
(criterion `BatchSize::SmallInput`): fresh `setup()` before every single
|
|
81
|
+
measured call, setup excluded from timing. Use when the operation mutates graph
|
|
82
|
+
state a second call would skip — e.g. the `==` guard on `setCell`, or a cold
|
|
83
|
+
first read that caches on first access.
|
|
84
|
+
|
|
85
|
+
### Regression workflow
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
node scripts/run-benchmarks.mjs # record "before" baseline in BENCHMARKS.md
|
|
89
|
+
# apply the performance patch
|
|
90
|
+
node scripts/run-benchmarks.mjs # compare against the new numbers
|
|
91
|
+
```
|
package/README.md
CHANGED
|
@@ -35,29 +35,37 @@ The full `lazily` capability set across every binding. Legend: ✅ shipped ·
|
|
|
35
35
|
notes and platform carve-outs lives in
|
|
36
36
|
[`lazily-spec` § Cross-Language Coverage](../lazily-spec/docs/coverage.md).
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
|
|
|
45
|
-
|
|
|
46
|
-
|
|
|
47
|
-
|
|
|
48
|
-
|
|
|
49
|
-
|
|
|
50
|
-
|
|
|
51
|
-
|
|
|
52
|
-
|
|
|
53
|
-
|
|
|
54
|
-
|
|
|
55
|
-
|
|
|
56
|
-
|
|
|
57
|
-
|
|
|
58
|
-
|
|
|
59
|
-
|
|
|
60
|
-
|
|
|
38
|
+
<!-- coverage-table:start -->
|
|
39
|
+
| Feature | Rust | Python | Kotlin | JS | Dart | Zig | Go | C++ |
|
|
40
|
+
| --------- | :----: | :------: | :------: | :--: | :----: | :---: | :--: | :---: |
|
|
41
|
+
| Reactive graph — `Cell` / `Slot` / `Signal` / `Effect` / memo / batch | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
42
|
+
| Thread-safe context (lock-backed) | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ |
|
|
43
|
+
| Async reactive context | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
44
|
+
| Flat state machine | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
45
|
+
| Harel state charts | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
46
|
+
| Keyed cell collections (`CellMap` / `CellTree`) + reconcile | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
47
|
+
| Memoized semantic tree (`SemTree`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
48
|
+
| Stable-id alignment (manufactured identity) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
49
|
+
| Reactive queue (`QueueCell` SPSC/MPSC + `QueueStorage` adapter) | — | — | — | — | — | — | — | — |
|
|
50
|
+
| Free-text character CRDT (`TextCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
51
|
+
| `TextCrdt` delta sync (`version_vector` / `delta_since` / `apply_delta`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
52
|
+
| Move-aware sequence CRDT (`SeqCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
53
|
+
| Lossless tree CRDT core (`LosslessTreeCrdt`, M1) | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
|
|
54
|
+
| Lossless tree — dotted-frontier anti-entropy | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
|
|
55
|
+
| Lossless tree — concurrent merge convergence | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
|
|
56
|
+
| Registers (LWW / MV) + `PnCounter` + `CellCrdt` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
57
|
+
| IPC wire — `Snapshot` + `Delta` + `CrdtSync` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
58
|
+
| Shared-memory blob path (`ShmBlobArena`) | ✅ | ✅ | ✅ | ~ | ~ | ✅ | ✅ | ✅ |
|
|
59
|
+
| Distributed CRDT plane (`CrdtPlaneRuntime` / anti-entropy) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
60
|
+
| Distributed plane — WebRTC transport + signaling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
61
|
+
| State projection / mirror | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
62
|
+
| Causal receipts (`CausalReceipts` outcome projection) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
63
|
+
| Message-passing + RPC command plane (`command-plane-v1`) | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
|
|
64
|
+
| C-ABI FFI boundary | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ |
|
|
65
|
+
| Permission boundary (`PeerPermissions` / `RemoteOp`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
66
|
+
| Capability negotiation (`SessionHandshake`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
67
|
+
| Instrumentation / benchmarks | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ |
|
|
68
|
+
<!-- coverage-table:end -->
|
|
61
69
|
|
|
62
70
|
CRDT convergence and the wire protocol are pinned by the shared conformance fixtures
|
|
63
71
|
and JSON Schemas in `lazily-spec` and the Lean models in `lazily-formal`.
|
|
@@ -374,11 +382,12 @@ bob.ingest(alice.syncFrame(), Date.now() * 1000); // 1 op applied; re-ingest app
|
|
|
374
382
|
|
|
375
383
|
lazily-js replays the shared `lazily-spec` fixtures for IPC, agent-doc state,
|
|
376
384
|
keyed collections (`CellMap`, `CellTree`, LIS reconciliation), semantic tree,
|
|
377
|
-
sequence and text CRDTs
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
`
|
|
381
|
-
|
|
385
|
+
sequence and text CRDTs (incl. `TextCrdt` delta sync, `#lztextsync`:
|
|
386
|
+
`textcrdt_convergence.json` + `textcrdt_delta_sync.json`), manufactured text
|
|
387
|
+
identity, Harel state charts, the signaling protocol (`signaling/frames.json`,
|
|
388
|
+
`signaling/anti_spoof_session.json`), and the distributed CRDT plane
|
|
389
|
+
(`distributed/crdt_sync_frames.json`, `distributed/anti_entropy_converge.json`).
|
|
390
|
+
It also validates generated wire values against the canonical JSON Schemas.
|
|
382
391
|
|
|
383
392
|
`npm test` builds the [`lazily-formal`][formal] Lean 4 model when that sibling
|
|
384
393
|
checkout and the `lake` toolchain are present. The script exits successfully
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Reactive-graph benchmarks for lazily-js — parity port of lazily-rs
|
|
2
|
+
// `benches/context.rs` (the single-threaded `Context` variants).
|
|
3
|
+
//
|
|
4
|
+
// JS runs on a single event-loop thread, so the lazily-rs `ThreadSafeContext`
|
|
5
|
+
// multi-worker contention benchmarks (thread_safe_contention,
|
|
6
|
+
// thread_safe_effect_contention, thread_safe_graph_propagation) have no
|
|
7
|
+
// like-for-like single-process counterpart here and are intentionally omitted.
|
|
8
|
+
// Every group/case below is a 1:1 port of the matching `context`-variant in
|
|
9
|
+
// lazily-rs, using the same widths/depths so numbers are cross-language
|
|
10
|
+
// comparable. Constants mirror `benches/context.rs` exactly.
|
|
11
|
+
|
|
12
|
+
import { bench, blackBox, run } from "./harness.mjs";
|
|
13
|
+
import { Context } from "../src/reactive.js";
|
|
14
|
+
|
|
15
|
+
const FAN_OUT_WIDTHS = [32, 256];
|
|
16
|
+
const MEMO_CHAIN_DEPTH = 32;
|
|
17
|
+
const BATCH_STORM_CELLS = 64;
|
|
18
|
+
const SET_CELL_INVALIDATION_FAN_OUT = 512;
|
|
19
|
+
|
|
20
|
+
// --- setup helpers (ports of setup_context_* in benches/context.rs) ---------
|
|
21
|
+
|
|
22
|
+
function setupFanOut(width) {
|
|
23
|
+
const ctx = new Context();
|
|
24
|
+
const root = ctx.cell(0);
|
|
25
|
+
const slots = [];
|
|
26
|
+
for (let offset = 0; offset < width; offset++) {
|
|
27
|
+
slots.push(ctx.computed(() => (ctx.getCell(root) + offset) >>> 0));
|
|
28
|
+
}
|
|
29
|
+
for (const slot of slots) blackBox(ctx.get(slot));
|
|
30
|
+
return { ctx, root, slots };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function setupMemoChain(depth) {
|
|
34
|
+
const ctx = new Context();
|
|
35
|
+
const root = ctx.cell(0);
|
|
36
|
+
let tail = ctx.memo(() => ctx.getCell(root) % 2);
|
|
37
|
+
for (let i = 0; i < depth; i++) {
|
|
38
|
+
const previous = tail;
|
|
39
|
+
tail = ctx.computed(() => (ctx.get(previous) + 1) >>> 0);
|
|
40
|
+
}
|
|
41
|
+
blackBox(ctx.get(tail));
|
|
42
|
+
return { ctx, root, tail };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setupBatchStorm(cellsLen) {
|
|
46
|
+
const ctx = new Context();
|
|
47
|
+
const cells = [];
|
|
48
|
+
for (let idx = 0; idx < cellsLen; idx++) cells.push(ctx.cell(idx));
|
|
49
|
+
let sink = 0;
|
|
50
|
+
const effectCells = cells.slice();
|
|
51
|
+
ctx.effect(() => {
|
|
52
|
+
let total = 0;
|
|
53
|
+
for (const cell of effectCells) total = (total + ctx.getCell(cell)) >>> 0;
|
|
54
|
+
sink = total;
|
|
55
|
+
});
|
|
56
|
+
return { ctx, cells, getSink: () => sink };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- cached_reads: steady-state cached slot read ----------------------------
|
|
60
|
+
// Parity: bench_cached_reads "context" in lazily-rs/context.rs.
|
|
61
|
+
// Setup runs once before registration; only the cached `get` is timed.
|
|
62
|
+
{
|
|
63
|
+
const ctx = new Context();
|
|
64
|
+
const root = ctx.cell(21);
|
|
65
|
+
const doubled = ctx.computed(() => ctx.getCell(root) * 2);
|
|
66
|
+
blackBox(ctx.get(doubled)); // prime the cache
|
|
67
|
+
bench("cached_reads", "context", () => blackBox(ctx.get(blackBox(doubled))));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- cold_first_get: first (uncached) read of a freshly built slot ---------
|
|
71
|
+
// Parity: bench_cold_first_get "context" (iter_batched — fresh ctx each call).
|
|
72
|
+
bench.batched(
|
|
73
|
+
"cold_first_get",
|
|
74
|
+
"context",
|
|
75
|
+
() => {
|
|
76
|
+
const ctx = new Context();
|
|
77
|
+
const root = ctx.cell(21);
|
|
78
|
+
const doubled = ctx.computed(() => ctx.getCell(root) * 2);
|
|
79
|
+
return { ctx, doubled };
|
|
80
|
+
},
|
|
81
|
+
({ ctx, doubled }) => blackBox(ctx.get(blackBox(doubled))),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// --- dependency_fan_out: one root cell invalidates N dependents ------------
|
|
85
|
+
// Parity: bench_dependency_fan_out "context / {32,256}" (iter_batched).
|
|
86
|
+
for (const width of FAN_OUT_WIDTHS) {
|
|
87
|
+
bench.batched(
|
|
88
|
+
"dependency_fan_out",
|
|
89
|
+
`context / ${width}`,
|
|
90
|
+
() => setupFanOut(width),
|
|
91
|
+
({ ctx, root, slots }) => {
|
|
92
|
+
ctx.setCell(root, blackBox(1));
|
|
93
|
+
let total = 0;
|
|
94
|
+
for (const slot of slots) total = (total + ctx.get(slot)) >>> 0;
|
|
95
|
+
blackBox(total);
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- set_cell_invalidation: cost of invalidating a wide fan-out ------------
|
|
101
|
+
// Parity: bench_set_cell_invalidation "high_fan_out / 512" (iter_batched).
|
|
102
|
+
// Only the setCell invalidation is timed (not the downstream recompute) —
|
|
103
|
+
// matches the lazily-rs routine which times `set_cell` + `black_box(slots.len())`.
|
|
104
|
+
bench.batched(
|
|
105
|
+
"set_cell_invalidation",
|
|
106
|
+
"high_fan_out / 512",
|
|
107
|
+
() => setupFanOut(SET_CELL_INVALIDATION_FAN_OUT),
|
|
108
|
+
({ ctx, root, slots }) => {
|
|
109
|
+
ctx.setCell(root, blackBox(1));
|
|
110
|
+
blackBox(slots.length);
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// --- memo_equality_suppression: memo chain that stays equal downstream -----
|
|
115
|
+
// Parity: bench_memo_equality_suppression "context" (iter_batched).
|
|
116
|
+
// root%2 memo head; setting root to an even value keeps the head's value equal
|
|
117
|
+
// (0) so downstream caches survive — the memo guard path.
|
|
118
|
+
bench.batched(
|
|
119
|
+
"memo_equality_suppression",
|
|
120
|
+
"context",
|
|
121
|
+
() => setupMemoChain(MEMO_CHAIN_DEPTH),
|
|
122
|
+
({ ctx, root, tail }) => {
|
|
123
|
+
ctx.setCell(root, blackBox(2));
|
|
124
|
+
blackBox(ctx.get(blackBox(tail)));
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// --- effect_flushing: effect re-runs on every cell change ------------------
|
|
129
|
+
// Parity: bench_effect_flushing "context" (idempotent: advances next each call
|
|
130
|
+
// so each setCell is a real 1->2->3 ... change, not a ==-guarded no-op).
|
|
131
|
+
{
|
|
132
|
+
const ctx = new Context();
|
|
133
|
+
const root = ctx.cell(0);
|
|
134
|
+
let seen = 0;
|
|
135
|
+
ctx.effect(() => {
|
|
136
|
+
seen = (seen + ctx.getCell(root)) >>> 0;
|
|
137
|
+
});
|
|
138
|
+
let next = 0;
|
|
139
|
+
bench("effect_flushing", "context", () => {
|
|
140
|
+
next = (next + 1) >>> 0;
|
|
141
|
+
ctx.setCell(root, blackBox(next));
|
|
142
|
+
blackBox(seen);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- batch_storms: coalesced batched writes to many cells -----------------
|
|
147
|
+
// Parity: bench_batch_storms "context / 64".
|
|
148
|
+
{
|
|
149
|
+
const { ctx, cells, getSink } = setupBatchStorm(BATCH_STORM_CELLS);
|
|
150
|
+
let base = BATCH_STORM_CELLS;
|
|
151
|
+
bench("batch_storms", `context / ${BATCH_STORM_CELLS}`, () => {
|
|
152
|
+
base = (base + BATCH_STORM_CELLS) >>> 0;
|
|
153
|
+
ctx.batch(() => {
|
|
154
|
+
for (let i = 0; i < cells.length; i++) {
|
|
155
|
+
ctx.setCell(cells[i], blackBox((base + i) >>> 0));
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
blackBox(getSink());
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- typed_cache_reads: direct slot/cell reads ----------------------------
|
|
163
|
+
// Parity: bench_typed_cache_reads "context_slot" / "context_cell".
|
|
164
|
+
{
|
|
165
|
+
const ctx = new Context();
|
|
166
|
+
const cell = ctx.cell(42);
|
|
167
|
+
const slot = ctx.computed(() => ctx.getCell(cell));
|
|
168
|
+
blackBox(ctx.get(slot)); // prime
|
|
169
|
+
bench("typed_cache_reads", "context_slot", () => blackBox(ctx.get(blackBox(slot))));
|
|
170
|
+
}
|
|
171
|
+
{
|
|
172
|
+
const ctx = new Context();
|
|
173
|
+
const cell = ctx.cell(99);
|
|
174
|
+
bench("typed_cache_reads", "context_cell", () => blackBox(ctx.getCell(blackBox(cell))));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
await run({
|
|
178
|
+
format: process.env.BENCH_FORMAT === "json" ? "json" : "markdown",
|
|
179
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Criterion-style micro-benchmark harness for lazily-js.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the lazily-rs `benches/context.rs` measurement model (criterion 0.8):
|
|
4
|
+
// - auto-batched timing for *idempotent* routines (each sample times N calls,
|
|
5
|
+
// N auto-scaled so one sample lasts ~TARGET_SAMPLE_MS), and
|
|
6
|
+
// - iter_batched timing for *non-idempotent* routines (fresh `setup()` before
|
|
7
|
+
// every single measured call, setup excluded from the timed region), so a
|
|
8
|
+
// case like `setCell(root, 1)` is always a real 0->1 invalidation rather
|
|
9
|
+
// than a `==`-guarded no-op on the second call.
|
|
10
|
+
//
|
|
11
|
+
// Zero dependencies (node:perf_hooks only) — keeps the published package lean.
|
|
12
|
+
// Emits a Group/Case/Mean/95% CI/p75/p99/Samples markdown table matching
|
|
13
|
+
// lazily-rs's BENCHMARKS.md so cross-language numbers are directly comparable.
|
|
14
|
+
|
|
15
|
+
import { performance } from "node:perf_hooks";
|
|
16
|
+
|
|
17
|
+
// Sink the optimizer cannot eliminate — black_box analog (force a read+write
|
|
18
|
+
// through a module-level mutable captured by an exported function).
|
|
19
|
+
let __blackHole = undefined;
|
|
20
|
+
export function blackBox(value) {
|
|
21
|
+
__blackHole = value;
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
export function doNotOptimize(value) {
|
|
25
|
+
return blackBox(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const TARGET_SAMPLE_MS = 5; // ~criterion measurement_time per sample
|
|
29
|
+
const MIN_SAMPLES = 20;
|
|
30
|
+
const MAX_SAMPLES = 100;
|
|
31
|
+
const WARMUP_ITERS = 64;
|
|
32
|
+
const NS_PER_MS = 1e6;
|
|
33
|
+
const MIN_RUN_MS = 500;
|
|
34
|
+
|
|
35
|
+
function quantile(sorted, q) {
|
|
36
|
+
if (sorted.length === 0) return 0;
|
|
37
|
+
const pos = q * (sorted.length - 1);
|
|
38
|
+
const lo = Math.floor(pos);
|
|
39
|
+
const hi = Math.ceil(pos);
|
|
40
|
+
if (lo === hi) return sorted[lo];
|
|
41
|
+
const frac = pos - lo;
|
|
42
|
+
return sorted[lo] * (1 - frac) + sorted[hi] * frac;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function summarize(samplesNs) {
|
|
46
|
+
const sorted = [...samplesNs].sort((a, b) => a - b);
|
|
47
|
+
const mean = sorted.reduce((a, v) => a + v, 0) / sorted.length;
|
|
48
|
+
const variance =
|
|
49
|
+
sorted.reduce((a, v) => a + (v - mean) ** 2, 0) / sorted.length;
|
|
50
|
+
const sem = Math.sqrt(variance / sorted.length);
|
|
51
|
+
return {
|
|
52
|
+
mean,
|
|
53
|
+
p50: quantile(sorted, 0.5),
|
|
54
|
+
p75: quantile(sorted, 0.75),
|
|
55
|
+
p99: quantile(sorted, 0.99),
|
|
56
|
+
samples: sorted.length,
|
|
57
|
+
ci: 1.96 * sem, // 95% CI half-width (t≈1.96 for n≥20)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function format(ns) {
|
|
62
|
+
if (ns >= 1e6) return `${(ns / 1e6).toFixed(3)} ms`;
|
|
63
|
+
if (ns >= 1e3) return `${(ns / 1e3).toFixed(3)} us`;
|
|
64
|
+
return `${ns.toFixed(3)} ns`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Auto-batched measurement for an idempotent routine (criterion `iter`).
|
|
68
|
+
function measure(fn, { minMs = MIN_RUN_MS } = {}) {
|
|
69
|
+
for (let i = 0; i < WARMUP_ITERS; i++) fn();
|
|
70
|
+
// Calibrate iteration count so one sample hits TARGET_SAMPLE_MS.
|
|
71
|
+
const t0 = performance.now();
|
|
72
|
+
fn();
|
|
73
|
+
const oneNs = (performance.now() - t0) * NS_PER_MS;
|
|
74
|
+
let iters = oneNs > 0 ? Math.max(1, Math.ceil((TARGET_SAMPLE_MS * NS_PER_MS) / oneNs)) : 1;
|
|
75
|
+
|
|
76
|
+
const samples = [];
|
|
77
|
+
let elapsed = 0;
|
|
78
|
+
while ((samples.length < MIN_SAMPLES || elapsed < minMs) && samples.length < MAX_SAMPLES) {
|
|
79
|
+
const start = performance.now();
|
|
80
|
+
for (let i = 0; i < iters; i++) fn();
|
|
81
|
+
const dtMs = performance.now() - start;
|
|
82
|
+
samples.push((dtMs * NS_PER_MS) / iters);
|
|
83
|
+
elapsed += dtMs;
|
|
84
|
+
}
|
|
85
|
+
return summarize(samples);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// iter_batched measurement: fresh setup() before every single measured call,
|
|
89
|
+
// setup excluded from timing (criterion `iter_batched`, BatchSize::SmallInput).
|
|
90
|
+
function measureBatched(setup, routine, { minMs = MIN_RUN_MS, minSamples = MIN_SAMPLES } = {}) {
|
|
91
|
+
for (let i = 0; i < WARMUP_ITERS; i++) routine(setup());
|
|
92
|
+
const samples = [];
|
|
93
|
+
let elapsed = 0;
|
|
94
|
+
while ((samples.length < minSamples || elapsed < minMs) && samples.length < MAX_SAMPLES) {
|
|
95
|
+
const ctx = setup();
|
|
96
|
+
const start = performance.now();
|
|
97
|
+
routine(ctx);
|
|
98
|
+
const ns = (performance.now() - start) * NS_PER_MS;
|
|
99
|
+
samples.push(ns);
|
|
100
|
+
elapsed += ns / NS_PER_MS;
|
|
101
|
+
}
|
|
102
|
+
return summarize(samples);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const registry = [];
|
|
106
|
+
|
|
107
|
+
// Idempotent routine: the function is called many times per sample, so it must
|
|
108
|
+
// be safe to repeat (each call performs the same amount of real work).
|
|
109
|
+
export function bench(group, caseLabel, fn, opts = {}) {
|
|
110
|
+
registry.push({ group, case: caseLabel, kind: "fn", fn, opts });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Non-idempotent routine: setup() runs before every single measured call and is
|
|
114
|
+
// excluded from timing; routine(ctx) runs once per sample. Use this whenever the
|
|
115
|
+
// operation mutates graph state that a second call would skip (e.g. the `==`
|
|
116
|
+
// guard on setCell, or a cold first read that caches on first access).
|
|
117
|
+
bench.batched = function batched(group, caseLabel, setup, routine, opts = {}) {
|
|
118
|
+
registry.push({ group, case: caseLabel, kind: "batched", setup, routine, opts });
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export async function run({ print = true, format = "markdown" } = {}) {
|
|
122
|
+
const results = [];
|
|
123
|
+
for (const entry of registry) {
|
|
124
|
+
let stats;
|
|
125
|
+
if (entry.kind === "fn") stats = measure(entry.fn, entry.opts);
|
|
126
|
+
else stats = measureBatched(entry.setup, entry.routine, entry.opts);
|
|
127
|
+
results.push({ group: entry.group, case: entry.case, ...stats });
|
|
128
|
+
}
|
|
129
|
+
if (print) {
|
|
130
|
+
if (format === "json") console.log(JSON.stringify({ results }, null, 2));
|
|
131
|
+
else printMarkdown(results);
|
|
132
|
+
}
|
|
133
|
+
return results;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function printMarkdown(results) {
|
|
137
|
+
console.log("| Group | Case | Mean | 95% CI | p75 | p99 | Samples |");
|
|
138
|
+
console.log("|---|---|---:|---:|---:|---:|---:|");
|
|
139
|
+
for (const r of results) {
|
|
140
|
+
console.log(
|
|
141
|
+
`| ${r.group} | ${r.case} | ${format(r.mean)} | ± ${format(r.ci)} | ${format(r.p75)} | ${format(r.p99)} | ${r.samples} |`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lazily-hub/lazily-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Native JavaScript port of the lazily reactive core: a full reactive graph (Cell/Slot/Signal/Effect), the lazily-spec IPC wire types, keyed cell collections + LIS reconciliation, the memoized semantic tree, the move-aware sequence CRDT, the Fugue/RGA text CRDT, manufactured text identity, full-Harel state charts, and an FFI state-projection consumer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -46,6 +46,10 @@
|
|
|
46
46
|
"types": "./src/text-crdt.d.ts",
|
|
47
47
|
"default": "./src/text-crdt.js"
|
|
48
48
|
},
|
|
49
|
+
"./lossless-tree-crdt": {
|
|
50
|
+
"types": "./src/lossless-tree-crdt.d.ts",
|
|
51
|
+
"default": "./src/lossless-tree-crdt.js"
|
|
52
|
+
},
|
|
49
53
|
"./state-projection": {
|
|
50
54
|
"types": "./src/state-projection.d.ts",
|
|
51
55
|
"default": "./src/state-projection.js"
|
|
@@ -60,8 +64,11 @@
|
|
|
60
64
|
}
|
|
61
65
|
},
|
|
62
66
|
"scripts": {
|
|
63
|
-
"build": "node --check src/index.js && node --check src/reactive.js && node --check src/reactive-async.js && node --check src/state-machine.js && node --check src/statechart.js && node --check src/collections.js && node --check src/sem-tree.js && node --check src/stable-id.js && node --check src/seq-crdt.js && node --check src/text-crdt.js && node --check src/state-projection.js && node --check src/signaling.js && node --check src/distributed.js",
|
|
67
|
+
"build": "node --check src/index.js && node --check src/reactive.js && node --check src/reactive-async.js && node --check src/state-machine.js && node --check src/statechart.js && node --check src/collections.js && node --check src/sem-tree.js && node --check src/stable-id.js && node --check src/seq-crdt.js && node --check src/text-crdt.js && node --check src/utf8-offsets.js && node --check src/lossless-tree-crdt.js && node --check src/state-projection.js && node --check src/signaling.js && node --check src/distributed.js",
|
|
64
68
|
"test:formal": "node scripts/formal-check.mjs",
|
|
69
|
+
"bench": "node bench/context.bench.mjs",
|
|
70
|
+
"benchmark-update": "node scripts/run-benchmarks.mjs",
|
|
71
|
+
"benchmark-check": "node scripts/run-benchmarks.mjs --check",
|
|
65
72
|
"test": "npm run test:formal && node --test test/*.test.js"
|
|
66
73
|
},
|
|
67
74
|
"dependencies": {
|