mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-20 03:41:28 +00:00
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com>
178 lines
8.7 KiB
JavaScript
178 lines
8.7 KiB
JavaScript
// Perf-baseline harness for the module-migration refactor (R0).
|
||
//
|
||
// Rerun this after every phase (R0 → R3c) to prove the split does not regress
|
||
// screen-entry, frame-time, memory, or server latency. It writes a markdown
|
||
// results block to stdout; paste it into docs/perf-baseline.md (or redirect).
|
||
//
|
||
// 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
|
||
// with charts (playback frame-time, screen-entry into a live highway) are
|
||
// clearly labelled — run those against an environment with real songs.
|
||
|
||
import { createRequire } from 'module';
|
||
const require = createRequire(import.meta.url);
|
||
const { chromium } = require('@playwright/test');
|
||
|
||
const args = new Map();
|
||
for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i].replace(/^--/, ''), process.argv[i + 1]);
|
||
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;
|
||
const s = [...xs].sort((a, b) => a - b);
|
||
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
|
||
};
|
||
const ms = (x) => (x == null ? '—' : `${x.toFixed(1)}`);
|
||
|
||
// ── Server latency: p50/p95/p99 over N requests per endpoint ──────────────────
|
||
async function serverLatency(paths) {
|
||
const rows = [];
|
||
for (const path of paths) {
|
||
const t = [];
|
||
let status = 0;
|
||
for (let i = 0; i < N; i++) {
|
||
const t0 = performance.now();
|
||
try {
|
||
const r = await fetch(BASE + path);
|
||
status = r.status;
|
||
await r.arrayBuffer();
|
||
} catch { status = -1; }
|
||
t.push(performance.now() - t0);
|
||
}
|
||
rows.push({ path, status, p50: pct(t, 50), p95: pct(t, 95), p99: pct(t, 99) });
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
// ── Client: cold boot-to-interactive + idle memory after a soak ───────────────
|
||
async function clientMetrics() {
|
||
const browser = await chromium.launch();
|
||
const page = await browser.newPage();
|
||
const t0 = Date.now();
|
||
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 });
|
||
const bootMs = Date.now() - t0;
|
||
|
||
// performance.memory is Chromium-only; JS heap after settle.
|
||
const mem0 = await page.evaluate(() => (performance.memory ? performance.memory.usedJSHeapSize : null));
|
||
await page.waitForTimeout(SOAK_S * 1000);
|
||
const mem1 = await page.evaluate(() => (performance.memory ? performance.memory.usedJSHeapSize : null));
|
||
|
||
const scripts = await page.evaluate(() =>
|
||
document.querySelectorAll('script[data-plugin-id]').length);
|
||
|
||
await browser.close();
|
||
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',
|
||
'/api/library?limit=60',
|
||
'/api/library/artists',
|
||
]);
|
||
const client = await clientMetrics();
|
||
|
||
const now = new Date().toISOString();
|
||
let out = `\n<!-- generated by scripts/perf-baseline.mjs @ ${now} against ${BASE} (n=${N}, soak=${SOAK_S}s) -->\n\n`;
|
||
out += `### Server latency (ms)\n\n| Endpoint | status | p50 | p95 | p99 |\n|---|---|---|---|---|\n`;
|
||
for (const r of server) out += `| \`${r.path}\` | ${r.status} | ${ms(r.p50)} | ${ms(r.p95)} | ${ms(r.p99)} |\n`;
|
||
out += `\n### Client\n\n| Metric | Value |\n|---|---|\n`;
|
||
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`;
|
||
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 "<filename in DLC_DIR>"\` to capture it.\n`;
|
||
out += `> (3D highway_3d + screen-entry timings are a separate R4 concern.)\n`;
|
||
}
|
||
|
||
console.log(out);
|