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" 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; } 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, 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