From b6098e3695932e2c5928755afa9d56e8d2e226c8 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Fri, 10 Jul 2026 23:15:07 +0200 Subject: [PATCH] perf(harness): add 2D-highway frame-time measurement + capture the R3c gate (H0) (#848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(harness): add 2D-highway frame-time measurement + capture the R3c gate (H0) scripts/perf-baseline.mjs gains a `--song` mode: it wraps requestAnimationFrame before any page script, TAGS the frames the highway actually painted (via highway.addDrawHook), starts playback, and reports draw-frame p50/p95/p99 over `--frames` seconds across `--runs` runs. Tagging is the point — ~half the rAF callbacks are other cheap loops (~0.1 ms); averaging them in would hide a renderer regression, so only draw frames are counted. This is the metric the plan says gates the highway.js split (R3c) but the harness never measured (it did server latency + boot + heap only, and the R0 numbers were against an empty library). docs/perf-baseline.md now records the pre-lift baseline on the Arcturus feedpak: p50 ~2.2 ms, p95 spread 2.7-3.2 ms across 3 runs. The H-container lift (next) re-runs this on the same box and must stay within noise. Maintainer/CI-only tooling; the existing no-`--song` run is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * perf(harness): close page on error + MD040 fence + CHANGELOG (CodeRabbit) - frameTimeOnce now closes its page in a finally, so a failing run doesn't leak the page until the final browser.close() (CodeRabbit). - fenced code block gets a bash language hint (MD040). - CHANGELOG mentions the new --song frame-time mode. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + docs/perf-baseline.md | 25 +++++++++++ scripts/perf-baseline.mjs | 91 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a301a03..5de4133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 next root-level module can't ship broken. ### Added +- **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`. - **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along. - **`routers/` — the first extracted route module (R3).** The five audio-effects mapping endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a diff --git a/docs/perf-baseline.md b/docs/perf-baseline.md index a84efdb..5883fd5 100644 --- a/docs/perf-baseline.md +++ b/docs/perf-baseline.md @@ -37,6 +37,31 @@ the hooks, they just need real songs in `DLC_DIR`. ## Results +### R3c pre-lift baseline — 2026-07-10 (2D highway draw cost) + +The gate for the `highway.js` split. Captured on a seeded library (the 33 MB +Arcturus feedpak) with the new `--song` mode, which measures **per-frame draw +cost** — rAF callbacks are tagged via `highway.addDrawHook`, so only frames the +highway actually painted count (the other ~half are cheap no-op loops that would +otherwise mask a regression). Any `highway.js` change must re-run this on the +same machine and stay within noise of these numbers. + +```bash +node scripts/perf-baseline.mjs --base http://127.0.0.1:8300 \ + --song "Arcturus - The Sham Mirrors - Kinetic.feedpak" +``` + +| run | draw frames | p50 | p95 | p99 | max | +|---|---|---|---|---|---| +| 1 | 53/106 | 2.2 | 3.2 | 3.6 | 3.6 | +| 2 | 50/100 | 2.2 | 2.9 | 3.2 | 3.2 | +| 3 | 53/106 | 2.1 | 2.7 | 3.5 | 3.5 | + +**p50 ≈ 2.2 ms · p95 spread 2.7–3.2 ms** (3 runs × 10 s playback, headless +chromium on the dev box). The `H`-container lift changes each closure-slot read +to a `H.` property load; this is the number that proves it doesn't cost the +hot loop. + ### R0 baseline — 2026-07-08 (branch `feat/r0-plugin-module-rails`) > ⚠️ A quick capture (`--n 50 --soak 8`) against an **empty** library (no charts diff --git a/scripts/perf-baseline.mjs b/scripts/perf-baseline.mjs index 91378f9..92db68f 100644 --- a/scripts/perf-baseline.mjs +++ b/scripts/perf-baseline.mjs @@ -6,6 +6,16 @@ // // Usage: // node scripts/perf-baseline.mjs --base http://127.0.0.1:8000 [--n 60] [--soak 30] +// node scripts/perf-baseline.mjs --base http://127.0.0.1:8300 --song "Arcturus ….feedpak" +// +// With --song, it additionally measures the 2D highway's PER-FRAME DRAW cost: +// it wraps requestAnimationFrame before any page script runs, tags the frames +// in which the highway actually painted (via highway.addDrawHook), starts +// playback, and reports draw-frame p50/p95/p99 over --frames seconds. Tagging +// matters — roughly half the rAF callbacks belong to other cheap loops, and +// averaging them in hides a renderer regression behind ~0.1 ms no-op frames. +// This is the metric that gates the highway.js split (R3c); run it before AND +// after each highway change on the same machine. // // Maintainer/CI-only dev tooling (uses the committed @playwright/test browser); // never part of the serve or Docker path. Metrics that need a seeded library @@ -21,6 +31,9 @@ for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i].replac const BASE = args.get('base') || 'http://127.0.0.1:8000'; const N = parseInt(args.get('n') || '60', 10); const SOAK_S = parseInt(args.get('soak') || '30', 10); +const SONG = args.get('song') || null; +const FRAME_S = parseInt(args.get('frames') || '10', 10); +const RUNS = parseInt(args.get('runs') || '3', 10); // repeat frame sampling to show spread const pct = (xs, p) => { if (!xs.length) return null; @@ -69,6 +82,64 @@ async function clientMetrics() { return { bootMs, memStartMB: mem0 && mem0 / 1048576, memSoakMB: mem1 && mem1 / 1048576, scripts }; } +// ── 2D highway per-frame draw cost (needs --song + a seeded library) ────────── +async function frameTimeOnce(browser) { + const page = await browser.newPage(); + let f, t2, notes; + try { + // Wrap rAF before any page script; mark frames the highway actually drew. + await page.addInitScript(() => { + window.__f = []; + window.__drew = false; + const raf = window.requestAnimationFrame.bind(window); + window.requestAnimationFrame = (cb) => raf((t) => { + window.__drew = false; + const t0 = performance.now(); + try { cb(t); } finally { window.__f.push({ ms: performance.now() - t0, drew: window.__drew }); } + }); + }); + await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 }); + await page.evaluate(() => document.getElementById('v3-onboarding')?.remove()); + await page.evaluate((s) => window.playSong(s), SONG); + await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length >= 0 && window.highway?.getSongInfo?.(), + null, { timeout: 45000 }).catch(() => {}); + await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length > 0, null, { timeout: 45000 }); + await page.evaluate(() => window.highway.addDrawHook(() => { window.__drew = true; })); + // Start playback so draw() leaves its paused-throttle path; confirm the clock advances. + await page.evaluate(async () => { const a = window.highway.getAudioElement?.(); if (a) a.muted = true; await a?.play?.(); }); + await page.waitForTimeout(1500); + const t1 = await page.evaluate(() => window.highway.getTime()); + await page.evaluate(() => { window.__f.length = 0; }); + await page.waitForTimeout(FRAME_S * 1000); + ({ f, t2, notes } = await page.evaluate(() => ({ + f: window.__f.slice(), t2: window.highway.getTime(), notes: window.highway.getNotes().length, + }))); + if (!(t2 > t1 + 1)) throw new Error(`clock did not advance (${t1}→${t2}) — measured the paused path`); + } finally { + // Always close, even when an await above throws — otherwise a failing + // run leaks its page until the final browser.close(). + await page.close(); + } + const drew = f.filter((x) => x.drew).map((x) => x.ms); + return { drew, total: f.length, notes }; +} + +async function frameTime() { + const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] }); + const rows = []; + for (let i = 0; i < RUNS; i++) { + try { + const r = await frameTimeOnce(browser); + rows.push({ + p50: pct(r.drew, 50), p95: pct(r.drew, 95), p99: pct(r.drew, 99), + max: Math.max(...r.drew), n: r.drew.length, total: r.total, notes: r.notes, + }); + } catch (e) { rows.push({ error: String(e.message || e) }); } + } + await browser.close(); + return rows; +} + const server = await serverLatency([ '/api/version', '/api/plugins', @@ -86,9 +157,21 @@ out += `| Cold boot → networkidle | ${client.bootMs} ms |\n`; out += `| JS heap after load | ${client.memStartMB ? client.memStartMB.toFixed(1) + ' MB' : '—'} |\n`; out += `| JS heap after ${SOAK_S}s idle soak | ${client.memSoakMB ? client.memSoakMB.toFixed(1) + ' MB' : '—'} |\n`; out += `| Plugin scripts injected | ${client.scripts} |\n`; -out += `\n> **Requires a seeded library** (not captured by this run): playback frame-time p95\n`; -out += `> on the 2D + 3D highway, and screen-entry (plugin inject → interactive) for\n`; -out += `> editor / notedetect / highway_3d with a real chart loaded. Run this harness\n`; -out += `> against an environment with charts in \`DLC_DIR\` to fill those in.\n`; +if (SONG) { + const frames = await frameTime(); + out += `\n### 2D highway per-frame draw cost — \`${SONG}\` (${RUNS} runs × ${FRAME_S}s)\n\n`; + out += `| run | draw frames | p50 | p95 | p99 | max |\n|---|---|---|---|---|---|\n`; + for (let i = 0; i < frames.length; i++) { + const r = frames[i]; + if (r.error) { out += `| ${i + 1} | — | \`${r.error}\` | | | |\n`; continue; } + out += `| ${i + 1} | ${r.n}/${r.total} | ${ms(r.p50)} | ${ms(r.p95)} | ${ms(r.p99)} | ${ms(r.max)} |\n`; + } + const p95s = frames.filter((r) => !r.error).map((r) => r.p95); + if (p95s.length) out += `\n**p95 spread across runs: ${ms(Math.min(...p95s))}–${ms(Math.max(...p95s))} ms**\n`; +} else { + out += `\n> **Requires a seeded library** (not captured by this run): playback frame-time p95\n`; + out += `> on the 2D highway — pass \`--song ""\` to capture it.\n`; + out += `> (3D highway_3d + screen-entry timings are a separate R4 concern.)\n`; +} console.log(out);