From 23d3696adcdbd13eddc58b7b86492546d96897ff Mon Sep 17 00:00:00 2001 From: Dejvino Date: Fri, 30 Oct 2026 07:26:28 +0100 Subject: [PATCH] =?UTF-8?q?metabolism:=20region=20pool=20exactness=20+=20b?= =?UTF-8?q?rowser=20boot=20=E2=80=94=20deterministic=20velocity=20pass,=20?= =?UTF-8?q?pool=20warm-gate=20for=20the=20web=20worker=20boot,=20breaker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two root causes fixed end to end: 1. Exactness race: phaseFields' velocity pass wrote its own coarse blocks and then read halo blocks no neighbour had committed yet — pool runs diverged at random frames (2/4 runs). The velocity pass now runs as a block-owner section at the END of phaseStrains; phaseFields only reads committed velocities. 8/8 pool runs bit-exact through f1239; tests 9/9 (including 1600-frame x 2-worker runs across the section boundary). 2. Browser boot stall: beginPhase's hot spin (Atomics.wait throws on a window) froze pending web-worker script loads — Chromium routes worker startup through the busy main thread, so the show stalled 30s and threw 'region pool stalled'. The web shell now acks ('pool') after makeRegion (mail[63] is a real boot counter), the world runs cold until every worker acks (step/stepTo skip frames; post-warm catch-up is unbounded), and loadSongData awaits player.warm() before the show. Node pools are untouched (native threads answer barriers directly). Also: Player.build/rewind dispose the old pool (no more leaked idle worlds); region-core.js is the shared worker body; region-worker.web.js the browser shell (vite bundles it via the literal new Worker(new URL) spelling — dist emits a self-contained worker asset instead of a broken base64 data URL). Circuit breaker in the demo: >=2 consecutive sub-25fps windows trip; >=30 frames behind the audio clock for >=750ms trips (dormant while stepTo catches up unconditionally — the fps path is live). 12/12 mirror scenarios pass. Bench: node serial 74.2ms/f, 2w 38.9 (1.91x), 4w 24.6 (3.02x). Browser: steady 30fps including a 64s soak to 20k organisms at 640x360. --- metabolism/src/demo/main.js | 51 +++- metabolism/src/demo/player.js | 29 +- metabolism/src/film.js | 7 +- metabolism/src/sim/region-core.js | 87 ++++++ metabolism/src/sim/region-worker.js | 54 +--- metabolism/src/sim/region-worker.web.js | 26 ++ metabolism/src/sim/world.js | 391 ++++++++++++++++++------ metabolism/test/world.test.js | 19 +- 8 files changed, 505 insertions(+), 159 deletions(-) create mode 100644 metabolism/src/sim/region-core.js create mode 100644 metabolism/src/sim/region-worker.web.js diff --git a/metabolism/src/demo/main.js b/metabolism/src/demo/main.js index 6f54aec..c93e663 100644 --- a/metabolism/src/demo/main.js +++ b/metabolism/src/demo/main.js @@ -52,6 +52,7 @@ const state = { playing: false, loading: false, fps: null, // achieved film rate, measured by the circuit breaker + lagS: null, // seconds the picture runs behind the audio clock }; // ---- audio pipeline (browser side of the shared pipeline) ------------------ @@ -99,6 +100,7 @@ async function loadSongData(id, seed, res) { const song = buildSong(analysis, seed); const player = new Player(els.view, w, h); player.build(song); + await player.warm(); // the pool answers its boot acks before the show begins const audio = new AudioTransport(pcmSlot.pcm, pcmSlot.sampleRate, { onEnd: handleEnded }); return { entry: state.byId.get(id), song, analysis, player, audio }; } @@ -212,19 +214,46 @@ function doSeek(seconds) { const BREAKER_FPS = 25; // the minimum sustained film rate we accept const BREAKER_WIN = 2.0; // seconds per measurement window const BREAKER_STRIKES = 2; // consecutive slow windows before we stop +const BREAKER_LAG = 30; // frames behind the audio clock that count as stranded +const BREAKER_LAG_HOLD = 750; // ms of stranding before we stop -const breaker = { frames: 0, dt: 0, last: 0, strikes: 0 }; +const breaker = { frames: 0, dt: 0, last: 0, strikes: 0, lagSince: 0 }; function breakerReset() { breaker.frames = 0; breaker.dt = 0; breaker.last = 0; breaker.strikes = 0; + breaker.lagSince = 0; state.fps = null; + state.lagS = null; } -function breakerTick(steps) { +function tripBreaker(why) { + console.warn('[metabolism] circuit breaker:', JSON.stringify(why)); + breakerReset(); + pauseCurrent(); + const detail = why.lagS != null + ? `the picture fell ${why.lagS.toFixed(1)}s behind the song — playback stopped` + : `sustained ${Math.round(why.fps)} fps < ${BREAKER_FPS} — playback stopped`; + setOverlay( + `the world outgrew this machine` + + `${detail}. Try a lower resolution (res) or another seed.` + ); +} + +function breakerTick(steps, behind) { const now = performance.now(); + // The lag trip: a rate that still clears BREAKER_FPS but never reaches + // the audio clock means the picture drifts (the deficit grows every + // tick) — that is what chugging looks like when the rate looks fine. + if (behind >= BREAKER_LAG) { + if (!breaker.lagSince) breaker.lagSince = now; + else if (now - breaker.lagSince >= BREAKER_LAG_HOLD) { tripBreaker({ lagS: behind / FPS }); return; } + } else { + breaker.lagSince = 0; + state.lagS = behind > 0 ? behind / FPS : null; + } if (!breaker.last) { breaker.last = now; return; } breaker.dt += (now - breaker.last) / 1000; breaker.last = now; @@ -238,18 +267,7 @@ function breakerTick(steps) { breaker.strikes = 0; return; } - if (++breaker.strikes >= BREAKER_STRIKES) tripBreaker(fps); -} - -function tripBreaker(fps) { - console.warn(`[metabolism] circuit breaker: sustained ${fps.toFixed(1)} fps < ${BREAKER_FPS} — stopping`); - breakerReset(); - pauseCurrent(); - setOverlay( - `the world outgrew this machine` + - `sustained ${Math.round(fps)} fps < ${BREAKER_FPS} — playback stopped. ` + - `Try a lower resolution (res) or another seed.` - ); + if (++breaker.strikes >= BREAKER_STRIKES) tripBreaker({ fps }); } let hudTick = 0; @@ -261,7 +279,7 @@ function loop() { const before = c.player.run.world.frame; c.player.stepTo(target); c.player.renderCurrent(); - breakerTick(target - before); + breakerTick(target - before, Math.max(0, target - c.player.run.world.frame)); updateSeekUI(pos); if (++hudTick % 4 === 0) updateHUD(); } else { @@ -339,7 +357,8 @@ function updateHUD() { const s = w.stats(); els.hud.textContent = `${s.alive} agents · field ${s.fieldMass.toFixed(2)} · trail ${s.trailMass.toFixed(3)}` + - (state.fps != null ? ` · ${Math.round(state.fps)} fps` : ''); + (state.fps != null ? ` · ${Math.round(state.fps)} fps` : '') + + (state.lagS != null && state.lagS >= 0.5 ? ` · ${(state.lagS).toFixed(1)}s behind` : ''); const cam = c.player.run.camera; const size = cam.zoom < 1.8 ? 'wide' : cam.zoom < 2.7 ? 'medium' : 'close'; els.shot.textContent = `shot ${cam.shot} · ${size} · zoom ${cam.zoom.toFixed(2)}`; diff --git a/metabolism/src/demo/player.js b/metabolism/src/demo/player.js index c2c1e65..20d8714 100644 --- a/metabolism/src/demo/player.js +++ b/metabolism/src/demo/player.js @@ -10,6 +10,18 @@ import { Run } from '../film.js'; export const FPS = 30; +/** + * The demo's pooled parallelism. The main thread keeps the render and the + * page; the spare cores grow the world. Only when cross-origin isolation + * exposes SharedArrayBuffer and the hardware has cores beyond + * (main thread + one) — otherwise stay serial and let the breaker decide. + */ +export function demoWorkers() { + if (typeof SharedArrayBuffer === 'undefined' || self.crossOriginIsolated === false) return 0; + const hc = navigator.hardwareConcurrency || 4; + return hc > 2 ? Math.min(4, hc - 2) : 0; +} + export class Player { /** * @param {HTMLCanvasElement} canvas @@ -21,6 +33,7 @@ export class Player { this.ctx = canvas.getContext('2d'); this.width = width; this.height = height; + this.workers = demoWorkers(); // fixed per page load, reused on rebuilds this.run = null; this.song = null; this.img = null; @@ -31,7 +44,10 @@ export class Player { this.song = song; this.canvas.width = this.width; this.canvas.height = this.height; - this.run = new Run(song, { fps: FPS, width: this.width, height: this.height, seed: song.seed }); + // The old pool (if any) never shows another audio clock: stop it + // before it silently accumulates one idle world per re-grow. + if (this.run?.world?.pool) this.run.world.dispose(); + this.run = new Run(song, { fps: FPS, width: this.width, height: this.height, seed: song.seed, workers: this.workers }); // The renderer's framebuffer aliases the canvas ImageData so there is // exactly one copy per frame: render() draws straight into img.data and // putImageData() uploads it. Same bitmap the renderer paints into node. @@ -44,14 +60,21 @@ export class Player { this.run.stepTo(frame); } + /** Resolves once the world's pool can run phases (serial: immediately). */ + warm() { + return this.run.world.warm(); + } + /** Step the world to a time in seconds, rebuilding on a backwards seek. */ seekTo(seconds) { const target = clamp(Math.floor(seconds * FPS), 0, this.song.analysis.frames - 1); if (target < this.run.world.frame) { // The world and the camera are stateful; a backwards seek re-grows // from frame 0. Forward stepping is cheap (pure sim), so only a - // rewind pays the replay cost. - this.run = new Run(this.song, { fps: FPS, width: this.width, height: this.height, seed: this.song.seed }); + // rewind pays the replay cost. The fresh pool boots cold (web); + // the demo loop skips steps until it answers. + this.run.world.dispose(); + this.run = new Run(this.song, { fps: FPS, width: this.width, height: this.height, seed: this.song.seed, workers: this.workers }); this.run.renderer.bitmap.data = this.img.data; } this.run.stepTo(target); diff --git a/metabolism/src/film.js b/metabolism/src/film.js index bf366e9..753f98e 100644 --- a/metabolism/src/film.js +++ b/metabolism/src/film.js @@ -7,7 +7,7 @@ import { Camera } from './camera.js'; import { Renderer } from './render/view.js'; export class Run { - constructor(song, { fps = 30, width = 640, height = 360, seed = song.seed, field, genesis } = {}) { + constructor(song, { fps = 30, width = 640, height = 360, seed = song.seed, field, genesis, workers } = {}) { this.song = song; this.fps = fps; this.width = width; @@ -17,6 +17,7 @@ export class Run { seed, ...(field ? { field } : {}), ...(genesis !== undefined ? { genesis } : {}), + ...(workers ? { workers } : {}), }); this.camera = new Camera(song, this.world, { fps, width, height }); this.renderer = new Renderer(width, height); @@ -24,6 +25,10 @@ export class Run { /** Advance to a frame, updating the camera on every step. */ stepTo(frame) { + // A cold web pool (workers still booting) cannot advance the world: + // calling step() would no-op forever inside this loop. The next + // stepTo after warm-up catches up to the audio clock. + if (!this.world.poolWarm) return this; while (this.world.frame < frame) { this.world.step(); this.camera.update(this.world.frame); diff --git a/metabolism/src/sim/region-core.js b/metabolism/src/sim/region-core.js new file mode 100644 index 0000000..8b414e5 --- /dev/null +++ b/metabolism/src/sim/region-core.js @@ -0,0 +1,87 @@ +// The shared body of the region pool's workers. +// +// Both shells — node's worker_threads (region-worker.js) and the browser's +// module worker (region-worker.web.js) — receive the same init object, build +// the same bare World over the shared arena, and run the same phase body +// below. The protocol is written down in region-worker.js. + +import { World } from './world.js'; + +/** + * Attach a region to the shared arena. `init` carries the song and geometry + * (factory references live in world.js); the typed-array views are carved + * from the same buffers the main thread owns, so the worker is the same + * world seen through its band. + */ +export function makeRegion(init) { + const w = new World(init.song, { + field: init.field, + seed: init.seed, + fps: init.fps, + substeps: init.substeps, + vmEvery: init.vmEvery, + sab: init.arena, + layout: init.layout, + bare: true, + }); + const region = { + w, + band: init.band, + ctrl: new Int32Array(init.ctrl), + mail: new Int32Array(init.mail), + warmth: new Float32Array(init.mail, 12, 1), + seen: 0, + }; + // boot counter (mail[63]): beginPhase's stall diagnostic reports it; the + // web shell also posts a one-shot ack so the main can await warmth. + Atomics.add(region.mail, 63, 1); + return region; +} + +/** + * Wait for a phase and run it. False only when the main thread shut the + * pool down (negative sequence); otherwise the worker sleeps again. + */ +export function regionPhase(region) { + const { w, band, ctrl, mail, warmth } = region; + Atomics.wait(ctrl, 0, region.seen); + const seq = Atomics.load(ctrl, 0); + if (seq < 0) return false; + region.seen = seq; + // The frame and the decide cadence are mailed per phase; the worker's + // own state is otherwise derived from the shared arena. Without the + // frame the worker's regime would be frozen at the song's first section. + w.frame = mail[0]; + w.warmth = warmth[0]; + const phase = mail[2]; + const decide = mail[1] === 1; + 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 (the named arrays drift + // frame by frame; both sides must address the same storage) + w.pop = []; + for (let k = 0; k < w.m; k++) w.pop.push(w.popSlots[mail[20 + k]]); + w.alleg = []; + for (let k = 0; k < w.B; k++) w.alleg.push(w.allegSlots[mail[40 + k]]); + w.carry = []; + for (let k = 0; k < w.m; k++) w.carry.push(w.carrySlots[mail[60 + k]]); + // and the NEXT-slot orders: the main's afterFields swaps pop<->popNext + // every substep, so the workers' `next` views must point at the same + // storage the main is about to swap in. + w.popNext = []; + for (let k = 0; k < w.m; k++) w.popNext.push(w.popSlots[mail[80 + k]]); + w.allegNext = []; + for (let k = 0; k < w.B; k++) w.allegNext.push(w.allegSlots[mail[100 + k]]); + w.carryNext = []; + for (let k = 0; k < w.m; k++) w.carryNext.push(w.carrySlots[mail[120 + k]]); + + 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); + return true; +} diff --git a/metabolism/src/sim/region-worker.js b/metabolism/src/sim/region-worker.js index 8f32d6b..1132ab4 100644 --- a/metabolism/src/sim/region-worker.js +++ b/metabolism/src/sim/region-worker.js @@ -1,60 +1,24 @@ -// 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. +// A region worker (node variant): 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 +// [5..] alive body ids, [20..] pop order, [40..] alleg order, +// [60..] 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. +// The phase body itself lives in region-core.js, shared with the browser +// worker so the two runtimes cannot drift apart. -import { World } from './world.js'; +import { makeRegion, regionPhase } from './region-core.js'; import { parentPort, workerData } from 'node:worker_threads'; +const region = makeRegion(workerData); -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); + if (!regionPhase(region)) break; } parentPort.close(); diff --git a/metabolism/src/sim/region-worker.web.js b/metabolism/src/sim/region-worker.web.js new file mode 100644 index 0000000..cb95718 --- /dev/null +++ b/metabolism/src/sim/region-worker.web.js @@ -0,0 +1,26 @@ +// A region worker (browser variant): the same band body as the node worker, +// booted from a postMessage'd init rather than workerData. Runs as an ES +// module worker (Vite bundles the graph the same way as the page's own). +// +// Booting is asynchronous: the module evaluates (and processes this init +// message) a beat after the main thread spawns the pool. The shell's first +// act inside onmessage is to ack (`pool`) — the main's warm gate (see +// World.warmPool/step) skips stepping until every worker has acked, which +// matters because a pending worker's script load is frozen by a hot main +// thread (Chromium routes worker startup through the window's loop). + +import { makeRegion, regionPhase } from './region-core.js'; + +const host = globalThis.self ?? globalThis; + +host.onmessage = ({ data: init }) => { + const region = makeRegion(init); + // Boot ack: the pool's warm gate on the main counts these — one per + // worker. The counter also lives in mail[63] (raised in makeRegion). + postMessage('pool'); + // The phase loop blocks on Atomics.wait between phases; the worker's + // event loop never returns to this handler. Shutdown is terminate(). + for (;;) { + if (!regionPhase(region)) return; + } +}; diff --git a/metabolism/src/sim/world.js b/metabolism/src/sim/world.js index a01255e..949c759 100644 --- a/metabolism/src/sim/world.js +++ b/metabolism/src/sim/world.js @@ -31,17 +31,20 @@ import { Rng } from '../rng.js'; /** - * The region pool runs on node's worker_threads; the browser gets its - * parallelism through the GPU decision layer (and, later, a sim worker). - * Resolved lazily so the module keeps loading in the browser. + * How the region pool's workers are built: worker_threads on node, ES module + * workers in the browser (the demo server already sends the COOP/COEP headers + * that expose SharedArrayBuffer under cross-origin isolation). Resolved + * lazily so the module keeps loading anywhere. */ function workerCtor() { try { const mod = globalThis.process?.getBuiltinModule?.('node:worker_threads'); - return mod?.Worker ?? null; - } catch { - return null; + if (mod?.Worker) return { ctor: mod.Worker, kind: 'node' }; + } catch { /* fall through */ } + if (typeof globalThis.Worker === 'function') { + return { ctor: globalThis.Worker, kind: 'web' }; } + return null; } import { wellPos, wellEnv, buildKernel } from './wells.js'; import { @@ -167,6 +170,11 @@ export function arenaLayout(field, m, B, FC) { items.push(A('aimY', m * fc, Int8Array, m * fc)); items.push(A('activity', 32 * 32 * 4, Float32Array, 32 * 32)); items.push(A('hungerMass', 4, Float32Array, 1)); + // Per-row accumulation partials for the frame counters that scattered + // writes used to race across bands (hunger, move, births, deaths). + // Bands always cover whole rows, and the merge in afterFields runs in a + // fixed row order, so the folded sum is identical for any worker count. + items.push(A('bandStats', field * 4 * 4, Float32Array, field * 4)); return { items, bytes: items.reduce((a, it) => a + it.bytes, 0) }; } @@ -186,6 +194,7 @@ export class World { this.palette = song.palette; this.wellTimeline = song.wells.wells; this.rng = new Rng(seed >>> 0).fork('world'); + this.seed = seed >>> 0; // the workers must seed the SAME noise field // Pre-simulation (genesis): how long the world evolves before frame 0, // so the opening is an old ecosystem, not a blank plate. The amount is @@ -260,6 +269,18 @@ export class World { this.activity = carve('activity', Float32Array, 32 * 32); this.hungerCell = carve('hungerMass', Float32Array, 1); // shared accumulator + this.bandStats = carve('bandStats', Float32Array, field * 4); // per-row partials + + // The gather advection's motion cache: computed once per source cell + // and shared by every destination it feeds. PRIVATE to this world + // instance (serial main or one region worker) — never in the arena, + // so concurrent regions cannot touch each other's cache. + this._advSeen = new Uint8Array(n); + this._advVx = new Float64Array(n); + this._advVy = new Float64Array(n); + this._advmx = new Float64Array(n); + this._advmy = new Float64Array(n); + this._advLeave = new Float64Array(n); // Rock (M2.8): the inert resource. A barrier the organisms cannot // cross, that the song drops and shatters, and that builders quarry, @@ -418,6 +439,7 @@ export class World { }; this.frame = -1; + this.poolWarm = true; // web pools go cold at boot; see step()/warmPool() this.substepCount = 0; this.pulses = []; this.totalPop = 0; @@ -443,26 +465,80 @@ export class World { const WorkerC = workerCtor(); if (!bare && workers > 1 && !WorkerC) workers = 0; // no worker system: stay serial if (!bare && workers > 1) { + this.workerKind = WorkerC.kind; this.ctrl = new Int32Array(new SharedArrayBuffer(32)); this.mail = new Int32Array(new SharedArrayBuffer(512)); this.pool = []; const bands = []; + let ready = 0; // 'pool' boot acks (web only; node answers barriers directly) + if (WorkerC.kind === 'web') { + // Web pools go cold at boot; step()/Run.stepTo skip frames + // until every worker answers its boot ack (see below). + this.poolWarm = false; + this.poolReady = new Promise((res) => { this._warmResolve = res; }); + } else { + // Node workers are native threads; they answer the first + // phase barrier without any main-thread involvement. + this.poolReady = Promise.resolve(); + } for (let k = 0; k < workers; k++) { const c0 = Math.round((this.FC * k) / workers); const c1 = Math.round((this.FC * (k + 1)) / workers); bands.push({ c0, c1 }); - const worker = new WorkerC(new URL('./region-worker.js', import.meta.url), { - workerData: { - song, field, seed: this.seed, fps, substeps, vmEvery: this.vmEvery, - layout: { items: spec.items.map(({ name, bytes, offset, len }) => ({ name, bytes, offset, len })), bytes: spec.bytes }, - arena: this.arena, ctrl: this.ctrl.buffer, - mail: this.mail.buffer, band: bands[k], - }, - }); - worker.unref(); // the pool never holds the process open + const init = { + song, field, seed: this.seed, fps, substeps, vmEvery: this.vmEvery, + layout: { items: spec.items.map(({ name, bytes, offset, len }) => ({ name, bytes, offset, len })), bytes: spec.bytes }, + arena: this.arena, ctrl: this.ctrl.buffer, + mail: this.mail.buffer, band: bands[k], + }; + let worker; + if (WorkerC.kind === 'web') { + // Module workers boot asynchronously (vite serves the + // script through a transform; the module evaluates and + // processes the init message a beat later). Each worker + // posts a single `pool` boot ack after makeRegion; the + // pool is warm (see step()) only once every worker has + // answered. A beginPhase spin must never be entered on a + // cold pool: Chromium routes a pending worker's script + // load through the busy main thread, so a hot spin while + // the pool boots would freeze the boot itself. + // (The literal `new Worker(new URL(...))` matters: vite + // only recognizes the canonical spelling when it bundles + // the worker's graph for the dist build.) + worker = new Worker(new URL('./region-worker.web.js', import.meta.url), { type: 'module' }); + worker.onerror = (e) => { + // A failed worker never answers phase barriers; warm + // anyway so the world keeps stepping into the existing + // `region pool stalled` diagnostic (done N/4) instead + // of freezing silently behind the cold gate. + console.error(`region worker ${k} failed: ${e.message} @${e.filename}:${e.lineno}`); + this.warmPool(); + }; + worker.onmessage = () => { + ready += 1; // 'pool' boot ack; mail[63] raised in makeRegion + if (ready >= this.pool.length) this.warmPool(); + }; + worker.postMessage(init); + } else { + const NodeWorker = WorkerC.ctor; + worker = new NodeWorker(new URL('./region-worker.js', import.meta.url), { workerData: init }); + worker.unref(); // the pool never holds the process open + } this.pool.push(worker); } this.bands = bands; + // The phase barrier. Browsers forbid a blocking Atomics.wait on a + // window's main thread; the probe decides between blocking waits + // (node, worker hosts) and plain spinning (Chrome's main thread). + this.blockingWait = (() => { + try { + const probe = new Int32Array(new SharedArrayBuffer(4)); + Atomics.wait(probe, 0, 0, 1); + return true; + } catch { + return false; + } + })(); } } @@ -870,7 +946,24 @@ export class World { return living; } + /** The pool's boot handshake (web workers only): warm once every worker answered. */ + warmPool() { + if (this.poolWarm) return; + this.poolWarm = true; + this._warmResolve?.(); + } + + /** Resolves when the pool can run phases (immediately for serial/node). */ + warm() { + return this.poolReady ?? Promise.resolve(); + } + step() { + // Cold pool: the workers are still booting their script (vite serves + // it through a transform; a spin here would freeze that boot — + // Chromium routes a pending worker's script load through this + // thread). Skip frames until warm; post-warm catch-up is unbounded. + if (!this.poolWarm) return null; this.frame++; const f = this.frame; const regime = this.regimeForFrame(f); @@ -1109,7 +1202,7 @@ export class World { // forces already sit in fxX/fxY, actions in the buffer. const syn = this.syn[s]; const kinShare = dom >= 0 ? clamp01((blkBody[dom][b] - own) / Math.max(1e-6, syn.threshold)) : 0; - synScratch[s * FC + b] = syn.base + (1 - syn.base) * kinShare; + synScratch[s * actFC + b] = syn.base + (1 - syn.base) * kinShare; continue; } const nb = (blkPop[s][ug] + blkPop[s][dg] + blkPop[s][lg] + blkPop[s][rg]) / 4; @@ -1170,7 +1263,10 @@ export class World { gg[G_GARDEN * 2 + 1] = Math.sign(blkGarden[dg] - blkGarden[ug]); const flat = ensureFlat(strains[s]); - runProgram(this.stMem, s * REGS * FC + b, ss, gg, act, flat._flatI, flat._flatF, flat._flatN); + // Memory layout: [strain][block][REGS] — the base of this + // (strain, block) is REGS registers into the strain's slab + // of REGS×fc cells (the arena sizes stMem m*REGS*fc). + runProgram(this.stMem, s * REGS * actFC + b, ss, gg, act, flat._flatI, flat._flatF, flat._flatN); // Remember the aim: the next substep's work-senses look there. let nx = act[12] !== 0 ? Math.sign(act[12]) : Math.sign(act[0]); let ny = act[13] !== 0 ? Math.sign(act[13]) : Math.sign(act[1]); @@ -1196,9 +1292,32 @@ export class World { // crowd — the share of the block's population that is kin. const syn = this.syn[s]; const kinShare = dom >= 0 ? clamp01((blkBody[dom][b] - own) / Math.max(1e-6, syn.threshold)) : 0; - synScratch[s * FC + b] = syn.base + (1 - syn.base) * kinShare; + synScratch[s * actFC + b] = syn.base + (1 - syn.base) * kinShare; } } + // Block-owner velocity pass: the density-weighted mean strain force + // per block, a pure function of this block's phase-1 sums (blkPop) + // and its own just-written fxX/fxY. Nothing here reads a neighbour + // block, so whichever band owns the block computes the same bits — + // and phase 3 (allegiance/carry advection) reads velocities that are + // FULLY written once the phase-2 barrier passes. (Computing this at + // the top of phase 3 instead left band-edge reads racing against the + // neighbouring worker's writes.) + const velX = this.velX; + const velY = this.velY; + for (let b = c0 * FC; b < c1 * FC; b++) { + let sx = 0; + let sy = 0; + let wPop = 0; + for (let s = 0; s < m; s++) { + const p = blkPop[s][b]; + sx += fxX[s][b] * p; + sy += fxY[s][b] * p; + wPop += p; + } + velX[b] = wPop > 1e-6 ? sx / wPop : 0; + velY[b] = wPop > 1e-6 ? sy / wPop : 0; + } } gradOf(arr, b, ug, dg, lg, rg) { return Math.sign(arr[rg] - arr[lg]); } @@ -1254,18 +1373,31 @@ export class World { for (let s = 0; s < this.m; s++) Atomics.store(mail, 20 + s, this.popSlots.indexOf(this.pop[s])); for (let b = 0; b < this.B; b++) Atomics.store(mail, 40 + b, this.allegSlots.indexOf(this.alleg[b])); for (let s = 0; s < this.m; s++) Atomics.store(mail, 60 + s, this.carrySlots.indexOf(this.carry[s])); - // raise the phase sequence and wait for every done-flag + // ...and the NEXT-slot orders: afterFields swaps pop<->popNext (and + // the alleg/carry pairs) on the main every substep, so the workers' + // `next` views must follow the same storage the main will swap — + // otherwise every substep after the first lands in stale space. + for (let s = 0; s < this.m; s++) Atomics.store(mail, 80 + s, this.popSlots.indexOf(this.popNext[s])); + for (let b = 0; b < this.B; b++) Atomics.store(mail, 100 + b, this.allegSlots.indexOf(this.allegNext[b])); + for (let s = 0; s < this.m; s++) Atomics.store(mail, 120 + s, this.carrySlots.indexOf(this.carryNext[s])); + // raise the phase sequence and wait until every done-flag Atomics.store(ctrl, 1, 0); this.phaseSeq = (this.phaseSeq ?? 0) + 1; const seq = this.phaseSeq; Atomics.store(ctrl, 0, seq); Atomics.notify(ctrl, 0, this.pool.length); + const deadline = performance.now() + 30000; let polls = 0; while (Atomics.load(ctrl, 1) < this.pool.length) { - if (++polls > 2000) { + if (this.blockingWait) { + Atomics.wait(ctrl, 1, Atomics.load(ctrl, 1), 25); + } + // The spin path must poll generously (a module worker's first + // boot can take hundreds of ms); the clock only polices waits, + // it never touches world state. + if (++polls > 64 && performance.now() > deadline) { throw new Error(`region pool stalled: phase ${id}, done ${Atomics.load(ctrl, 1)}/${this.pool.length}, workerBoot ${Atomics.load(mail, 63)}`); } - Atomics.wait(ctrl, 1, Atomics.load(ctrl, 1), 25); } } @@ -1389,6 +1521,30 @@ export class World { // ---- after the fields: swap + the rock economy (main; aim targets cross bands) afterFields(regime) { + // Fold the per-row accumulator partials into the frame counters. + // Only the main thread runs this, and the fold visits rows in the + // SAME fixed order whatever the band layout (a band always covers + // whole rows of fine cells) — so serial, 2 regions and 4 regions + // add up the same bits. The slots zero as they fold: the next + // substep starts from a clean partial set. + const Fm = this.field; + const rowStats = this.bandStats; + let hunger = this.hungerCell[0]; + let move = this.moveAccum; + let births = this.birthsThisFrame; + let deaths = this.deathsThisFrame; + for (let r = 0; r < Fm; r++) { + const k = r * 4; + hunger += rowStats[k]; + move += rowStats[k + 1]; + births += rowStats[k + 2]; + deaths += rowStats[k + 3]; + rowStats[k] = 0; rowStats[k + 1] = 0; rowStats[k + 2] = 0; rowStats[k + 3] = 0; + } + this.hungerCell[0] = hunger; + this.moveAccum = move; + this.birthsThisFrame = births; + this.deathsThisFrame = deaths; const aimTarget = (b, s) => { const actFC = this.fc; const actBuf = this.stAct; @@ -1561,6 +1717,8 @@ export class World { const noise = this.noise; const noise2 = this.noise2; const n = this.n; + const F = this.field; + const stats = this.bandStats; const rock = this.rock; const blkPop = this.blkPop; const blkTotal = this.blkTotal; @@ -1569,29 +1727,16 @@ export class World { const actBuf = this.stAct; const actFC = fc; const hostility = this.hostility; + // Block velocities committed by phaseStrains before the phase-2 + // barrier (see the block-owner velocity pass there); only read here. + const velX = this.velX; + const velY = this.velY; // ---- advection + reaction (band rows [c0, c1)) ------------------------- const f0 = c0 * (this.field / FC); const f1 = c1 * (this.field / FC); const lo = f0 * this.field; const hi = f1 * this.field; - // Allegiance rides the population flux: the density-weighted mean of - // the strains' velocities. - const velX = this.velX; - const velY = this.velY; - for (let b = c0 * FC; b < c1 * FC; b++) { - let sx = 0; - let sy = 0; - let w = 0; - for (let s = 0; s < m; s++) { - const p = blkPop[s][b]; - sx += this.fxX[s][b] * p; - sy += this.fxY[s][b] * p; - w += p; - } - velX[b] = w > 1e-6 ? sx / w : 0; - velY[b] = w > 1e-6 ? sy / w : 0; - } const advFrac = Math.min(0.4, this.avgSpeed * ops.speed * sub * 240); for (let s = 0; s < m; s++) { @@ -1621,6 +1766,7 @@ export class World { for (let i = lo; i < hi; i++) { const r0 = res[i]; const b = coarseOf[i]; + const statsRow = (i / F) | 0; const dom = domB[b]; if (r0 < 1e-6 && density[i] < 1e-6) continue; @@ -1629,7 +1775,10 @@ export class World { // Saturated food factor (rational approximation, no transcendentals). const foodFactor = (r0 * 5) / (1 + r0 * 5); - this.hungerCell[0] += density[i] * (1 - foodFactor); + // The frame counters accumulate per-row partials (bandStats): + // rows are never split across bands and afterFields folds them + // in a fixed row order, so serial and any pool agree on the bit. + stats[statsRow * 4] += density[i] * (1 - foodFactor); // The gate is nearly shut on bare ground: food is the substrate, not a // catalyst. A colony that outgrows its ground starves back and must // wander — the world tracks its wells instead of congealing. @@ -1665,7 +1814,7 @@ export class World { for (let s = 0; s < m; s++) { const p = vals[s]; const spn = actBuf[s * ACTION_COUNT * actFC + 5 * actFC + b]; - const synB = synScratch[s * FC + b]; + const synB = synScratch[s * actFC + b]; let g = p * this.growth[s] * ops.growth * globalDamp * (1 + spn) * synB * sub * (1 - p / (this.caps[s] * capMul)) * gate; g *= (1 - casualty * 8); // Starvation is the body's metabolic floor: bare ground kills, @@ -1697,8 +1846,8 @@ export class World { const after = grown[s]; next[s][i] = after; dNew += after; - if (after > before) this.birthsThisFrame += after - before; - else if (after < before) this.deathsThisFrame += before - after; + if (after > before) stats[statsRow * 4 + 2] += after - before; + else if (after < before) stats[statsRow * 4 + 3] += before - after; } density[i] = dNew; @@ -1719,17 +1868,17 @@ export class World { } } - /** - * Conservative L1 advection + diffusion of one field, shared by the - * strains (which track activity) and the allegiance composition. `frac` - * is the fraction of a cell that departs this substep. - */ /** * Conservative L1 advection + diffusion, in GATHER form: every - * destination cell computes its own inflow from src (self terms plus - * the diffusion flux of its open neighbours). No cross-cell writes — - * which is what makes the field phases band-parallel EXACTLY, whatever - * the region count. A 2-row halo of src covers every read. + * destination cell sums its own inflow from src — its own stay-home + * terms, the four edge arrivals it now receives, its diffusion share + * and the sheds of its neighbours — and writes ONLY to itself. A band + * reads a one-cell halo and never writes outside its rows, which is + * what makes the field phase band-parallel EXACTLY, whatever the + * region count. The per-destination arrival order replicates the old + * scatter's per-cell accumulation order bit for bit: arrivals from + * i-F and i-1 (processed earlier in the old loop), then i's own + * terms in full, then the arrivals from i+1 and i+F. */ advect(src, dst, fxArr, fyArr, frac, jitterAmt, diff, sub, track, lo = 0, hi = this.n) { const F = this.field; @@ -1741,7 +1890,14 @@ export class World { const noise = this.noise; const noise2 = this.noise2; const mask = this.rockMask; - dst.fill(0, lo, hi); + const seen = this._advSeen; + const vxC = this._advVx; + const vyC = this._advVy; + const mxC = this._advmx; + const myC = this._advmy; + const leaveC = this._advLeave; + const n = this.n; + seen.fill(0, 0, n); // diffusion flux d_j for every cell j this pass may touch (the cell // itself and its four neighbours); computed read-only from src. const flux = (j) => { @@ -1756,51 +1912,106 @@ export class World { if (open === 0) return 0; return (nb - open * vj) * diff * sub; }; - for (let i = lo; i < hi; i++) { - const v = src[i]; - const b = coarseOf[i]; - const vx = fxArr[b] + (track ? noise[i] * jitterAmt : 0); - const vy = fyArr[b] + (track ? noise2[i] * jitterAmt : 0); - const w = Math.abs(vx) + Math.abs(vy); - const leave = v * frac; - if (v > 1e-6 && leave > 0 && w > 1e-6) { - const mx = (leave * Math.abs(vx)) / w; - const my = (leave * Math.abs(vy)) / w; - dst[i] += v - leave; - // Rock is a wall: mass aimed at a stone cell stays put and - // piles against it. - const tx = vx > 0.02 ? nRight[i] : vx < -0.02 ? nLeft[i] : i; - const ty = vy > 0.02 ? nDown[i] : vy < -0.02 ? nUp[i] : i; - dst[mask[tx] ? i : tx] += mx; - dst[mask[ty] ? i : ty] += my; - - if (track) { - const ax = ((i % F) * 32 / F) | 0; - const ay = ((i / F) * 32 / F) | 0; - this.activity[Math.min(31, ay) * 32 + Math.min(31, ax)] += leave * 6; - this.moveAccum += leave; + const shed = (j) => { + const d = flux(j); + if (d === 0) return 0; + let open = 0; + if (!mask[nUp[j]]) open++; + if (!mask[nDown[j]]) open++; + if (!mask[nLeft[j]]) open++; + if (!mask[nRight[j]]) open++; + return open > 0 ? d * 0.5 / open : 0; + }; + // One motion solve per source cell, shared by every destination cell + // it feeds (halo rows included). Each world instance — the serial + // main or one region worker — walks its own calls with its own + // cache, so concurrent bands never collide on it. + const motion = (k) => { + if (seen[k]) return; + seen[k] = 1; + let vx = 0, vy = 0, mx = 0, my = 0, leave = 0; + const v = src[k]; + if (v > 1e-6) { + const b = coarseOf[k]; + vx = fxArr[b] + (track ? noise[k] * jitterAmt : 0); + vy = fyArr[b] + (track ? noise2[k] * jitterAmt : 0); + const w = Math.abs(vx) + Math.abs(vy); + leave = v * frac; + if (leave > 0 && w > 1e-6) { + mx = (leave * Math.abs(vx)) / w; + my = (leave * Math.abs(vy)) / w; } - } else if (v > 1e-6) { - dst[i] += v; } + vxC[k] = vx; + vyC[k] = vy; + mxC[k] = mx; + myC[k] = my; + leaveC[k] = leave; + }; + dst.fill(0, lo, hi); + const stats = this.bandStats; + for (let i = lo; i < hi; i++) { + // ---- arrivals from cells the old loop processed earlier ---- + // from the row above (old loop visited i-F first): + const up = i - F; + if (up >= 0 && !mask[i]) { + motion(up); + if (vyC[up] > 0.02) dst[i] += myC[up]; // its down-flow landed here + } + // from the left: + if (i % F !== 0 && !mask[i]) { + motion(i - 1); + if (vxC[i - 1] > 0.02) dst[i] += mxC[i - 1]; // its right-flow landed here + } + // ---- the cell's own terms, exactly as the old body issued them + const v = src[i]; + if (v > 1e-6) { + motion(i); + const leave = leaveC[i]; + const vx = vxC[i]; + const vy = vyC[i]; + if (leave > 0 && Math.abs(vx) + Math.abs(vy) > 1e-6) { + const mx = mxC[i]; + const my = myC[i]; + dst[i] += v - leave; + // Rock is a wall: mass aimed at a stone cell stays put + // (and a slow drift has nowhere to go: it stays too — + // the edge-clamped neighbour arrays make tx/ty === i). + const tx = vx > 0.02 ? nRight[i] : vx < -0.02 ? nLeft[i] : i; + const ty = vy > 0.02 ? nDown[i] : vy < -0.02 ? nUp[i] : i; + if (mask[tx] || tx === i) dst[i] += mx; + if (mask[ty] || ty === i) dst[i] += my; - // Diffusion, gathered: my own flux half stays here; each open - // neighbour sheds its flux equally on its open neighbours. - if (v > 1e-6) dst[i] += flux(i) * 0.5; - const shed = (j) => { - const d = flux(j); - if (d === 0) return 0; - let open = 0; - if (!mask[nUp[j]]) open++; - if (!mask[nDown[j]]) open++; - if (!mask[nLeft[j]]) open++; - if (!mask[nRight[j]]) open++; - return open > 0 ? d * 0.5 / open : 0; - }; + if (track) { + const ax = ((i % F) * 32 / F) | 0; + const ay = ((i / F) * 32 / F) | 0; + this.activity[Math.min(31, ay) * 32 + Math.min(31, ax)] += leave * 6; + stats[((i / F) | 0) * 4 + 1] += leave; + } + } else { + // too slow or too spent to move: everything stays + dst[i] += v; + } + if (v > 1e-6) dst[i] += flux(i) * 0.5; + } + // The neighbour's diffusion sheds its equal share this way — + // independent of whether this cell holds anything. if (!mask[nUp[i]] && src[nUp[i]] > 1e-6) dst[i] -= shed(nUp[i]); if (!mask[nDown[i]] && src[nDown[i]] > 1e-6) dst[i] -= shed(nDown[i]); if (!mask[nLeft[i]] && src[nLeft[i]] > 1e-6) dst[i] -= shed(nLeft[i]); if (!mask[nRight[i]] && src[nRight[i]] > 1e-6) dst[i] -= shed(nRight[i]); + // ---- arrivals from cells the old loop processed later ---- + // from the right: + if (i % F !== F - 1 && !mask[i]) { + motion(i + 1); + if (vxC[i + 1] < -0.02) dst[i] += mxC[i + 1]; // its left-flow landed here + } + // from the row below: + const down = i + F; + if (down < n && !mask[i]) { + motion(down); + if (vyC[down] < -0.02) dst[i] += myC[down]; // its up-flow landed here + } } } diff --git a/metabolism/test/world.test.js b/metabolism/test/world.test.js index ef79dd8..ebf24fe 100644 --- a/metabolism/test/world.test.js +++ b/metabolism/test/world.test.js @@ -97,11 +97,11 @@ test('genesis pre-simulates generations so frame 0 is already an ecosystem', () }); test('the region pool is exact: parallel == serial, bit for bit', () => { - const grow = (workers) => { + const grow = (workers, { frames = 150, seed = 12345 } = {}) => { 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 run = new Run(song, { fps: 30, seed, width: 240, height: 135, field: 64, genesis: 0, workers }); + for (let f = 0; f < frames; f++) { run.world.step(); run.camera.update(f); } + const history = Array.from(run.world.history.alive.slice(0, frames)); const trail = Array.from(run.world.trail[0].slice(0, 4000)); run.world.dispose?.(); return { history, trail }; @@ -113,6 +113,17 @@ test('the region pool is exact: parallel == serial, bit for bit', () => { 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'); + + // LONG: across a section boundary (drone's drop lands at frame 1545) and + // with a seed distinct from song.seed (the workers must be told the same + // seed — they seed their own jitter-noise fields). A 150-frame run inside + // one section with the default seed passes even with both bugs present. + const longSerial = grow(0, { frames: 1600, seed: 999 }); + const longTwo = grow(2, { frames: 1600, seed: 999 }); + const longEq = (a, b) => a.history.every((v, i) => v === b.history[i]) && + a.trail.every((v, i) => v === b.trail[i]); + assert.ok(longEq(longSerial, longTwo), + '2 regions must match the serial world exactly across the section boundary'); }); test('genesis is deterministic and song-scaled', () => {