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:
@@ -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:
|
||||||
|
|||||||
@@ -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();
|
||||||
+548
-263
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||||
|
|||||||
@@ -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?.();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user