Multi-core simulation: grain movement and heat across a worker pool (t88 phase 3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+5
-3
@@ -1,4 +1,4 @@
|
||||
// Reference heat loop before t21 phase 3; preserves conduction and Float32 store order.
|
||||
// Unoptimized heat oracle, re-baselined for t88 chunk-pass heat outputs.
|
||||
function referenceHeatChunk(c, sc) {
|
||||
const cx = c % CW, cy = (c / CW) | 0;
|
||||
const x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32);
|
||||
@@ -37,6 +37,8 @@ function referenceHeatChunk(c, sc) {
|
||||
if (flags[i] & F_BURN) burning = true;
|
||||
}
|
||||
}
|
||||
if (burning || maxDev > 1.5) { hot[c] = 6; hotLvl[c] = (burning || maxDev > 60) ? 2 : 1; }
|
||||
else hot[c]--;
|
||||
const h = burning || maxDev > 1.5 ? 6 : hot[c] - 1;
|
||||
const level = burning || maxDev > 1.5 ? ((burning || maxDev > 60) ? 2 : 1) : hotLvl[c];
|
||||
if (chunkWork) { chunkWork.ownHot[c] = h; chunkWork.ownLevel[c] = level; }
|
||||
else { hot[c] = h; hotLvl[c] = level; }
|
||||
}
|
||||
|
||||
@@ -81,3 +81,34 @@ function dump(run, x0, y0, x1, y1, withTemp) {
|
||||
}
|
||||
}
|
||||
module.exports.dump = dump;
|
||||
|
||||
// Node uses exactly the browser worker entry point and scheduler.
|
||||
async function enableThreads(run, count) {
|
||||
const { Worker } = require('worker_threads');
|
||||
const entry = `const {parentPort}=require('worker_threads');
|
||||
require(${JSON.stringify(__filename)}).load();
|
||||
parentPort.on('message', data=>threadMessage(data));`;
|
||||
const pool = { world: run('mat'), workers: [], slots: [], force: true };
|
||||
try {
|
||||
const ready = [];
|
||||
for (let k = 0; k < count; k++) {
|
||||
const worker = new Worker(entry, {eval:true}), slot = run('threadSlot()');
|
||||
pool.workers.push(worker); pool.slots.push(slot);
|
||||
ready.push(new Promise((resolve,reject) => {
|
||||
const deadline = Date.now()+10000;
|
||||
worker.once('error',reject);
|
||||
const poll = () => {
|
||||
if (Atomics.load(slot.control,0)) resolve();
|
||||
else if (Date.now()>deadline) reject(Error('Worker startup timed out'));
|
||||
else setTimeout(poll,1);
|
||||
};
|
||||
poll();
|
||||
}));
|
||||
worker.postMessage({world:run('threadWorld()'),slot});
|
||||
}
|
||||
await Promise.all(ready);
|
||||
globalThis.__testPool = pool; run('simPool = globalThis.__testPool;'); delete globalThis.__testPool;
|
||||
return async () => { run('simPool=null;'); await Promise.all(pool.workers.map(w=>w.terminate())); };
|
||||
} catch (e) { await Promise.all(pool.workers.map(w=>w.terminate())); throw e; }
|
||||
}
|
||||
module.exports.enableThreads = enableThreads;
|
||||
|
||||
+11
-2
@@ -8,16 +8,18 @@ ensure_playwright()
|
||||
from browser import capabilities, consume_gpu_option, launch
|
||||
|
||||
consume_gpu_option()
|
||||
for case in ['native', 'absent', 'null', 'reject', 'firefox', 'limits']:
|
||||
for case in ['native', 'absent', 'null', 'reject', 'firefox', 'limits', 'threads']:
|
||||
with launch() as (_, page, url):
|
||||
errors = []
|
||||
page.on('pageerror', lambda e: errors.append(str(e)))
|
||||
if case != 'native':
|
||||
if case not in ('native', 'threads'):
|
||||
gpu = 'undefined' if case == 'absent' else "{requestAdapter: async () => null}"
|
||||
if case == 'reject': gpu = "{requestAdapter: async () => {throw new Error('adapter denied')}}"
|
||||
if case == 'limits': gpu="{requestAdapter:async()=>({limits:{maxStorageTexturesPerShaderStage:3}})}"
|
||||
page.add_init_script(f"Object.defineProperty(navigator, 'gpu', {{value: {gpu}}});")
|
||||
page.add_init_script("Object.defineProperty(self, 'crossOriginIsolated', {value: false});")
|
||||
if case == 'threads':
|
||||
page.add_init_script("Object.defineProperty(self, 'crossOriginIsolated', {value: false});")
|
||||
if case == 'firefox':
|
||||
page.add_init_script("Object.defineProperty(navigator, 'userAgent', {value: 'Firefox/157.0'});")
|
||||
page.goto(url)
|
||||
@@ -26,6 +28,13 @@ for case in ['native', 'absent', 'null', 'reject', 'firefox', 'limits']:
|
||||
if case == 'native':
|
||||
assert caps['isolated']
|
||||
assert page.locator('#cap-notice').is_hidden() == caps['webgpu']
|
||||
elif case == 'threads':
|
||||
assert caps['webgpu'] and not caps['isolated']
|
||||
assert not page.evaluate('canStartGame()')
|
||||
page.evaluate('startNewWorld(424242, 512, 384)')
|
||||
assert not page.evaluate('GAME.running || GAME.loading')
|
||||
assert not page.evaluate('startFromSave({})')
|
||||
assert 'python3 tools/serve.py' in page.inner_text('#cap-notice')
|
||||
else:
|
||||
assert not caps['webgpu'] and caps['adapterInfo'] is None
|
||||
notice = page.inner_text('#cap-notice')
|
||||
|
||||
+2
-1
@@ -123,7 +123,8 @@ const liftLanes = run('(() => { const s = new Set(); for (let y = 76; y < 80; y+
|
||||
check('a lift turns its corner without squeezing down to one lane', liftLanes === 4 && lr > 30, `${lr.toFixed(0)}/s, ${liftLanes} lanes at the top`);
|
||||
fresh();
|
||||
run('for (let bx = 20; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true);');
|
||||
rect(run, 80, 20, 95, 99, run('SAND'));
|
||||
// A wide heap keeps the belt fed across the new chunk-local random streams.
|
||||
rect(run, 80, 20, 111, 99, run('SAND'));
|
||||
const br = rate(128, 319, 104, 119, 90, 300);
|
||||
ticks(run, 0);
|
||||
check('a belt carries a pile off at several dozen grains a second', br > 50, `${br.toFixed(0)}/s`);
|
||||
|
||||
@@ -5,13 +5,13 @@ const { run } = load();
|
||||
const { check, done } = suite();
|
||||
|
||||
for (const [seed, expected] of [
|
||||
[777, '040644fb50c891911383ef0206ea988677d7ec447a3e6637cefa99364e765861'],
|
||||
[77, '2e6af816af54c8de4fe9c7188607f7af503ef844b2087245779bcf39e8330bdb'],
|
||||
[4242, 'f09270f4ac5da9347bc277c9959e31ee797313b4a994598b4647dcf852532782'],
|
||||
[777, 'b6f7b493e93a59e38ee57d33f24690daa87e7be0f4b356b32225a3a6f1a9d001'],
|
||||
[77, '5e08c24603686c8617dd1156b10612b91a05fd9ee8b3648dfc4ee32fc008383a'],
|
||||
[4242, 'f7ca75d19eba9eae4894b21b95fe1b683bd54e1f65a415dcc296f3c23b5d7e05'],
|
||||
]) {
|
||||
run(`generateWorld(${seed}, 512, 384)`);
|
||||
check(`seed ${seed} restores corner sealing`,run('CORNER_SEAL')===true);
|
||||
check(`seed ${seed} keeps its pre-seal material hash`, createHash('sha256').update(run('mat')).digest('hex') === expected);
|
||||
check(`seed ${seed} matches its thread-independent material hash`, createHash('sha256').update(run('mat')).digest('hex') === expected);
|
||||
}
|
||||
|
||||
for (const material of ['SAND', 'WATER', 'STEAM']) for (const mirror of [false, true]) {
|
||||
@@ -64,8 +64,8 @@ ticks(run, 600);
|
||||
const pile = run(`(()=>{let x0=W,x1=0,y0=H,y1=0;
|
||||
for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(mat[y*W+x]===SAND){x0=Math.min(x0,x);x1=Math.max(x1,x);y0=Math.min(y0,y);y1=Math.max(y1,y);}
|
||||
return [x1-x0+1,y1-y0+1];})()`);
|
||||
// Before the fix: 57 wide, 17 high. Small bounds allow random-state differences from prior scenes.
|
||||
check('open-ground sand still forms the same size pile', pile[0]>=55 && pile[0]<=59 && pile[1]>=16 && pile[1]<=18 && count(run,run('SAND'))===400, pile);
|
||||
// t88 chunk streams re-baseline this seeded pile to 46 wide and 19 high.
|
||||
check('the seeded sand pile keeps its re-baselined shape and all grains', pile[0]>=44 && pile[0]<=48 && pile[1]>=18 && pile[1]<=20 && count(run,run('SAND'))===400, pile);
|
||||
const sealedPile = createHash('sha256').update(run('mat')).digest('hex'), sealedRS=run('RS');
|
||||
flatWorld(run,240,160,120);
|
||||
run('RS=12345; CORNER_SEAL=false;');
|
||||
|
||||
+3
-3
@@ -23,7 +23,7 @@ with launch(viewport={'width':960,'height':600}) as (_,pg,url):
|
||||
assert startup['warm']>=0
|
||||
print('PASS both looks submit behind loading screen; first visible frame ms',startup)
|
||||
pg.add_script_tag(path=str(root/'tools/perf-scenes.js'))
|
||||
pg.evaluate("perfScene('factory',1024,640); SET.look='pixel'; SET.zoom=2;resizeRender();snapCamera();drawWorld();")
|
||||
pg.evaluate("async()=>{perfScene('factory',1024,640);await startThreads();simPool.force=true;SET.look='pixel';SET.zoom=2;resizeRender();snapCamera();drawWorld();}")
|
||||
result=pg.evaluate('''async () => {
|
||||
const results=[];
|
||||
for(let n=0;n<240;n++) {
|
||||
@@ -35,9 +35,9 @@ with launch(viewport={'width':960,'height':600}) as (_,pg,url):
|
||||
}
|
||||
}
|
||||
drawWorld();await gpuCheckUploads();
|
||||
drawWorld();return {checked:results,cleanCells:GPU.uploadCells};
|
||||
drawWorld();return {checked:results,cleanCells:GPU.uploadCells,workers:simPool.workers.length,active:simPool.activeMax};
|
||||
}''')
|
||||
assert min(result['checked'])>1000 and result['cleanCells']==0,result
|
||||
assert min(result['checked'])>1000 and result['cleanCells']==0 and result['workers']>0 and result['active']>0,result
|
||||
print('PASS busy ticks, heat, fog, scrolling, both looks: GPU bytes equal full CPU data; clean frame uploads no world cells',result)
|
||||
parity=pg.evaluate("""async()=>{
|
||||
const results=[];
|
||||
|
||||
+12
-2
@@ -1,5 +1,5 @@
|
||||
"""Frozen legacy references, captured before retirement; compare the WebGPU world only."""
|
||||
import base64, pathlib, sys, subprocess
|
||||
import base64, gzip, json, pathlib, sys, subprocess
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
|
||||
from use_venv import ensure_playwright; ensure_playwright()
|
||||
from browser import launch, consume_gpu_option
|
||||
@@ -16,7 +16,17 @@ with launch(viewport={'width': 640, 'height': 480}) as (_, pg, url):
|
||||
for scene in ['renderer','joins','sky','lamp','opening','magma','fire']:
|
||||
for look in ['pixel','smooth']:
|
||||
for zoom in [12,2]:
|
||||
pg.evaluate('(h)=>{generateWorld(424242,640,h,null);enterGame();GAME.paused=true;}',512 if scene=='joins' else 384)
|
||||
height = 512 if scene=='joins' else 384
|
||||
frozen = json.loads(gzip.decompress((ROOT/f'test/fixtures/golden-world-640x{height}.json.gz').read_bytes()))
|
||||
# Keep the inputs that produced the legacy PNGs when simulation RNG changes.
|
||||
pg.evaluate("""([h, frozen])=>{
|
||||
generateWorld(424242,640,h,null);
|
||||
for(const [name,encoded]of Object.entries(frozen)) {
|
||||
const target=eval(name),raw=Uint8Array.from(atob(encoded),c=>c.charCodeAt(0));
|
||||
new Uint8Array(target.buffer).set(raw);
|
||||
}
|
||||
enterGame();GAME.paused=true;
|
||||
}""",[height,frozen])
|
||||
name = 'joins' if scene=='joins' else 't88-renderer' if scene in ['renderer','sky'] else 't88-light'
|
||||
pg.evaluate('() => {'+(ROOT / f'tools/scenes/{name}.js').read_text()+'}')
|
||||
if scene not in ['renderer','joins','sky']:
|
||||
|
||||
+5
-1
@@ -42,7 +42,11 @@ check('the settled water area goes back to sleep', run('awake[6 * CW + 1] === 0
|
||||
run('deserializeWorld(loadSave.meta, loadSave.buffers); bot.x = 14; bot.y = 190;');
|
||||
check('the destination is still waiting before moving wakes it', run('loadWakePending[6 * CW] === 2 && hot[6 * CW] === 0'));
|
||||
ticks(run, 8);
|
||||
check('moving near a waiting area wakes it within eight ticks, including heat', run('loadWakePending[6 * CW] === 0 && hot[6 * CW] > 0 && life[192 * W + 16] < FUEL[WOOD]'));
|
||||
check('moving near a waiting area wakes it within eight ticks, including heat', run('loadWakePending[6 * CW] === 0 && hot[6 * CW] > 0'));
|
||||
|
||||
// Burning is probabilistic: waking does not promise a fuel draw on that exact tick.
|
||||
ticks(run, 32);
|
||||
check('newly woken fuel resumes burning', run('life[192 * W + 16] < FUEL[WOOD]'));
|
||||
|
||||
// Saving midway keeps the actual cells, and loading starts a fresh queue at the new bot.
|
||||
run('globalThis.midLoadSave = serializeWorld(); deserializeWorld(midLoadSave.meta, midLoadSave.buffers);');
|
||||
|
||||
@@ -12,7 +12,9 @@ check('revealing the whole map invalidates every chunk',marked('fogRevealAll();'
|
||||
check('heat invalidates the chunk and conduction across its edges',marked('heatChunk(5,1);',64,40)&&run('!!renderDirty[1] && !!renderDirty[4] && !!renderDirty[9]'));
|
||||
check('tree growth invalidates the background',marked('setTreeCell(70,70,WOOD);',70,70));
|
||||
run('setCell(60*W+60,FIRE,900,0);life[60*W+60]=10;for(const j of [60*W+59,60*W+61,59*W+60,61*W+60])setCell(j,STONE,20,F_NAT);');
|
||||
check('blocked fire aging invalidates without movement',marked('gas(60*W+60,60,60,FIRE);',60,60) && run('life[60*W+60]===9'));
|
||||
check('blocked fire aging invalidates without movement',marked('tick++;tickStamp++;simMove();',60,60) && run('life[60*W+60]===9'));
|
||||
run('setCell(32*W+64,SAND,20,0);part[32*W+64]=P_GOPEN;setCell(31*W+64,EMPTY,20,0);');
|
||||
check('closing a gate invalidates the displaced grain across a chunk edge',marked('toggleGateAt(64,32);',64,31) && run('mat[31*W+64]===SAND'));
|
||||
run('awake.fill(0);setCell(63*W+63,SAND,20,0);renderDirty.fill(0);tick++;tickStamp++;simMove();');
|
||||
check('movement invalidates its scanned chunk and diagonal destination',run('renderDirty[5] && renderDirty[9]'));
|
||||
done();
|
||||
|
||||
@@ -52,4 +52,10 @@ for (const scale of [1, 4]) {
|
||||
run(`for (let c = 1; c < NCH; c++) heatChunk(c, ${scale});`);
|
||||
check('heat matches original bytes and random state at scale ' + scale, snapshot() === expected);
|
||||
}
|
||||
setup();
|
||||
run('const optimizedHeatChunk=heatChunk;heatChunk=referenceHeatChunk;heatPass();');
|
||||
const passExpected=snapshot();
|
||||
run('heatChunk=optimizedHeatChunk;');
|
||||
setup();run('heatPass();');
|
||||
check('four-pass heat matches the unoptimized oracle byte for byte',snapshot()===passExpected);
|
||||
done();
|
||||
|
||||
@@ -88,7 +88,8 @@ for(const grade of [-1,1]) {
|
||||
for(const grade of [-1,1]) {
|
||||
const s=line(grade,true);
|
||||
run('for(const b of filterCfg.keys())setFilter(b,{m:[SAND],inv:1});');
|
||||
collect(s,220,true);
|
||||
// Let the changed seeded pile reach the full screen before timing its throughput.
|
||||
collect(s,440,true);
|
||||
const r=collect(s,600,true);
|
||||
console.log(`Filter throughput grade ${grade}, grains/s: ${r.out/10}`);
|
||||
check(`downhill screen grade ${grade} discharges its generous feed`,r.out/10>=45 && r.out/10<=90,JSON.stringify(r));
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Shared allocation, checkerboard independence, and real worker-count determinism.
|
||||
const assert = require('assert');
|
||||
const { load, flatWorld } = require('./harness');
|
||||
const { run } = load();
|
||||
flatWorld(run, 131, 129, 110);
|
||||
assert(run('[mat,temp,life,flags,stamp,part,mach,digp,noiseB,bgm,airS,netId,isPow,occ,awake,hot,hotLvl,renderDirty,powderArea,cleatMask,cleatChunks,roofDirty,blockP,blockV,slopePhase,fog,AMB,roofY].every(a=>a.buffer instanceof SharedArrayBuffer)'));
|
||||
console.log('PASS world arrays use shared memory');
|
||||
const crypto = require('crypto');
|
||||
const { enableThreads } = require('./harness');
|
||||
flatWorld(run, 192, 96, 31);
|
||||
run(`setCell(30*W+32,WATER,20,0);setCell(31*W+130,EMPTY,20,0);
|
||||
tick++;tickStamp++;var draws=0,savedRnd=rnd;rnd=()=>{draws++;return .99};
|
||||
var searchWork=inlineChunkWork();runChunks([1],0,searchWork);`);
|
||||
assert(run('searchWork.events.some(e=>e[1]==="liquid")'), 'fixture must defer a long search');
|
||||
run('mergeChunkWork([searchWork]);rnd=savedRnd;');
|
||||
assert.strictEqual(run('draws'),1,'resuming a liquid search must not reroll its movement chance');
|
||||
assert.strictEqual(run('mat[30*W+40]'),run('WATER'),'the resumed search finds the distant drop');
|
||||
console.log('PASS long liquid search resumes without rerolling movement');
|
||||
function setup() {
|
||||
flatWorld(run, 387, 257, 214);
|
||||
run(`{RS=123;WS=456;resetWeather();weather.next=1e15;seen.fill(0);ledger={};EV.length=0;for(const k of Object.keys(stats))stats[k]=0;
|
||||
resetEconomy();nextPowerBuild=0;bot.x=185;bot.y=120;
|
||||
const random=mulberry32(99), materials=[SAND,COAL,WATER,MAGMA,MCU,SNOW,LWOOD,STEAM,SMOKE];
|
||||
for(let y=40;y<210;y++)for(let x=8;x<W-8;x++) {
|
||||
if(random()<.68)continue;
|
||||
const m=materials[(random()*materials.length)|0];
|
||||
setCell(y*W+x,m,m===MAGMA?1450:m===MCU?1200:m===STEAM?110:20,0);
|
||||
}
|
||||
addMachine('wheel',40,35,0);
|
||||
for(let x=0;x<W;x++)setCell(31*W+x,STONE,20,F_NAT);
|
||||
setCell(31*W+W-10,EMPTY,20,0);
|
||||
for(let x=32;x<W-10;x+=64)setCell(30*W+x,WATER,20,0);
|
||||
for(let bx=12;bx<40;bx++)placeBlock(BP_FILTER,128,bx,48,true,{m:[SAND],inv:0});
|
||||
for(let x=80;x<210;x++)setCell(120*W+x,COAL,1000,F_BURN);
|
||||
hot.fill(6);hotLvl.fill(2);awake.fill(3);updateRoof();}
|
||||
`);
|
||||
}
|
||||
function hash() {
|
||||
const h = crypto.createHash('sha256');
|
||||
for (const a of run('[mat,temp,life,flags,stamp,part,mach,digp,bgm,isPow,netId,awake,hot,hotLvl,powderArea,roofDirty,seen,renderDirty]'))
|
||||
h.update(Buffer.from(a.buffer,a.byteOffset,a.byteLength));
|
||||
h.update(run('JSON.stringify({RS,stats,EV,flyers,brokenMachines,pcells:[...pcells],powerDirty,weather,machines})'));
|
||||
return h.digest('hex');
|
||||
}
|
||||
(async () => {
|
||||
let expected;
|
||||
for (const n of [0,1,2,8,8,2,1,0]) {
|
||||
setup(); const stop = n ? await enableThreads(run,n) : null;
|
||||
try {
|
||||
run('for(let k=0;k<320;k++)simTick();');
|
||||
const actual=hash();
|
||||
if(!expected)expected=actual;
|
||||
assert.strictEqual(actual,expected,`world and effects differ with ${n} workers`);
|
||||
console.log(`PASS identical world and side effects with ${n} workers (${actual.slice(0,16)})`);
|
||||
} finally { if(stop)await stop(); }
|
||||
}
|
||||
setup();
|
||||
run('const forwardChunks=runChunks;runChunks=(chunks,pass,work)=>forwardChunks(chunks.slice().reverse(),pass,work);for(let k=0;k<320;k++)simTick();runChunks=forwardChunks;');
|
||||
assert.strictEqual(hash(),expected,'chunk order within a colour must not matter');
|
||||
console.log('PASS reversing chunks within every pass preserves all bytes and effects');
|
||||
const {execFileSync}=require('child_process');
|
||||
const fallback=execFileSync(process.execPath,['-e',`
|
||||
global.SharedArrayBuffer=undefined;
|
||||
const {load,flatWorld}=require(${JSON.stringify(require.resolve('./harness'))});
|
||||
const {run}=load();flatWorld(run,64,64,50);
|
||||
if(!run('mat.buffer instanceof ArrayBuffer'))throw Error('fallback allocation');
|
||||
run('setCell(20*W+20,SAND,20,0);for(let k=0;k<60;k++)simTick();');
|
||||
process.stdout.write(JSON.stringify(run('Array.from(mat)')));
|
||||
`],{encoding:'utf8'});
|
||||
flatWorld(run,64,64,50);
|
||||
run('setCell(20*W+20,SAND,20,0);for(let k=0;k<60;k++)simTick();');
|
||||
assert.strictEqual(JSON.stringify(run('Array.from(mat)')),fallback);
|
||||
console.log('PASS Node ArrayBuffer fallback matches shared inline movement');
|
||||
console.log('ALL PASS');
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
Reference in New Issue
Block a user