Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/machine-notify-and-cleanup-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@dunky.dev/state-machine': patch
---

Harden the notify and teardown paths across the core, and cut hot-path
allocations:

- A listener removed during a notify pass no longer fires again when a nested
notify (a send from inside a subscriber) rebuilds the iteration snapshot
mid-pass — unsubscribing is now final even under re-entrancy. The same
guarantee now holds for connector and store subscribers, and the mechanism
lives in one shared primitive instead of three near-copies.
- A state cleanup that throws no longer skips the remaining cleanups or leaves
the pass populated: every cleanup runs (timers and subscriptions all
release), the first error is rethrown after the pass, and the next stop
cannot double-run them.
- A `sync()` rule or `combine().subscribe()` disposed by hand now detaches from
the composition's registry — long-lived groups with subscribe/unsubscribe
churn no longer grow it without bound, and `stop()` no longer re-runs
hand-run disposers.
- Computed recompute is allocation-free: dep keys/values live in reused
buffers and are captured at read time, so the old post-pass that re-read
every dep is gone. In the benchmark suite this lands recompute ~1.5× and
4-deep computed chains ~1.6× faster.
- `machine.select` is built once and reused instead of allocating a fresh
facade object on every property access, so its identity is stable (safe for
dependency arrays).
- Dropped the internal write-only `version` counter — bumped on every notify,
read by nothing.
24 changes: 15 additions & 9 deletions .claude/skills/benchmark/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Do not proceed to Step 3 unless the user says yes.

## Step 3 — update the results (only on a yes)

Three files carry benchmark numbers. Update all three in one pass:
Four files carry benchmark numbers. Update all four in one pass:

### A. `benchmark/README.md` — full tables (source of truth)

Expand Down Expand Up @@ -83,16 +83,15 @@ The core README carries short prose claims ("up to ~8× the event throughput",
headline ratio clearly crossed a round number (e.g. throughput drops from ~8× to
~6×, or memory from ~33× to ~25×). Don't churn it for a rounding wobble.

Relevant lines to check:
Relevant line to check:

- The `### Performance` section prose claim ("up to ~8× the event throughput").
- The memory comparison claim ("~33×" in the `## How it compares` diff table footnotes).
- The `### Performance` section prose claim ("up to ~N× the event throughput").
(There is no memory-ratio claim in this file — verify with a grep for `×`
rather than assuming this list is complete.)

### C. `website/src/pages/benchmark.mdx` — headline table + section numbers
### C. `website/src/content/docs/benchmark.mdx` — headline table + section numbers

The website benchmark page at
`/Users/ivanbanov/dev/dunky-dev/.worktrees/website/website/src/pages/benchmark.mdx`
carries:
The website benchmark page (at that path from the repo root) carries:

1. **The headline table** — event throughput ops/s, memory 2-field and 64-field
KB/machine. Update all three Dunky / XState / Zag cells.
Expand All @@ -103,7 +102,14 @@ carries:

Apply the same K/M notation rules as `benchmark/README.md`.

### After updating all three
### D. root `README.md` — the "Fast at scale" headline table

The repo root README's performance section carries a small three-row
table (event throughput, memory 2-field, memory 64-field) plus a `→` prose line with ratio claims. Update
the table cells and recheck both ratios against the fresh figures — same K/M
notation rules.

### After updating all four

- Re-read each edited file's tables to confirm markdown pipes still line up.
- Run `pnpm format` at the repo root so formatting matches the repo style.
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,13 @@ canvas board, a game HUD. There the cost of each transition and the memory per
machine, multiplied by thousands, is what decides whether you hold the frame. The
engine is built for it:

| At scale (thousands of machines) | Dunky | XState | Zag |
| -------------------------------- | --------: | -----: | ------: |
| Event throughput (ops/s) | **7.2 M** | 897 K | n/a ᵃ |
| Memory / machine, 2-field (KB) | **3.6** | 3.6 | 9.1 |
| Memory / machine, 64-field (KB) | **4.1** | 4.1 | **134** |
| At scale (thousands of machines) | Dunky | XState | Zag |
| -------------------------------- | ---------: | -----: | ------: |
| Event throughput (ops/s) | **11.6 M** | 1.6 M | n/a ᵃ |
| Memory / machine, 2-field (KB) | **3.9** | 3.6 | 8.9 |
| Memory / machine, 64-field (KB) | **4.4** | 4.1 | **134** |

→ **~8× XState's throughput**, on par with XState for memory but at least **3× lighter than Zag** — and the gap widens as context grows, because memory stays ~flat in field count (no per-field cell). ᵃ Zag uses async ops, so a synchronous ops/s loop can't time it. Full methodology + per-scenario tables in the
→ **~7× XState's throughput**, on par with XState for memory but at least **2× lighter than Zag** — and the gap widens as context grows, because memory stays ~flat in field count (no per-field cell). ᵃ Zag uses async ops, so a synchronous ops/s loop can't time it. Full methodology + per-scenario tables in the
**[benchmark README](./benchmark/README.md)**.

**▶ [Try the live benchmark demo](https://dunky.dev/state-machine/benchmark/demo)** — watch all three engines run in your browser.
Expand Down
96 changes: 48 additions & 48 deletions benchmark/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ A performance harness for `@dunky.dev/state-machine`. It measures the engine's
hot paths in isolation and compares the runnable parts against
[XState](https://stately.ai/docs) and [Zag](https://zagjs.com/).

Numbers below are from one clean run (Node 24, Apple Silicon). Absolute figures
Numbers below are averaged over four clean runs (Node 24, Apple Silicon). Absolute figures
vary by machine, Node version, and thermal state — **run it yourself** — but the
ranking and the scaling shape are what hold.

Expand Down Expand Up @@ -95,11 +95,11 @@ XState's coarse `actor.subscribe`.

| Change 1 of N | Dunky (ops/sec) | XState (ops/sec) | Zag |
| ------------- | --------------: | ---------------: | ----: |
| 100 | 325 K | 253 K | n/a ᵃ |
| 1000 | 10.7 K | 10.7 K | n/a ᵃ |
| 5000 | **7.9 K** | 741 | n/a ᵃ |
| 100 | 658.5 K | 437.8 K | n/a ᵃ |
| 1000 | 56.8 K | 49.2 K | n/a ᵃ |
| 5000 | **15.5 K** | 1.3 K | n/a ᵃ |

→ Roughly par at small N, but Dunky **~10× faster at 5000 observers** — XState's
→ Roughly par at small N, but Dunky **~11× faster at 5000 observers** — XState's
coarse subscribe degrades much faster as the observer set grows.

**B. Fine-grain — change an UNOBSERVED field.** Change a field nobody selects. The
Expand All @@ -109,23 +109,23 @@ model gets for free and a coarse bus has to work to ignore.

| Irrelevant write, N cells | Dunky (ops/sec) | XState (ops/sec) | Zag |
| ------------------------- | --------------: | ---------------: | ----: |
| 1000 | **4.5 M** | 536 K | n/a ᵃ |
| 5000 | **1.9 M** | 453 K | n/a ᵃ |
| 1000 | **5.4 M** | 987.9 K | n/a ᵃ |
| 5000 | **4.4 M** | 844.1 K | n/a ᵃ |

→ Dunky is **~8× faster** at shrugging off a write nobody is watching (1000
→ Dunky is **~6× faster** at shrugging off a write nobody is watching (1000
cells); the value-deduping bus skips waking observers entirely.

**C. Throughput — single machine, one event.** Per-transition cost with no
selection scaling — the raw `send` price.

| Single machine, one event | ops/sec |
| ------------------------- | --------: |
| Dunky | **7.2 M** |
| XState (raw) | 898 K |
| XState (diffed) | 897 K |
| Zag | n/a ᵃ |
| Single machine, one event | ops/sec |
| ------------------------- | ---------: |
| Dunky | **11.6 M** |
| XState (raw) | 1.6 M |
| XState (diffed) | 1.6 M |
| Zag | n/a ᵃ |

→ Dunky pushes **~8× the events/sec** of XState. Context is mutated in place,
→ Dunky pushes **~7× the events/sec** of XState. Context is mutated in place,
so a transition allocates nothing; XState builds a fresh snapshot per event.

## 2. Compose / synced machines (`tests/compose.ts`)
Expand All @@ -142,12 +142,12 @@ change (the O(members) path by design).

| Members | Dunky combine (ops/sec) | Dunky sync (ops/sec) | XState | Zag |
| ------- | ----------------------: | -------------------: | ------ | ----- |
| 2 | 6.7 M | 7.1 M | n/a ᶠ | n/a ᶠ |
| 10 | 6.6 M | 6.5 M | n/a ᶠ | n/a ᶠ |
| 50 | 5.8 M | 6.2 M | n/a ᶠ | n/a ᶠ |
| 2 | 11.0 M | 11.7 M | n/a ᶠ | n/a ᶠ |
| 10 | 10.1 M | 10.5 M | n/a ᶠ | n/a ᶠ |
| 50 | 8.7 M | 10.2 M | n/a ᶠ | n/a ᶠ |

→ Cross-region coordination stays in the **~5.8–7.1 M ops/sec** band even at 50
synced members — the O(M) re-eval pass costs ~13% going from 2 to 50.
→ Cross-region coordination stays in the **~8.7–11.7 M ops/sec** band even at 50
synced members — the O(M) re-eval pass costs ~13–21% going from 2 to 50.

> A third "chain" sub-test (a sync rule that `send()`s downstream every change) was
> removed: under a tight loop it shows superlinear slowdown. That's a real
Expand All @@ -163,13 +163,13 @@ profile: XState has no first-class lazy/memoized `computed` (**n/a ᶠ**), and Z

| Scenario | Dunky (ops/sec) | XState | Zag |
| ------------------------------------ | --------------: | ------ | ----- |
| Cached read (no change) | **16.6 M** | n/a ᶠ | n/a ᵃ |
| Fine-grain (change unread, re-read) | 6.2 M | n/a ᶠ | n/a ᵃ |
| Recompute (change read field) | 2.1 M | n/a ᶠ | n/a ᵃ |
| 4-deep chain (change root, read tip) | 567 K | n/a ᶠ | n/a ᵃ |
| Cached read (no change) | **44.0 M** | n/a ᶠ | n/a ᵃ |
| Fine-grain (change unread, re-read) | 10.7 M | n/a ᶠ | n/a ᵃ |
| Recompute (change read field) | 4.9 M | n/a ᶠ | n/a ᵃ |
| 4-deep chain (change root, read tip) | 1.5 M | n/a ᶠ | n/a ᵃ |

→ A cached read is **~16.6 M/sec** (near-free memo hit), and changing a field the
computed _doesn't_ read stays a memo hit at ~6.2 M/sec — read-key tracking means
→ A cached read is **~44 M/sec** (near-free memo hit), and changing a field the
computed _doesn't_ read stays a memo hit at ~10.7 M/sec — read-key tracking means
you only pay the recompute when an input you actually read changes.

## 4. Engine hot paths (`tests/engine.ts`)
Expand All @@ -181,15 +181,15 @@ Zag's `send` is async (**n/a ᵃ**).

| Scenario | Dunky (ops/sec) | XState | Zag |
| -------------------------------------- | --------------: | ------ | ----- |
| Guard fallthrough — 2 candidates | 3.4 M | n/a ᶠ | n/a ᵃ |
| Guard fallthrough — 8 candidates | 2.9 M | n/a ᶠ | n/a ᵃ |
| Guard fallthrough — 32 candidates | 2.0 M | n/a ᶠ | n/a ᵃ |
| State churn — exit+entry every event | 5.6 M | n/a ᶠ | n/a ᵃ |
| Effect churn — boot+cleanup each trans | 5.5 M | n/a ᶠ | n/a ᵃ |
| Sub churn — stable set | 7.0 M | n/a ᶠ | n/a ᵃ |
| Sub churn — churning set (rebuild) | 4.8 M | n/a ᶠ | n/a ᵃ |

→ Even the heavy paths hold **~2–7 M ops/sec**: a 32-candidate guard walk, full
| Guard fallthrough — 2 candidates | 5.2 M | n/a ᶠ | n/a ᵃ |
| Guard fallthrough — 8 candidates | 4.5 M | n/a ᶠ | n/a ᵃ |
| Guard fallthrough — 32 candidates | 3.2 M | n/a ᶠ | n/a ᵃ |
| State churn — exit+entry every event | 8.8 M | n/a ᶠ | n/a ᵃ |
| Effect churn — boot+cleanup each trans | 8.2 M | n/a ᶠ | n/a ᵃ |
| Sub churn — stable set | 11.1 M | n/a ᶠ | n/a ᵃ |
| Sub churn — churning set (rebuild) | 8.0 M | n/a ᶠ | n/a ᵃ |

→ Even the heavy paths hold **~3–11 M ops/sec**: a 32-candidate guard walk, full
state transitions with entry/exit actions, and effect boot/cleanup every
transition all stay in the same order of magnitude as a bare `send`.

Expand All @@ -201,11 +201,11 @@ warmed first.

| Build + start | Dunky (µs/machine) | XState | Zag |
| ------------- | -----------------: | -----: | ---: |
| 10 000 | 2.42 | 1.95 | 8.16 |
| 10 000 | 1.58 | 1.35 | 5.06 |

→ Construction is the one axis where Dunky **doesn't** win — XState spins up
~1.2× faster. Dunky's bet is flat memory + hot-path throughput, not spin-up;
it's still ~3.4× faster than Zag's per-field reactive cells.
it's still ~3.2× faster than Zag's per-field reactive cells.

## 6. Memory per machine (`tests/memory.ts`)

Expand All @@ -217,12 +217,12 @@ the footprint a churny app actually pays).

| Context | Dunky (KB/machine) | XState | Zag |
| -------- | -----------------: | -----: | ------: |
| 2-field | 3.60 | 3.62 | 9.06 |
| 64-field | 4.10 | 4.10 | **134** |
| 2-field | 3.85 | 3.61 | 8.92 |
| 64-field | 4.35 | 4.10 | **134** |

→ Going 2 → 64 fields costs Dunky only **~0.5 KB/machine** — memory grows with
the data you store, not with a per-field cell. **Zag is the contrast**: one reactive
cell per field balloons the 64-field context to ~134 KB/machine — **~33× more** than
cell per field balloons the 64-field context to ~134 KB/machine — **~31× more** than
Dunky.

**Idle vs written.** Dunky owns its context copy from construction and mutates
Expand All @@ -231,8 +231,8 @@ lazy-copy scheme steps up once writes start:

| 64-field, 5000 machines | Dunky | XState | Zag |
| ----------------------- | ----: | -----: | --: |
| Idle (never written) | 4.10 | 3.55 | 130 |
| Written (1 event each) | 4.10 | 4.10 | 134 |
| Idle (never written) | 4.35 | 3.55 | 130 |
| Written (1 event each) | 4.35 | 4.10 | 134 |

→ Dunky idle ≡ written; XState's first `assign` allocates a per-actor context,
so its written row grows.
Expand All @@ -250,15 +250,15 @@ List of 1000 rows:

| Strategy | Rows woken / move | Mount (ms) | Re-render wall (ms) |
| -------------------- | ----------------: | ---------: | ------------------: |
| Dunky/instance | **2** | 5.6 | **3.9** |
| Dunky/selector | 2 | 8.4 | 5.9 |
| xstate/selector | 2 | 5.7 | 6.8 |
| zag/instance | 2 | 6.2 | n/a ᵃ |
| naive (anti-pattern) | **980** | 7.1 | 56.2 |
| Dunky/instance | **2** | 3.6 | **2.4** |
| Dunky/selector | 2 | 4.9 | 3.8 |
| xstate/selector | 2 | 3.7 | 4.3 |
| zag/instance | 2 | 3.9 | n/a ᵃ |
| naive (anti-pattern) | **980** | 4.5 | 38.3 |

→ Every properly-set-up engine wakes only the **2** rows that changed (vs. the
naive whole-snapshot subscription, which re-renders all **980** — a ~490× gap and
~14× the wall time). Among the surgical strategies Dunky re-renders **~1.7×
~16× the wall time). Among the surgical strategies Dunky re-renders **~1.8×
faster than XState**.

Zag mounts and wakes the same **2** rows, but its re-render wall is **n/a ᵃ** — the
Expand Down
2 changes: 1 addition & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ monitoring walls, game HUDs). Context is one plain object mutated in place behin
a value-deduping bus, so a transition allocates nothing and an irrelevant write
wakes no observers.

In practice that's **up to ~8× the event throughput** of the alternatives, flat
In practice that's **up to ~7× the event throughput** of the alternatives, flat
memory as context grows wide, and surgical re-renders that wake only the rows
that actually changed.

Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/broadcast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/** Payload-less one-to-all notify: add listeners, wake them all, drop them all. */
export interface Broadcast {
add: (listener: () => void) => () => void
notify: () => void
clear: () => void
}

/**
* Steady-state notifies allocate nothing: iteration runs over a cached snapshot,
* re-derived only when membership changes. Mid-pass (un)subscribes still take
* effect within the pass — a dirty flag flips iteration to membership-checked
* mode, and since a nested notify() clears that flag, a swapped snapshot
* (rebuilds always allocate anew) counts as mid-pass churn too.
*/
export function makeBroadcast(): Broadcast {
const listeners = new Set<() => void>()
let snapshot: Array<() => void> = []
let dirty = false
return {
add(listener) {
listeners.add(listener)
dirty = true
return () => {
listeners.delete(listener)
dirty = true
}
},
notify() {
if (dirty) {
snapshot = [...listeners]
dirty = false
}
const snap = snapshot
for (const l of snap) {
if ((!dirty && snap === snapshot) || listeners.has(l)) l()
}
},
clear() {
listeners.clear()
dirty = true
},
}
}
Loading
Loading