Profiler: real-GPU breakdown by zoom, look and scene, with a baseline (t88 phase 0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 18:31:43 -04:00
co-authored by Claude Opus 5.5
parent 0c0edf46ca
commit 9781aa3ee1
17 changed files with 38797 additions and 57 deletions
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
// Temporary wrappers live only in the profiling page, never in the simulation bundle.
function perfWrap(names, data) {
const originals = {};
for (const name of names) {
if (typeof window[name] !== 'function') throw Error('Missing profiler target: ' + name);
const f = originals[name] = window[name];
data[name] = { ms: 0, calls: 0 };
window[name] = function (...args) {
const t = performance.now();
try { return f.apply(this, args); }
finally { data[name].ms += performance.now() - t; data[name].calls++; }
};
}
return () => { for (const name of names) window[name] = originals[name]; };
}
function perfSim(names, scene, w, h) {
// Five full periods warm even the 120-tick cold pass without forcing conditional calls.
for (let k = 0; k < 600; k++) simTick();
perfScene(scene, w, h);
const start = perfState(), data = {}, restore = perfWrap(names, data), n = 240;
const t = performance.now();
try { for (let k = 0; k < n; k++) simTick(); }
finally { restore(); }
const total = (performance.now() - t) / n;
const steps = {};
for (const [name, d] of Object.entries(data)) steps[name] = {
ms_per_tick: d.ms / n, ms_per_call: d.calls ? d.ms / d.calls : null, calls: d.calls
};
return { total_ms: total, ticks: n, start, end: perfState(), steps,
rest_ms: total - Object.values(data).reduce((s, d) => s + d.ms / n, 0) };
}
function perfRender(lighting, overlays) {
SET.lighting = lighting;
PERF_MARKS = true;
try {
for (let k = 0; k < 5; k++) { R.frame++; drawWorld(); }
const marks = {}, n = 30;
let draw = 0;
for (let k = 0; k < n; k++) {
R.frame++; drawWorld();
draw += perfLast.drawWorld / n;
for (const [key, value] of Object.entries(perfMarks)) marks[key] = (marks[key] || 0) + value / n;
}
for (let k = 0; k < 5; k++) computeLight();
const l0 = performance.now();
for (let k = 0; k < n; k++) computeLight();
const light = (performance.now() - l0) / n;
// Exercise the actual overlay calls and their arguments/conditions in frame().
PERF_MARKS = false;
for (let k = 0; k < 5; k++) frame(lastT + 1000 / 60);
const data = {}, restore = perfWrap(overlays, data);
try { for (let k = 0; k < n; k++) frame(lastT + 1000 / 60); }
finally { restore(); }
return { drawWorld_ms: draw, computeLight_ms: light, marks_ms: marks,
overlays: Object.fromEntries(Object.entries(data).map(([key, d]) => [key, { ms_per_frame: d.ms / n, calls: d.calls }])),
overlay_ms: Object.values(data).reduce((sum, d) => sum + d.ms / n, 0),
scale: SM.scale, actual_look: SET.look };
} finally { PERF_MARKS = false; }
}
function perfRun(seconds) {
return new Promise(resolve => {
SET.lighting = 'full';
let warm = 5, previous = null;
const samples = [];
GAME.paused = false; acc = 0; lastT = performance.now(); lastRender = lastT;
PERF_FRAME = s => {
if (warm-- > 0) { previous = s.at; return; }
samples.push({ interval: s.at - previous, work: s.work, sim: s.sim, steps: s.steps });
previous = s.at;
};
window.__perfStop = false;
requestAnimationFrame(frame);
setTimeout(() => {
window.__perfHalt(); PERF_FRAME = null; GAME.paused = true;
resolve({ samples, end: perfState(), scale: SM.scale, actual_look: SET.look });
}, seconds * 1000);
});
}
+66
View File
@@ -0,0 +1,66 @@
// Seeded worlds, no storage round-trip (saves quantize heat and land flying grains).
function perfScene(name, w, h) {
GAME.running = false;
// Match a fresh page: generation retains the power scheduler and weather random stream.
nextPowerBuild = 0; WS = 0x1b873593 | 0;
for (const key of Object.keys(stats)) stats[key] = 0;
generateWorld(424242, w, h, null);
for (let k = 0; k < 600; k++) simTick();
if (name !== 'settled') {
const x0 = Math.max(8, (W / 4 - 100) & ~3), y0 = Math.max(16, Math.min(H - 176, (surfY[Math.max(8, W / 4 | 0)] - 100) & ~3));
for (let y = y0; y < y0 + 160; y++) for (let x = x0; x < x0 + 200; x++) {
setCell(y * W + x, y >= y0 + 144 ? STONE : EMPTY, AMB[y], 0);
bgm[y * W + x] = 0;
}
bot.x = x0 + 98; bot.y = y0 + 50;
if (name === 'factory') {
// Furnace and hopper from experiments/factory_cost.js, with a screw/filter line.
const mx = x0 / 4 + 3, my = y0 / 4 + 16;
const place = (code, v, x, y, extra) => {
const error = placeBlock(code, v, x, y, true, extra);
if (error) throw Error(`factory ${x},${y}: ${error}`);
};
for (let by = my; by < my + 4; by++) for (let bx = mx; bx < mx + 4; bx++) {
if ((bx > mx && bx < mx + 3 && by > my && by < my + 3) || (by === my && bx === mx + 2)) continue;
place(BP_WALL, RSTONE, bx, by);
}
for (let y = (my + 1) * 4 + 3; y < (my + 3) * 4; y++) for (let x = (mx + 1) * 4; x < (mx + 3) * 4; x++)
setCell(y * W + x, (x + y) & 3 ? COAL : CUORE, 700, 0);
for (let x = mx + 8; x < mx + 25; x++) place(BP_BELT, 1, x, my + 5);
for (let x = mx + 25; x < mx + 30; x++) place(BP_SCREW, 1, x, my + 4);
for (let x = mx + 30; x < mx + 40; x++) place(BP_FILTER, 128, x, my + 5, { m: [SAND], inv: 0 });
// Two full silos keep both materials flowing for the entire real run.
for (const [dx, material] of [[9, SAND], [33, CUORE]]) {
const silo = addMachine('silo', mx + dx, my - 2, 0);
silo.sm = material; silo.sn = SILO_CAP; silo.open = true;
}
// Start with a loaded line, rather than timing its initial travel delay.
for (let x = (mx + 8) * 4; x < (mx + 40) * 4; x++)
setCell(((my + 5) * 4 - 1) * W + x, x & 3 ? SAND : CUORE, AMB[(my + 5) * 4 - 1], 0);
} else {
for (let y = y0 + 100; y < y0 + 144; y++) for (let x = x0 + 12; x < x0 + 188; x++)
setCell(y * W + x, y < y0 + 114 ? WATER : MAGMA, y < y0 + 114 ? 20 : 1450, 0);
}
for (let k = 0; k < 60; k++) simTick();
}
enterGame(); GAME.paused = true;
SET.fog = false; SET.autosave = 0; SET.cameraLead = false; SET.shake = false;
SET.fpsCap = 0; SET.showPerf = false;
document.querySelectorAll('.card, .toast').forEach(e => e.remove());
R.particles.length = 0;
snapCamera(); acc = 0;
return perfState();
}
function perfState() {
let a = 0, h = 0;
for (let c = 0; c < NCH; c++) { if (awake[c]) a++; if (hot[c]) h++; }
return { tick, awake: a, hot: h, chunks: NCH, moved: stats.moved,
flyers: flyers.length, silos: machines.filter(m => m && m.type === 'silo').map(m => ({ material: m.sm, grains: m.sn, pouring: tick - m.pourT < 10 })), camera: [R.camX, R.camY], bot: [bot.x, bot.y] };
}
function perfView(look, zoom) {
SET.look = look; SET.zoom = zoom; SET.lighting = 'full';
resizeRender(); snapCamera();
for (let k = 0; k < 5; k++) { R.frame++; drawWorld(); }
if (look === 'smooth' && (!SM.ok || !R.smoothOn)) throw Error('Smooth look unavailable: ' + SM.why);
return { grains: R.bw * R.bh, buffer: [R.bw, R.bh], scale: SM.scale };
}
+212 -34
View File
@@ -1,35 +1,213 @@
"""Times the renderer and the simulation in a headless browser: light, drawWorld (with and without
light and glow), the pixel upload and blit, and one simulation tick, in ms. Rebuild first.
#!/usr/bin/env python3
"""Profile a seeded world on the real GPU, with isolated HTTP serving. Times are milliseconds."""
import argparse
from datetime import datetime, timezone
import hashlib
import json
import math
from pathlib import Path
import re
import subprocess
import sys
import time
python3 tools/perf.py
"""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from use_venv import ensure_playwright; ensure_playwright()
from playwright.sync_api import sync_playwright
root = pathlib.Path(__file__).resolve().parent.parent
url = (root / 'dist' / 'index.html').as_uri()
with sync_playwright() as p:
b = p.chromium.launch(args=['--enable-gpu-rasterization','--ignore-gpu-blocklist'])
pg = b.new_page(viewport={'width': 1440, 'height': 860})
pg.goto(url); pg.evaluate("SET.fog = false"); pg.wait_for_timeout(300)
pg.evaluate("startNewWorld(424242, 1024, 640)")
pg.wait_for_function('GAME.running === true', timeout=20000)
pg.wait_for_timeout(500)
res = pg.evaluate('''() => {
const t = (f, n) => { const a = performance.now(); for (let k=0;k<n;k++) f(); return ((performance.now()-a)/n).toFixed(2); };
const out = {};
out.light = t(() => computeLight(), 20);
out.drawWorld = t(() => drawWorld(), 20);
SET.lighting = 'off'; out.drawWorldNoLight = t(() => drawWorld(), 20); SET.lighting='full';
SET.glow = 'off'; out.drawWorldNoGlow = t(() => drawWorld(), 20); SET.glow='low';
SET.fog = true; out.drawWorldFog = t(() => drawWorld(), 100);
SET.lighting = 'off'; out.drawWorldFogNoLight = t(() => drawWorld(), 100); SET.lighting = 'full'; SET.fog = false;
out.put = t(() => { R.bctx.putImageData(R.img,0,0); }, 20);
out.blit = t(() => { R.ctx.drawImage(R.buf, 0, 0, R.bw, R.bh, 0, 0, R.bw*R.zp, R.bh*R.zp); }, 20);
out.sim = t(() => simTick(), 60);
out.view = [R.bw, R.bh, R.zp, R.lw, R.lh];
return out;
}''')
print(res)
b.close()
from server import isolated_server
from use_venv import ensure_playwright
ROOT = Path(__file__).resolve().parent.parent
GPU_ARGS = ['--use-angle=vulkan', '--enable-features=Vulkan', '--enable-gpu',
'--ignore-gpu-blocklist', '--enable-gpu-rasterization']
def size(value):
try:
w, h = map(int, value.lower().split('x'))
if w < 320 or h < 240 or w > 8192 or h > 8192:
raise ValueError()
return w, h
except ValueError:
raise argparse.ArgumentTypeError('size must be WxH, between 320x240 and 8192x8192')
def quantiles(values):
if not values:
raise ValueError('No real-loop samples were collected')
ordered = sorted(values)
def percentile(p):
i = (len(ordered) - 1) * p
lo = math.floor(i)
return ordered[lo] + (ordered[math.ceil(i)] - ordered[lo]) * (i - lo)
return {name: percentile(p) for name, p in [('p50', .5), ('p95', .95), ('p99', .99)]}
def measurements(data, prefix=''):
"""Only timings, never counts, frame timestamps or hardware identifiers."""
for key, value in data.items():
path = f'{prefix}/{key}'
if isinstance(value, dict):
yield from measurements(value, path)
elif isinstance(value, (int, float)) and (
key.endswith('_ms') or key.startswith('ms_per_') or key in ('p50', 'p95', 'p99')
or prefix.endswith('/marks_ms')):
yield path, value
def report(result, baseline=None):
m = result['machine']
print(f"GPU: {m['renderer']}\nCores: {m['cores']} | isolated: {m['crossOriginIsolated']} | navigator.gpu: {m['webgpu']}")
print(f"Viewport {result['options']['viewport']} | world {result['options']['world']} | seed 424242 | ms (CPU wall time)")
print('\nScene Look Zoom Light Grains World Light Prep Colour Upload Blit Overlays Scale')
for scene, data in result['scenes'].items():
for key, view in data['views'].items():
look, zoom = key.split('/')
for light, r in view['lighting'].items():
p = r['marks_ms']
print(f"{scene:8} {look:6} {zoom:>4} {light:>5} {view['grains']:6d} "
f"{r['drawWorld_ms']:7.2f} {p['light']:6.2f} {p['prepare']:5.2f} {p['color']:6.2f} "
f"{p['upload']:6.2f} {p['blit']:6.2f} {r['overlay_ms']:8.3f} {r['scale']:5.2f}")
print('\nSimulation: average ms/tick (periodic work amortized over 240 ticks)')
names = list(next(iter(result['scenes'].values()))['sim']['steps'])
print(f"{'Step':24}" + ''.join(f'{s:>12}' for s in result['scenes']))
for name in ['TOTAL', *names, 'unattributed']:
values = []
for d in result['scenes'].values():
sim = d['sim']
values.append(sim['total_ms'] if name == 'TOTAL' else sim['rest_ms'] if name == 'unattributed' else sim['steps'][name]['ms_per_tick'])
print(f'{name:24}' + ''.join(f'{v:12.4f}' for v in values))
for scene, d in result['scenes'].items():
s = d['sim']
print(f" {scene}: awake {s['start']['awake']} -> {s['end']['awake']}, hot {s['start']['hot']} -> {s['end']['hot']} / {s['end']['chunks']}")
print('\nReal loop: frame interval / CPU frame work / sim per frame, p50/p95/p99 ms')
for scene, d in result['scenes'].items():
for key, r in d['runs'].items():
triples = ['/'.join(f'{v:.2f}' for v in r[k].values()) for k in ('interval_ms', 'work_ms', 'sim_ms')]
print(f"{scene:8} {key:9} n={len(r['samples']):4} " + ' | '.join(f'{v:>20}' for v in triples) + f" | scale {r['scale']:.2f}")
if baseline:
old = dict(measurements(baseline['scenes']))
print('\nComparison: change in every matching timing (>10% marked REGRESSION; zero baselines are n/a)')
for key, value in measurements(result['scenes']):
before = old.get(key)
if before is None:
print(f'{key}: new measurement')
elif before == 0:
print(f'{key}: n/a (baseline zero)')
else:
delta = 100 * (value / before - 1)
print(f"{key}: {delta:+.1f}%{' REGRESSION' if delta > 10 else ''}")
print(f"\nFull run: {result['elapsed_seconds']:.1f} seconds")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('--viewport', type=size, default=(1920, 1080))
ap.add_argument('--world', type=size, default=(2048, 1024))
ap.add_argument('--zooms', default='12,6,4,3,2')
ap.add_argument('--looks', default='pixel,smooth')
ap.add_argument('--scenes', default='settled,factory,heat')
ap.add_argument('--seconds', type=float, default=10)
ap.add_argument('--json', type=Path)
ap.add_argument('--compare', type=Path)
args = ap.parse_args()
try:
zooms = list(dict.fromkeys(float(z) for z in args.zooms.split(',')))
except ValueError:
ap.error('--zooms must be comma-separated numbers')
looks, scenes = args.looks.split(','), args.scenes.split(',')
if not zooms or any(not math.isfinite(z) or z < 2 or z > 12 for z in zooms):
ap.error('zooms must be between 2 and 12')
if not looks or set(looks) - {'pixel', 'smooth'}:
ap.error('looks must be pixel,smooth')
if not scenes or set(scenes) - {'settled', 'factory', 'heat'}:
ap.error('scenes must be settled,factory,heat')
if not math.isfinite(args.seconds) or args.seconds <= 0:
ap.error('--seconds must be positive')
if args.world[0] % 32 or args.world[1] % 32:
ap.error('world dimensions must be multiples of 32')
options = dict(viewport=list(args.viewport), world=list(args.world), zooms=zooms,
looks=looks, scenes=scenes, seconds=args.seconds)
baseline = json.loads(args.compare.read_text()) if args.compare else None
if baseline and (baseline.get('schema') != 1 or baseline['options'] != options):
ap.error('baseline schema/options differ; use the same matrix, dimensions and seconds')
ensure_playwright()
from playwright.sync_api import sync_playwright
started = time.monotonic()
subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True, stdout=sys.stderr)
# Derive coverage from the source so a newly added sim step cannot silently disappear.
tick_source = (ROOT / 'src/sim/tick.js').read_text()
steps = list(dict.fromkeys(re.findall(r'\b([A-Za-z_]\w*)\(', tick_source)))
steps = [n for n in steps if n not in ('simTick', 'if', 'for')]
overlays = list(dict.fromkeys(re.findall(r'\b(draw[A-Z]\w*)\(', (ROOT / 'src/ui/loop.js').read_text())))
overlays.remove('drawWorld')
# boot's title draw is outside frame().
overlays.remove('drawTitle')
result = dict(schema=1, created_utc=datetime.now(timezone.utc).isoformat(), options=options,
gpu_args=GPU_ARGS, scenes={}, sim_targets=steps, overlay_targets=overlays)
files = sorted((ROOT / 'src').rglob('*')) + [Path(__file__), ROOT / 'tools/perf-scenes.js', ROOT / 'tools/perf-measure.js']
result['source_sha256'] = hashlib.sha256(b''.join(p.read_bytes() for p in files if p.is_file())).hexdigest()
errors = []
with isolated_server() as url, sync_playwright() as p:
browser = p.chromium.launch(args=GPU_ARGS)
pg = browser.new_page(viewport=dict(width=args.viewport[0], height=args.viewport[1]))
pg.on('pageerror', lambda e: errors.append(str(e)))
pg.add_init_script('''window.__perfStop = true;
const raf = window.requestAnimationFrame.bind(window), pending = new Set();
window.__perfHalt = () => { window.__perfStop = true; for (const id of pending) cancelAnimationFrame(id); pending.clear(); };
window.requestAnimationFrame = f => {
if (window.__perfStop) return 0;
const id = raf(t => { pending.delete(id); f(t); }); pending.add(id); return id;
};''')
pg.goto(url)
pg.add_script_tag(content=(ROOT / 'tools/perf-scenes.js').read_text())
pg.add_script_tag(content=(ROOT / 'tools/perf-measure.js').read_text())
result['machine'] = pg.evaluate('''() => {
if (!smoothAvailable()) throw Error('Real GPU unavailable: ' + SM.why);
const gl = SM.gl, dbg = gl.getExtension('WEBGL_debug_renderer_info');
return { renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
vendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR),
cores: navigator.hardwareConcurrency, crossOriginIsolated, webgpu: !!navigator.gpu,
user_agent: navigator.userAgent, dpr: devicePixelRatio };
}''')
if not result['machine']['crossOriginIsolated']:
raise RuntimeError('Local server did not provide cross-origin isolation')
if re.search('swiftshader|llvmpipe|softpipe|software', result['machine']['renderer'], re.I):
raise RuntimeError('Refusing a software-rendered baseline')
result['machine']['browser_version'] = browser.version
def setup(scene):
return pg.evaluate('([s,w,h]) => perfScene(s,w,h)', [scene, *args.world])
for scene in scenes:
print(f'Measuring {scene}...', file=sys.stderr, flush=True)
setup(scene)
sim = pg.evaluate('([names,s,w,h]) => perfSim(names,s,w,h)', [steps, scene, *args.world])
initial = setup(scene)
data = result['scenes'][scene] = dict(initial=initial, sim=sim, views={}, runs={})
for look in looks:
for zoom in zooms:
view = pg.evaluate('([l,z]) => perfView(l,z)', [look, zoom])
view['lighting'] = {}
for light in ['full', 'off']:
view['lighting'][light] = pg.evaluate('([l,o]) => perfRender(l,o)', [light, overlays])
if view['lighting']['full']['actual_look'] != look or view['lighting']['off']['actual_look'] != look:
raise RuntimeError('Look fell back while measuring')
data['views'][f'{look}/{zoom:g}'] = view
for look in looks:
for zoom in [2, 6]:
setup(scene)
pg.evaluate('([l,z]) => perfView(l,z)', [look, zoom])
r = pg.evaluate('seconds => perfRun(seconds)', args.seconds)
if r['actual_look'] != look:
raise RuntimeError('Look fell back during real run')
for key in ['interval', 'work', 'sim']:
r[key + '_ms'] = quantiles([s[key] for s in r['samples']])
data['runs'][f'{look}/{zoom}'] = r
print(f' {look}/{zoom}: {len(r["samples"])} frames', file=sys.stderr, flush=True)
browser.close()
if errors:
raise RuntimeError('Page errors: ' + '; '.join(errors))
result['elapsed_seconds'] = time.monotonic() - started
if args.json:
args.json.write_text(json.dumps(result, indent=2) + '\n')
report(result, baseline)
if __name__ == '__main__':
main()
+1 -12
View File
@@ -1,23 +1,12 @@
#!/usr/bin/env python3
"""Build, serve and rebuild when sources change: python3 tools/serve.py [--port 8000]."""
import argparse, errno, http.server, pathlib, subprocess, sys, threading, time
from server import Handler
ROOT = pathlib.Path(__file__).resolve().parent.parent
DIST = ROOT / 'dist'
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(DIST), **kwargs)
def end_headers(self):
self.send_header('Cache-Control', 'no-store')
super().end_headers()
def log_message(self, format, *args):
pass
def build(first=False):
result = subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
+33
View File
@@ -0,0 +1,33 @@
"""Serve dist/ with isolation headers; isolated_server() owns a free-port background server."""
from contextlib import contextmanager
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from threading import Thread
DIST = Path(__file__).resolve().parent.parent / 'dist'
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(DIST), **kwargs)
def end_headers(self):
self.send_header('Cache-Control', 'no-store')
self.send_header('Cross-Origin-Opener-Policy', 'same-origin')
self.send_header('Cross-Origin-Embedder-Policy', 'require-corp')
super().end_headers()
def log_message(self, format, *args):
pass
@contextmanager
def isolated_server():
with ThreadingHTTPServer(('127.0.0.1', 0), Handler) as server:
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f'http://127.0.0.1:{server.server_port}'
finally:
server.shutdown()
thread.join()
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Profiler checks: headers, summaries/comparison, and unchanged pixels/simulation with clocks on."""
import contextlib
import io
import math
import copy
from urllib.request import urlopen
from perf import ROOT, GPU_ARGS, quantiles, measurements, report
from server import isolated_server
from use_venv import ensure_playwright
def main():
q = quantiles([4, 1, 3, 2])
assert all(math.isclose(q[k], v) for k, v in [('p50', 2.5), ('p95', 3.85), ('p99', 3.97)])
assert dict(measurements({'calls': 10, 'total_ms': 3, 'marks_ms': {'color': 2}})) == {
'/total_ms': 3, '/marks_ms/color': 2}
data = {'machine': {'renderer': 'test', 'cores': 1, 'crossOriginIsolated': True, 'webgpu': True},
'options': {'viewport': [1920, 1080], 'world': [2048, 1024]}, 'elapsed_seconds': 1,
'scenes': {'factory': {'views': {}, 'runs': {}, 'sim': {'total_ms': 1, 'rest_ms': 0,
'steps': {}, 'start': {'awake': 1, 'hot': 1}, 'end': {'awake': 1, 'hot': 1, 'chunks': 2}}}}}
newer = copy.deepcopy(data)
newer['scenes']['factory']['sim']['total_ms'] = 1.2
out = io.StringIO()
with contextlib.redirect_stdout(out):
report(newer, data)
assert '/factory/sim/total_ms: +20.0% REGRESSION' in out.getvalue()
assert 'n/a (baseline zero)' in out.getvalue()
ensure_playwright()
from playwright.sync_api import sync_playwright
with isolated_server() as url, sync_playwright() as p:
with urlopen(url) as response:
assert response.headers['Cross-Origin-Opener-Policy'] == 'same-origin'
assert response.headers['Cross-Origin-Embedder-Policy'] == 'require-corp'
response.read()
browser = p.chromium.launch(args=GPU_ARGS)
pg = browser.new_page(viewport={'width': 1280, 'height': 760})
errors = []
pg.on('pageerror', lambda e: errors.append(str(e)))
pg.add_init_script('window.requestAnimationFrame = () => 0;')
pg.goto(url)
pg.add_script_tag(content=(ROOT / 'tools/perf-scenes.js').read_text())
assert pg.evaluate('crossOriginIsolated')
# Compare to the unwrapped functions, and hash the byte representation of all grid state.
hashes = pg.evaluate('''() => {
const hash = () => {
let h = 2166136261;
for (const a of [mat, temp, flags, stamp, part, awake, hot])
for (const b of new Uint8Array(a.buffer, a.byteOffset, a.byteLength)) h = Math.imul(h ^ b, 16777619);
return h >>> 0;
};
const results = [];
for (const enabled of [false, true]) {
SET.showPerf = enabled;
generateWorld(424242, 640, 384, null);
for (let k = 0; k < 240; k++) simTick();
results.push(hash());
}
return results;
}''')
assert hashes[0] == hashes[1], hashes
pg.evaluate("perfScene('factory', 1024, 640)")
for look in ['pixel', 'smooth']:
identical = pg.evaluate('''look => {
perfView(look, 6);
const now = performance.now.bind(performance);
performance.now = () => 5000;
R.rain = rainAt(Math.max(0, Math.min(W - 1, Math.floor(R.camX + R.bw / 2))));
try {
PERF_MARKS = false; drawWorld(); const a = R.canvas.toDataURL();
PERF_MARKS = true; drawWorld(); const b = R.canvas.toDataURL();
return a === b;
} finally { PERF_MARKS = false; performance.now = now; }
}''', look)
assert identical, f'{look}: marks changed pixels'
pg.evaluate('''() => {
SET.showPerf = true; GAME.paused = false;
for (let k = 0; k < 30; k++) frame(lastT + 1000 / 60);
GAME.paused = true;
$('cards').innerHTML = '';
document.querySelectorAll('.toast').forEach(e => e.remove());
}''')
text = pg.locator('#perf').inner_text()
assert all(word in text for word in ['world', 'light', 'overlays', 'move', 'heat', 'rest']), text
pg.screenshot(path=f'/tmp/t88-perf-{look}.png')
assert not errors, errors
browser.close()
print('PASS headers, quantiles, comparison, deterministic simulation and pixel/smooth image parity; readout screenshots saved')
if __name__ == '__main__':
main()