Metabolism: region pool — SharedArrayBuffer arena, band-parallel phases (block sums / VM decisions / advection+reaction), bit-identical to serial; 5.9x at 41k organisms; provider protocol sketched

This commit is contained in:
Dejvino
2026-10-28 15:25:52 +01:00
parent 3a72525ccb
commit d6e97a2b52
5 changed files with 685 additions and 268 deletions
+46
View File
@@ -249,6 +249,52 @@ and wander); rock has a budget that survives a whole film (calm songs keep
sparse stone, percussive ones accumulate walls); agriculture consumes stone sparse stone, percussive ones accumulate walls); agriculture consumes stone
instead of minting it. 44 tests green; realtime holds at 640×360. instead of minting it. 44 tests green; realtime holds at 640×360.
### The region pool + the browser as workhorse (landed)
**Node stays CPU** — it is the dev, test and export path, simple on purpose.
**The browser is the workhorse**: it renders, it previews, and it has the
GPU. The compute architecture, in three layers:
1. **Think slower than you move** (landed): the strains decide every
`vmEvery`-th substep (default 2) and empty ground never thinks. Measured
at field 128 with 41 k organisms: 25 → 20 ms/frame, ecology unchanged.
2. **The region pool** (landed): the field is carved into horizontal
coarse-row bands; every shared array lives in ONE SharedArrayBuffer
(`arenaLayout`), and `World`'s substep splits into three band-local
phases — block sums, the strain programs, advection+reaction — separated
by atomics barriers (`beginPhase`). The main thread keeps only the
cross-band work: the war front (hits land at aim targets), the swap, the
rock economy, politics, decay. The mail carries the frame, the decide
flag, warmth, the living bodies and the current swap orders (the
workers' named arrays follow the main's swaps through the mail).
**Measured: 52.3 → 14.7 ms/frame (2 workers) → 8.9 ms/frame (4 workers)
at 41 k organisms — near-linear.** And the invariant that makes it safe:
the phases are EXACT partitions, so a parallel world is the serial world
BIT FOR BIT (pinned by a test across 0/2/4 workers). Node runs it on
`worker_threads` (lazily resolved, so the browser still loads the
module); the browser will run the same pool inside its simulation worker.
3. **The GPU** (first slice landed): the strain programs are uniform over
thousands of blocks — one dispatch per strain covers the whole decision
layer. `src/sim/gpu.js` + `tools/gpu-check.js` (browser-side validation
against the CPU interpreter) await device time.
**The browser as the server's worker (protocol sketch, next).** Once
rendering exists, the browser becomes a compute provider to the node farm:
- the farm (node) holds prepared songs (`out/prepared/*.json`) and a job
queue: `(songId, seed, frameFrom, frameTo)`;
- a browser provider connects (WebSocket), registers its capability
(GPU? cores?), receives a prepared artifact + a frame range, runs
`Run` inside its worker pool with the GPU decision layer, and streams
back encoded frames (PNG or raw) keyed by frame number;
- the farm stitches the frames into the film. Sim runs are sequential in
time, so the parallel unit is the TAKE (a song × seed × range), not a
frame — many takes in flight across many providers, with the farm
checkpointing world state at range boundaries so a provider can resume.
Determinism policy unchanged: node's CPU path is the canonical exporter;
the GPU/preview paths are the same world to within float noise.
### The decision layer on the GPU (analysis, first slice landed) ### The decision layer on the GPU (analysis, first slice landed)
The profile of a heavy frame (field 128, ~40 000 organisms planted) says: The profile of a heavy frame (field 128, ~40 000 organisms planted) says:
+60
View File
@@ -0,0 +1,60 @@
// A region worker: owns a horizontal band of coarse rows and runs the
// band-local phases of the world's substep against the shared arena.
//
// The protocol is a pair of small shared arrays:
// ctrl [0] = phase sequence number (raised by the main thread)
// [1] = done count (raised by each worker; the main waits on it)
// mail [0] frame, [1] decide flag, [3] warmth (f32), [4] aliveCount,
// [5..] alive body ids, [20..] pop order, [30..] alleg order,
// [40..] carry order
//
// The worker constructs a bare World whose arrays are views into the SAME
// arena the main thread uses — it is the same world, seen through a band.
import { World } from './world.js';
import { parentPort, workerData } from 'node:worker_threads';
const { song, field, seed, fps, substeps, vmEvery, layout, arena, ctrl: ctrlBuf, mail: mailBuf, band } = workerData;
const w = new World(song, { field, seed, fps, substeps, vmEvery, sab: arena, layout, bare: true });
const ctrl = new Int32Array(ctrlBuf);
const mail = new Int32Array(mailBuf);
const warmth = new Float32Array(mailBuf, 12, 1);
let seen = 0;
for (;;) {
Atomics.wait(ctrl, 0, seen);
const seq = Atomics.load(ctrl, 0);
if (seq < 0) break;
seen = seq;
const phase = mail[2];
const decide = mail[1] === 1;
w.warmth = warmth[0];
const aliveCount = mail[4];
const alive = [];
for (let k = 0; k < aliveCount; k++) alive.push(mail[5 + k]);
w.aliveBodies = alive;
// follow the main thread's swap orders
const popOrder = [];
for (let k = 0; k < w.m; k++) popOrder.push(mail[20 + k]);
const allegOrder = [];
for (let k = 0; k < w.B; k++) allegOrder.push(mail[40 + k]);
const carryOrder = [];
for (let k = 0; k < w.m; k++) carryOrder.push(mail[60 + k]);
w.pop = popOrder.map((i) => w.popSlots[i]);
w.alleg = allegOrder.map((i) => w.allegSlots[i]);
w.carry = carryOrder.map((i) => w.carrySlots[i]);
const regime = w.regimeForFrame(w.frame);
if (phase === 1) w.phaseSums(band.c0, band.c1, regime);
else if (phase === 2) w.phaseStrains(regime, band.c0, band.c1, decide);
else if (phase === 3) w.phaseFields(regime, band.c0, band.c1);
Atomics.add(ctrl, 1, 1);
Atomics.notify(ctrl, 1);
}
parentPort.close();
File diff suppressed because it is too large Load Diff
+19
View File
@@ -96,6 +96,25 @@ test('genesis pre-simulates generations so frame 0 is already an ecosystem', ()
assert.equal(withGenesis.world.frame, -1, 'the film starts at frame 0'); assert.equal(withGenesis.world.frame, -1, 'the film starts at frame 0');
}); });
test('the region pool is exact: parallel == serial, bit for bit', () => {
const grow = (workers) => {
const song = load('drone');
const run = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0, workers });
for (let f = 0; f < 150; f++) { run.world.step(); run.camera.update(f); }
const history = Array.from(run.world.history.alive.slice(0, 150));
const trail = Array.from(run.world.trail[0].slice(0, 4000));
run.world.dispose?.();
return { history, trail };
};
const serial = grow(0);
const two = grow(2);
const four = grow(4);
const eq = (a, b) => a.history.every((v, i) => v === b.history[i]) &&
a.trail.every((v, i) => v === b.trail[i]);
assert.ok(eq(serial, two), '2 regions must match the serial world exactly');
assert.ok(eq(serial, four), '4 regions must match the serial world exactly');
});
test('genesis is deterministic and song-scaled', () => { test('genesis is deterministic and song-scaled', () => {
const a = new Run(load('drone'), { fps: 30, seed: 12345, width: 240, height: 135, field: 64 }); const a = new Run(load('drone'), { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
const b = new Run(load('drone'), { fps: 30, seed: 12345, width: 240, height: 135, field: 64 }); const b = new Run(load('drone'), { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
+10 -3
View File
@@ -23,8 +23,14 @@ function plant(w) {
w.recount(); w.recount();
} }
for (const vmEvery of [1, 2, 3]) { const configs = [];
const w = new World(song, { fps: 30, field: 128, genesis: 0, vmEvery }); for (const a of process.argv.slice(4)) {
if (a.startsWith('w')) configs.push({ workers: Number(a.slice(1)) });
else if (a.startsWith('v')) configs.push({ vmEvery: Number(a.slice(1)) });
}
if (!configs.length) configs = [{ workers: 0, vmEvery: 2 }, { workers: 2 }, { workers: 4 }];
for (const cfg of configs) {
const w = new World(song, { fps: 30, field: 128, genesis: 0, vmEvery: cfg.vmEvery ?? 2, workers: cfg.workers ?? 0 });
plant(w); plant(w);
const start = w.count; const start = w.count;
// warm-up // warm-up
@@ -32,5 +38,6 @@ for (const vmEvery of [1, 2, 3]) {
const t0 = process.hrtime.bigint(); const t0 = process.hrtime.bigint();
for (let f = 0; f < frames; f++) w.step(); for (let f = 0; f < frames; f++) w.step();
const ms = Number(process.hrtime.bigint() - t0) / 1e6 / frames; const ms = Number(process.hrtime.bigint() - t0) / 1e6 / frames;
console.log(`vmEvery=${vmEvery} sim ${ms.toFixed(1)} ms/frame (start ${Math.round(start)}, now ${Math.round(w.count)}, peak-ish budget ${Math.round(w.cap).toLocaleString()})`); console.log(`workers=${w.pool?.length ?? 0} vmEvery=${w.vmEvery} sim ${ms.toFixed(1)} ms/frame (start ${Math.round(start)}, now ${Math.round(w.count)})`);
w.dispose?.();
} }