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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+100
-31
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]})`);
|
||||
});
|
||||
|
||||
@@ -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()})`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user