From 53bd60cbcf8f424bf66e0b66283b1e393bcb83d4 Mon Sep 17 00:00:00 2001 From: Jake Klassen Date: Tue, 13 Oct 2026 21:01:48 -0400 Subject: [PATCH 1/4] chore: bump node to 26.11.1 and pnpm to 12.12.1 Co-Authored-By: Claude Opus 4.8 --- mise.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mise.toml b/mise.toml index 681dfe1..c5c88ea 100644 --- a/mise.toml +++ b/mise.toml @@ -1,3 +1,3 @@ [tools] -pnpm = "11.15.1" -node = "26.5.0" +pnpm = "12.12.1" +node = "26.11.1" From de9cb4f1ae9cb450472cee10fb53bbf4b1164c05 Mon Sep 17 00:00:00 2001 From: Jake Klassen Date: Tue, 13 Oct 2026 21:01:48 -0400 Subject: [PATCH 2/4] perf(examples): hoist bounds and inline AABB test in shmup collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoist the outer entity's AABB bounds out of the inner loop (the previous version recomputed them for every otherEntity) and inline the rectangle intersection test, which removes the two temp AABB object literals (four allocations) built on every pair check. Drops the now-unused intersects import. Selected collision pairs and emitted events are unchanged — this is the serial/precomputed transformation whose pair-for-pair equivalence to the original is asserted by the perf-proofs parallel-producer parity harness. Co-Authored-By: Claude Opus 4.8 --- .../demos/shmup/systems/collision-system.ts | 63 +++++++++---------- 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/packages/examples/src/demos/shmup/systems/collision-system.ts b/packages/examples/src/demos/shmup/systems/collision-system.ts index e9a10a9..008daaf 100644 --- a/packages/examples/src/demos/shmup/systems/collision-system.ts +++ b/packages/examples/src/demos/shmup/systems/collision-system.ts @@ -1,4 +1,3 @@ -import { intersects } from "#/lib/collision/aabb.ts"; import { World } from "objecs"; import { Config } from "../config.ts"; import { EnemyType } from "../constants.ts"; @@ -31,6 +30,16 @@ export function collisionSystemFactory({ continue; } + const { boxCollider, transform } = entity; + const collisionLayer = entity.collisionLayer; + + // Hoist the outer entity's bounds out of the inner loop. The previous + // version recomputed these for every otherEntity. + const left = transform.position.x + boxCollider.offsetX; + const top = transform.position.y + boxCollider.offsetY; + const right = left + boxCollider.width; + const bottom = top + boxCollider.height; + for (const otherEntity of collidables.entities) { if (entity === otherEntity) { continue; @@ -42,42 +51,28 @@ export function collisionSystemFactory({ // Make sure entityA's collision layer is a subset of entityB's // collision mask - if ( - (entity.collisionLayer & otherEntity.collisionMask) !== - entity.collisionLayer - ) { + if ((collisionLayer & otherEntity.collisionMask) !== collisionLayer) { continue; } - const { boxCollider, transform } = entity; - - const { boxCollider: otherBoxCollider, transform: otherTransform } = - otherEntity; - - const x = transform.position.x + boxCollider.offsetX; - const y = transform.position.y + boxCollider.offsetY; - const otherX = otherTransform.position.x + otherBoxCollider.offsetX; - const otherY = otherTransform.position.y + otherBoxCollider.offsetY; - - const aabb = { - position: { - x, - y, - }, - width: boxCollider.width, - height: boxCollider.height, - }; - - const otherAabb = { - position: { - x: otherX, - y: otherY, - }, - width: otherBoxCollider.width, - height: otherBoxCollider.height, - }; - - if (!intersects(aabb, otherAabb)) { + const otherBoxCollider = otherEntity.boxCollider; + const otherTransform = otherEntity.transform; + + const otherLeft = otherTransform.position.x + otherBoxCollider.offsetX; + const otherTop = otherTransform.position.y + otherBoxCollider.offsetY; + + // Inlined AABB intersection test. The previous version allocated + // two temp AABB literals (four objects) on every pair check; this + // reads the hoisted bounds directly. Same comparison as + // `intersects()` in lib/collision/aabb.ts. + if ( + !( + left < otherLeft + otherBoxCollider.width && + right > otherLeft && + top < otherTop + otherBoxCollider.height && + bottom > otherTop + ) + ) { continue; } From d2c1f0778b49f2e6faad1cfd7d158debff31f02b Mon Sep 17 00:00:00 2001 From: Jake Klassen Date: Tue, 13 Oct 2026 21:01:48 -0400 Subject: [PATCH 3/4] test(perf-proofs): add parallel-producer worker spike Evidence-based evaluation of Worker threads + shared read-only snapshots for producer-only systems, using shmup collision as the probe. Adds three serial kernels (current-object replica, precomputed, packed SoA), a persistent SAB worker pool, the end-to-end benchmark matrix, and a correctness gate (cross-kernel parity + hand-derived ground truth) that aborts rather than reporting timings for incorrect kernels. Finding: the single-threaded packed cleanup is ~10x; SAB workers add a further 2.5-5.5x only at large-N low-density, and lose on dense collisions because emit-all-candidates forfeits the serial greedy early-break. Full writeup and recommendation in src/worker/README.md; original brief in src/worker/SPIKE.md. Co-Authored-By: Claude Opus 4.8 --- packages/perf-proofs/README.md | 17 + packages/perf-proofs/package.json | 6 +- packages/perf-proofs/src/worker/README.md | 327 ++++ packages/perf-proofs/src/worker/SPIKE.md | 1446 +++++++++++++++++ .../src/worker/collision-producer.ts | 228 +++ .../src/worker/collision-serial.ts | 175 ++ packages/perf-proofs/src/worker/lib/aabb.ts | 32 + .../src/worker/lib/collision-worker.mjs | 133 ++ .../perf-proofs/src/worker/lib/control.mjs | 29 + packages/perf-proofs/src/worker/lib/entity.ts | 63 + .../perf-proofs/src/worker/lib/fixtures.ts | 192 +++ .../perf-proofs/src/worker/lib/kernels.ts | 355 ++++ packages/perf-proofs/src/worker/lib/parity.ts | 285 ++++ packages/perf-proofs/src/worker/lib/reduce.ts | 62 + .../src/worker/lib/shared-snapshot.ts | 95 ++ .../perf-proofs/src/worker/lib/worker-pool.ts | 245 +++ .../perf-proofs/src/worker/worker-floor.ts | 103 ++ packages/perf-proofs/tsconfig.json | 1 + pnpm-lock.yaml | 26 +- 19 files changed, 3797 insertions(+), 23 deletions(-) create mode 100644 packages/perf-proofs/src/worker/README.md create mode 100644 packages/perf-proofs/src/worker/SPIKE.md create mode 100644 packages/perf-proofs/src/worker/collision-producer.ts create mode 100644 packages/perf-proofs/src/worker/collision-serial.ts create mode 100644 packages/perf-proofs/src/worker/lib/aabb.ts create mode 100644 packages/perf-proofs/src/worker/lib/collision-worker.mjs create mode 100644 packages/perf-proofs/src/worker/lib/control.mjs create mode 100644 packages/perf-proofs/src/worker/lib/entity.ts create mode 100644 packages/perf-proofs/src/worker/lib/fixtures.ts create mode 100644 packages/perf-proofs/src/worker/lib/kernels.ts create mode 100644 packages/perf-proofs/src/worker/lib/parity.ts create mode 100644 packages/perf-proofs/src/worker/lib/reduce.ts create mode 100644 packages/perf-proofs/src/worker/lib/shared-snapshot.ts create mode 100644 packages/perf-proofs/src/worker/lib/worker-pool.ts create mode 100644 packages/perf-proofs/src/worker/worker-floor.ts diff --git a/packages/perf-proofs/README.md b/packages/perf-proofs/README.md index 18dd2a8..b4fda06 100644 --- a/packages/perf-proofs/README.md +++ b/packages/perf-proofs/README.md @@ -54,3 +54,20 @@ node --expose-gc src/object/hasown-vs-undefined-check.ts ### function/ - **call-vs-direct-invocation** — `Function.prototype.call()` vs direct invocation in tight loops. Maps to `EntityCollection.forEach()`. + +### worker/ + +Spike category — evaluating parallel producer systems (Worker threads + +shared read-only snapshots). Unlike the other categories, these benchmarks +depend on the built `objecs` package and gate on correctness assertions +before reporting timings. + +- **collision-serial** — three serial collision kernels (faithful shmup replica, + precomputed, packed SoA) establishing the baseline any Worker variant must + beat. Maps to `packages/examples/src/demos/shmup/systems/collision-system.ts`. + +See [`src/worker/README.md`](src/worker/README.md) for results and status. + +```bash +pnpm --filter perf-proofs start:worker +``` diff --git a/packages/perf-proofs/package.json b/packages/perf-proofs/package.json index 7399201..cc70b90 100644 --- a/packages/perf-proofs/package.json +++ b/packages/perf-proofs/package.json @@ -4,17 +4,19 @@ "version": "0.0.0", "type": "module", "scripts": { - "start": "for f in $(find src -name '*.ts' | sort); do echo \"\\n--- $f ---\"; node --expose-gc \"$f\"; done", + "start": "for f in $(find src -name '*.ts' -not -path '*/lib/*' | sort); do echo \"\\n--- $f ---\"; node --expose-gc \"$f\"; done", "start:object": "for f in src/object/*.ts; do echo \"\\n--- $f ---\"; node --expose-gc \"$f\"; done", "start:array": "for f in src/array/*.ts; do echo \"\\n--- $f ---\"; node --expose-gc \"$f\"; done", "start:map-set": "for f in src/map-set/*.ts; do echo \"\\n--- $f ---\"; node --expose-gc \"$f\"; done", "start:set": "for f in src/set/*.ts; do echo \"\\n--- $f ---\"; node --expose-gc \"$f\"; done", "start:iteration": "for f in src/iteration/*.ts; do echo \"\\n--- $f ---\"; node --expose-gc \"$f\"; done", "start:function": "for f in src/function/*.ts; do echo \"\\n--- $f ---\"; node --expose-gc \"$f\"; done", + "start:worker": "pnpm --filter objecs build && for f in src/worker/*.ts; do echo \"\\n--- $f ---\"; node --expose-gc \"$f\"; done", "typecheck": "tsc --noEmit" }, "dependencies": { - "mitata": "^1.0.34" + "mitata": "^1.0.34", + "objecs": "workspace:*" }, "devDependencies": { "@types/node": "^26.1.1", diff --git a/packages/perf-proofs/src/worker/README.md b/packages/perf-proofs/src/worker/README.md new file mode 100644 index 0000000..eaf2d9f --- /dev/null +++ b/packages/perf-proofs/src/worker/README.md @@ -0,0 +1,327 @@ +# Parallel producer systems — spike results + +Investigating whether objecs can gain CPU performance from persistent Worker +threads while preserving the plain-object entity API. See [`SPIKE.md`](./SPIKE.md) +for the original brief. + +## Status + +| Phase | State | +| --------------------------- | ------------ | +| 1 — serial controls | **Complete** | +| 2 — Worker round-trip floor | **Complete** | +| 3 — SAB collision worker | **Complete** | +| 4 — benchmark matrix | **Complete** | +| 5 — recommendation | **Complete** | + +## Recommendation (TL;DR) + +**Qualified go, narrow scope — but bank the serial win first.** + +- The single-threaded `serial/packed` cleanup is ~10× and needs no threads or + API change. Do that regardless. It alone takes the shmup collision demo from + "blows the frame budget at n≈1024" to "fine past n≈2048". +- SAB + persistent workers add a **further 2.5–5.5× only in low-collision-density + scenarios at n≥1024** (no-hit, sparse). Crossover vs the best serial is ~n=512. +- **Dense collisions are a loss at every size (0.24–0.50×).** The "emit all + candidates, reduce on main" strategy forfeits the greedy early-`break` that + makes serial fast under heavy overlap — workers do full O(n²) while serial + short-circuits. +- The wins require an **async frame barrier**, which the current synchronous + `Scene.update()` does not support. That integration cost is real and is an + API/architecture decision, not a benchmark detail. + +Net: worth a follow-up **only** if a target workload is genuinely large-N and +low-density. For the shmup demo specifically, a spatial broadphase would beat +both serial and this Worker design and should come first. + +## Running + +```bash +pnpm --filter perf-proofs start:worker # builds objecs, then runs all worker benchmarks +node --expose-gc src/worker/collision-serial.ts # Phase 1 only +SIZES=64,256 node --expose-gc src/worker/collision-serial.ts # quick pass +``` + +`start:worker` rebuilds `objecs` first on purpose: perf-proofs imports the +published `dist` bundle (the package exports no `./src` subpath), so a stale +build would silently benchmark the wrong library. + +## Correctness gate + +The benchmark exits non-zero without printing timings if any kernel disagrees. +Two layers: + +1. **Cross-kernel parity** — all three kernels must select identical pairs, in + identical order, on every fixture. +2. **Hand-derived ground truth** — 11 of the 12 edge cases carry an `expected` + pair list derived by hand from `collision-system.ts:26-181`, not captured + from a kernel run. Cross-kernel agreement alone would happily confirm three + identically-wrong kernels. + +Layer 2 earned its keep immediately: two edge cases originally written as +"layer/mask mismatch, expect zero accepts" were wrong. `Enemy & Enemy` is +`4 & 4 === 4`, which **passes** the directional test — those fixtures were +accept-cases masquerading as reject-cases, and all three kernels "agreed" on +them. They now test what their names claim, and a genuine asymmetric case was +added (rejected as `(0,1)`, accepted as `(1,0)`). + +## Phase 1 results + +`AMD Ryzen 9 7950X3D` (16C/32T), Node v26.5.0, linux/x64, +`availableParallelism() = 32`. Single-threaded throughout. + +Times are mitata `avg`. Lower is better. + +### no-hit (full O(n²) scan, zero accepts — the serial worst case) + +| n | current-object | precomputed | packed (pack+scan) | packed vs current | +| ---: | -------------: | ----------: | -----------------: | ----------------: | +| 256 | 1.09 ms | 447 µs | 114 µs | **9.6×** | +| 512 | 4.67 ms | 2.01 ms | 492 µs | **9.5×** | +| 1024 | 17.98 ms | 7.23 ms | 1.75 ms | **10.3×** | +| 2048 | 70.71 ms | 29.13 ms | 7.04 ms | **10.0×** | +| 4096 | 297.38 ms | 129.56 ms | 27.66 ms | **10.8×** | + +### sparse (shmup-like mix) + +| n | current-object | precomputed | packed (pack+scan) | packed vs current | +| ---: | -------------: | ----------: | -----------------: | ----------------: | +| 256 | 1.13 ms | 427 µs | 112 µs | **10.1×** | +| 512 | 4.37 ms | 1.58 ms | 430 µs | **10.2×** | +| 1024 | 14.88 ms | 5.18 ms | 1.66 ms | **9.0×** | +| 2048 | 49.15 ms | 19.10 ms | 5.80 ms | **8.5×** | +| 4096 | 181.30 ms | 62.19 ms | 19.30 ms | **9.4×** | + +### dense (heavy overlap — greedy `break` fires early and often) + +| n | current-object | precomputed | packed (pack+scan) | packed vs current | +| ---: | -------------: | ----------: | -----------------: | ----------------: | +| 256 | 310 µs | 101 µs | 42.7 µs | **7.2×** | +| 512 | 1.13 ms | 379 µs | 149 µs | **7.6×** | +| 1024 | 3.88 ms | 1.24 ms | 525 µs | **7.4×** | +| 2048 | 14.40 ms | 4.39 ms | 1.83 ms | **7.9×** | +| 4096 | 65.18 ms | 16.19 ms | 6.66 ms | **9.8×** | + +### Snapshot packing cost + +Linear and cheap — **~15–16 ns per collider**, stable across all sizes: + +| n | pack | ns/collider | % of packed end-to-end (no-hit) | +| ---: | -------: | ----------: | ------------------------------: | +| 256 | 4.02 µs | 15.7 | 3.5% | +| 1024 | 15.24 µs | 14.9 | 0.9% | +| 4096 | 65.70 µs | 16.0 | 0.24% | + +Packing is **not** a blocker. It is a rounding error at the sizes where +parallelism could plausibly help, and it is the same O(n) cost any Worker +design must pay. + +## Findings + +**1. The current demo kernel is leaving ~10× on the table, single-threaded.** +This is the headline. `serial/packed` beats the shipped collision code by +7–11× on one thread, across every scenario and size. Most of that is not the +TypedArray layout — it is the four temp objects allocated per pair test and the +outer entity's AABB being rebuilt on every inner iteration. + +**2. The bar for Workers is `serial/packed`, not the current code.** Beating +`serial/current-object` with Workers would prove nothing. Any honest speedup +claim must clear 27.66 ms at no-hit n=4096, not 297 ms. + +**3. Dense is cheaper than no-hit, not more expensive.** Greedy early-`break` +means heavy overlap _reduces_ serial work — dense n=4096 (65 ms) is 4.6× cheaper +than no-hit n=4096 (297 ms) for the current kernel. The serial worst case is the +zero-collision case, which is also the case with the smallest Worker output. +That is the most favourable possible shape for the parallel design. + +**4. Frame-budget context.** At 60 FPS (16.67 ms/frame), `serial/packed` +handles n=2048 no-hit in 7.04 ms (42% of budget) and only exceeds the budget at +n=4096. The current kernel blows the frame budget at n≈1024. So the _demo_ has a +real problem — but it is a serial-optimization problem, not a parallelism one. + +**5. Projected crossover (confirmed in Phase 2/3).** The floor landed near +~100 µs for 4 workers, the favourable end. Measured crossover vs the best serial +is ~n=512 for low-density scenarios. See below. + +## Phase 2 — worker barrier floor + +Steady-state cost of dispatching a zero-work job to every worker and waiting +for all to reply (`postMessage` round trip, through the event loop). Workers, +module parse, and the ready handshake are all excluded — only the round trip is +timed. + +| workers | floor (µs/round-trip) | +| ------: | --------------------: | +| 1 | ~68 | +| 2 | ~80 | +| 4 | ~102 | +| 8 | ~169 | +| 16 | ~281 | + +Two consequences: (a) ~100 µs of fixed overhead per dispatch at 4 workers means +anything whose whole serial job is under ~200 µs cannot win — that is n≤256 +here; (b) the floor **grows** with worker count, because the main thread posts +to and drains every worker single-threaded. Going wide is not free. + +This is an async barrier (through the event loop). An `Atomics.wait`/`notify` +blocking barrier would likely be several times tighter and would also fit a +synchronous game loop — but it cannot fix the dense-collision loss below, which +is algorithmic, and the large-N wins already have negligible barrier cost. Left +as a follow-up (see below). + +## Phase 3/4 — SAB collision workers, end-to-end + +`worker/sab/` = pack shared snapshot → dispatch by outer-index partition → +workers emit ordered candidate pairs → main-thread greedy reduce. **Parity with +the serial ground truth is asserted for every fixture and every worker count +before any timing prints** (84 combinations green). + +Reference = Phase-1 `serial/packed` (isolated run, no worker threads alive — +the honest "best serial you would actually ship" bar). Best worker = fastest +end-to-end across W ∈ {1,2,4,8}. Speedup = serial ÷ best-worker; **>1 = worker +win**, **<1 = worker loss**. + +### no-hit (zero collisions — most favourable for workers) + +| n | serial/packed | best worker (W) | speedup | +| ---: | ------------: | --------------: | --------: | +| 256 | 114 µs | 153 µs (2W) | 0.75× ✗ | +| 512 | 492 µs | 276 µs (4W) | **1.78×** | +| 1024 | 1.75 ms | 590 µs (8W) | **2.97×** | +| 2048 | 7.04 ms | 1.27 ms (8W) | **5.54×** | +| 4096 | 27.66 ms | 5.33 ms (8W) | **5.19×** | + +### sparse (shmup-like mix) + +| n | serial/packed | best worker (W) | speedup | +| ---: | ------------: | --------------: | --------: | +| 256 | 112 µs | 165 µs (4W) | 0.68× ✗ | +| 512 | 430 µs | 258 µs (4W) | **1.67×** | +| 1024 | 1.66 ms | 578 µs (8W) | **2.87×** | +| 2048 | 5.80 ms | 1.81 ms (8W) | **3.20×** | +| 4096 | 19.30 ms | 7.82 ms (8W) | **2.47×** | + +### dense (heavy overlap) — workers LOSE at every size + +| n | serial/packed | best worker (W) | speedup | +| ---: | ------------: | --------------: | ------: | +| 256 | 42.7 µs | 180 µs (4W) | 0.24× ✗ | +| 512 | 149 µs | 397 µs (4W) | 0.38× ✗ | +| 1024 | 525 µs | 1.06 ms (8W) | 0.50× ✗ | +| 2048 | 1.83 ms | 3.70 ms (8W) | 0.49× ✗ | +| 4096 | 6.66 ms | 15.14 ms (8W) | 0.44× ✗ | + +**Why dense loses.** The serial greedy loop breaks on the first accepted +collision per outer entity, so heavy overlap makes it _finish early_. The +workers must emit **every** candidate pair (no `break`, no `handled` check — +that state only exists after the reduction) to keep the reduction deterministic, +so they do the full O(n²) plus a large output transfer. Serial short-circuits; +workers cannot. This is the failure mode the spike brief predicted, and it is +informative rather than a defect. + +**Pack and transfer are not the bottleneck.** end-to-end ≈ compute-only across +the board (e.g. dense n=4096: 15.14 ms vs 15.24 ms), confirming the snapshot +pack (~16 ns/collider) and result transfer are negligible next to the compute. +The cost is the wasted candidate work, not the plumbing. + +### Answers to the brief's ten questions + +1. **Barrier floor?** ~68 µs (1W) → ~102 µs (4W) → ~281 µs (16W), `postMessage`. +2. **Snapshot pack cost?** ~15–16 ns/collider, linear, <1% of end-to-end at + the sizes that matter. +3. **Precomputed vs current serial?** ~2.5× from the cleanup alone; ~10× with + the packed layout. +4. **Where does 2W beat optimized serial?** ~n=1024 for no-hit/sparse; never for + dense. +5. **Where does 4W beat optimized serial?** ~n=512 for no-hit/sparse; never for + dense. +6. **Candidate density effect?** Decisive. Low density → workers win big; + high density → workers lose because serial early-`break` wins. +7. **Does reduction matter to perf?** No — reduction is cheap (O(candidates)); + it is the worker-side full-candidate _emission_ that costs, not the reduce. +8. **Justify a general parallel-producer API?** Only for large-N, low-density + producer workloads, and only with an async barrier. Not on collision alone. +9. **Same-frame async vs one-frame pipelining?** The measured wins require an + async barrier; `Scene.update()` is synchronous today. One-frame pipelining + avoids restructuring the loop at the cost of one frame of event latency. + Undecided — deferred to an API design issue, not the spike. +10. **Other workloads likely to benefit?** Anything O(>n) read-only per frame + with small output and _low_ result density: spatial queries / AI sensing / + visibility / procedural generation. Collision specifically is better served + by a broadphase. + +## Open concern for the go/no-go + +Threading an O(n²) algorithm buys at most `W`×. A spatial broadphase (uniform +grid / sweep-and-prune) is a strictly larger win on one thread and composes with +threading afterwards. Follow-up #1 in the spike brief anticipated this, and the +Phase 1 data supports it: the algorithmic ceiling is far above the parallel one. + +This does not invalidate the spike — the question "can objecs parallelise +producer systems at all" is worth answering on its own terms, and collision is +a convenient probe. But if the motivating goal is _making shmup collision fast_, +the honest ordering is: precompute + pack (10×, done, no API change) → broadphase +→ only then consider Workers. + +## Layout + +``` +src/worker/ + SPIKE.md the original brief (this doc reports its results) + collision-serial.ts Phase 1 — three serial kernels (executable) + worker-floor.ts Phase 2 — barrier floor (executable) + collision-producer.ts Phase 3/4 — SAB workers, end-to-end matrix (executable) + lib/ + entity.ts minimal collision-relevant Entity + CollisionMasks + aabb.ts verbatim copy of the demo's aabb helpers + fixtures.ts seeded deterministic worlds (no Math.random) + kernels.ts the three serial kernels + CollisionSnapshot + parity.ts edge cases, ground truth, parity assertions + control.mjs shared control-block layout (Int32 slots) + collision-worker.mjs persistent worker entry (noop + collision jobs) + worker-pool.ts WorkerPool + range partitioning + shared-snapshot.ts SAB-backed snapshot (packing views + raw buffers) + reduce.ts deterministic greedy candidate reduction +``` + +`lib/` is excluded from the aggregate `pnpm start` script — only top-level +`src/worker/*.ts` files are executable benchmarks. + +### Environment knobs + +```bash +# Phase 1 +node --expose-gc src/worker/collision-serial.ts +SIZES=256,1024 node --expose-gc src/worker/collision-serial.ts + +# Phase 2 +node --expose-gc src/worker/worker-floor.ts + +# Phase 3/4 +node --expose-gc src/worker/collision-producer.ts +SIZES=512,2048 WORKERS=2,4 node --expose-gc src/worker/collision-producer.ts +``` + +### Measurement caveat + +The Phase 3/4 process keeps every worker pool alive at once (up to 4 pools × +8 workers). Idle workers do not burn CPU, but the in-process `serial/packed` +reference in that run measured 30–60% slower on some samples than the isolated +Phase-1 run (e.g. no-hit n=2048: 11.4 ms in-process vs 7.04 ms isolated). The +speedup tables above deliberately use the **faster, isolated Phase-1 serial** as +the reference — the conservative choice, since a faster serial baseline makes +the worker wins _harder_ to claim, not easier. + +## Follow-ups (not done — ranked) + +1. **Bank the serial win.** Apply the `serial/precomputed`+pack cleanup to the + real `collision-system.ts`; validate in a macro/game benchmark. ~10×, no API + change, no threads. Highest value, lowest risk. +2. **Spatial broadphase** (uniform grid / sweep-and-prune). Beats both serial + and this Worker design for collision; composes with threading afterwards. +3. **`Atomics.wait`/`notify` blocking barrier** variant of the pool. Would lower + the small-N floor and suit a synchronous game loop. Won't fix the dense loss. +4. **Re-scope the Worker experiment** onto a genuinely large-N, low-density, + O(>n) read-only producer (AI sensing / visibility / procedural generation) + where the candidate-emission strategy does not forfeit an early-`break`. diff --git a/packages/perf-proofs/src/worker/SPIKE.md b/packages/perf-proofs/src/worker/SPIKE.md new file mode 100644 index 0000000..31bffd0 --- /dev/null +++ b/packages/perf-proofs/src/worker/SPIKE.md @@ -0,0 +1,1446 @@ +# [Spike] Explore producer-only parallel systems with Workers + shared read snapshots + +> **This is the original brief.** The spike is complete — results, numbers, and +> the recommendation live in [`README.md`](./README.md). + +> **Spike / research issue — no public API commitment** +> +> Investigate whether objecs can gain meaningful CPU performance from persistent Worker threads / Web Workers while preserving its current plain-object entity/component API. +> +> The initial model is intentionally constrained: +> +> **A parallel system may read existing entity state, but it may not mutate existing entities or structurally mutate the `World`. Its only logical output is new entities (typically event entities), which are materialized on the main thread after a barrier.** + +## Why this is worth exploring + +objecs is deliberately optimized around ordinary JavaScript objects and fast cached archetype membership. The current hot-path API is excellent: + +```ts +const movables = world.archetype("direction", "transform", "velocity"); + +for (const entity of movables.entities) { + entity.transform.position.x += entity.velocity.x * entity.direction.x * dt; + + entity.transform.position.y += entity.velocity.y * entity.direction.y * dt; +} +``` + +Changing component storage to TypedArrays/native memory would be a large API/philosophy change. Parallel **producer** systems may provide a performance path that leaves the entity API untouched. + +The shmup collision system is the best first candidate because it: + +- reads collidable entities; +- does potentially expensive pairwise work; +- does **not mutate the colliding entities during detection**; +- communicates the result by creating collision event entities; +- currently executes an ordered nested loop over all collidables. + +Relevant source: + +- `packages/examples/src/demos/shmup/systems/collision-system.ts` +- `packages/examples/src/demos/shmup/scenes/gameplay-screen.ts` +- `packages/examples/src/demos/shmup/bitmasks.ts` +- `packages/examples/src/lib/collision/aabb.ts` + +## Repository observations verified for this spike + +Based on `main` as inspected on **2026-07-23**: + +### objecs core + +`packages/objecs/src/world.ts`: + +- entities remain ordinary mutable JS objects; +- `EntityCollection` is array-backed for fast iteration and uses a `Map` for O(1) membership; +- removal is swap-and-pop; +- `archetype()` caches matching entity collections; +- affected archetypes are indexed by component; +- component removal assigns `undefined` rather than using `delete` to keep V8 hidden classes stable; +- `ComponentValue` intentionally excludes functions and symbols. + +`packages/objecs/src/archetype.ts`: + +- archetype membership tests use manual loops rather than `.every()` / `.some()`; +- archetypes remain reactive as components are added/removed. + +This means the benchmark must compare Workers against an already optimized object ECS, not against a naive query-per-frame baseline. + +### Existing performance infrastructure + +`packages/perf-proofs` already exists specifically for evidence-based performance decisions. + +It currently: + +- uses `mitata`; +- runs benchmarks directly with Node and `--expose-gc`; +- uses TypeScript files executed by Node; +- contains benchmarks tied directly to objecs implementation choices; +- has category-specific scripts; +- uses TypeScript `NodeNext`; +- depends on `mitata@^1.0.34`. + +This spike should live there rather than introducing a second benchmark framework. + +Current commands include: + +```bash +pnpm --filter perf-proofs start +pnpm --filter perf-proofs typecheck +``` + +Individual benchmark style: + +```bash +node --expose-gc src/array/for-of-vs-for-index-vs-foreach.ts +``` + +### Runtime matrix + +The normal objecs CI currently tests: + +```text +Node 22.x +Node 24.x +Node 26.x +``` + +The spike should avoid a design that inherently requires Node 26-only APIs unless the benchmark demonstrates a compelling reason. + +`node:worker_threads`, `SharedArrayBuffer`, transferable `ArrayBuffer`, and normal `Atomics` are sufficiently established for the proposed experiment. + +### Browser build + +The package build target is currently `chrome101`. + +The examples use Vite, but the current: + +- `packages/examples/vite.config.ts` +- root `netlify.toml` + +do **not** configure cross-origin isolation headers. + +Browser `SharedArrayBuffer` usage requires a cross-origin-isolated page, normally including: + +```http +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Embedder-Policy: require-corp +``` + +Therefore: + +> **Run the first performance spike under Node worker threads. Do not make browser deployment/header changes as part of the initial experiment.** + +Browser support can be a follow-up if the Node results justify continuing. + +--- + +# Hypothesis + +For systems where: + +```text +snapshot/setup work = O(n) +parallel computation = substantially more expensive than O(n) +output = relatively small +``` + +persistent workers plus shared read-only packed snapshots can outperform the current serial JS implementation without changing the public entity/component API. + +Collision is especially favorable because the current worst-case detection work is approximately pairwise while snapshot packing is linear: + +```text +pack colliders: O(n) +collision search: O(n²) +merge events: O(number of hits/candidates) +``` + +By contrast, a simple movement system is a poor first candidate: + +```text +pack: O(n) +movement: O(n) +apply writes: O(n) +``` + +This spike is specifically intended to find the crossover point rather than assume parallel execution is automatically faster. + +--- + +# Important correctness constraint: current collision behavior is order-dependent + +Do **not** parallelize the current collision loop by having each Worker independently emit every collision it finds. + +The existing shmup collision system has these semantics: + +1. Iterate collidables in their current `EntityCollection` order. +2. Skip an outer entity if it has already been handled. +3. Iterate possible `otherEntity` values in collection order. +4. Skip the same entity and any already-handled entity. +5. Apply the collision-layer/mask predicate. +6. Test AABB intersection. +7. On the **first accepted collision**: + - create the appropriate event entity; + - add both entities to `handledEntities`; + - `break` the inner loop. +8. Continue the outer loop. + +Consequences: + +- an entity participates in at most one accepted collision per collision-system invocation; +- which pair wins can depend on archetype/entity iteration order; +- the layer/mask test is directional in the current implementation; +- a naive parallel "emit all intersecting pairs" implementation changes gameplay behavior. + +The spike must include a parallel implementation whose **final selected collision pairs exactly match the current serial semantics** for deterministic test inputs. + +--- + +# Proposed spike architecture + +## High-level flow + +```text +main thread +──────────────────────────────────────────── + +objecs collidables + │ + │ pack once + ▼ +SharedArrayBuffer collision snapshot + │ + ├──────────────┬──────────────┐ + ▼ ▼ ▼ + worker 0 worker 1 worker 2 ... + │ │ │ +candidate pairs candidate pairs candidate pairs + │ │ │ + └──────────────┴──────────────┘ + │ + transferable + ArrayBuffers + │ + ▼ + deterministic reduction + │ + selected collision pairs + │ + ▼ + create normal objecs event + entities +``` + +Workers never receive or mutate ordinary entity objects. + +The main-thread objecs API remains unchanged. + +## Why the Worker output should initially be candidate pairs + +Although the conceptual feature is "parallel systems produce entities", the shmup collision system has greedy `handledEntities` semantics. + +The cleanest correct decomposition is: + +### Parallel phase + +Workers perform the expensive read-only predicate: + +```text +layer/mask filtering ++ +AABB intersection +``` + +and return ordered candidate pairs: + +```text +outerIndex, innerIndex +``` + +### Main-thread reduction phase + +The main thread reconstructs the current greedy behavior using the candidate pairs: + +```ts +for (let i = 0; i < count; i++) { + if (handled[i]) continue; + + for (const j of candidatesForOuterIndex(i)) { + if (handled[j]) continue; + + // This is the first still-valid collision in the same order as today. + select(i, j); + handled[i] = true; + handled[j] = true; + break; + } +} +``` + +### Materialization phase + +Resolve the pair indices back to the original archetype array: + +```ts +const entities = collidables.entities.raw; + +const entity = entities[i]; +const otherEntity = entities[j]; +``` + +Then execute the existing collision classification logic and call: + +```ts +world.createEntity(...) +``` + +exactly as the current collision system does. + +This keeps Worker data compact and preserves real entity object identity in event components. + +--- + +# Do not introduce entity IDs for the first spike + +For this benchmark/prototype, use indexes into the frozen archetype snapshot: + +```text +0 ... collidables.entities.raw.length - 1 +``` + +Reason: + +- the main thread does not mutate the world while the parallel collision phase is running; +- `collidables.entities.raw` therefore remains stable for the duration of the phase; +- pair indices are cheaper and simpler than adding identity machinery to `World`; +- event entities can still contain the existing entity object references after results return. + +If a future scheduler allows world mutation while Workers are running, stable entity IDs/generations will need a separate design. + +--- + +# Shared snapshot format + +Do **not** structured-clone the entity object graph every frame. + +Use a reusable packed `SharedArrayBuffer`. + +For collision, the Worker only needs values used by the broad/narrow predicate. + +Recommended first layout: + +```ts +type CollisionSnapshot = { + count: number; + + left: Float64Array; + right: Float64Array; + top: Float64Array; + bottom: Float64Array; + + layer: Uint32Array; + mask: Uint32Array; +}; +``` + +Populate once per collision-system invocation: + +```ts +for (let i = 0; i < entities.length; i++) { + const entity = entities[i]; + + const x = entity.transform.position.x + entity.boxCollider.offsetX; + + const y = entity.transform.position.y + entity.boxCollider.offsetY; + + left[i] = x; + right[i] = x + entity.boxCollider.width; + top[i] = y; + bottom[i] = y + entity.boxCollider.height; + + layer[i] = entity.collisionLayer >>> 0; + mask[i] = entity.collisionMask >>> 0; +} +``` + +Precomputing bounds is intentional. + +The current collision implementation creates temporary AABB object literals inside the nested loop and repeatedly recomputes entity bounds. A fair Worker experiment must separately measure whether simply hoisting/prepacking this work on one thread already produces most of the speedup. + +Use `Float64Array` initially rather than `Float32Array` so the spike does not silently change normal JS-number precision. + +## Allocation policy + +Do not allocate new shared input buffers every frame. + +Create a reusable snapshot with a capacity. + +On capacity overflow: + +```text +newCapacity = next power of two >= entityCount +``` + +Reallocate outside the measured hot path where possible. + +Do not rely on growable `SharedArrayBuffer` in the first implementation; ordinary fixed SABs keep the experiment compatible with a wider runtime range. + +--- + +# Synchronization model + +Use persistent workers. + +**Do not create Workers inside the measured per-frame path.** + +Node's own worker-thread documentation recommends pools for repeated CPU work because Worker startup overhead can exceed the work being offloaded. + +## Control messages + +For the first implementation: + +- use `postMessage()` for small job-control messages; +- use a shared input SAB for collision data; +- use transferable `ArrayBuffer`s for each Worker's output; +- use a tiny shared `Int32Array` control block only where memory publication/synchronization is needed. + +Do not use an Atomic operation for each collider field read. + +The Worker should perform ordinary TypedArray reads over shared memory. + +Atomics should be limited to synchronization/control state, e.g. generation/ready markers if used. + +Example conceptual control block: + +```ts +const enum ControlIndex { + Generation = 0, + Count = 1, +} +``` + +Main thread: + +```ts +populateSnapshot(); + +Atomics.store(control, ControlIndex.Count, count); +Atomics.store(control, ControlIndex.Generation, generation); + +worker.postMessage({ + type: "collision", + generation, + start, + end, +}); +``` + +Worker: + +```ts +const publishedGeneration = Atomics.load(control, ControlIndex.Generation); + +if (publishedGeneration !== job.generation) { + throw new Error("stale collision job"); +} + +// Read snapshot with normal TypedArray indexing. +``` + +This is deliberately simple. Benchmarking a fully Atomics-driven job queue can be a separate variant after the basic Worker design works. + +## Output + +Each Worker should own its result buffer. + +Suggested pair representation: + +```ts +Uint32Array; +``` + +with: + +```text +[i0, j0, i1, j1, i2, j2, ...] +``` + +When complete: + +```ts +parentPort.postMessage( + { + generation, + pairCount, + pairs, + }, + [pairs.buffer], +); +``` + +The output buffer is transferred back rather than cloned. + +For reuse, maintain a small output-buffer pool and transfer replacement buffers back to Workers after merge, or allocate during the initial spike and measure the allocation cost explicitly. + +--- + +# Work partitioning + +Partition by **outer collision-loop index**. + +For `W` workers and `N` collidables: + +```text +worker 0: i = 0 ... split1 - 1 +worker 1: i = split1 ... split2 - 1 +... +worker W: i = finalSplit ... N - 1 +``` + +Each Worker scans `j` in ascending order. + +Pseudo-code: + +```ts +for (let i = start; i < end; i++) { + for (let j = 0; j < count; j++) { + if (i === j) continue; + + if ((layer[i] & mask[j]) !== layer[i]) { + continue; + } + + if ( + left[i] < right[j] && + right[i] > left[j] && + top[i] < bottom[j] && + bottom[i] > top[j] + ) { + emitCandidate(i, j); + } + } +} +``` + +Important: + +> Do **not** reduce this to only `i < j` during the first spike. + +The current layer/mask predicate is directional and the current nested-loop ordering is observable through greedy handling. The benchmark must preserve existing behavior before exploring algorithmic changes. + +Because workers own contiguous `i` ranges and scan `j` ascending, concatenating result buffers by Worker range preserves global `(i, j)` order without a sort. + +--- + +# Deterministic reduction + +Given candidate output in global `(i, j)` order: + +```ts +const handled = new Uint8Array(count); + +for (const [i, j] of orderedCandidates) { + if (handled[i] !== 0) continue; + if (handled[j] !== 0) continue; + + selectPair(i, j); + + handled[i] = 1; + handled[j] = 1; +} +``` + +However, verify this against the original exact control flow. + +The original implementation breaks after the first collision for an outer entity and skips an already-handled outer entity entirely. A flat ordered-pair reduction should be equivalent **only if** candidates are globally ordered exactly as the original nested loop evaluates them. + +Add explicit tests for this equivalence. + +If in doubt, store or merge candidates grouped by outer `i` and implement the original nested control structure literally. + +--- + +# Event creation remains on the main thread + +Once pair `(i, j)` is selected: + +```ts +const entity = entities[i]; +const otherEntity = entities[j]; +``` + +Run the existing event-classification code on the original entity objects. + +Examples currently produced by the shmup collision system include: + +```text +eventPlayerProjectileBossCollision +eventPlayerProjectileEnemyCollision +eventPlayerPickupCollision +eventPlayerEnemyCollision +``` + +Do not attempt to serialize entity references into Worker output. + +This is a core advantage of the design: + +```text +Workers: numbers only +Main thread: real objecs entities +``` + +--- + +# Worker pool requirements + +Implement a small dedicated pool for the spike, not a production abstraction. + +Suggested location: + +```text +packages/perf-proofs/src/worker/ + collision-parallel.ts + collision-worker.mjs + worker-pool.ts + fixtures.ts +``` + +Using `.mjs` for the Worker entry is acceptable and avoids making worker startup depend on TypeScript execution support. `packages/perf-proofs/tsconfig.json` already has `allowJs: true`. + +Pool requirements: + +- workers are created once before benchmarks; +- all workers must report ready before measurement starts; +- each job has a monotonically increasing generation/job ID; +- reject on Worker error/exit; +- support clean `terminate()` after benchmarks; +- do not accidentally keep extra Worker creation in the benchmark body; +- default worker counts should be capped by available CPU parallelism. + +Recommended worker-count matrix: + +```text +1 +2 +4 +min(8, availableParallelism) +``` + +Use: + +```ts +import { availableParallelism } from "node:os"; +``` + +Do not assume every benchmark machine has 8 useful cores. + +--- + +# Benchmark plan + +## Location + +Add a new category: + +```text +packages/perf-proofs/src/worker/ +``` + +Add a package script: + +```json +"start:worker": "for f in src/worker/*.ts; do echo \"\\n--- $f ---\"; node --expose-gc \"$f\"; done" +``` + +If helper `.ts` files should not execute directly, prefer one benchmark entry: + +```text +src/worker/collision-producer.ts +``` + +and keep helpers as `.mjs`, or put helpers under a nested `lib/` directory and make `start:worker` target known benchmark filenames. + +Recommended command: + +```bash +pnpm --filter perf-proofs start:worker +``` + +Typecheck: + +```bash +pnpm --filter perf-proofs typecheck +``` + +Normal library regression checks: + +```bash +pnpm --filter objecs test -- --run +pnpm --filter objecs build +``` + +## Use mitata + +Follow the existing perf-proofs style: + +```ts +import { bench, run, summary } from "mitata"; +``` + +Mitata supports asynchronous benchmarks, so Worker round trips can be measured with async benchmark bodies. + +Run with `--expose-gc`, consistent with the rest of the package. + +--- + +# Required benchmark variants + +The benchmark must distinguish **parallelism gains** from simpler serial optimizations. + +## A. Current-style serial object baseline + +Reproduce the current shmup collision kernel as faithfully as practical: + +- plain JS entity objects; +- outer + inner iteration; +- directional layer/mask test; +- compute AABB values from nested transform/collider objects; +- current greedy handled behavior; +- same result checksum. + +Do not include rendering or unrelated game logic. + +Name example: + +```text +serial/current-object +``` + +## B. Optimized serial object baseline + +Before introducing Workers, remove obvious inner-loop overhead while keeping normal objects. + +At minimum: + +- precompute collider bounds once per entity; +- avoid temporary AABB object allocations; +- inline the rectangle comparison. + +This answers: + +> Is the performance opportunity actually "Workers", or is the collision demo simply leaving an easy serial optimization on the table? + +Name: + +```text +serial/precomputed +``` + +## C. Packed TypedArray serial baseline + +Pack the same `left/right/top/bottom/layer/mask` layout intended for Workers, but execute the pair loop on the main thread. + +This isolates: + +- object-property access; +- snapshot packing; +- TypedArray data layout; +- Worker scheduling. + +Measure: + +```text +pack +compute +reduce +end-to-end +``` + +Name: + +```text +serial/packed +``` + +## D. Worker + structured-clone control (optional but useful) + +Send packed ordinary `ArrayBuffer`/TypedArray input without SAB, allowing cloning or transfer according to the chosen control. + +This is useful for demonstrating whether shared input memory materially matters. + +Do not make this variant block the spike if time is limited. + +Name: + +```text +worker/clone +``` + +## E. SharedArrayBuffer + persistent Workers + +The primary experiment. + +For each Worker count: + +```text +worker/sab/1 +worker/sab/2 +worker/sab/4 +worker/sab/8 +``` + +or cap to `availableParallelism()`. + +Measure both: + +```text +compute-only after snapshot is ready +``` + +and: + +```text +end-to-end: +pack + dispatch + worker compute + return + deterministic reduction +``` + +The end-to-end number is the one that determines whether this is useful for objecs. + +--- + +# Required workload sizes + +Start with: + +```text +64 +128 +256 +512 +1_024 +2_048 +4_096 +``` + +Only add `8_192+` if benchmark duration remains reasonable. + +Pairwise work grows quickly. Do not let mitata spend minutes repeatedly running a pathological `N²` case. + +It is acceptable to use fewer samples or separate large-N runs when needed, but document the configuration. + +--- + +# Required workload scenarios + +Use deterministic fixture generation. Do not use uncontrolled `Math.random()` inside setup. + +A tiny seeded xorshift/LCG helper is sufficient; do not add a dependency. + +## 1. No-hit / worst-search case + +Arrange colliders so none overlap. + +Purpose: + +- current serial implementation scans close to the full candidate space; +- Worker output stays tiny; +- this should be one of the most favorable cases for parallel computation. + +## 2. Sparse shmup-like case + +Create a mix of: + +```text +Player +PlayerProjectile +Enemy +EnemyProjectile +Pickup +``` + +using the collision bits currently defined in: + +`packages/examples/src/demos/shmup/bitmasks.ts` + +Current masks: + +```ts +Player: 1 << 0; +PlayerProjectile: 1 << 1; +Enemy: 1 << 2; +EnemyProjectile: 1 << 3; +Pickup: 1 << 4; +``` + +Use positions/sizes that generate a small, reproducible number of overlaps. + +Purpose: + +- approximate the actual demo's filtering behavior; +- avoid benchmarking only synthetic worst cases. + +## 3. Dense-overlap case + +Many colliders overlap. + +Purpose: + +- stress candidate output; +- expose cases where the current greedy serial loop benefits heavily from early `break`; +- show whether "emit every candidate then reduce" is a poor strategy under dense collisions. + +A loss here is acceptable and informative. + +--- + +# Measure the stages separately + +The benchmark report must include these timings or dedicated microbenchmarks: + +```text +1. snapshot packing +2. worker dispatch/barrier overhead with zero/minimal work +3. collision compute +4. result transfer +5. deterministic reduction +6. total end-to-end +``` + +This is necessary to answer where the crossover comes from. + +Also capture: + +```text +Node version +OS +CPU model if easy to obtain +availableParallelism() +worker count +entity count +scenario +candidate pair count +accepted pair count +``` + +Recommended Node helpers: + +```ts +process.version; +process.platform; +process.arch; +availableParallelism(); +cpus()[0]?.model; +``` + +--- + +# Add a Worker round-trip floor benchmark + +Before collision results are interpreted, establish the minimum steady-state cost of the pool. + +Add a tiny benchmark that: + +1. starts workers before measurement; +2. dispatches a generation/job with no collision work; +3. waits for all workers to respond. + +Measure for: + +```text +1 worker +2 workers +4 workers +8 workers (where available) +``` + +This gives an empirical lower bound for any future parallel objecs system. + +If a system does less CPU work than the Worker barrier costs, it should never be auto-parallelized. + +--- + +# Correctness tests + +Performance numbers are not sufficient. + +Add deterministic assertions before invoking `run()`. + +For each fixture: + +```ts +const serial = runCurrentSerial(fixture); +const parallel = await runParallel(fixture); + +assert.deepStrictEqual(parallel.selectedPairs, serial.selectedPairs); +``` + +Test at least: + +- zero entities; +- one entity; +- two colliding entities; +- two non-colliding entities; +- three entities where two possible collisions compete for the same entity; +- directional layer/mask mismatch; +- multiple disjoint collisions; +- dense overlaps; +- worker partition boundaries. + +Especially include a fixture where: + +```text +entity 0 collides with 1 and 2 +entity 1 collides with 0 and 3 +``` + +to prove greedy ordering is preserved. + +The benchmark should abort rather than print performance results if correctness differs. + +--- + +# Observable checksum / anti-optimization requirement + +Ensure benchmark work has an observable result. + +For example: + +```ts +return acceptedPairCount + candidatePairCount; +``` + +or consume a checksum. + +Do not benchmark loops whose results can be proven dead by V8. + +Mitata also provides `do_not_optimize()` if needed. + +--- + +# Avoid benchmark contamination + +Do not include these in the measured steady-state Worker path unless they are deliberately being measured: + +```text +Worker construction +Worker module parsing +initial SAB allocation +initial pool handshake +capacity growth +fixture generation +``` + +Run warmup/ready steps before benchmark measurement. + +Conversely, **do include snapshot packing in the end-to-end variant**, because production objecs must pay that cost every frame with the current object storage model. + +--- + +# Candidate result-buffer strategy + +Start simple. + +Each Worker can grow a normal local JS number array and pack it into a `Uint32Array` before returning. + +Then add a lower-allocation version if profiling says output allocation matters. + +Potential strategies: + +### Simple + +```ts +const pairs: number[] = []; +pairs.push(i, j); +``` + +then: + +```ts +const output = Uint32Array.from(pairs); +``` + +### Reusable capacity + +Maintain a reusable `Uint32Array`, doubling when full. + +Because transferring the underlying `ArrayBuffer` detaches it from the Worker, return replacement buffers from the main thread or use a two-buffer pool. + +Do not build a complicated lock-free shared output queue before measurements show it is needed. + +--- + +# Why not have Workers directly mutate shared objecs state? + +Out of scope. + +Ordinary objecs entities are normal JS objects in one isolate/heap. Workers cannot directly share those objects. + +Moving component storage into SABs would be a fundamentally different ECS/storage project and would change the API/performance model much more substantially. + +The purpose of this spike is to test whether **packing only the read subset needed by expensive producer systems** is enough. + +--- + +# Why not use Atomics for every value? + +Also out of scope and likely counterproductive. + +The intended model is phased: + +```text +main writes snapshot + ↓ +publish/barrier + ↓ +workers read snapshot only + ↓ +workers return output + ↓ +main mutates World +``` + +Bulk collider data should be normal TypedArray reads. + +Atomics are for synchronization/control, not for every `x`, `y`, `width`, etc. + +--- + +# Browser follow-up, only if Node results are promising + +If this spike shows a worthwhile crossover, a second issue should test Dedicated Web Workers. + +Important existing repo constraint: + +The examples currently do not configure cross-origin isolation. + +For a browser SAB prototype, add local Vite server headers such as: + +```ts +server: { + headers: { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + }, +}, +``` + +and equivalent deployment headers. + +Before doing that, audit all demo assets/resources for COEP compatibility. + +Do **not** make these deployment changes in the Node performance spike. + +A non-SAB fallback could later use transferable `ArrayBuffer` double-buffering, but that is a separate measurement. + +--- + +# Production scheduler/API questions deliberately left open + +Do not commit objecs to an API during the spike. + +If the benchmark succeeds, a later design issue can evaluate an API such as: + +```ts +parallelProducer({ + // Declarative read requirements / snapshot builder + reads: ["boxCollider", "collisionLayer", "collisionMask", "transform"], + + // Worker implementation / job description + worker: new URL("./collision.worker.js", import.meta.url), + + // Only main-thread World mutation is allowed here. + merge(world, results) { + for (const result of results) { + world.createEntity(result); + } + }, +}); +``` + +But the actual collision example suggests a generic abstraction may need a reduction/finalization phase: + +```text +snapshot -> parallel detect -> deterministic reduce -> emit entities +``` + +Do not force this into the public API before the performance case is proven. + +--- + +# Sync-vs-async game-loop issue + +The current shmup scene update is synchronous: + +```ts +for (const system of this.systems) { + system(delta); +} +``` + +A browser Worker barrier is naturally asynchronous. + +Do not change `Scene.update()` as part of this benchmark. + +If this feature moves toward production, explicitly evaluate: + +## Option A — async barrier + +```ts +await parallelProducerPhase(); +``` + +Pros: + +- same-frame semantics. + +Cons: + +- introduces async scheduling into the game update pipeline. + +## Option B — one-frame pipelining + +```text +frame N: dispatch detection +frame N+1: consume results +``` + +Pros: + +- current synchronous main loop can remain mostly intact; +- main thread can perform other work while workers run. + +Cons: + +- event latency changes by one frame; +- requires double-buffered snapshots; +- semantics differ from the current collision system. + +## Option C — explicit parallel phase owned by application + +Do not make all systems implicitly async. Expose a separate opt-in mechanism. + +This API decision is explicitly **not part of the spike**. + +--- + +# Suggested implementation sequence for the agent + +## Phase 1 — establish serial controls + +1. Create deterministic collision fixtures. +2. Implement `serial/current-object`. +3. Implement `serial/precomputed`. +4. Implement `serial/packed`. +5. Confirm selected-pair equivalence where applicable. +6. Record whether precomputation alone materially improves the shmup collision kernel. + +## Phase 2 — Worker floor + +1. Implement a persistent `WorkerPool`. +2. Start workers outside benchmark timing. +3. Measure empty/minimal job round-trip for 1/2/4/8 workers. +4. Ensure clean shutdown. + +## Phase 3 — shared collision worker + +1. Implement reusable SAB snapshot. +2. Partition outer indices. +3. Workers produce ordered candidate pairs. +4. Main thread concatenates/merges in original evaluation order. +5. Apply deterministic greedy reduction. +6. Assert exact parity with serial selected pairs. + +## Phase 4 — benchmark matrix + +Run: + +```text +serial/current-object +serial/precomputed +serial/packed +worker/sab/1 +worker/sab/2 +worker/sab/4 +worker/sab/8 if useful +``` + +across: + +```text +64 +128 +256 +512 +1024 +2048 +4096 +``` + +and: + +```text +no-hit +sparse/shmup-like +dense +``` + +## Phase 5 — report + +Add a short Markdown report under: + +```text +packages/perf-proofs/src/worker/README.md +``` + +or update `packages/perf-proofs/README.md`. + +Report: + +- machine/runtime; +- worker counts; +- crossover entity counts; +- best speedup; +- worst regression; +- snapshot cost; +- barrier cost; +- result-transfer/reduction cost; +- whether optimized serial already captures most of the gain; +- recommendation. + +--- + +# Go / no-go criteria + +This is a spike, so avoid inventing a benchmark victory. + +Recommend continuing toward an objecs feature only if the data shows a meaningful region where the **end-to-end** Worker version beats the best reasonable serial version. + +A useful signal would be something like: + +```text +>= 1.25x end-to-end improvement +``` + +for a workload size that is plausibly useful to real games/simulations, with larger workloads scaling further. + +A stronger result would be: + +```text +2x+ at high collision counts +``` + +with a clear, measurable crossover point. + +Recommend **not** adding a parallel-system abstraction if: + +- Workers only beat the unoptimized current collision code but not `serial/precomputed`; +- snapshot packing dominates; +- the crossover only occurs at impractically large workloads; +- candidate output/reduction destroys gains for realistic densities; +- scheduling/API complexity outweighs measured benefit. + +A no-go result is still a successful spike. + +--- + +# Possible follow-up optimizations if the first Worker version is promising + +Do not implement all of these up front. + +1. **Spatial broadphase** + - grid/spatial hash/sweep-and-prune; + - likely much larger collision win than threading the same O(n²) algorithm. + +2. **Shared persistent output buffers** + - only if transferable output allocation is measurable. + +3. **Atomics-based job queue** + - only if `postMessage` control overhead is material. + +4. **Double-buffered snapshots** + - for pipelining Worker compute with main-thread systems/render prep. + +5. **Multiple parallel producer systems** + - collision, AI sensing, visibility, procedural generation. + +6. **Read-set metadata** + - useful later for scheduler conflict analysis. + +7. **Auto threshold** + - keep small workloads on main thread; + - dispatch only above empirically measured crossover. + +--- + +# Definition of done + +- [ ] New Worker benchmark category exists under `packages/perf-proofs`. +- [ ] Uses the existing mitata benchmark stack. +- [ ] Persistent Worker pool; Worker startup excluded from steady-state timing. +- [ ] Shared read-only collision snapshot implemented. +- [ ] Workers never receive ordinary entity objects. +- [ ] Workers return compact candidate pair data. +- [ ] Main-thread reduction preserves the current shmup greedy collision ordering exactly. +- [ ] Correctness assertions cover ordering/contention cases. +- [ ] Benchmarks include current serial, optimized serial, packed serial, and SAB Worker variants. +- [ ] Measurements cover at least 1/2/4 Workers where hardware allows. +- [ ] Measurements cover no-hit, sparse, and dense collision scenarios. +- [ ] End-to-end timing includes snapshot packing. +- [ ] Worker round-trip floor is measured separately. +- [ ] Results identify the crossover point, if any. +- [ ] Results include runtime/hardware metadata. +- [ ] `pnpm --filter perf-proofs typecheck` passes. +- [ ] Existing objecs build/tests remain passing. +- [ ] A written recommendation says **continue** or **do not continue**, based on data. +- [ ] No public objecs API change is required to complete the spike. + +--- + +# Expected conclusion format + +The implementing agent should finish the spike with a table similar to: + +| Scenario | N | Serial current | Serial optimized | SAB 1W | SAB 2W | SAB 4W | Best end-to-end | +| -------- | ---: | -------------: | ---------------: | -----: | -----: | -----: | --------------: | +| no-hit | 512 | | | | | | | +| no-hit | 1024 | | | | | | | +| sparse | 1024 | | | | | | | +| sparse | 2048 | | | | | | | +| dense | 1024 | | | | | | | + +And answer these questions explicitly: + +1. What is the steady-state Worker barrier floor? +2. How much does snapshot packing cost per collider? +3. How much faster is precomputed serial collision than the current object implementation? +4. At what `N`, if any, does SAB + 2 Workers beat optimized serial? +5. At what `N`, if any, does SAB + 4 Workers beat optimized serial? +6. How does candidate density affect the crossover? +7. Does deterministic reduction materially affect performance? +8. Does this justify a general objecs parallel-producer API? +9. If yes, should production use same-frame async barriers or one-frame pipelining? +10. Which other objecs workloads appear likely to benefit? + +--- + +# References + +Repository: + +- https://github.com/jakeklassen/objecs +- https://github.com/jakeklassen/objecs/tree/main/packages/perf-proofs +- https://github.com/jakeklassen/objecs/blob/main/packages/objecs/src/world.ts +- https://github.com/jakeklassen/objecs/blob/main/packages/objecs/src/archetype.ts +- https://github.com/jakeklassen/objecs/blob/main/packages/examples/src/demos/shmup/systems/collision-system.ts +- https://github.com/jakeklassen/objecs/blob/main/packages/examples/src/demos/shmup/scenes/gameplay-screen.ts +- https://github.com/jakeklassen/objecs/blob/main/packages/examples/src/demos/shmup/bitmasks.ts +- https://github.com/jakeklassen/objecs/blob/main/packages/examples/src/lib/collision/aabb.ts +- https://github.com/jakeklassen/objecs/blob/main/.github/workflows/ci.yml + +Worker/shared-memory references: + +- Node Worker Threads: https://nodejs.org/download/release/latest/docs/api/worker_threads.html +- SharedArrayBuffer: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer +- Atomics.wait: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/wait +- Atomics.waitAsync: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/waitAsync +- Browser cross-origin isolation: https://developer.mozilla.org/en-US/docs/Web/API/Window/crossOriginIsolated +- mitata: https://www.npmjs.com/package/mitata diff --git a/packages/perf-proofs/src/worker/collision-producer.ts b/packages/perf-proofs/src/worker/collision-producer.ts new file mode 100644 index 0000000..2dcdcc6 --- /dev/null +++ b/packages/perf-proofs/src/worker/collision-producer.ts @@ -0,0 +1,228 @@ +/** + * collision-producer.ts — Phases 3 & 4 of the parallel-producer spike. + * + * SharedArrayBuffer + persistent workers, measured against the serial controls + * from Phase 1. Workers scan their outer-index range over the shared snapshot + * and emit ordered candidate pairs; the main thread greedily reduces them into + * the exact selected pairs the serial kernel produces. + * + * Correctness gate runs first: for every fixture AND the Phase-1 edge cases, + * the parallel selected pairs must equal the serial ground truth, for each + * worker count. The process exits non-zero without benchmarking on any mismatch. + * + * Two timings per worker count: + * end-to-end — pack + dispatch + worker compute + return + reduce + * compute-only — dispatch + compute + return + reduce (snapshot pre-packed) + * + * end-to-end is the number that decides whether this is useful for objecs. + * + * node --expose-gc src/worker/collision-producer.ts + */ +import { availableParallelism, cpus } from "node:os"; +import { bench, run, summary } from "mitata"; +import { createFixture, type Scenario } from "./lib/fixtures.ts"; +import { + type Collidable, + CollisionSnapshot, + maxPairs, + packSnapshot, + selectPacked, +} from "./lib/kernels.ts"; +import { buildWorld, EDGE_CASES, runAllKernels } from "./lib/parity.ts"; +import { reduceCandidates } from "./lib/reduce.ts"; +import { nextPow2, SharedSnapshot } from "./lib/shared-snapshot.ts"; +import { + type CollisionResult, + partitionRange, + WorkerPool, +} from "./lib/worker-pool.ts"; + +const SCENARIOS: ReadonlyArray = ["no-hit", "sparse", "dense"]; + +const SIZES = (process.env.SIZES ?? "256,512,1024,2048,4096") + .split(",") + .map((s) => Number.parseInt(s.trim(), 10)) + .filter((n) => Number.isFinite(n) && n > 0); + +const MAX_WORKERS = Math.min(8, availableParallelism()); + +const WORKER_COUNTS = (process.env.WORKERS ?? "1,2,4,8") + .split(",") + .map((s) => Number.parseInt(s.trim(), 10)) + .filter((n) => Number.isFinite(n) && n > 0 && n <= MAX_WORKERS); + +const maxSize = Math.max(...SIZES, 32); +const capacity = nextPow2(maxSize); + +console.log("=".repeat(72)); +console.log( + "objecs parallel-producer spike — Phase 3/4: SAB collision workers", +); +console.log("=".repeat(72)); +console.log(`node ${process.version}`); +console.log(`platform ${process.platform}/${process.arch}`); +console.log(`cpu ${cpus()[0]?.model ?? "unknown"}`); +console.log(`availableParallelism ${availableParallelism()}`); +console.log(`worker counts ${WORKER_COUNTS.join(", ")}`); +console.log(`sizes ${SIZES.join(", ")}`); +console.log(`snapshot capacity ${capacity}`); +console.log("=".repeat(72)); +console.log(); + +// --------------------------------------------------------------------------- +// Shared state, allocated once outside every measured path. +// --------------------------------------------------------------------------- + +const snapshot = new SharedSnapshot(capacity); + +// One pool per worker count; workers share the single snapshot's buffers. +const pools = new Map(); + +for (const workerCount of WORKER_COUNTS) { + pools.set( + workerCount, + await WorkerPool.create(workerCount, snapshot.buffers), + ); +} + +console.log(`✓ ${pools.size} pools ready\n`); + +const out = new Int32Array(Math.max(2, maxPairs(capacity) * 2)); +const handled = new Uint8Array(capacity); + +/** + * Full parallel path: pack the shared snapshot, dispatch by outer-index + * partition, greedily reduce the returned candidates into selected pairs. + */ +async function runParallel( + pool: WorkerPool, + raw: ReadonlyArray, +): Promise { + snapshot.pack(raw); + + const partitions = partitionRange(raw.length, pool.workerCount); + const results = await pool.collision(raw.length, partitions); + + return reduceCandidates(results, raw.length, handled, out); +} + +/** Reduce a pre-fetched candidate set (snapshot already packed & dispatched). */ +function selectedPairsFromOut(pairCount: number): number[] { + return Array.from(out.subarray(0, pairCount * 2)); +} + +// --------------------------------------------------------------------------- +// Correctness gate +// --------------------------------------------------------------------------- + +let checks = 0; + +try { + for (const workerCount of WORKER_COUNTS) { + const pool = pools.get(workerCount)!; + + // Edge cases. + for (const edgeCase of EDGE_CASES) { + const { collidables } = buildWorld(edgeCase.specs); + const raw = collidables.entities.raw as ReadonlyArray; + + const truth = runAllKernels(collidables as never).currentObject; + + const pairCount = await runParallel(pool, raw); + const got = selectedPairsFromOut(pairCount); + + if (JSON.stringify(got) !== JSON.stringify(truth)) { + throw new Error( + `parallel mismatch (${workerCount}w) — edge case: ${edgeCase.name}\n` + + ` serial: ${JSON.stringify(truth)}\n` + + ` parallel: ${JSON.stringify(got)}\n`, + ); + } + + checks++; + } + + // Fixtures. + for (const scenario of SCENARIOS) { + for (const size of SIZES) { + const { collidables } = createFixture(scenario, size); + const raw = collidables.entities.raw as ReadonlyArray; + + const truth = runAllKernels(collidables as never).currentObject; + + const pairCount = await runParallel(pool, raw); + const got = selectedPairsFromOut(pairCount); + + if (JSON.stringify(got) !== JSON.stringify(truth)) { + throw new Error( + `parallel mismatch (${workerCount}w) — ${scenario} n=${size}\n` + + ` serial select count: ${truth.length / 2}\n` + + ` parallel select count: ${got.length / 2}\n`, + ); + } + + checks++; + } + } + } +} catch (error) { + console.error((error as Error).message); + console.error("Aborting: refusing to report timings for incorrect kernels."); + await Promise.all([...pools.values()].map((pool) => pool.terminate())); + process.exit(1); +} + +console.log( + `✓ parallel parity verified across ${checks} fixture/worker combinations\n`, +); + +// --------------------------------------------------------------------------- +// Benchmarks +// --------------------------------------------------------------------------- + +for (const scenario of SCENARIOS) { + for (const size of SIZES) { + const { collidables } = createFixture(scenario, size); + const raw = collidables.entities.raw as ReadonlyArray; + + // Serial reference, packed once for the "scan only" comparison. + const serialSnapshot = new CollisionSnapshot(size); + packSnapshot(raw, serialSnapshot); + + // Pre-pack the shared snapshot for the compute-only parallel benches. + const partitionsByWorkers = new Map( + WORKER_COUNTS.map((w) => [w, partitionRange(size, w)]), + ); + + summary(() => { + // Reference line: best serial end-to-end (pack + scan). + bench(`serial/packed (pack+scan) — ${scenario} n=${size}`, () => { + packSnapshot(raw, serialSnapshot); + return selectPacked(serialSnapshot, handled, out); + }); + + for (const workerCount of WORKER_COUNTS) { + const pool = pools.get(workerCount)!; + const partitions = partitionsByWorkers.get(workerCount)!; + + bench(`worker/sab/${workerCount} end-to-end — ${scenario} n=${size}`, async () => { + snapshot.pack(raw); + const results = await pool.collision(size, partitions); + return reduceCandidates(results, size, handled, out); + }); + + bench(`worker/sab/${workerCount} compute-only — ${scenario} n=${size}`, async () => { + const results: CollisionResult[] = await pool.collision( + size, + partitions, + ); + return reduceCandidates(results, size, handled, out); + }); + } + }); + } +} + +await run(); + +await Promise.all([...pools.values()].map((pool) => pool.terminate())); diff --git a/packages/perf-proofs/src/worker/collision-serial.ts b/packages/perf-proofs/src/worker/collision-serial.ts new file mode 100644 index 0000000..f6846d0 --- /dev/null +++ b/packages/perf-proofs/src/worker/collision-serial.ts @@ -0,0 +1,175 @@ +/** + * collision-serial.ts — Phase 1 of the parallel-producer spike. + * + * Establishes the three serial controls the Worker variants must beat: + * + * serial/current-object — faithful replica of the shmup collision kernel + * serial/precomputed — same objects, obvious inner-loop overhead removed + * serial/packed — SoA snapshot the Workers will consume (pack + scan) + * + * The critical question this file answers BEFORE any Worker is written: + * is the opportunity actually parallelism, or is the demo simply leaving an + * easy serial optimization on the table? + * + * Parity across all three kernels is asserted first; the process exits + * non-zero without running benchmarks if any kernel disagrees. + * + * node --expose-gc src/worker/collision-serial.ts + */ +import { availableParallelism, cpus } from "node:os"; +import { bench, run, summary } from "mitata"; +import { createFixture, type Scenario } from "./lib/fixtures.ts"; +import { + type Collidable, + CollisionSnapshot, + maxPairs, + packSnapshot, + selectCurrentObject, + selectPacked, + selectPrecomputed, +} from "./lib/kernels.ts"; +import { + assertParity, + buildWorld, + EDGE_CASES, + runAllKernels, +} from "./lib/parity.ts"; + +const SCENARIOS: ReadonlyArray = ["no-hit", "sparse", "dense"]; + +const SIZES = (process.env.SIZES ?? "64,128,256,512,1024,2048,4096") + .split(",") + .map((s) => Number.parseInt(s.trim(), 10)) + .filter((n) => Number.isFinite(n) && n > 0); + +// --------------------------------------------------------------------------- +// Environment metadata +// --------------------------------------------------------------------------- + +console.log("=".repeat(72)); +console.log("objecs parallel-producer spike — Phase 1: serial controls"); +console.log("=".repeat(72)); +console.log(`node ${process.version}`); +console.log(`platform ${process.platform}/${process.arch}`); +console.log(`cpu ${cpus()[0]?.model ?? "unknown"}`); +console.log(`availableParallelism ${availableParallelism()}`); +console.log(`sizes ${SIZES.join(", ")}`); +console.log("=".repeat(72)); +console.log(); + +// --------------------------------------------------------------------------- +// Correctness first +// --------------------------------------------------------------------------- + +let checks = 0; + +try { + for (const edgeCase of EDGE_CASES) { + const { collidables } = buildWorld(edgeCase.specs); + + assertParity( + `edge case: ${edgeCase.name}`, + runAllKernels(collidables as never), + edgeCase.expected, + ); + + checks++; + } + + for (const scenario of SCENARIOS) { + for (const size of SIZES) { + const { collidables } = createFixture(scenario, size); + + assertParity( + `fixture: ${scenario} n=${size}`, + runAllKernels(collidables as never), + ); + + checks++; + } + } +} catch (error) { + console.error((error as Error).message); + console.error("Aborting: refusing to report timings for incorrect kernels."); + process.exit(1); +} + +console.log(`✓ kernel parity verified across ${checks} fixtures\n`); + +// --------------------------------------------------------------------------- +// Workload characterisation +// --------------------------------------------------------------------------- + +console.log("Selected pairs per scenario (accepted collisions):"); +console.log(" scenario n accepted %entities-consumed"); + +for (const scenario of SCENARIOS) { + for (const size of SIZES) { + const { collidables } = createFixture(scenario, size); + const results = runAllKernels(collidables as never); + const accepted = results.currentObject.length / 2; + + console.log( + ` ${scenario.padEnd(10)} ${String(size).padEnd(6)} ` + + `${String(accepted).padEnd(10)} ` + + `${((accepted * 2 * 100) / size).toFixed(1)}%`, + ); + } +} + +console.log(); + +// --------------------------------------------------------------------------- +// Benchmarks +// --------------------------------------------------------------------------- + +for (const scenario of SCENARIOS) { + for (const size of SIZES) { + const { collidables } = createFixture(scenario, size); + + const raw = collidables.entities.raw as ReadonlyArray; + const entities = collidables.entities as never as Parameters< + typeof selectCurrentObject + >[0]; + + // Everything below is allocated once, outside the measured path. + const out = new Int32Array(Math.max(2, maxPairs(size) * 2)); + const handled = new Uint8Array(size); + const handledSet = new Set(); + const snapshot = new CollisionSnapshot(size); + + // Pre-pack so the "scan only" bench does not depend on a prior bench in + // this summary having populated the snapshot as a side effect. + packSnapshot(raw, snapshot); + + summary(() => { + bench(`serial/current-object — ${scenario} n=${size}`, () => { + return selectCurrentObject(entities, handledSet, out); + }); + + bench(`serial/precomputed — ${scenario} n=${size}`, () => { + return selectPrecomputed(raw, handled, out); + }); + + // Pack is included: production objecs must pay it every frame with + // object component storage. + bench(`serial/packed (pack+scan) — ${scenario} n=${size}`, () => { + packSnapshot(raw, snapshot); + return selectPacked(snapshot, handled, out); + }); + + // Scan only, to separate the packing cost from the layout benefit. + bench(`serial/packed (scan only) — ${scenario} n=${size}`, () => { + return selectPacked(snapshot, handled, out); + }); + + // Pack only — this is the per-frame tax any Worker design also pays. + bench(`snapshot pack only — ${scenario} n=${size}`, () => { + packSnapshot(raw, snapshot); + return snapshot.count; + }); + }); + } +} + +await run(); diff --git a/packages/perf-proofs/src/worker/lib/aabb.ts b/packages/perf-proofs/src/worker/lib/aabb.ts new file mode 100644 index 0000000..219d454 --- /dev/null +++ b/packages/perf-proofs/src/worker/lib/aabb.ts @@ -0,0 +1,32 @@ +/** + * Verbatim copy of packages/examples/src/lib/collision/aabb.ts + * + * Copied rather than imported so the `serial/current-object` baseline pays the + * exact same helper-call overhead the shmup demo pays today. Do not "optimize" + * this file — that is what `serial/precomputed` is for. + */ + +type Vector2d = { + x: number; + y: number; +}; + +type AABB = { + position: Vector2d; + width: number; + height: number; +}; + +export const aabbLeft = (aabb: AABB) => aabb.position.x; + +export const aabbRight = (aabb: AABB) => aabb.position.x + aabb.width; + +export const aabbTop = (aabb: AABB) => aabb.position.y; + +export const aabbBottom = (aabb: AABB) => aabb.position.y + aabb.height; + +export const intersects = (aabb1: AABB, aabb2: AABB) => + aabbLeft(aabb1) < aabbRight(aabb2) && + aabbRight(aabb1) > aabbLeft(aabb2) && + aabbTop(aabb1) < aabbBottom(aabb2) && + aabbBottom(aabb1) > aabbTop(aabb2); diff --git a/packages/perf-proofs/src/worker/lib/collision-worker.mjs b/packages/perf-proofs/src/worker/lib/collision-worker.mjs new file mode 100644 index 0000000..60a9e5d --- /dev/null +++ b/packages/perf-proofs/src/worker/lib/collision-worker.mjs @@ -0,0 +1,133 @@ +/** + * Persistent collision worker. + * + * Static config (worker index, control block, snapshot SABs) arrives once via + * `workerData`. Per-job control arrives via `postMessage`. Bulk collider data + * is read directly from the shared snapshot with ordinary TypedArray indexing — + * no Atomics per field. + * + * Job types: + * "noop" — respond immediately. Measures the pool's steady-state barrier + * floor (dispatch + return with zero compute). + * "collision" — scan the assigned outer-index range [start, end) against all + * colliders, emitting ordered candidate pairs. (Phase 3) + */ +import { parentPort, workerData } from "node:worker_threads"; +import { Control } from "./control.mjs"; + +const { workerIndex, control: controlSab, snapshot } = workerData; + +const control = new Int32Array(controlSab); + +// Views over the shared snapshot. Present from startup; only the first `count` +// entries of each are meaningful for a given job. +const left = new Float64Array(snapshot.left); +const right = new Float64Array(snapshot.right); +const top = new Float64Array(snapshot.top); +const bottom = new Float64Array(snapshot.bottom); +const layer = new Uint32Array(snapshot.layer); +const mask = new Uint32Array(snapshot.mask); + +// Reusable local output buffer. Grown (never shrunk) as needed; a right-sized +// copy is transferred back per job so the main thread owns detached memory. +let pairs = new Uint32Array(1024); + +function ensureCapacity(pairCount) { + // pairCount pairs need pairCount*2 slots. + const needed = pairCount * 2; + + if (needed <= pairs.length) { + return; + } + + let capacity = pairs.length; + + while (capacity < needed) { + capacity *= 2; + } + + // Copy forward — candidates written so far must survive the grow, or the + // reduction sees a truncated list and drops selections. + const grown = new Uint32Array(capacity); + grown.set(pairs); + pairs = grown; +} + +function runCollision(job) { + const count = Atomics.load(control, Control.Count); + const { start, end } = job; + + let pairCount = 0; + + for (let i = start; i < end; i++) { + const layerI = layer[i]; + const leftI = left[i]; + const rightI = right[i]; + const topI = top[i]; + const bottomI = bottom[i]; + + for (let j = 0; j < count; j++) { + if (i === j) { + continue; + } + + if ((layerI & mask[j]) !== layerI) { + continue; + } + + if ( + leftI < right[j] && + rightI > left[j] && + topI < bottom[j] && + bottomI > top[j] + ) { + ensureCapacity(pairCount + 1); + pairs[pairCount * 2] = i; + pairs[pairCount * 2 + 1] = j; + pairCount++; + } + } + } + + // Transfer a right-sized copy back; keep our growable buffer intact. + const out = pairs.slice(0, pairCount * 2); + + parentPort.postMessage( + { + type: "collision", + generation: job.generation, + workerIndex, + pairCount, + pairs: out, + }, + [out.buffer], + ); +} + +parentPort.on("message", (job) => { + switch (job.type) { + case "noop": { + parentPort.postMessage({ + type: "noop", + generation: job.generation, + workerIndex, + }); + + break; + } + + case "collision": { + runCollision(job); + + break; + } + + default: { + throw new Error(`unknown job type: ${job.type}`); + } + } +}); + +// Announce readiness. The pool waits for one of these from every worker before +// any benchmark measurement begins. +parentPort.postMessage({ type: "ready", workerIndex }); diff --git a/packages/perf-proofs/src/worker/lib/control.mjs b/packages/perf-proofs/src/worker/lib/control.mjs new file mode 100644 index 0000000..d256176 --- /dev/null +++ b/packages/perf-proofs/src/worker/lib/control.mjs @@ -0,0 +1,29 @@ +/** + * Shared control-block layout for the collision worker pool. + * + * A tiny `Int32Array` over a SharedArrayBuffer, used only for + * synchronisation/publication — never for bulk collider data (those are + * ordinary TypedArray reads over the snapshot SABs). + * + * `erasableSyntaxOnly` is on in this package's tsconfig, so `const enum` is + * banned. Plain frozen object of indices instead. This file is `.mjs` so the + * worker entry does not depend on TypeScript execution support, and it is + * imported by the `.ts` side too (allowJs is on). + */ + +/** Int32 slots in the control block. */ +export const Control = Object.freeze({ + /** Bumped by main thread each dispatch; workers echo it to prove freshness. */ + Generation: 0, + /** Number of colliders in the current snapshot. */ + Count: 1, + /** Slots per worker start here: [readyGen_w0, readyGen_w1, ...]. */ + WorkerReadyBase: 2, +}); + +/** + * Size (in Int32 slots) of a control block for `workerCount` workers. + */ +export function controlSize(workerCount) { + return Control.WorkerReadyBase + workerCount; +} diff --git a/packages/perf-proofs/src/worker/lib/entity.ts b/packages/perf-proofs/src/worker/lib/entity.ts new file mode 100644 index 0000000..c01ae6d --- /dev/null +++ b/packages/perf-proofs/src/worker/lib/entity.ts @@ -0,0 +1,63 @@ +/** + * Minimal entity shape for the parallel-producer collision spike. + * + * Mirrors the collision-relevant subset of the shmup demo's `Entity` + * (packages/examples/src/demos/shmup/entity.ts) without dragging in `Config`, + * `EnemyType`, tween paths, or the rest of the demo graph. + * + * Object *shape* is load-bearing here: the current collision kernel pays for + * nested property access (`entity.transform.position.x`), so `Transform` keeps + * its `scale`/`rotation` siblings even though collision never reads them. + */ + +export type Vector2d = { + x: number; + y: number; +}; + +export type Transform = { + position: Vector2d; + scale: Vector2d; + rotation: number; +}; + +export type BoxCollider = { + offsetX: number; + offsetY: number; + width: number; + height: number; +}; + +export type Entity = { + boxCollider?: BoxCollider; + collisionLayer?: number; + collisionMask?: number; + transform?: Transform; + + enemyType?: string; + invulnerable?: { + durationMs: number; + elapsedMs: number; + }; + + tagBigBullet?: true; + tagBomb?: true; + tagBoss?: true; + tagBullet?: true; + tagEnemy?: true; + tagEnemyBullet?: true; + tagPickup?: true; + tagPlayer?: true; +}; + +/** + * Collision bits copied verbatim from + * packages/examples/src/demos/shmup/bitmasks.ts + */ +export const CollisionMasks = { + Player: 1 << 0, + PlayerProjectile: 1 << 1, + Enemy: 1 << 2, + EnemyProjectile: 1 << 3, + Pickup: 1 << 4, +}; diff --git a/packages/perf-proofs/src/worker/lib/fixtures.ts b/packages/perf-proofs/src/worker/lib/fixtures.ts new file mode 100644 index 0000000..a0af994 --- /dev/null +++ b/packages/perf-proofs/src/worker/lib/fixtures.ts @@ -0,0 +1,192 @@ +/** + * Deterministic collision fixtures. + * + * No `Math.random()` anywhere — every scenario is reproducible from a seed so + * parity assertions and benchmark numbers refer to identical worlds. + * + * Layer/mask values are the real shmup assignments: + * + * Player layer Player mask Enemy|EnemyProjectile|Pickup + * PlayerProjectile layer PlayerProjectile mask Enemy + * Enemy layer Enemy mask PlayerProjectile|Player + * EnemyProjectile layer EnemyProjectile mask Player + * Pickup layer Pickup mask Player + */ +import { World } from "objecs"; +import { CollisionMasks, type Entity } from "./entity.ts"; + +/** + * mulberry32 — small, fast, well-distributed enough for fixture placement. + */ +export function createRng(seed: number): () => number { + let a = seed >>> 0; + + return function next() { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +type Kind = + | "player" + | "playerProjectile" + | "enemy" + | "enemyProjectile" + | "pickup"; + +const KIND_SPECS: Record< + Kind, + { + layer: number; + mask: number; + width: number; + height: number; + tag: Partial; + } +> = { + player: { + layer: CollisionMasks.Player, + mask: + CollisionMasks.Enemy | + CollisionMasks.EnemyProjectile | + CollisionMasks.Pickup, + width: 8, + height: 8, + tag: { tagPlayer: true }, + }, + playerProjectile: { + layer: CollisionMasks.PlayerProjectile, + mask: CollisionMasks.Enemy, + width: 1, + height: 3, + tag: { tagBullet: true }, + }, + enemy: { + layer: CollisionMasks.Enemy, + mask: CollisionMasks.PlayerProjectile | CollisionMasks.Player, + width: 8, + height: 8, + tag: { tagEnemy: true, enemyType: "greenAlien" }, + }, + enemyProjectile: { + layer: CollisionMasks.EnemyProjectile, + mask: CollisionMasks.Player, + width: 2, + height: 2, + tag: { tagEnemyBullet: true }, + }, + pickup: { + layer: CollisionMasks.Pickup, + mask: CollisionMasks.Player, + width: 5, + height: 5, + tag: { tagPickup: true }, + }, +}; + +function makeEntity(kind: Kind, x: number, y: number): Entity { + const spec = KIND_SPECS[kind]; + + return { + boxCollider: { + offsetX: 0, + offsetY: 0, + width: spec.width, + height: spec.height, + }, + collisionLayer: spec.layer, + collisionMask: spec.mask, + transform: { + position: { x, y }, + scale: { x: 1, y: 1 }, + rotation: 0, + }, + ...spec.tag, + }; +} + +export type Scenario = "no-hit" | "sparse" | "dense"; + +/** + * Build a `World` populated with `count` collidables for the given scenario. + * + * Returns the world plus the collidables archetype, matching how the shmup + * collision system acquires its entities. + */ +export function createFixture( + scenario: Scenario, + count: number, + seed = 0x9e37, +) { + const world = new World(); + const rng = createRng(seed); + + if (scenario === "no-hit") { + // Alternating PlayerProjectile / Enemy so the directional layer/mask test + // passes in BOTH directions for most pairs — this maximises the number of + // AABB tests actually evaluated. Grid spacing guarantees zero overlaps, so + // the greedy loop never breaks early and scans the full candidate space. + const cell = 16; + const cols = Math.ceil(Math.sqrt(count)); + + for (let i = 0; i < count; i++) { + const kind: Kind = i % 2 === 0 ? "playerProjectile" : "enemy"; + const x = (i % cols) * cell; + const y = Math.floor(i / cols) * cell; + + world.createEntity(makeEntity(kind, x, y)); + } + } else if (scenario === "sparse") { + // Shmup-like composition on a playfield sized to produce a small, stable + // number of overlaps. + const width = 320; + const height = 180; + + for (let i = 0; i < count; i++) { + let kind: Kind; + + if (i === 0) { + kind = "player"; + } else { + const roll = rng(); + + if (roll < 0.45) { + kind = "playerProjectile"; + } else if (roll < 0.75) { + kind = "enemy"; + } else if (roll < 0.95) { + kind = "enemyProjectile"; + } else { + kind = "pickup"; + } + } + + world.createEntity(makeEntity(kind, rng() * width, rng() * height)); + } + } else { + // Dense: same composition, but a playfield small enough that colliders + // pile up. Exercises the early-`break` advantage of the greedy serial loop. + const width = 48; + const height = 32; + + for (let i = 0; i < count; i++) { + const roll = rng(); + const kind: Kind = + roll < 0.5 ? "playerProjectile" : roll < 0.9 ? "enemy" : "player"; + + world.createEntity(makeEntity(kind, rng() * width, rng() * height)); + } + } + + const collidables = world.archetype( + "boxCollider", + "collisionLayer", + "collisionMask", + "transform", + ); + + return { world, collidables }; +} diff --git a/packages/perf-proofs/src/worker/lib/kernels.ts b/packages/perf-proofs/src/worker/lib/kernels.ts new file mode 100644 index 0000000..b0c11c4 --- /dev/null +++ b/packages/perf-proofs/src/worker/lib/kernels.ts @@ -0,0 +1,355 @@ +/** + * The three serial collision kernels. + * + * All three select the SAME pairs, in the SAME order, and write them into a + * caller-supplied `Int32Array` as [i0, j0, i1, j1, ...] where the values are + * indices into `collidables.entities.raw`. Parity between them is asserted + * before any timing is reported. + * + * A. selectCurrentObject — faithful replica of the shmup collision kernel + * B. selectPrecomputed — same objects, obvious inner-loop overhead removed + * C. packSnapshot + selectPacked — SoA layout the workers will consume + */ +import type { ReadonlyEntityCollection } from "objecs"; +import { intersects } from "./aabb.ts"; +import type { Entity } from "./entity.ts"; + +export type Collidable = Entity & + Required< + Pick< + Entity, + "boxCollider" | "collisionLayer" | "collisionMask" | "transform" + > + >; + +/** + * Upper bound on selected pairs: each selection consumes two entities, so at + * most floor(n / 2) pairs can ever be accepted. + */ +export function maxPairs(count: number): number { + return Math.floor(count / 2); +} + +// --------------------------------------------------------------------------- +// A. serial/current-object +// --------------------------------------------------------------------------- + +/** + * Faithful replica of collision-system.ts:26-181. + * + * Deliberately preserves every cost the demo pays today: + * - `for...of` over the EntityCollection (nested iterator allocation) + * - `Set` membership tests + * - the outer entity's AABB rebuilt on EVERY inner iteration + * - two temp AABB literals (four objects) allocated per pair test + * - `intersects()` helper-call chain + * + * The only addition is the `i`/`j` index counters, needed to report selected + * pairs as indices. Two integer increments; everything else is untouched. + * + * Event classification is intentionally omitted — it does not influence which + * pairs get selected, and it is measured separately. + */ +export function selectCurrentObject( + entities: ReadonlyEntityCollection, + handledEntities: Set, + out: Int32Array, +): number { + handledEntities.clear(); + + let pairCount = 0; + let i = -1; + + for (const entity of entities) { + i++; + + if (handledEntities.has(entity)) { + continue; + } + + let j = -1; + + for (const otherEntity of entities) { + j++; + + if (entity === otherEntity) { + continue; + } + + if (handledEntities.has(otherEntity)) { + continue; + } + + if ( + (entity.collisionLayer & otherEntity.collisionMask) !== + entity.collisionLayer + ) { + continue; + } + + const { boxCollider, transform } = entity; + + const { boxCollider: otherBoxCollider, transform: otherTransform } = + otherEntity; + + const x = transform.position.x + boxCollider.offsetX; + const y = transform.position.y + boxCollider.offsetY; + const otherX = otherTransform.position.x + otherBoxCollider.offsetX; + const otherY = otherTransform.position.y + otherBoxCollider.offsetY; + + const aabb = { + position: { x, y }, + width: boxCollider.width, + height: boxCollider.height, + }; + + const otherAabb = { + position: { x: otherX, y: otherY }, + width: otherBoxCollider.width, + height: otherBoxCollider.height, + }; + + if (!intersects(aabb, otherAabb)) { + continue; + } + + out[pairCount * 2] = i; + out[pairCount * 2 + 1] = j; + pairCount++; + + handledEntities.add(entity); + handledEntities.add(otherEntity); + + break; + } + } + + return pairCount; +} + +// --------------------------------------------------------------------------- +// B. serial/precomputed +// --------------------------------------------------------------------------- + +/** + * Same plain entity objects, same greedy semantics — but: + * - indexed iteration over `.raw` instead of nested iterators + * - `Uint8Array` handled-flags instead of `Set` + * - the outer entity's bounds hoisted out of the inner loop + * - zero temp AABB allocations + * - the rectangle test inlined + * + * This is the drop-in fix a reviewer would suggest for the demo without any + * architectural change. If this captures most of the available speedup, the + * Worker experiment is answering a question nobody needed to ask. + */ +export function selectPrecomputed( + raw: ReadonlyArray, + handled: Uint8Array, + out: Int32Array, +): number { + const count = raw.length; + + handled.fill(0, 0, count); + + let pairCount = 0; + + for (let i = 0; i < count; i++) { + if (handled[i] !== 0) { + continue; + } + + const entity = raw[i]!; + const collider = entity.boxCollider; + const position = entity.transform.position; + + const layer = entity.collisionLayer; + + // Hoisted: the current implementation recomputes these for every j. + const left = position.x + collider.offsetX; + const top = position.y + collider.offsetY; + const right = left + collider.width; + const bottom = top + collider.height; + + for (let j = 0; j < count; j++) { + if (i === j) { + continue; + } + + if (handled[j] !== 0) { + continue; + } + + const other = raw[j]!; + + if ((layer & other.collisionMask) !== layer) { + continue; + } + + const otherCollider = other.boxCollider; + const otherPosition = other.transform.position; + + const otherLeft = otherPosition.x + otherCollider.offsetX; + const otherTop = otherPosition.y + otherCollider.offsetY; + + if ( + left < otherLeft + otherCollider.width && + right > otherLeft && + top < otherTop + otherCollider.height && + bottom > otherTop + ) { + out[pairCount * 2] = i; + out[pairCount * 2 + 1] = j; + pairCount++; + + handled[i] = 1; + handled[j] = 1; + + break; + } + } + } + + return pairCount; +} + +// --------------------------------------------------------------------------- +// C. serial/packed +// --------------------------------------------------------------------------- + +/** + * Reusable struct-of-arrays snapshot — the exact layout the Workers will read + * from a SharedArrayBuffer. Allocated with a capacity and grown to the next + * power of two, never reallocated inside the measured path. + * + * `Float64Array` (not Float32Array) so the spike does not silently change + * normal JS number precision. + */ +export class CollisionSnapshot { + capacity: number; + count = 0; + + left: Float64Array; + right: Float64Array; + top: Float64Array; + bottom: Float64Array; + layer: Uint32Array; + mask: Uint32Array; + + constructor(capacity: number, shared = false) { + this.capacity = capacity; + + const f64 = () => + shared + ? new Float64Array( + new SharedArrayBuffer(capacity * Float64Array.BYTES_PER_ELEMENT), + ) + : new Float64Array(capacity); + + const u32 = () => + shared + ? new Uint32Array( + new SharedArrayBuffer(capacity * Uint32Array.BYTES_PER_ELEMENT), + ) + : new Uint32Array(capacity); + + this.left = f64(); + this.right = f64(); + this.top = f64(); + this.bottom = f64(); + this.layer = u32(); + this.mask = u32(); + } +} + +/** + * O(n) pack. Measured separately AND included in end-to-end, because + * production objecs must pay this every frame with object component storage. + */ +export function packSnapshot( + raw: ReadonlyArray, + snapshot: CollisionSnapshot, +): void { + const count = raw.length; + + const { left, right, top, bottom, layer, mask } = snapshot; + + for (let i = 0; i < count; i++) { + const entity = raw[i]!; + const collider = entity.boxCollider; + const position = entity.transform.position; + + const x = position.x + collider.offsetX; + const y = position.y + collider.offsetY; + + left[i] = x; + right[i] = x + collider.width; + top[i] = y; + bottom[i] = y + collider.height; + + layer[i] = entity.collisionLayer >>> 0; + mask[i] = entity.collisionMask >>> 0; + } + + snapshot.count = count; +} + +/** + * Greedy scan over the packed snapshot. Identical control flow to the + * original nested loop, reading only TypedArrays. + */ +export function selectPacked( + snapshot: CollisionSnapshot, + handled: Uint8Array, + out: Int32Array, +): number { + const { count, left, right, top, bottom, layer, mask } = snapshot; + + handled.fill(0, 0, count); + + let pairCount = 0; + + for (let i = 0; i < count; i++) { + if (handled[i] !== 0) { + continue; + } + + const layerI = layer[i]!; + const leftI = left[i]!; + const rightI = right[i]!; + const topI = top[i]!; + const bottomI = bottom[i]!; + + for (let j = 0; j < count; j++) { + if (i === j) { + continue; + } + + if (handled[j] !== 0) { + continue; + } + + if ((layerI & mask[j]!) !== layerI) { + continue; + } + + if ( + leftI < right[j]! && + rightI > left[j]! && + topI < bottom[j]! && + bottomI > top[j]! + ) { + out[pairCount * 2] = i; + out[pairCount * 2 + 1] = j; + pairCount++; + + handled[i] = 1; + handled[j] = 1; + + break; + } + } + } + + return pairCount; +} diff --git a/packages/perf-proofs/src/worker/lib/parity.ts b/packages/perf-proofs/src/worker/lib/parity.ts new file mode 100644 index 0000000..0b0494a --- /dev/null +++ b/packages/perf-proofs/src/worker/lib/parity.ts @@ -0,0 +1,285 @@ +/** + * Correctness harness. + * + * The benchmark aborts rather than printing performance results if any kernel + * disagrees about which pairs get selected. Performance numbers for a kernel + * that computes the wrong answer are worse than no numbers at all. + */ +import { World } from "objecs"; +import { CollisionMasks, type Entity } from "./entity.ts"; +import { + type Collidable, + CollisionSnapshot, + maxPairs, + packSnapshot, + selectCurrentObject, + selectPacked, + selectPrecomputed, +} from "./kernels.ts"; + +export type Spec = { + layer: number; + mask: number; + x: number; + y: number; + w?: number; + h?: number; +}; + +export function buildWorld(specs: ReadonlyArray) { + const world = new World(); + + for (const spec of specs) { + world.createEntity({ + boxCollider: { + offsetX: 0, + offsetY: 0, + width: spec.w ?? 8, + height: spec.h ?? 8, + }, + collisionLayer: spec.layer, + collisionMask: spec.mask, + transform: { + position: { x: spec.x, y: spec.y }, + scale: { x: 1, y: 1 }, + rotation: 0, + }, + }); + } + + const collidables = world.archetype( + "boxCollider", + "collisionLayer", + "collisionMask", + "transform", + ); + + return { world, collidables }; +} + +const { Player, PlayerProjectile, Enemy, Pickup } = CollisionMasks; + +const PROJECTILE = { layer: PlayerProjectile, mask: Enemy }; +const ENEMY = { layer: Enemy, mask: PlayerProjectile | Player }; +const PLAYER = { layer: Player, mask: Enemy | Pickup }; + +/** + * Hand-built cases covering the ordering hazards called out in the spike. + * + * `expected` is derived BY HAND from the control flow in + * collision-system.ts:26-181 — not captured from a kernel run. Cross-kernel + * agreement alone would happily confirm three identically-wrong kernels; these + * literals are what tie the kernels to the real shmup semantics. + * + * Recall the test is directional: + * skip unless (entity.collisionLayer & otherEntity.collisionMask) === entity.collisionLayer + */ +export const EDGE_CASES: ReadonlyArray<{ + name: string; + specs: ReadonlyArray; + /** Flat [i0, j0, i1, j1, ...]; `undefined` = hand-derivation impractical. */ + expected?: ReadonlyArray; +}> = [ + { name: "zero entities", specs: [], expected: [] }, + { + name: "one entity", + specs: [{ ...PROJECTILE, x: 0, y: 0 }], + expected: [], + }, + { + name: "two colliding", + specs: [ + { ...PROJECTILE, x: 0, y: 0 }, // [0,8] x [0,8] + { ...ENEMY, x: 4, y: 4 }, // [4,12] x [4,12] + ], + // i=0,j=1: layer 2 & mask 3 === 2, boxes overlap -> select (0,1) + expected: [0, 1], + }, + { + name: "two non-colliding", + specs: [ + { ...PROJECTILE, x: 0, y: 0 }, + { ...ENEMY, x: 100, y: 100 }, + ], + expected: [], + }, + { + name: "layer/mask rejects both directions (boxes DO overlap)", + specs: [ + // PlayerProjectile(2)/mask Enemy(4) vs Player(1)/mask Pickup(16) + { layer: PlayerProjectile, mask: Enemy, x: 0, y: 0 }, + { layer: Player, mask: Pickup, x: 2, y: 2 }, + ], + // i=0,j=1: 2 & 16 = 0 !== 2 -> skip + // i=1,j=0: 1 & 4 = 0 !== 1 -> skip + expected: [], + }, + { + name: "directional: rejected as (0,1) but accepted as (1,0)", + specs: [ + // Proves the pair is emitted in the correct order, and that the + // predicate is genuinely asymmetric. + { layer: PlayerProjectile, mask: Enemy, x: 0, y: 0 }, + { layer: Enemy, mask: 0, x: 2, y: 2 }, + ], + // i=0,j=1: 2 & 0 = 0 !== 2 -> skip, entity 0 finds no partner + // i=1,j=0: 4 & 4 = 4 === 4, boxes overlap -> select (1,0) + expected: [1, 0], + }, + { + name: "three entities competing for the same partner", + specs: [ + { ...PROJECTILE, x: 0, y: 0 }, + { ...ENEMY, x: 2, y: 2 }, + { ...PROJECTILE, x: 4, y: 4 }, + ], + // i=0 takes 1 and breaks; i=1 handled; i=2 finds 0 and 1 both handled. + expected: [0, 1], + }, + { + name: "multiple disjoint collisions", + specs: [ + { ...PROJECTILE, x: 0, y: 0 }, + { ...ENEMY, x: 2, y: 2 }, + { ...PROJECTILE, x: 200, y: 200 }, + { ...ENEMY, x: 202, y: 202 }, + ], + expected: [0, 1, 2, 3], + }, + { + // The case the spike calls out explicitly: + // entity 0 overlaps 1 and 2 + // entity 1 overlaps 0 and 3 + name: "greedy contention: 0-{1,2}, 1-{0,3}", + specs: [ + { ...PROJECTILE, x: 0, y: 0 }, // [0,8] + { ...ENEMY, x: 1, y: 1 }, // [1,9] + { ...ENEMY, x: 2, y: 2 }, // [2,10] + { ...PROJECTILE, x: 3, y: 3 }, // [3,11] + ], + // i=0 takes the FIRST valid partner (1), not (2). i=1 handled. + // i=2 (Enemy) then finds j=3 (PlayerProjectile): 4 & 4 === 4 -> (2,3). + expected: [0, 1, 2, 3], + }, + { + name: "all 16 overlapping and mutually compatible — pairs off in order", + specs: Array.from({ length: 16 }, () => ({ + // Enemy layer & Enemy mask -> 4 & 4 === 4, so these DO accept. + layer: Enemy, + mask: Enemy, + x: 0, + y: 0, + })), + // Greedy consumes them two at a time in index order. + expected: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + }, + { + name: "all 16 overlapping, layer/mask never matches — zero accepts", + specs: Array.from({ length: 16 }, () => ({ + // Enemy layer(4) vs Player mask(1) -> 4 & 1 = 0 !== 4. + layer: Enemy, + mask: Player, + x: 0, + y: 0, + })), + expected: [], + }, + { + name: "dense overlap pile", + specs: Array.from({ length: 24 }, (_, i) => ({ + ...(i % 3 === 0 ? PROJECTILE : i % 3 === 1 ? ENEMY : PLAYER), + x: i % 5, + y: Math.floor(i / 5) % 5, + })), + // Hand-derivation impractical; cross-kernel agreement only. + }, +]; + +export type KernelResults = { + currentObject: number[]; + precomputed: number[]; + packed: number[]; +}; + +function toPairList(out: Int32Array, pairCount: number): number[] { + return Array.from(out.subarray(0, pairCount * 2)); +} + +/** + * Run all three kernels over the same collidables and return their selected + * pairs as plain arrays for comparison. + */ +export function runAllKernels(collidables: { + entities: { raw: ReadonlyArray; size: number }; +}): KernelResults { + const raw = collidables.entities.raw; + const count = raw.length; + + const capacity = Math.max(1, count); + const out = new Int32Array(Math.max(2, maxPairs(count) * 2)); + const handled = new Uint8Array(capacity); + const snapshot = new CollisionSnapshot(capacity); + + const entities = collidables.entities as never as Parameters< + typeof selectCurrentObject + >[0]; + + const aCount = selectCurrentObject(entities, new Set(), out); + const a = toPairList(out, aCount); + + const bCount = selectPrecomputed(raw, handled, out); + const b = toPairList(out, bCount); + + packSnapshot(raw, snapshot); + const cCount = selectPacked(snapshot, handled, out); + const c = toPairList(out, cCount); + + return { currentObject: a, precomputed: b, packed: c }; +} + +function fmtPairs(pairs: ReadonlyArray): string { + const out: string[] = []; + + for (let i = 0; i < pairs.length; i += 2) { + out.push(`(${pairs[i]},${pairs[i + 1]})`); + } + + return out.length === 0 ? "" : out.join(" "); +} + +/** + * Assert all kernels agree, and — when ground truth is supplied — that they + * agree with the hand-derived expectation rather than merely with each other. + */ +export function assertParity( + label: string, + results: KernelResults, + expected?: ReadonlyArray, +): void { + const { currentObject, precomputed, packed } = results; + + const a = fmtPairs(currentObject); + const b = fmtPairs(precomputed); + const c = fmtPairs(packed); + + if (a !== b || a !== c) { + throw new Error( + `Kernel parity mismatch — ${label}\n` + + ` serial/current-object: ${a}\n` + + ` serial/precomputed: ${b}\n` + + ` serial/packed: ${c}\n`, + ); + } + + if (expected !== undefined) { + const want = fmtPairs(expected); + + if (a !== want) { + throw new Error( + `Ground-truth mismatch — ${label}\n` + + ` expected (hand-derived): ${want}\n` + + ` actual (all kernels): ${a}\n`, + ); + } + } +} diff --git a/packages/perf-proofs/src/worker/lib/reduce.ts b/packages/perf-proofs/src/worker/lib/reduce.ts new file mode 100644 index 0000000..42cbbdf --- /dev/null +++ b/packages/perf-proofs/src/worker/lib/reduce.ts @@ -0,0 +1,62 @@ +/** + * Deterministic greedy reduction of worker candidate output. + * + * Each worker emits EVERY mask-passing, intersecting pair (i, j) in its outer + * range, with i ascending and j ascending. Because workers own contiguous + * ascending i-ranges, concatenating their outputs by worker index reproduces + * the exact order the original serial nested loop evaluates pairs — no sort. + * + * The greedy reduction then reconstructs the shmup semantics: an entity + * participates in at most one accepted collision, and the FIRST still-valid + * candidate wins. + * + * Equivalence to the serial loop (verified against ground truth in parity.ts): + * - serial skips a handled outer i ⇔ handled[i] guard skips all (i, *) + * - serial breaks on first accepted ⇔ handled[i]=1 blocks later (i, *) + * - serial picks first unhandled j ⇔ candidates are j-ascending, first + * unhandled wins + */ +import type { CollisionResult } from "./worker-pool.ts"; + +/** + * Reduce worker results (in worker-index order) into selected pairs. + * Writes [i0, j0, i1, j1, ...] into `out`; returns the pair count. + */ +export function reduceCandidates( + results: ReadonlyArray, + count: number, + handled: Uint8Array, + out: Int32Array, +): number { + handled.fill(0, 0, count); + + let pairCount = 0; + + for (let w = 0; w < results.length; w++) { + const result = results[w]!; + const pairs = result.pairs; + const candidateCount = result.pairCount; + + for (let k = 0; k < candidateCount; k++) { + const i = pairs[k * 2]!; + const j = pairs[k * 2 + 1]!; + + if (handled[i] !== 0) { + continue; + } + + if (handled[j] !== 0) { + continue; + } + + out[pairCount * 2] = i; + out[pairCount * 2 + 1] = j; + pairCount++; + + handled[i] = 1; + handled[j] = 1; + } + } + + return pairCount; +} diff --git a/packages/perf-proofs/src/worker/lib/shared-snapshot.ts b/packages/perf-proofs/src/worker/lib/shared-snapshot.ts new file mode 100644 index 0000000..65a767a --- /dev/null +++ b/packages/perf-proofs/src/worker/lib/shared-snapshot.ts @@ -0,0 +1,95 @@ +/** + * SharedArrayBuffer-backed collision snapshot. + * + * Same SoA layout as the serial `CollisionSnapshot`, but every column lives in + * a `SharedArrayBuffer` so workers read it directly. Holds both: + * - typed views, for packing on the main thread; + * - the raw SABs, to hand to workers via `workerData`. + * + * Fixed capacity — no growable SAB, per the spike brief (keeps the experiment + * compatible with a wider runtime range). Grow by constructing a new one + * outside the measured path. + */ +import type { SharedSnapshotBuffers } from "./worker-pool.ts"; +import type { Collidable } from "./kernels.ts"; + +/** Next power of two >= n (min 1). */ +export function nextPow2(n: number): number { + if (n <= 1) { + return 1; + } + + let capacity = 1; + + while (capacity < n) { + capacity *= 2; + } + + return capacity; +} + +export class SharedSnapshot { + readonly capacity: number; + count = 0; + + readonly buffers: SharedSnapshotBuffers; + + readonly left: Float64Array; + readonly right: Float64Array; + readonly top: Float64Array; + readonly bottom: Float64Array; + readonly layer: Uint32Array; + readonly mask: Uint32Array; + + constructor(capacity: number) { + this.capacity = capacity; + + const f64Bytes = capacity * Float64Array.BYTES_PER_ELEMENT; + const u32Bytes = capacity * Uint32Array.BYTES_PER_ELEMENT; + + this.buffers = { + left: new SharedArrayBuffer(f64Bytes), + right: new SharedArrayBuffer(f64Bytes), + top: new SharedArrayBuffer(f64Bytes), + bottom: new SharedArrayBuffer(f64Bytes), + layer: new SharedArrayBuffer(u32Bytes), + mask: new SharedArrayBuffer(u32Bytes), + }; + + this.left = new Float64Array(this.buffers.left); + this.right = new Float64Array(this.buffers.right); + this.top = new Float64Array(this.buffers.top); + this.bottom = new Float64Array(this.buffers.bottom); + this.layer = new Uint32Array(this.buffers.layer); + this.mask = new Uint32Array(this.buffers.mask); + } + + /** + * O(n) pack of collider bounds into shared memory. Identical arithmetic to + * the serial `packSnapshot`, so results are bit-for-bit comparable. + */ + pack(raw: ReadonlyArray): void { + const count = raw.length; + + const { left, right, top, bottom, layer, mask } = this; + + for (let i = 0; i < count; i++) { + const entity = raw[i]!; + const collider = entity.boxCollider; + const position = entity.transform.position; + + const x = position.x + collider.offsetX; + const y = position.y + collider.offsetY; + + left[i] = x; + right[i] = x + collider.width; + top[i] = y; + bottom[i] = y + collider.height; + + layer[i] = entity.collisionLayer >>> 0; + mask[i] = entity.collisionMask >>> 0; + } + + this.count = count; + } +} diff --git a/packages/perf-proofs/src/worker/lib/worker-pool.ts b/packages/perf-proofs/src/worker/lib/worker-pool.ts new file mode 100644 index 0000000..b11e5a2 --- /dev/null +++ b/packages/perf-proofs/src/worker/lib/worker-pool.ts @@ -0,0 +1,245 @@ +/** + * Small dedicated worker pool for the collision spike. Not a production + * abstraction — just enough to measure honestly. + * + * Requirements (from the spike brief): + * - workers created once, before any benchmark timing; + * - all workers report ready before measurement starts; + * - monotonically increasing generation per job; + * - reject on worker error/exit; + * - clean terminate() afterwards; + * - no worker construction hidden in the measured path. + */ +import { Worker } from "node:worker_threads"; +import { Control, controlSize } from "./control.mjs"; + +const WORKER_URL = new URL("./collision-worker.mjs", import.meta.url); + +/** Shared snapshot as raw SABs, the shape passed to workers via workerData. */ +export type SharedSnapshotBuffers = { + left: SharedArrayBuffer; + right: SharedArrayBuffer; + top: SharedArrayBuffer; + bottom: SharedArrayBuffer; + layer: SharedArrayBuffer; + mask: SharedArrayBuffer; +}; + +export type CollisionResult = { + workerIndex: number; + pairCount: number; + /** [i0, j0, i1, j1, ...], indices into the packed snapshot. */ + pairs: Uint32Array; +}; + +type Pending = { + remaining: number; + results: (CollisionResult | undefined)[]; + resolve: (results: CollisionResult[]) => void; + reject: (error: Error) => void; +}; + +export type Partition = { start: number; end: number }; + +/** + * Split `count` outer indices into `workerCount` contiguous ascending ranges. + * Contiguous + ascending is load-bearing: concatenating worker outputs by + * worker index then reproduces global (i asc, j asc) candidate order without a + * sort. See the deterministic-reduction requirement in the spike brief. + */ +export function partitionRange( + count: number, + workerCount: number, +): Partition[] { + const partitions: Partition[] = []; + const base = Math.floor(count / workerCount); + const remainder = count % workerCount; + + let start = 0; + + for (let w = 0; w < workerCount; w++) { + // Hand the first `remainder` workers one extra index so ranges differ by + // at most one and cover [0, count) exactly. + const size = base + (w < remainder ? 1 : 0); + const end = start + size; + + partitions.push({ start, end }); + start = end; + } + + return partitions; +} + +export class WorkerPool { + readonly workerCount: number; + readonly control: Int32Array; + + #workers: Worker[] = []; + #generation = 0; + #pending = new Map(); + #terminated = false; + + private constructor(workerCount: number, controlSab: SharedArrayBuffer) { + this.workerCount = workerCount; + this.control = new Int32Array(controlSab); + } + + /** + * Spawn `workerCount` workers and resolve once every one has reported ready. + * Do this before benchmarking; worker startup must not land in the hot path. + */ + static async create( + workerCount: number, + snapshot: SharedSnapshotBuffers, + ): Promise { + const controlSab = new SharedArrayBuffer( + controlSize(workerCount) * Int32Array.BYTES_PER_ELEMENT, + ); + + const pool = new WorkerPool(workerCount, controlSab); + + const ready: Promise[] = []; + + for (let w = 0; w < workerCount; w++) { + const worker = new Worker(WORKER_URL, { + workerData: { + workerIndex: w, + control: controlSab, + snapshot, + }, + }); + + pool.#workers.push(worker); + + ready.push( + new Promise((resolve, reject) => { + worker.once("message", (message: { type: string }) => { + if (message.type === "ready") { + resolve(); + } else { + reject( + new Error( + `worker ${w} first message was ${message.type}, expected ready`, + ), + ); + } + }); + + worker.once("error", reject); + worker.once("exit", (code) => { + if (code !== 0) { + reject(new Error(`worker ${w} exited with code ${code}`)); + } + }); + }), + ); + } + + await Promise.all(ready); + + // Wire steady-state message/error handling after the ready handshake. + for (let w = 0; w < workerCount; w++) { + const worker = pool.#workers[w]!; + + worker.on("message", (message) => pool.#onMessage(message)); + worker.on("error", (error: Error) => pool.#onError(error)); + } + + return pool; + } + + #onMessage(message: { + type: string; + generation: number; + workerIndex: number; + pairCount?: number; + pairs?: Uint32Array; + }): void { + const pending = this.#pending.get(message.generation); + + if (pending === undefined) { + return; + } + + pending.results[message.workerIndex] = { + workerIndex: message.workerIndex, + pairCount: message.pairCount ?? 0, + pairs: message.pairs ?? new Uint32Array(0), + }; + + pending.remaining--; + + if (pending.remaining === 0) { + this.#pending.delete(message.generation); + pending.resolve(pending.results as CollisionResult[]); + } + } + + #onError(error: Error): void { + for (const pending of this.#pending.values()) { + pending.reject(error); + } + + this.#pending.clear(); + } + + #dispatch( + build: (generation: number, workerIndex: number) => unknown, + ): Promise { + if (this.#terminated) { + return Promise.reject(new Error("pool terminated")); + } + + const generation = ++this.#generation; + + return new Promise((resolve, reject) => { + this.#pending.set(generation, { + remaining: this.workerCount, + results: new Array(this.workerCount).fill(undefined), + resolve, + reject, + }); + + for (let w = 0; w < this.workerCount; w++) { + this.#workers[w]!.postMessage(build(generation, w)); + } + }); + } + + /** + * Dispatch a zero-work job to every worker and resolve when all reply. + * This is the steady-state barrier floor. + */ + noop(): Promise { + return this.#dispatch((generation) => ({ type: "noop", generation })); + } + + /** + * Publish the current collider count and dispatch a collision job to each + * worker over its outer-index partition. The snapshot itself must already be + * packed into the shared buffers by the caller. + */ + collision( + count: number, + partitions: Partition[], + ): Promise { + // Publish count with release semantics before waking workers. + Atomics.store(this.control, Control.Count, count); + Atomics.store(this.control, Control.Generation, this.#generation + 1); + + return this.#dispatch((generation, workerIndex) => { + const { start, end } = partitions[workerIndex]!; + + return { type: "collision", generation, start, end }; + }); + } + + async terminate(): Promise { + this.#terminated = true; + + await Promise.all(this.#workers.map((worker) => worker.terminate())); + + this.#workers = []; + this.#pending.clear(); + } +} diff --git a/packages/perf-proofs/src/worker/worker-floor.ts b/packages/perf-proofs/src/worker/worker-floor.ts new file mode 100644 index 0000000..21379f8 --- /dev/null +++ b/packages/perf-proofs/src/worker/worker-floor.ts @@ -0,0 +1,103 @@ +/** + * worker-floor.ts — Phase 2 of the parallel-producer spike. + * + * Establishes the steady-state barrier floor: the minimum cost of dispatching + * a job to every worker and waiting for all to respond, with ZERO collision + * work. This is the empirical lower bound for any parallel objecs system — a + * system doing less CPU work than this should never be auto-parallelised. + * + * Worker construction, module parsing, and the ready handshake all happen + * before measurement. Only the round trip is timed. + * + * node --expose-gc src/worker/worker-floor.ts + */ +import { availableParallelism, cpus } from "node:os"; +import { bench, run, summary } from "mitata"; +import { SharedSnapshot } from "./lib/shared-snapshot.ts"; +import { WorkerPool } from "./lib/worker-pool.ts"; + +const MAX_WORKERS = Math.min(16, availableParallelism()); + +// 1, 2, 4, 8, ... up to availableParallelism (capped at 16). +const WORKER_COUNTS = (() => { + const counts: number[] = []; + + for (let w = 1; w <= MAX_WORKERS; w *= 2) { + counts.push(w); + } + + // Include the exact cap if it is not already a power of two we hit. + if (counts[counts.length - 1] !== MAX_WORKERS) { + counts.push(MAX_WORKERS); + } + + return counts; +})(); + +console.log("=".repeat(72)); +console.log("objecs parallel-producer spike — Phase 2: worker barrier floor"); +console.log("=".repeat(72)); +console.log(`node ${process.version}`); +console.log(`platform ${process.platform}/${process.arch}`); +console.log(`cpu ${cpus()[0]?.model ?? "unknown"}`); +console.log(`availableParallelism ${availableParallelism()}`); +console.log(`worker counts ${WORKER_COUNTS.join(", ")}`); +console.log("=".repeat(72)); +console.log(); + +// A tiny shared snapshot is required to construct the pool (workers hold views +// over it). The floor benchmark never reads it. +const snapshot = new SharedSnapshot(16); + +// Build every pool up front, outside the measured path. +const pools: WorkerPool[] = []; + +for (const workerCount of WORKER_COUNTS) { + pools.push(await WorkerPool.create(workerCount, snapshot.buffers)); +} + +console.log( + `✓ ${pools.length} pools ready (workers spawned, handshake done)\n`, +); + +summary(() => { + for (let i = 0; i < pools.length; i++) { + const pool = pools[i]!; + + bench(`barrier floor — ${pool.workerCount} worker(s)`, async () => { + await pool.noop(); + }); + } +}); + +await run(); + +// Report a single-number summary of mean round trips too, since mitata's +// async timing includes event-loop scheduling that is easy to misread. +console.log("\nManual round-trip means (1000 iters each, after warmup):"); + +for (const pool of pools) { + // Warmup. + for (let i = 0; i < 200; i++) { + await pool.noop(); + } + + const iters = 1000; + const start = process.hrtime.bigint(); + + for (let i = 0; i < iters; i++) { + await pool.noop(); + } + + const end = process.hrtime.bigint(); + const nsPer = Number(end - start) / iters; + + console.log( + ` ${String(pool.workerCount).padStart(2)} worker(s): ` + + `${(nsPer / 1000).toFixed(2)} µs/round-trip`, + ); +} + +console.log(); + +await Promise.all(pools.map((pool) => pool.terminate())); diff --git a/packages/perf-proofs/tsconfig.json b/packages/perf-proofs/tsconfig.json index 921038d..bcc3fb4 100644 --- a/packages/perf-proofs/tsconfig.json +++ b/packages/perf-proofs/tsconfig.json @@ -14,6 +14,7 @@ "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, "rootDir": "src", + "types": ["node"], "resolveJsonModule": true, "erasableSyntaxOnly": true }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f1a13f..23e7bb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -274,6 +274,9 @@ importers: mitata: specifier: ^1.0.34 version: 1.0.34 + objecs: + specifier: workspace:* + version: link:../objecs devDependencies: '@types/node': specifier: ^26.1.1 @@ -3365,11 +3368,6 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.16: resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3620,10 +3618,6 @@ packages: yaml: optional: true - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.20: resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==} engines: {node: ^10 || ^12 || >=14} @@ -5439,7 +5433,7 @@ snapshots: '@oxc-project/runtime': 0.133.0 '@oxc-project/types': 0.133.0 lightningcss: 1.32.0 - postcss: 8.5.16 + postcss: 8.5.20 optionalDependencies: '@arethetypeswrong/core': 0.18.5 '@types/node': 26.1.1 @@ -5475,7 +5469,7 @@ snapshots: '@types/chai': 5.2.3 '@voidzero-dev/vite-plus-core': 0.1.24(@arethetypeswrong/core@0.18.5)(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@7.0.2)(yaml@2.9.0) es-module-lexer: 1.7.0 - obug: 2.1.3 + obug: 2.1.4 pixelmatch: 7.2.0 pngjs: 7.0.0 sirv: 3.0.2 @@ -6519,9 +6513,6 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.15: - optional: true - nanoid@3.3.16: {} napi-build-utils@2.0.0: {} @@ -6869,13 +6860,6 @@ snapshots: tsx: 4.23.1 yaml: 2.9.0 - postcss@8.5.16: - dependencies: - nanoid: 3.3.15 - picocolors: 1.1.1 - source-map-js: 1.2.1 - optional: true - postcss@8.5.20: dependencies: nanoid: 3.3.16 From 028856be5de334ae0b1448614fde567392e914aa Mon Sep 17 00:00:00 2001 From: Jake Klassen Date: Tue, 13 Oct 2026 21:03:39 -0400 Subject: [PATCH 4/4] fix(examples): use rolldownOptions instead of deprecated rollupOptions Vite 8 runs on rolldown and marks build.rollupOptions deprecated in favor of build.rolldownOptions (identical shape). Clears the type-aware oxlint error that was failing the repo-wide `pnpm lint` CI step. Co-Authored-By: Claude Opus 4.8 --- packages/examples/vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/examples/vite.config.ts b/packages/examples/vite.config.ts index 977b499..72148b9 100644 --- a/packages/examples/vite.config.ts +++ b/packages/examples/vite.config.ts @@ -15,7 +15,7 @@ const demoInputs = await fs export default defineConfig({ build: { - rollupOptions: { + rolldownOptions: { input: { main: resolve(__dirname, "index.html"), ...demoInputs,