pcbjam/tests/kicad/pcbnew-large-perf.spec.ts
Viktor Vaczi e652810de7 bench: jetson FPS battery — both backends saturate at 80.9 MB scale
Parameterizes the throttle-sweep FPS battery (fpsBattery helper) and adds a
jetson-agx-thor run: asyncify raf 2.1-5.3 / distinct 0.2-0.8 vs JSPI raf
1.9-5.0 / distinct 0.1-0.7 — indistinguishable. Suspension overhead is a
per-event-loop-turn cost; at hundreds of ms of GAL work per frame it stops
discriminating. vme-wren (~27 MB) remains the discriminating size class.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016X9eh1s5sTx1o9Em9KBuwR
2026-08-14 12:07:18 +02:00

203 lines
8.5 KiB
TypeScript

import { test, expect } from './fixtures';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import {
measureLoad,
measureOpenRender,
openAndWait,
measureFpsDetailed,
setThrottle,
sampleMemory,
startHeapPeakSampler,
stopHeapPeakSampler,
getWasmResourceTiming,
} from './utils/perf-utils';
import { fetchIntoMemfs } from './utils/fs-inject';
import { waitForBoardLoaded } from './utils/board-ready';
/**
* Large-board perf battery (TRACK-ONLY, bench-driven). Extends pcbnew-perf with
* the measurements the JSPI-vs-asyncify comparison needs: repeated cold loads,
* open+render of a REAL large board (vme-wren: 1508 footprints / 24 858
* segments), rAF + distinct-frame FPS under CPU throttle, and wasm-heap /
* JS-heap checkpoints. Results append to
* tests/test-results/perf-bench-<arm>.ndjson (gitignored), one JSON row per
* measurement, each row carrying the sha256 of the wasm actually measured so
* A/B artifact swaps can't get misattributed.
*
* Gated behind PERF_LARGE=1 so the CI perf project (which matches
* *-perf.spec.ts) is unaffected. Fixtures are expected in
* tests/apps/kicad/board/ (gitignored) — see docs/features/async/
* migration-evidence/jspi-vs-asyncify-bench-2026-08.md for the bench flow.
*/
const ARM = process.env.BENCH_ARM || 'current';
const LOAD_RUNS = parseInt(process.env.PERF_LOAD_RUNS || '5', 10);
const OPEN_RUNS = parseInt(process.env.PERF_OPEN_RUNS || '3', 10);
const THROTTLES = (process.env.PERF_THROTTLES || '1,4,6').split(',').map(Number);
const FPS_SECS = parseInt(process.env.PERF_FPS_SECS || '6', 10);
const FPS_REPS = parseInt(process.env.PERF_FPS_REPS || '2', 10);
const APPS_KICAD = path.join(__dirname, '..', 'apps', 'kicad');
const DEMO = path.join(__dirname, '..', 'fixtures', 'demo', 'demo.kicad_pcb');
const VME_URL = '/kicad/board/vme-wren.kicad_pcb';
const JETSON_URL = '/kicad/board/jetson-agx-thor-baseboard.kicad_pcb';
// NOT under test-results/ — Playwright clears that whole dir at session start,
// so an A/B pair of invocations would each wipe the other arm's rows.
const RESULTS = path.join(__dirname, '..', 'bench-results', `perf-bench-${ARM}.ndjson`);
let wasmSha = '';
function wasmSha256(): string {
if (!wasmSha) {
const bytes = fs.readFileSync(path.join(APPS_KICAD, 'kicad_editor.wasm'));
wasmSha = crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 16);
}
return wasmSha;
}
function record(section: string, data: Record<string, unknown>): void {
fs.mkdirSync(path.dirname(RESULTS), { recursive: true });
const row = { arm: ARM, sha256: wasmSha256(), when: new Date().toISOString(), section, ...data };
fs.appendFileSync(RESULTS, JSON.stringify(row) + '\n');
// eslint-disable-next-line no-console
console.log(`[bench] ${section}: ${JSON.stringify(data)}`);
}
test.describe('pcbnew large-board bench', () => {
test.skip(!process.env.PERF_LARGE, 'bench battery — run with PERF_LARGE=1');
for (let run = 0; run < LOAD_RUNS; run++) {
test(`cold load #${run + 1}`, async ({ page }) => {
test.setTimeout(300000);
const loadMs = await measureLoad(page, '/kicad/pcbnew.html');
const wasmFetch = await getWasmResourceTiming(page);
const mem = await sampleMemory(page);
record('load', { run: run + 1, loadMs, wasmFetch, bootMem: mem });
expect(loadMs).toBeGreaterThan(0);
});
}
test('cold load with CDP pre-attached (tier-down sanity)', async ({ page }) => {
test.setTimeout(300000);
const cdp = await page.context().newCDPSession(page);
await setThrottle(cdp, 1); // attach + a no-op emulation command, like the FPS path
const loadMs = await measureLoad(page, '/kicad/pcbnew.html');
record('load-cdp-sanity', { loadMs });
expect(loadMs).toBeGreaterThan(0);
});
for (let run = 0; run < OPEN_RUNS; run++) {
test(`open demo board #${run + 1}`, async ({ page, testLogger }) => {
test.setTimeout(300000);
await measureLoad(page, '/kicad/pcbnew.html');
const openMs = await measureOpenRender(page, DEMO, 'board', testLogger);
const mem = await sampleMemory(page);
record('open-demo', { run: run + 1, openMs, postOpenMem: mem });
expect(openMs).toBeGreaterThan(0);
});
}
for (let run = 0; run < OPEN_RUNS; run++) {
test(`open vme-wren (27.7 MB) #${run + 1}`, async ({ page, testLogger }) => {
test.setTimeout(600000);
await measureLoad(page, '/kicad/pcbnew.html');
const bytes = await fetchIntoMemfs(page, VME_URL, '/home/kicad/documents/vme-wren.kicad_pcb');
await startHeapPeakSampler(page);
const openMs = await openAndWait(
page,
'/home/kicad/documents/vme-wren.kicad_pcb',
'board',
testLogger,
480000,
);
const peak = await stopHeapPeakSampler(page);
const mem = await sampleMemory(page);
record('open-vme', { run: run + 1, bytes, openMs, openPeakHeap: peak, postOpenMem: mem });
expect(openMs).toBeGreaterThan(0);
});
}
// Shared throttle-sweep FPS battery: load editor, fetch+open the board,
// then rAF + distinct-glcanvas-frame FPS at each throttle rate.
async function fpsBattery(
page: Parameters<Parameters<typeof test>[2]>[0]['page'],
testLogger: { consoleLogs: string[]; errors: string[] },
boardUrl: string,
memfsPath: string,
section: string,
): Promise<void> {
await measureLoad(page, '/kicad/pcbnew.html');
await fetchIntoMemfs(page, boardUrl, memfsPath);
await openAndWait(page, memfsPath, 'board', testLogger, 780000);
await page.keyboard.press('Escape').catch(() => {}); // eslint-disable-line -- best-effort Escape
const cdp = await page.context().newCDPSession(page);
for (const rate of THROTTLES) {
await setThrottle(cdp, rate);
for (let rep = 0; rep < FPS_REPS; rep++) {
const f = await measureFpsDetailed(page, FPS_SECS);
record(section, { throttle: rate, rep: rep + 1, ...f });
expect(f.rafFps).toBeGreaterThan(0);
}
}
await setThrottle(cdp, 1);
const mem = await sampleMemory(page);
record(`${section}-postmem`, { postFpsMem: mem });
}
test('FPS on vme-wren across throttles', async ({ page, testLogger }) => {
test.setTimeout(900000);
await fpsBattery(page, testLogger, VME_URL, '/home/kicad/documents/vme-wren.kicad_pcb', 'fps-vme');
});
test('FPS on jetson-agx-thor across throttles', async ({ page, testLogger }) => {
test.setTimeout(900000);
await fpsBattery(
page,
testLogger,
JETSON_URL,
'/home/kicad/documents/jetson-agx-thor-baseboard.kicad_pcb',
'fps-jetson',
);
});
test('open jetson-agx-thor (80.9 MB) — outcome, OOM allowed', async ({ page, testLogger }) => {
test.setTimeout(900000);
await measureLoad(page, '/kicad/pcbnew.html');
const bytes = await fetchIntoMemfs(
page,
JETSON_URL,
'/home/kicad/documents/jetson-agx-thor-baseboard.kicad_pcb',
);
await startHeapPeakSampler(page);
const t0 = Date.now();
try {
await page.evaluate(() => {
(window as unknown as { Module: { kicadOpenFile(p: string): unknown } }).Module.kicadOpenFile(
'/home/kicad/documents/jetson-agx-thor-baseboard.kicad_pcb',
);
});
await waitForBoardLoaded(page, testLogger, 780000);
const peak = await stopHeapPeakSampler(page);
const mem = await sampleMemory(page);
record('open-jetson', {
bytes,
outcome: 'loaded',
openMs: Date.now() - t0,
openPeakHeap: peak,
postOpenMem: mem,
});
} catch (e) {
// A 4 GB-cap OOM / abort is a RESULT for this stress tier, not a harness error.
const peak = await stopHeapPeakSampler(page).catch(() => -1);
record('open-jetson', {
bytes,
outcome: 'failed',
afterMs: Date.now() - t0,
openPeakHeap: peak,
error: String(e).slice(0, 300),
});
}
});
});