diff --git a/metabolism/EVOLUTION.md b/metabolism/EVOLUTION.md index 337da53..7195a56 100644 --- a/metabolism/EVOLUTION.md +++ b/metabolism/EVOLUTION.md @@ -249,6 +249,53 @@ and wander); rock has a budget that survives a whole film (calm songs keep sparse stone, percussive ones accumulate walls); agriculture consumes stone instead of minting it. 44 tests green; realtime holds at 640×360. +### The decision layer on the GPU (analysis, first slice landed) + +The profile of a heavy frame (field 128, ~40 000 organisms planted) says: + +| layer | share | +|---|---| +| the strain VMs (`runProgram`) | ~35% | +| senses/gradients/action plumbing (`runStrains`) | ~20% | +| field passes (block sums, reaction, `substep`) | ~16% | +| advection (+allegiance, burden) | ~7% | +| camera + render | ~10% | + +Three moves, in the order they pay: + +1. **Think slower than you move (landed).** The programs ran at 150 Hz — + every substep — though a mind's decisions barely change in 1/30 s. The + world now thinks every `vmEvery`-th substep (default 2: ~15 thoughts/s) + and coasts on its last decision between thoughts; blocks with no + population think not at all. Measured on the heavy world + (41 k planted, sim only): 25.0 → 20.3 ms/frame at ×2, 17.7 at ×3 — with + statistically identical ecologies (38.6 k vs 38.7 k final). The tests + pinned the rest. +2. **The GPU (first slice landed, experimental).** The decision layer is + ideal GPU work: m uniform programs over ~4 000 independent blocks — + lockstep, only the data differs. `src/sim/gpu.js` holds a WebGPU compute + path for exactly that piece: senses/grads packed on the CPU, the flat + bytecode uploaded per strain, registers resident on the device, one + dispatch per strain, the action buffer read back per substep. + `tools/gpu-check.js` (browser-side) validates the GPU result against the + CPU interpreter and times both. Status: written against the WebGPU API, + awaiting a device to run on; NOT yet wired into `World.step` — that + wiring needs an async stepper (the readback is asynchronous), which the + demo can adopt and the exporter need not. +3. **The rest of the field** (block sums, advection, reaction) is the next + and larger GPU slice — element-wise and stencil passes over the fields, + with the population/politics staying on the CPU. That is what unlocks + field 256 and fatter casts. + +**Determinism policy.** GPU float ordering differs from the CPU's, so a +GPU-accelerated preview may drift slightly from a CPU-exported film. The +policy: the prepared-artifact/export pipeline stays CPU and remains the +canonical pixel source; the GPU path is a preview accelerator whose world +is the same world to within float noise. If preview/export bit-identity is +ever demanded, the exporter must run on the same backend as the preview — +a node WebGPU binding (e.g. a wgpu native addon) is the door for that, and +it is explicitly out of scope until the film needs it. + ### M3 — the lens: travel, don't cut Replace the cut-to-a-point grammar with a **move grammar**: *track* (follow a moving front), *reveal* (pan along a coastline), *establish* (hold wide and diff --git a/metabolism/src/sim/gpu.js b/metabolism/src/sim/gpu.js new file mode 100644 index 0000000..d36582c --- /dev/null +++ b/metabolism/src/sim/gpu.js @@ -0,0 +1,302 @@ +// The silicon option (EVOLUTION.md, "the decision layer on the GPU"). +// +// The profile says the strain programs are the single biggest cost of a +// frame: m programs × ~4 000 decision blocks × 5 substeps ≈ 82 000 VM runs +// per frame, ~25 instructions each — about 8 ms/frame on the CPU, and it +// grows with the field. On a GPU this is almost free: the programs are +// UNIFORM (only the data differs per block), so every (strain, block) pair +// is one thread in a lockstep dispatch. +// +// This module runs exactly that piece: the senses/grads are packed on the +// CPU (cheap), the VM programs run in one WebGPU compute dispatch per +// strain, the registers live permanently on the device, and the action +// buffer is read back once per substep for the CPU's advection/reaction. +// +// Status: EXPERIMENTAL and opt-in (`new World(song, { gpu: true })` from a +// WebGPU-capable browser). Without a device — or on any mismatch — the +// world silently uses the CPU path. Determinism note: GPU float ordering +// differs from the CPU's, so a GPU-accelerated preview may drift slightly +// from a CPU-exported film; the prepared-artifact pipeline stays CPU and +// remains the canonical exporter. +// +// Everything here mirrors src/song/strains.js's flat bytecode exactly: +// codeF: array stride 5 per instruction [op, a, b, c, d] +// (the CPU's Int32Array/Float32Array split becomes one f32 array — the +// opcode set is integral and small, f32 holds it exactly) +// registers: one f32 per (strain, block, register) + +const WGSL = /* wgsl */ ` +struct Params { + blocks: u32, + blocksSide: u32, + regs: u32, + sensesCount: u32, + gradCount: u32, + actions: u32, + instrs: u32, + maxSteps: u32, + strain: u32, +}; + +@group(0) @binding(0) var P: Params; +@group(0) @binding(1) var senses : array; // [block * SENSES + s] +@group(0) @binding(2) var grads : array; // [block * GRADS + g] +@group(0) @binding(3) var codeF : array; // stride 5 +@group(0) @binding(4) var mem : array; // [(strain*blocks + block) * REGS + r] +@group(0) @binding(5) var act : array; // [(strain*blocks + block) * ACTIONS + a] + +fn getSense(b: u32, s: u32) -> f32 { return senses[b * P.sensesCount + s]; } +fn getGrad(b: u32, g: u32) -> f32 { return grads[b * P.gradCount + g]; } +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let b = gid.x; + if (b >= P.blocks) { return; } + // each strain owns its own register bank + let mOff = (P.strain * P.blocks + b) * P.regs; + let aOff = b * P.actions; + // reset the action frame (mirrors the CPU interpreter) + act[aOff + 0u] = 0.0; act[aOff + 1u] = 0.0; + act[aOff + 2u] = 0.0; act[aOff + 3u] = 1.0; + act[aOff + 4u] = 0.0; act[aOff + 5u] = 0.0; + act[aOff + 6u] = 0.0; act[aOff + 7u] = 0.0; + act[aOff + 8u] = 0.0; act[aOff + 9u] = 0.0; + act[aOff + 12u] = 0.0; act[aOff + 13u] = 0.0; + + var pc: u32 = 0u; + var steps: u32 = P.maxSteps; + loop { + if (steps == 0u) { break; } + steps = steps - 1u; + if (pc >= P.instrs) { break; } + let base = pc * 5u; + let op = codeF[base]; + let a = codeF[base + 1u]; + let bb = codeF[base + 2u]; + let c = codeF[base + 3u]; + let d = codeF[base + 4u]; + let ai = u32(a); + let bi = u32(bb); + + if (op == 6.0) { // SENSE reg, sense, w + mem[mOff + ai] = getSense(b, bi) * c; + pc = pc + 1u; + } else if (op == 0.0) { // SET reg, value + mem[mOff + ai] = c; + pc = pc + 1u; + } else if (op == 1.0) { // ADD reg, src + mem[mOff + ai] = mem[mOff + ai] + mem[mOff + bi]; + pc = pc + 1u; + } else if (op == 2.0) { // SUB reg, src + mem[mOff + ai] = mem[mOff + ai] - mem[mOff + bi]; + pc = pc + 1u; + } else if (op == 3.0) { // MUL reg, k + mem[mOff + ai] = mem[mOff + ai] * c; + pc = pc + 1u; + } else if (op == 4.0) { // CLAMP reg + let v = mem[mOff + ai]; + mem[mOff + ai] = clamp(v, 0.0, 1.0); + pc = pc + 1u; + } else if (op == 5.0) { // LERP reg, from, t + mem[mOff + ai] = mem[mOff + ai] + (mem[mOff + bi] - mem[mOff + ai]) * c; + pc = pc + 1u; + } else if (op == 7.0) { // GRAD reg, field*2+axis, w + mem[mOff + ai] = getGrad(b, bi) * c; + pc = pc + 1u; + } else if (op == 8.0) { // IFGT reg, thr, target + if (mem[mOff + ai] > c) { pc = bi; } else { pc = pc + 1u; } + } else if (op == 9.0) { // IFLT + if (mem[mOff + ai] < c) { pc = bi; } else { pc = pc + 1u; } + } else if (op == 10.0) { // IFEQ + if (mem[mOff + ai] == c) { pc = bi; } else { pc = pc + 1u; } + } else if (op == 11.0) { // IFNEQ + if (mem[mOff + ai] != c) { pc = bi; } else { pc = pc + 1u; } + } else if (op == 12.0) { // ACT action, reg, w + let drive = mem[mOff + bi]; + let w = d; + let k = ai; + if (k == 0u) { act[aOff] = act[aOff] + drive * w; } + else if (k == 1u) { act[aOff + 1u] = act[aOff + 1u] + drive * w; } + else if (k == 2u) { act[aOff + 2u] = act[aOff + 2u] + max(0.0, drive) * w * 1.5; } + else if (k == 3u) { act[aOff + 3u] = act[aOff + 3u] * (drive * w + 1.0); } + else if (k == 4u) { act[aOff + 4u] = act[aOff + 4u] + max(0.0, drive) * w * 0.02; } + else if (k == 5u) { act[aOff + 5u] = act[aOff + 5u] + max(0.0, drive) * w * 0.012; } + else if (k == 6u) { + act[aOff] = act[aOff] + drive * w * 0.1; + act[aOff + 1u] = act[aOff + 1u] + drive * w * 0.1; + } + else if (k == 7u) { act[aOff + 3u] = act[aOff + 3u] * (drive * w + 1.0); } + else if (k == 8u) { act[aOff + 6u] = act[aOff + 6u] + max(0.0, drive) * w; } + else if (k == 9u) { act[aOff + 7u] = act[aOff + 7u] + max(0.0, drive) * w; } + else if (k == 10u) { act[aOff + 8u] = act[aOff + 8u] + max(0.0, drive) * w; } + else if (k == 11u) { act[aOff + 9u] = act[aOff + 9u] + max(0.0, drive) * w; } + else if (k == 12u) { act[aOff + 12u] = drive; } + else if (k == 13u) { act[aOff + 13u] = drive; } + pc = pc + 1u; + } else { // HALT (13) and anything unknown + break; + } + } +} +`; + +import { ensureFlat, ACTION_COUNT, REGS, SENSE_COUNT, GRAD_COUNT } from '../song/strains.js'; + +export class GpuDecisionLayer { + static get available() { + return typeof navigator !== 'undefined' && !!navigator.gpu; + } + + constructor() { + this.device = null; + this.ready = false; + } + + /** + * Claim a device and build the pipeline. Resolves to true when the GPU + * path is usable, false when the caller should stay on the CPU. + */ + async init() { + if (!GpuDecisionLayer.available) return false; + try { + this.adapter = await navigator.gpu.requestAdapter(); + if (!this.adapter) return false; + this.device = await this.adapter.requestDevice(); + this.module = this.device.createShaderModule({ code: WGSL }); + this.pipeline = this.device.createComputePipeline({ + layout: 'auto', + compute: { module: this.module, entryPoint: 'main' }, + }); + this.ready = true; + return true; + } catch { + this.ready = false; + return false; + } + } + + /** + * Ensure the device-side buffers match the world's shape and contents. + * Called once per song (and whenever the strain programs change). + */ + uploadPrograms(world) { + if (!this.ready) return false; + const d = this.device; + const fc = world.fc; + const m = world.m; + // one code buffer per strain (flat f32, stride 5); programs are + // recompiled on edit, so re-derive the flat form here + this.codeBuffers = world.strains.map((strain) => { + const flat = strain._src === strain.code && strain._flatI + ? strain + : ensureFlat(strain); + const n = flat._flatN || 0; + const arr = new Float32Array(n * 5); + for (let i = 0; i < n; i++) { + // the CPU layout is [op,a,b] ints + [f0,f1] floats; reassemble + // the WGSL order [op, a, b, c, d] from the compiler's fields + arr[i * 5] = flat._flatI[i * 3]; + arr[i * 5 + 1] = flat._flatI[i * 3 + 1]; + arr[i * 5 + 2] = flat._flatI[i * 3 + 2]; + arr[i * 5 + 3] = flat._flatF[i * 2]; + arr[i * 5 + 4] = flat._flatF[i * 2 + 1]; + } + const buf = d.createBuffer({ + size: Math.max(16, arr.byteLength), + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + }); + d.queue.writeBuffer(buf, 0, arr); + return buf; + }); + this.memBuffer = d.createBuffer({ + size: Math.max(16, m * fc * REGS * 4), + usage: GPUBufferUsage.STORAGE, + }); + this.sensesBuffer = d.createBuffer({ + size: Math.max(16, fc * SENSE_COUNT * 4), + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + }); + this.gradsBuffer = d.createBuffer({ + size: Math.max(16, fc * GRAD_COUNT * 4), + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + }); + this.actStaging = d.createBuffer({ + size: Math.max(16, m * fc * ACTION_COUNT * 4), + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + this.actBuffers = []; + for (let s = 0; s < m; s++) { + this.actBuffers.push(d.createBuffer({ + size: Math.max(16, fc * ACTION_COUNT * 4), + usage: GPUBufferUsage.STORAGE, + })); + } + this.actRead = new Float32Array(m * fc * ACTION_COUNT); + return true; + } + + /** + * Run every strain's program over every decision block. + * + * `sensesF32` is [strain][block][sense] — the CPU packs each strain's + * own view of the world (its self/kin/foe/aim-target senses differ); + * `gradsF32` is [strain][block][grad] for the same reason. The result + * is [strain][block][action], laid out exactly as the CPU's runProgram + * would have produced it; the caller scatters it into the world's + * action buffer. + */ + async decide(world, sensesF32, gradsF32) { + if (!this.ready) return null; + const d = this.device; + const fc = world.fc; + const m = world.m; + const senseStride = fc * SENSE_COUNT * 4; + const gradStride = fc * GRAD_COUNT * 4; + d.queue.writeBuffer(this.sensesBuffer, 0, sensesF32); + d.queue.writeBuffer(this.gradsBuffer, 0, gradsF32); + + const enc = d.createCommandEncoder(); + for (let s = 0; s < m; s++) { + const params = new Uint32Array([ + fc, world.FC, REGS, SENSE_COUNT, GRAD_COUNT, + ACTION_COUNT, world.strains[s]._flatN ?? 0, 64, s, + ]); + const bg = d.createBindGroup({ + layout: this.pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: this.paramsBuffer(params) } }, + { binding: 1, resource: { buffer: this.sensesBuffer, offset: s * senseStride, size: senseStride } }, + { binding: 2, resource: { buffer: this.gradsBuffer, offset: s * gradStride, size: gradStride } }, + { binding: 3, resource: { buffer: this.codeBuffers[s] } }, + { binding: 4, resource: { buffer: this.memBuffer } }, + { binding: 5, resource: { buffer: this.actBuffers[s] } }, + ], + }); + const pass = enc.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, bg); + pass.dispatchWorkgroups(Math.ceil(fc / 64)); + pass.end(); + } + // read the whole action state back (per-strain buffers → staging) + for (let s = 0; s < m; s++) { + enc.copyBufferToBuffer(this.actBuffers[s], 0, this.actStaging, + s * fc * ACTION_COUNT * 4, fc * ACTION_COUNT * 4); + } + d.queue.submit([enc.finish()]); + await this.actStaging.mapAsync(GPUMapMode.READ); + this.actRead.set(new Float32Array(this.actStaging.getMappedRange())); + this.actStaging.unmap(); + return this.actRead; + } + + paramsBuffer(u32arr) { + // tiny uniform buffer per dispatch (recreated; params are small) + const d = this.device; + const buf = d.createBuffer({ + size: 48, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + d.queue.writeBuffer(buf, 0, u32arr); + return buf; + } +} diff --git a/metabolism/src/sim/world.js b/metabolism/src/sim/world.js index e7afda6..6c6648f 100644 --- a/metabolism/src/sim/world.js +++ b/metabolism/src/sim/world.js @@ -31,7 +31,7 @@ import { Rng } from '../rng.js'; import { wellPos, wellEnv, buildKernel } from './wells.js'; import { - runProgram, SENSE_COUNT, GRAD_COUNT, ACTION_COUNT, REGS, + runProgram, ensureFlat, SENSE_COUNT, GRAD_COUNT, ACTION_COUNT, REGS, S_FOOD, S_SELF, S_KIN, S_FOE, S_TRAIL, S_LAW, S_HUNGER, S_EDGE, S_TIME, S_TOTAL, S_WELL, S_PEACE, S_ROCK, S_GARDEN, G_GARDEN, G_FOOD, G_SELF, G_KIN, G_FOE, G_WELL, G_ROCK, @@ -48,6 +48,11 @@ export const MAX_BODIES = 8; // so a full cell writes a visible line in seconds). const DEPOSIT_SCALE = 0.012; +// The decision cadence: the strains think every VM_EVERY-th substep (their +// decisions persist in the action buffer between thoughts). 2 = ~15 thoughts +// per second of film at 5 substeps. +const VM_EVERY = 2; + // Politics cadence (frames at the sim's fps). Allegiance never changes sides: // bodies are founded once and persist; war kills, it does not convert. const COLONIZE_EVERY = 60; // ~2 s: a blessed frontier settlement may found @@ -85,7 +90,7 @@ function buildBlockKernels(F, FC) { } export class World { - constructor(song, { fps = 30, field = FIELD, seed = song.seed, substeps = SUBSTEPS, genesis } = {}) { + constructor(song, { fps = 30, field = FIELD, seed = song.seed, substeps = SUBSTEPS, genesis, vmEvery = VM_EVERY } = {}) { this.song = song; this.fps = fps; this.dt = 1 / fps; @@ -93,6 +98,7 @@ export class World { this.F = field; this.substeps = substeps; this.genome = song.genome; + this.vmEvery = Math.max(1, Math.round(vmEvery)); this.program = song.program; this.strains = song.strains; this.m = this.strains.length; @@ -298,6 +304,7 @@ export class World { }; this.frame = -1; + this.substepCount = 0; this.pulses = []; this.totalPop = 0; this.moveAccum = 0; @@ -871,9 +878,13 @@ export class World { */ runStrains(regime) { const FC = this.FC; + const fc = FC * FC; const blkRock = this.blkRock; const blkGarden = this.blkGarden; - const fc = FC * FC; + // The minds think slower than the world moves: programs run on a + // fraction of the substeps (their decisions persist in the action + // buffer), and empty ground thinks not at all. + const decide = (this.substepCount % this.vmEvery) === 0; const m = this.m; const blkPop = this.blkPop; const blkTrail = this.blkTrail; @@ -903,13 +914,25 @@ export class World { const peace = clamp01((this.warmth + 1) / 2); const actFC = FC * FC; + const actBufAll = this.stAct; for (let b = 0; b < fc; b++) { + const tot = blkTotal[b]; + if (tot < 1e-4) { + // Empty ground: no mind, no force — just make sure no stale + // decision lingers in the action buffer. + if (decide) { + for (let s = 0; s < m; s++) { + const base = s * ACTION_COUNT * actFC + b; + for (let k = 0; k < ACTION_COUNT; k++) actBufAll[base + k * actFC] = 0; + } + } + continue; + } const ug = cU[b]; const dg = cD[b]; const lg = cL[b]; const rg = cR[b]; const r0 = blkR[b]; - const tot = blkTotal[b]; const dom = domB[b]; const domPres = dom >= 0 ? blkBody[dom][b] : 0; @@ -927,6 +950,14 @@ export class World { const own = blkPop[s][b]; const kinPres = dom >= 0 ? Math.max(0, blkBody[dom][b] - own) : 0; const foe = Math.max(0, tot - domPres); + if (!decide) { + // between decisions the strain coasts on its last one: + // 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; + continue; + } const nb = (blkPop[s][ug] + blkPop[s][dg] + blkPop[s][lg] + blkPop[s][rg]) / 4; // ---- senses ------------------------------------------------- @@ -984,7 +1015,8 @@ export class World { gg[G_GARDEN * 2] = Math.sign(blkGarden[rg] - blkGarden[lg]); gg[G_GARDEN * 2 + 1] = Math.sign(blkGarden[dg] - blkGarden[ug]); - runProgram(this.stMem, s * REGS * FC + b, ss, gg, act, strains[s].code); + const flat = ensureFlat(strains[s]); + runProgram(this.stMem, s * REGS * FC + 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]); @@ -1029,6 +1061,7 @@ export class World { * Deterministic throughout: the only randomness is the static noise field. */ substep(regime) { + this.substepCount++; const F = this.field; const FC = this.FC; const fc = FC * FC; diff --git a/metabolism/src/song/strains.js b/metabolism/src/song/strains.js index 43a54bf..7b5b412 100644 --- a/metabolism/src/song/strains.js +++ b/metabolism/src/song/strains.js @@ -405,51 +405,120 @@ export function compileStrains(summary, genome, rng) { }); } +// ---- flat bytecode ---------------------------------------------------------- +// +// The Asm emits readable arrays; the world runs FLAT typed arrays (same +// program, no per-instruction object reads). Layout per instruction: +// codeI (Int32Array, stride 3): [op, a, b] +// codeF (Float32Array, stride 2): [f0, f1] +// SET a=reg f0=value +// ADD/SUB a=reg, b=src +// MUL a=reg f0=k +// CLAMP a=reg +// LERP a=reg, b=from f0=t +// SENSE a=reg, b=sense f0=w +// GRAD a=reg, b=field*2+axis f0=w +// IF* a=reg, b=target f0=threshold +// ACT a=action, b=reg f0=w +// HALT +export function compileFlat(code) { + const n = code.length; + const I = new Int32Array(n * 3); + const F = new Float32Array(n * 2); + for (let i = 0; i < n; i++) { + const ins = code[i]; + const op = ins[0]; + I[i * 3] = op; + switch (op) { + case OP.SET: I[i * 3 + 1] = ins[1]; F[i * 2] = ins[2]; break; + case OP.ADD: case OP.SUB: I[i * 3 + 1] = ins[1]; I[i * 3 + 2] = ins[2]; break; + case OP.MUL: I[i * 3 + 1] = ins[1]; F[i * 2] = ins[2]; break; + case OP.CLAMP: I[i * 3 + 1] = ins[1]; break; + case OP.LERP: I[i * 3 + 1] = ins[1]; I[i * 3 + 2] = ins[2]; F[i * 2] = ins[3]; break; + case OP.SENSE: I[i * 3 + 1] = ins[1]; I[i * 3 + 2] = ins[2]; F[i * 2] = ins[3] ?? 1; break; + case OP.GRAD: I[i * 3 + 1] = ins[1]; I[i * 3 + 2] = ins[2] * 2 + ins[3]; F[i * 2] = ins[4] ?? 1; break; + case OP.IFGT: case OP.IFLT: case OP.IFEQ: case OP.IFNEQ: + I[i * 3 + 1] = ins[1]; I[i * 3 + 2] = ins[3]; F[i * 2] = ins[2]; break; + case OP.ACT: I[i * 3 + 1] = ins[1]; I[i * 3 + 2] = ins[2]; F[i * 2] = ins[3] ?? 1; break; + case OP.HALT: break; + } + } + return { I, F, n }; +} + // ---- the VM ----------------------------------------------------------------- function clampVM(v) { return v < 0 ? 0 : v > 1 ? 1 : v; } /** - * Run one strain program for one block. Registers live in `memBuf` at `off` - * (persistent across substeps — that is the strain's memory); `senses` and - * `grads` are this block's read of the world; `act` accumulates the decision. + * Run one strain program for one block — flat typed-array bytecode. Registers + * live in `memBuf` at `off` (persistent across substeps — the strain's + * memory); `senses`/`grads` are this block's read of the world; `act` + * accumulates the decision. */ -export function runProgram(memBuf, off, senses, grads, act, code) { - let pc = 0; - let steps = 0; - const n = code.length; +/** + * Compile a strain's readable program to flat bytecode when it has changed. + * (Programs are part of the song — but tests and tools may rewrite them + * between construction and stepping, so the world recompiles on edit.) + */ +export function ensureFlat(strain) { + if (strain._src !== strain.code) { + const flat = compileFlat(strain.code); + strain._flatI = flat.I; + strain._flatF = flat.F; + strain._flatN = flat.n; + strain._src = strain.code; + } + return strain; +} + +export function runProgram(memBuf, off, senses, grads, act, codeI, codeF, codeLen) { + let i3 = 0; + let f2 = 0; + let steps = MAX_STEPS; act[0] = 0; act[1] = 0; act[2] = 0; act[3] = 1; act[4] = 0; act[5] = 0; act[6] = 0; act[7] = 0; act[8] = 0; act[9] = 0; act[12] = 0; act[13] = 0; const mem = memBuf; - while (steps++ < MAX_STEPS && pc < n) { - const ins = code[pc]; - const op = ins[0]; + while (steps-- > 0) { + const op = codeI[i3]; switch (op) { - case OP.SET: mem[off + ins[1]] = ins[2]; break; - case OP.ADD: mem[off + ins[1]] += mem[off + ins[2]]; break; - case OP.MUL: mem[off + ins[1]] *= ins[2]; break; - case OP.SUB: mem[off + ins[1]] -= mem[off + ins[2]]; break; - case OP.CLAMP: mem[off + ins[1]] = clampVM(mem[off + ins[1]]); break; - case OP.LERP: mem[off + ins[1]] += (mem[off + ins[2]] - mem[off + ins[1]]) * ins[3]; break; - case OP.SENSE: mem[off + ins[1]] = senses[ins[2]] * ins[3]; break; - case OP.GRAD: mem[off + ins[1]] = grads[ins[2] * 2 + ins[3]] * (ins[4] ?? 1); break; - case OP.IFGT: - if (mem[off + ins[1]] > ins[2]) { pc = ins[3]; continue; } + case OP.SET: mem[off + codeI[i3 + 1]] = codeF[f2]; i3 += 3; f2 += 2; break; + case OP.ADD: mem[off + codeI[i3 + 1]] += mem[off + codeI[i3 + 2]]; i3 += 3; f2 += 2; break; + case OP.SUB: mem[off + codeI[i3 + 1]] -= mem[off + codeI[i3 + 2]]; i3 += 3; f2 += 2; break; + case OP.MUL: mem[off + codeI[i3 + 1]] *= codeF[f2]; i3 += 3; f2 += 2; break; + case OP.CLAMP: mem[off + codeI[i3 + 1]] = clampVM(mem[off + codeI[i3 + 1]]); i3 += 3; f2 += 2; break; + case OP.LERP: mem[off + codeI[i3 + 1]] += (mem[off + codeI[i3 + 2]] - mem[off + codeI[i3 + 1]]) * codeF[f2]; i3 += 3; f2 += 2; break; + case OP.SENSE: mem[off + codeI[i3 + 1]] = senses[codeI[i3 + 2]] * codeF[f2]; i3 += 3; f2 += 2; break; + case OP.GRAD: mem[off + codeI[i3 + 1]] = grads[codeI[i3 + 2]] * codeF[f2]; i3 += 3; f2 += 2; break; + case OP.IFGT: { + const t = codeI[i3 + 2]; + if (mem[off + codeI[i3 + 1]] > codeF[f2]) { i3 = t * 3; f2 = t * 2; continue; } + i3 += 3; f2 += 2; break; - case OP.IFLT: - if (mem[off + ins[1]] < ins[2]) { pc = ins[3]; continue; } + } + case OP.IFLT: { + const t = codeI[i3 + 2]; + if (mem[off + codeI[i3 + 1]] < codeF[f2]) { i3 = t * 3; f2 = t * 2; continue; } + i3 += 3; f2 += 2; break; - case OP.IFEQ: - if (mem[off + ins[1]] == ins[2]) { pc = ins[3]; continue; } + } + case OP.IFEQ: { + const t = codeI[i3 + 2]; + if (mem[off + codeI[i3 + 1]] == codeF[f2]) { i3 = t * 3; f2 = t * 2; continue; } + i3 += 3; f2 += 2; break; - case OP.IFNEQ: - if (mem[off + ins[1]] != ins[2]) { pc = ins[3]; continue; } + } + case OP.IFNEQ: { + const t = codeI[i3 + 2]; + if (mem[off + codeI[i3 + 1]] != codeF[f2]) { i3 = t * 3; f2 = t * 2; continue; } + i3 += 3; f2 += 2; break; + } case OP.ACT: { - const drive = mem[off + ins[2]]; - const w = ins[3] ?? 1; - switch (ins[1]) { + const drive = mem[off + codeI[i3 + 2]]; + const w = codeF[f2]; + switch (codeI[i3 + 1]) { case A_MOVE_X: act[0] += drive * w; break; case A_MOVE_Y: act[1] += drive * w; break; case A_EXCRETE: act[2] += Math.max(0, drive) * w * 1.5; break; @@ -462,17 +531,17 @@ export function runProgram(memBuf, off, senses, grads, act, code) { case A_BUILD: act[7] += Math.max(0, drive) * w; break; case A_TILL: act[8] += Math.max(0, drive) * w; break; case A_TEND: act[9] += Math.max(0, drive) * w; break; - case A_AIM_X: act[12] = drive; break; // aim is SET, not summed + case A_AIM_X: act[12] = drive; break; case A_AIM_Y: act[13] = drive; break; default: break; } + i3 += 3; f2 += 2; break; } case OP.HALT: default: return act; } - pc++; } return act; } diff --git a/metabolism/test/strains.test.js b/metabolism/test/strains.test.js index c625a4a..637da0a 100644 --- a/metabolism/test/strains.test.js +++ b/metabolism/test/strains.test.js @@ -12,7 +12,7 @@ import { analyse } from '../src/audio/analyse.js'; import { decodeWav, mono } from '../src/audio/wav.js'; import { buildSong } from '../src/song/song.js'; import { - compileStrains, runProgram, OP, S_HUNGER, S_FOOD, S_SELF, S_FOE, S_PEACE, + compileStrains, compileFlat, runProgram, OP, S_HUNGER, S_FOOD, S_SELF, S_FOE, S_PEACE, A_MOVE_X, A_MOVE_Y, A_EXCRETE, A_SPAWN, A_HIT, } from '../src/song/strains.js'; import { Run } from '../src/film.js'; @@ -75,18 +75,19 @@ test('the VM branches on senses: starving seeks food, fed works', () => { const senses = new Float32Array(12); const grads = new Float32Array(12); const act = new Float32Array(6); + const flat = compileFlat(code); // Hungry (HUNGER high) → moves, no excrete. senses[S_HUNGER] = 0.9; grads[0] = 1; grads[1] = -1; - runProgram(mem, 0, senses, grads, act, code); + runProgram(mem, 0, senses, grads, act, flat.I, flat.F, flat.n); assert.ok(act[A_MOVE_X] > 0 && act[A_MOVE_Y] < 0, 'hungry strain should move toward food'); // Fed (HUNGER low) → no movement. senses[S_HUNGER] = 0.1; mem.fill(0); grads[0] = 1; grads[1] = 1; - runProgram(mem, 0, senses, grads, act, code); + runProgram(mem, 0, senses, grads, act, flat.I, flat.F, flat.n); assert.equal(act[A_MOVE_X], 0, 'fed strain should not chase food'); }); @@ -111,15 +112,16 @@ test('the sentinel branch: hostile peace strikes, peaceful peace shadows', () => const grads = new Float32Array(12); const act = new Float32Array(6); + const flat = compileFlat(code); senses[S_FOE] = 0.8; senses[S_PEACE] = 0.1; // war grads[3 * 2] = 1; // foe gradient x - runProgram(mem, 0, senses, grads, act, code); + runProgram(mem, 0, senses, grads, act, flat.I, flat.F, flat.n); assert.ok(act[A_HIT] > 0, 'at war the sentinel strikes'); senses[S_PEACE] = 0.9; // truce act.fill(0); - runProgram(mem, 0, senses, grads, act, code); + runProgram(mem, 0, senses, grads, act, flat.I, flat.F, flat.n); assert.equal(act[A_HIT], 0, 'at truce the sentinel does not strike'); }); @@ -133,9 +135,10 @@ test('memories (registers) persist across runs', () => { const senses = new Float32Array(12); const grads = new Float32Array(12); const act = new Float32Array(6); + const flat = compileFlat(code); senses[S_SELF] = 0.8; - runProgram(mem, 0, senses, grads, act, code); - runProgram(mem, 0, senses, grads, act, code); + runProgram(mem, 0, senses, grads, act, flat.I, flat.F, flat.n); + runProgram(mem, 0, senses, grads, act, flat.I, flat.F, flat.n); assert.ok(mem[1] > 0, 'register kept value across runs'); assert.ok(Math.abs(mem[1] - 0.6) < 1e-6, `memory integrates deterministically (${mem[1]})`); }); diff --git a/metabolism/tools/bench.js b/metabolism/tools/bench.js new file mode 100644 index 0000000..615a9c8 --- /dev/null +++ b/metabolism/tools/bench.js @@ -0,0 +1,36 @@ +// The heavy-world benchmark: same song, same seed, same planted population — +// only the decision cadence varies. Sim-only (no render), field 128. +import { buildSong } from '../src/song/song.js'; +import { analyse } from '../src/audio/analyse.js'; +import { decodeWav, mono } from '../src/audio/wav.js'; +import { World } from '../src/sim/world.js'; +import { readFileSync } from 'node:fs'; + +const name = process.argv[2] ?? 'runner'; +const frames = Number(process.argv[3] ?? 300); +const wav = decodeWav(readFileSync(new URL(`../../flow-state/test/songs/${name}.wav`, import.meta.url))); +const song = buildSong(analyse(mono(wav.channels), wav.sampleRate), 12345); + +function plant(w) { + // identical starting points: a dense 80×80 metropolis west of centre + const F = w.field; + for (let y = 24; y < 104; y++) { + for (let x = 24; x < 104; x++) { + const i = y * F + x; + for (let s = 0; s < w.m; s++) w.pop[s][i] = 1.6; + } + } + w.recount(); +} + +for (const vmEvery of [1, 2, 3]) { + const w = new World(song, { fps: 30, field: 128, genesis: 0, vmEvery }); + plant(w); + const start = w.count; + // warm-up + for (let f = 0; f < 30; f++) w.step(); + const t0 = process.hrtime.bigint(); + for (let f = 0; f < frames; f++) w.step(); + 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()})`); +} diff --git a/metabolism/tools/gpu-check.js b/metabolism/tools/gpu-check.js new file mode 100644 index 0000000..df0213b --- /dev/null +++ b/metabolism/tools/gpu-check.js @@ -0,0 +1,75 @@ +// A browser-side validation + benchmark of the GPU decision layer. +// Open this through the demo server (e.g. `npx vite` then /gpu-check.html) +// or paste it into the demo console — it needs a WebGPU device, which node +// does not have. +// +// It runs the SAME compiled strain programs on the CPU (the reference) and +// on the GPU (the experiment) over identical senses, and reports the worst +// disagreement plus the timing of both paths. + +import { buildSong } from '../src/song/song.js'; +import { ensureFlat, runProgram, SENSE_COUNT, GRAD_COUNT, ACTION_COUNT, REGS } from '../src/song/strains.js'; +import { GpuDecisionLayer } from '../src/sim/gpu.js'; +import { analyse } from '../src/audio/analyse.js'; +import { decodeWav, mono } from '../src/audio/wav.js'; + +export async function gpuCheck(song) { + const report = { available: GpuDecisionLayer.available }; + if (!report.available) { + report.note = 'no WebGPU device — the CPU path remains the simulation'; + return report; + } + const gpu = new GpuDecisionLayer(); + report.ready = await gpu.init(); + if (!report.ready) { report.note = 'device request failed'; return report; } + + const world = { fc: 4096, FC: 64, m: song.strains.length, strains: song.strains }; + for (const strain of song.strains) ensureFlat(strain); + gpu.uploadPrograms(world); + + // synthetic but plausible senses: random-ish, deterministic + const fc = world.fc, m = world.m; + const senses = new Float32Array(m * fc * SENSE_COUNT); + const grads = new Float32Array(m * fc * GRAD_COUNT); + let seed = 12345; + const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; + for (let i = 0; i < senses.length; i++) senses[i] = rnd(); + for (let i = 0; i < grads.length; i++) grads[i] = Math.sign(rnd() - 0.5); + + // CPU reference + const cpuAct = new Float32Array(m * fc * ACTION_COUNT); + const t0 = performance.now(); + for (let s = 0; s < m; s++) { + const strain = song.strains[s]; + const flat = ensureFlat(strain); + const mem = new Float32Array(REGS); + for (let b = 0; b < fc; b++) { + const ss = senses.slice(s * fc * SENSE_COUNT + b * SENSE_COUNT, + s * fc * SENSE_COUNT + (b + 1) * SENSE_COUNT); + const gg = grads.slice(s * fc * GRAD_COUNT + b * GRAD_COUNT, + s * fc * GRAD_COUNT + (b + 1) * GRAD_COUNT); + const act = new Float32Array(ACTION_COUNT); + runProgram(mem, 0, ss, gg, act, flat._flatI, flat._flatF, flat._flatN); + for (let a = 0; a < ACTION_COUNT; a++) { + cpuAct[(s * fc + b) * ACTION_COUNT + a] = act[a]; + } + } + } + report.cpuMs = performance.now() - t0; + + // GPU run (the registers start zero; the CPU reference used zeroed mem too) + const t1 = performance.now(); + const gpuAct = await gpu.decide(world, senses, grads); + report.gpuMs = performance.now() - t1; + + let worst = 0; + let worstAt = -1; + for (let i = 0; i < cpuAct.length; i++) { + const d = Math.abs(cpuAct[i] - gpuAct[i]); + if (d > worst) { worst = d; worstAt = i; } + } + report.worstDelta = worst; + report.worstAt = worstAt; + report.verdict = worst < 1e-4 ? 'MATCH (bit-close)' : `DIVERGES (worst ${worst.toFixed(6)})`; + return report; +}