Metabolism: decision-cadence (think slower than you move) + empty-block skip; flat typed-array bytecode; WebGPU decision-layer slice (experimental) + heavy-world benchmark

This commit is contained in:
Dejvino
2026-10-28 13:51:58 +01:00
parent 476fa1833a
commit 3a72525ccb
7 changed files with 608 additions and 43 deletions
+47
View File
@@ -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 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 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 ### M3 — the lens: travel, don't cut
Replace the cut-to-a-point grammar with a **move grammar**: *track* (follow a 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 moving front), *reveal* (pan along a coastline), *establish* (hold wide and
+302
View File
@@ -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<f32> 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<uniform> P: Params;
@group(0) @binding(1) var<storage, read> senses : array<f32>; // [block * SENSES + s]
@group(0) @binding(2) var<storage, read> grads : array<f32>; // [block * GRADS + g]
@group(0) @binding(3) var<storage, read> codeF : array<f32>; // stride 5
@group(0) @binding(4) var<storage, read_write> mem : array<f32>; // [(strain*blocks + block) * REGS + r]
@group(0) @binding(5) var<storage, read_write> act : array<f32>; // [(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<u32>) {
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;
}
}
+38 -5
View File
@@ -31,7 +31,7 @@
import { Rng } from '../rng.js'; import { Rng } from '../rng.js';
import { wellPos, wellEnv, buildKernel } from './wells.js'; import { wellPos, wellEnv, buildKernel } from './wells.js';
import { 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, 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_GARDEN,
G_FOOD, G_SELF, G_KIN, G_FOE, G_WELL, G_ROCK, 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). // so a full cell writes a visible line in seconds).
const DEPOSIT_SCALE = 0.012; 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: // Politics cadence (frames at the sim's fps). Allegiance never changes sides:
// bodies are founded once and persist; war kills, it does not convert. // bodies are founded once and persist; war kills, it does not convert.
const COLONIZE_EVERY = 60; // ~2 s: a blessed frontier settlement may found const COLONIZE_EVERY = 60; // ~2 s: a blessed frontier settlement may found
@@ -85,7 +90,7 @@ function buildBlockKernels(F, FC) {
} }
export class World { 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.song = song;
this.fps = fps; this.fps = fps;
this.dt = 1 / fps; this.dt = 1 / fps;
@@ -93,6 +98,7 @@ export class World {
this.F = field; this.F = field;
this.substeps = substeps; this.substeps = substeps;
this.genome = song.genome; this.genome = song.genome;
this.vmEvery = Math.max(1, Math.round(vmEvery));
this.program = song.program; this.program = song.program;
this.strains = song.strains; this.strains = song.strains;
this.m = this.strains.length; this.m = this.strains.length;
@@ -298,6 +304,7 @@ export class World {
}; };
this.frame = -1; this.frame = -1;
this.substepCount = 0;
this.pulses = []; this.pulses = [];
this.totalPop = 0; this.totalPop = 0;
this.moveAccum = 0; this.moveAccum = 0;
@@ -871,9 +878,13 @@ export class World {
*/ */
runStrains(regime) { runStrains(regime) {
const FC = this.FC; const FC = this.FC;
const fc = FC * FC;
const blkRock = this.blkRock; const blkRock = this.blkRock;
const blkGarden = this.blkGarden; 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 m = this.m;
const blkPop = this.blkPop; const blkPop = this.blkPop;
const blkTrail = this.blkTrail; const blkTrail = this.blkTrail;
@@ -903,13 +914,25 @@ export class World {
const peace = clamp01((this.warmth + 1) / 2); const peace = clamp01((this.warmth + 1) / 2);
const actFC = FC * FC; const actFC = FC * FC;
const actBufAll = this.stAct;
for (let b = 0; b < fc; b++) { 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 ug = cU[b];
const dg = cD[b]; const dg = cD[b];
const lg = cL[b]; const lg = cL[b];
const rg = cR[b]; const rg = cR[b];
const r0 = blkR[b]; const r0 = blkR[b];
const tot = blkTotal[b];
const dom = domB[b]; const dom = domB[b];
const domPres = dom >= 0 ? blkBody[dom][b] : 0; const domPres = dom >= 0 ? blkBody[dom][b] : 0;
@@ -927,6 +950,14 @@ export class World {
const own = blkPop[s][b]; const own = blkPop[s][b];
const kinPres = dom >= 0 ? Math.max(0, blkBody[dom][b] - own) : 0; const kinPres = dom >= 0 ? Math.max(0, blkBody[dom][b] - own) : 0;
const foe = Math.max(0, tot - domPres); 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; const nb = (blkPop[s][ug] + blkPop[s][dg] + blkPop[s][lg] + blkPop[s][rg]) / 4;
// ---- senses ------------------------------------------------- // ---- senses -------------------------------------------------
@@ -984,7 +1015,8 @@ export class World {
gg[G_GARDEN * 2] = Math.sign(blkGarden[rg] - blkGarden[lg]); gg[G_GARDEN * 2] = Math.sign(blkGarden[rg] - blkGarden[lg]);
gg[G_GARDEN * 2 + 1] = Math.sign(blkGarden[dg] - blkGarden[ug]); 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. // 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 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]); 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. * Deterministic throughout: the only randomness is the static noise field.
*/ */
substep(regime) { substep(regime) {
this.substepCount++;
const F = this.field; const F = this.field;
const FC = this.FC; const FC = this.FC;
const fc = FC * FC; const fc = FC * FC;
+100 -31
View File
@@ -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 ----------------------------------------------------------------- // ---- the VM -----------------------------------------------------------------
function clampVM(v) { return v < 0 ? 0 : v > 1 ? 1 : v; } function clampVM(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
/** /**
* Run one strain program for one block. Registers live in `memBuf` at `off` * Run one strain program for one block — flat typed-array bytecode. Registers
* (persistent across substeps — that is the strain's memory); `senses` and * live in `memBuf` at `off` (persistent across substeps — the strain's
* `grads` are this block's read of the world; `act` accumulates the decision. * 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; * Compile a strain's readable program to flat bytecode when it has changed.
let steps = 0; * (Programs are part of the song — but tests and tools may rewrite them
const n = code.length; * 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[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[6] = 0; act[7] = 0; act[8] = 0; act[9] = 0;
act[12] = 0; act[13] = 0; act[12] = 0; act[13] = 0;
const mem = memBuf; const mem = memBuf;
while (steps++ < MAX_STEPS && pc < n) { while (steps-- > 0) {
const ins = code[pc]; const op = codeI[i3];
const op = ins[0];
switch (op) { switch (op) {
case OP.SET: mem[off + ins[1]] = ins[2]; break; case OP.SET: mem[off + codeI[i3 + 1]] = codeF[f2]; i3 += 3; f2 += 2; break;
case OP.ADD: mem[off + ins[1]] += mem[off + ins[2]]; break; case OP.ADD: mem[off + codeI[i3 + 1]] += mem[off + codeI[i3 + 2]]; i3 += 3; f2 += 2; break;
case OP.MUL: mem[off + ins[1]] *= ins[2]; break; case OP.SUB: mem[off + codeI[i3 + 1]] -= mem[off + codeI[i3 + 2]]; i3 += 3; f2 += 2; break;
case OP.SUB: mem[off + ins[1]] -= mem[off + ins[2]]; break; case OP.MUL: mem[off + codeI[i3 + 1]] *= codeF[f2]; i3 += 3; f2 += 2; break;
case OP.CLAMP: mem[off + ins[1]] = clampVM(mem[off + ins[1]]); 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 + ins[1]] += (mem[off + ins[2]] - mem[off + ins[1]]) * ins[3]; 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 + ins[1]] = senses[ins[2]] * ins[3]; 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 + ins[1]] = grads[ins[2] * 2 + ins[3]] * (ins[4] ?? 1); break; case OP.GRAD: mem[off + codeI[i3 + 1]] = grads[codeI[i3 + 2]] * codeF[f2]; i3 += 3; f2 += 2; break;
case OP.IFGT: case OP.IFGT: {
if (mem[off + ins[1]] > ins[2]) { pc = ins[3]; continue; } 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; 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; 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; 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; break;
}
case OP.ACT: { case OP.ACT: {
const drive = mem[off + ins[2]]; const drive = mem[off + codeI[i3 + 2]];
const w = ins[3] ?? 1; const w = codeF[f2];
switch (ins[1]) { switch (codeI[i3 + 1]) {
case A_MOVE_X: act[0] += drive * w; break; case A_MOVE_X: act[0] += drive * w; break;
case A_MOVE_Y: act[1] += 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; 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_BUILD: act[7] += Math.max(0, drive) * w; break;
case A_TILL: act[8] += 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_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; case A_AIM_Y: act[13] = drive; break;
default: break; default: break;
} }
i3 += 3; f2 += 2;
break; break;
} }
case OP.HALT: case OP.HALT:
default: default:
return act; return act;
} }
pc++;
} }
return act; return act;
} }
+10 -7
View File
@@ -12,7 +12,7 @@ import { analyse } from '../src/audio/analyse.js';
import { decodeWav, mono } from '../src/audio/wav.js'; import { decodeWav, mono } from '../src/audio/wav.js';
import { buildSong } from '../src/song/song.js'; import { buildSong } from '../src/song/song.js';
import { 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, A_MOVE_X, A_MOVE_Y, A_EXCRETE, A_SPAWN, A_HIT,
} from '../src/song/strains.js'; } from '../src/song/strains.js';
import { Run } from '../src/film.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 senses = new Float32Array(12);
const grads = new Float32Array(12); const grads = new Float32Array(12);
const act = new Float32Array(6); const act = new Float32Array(6);
const flat = compileFlat(code);
// Hungry (HUNGER high) → moves, no excrete. // Hungry (HUNGER high) → moves, no excrete.
senses[S_HUNGER] = 0.9; senses[S_HUNGER] = 0.9;
grads[0] = 1; grads[1] = -1; 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'); assert.ok(act[A_MOVE_X] > 0 && act[A_MOVE_Y] < 0, 'hungry strain should move toward food');
// Fed (HUNGER low) → no movement. // Fed (HUNGER low) → no movement.
senses[S_HUNGER] = 0.1; senses[S_HUNGER] = 0.1;
mem.fill(0); mem.fill(0);
grads[0] = 1; grads[1] = 1; 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'); 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 grads = new Float32Array(12);
const act = new Float32Array(6); const act = new Float32Array(6);
const flat = compileFlat(code);
senses[S_FOE] = 0.8; senses[S_FOE] = 0.8;
senses[S_PEACE] = 0.1; // war senses[S_PEACE] = 0.1; // war
grads[3 * 2] = 1; // foe gradient x 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'); assert.ok(act[A_HIT] > 0, 'at war the sentinel strikes');
senses[S_PEACE] = 0.9; // truce senses[S_PEACE] = 0.9; // truce
act.fill(0); 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'); 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 senses = new Float32Array(12);
const grads = new Float32Array(12); const grads = new Float32Array(12);
const act = new Float32Array(6); const act = new Float32Array(6);
const flat = compileFlat(code);
senses[S_SELF] = 0.8; senses[S_SELF] = 0.8;
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, code); runProgram(mem, 0, senses, grads, act, flat.I, flat.F, flat.n);
assert.ok(mem[1] > 0, 'register kept value across runs'); 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]})`); assert.ok(Math.abs(mem[1] - 0.6) < 1e-6, `memory integrates deterministically (${mem[1]})`);
}); });
+36
View File
@@ -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()})`);
}
+75
View File
@@ -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;
}