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:
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user