#!/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 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()