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:
+14
-5
@@ -20,7 +20,7 @@ src/ui/ the browser shell: input, HUD, build menu, notebook, setti
|
||||
audio, the frame loop
|
||||
test/ headless suites (t_*.js) and their harness; browser tests (play.py,
|
||||
t_smooth.py, t_dev_console.py); run_all.py
|
||||
tools/ serve.py (build, serve, rebuild on change), shot.py (screenshots),
|
||||
tools/ serve.py (build, serve, rebuild on change), server.py (isolated HTTP), shot.py (screenshots),
|
||||
where.py, map.py, perf.py, playthrough.py, use_venv.py (switches browser
|
||||
scripts to .venv), scenes/ (screenshot setups), experiments/
|
||||
(measurements and reports)
|
||||
@@ -405,7 +405,7 @@ Screw casing geometry shared by both looks, ghosts and placement previews. smoot
|
||||
- functions: diagonalScrewJoins, straightScrewJoins, diagonalScrewShape, diagonalScrewBlock
|
||||
- variables: SCREW_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap
|
||||
|
||||
### src/render/world.js (404 lines)
|
||||
### src/render/world.js (419 lines)
|
||||
Scale colour without changing the smooth-look class.
|
||||
- functions: fogColor, drawWorld
|
||||
|
||||
@@ -418,7 +418,7 @@ Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, t
|
||||
- functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, pingImage, drawPing, drawHoveredVein, drawPingRecharge, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
|
||||
- variables: PING_SPEED, PING_HOLD, PING_FADE, PING_BANDS, PING_GLINTS, GLINT_MS
|
||||
|
||||
### src/render/smooth.js (99 lines)
|
||||
### src/render/smooth.js (103 lines)
|
||||
Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution.
|
||||
- functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace
|
||||
- variables: SM, smBytes
|
||||
@@ -553,7 +553,11 @@ Shared world colours, minimap and full map.
|
||||
- functions: resetMap, mapArea, updateMap, drawMap, mapFit, drawFullMap, openMap, zoomMap, zoomMinimap
|
||||
- variables: MAP_S, MAP
|
||||
|
||||
### src/ui/loop.js (140 lines)
|
||||
### src/ui/perf.js (15 lines)
|
||||
Browser-only clocks; simulation code stays usable without a browser or a clock.
|
||||
- variables: PERF_MARKS, PERF_FRAME, perfMarks, perfTimes, perfLast
|
||||
|
||||
### src/ui/loop.js (148 lines)
|
||||
The frame loop and boot.
|
||||
- functions: scanVisible, frame, boot
|
||||
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT
|
||||
@@ -583,6 +587,7 @@ The frame loop and boot.
|
||||
- `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state.
|
||||
- `test/t_molds.js`: Molds and furnaces stay usable: ingots drop below and keep their stubs, plugs over molds are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe.
|
||||
- `test/t_nozzle.js`: Blowing aims within the nozzle's reach, with room for the brush and settling.
|
||||
- `test/t_perf.js`: Profiling fixtures start in the same state after previous scenes and warm-up ticks.
|
||||
- `test/t_physics.js`: Sand and other powders, molten metal and gases: heaps, sorting, sinking, setting, rising and fading. Water has its own suite (t_water.js). Tall worlds keep geothermal heat out of the way.
|
||||
- `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore.
|
||||
- `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation.
|
||||
@@ -597,10 +602,14 @@ The frame loop and boot.
|
||||
- `test/t_weather.js`: Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing.
|
||||
- `test/t_world.js`: Trees from acorns, charcoal, brick, density, water/lava/snow/ice, full-bag refunds, drill reach, ballistic blowing, snowy mountains.
|
||||
- `tools/map.py`: Regenerate the file and symbol index at the end of docs/PROJECT_MAP.md.
|
||||
- `tools/perf.py`: 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.
|
||||
- `tools/perf-measure.js`: Temporary wrappers live only in the profiling page, never in the simulation bundle.
|
||||
- `tools/perf-scenes.js`: Seeded worlds, no storage round-trip (saves quantize heat and land flying grains).
|
||||
- `tools/perf.py`: Profile a seeded world on the real GPU, with isolated HTTP serving. Times are milliseconds.
|
||||
- `tools/playthrough.py`: Headless browser run: load the page, start a world, play a little, take screenshots.
|
||||
- `tools/serve.py`: Build, serve and rebuild when sources change: python3 tools/serve.py [--port 8000].
|
||||
- `tools/server.py`: Serve dist/ with isolation headers; isolated_server() owns a free-port background server.
|
||||
- `tools/shot.py`: Screenshot the game in a headless browser, after setting up a scene with JavaScript.
|
||||
- `tools/test_perf.py`: Profiler checks: headers, summaries/comparison, and unchanged pixels/simulation with clocks on.
|
||||
- `tools/use_venv.py`: Let browser scripts run with plain python3 by switching to the repo's .venv.
|
||||
- `tools/where.py`: Turn a line number in dist/index.html into the source file and line it came from.
|
||||
- `tools/experiments/cast_sunk_mold.js`: Block-sized bit mold sunk into the ground with a stone furnace built over its funnel.
|
||||
|
||||
+61
-2
@@ -106,7 +106,7 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **WebGL in headless Chromium.** There is no GPU, so the smooth look needs SwiftShader:
|
||||
- **WebGL in headless Chromium.** The browser tests use SwiftShader for portable graphics checks:
|
||||
launch with `--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader`. Also set
|
||||
`window.__allowSoftwareGL = true` before the page loads (`add_init_script`); otherwise the game
|
||||
sees software rendering and switches to the pixel look. `tools/shot.py` does both.
|
||||
@@ -128,7 +128,66 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
|
||||
|
||||
## Measuring
|
||||
|
||||
- `tools/perf.py`: renderer and tick times in the browser.
|
||||
- `tools/perf.py`: the phase 0 CPU/frame profiler, served over HTTP with COOP/COEP isolation.
|
||||
Rebuilds automatically. Run on an otherwise idle machine, without another browser test:
|
||||
|
||||
```sh
|
||||
python3 tools/perf.py --json tools/perf-baseline.json
|
||||
python3 tools/perf.py --json /tmp/perf-now.json --compare tools/perf-baseline.json
|
||||
python3 tools/perf.py --viewport 1920x1080 --world 2048x1024 --zooms 12,6,4,3,2 \
|
||||
--looks pixel,smooth --scenes settled,factory,heat --seconds 10 --json /tmp/perf.json
|
||||
python3 tools/test_perf.py
|
||||
```
|
||||
|
||||
The default matrix is three scenes × two looks × five zooms × lighting full/off. The world
|
||||
seed is 424242; fog is off so all visible grains are shaded. Settings otherwise use game
|
||||
defaults (including glow and the minimap), with autosave, camera lead and shake disabled.
|
||||
`settled` runs 600 ticks; `factory` reuses the furnace/transport layout from
|
||||
`experiments/factory_cost.js`, adding screws, a sand filter and full silos for sustained sand
|
||||
and ore flow; `heat` places water over magma beside a rock floor. The latter two settle for
|
||||
another 60 ticks. Each rebuild resets the power-rebuild scheduler, weather random stream and all statistics to
|
||||
fresh-page values; fixture hashes are checked by `test/t_perf.js`. No save/load snapshot is used: it would quantize heat and land flyers.
|
||||
|
||||
**World** is the whole `drawWorld` CPU call. **Light** is the lighting work inside that call;
|
||||
**Prep** sets up the view/masks/joins; **Colour** fills CPU grain buffers (including ghosts and
|
||||
flyers); **Upload** submits the pixel buffer or smooth textures; **Blit** submits drawing,
|
||||
including pixel light/glow composition or the smooth shader and Canvas2D copy. These are CPU
|
||||
wall times, including driver stalls; they are not GPU timestamp measurements. In pixel look,
|
||||
the glow upload is part of Blit. The separate `computeLight_ms` times an unconditional light
|
||||
call even when the row has lighting off. World includes Light: do not add them together.
|
||||
Overlay totals and each individual draw call are in JSON, with ms/frame and invocation counts; conditional
|
||||
overlays absent from the fixture have zero calls, not a measured zero execution cost.
|
||||
|
||||
Five calls warm each render configuration before 30 measured calls. Simulation warms five
|
||||
full 120-tick periods on a disposable scene, then rebuilds the same seeded fixture and
|
||||
measures 240 consecutive ticks. Every direct `simTick` callee is discovered from its source;
|
||||
JSON records per-call and amortized per-tick costs and invocation counts, including periodic
|
||||
functions. Inactive paths (e.g. load wake, saplings, flow tracking) have zero calls and null
|
||||
per-call cost. Unattributed time includes tick bookkeeping and clock/wrapper overhead.
|
||||
Awake/hot chunk counts are recorded at the start/end of simulation and real runs.
|
||||
|
||||
Real-loop tests independently rebuild each scene for zooms 2 and 6 in both requested looks,
|
||||
regardless of `--zooms`. `--seconds` applies to each of these twelve default runs. Raw samples
|
||||
retain frame interval (rAF timestamps, including waiting/scheduling), CPU frame work, total
|
||||
simulation work per frame and number of ticks. p50/p95/p99 use interpolated sorted samples;
|
||||
five warm-up frames are excluded. Zero-tick frames correctly have zero simulation time.
|
||||
JSON also records the smooth renderer's adaptive resolution scale: compare it as well as time.
|
||||
Comparisons require identical options, print all timing changes, and mark >10% regressions;
|
||||
tiny timings and tails can be noisy, and a zero baseline has no meaningful percent change.
|
||||
|
||||
Performance runs launch Chromium with
|
||||
`--use-angle=vulkan --enable-features=Vulkan --enable-gpu --ignore-gpu-blocklist --enable-gpu-rasterization`.
|
||||
They record the unmasked GPU/renderer, cores, browser version, viewport/world size,
|
||||
`crossOriginIsolated`, `navigator.gpu`, source hash and elapsed time. They refuse software
|
||||
renderers or missing isolation. Chromium needs access to the host GPU devices; a filesystem
|
||||
sandbox that hides `/dev/dri`/NVIDIA devices must be lifted for this measurement. The browser
|
||||
tests' SwiftShader numbers are not a performance baseline.
|
||||
|
||||
The existing in-game Performance stats toggle shows smoothed CPU world/light/overlay times
|
||||
and movement/heat/rest per simulation tick. Detailed renderer clocks are off by default
|
||||
(`PERF_MARKS = false`); the raw frame hook (`PERF_FRAME`) is also off outside the profiler.
|
||||
`tools/server.py` shares the isolation/no-cache headers with `tools/serve.py`, and supplies a
|
||||
free-port background server with cleanup for measurement tools.
|
||||
- `tools/experiments/factory_cost.js`: what running factories cost the simulation.
|
||||
- `tools/experiments/flow.js`: belt and screw conveyor throughput in grains a second.
|
||||
- `tools/experiments/worldgen.js`: material counts and timings of world generation.
|
||||
|
||||
@@ -4,6 +4,23 @@ What agents have done, newest first. Add an entry for every piece of work: the d
|
||||
note or request it was for, what changed, the files touched, the tests run, and anything left
|
||||
open or not verified. Keep each entry short; the git history has the details.
|
||||
|
||||
## 2026-10-27: t88 phase 0, profiler and baseline (Claude Code + Codex)
|
||||
|
||||
- `tools/perf.py` rewritten: real GPU (ANGLE Vulkan flags), served by the new `tools/server.py`
|
||||
with COOP/COEP (`tools/serve.py` sends them too), zooms 12/6/4/3/2 x pixel/smooth x scenes
|
||||
settled/factory/heat (`tools/perf-scenes.js`), drawWorld split by `PERF_MARKS`, per-step sim
|
||||
timings, 10 s real runs with p50/p95/p99, `--json`/`--compare`. Baseline `tools/perf-baseline.json`.
|
||||
In-game perf readout shows world, light, overlays, move, heat, rest (`src/ui/perf.js`).
|
||||
- Baseline (1920x1080, RTX 5070): zoom 2 smooth heat scene drawWorld 27.4 ms (colour loop 21.1,
|
||||
light 5.5, upload 0.6); sim 3.7-4.9 ms/tick (simMove 2.0-2.3, heatPass 1.5-2.6); real loop at
|
||||
zoom 2 runs at 30 fps (CPU work p50 30-37 ms), at zoom 6 at 60 fps (7-9 ms).
|
||||
- Files: `tools/perf.py`, `tools/server.py`, `tools/serve.py`, `tools/perf-measure.js`,
|
||||
`tools/perf-scenes.js`, `tools/test_perf.py`, `tools/perf-baseline.json`, `src/ui/perf.js`,
|
||||
`src/render/world.js`, `src/render/smooth.js`, `src/ui/loop.js`, `src/styles/style.css`,
|
||||
`src/manifest.json`, `test/t_perf.js`, `docs/TESTING.md`, `docs/PROJECT_MAP.md`.
|
||||
- Tests: `python3 test/run_all.py --browser` all passed. Not measured yet: GPU time (timestamp
|
||||
queries come with WebGPU), Firefox.
|
||||
|
||||
## 2026-10-27: OS pointer hidden over the game (Claude Code)
|
||||
|
||||
- The canvas used `cursor: crosshair`, so the system pointer showed on top of the game's own aim;
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"ui/preview-selection.js",
|
||||
"ui/events.js",
|
||||
"ui/map.js",
|
||||
"ui/perf.js",
|
||||
"ui/loop.js"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ function smoothDraw(lighting, glowOn) {
|
||||
gl.viewport(0, 0, cw, ch);
|
||||
gl.useProgram(SM.prog);
|
||||
const bw = R.bw, bh = R.bh;
|
||||
const uploadStart = PERF_MARKS ? performance.now() : 0;
|
||||
smUpload('tCol', bw, bh, smBytes(R.px));
|
||||
smUpload('tColL', bw, bh, smBytes(R.px));
|
||||
smUpload('tBack', bw, bh, smBytes(R.backPx));
|
||||
@@ -71,6 +72,8 @@ function smoothDraw(lighting, glowOn) {
|
||||
smUpload('tField', bw, bh, smBytes(R.fieldPx));
|
||||
if (lighting && R.lightImg) smUpload('tLight', R.lw, R.lh, smBytes(R.lightImg.data)); else smUpload('tLight', 1, 1, SM.white);
|
||||
if (glowOn) smUpload('tGlow', R.glowC.width, R.glowC.height, smBytes(R.glowImg.data)); else smUpload('tGlow', 1, 1, SM.white);
|
||||
const blitStart = PERF_MARKS ? performance.now() : 0;
|
||||
if (PERF_MARKS) { perfMarks.upload = blitStart - uploadStart; perfMarks.prepare += uploadStart - t0; }
|
||||
gl.uniform2f(u.uSize, bw, bh);
|
||||
gl.uniform2f(u.uOrigin, R.vx0, R.vy0);
|
||||
gl.uniform4f(u.uView, R.camX - R.vx0, R.camY - R.vy0, R.zp * (ch / R.canvas.height), ch);
|
||||
@@ -85,6 +88,7 @@ function smoothDraw(lighting, glowOn) {
|
||||
const ctx = R.ctx;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(cv, 0, 0, cw, ch, 0, 0, R.canvas.width, R.canvas.height);
|
||||
if (PERF_MARKS) perfMarks.blit = performance.now() - blitStart;
|
||||
smoothPace(performance.now() - t0);
|
||||
}
|
||||
// Keep the frame rate up: drop the GPU pass's resolution when it is slow, raise it again when there
|
||||
|
||||
+16
-1
@@ -3,6 +3,8 @@ function fogColor(c, k) { return (c & 0xff000000) | (((c >>> 16 & 255) * k) << 1
|
||||
// drawWorld: the per-grain pixel buffer (pixel look), also the input of the smooth look.
|
||||
// ---------- World pixels ----------
|
||||
function drawWorld() {
|
||||
const perfStart = PERF_MARKS ? performance.now() : 0;
|
||||
if (PERF_MARKS) perfMarks.light = 0;
|
||||
const bw = R.bw, bh = R.bh, px = R.px;
|
||||
R.vx0 = Math.floor(R.camX); R.vy0 = Math.floor(R.camY);
|
||||
const vx0 = R.vx0, vy0 = R.vy0;
|
||||
@@ -31,7 +33,12 @@ function drawWorld() {
|
||||
const useFog = SET.fog;
|
||||
const OUT = sm ? 0x04000000 : 0xff000000; // unexplored and off the map: black
|
||||
const lighting = SET.lighting !== 'off' && !R.lens;
|
||||
if (lighting && (R.frame % SET.lightEvery === 0 || !R.lw)) computeLight();
|
||||
if (lighting && (R.frame % SET.lightEvery === 0 || !R.lw)) {
|
||||
const lightStart = PERF_MARKS ? performance.now() : 0;
|
||||
computeLight();
|
||||
if (PERF_MARKS) perfMarks.light = performance.now() - lightStart;
|
||||
}
|
||||
const colorStart = PERF_MARKS ? performance.now() : 0;
|
||||
const grain = SET.grain, incand = SET.incandescence;
|
||||
const anim = R.frame;
|
||||
const lens = R.lens, tl = THERM[SET.thermalPalette] || THERM.inferno;
|
||||
@@ -374,8 +381,15 @@ function drawWorld() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (PERF_MARKS) {
|
||||
perfMarks.color = performance.now() - colorStart;
|
||||
perfMarks.prepare = colorStart - perfStart - perfMarks.light;
|
||||
}
|
||||
if (sm) { smoothDraw(lighting, glowOn); return; }
|
||||
const uploadStart = PERF_MARKS ? performance.now() : 0;
|
||||
R.bctx.putImageData(R.img, 0, 0);
|
||||
const blitStart = PERF_MARKS ? performance.now() : 0;
|
||||
if (PERF_MARKS) perfMarks.upload = blitStart - uploadStart;
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
const ox = -(R.camX - vx0) * zp, oy = -(R.camY - vy0) * zp;
|
||||
@@ -401,4 +415,5 @@ function drawWorld() {
|
||||
ctx.filter = 'none';
|
||||
ctx.restore();
|
||||
}
|
||||
if (PERF_MARKS) perfMarks.blit = performance.now() - blitStart;
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ table.mt td.dip { color: var(--melt); }
|
||||
|
||||
#perf { position: fixed; right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(12px, env(safe-area-inset-bottom, 0px)) + 4px); font-family: var(--font-mono); font-size: 11px; color: var(--ash); pointer-events: none; text-align: right; white-space: pre; }
|
||||
#hud .br { bottom: max(12px, env(safe-area-inset-bottom, 0px)); }
|
||||
#perf:not(:empty) { background: var(--plate); color: var(--bone); padding: 6px 8px; border-radius: 4px; }
|
||||
#perf:not(:empty) ~ .br { }
|
||||
|
||||
/* ---------- modals ---------- */
|
||||
|
||||
+11
-3
@@ -19,6 +19,7 @@ function frame(now) {
|
||||
if (GAME.loading) { acc = 0; return; }
|
||||
if (!GAME.running) { pollPad(); return; }
|
||||
const t0 = performance.now();
|
||||
let frameSim = 0, frameSteps = 0;
|
||||
const pad = pollPad();
|
||||
if (GAME.usingPad) { GAME.aim.x = bot.x + bot.w / 2 + GAME.padAim.x; GAME.aim.y = bot.y + bot.h / 2 + GAME.padAim.y; }
|
||||
else { const w = screenToWorld(GAME.mouse.x, GAME.mouse.y); GAME.aim.x = w.x; GAME.aim.y = w.y; }
|
||||
@@ -37,7 +38,8 @@ function frame(now) {
|
||||
GAME.padRun = pm > 0.92 ? GAME.padRun + 1 : 0;
|
||||
botStep(ix, iy, keys.has('Shift') || GAME.padRun > 24);
|
||||
toolTick();
|
||||
const s0 = performance.now(); simTick(); simMs = simMs * 0.9 + (performance.now() - s0) * 0.1;
|
||||
const s0 = performance.now(); simTick(); const elapsed = performance.now() - s0;
|
||||
simMs = simMs * 0.9 + elapsed * 0.1; frameSim += elapsed; frameSteps++;
|
||||
if (devPaused) devSteps--; else acc -= step;
|
||||
steps++;
|
||||
GAME.playTime += step;
|
||||
@@ -56,7 +58,10 @@ function frame(now) {
|
||||
if (!GAME.drag || Math.hypot(bot.vx, bot.vy) > 0.08) updateCamera(dt, lookX, lookY);
|
||||
{ const v = viewCells(); setBuildRange(R.camX + v.w / 2, R.camY + v.h / 2, v.w, v.h); }
|
||||
R.frame++;
|
||||
if (SET.showPerf) perfLast.computeLight = 0;
|
||||
drawWorld();
|
||||
if (SET.showPerf) perfTimes.computeLight = (perfTimes.computeLight || 0) * 0.9 + perfLast.computeLight * 0.1;
|
||||
const overlayStart = SET.showPerf ? performance.now() : 0;
|
||||
drawCracks();
|
||||
if (!weather.seen && (R.rain || 0) > 0.12) {
|
||||
weather.seen = true;
|
||||
@@ -81,6 +86,7 @@ function frame(now) {
|
||||
drawRain();
|
||||
drawBot();
|
||||
if (!GAME.hidePreviews) drawPreviews(); // tools/shot.py hides them for clean screenshots
|
||||
if (SET.showPerf) perfTimes.overlays = (perfTimes.overlays || 0) * 0.9 + (performance.now() - overlayStart) * 0.1;
|
||||
if (GAME.replace !== GAME.replaceShown) { GAME.replaceShown = GAME.replace; hudDirty = true; }
|
||||
renderBuildBar();
|
||||
if (hudDirty || R.frame % 6 === 0) { renderSelCard(); renderBag(); renderWallet(); hudDirty = false; }
|
||||
@@ -89,11 +95,13 @@ function frame(now) {
|
||||
updateOverlayUI();
|
||||
if (now > ambientT) { ambientT = now + 200; auAmbient(scanVisible()); }
|
||||
updateMap(now);
|
||||
frameMs = frameMs * 0.9 + (performance.now() - t0) * 0.1;
|
||||
const frameWork = performance.now() - t0;
|
||||
frameMs = frameMs * 0.9 + frameWork * 0.1;
|
||||
if (PERF_FRAME) PERF_FRAME({ at: now, work: frameWork, sim: frameSim, steps: frameSteps });
|
||||
fpsSm = fpsSm * 0.95 + (1 / Math.max(0.001, dt)) * 0.05;
|
||||
if (SET.showPerf && R.frame % 15 === 0) {
|
||||
let aw = 0, ht = 0; for (let c = 0; c < NCH; c++) { if (awake[c]) aw++; if (hot[c]) ht++; }
|
||||
$('perf').textContent = `${fpsSm.toFixed(0)} fps · frame ${frameMs.toFixed(1)} ms · sim ${simMs.toFixed(1)} ms/tick\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`;
|
||||
$('perf').textContent = `${fpsSm.toFixed(0)} fps · frame ${frameMs.toFixed(1)} ms · sim ${simMs.toFixed(1)} ms/tick (move ${(perfTimes.simMove || 0).toFixed(1)}, heat ${(perfTimes.heatPass || 0).toFixed(1)}, rest ${Math.max(0, simMs - (perfTimes.simMove || 0) - (perfTimes.heatPass || 0)).toFixed(1)})\nworld ${(perfTimes.drawWorld || 0).toFixed(1)} ms · light ${(perfTimes.computeLight || 0).toFixed(1)} ms · overlays ${(perfTimes.overlays || 0).toFixed(1)} ms\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Browser-only clocks; simulation code stays usable without a browser or a clock.
|
||||
let PERF_MARKS = false, PERF_FRAME = null;
|
||||
const perfMarks = {}, perfTimes = {}, perfLast = {};
|
||||
for (const name of ['drawWorld', 'computeLight', 'simMove', 'heatPass']) {
|
||||
const original = window[name];
|
||||
window[name] = function (...args) {
|
||||
if (!SET.showPerf && !PERF_MARKS) return original.apply(this, args);
|
||||
const start = performance.now();
|
||||
const result = original.apply(this, args);
|
||||
const ms = performance.now() - start;
|
||||
perfLast[name] = ms;
|
||||
if (name !== 'computeLight') perfTimes[name] = (perfTimes[name] || 0) * 0.9 + ms * 0.1;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Profiling fixtures start in the same state after previous scenes and warm-up ticks.
|
||||
const fs = require('fs'), path = require('path'), crypto = require('crypto');
|
||||
const { load, suite } = require('./harness');
|
||||
const { run } = load();
|
||||
const { check, done } = suite();
|
||||
// Only the fixture's browser shell is stubbed; all simulation functions are real.
|
||||
run(`const GAME = {}, R = {particles: [], camX: 0, camY: 0}, SET = {};
|
||||
const document = {querySelectorAll: () => []}; let acc;
|
||||
function enterGame() {} function snapCamera() {}`);
|
||||
run(fs.readFileSync(path.join(__dirname, '../tools/perf-scenes.js'), 'utf8'));
|
||||
function fixture(name) {
|
||||
run(`perfScene('${name}', 1024, 640);`);
|
||||
const hash = crypto.createHash('sha256');
|
||||
for (const name of ['mat', 'temp', 'flags', 'part', 'awake', 'hot'])
|
||||
hash.update(run(`Buffer.from(${name}.buffer)`));
|
||||
hash.update(run('JSON.stringify([RS, WS, stats])'));
|
||||
return hash.digest('hex');
|
||||
}
|
||||
const first = fixture('factory');
|
||||
check('factory fixture repeats exactly', fixture('factory') === first);
|
||||
run('for (let k = 0; k < 600; k++) simTick();');
|
||||
check('factory fixture repeats after a warmed simulation', fixture('factory') === first);
|
||||
const moved = run('stats.moved');
|
||||
run('for (let k = 0; k < 240; k++) simTick();');
|
||||
check('factory still transports grains after four seconds', run('stats.moved') > moved + 1000);
|
||||
check('both factory feeds remain active', run('machines.filter(m => m && m.type === "silo" && tick - m.pourT < 10).length') === 2);
|
||||
check('factory has fuel remaining in both silos', run('machines.filter(m => m && m.type === "silo" && m.sn > 0).length') === 2);
|
||||
const heat = fixture('heat');
|
||||
fixture('settled');
|
||||
check('heat fixture repeats after another scene', fixture('heat') === heat);
|
||||
done();
|
||||
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 };
|
||||
}
|
||||
+210
-32
@@ -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 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
|
||||
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;
|
||||
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 };
|
||||
}''')
|
||||
print(res)
|
||||
b.close()
|
||||
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