From da299ed6f9b0d16d746883e3d3498fc510786ba9 Mon Sep 17 00:00:00 2001 From: Viktor Vaczi Date: Fri, 14 Aug 2026 11:53:06 +0200 Subject: [PATCH] =?UTF-8?q?bench:=20JSPI=20vs=20asyncify=20A/B=20=E2=80=94?= =?UTF-8?q?=20harness=20+=20results?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds pcbnew-large-perf.spec.ts (PERF_LARGE-gated: repeated cold loads, vme-wren/jetson opens, rAF + distinct-glcanvas-frame FPS under throttle, wasm/JS heap checkpoints), fetchIntoMemfs + openAndWait/sampleMemory/ measureFpsDetailed perf-utils, dual 9.99+10.0 config seeding in pcbnew.html so foreign-branch builds boot wizard-free, and the full benchmark report + raw data under docs/features/async/migration-evidence/. Headlines: wasm 94 vs 113 MB raw (18.6 vs 36.7 MB gzip), post-link tail 1.6 s/49 MB vs 63 s/6.1 GB per build, cold load −40 %, 27.7 MB board open −45 %, real redraws +68 % at 4× throttle, boot heap −31 %. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016X9eh1s5sTx1o9Em9KBuwR --- .gitignore | 1 + docs/features/async/23-jspi-runtime.md | 4 + .../build-summary-asyncify.csv | 6 + .../bench-data-2026-08/build-summary-jspi.csv | 6 + .../bench-data-2026-08/bundle-sizes.json | 66 ++++++ .../perf-bench-asyncify.ndjson | 20 ++ .../bench-data-2026-08/perf-bench-jspi.ndjson | 27 +++ .../jspi-vs-asyncify-bench-2026-08.md | 199 ++++++++++++++++++ tests/apps/kicad/pcbnew.html | 25 ++- tests/kicad/pcbnew-large-perf.spec.ts | 180 ++++++++++++++++ tests/kicad/utils/fs-inject.ts | 30 +++ tests/kicad/utils/perf-utils.ts | 154 +++++++++++++- 12 files changed, 707 insertions(+), 11 deletions(-) create mode 100644 docs/features/async/migration-evidence/bench-data-2026-08/build-summary-asyncify.csv create mode 100644 docs/features/async/migration-evidence/bench-data-2026-08/build-summary-jspi.csv create mode 100644 docs/features/async/migration-evidence/bench-data-2026-08/bundle-sizes.json create mode 100644 docs/features/async/migration-evidence/bench-data-2026-08/perf-bench-asyncify.ndjson create mode 100644 docs/features/async/migration-evidence/bench-data-2026-08/perf-bench-jspi.ndjson create mode 100644 docs/features/async/migration-evidence/jspi-vs-asyncify-bench-2026-08.md create mode 100644 tests/kicad/pcbnew-large-perf.spec.ts diff --git a/.gitignore b/.gitignore index 2507383..54309e7 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ wxwidgets-clean/ /tests/node_modules/ /tests/playwright-report/ /tests/test-results/ +/tests/bench-results/ /tests/pw-artifacts/ /tests/.test-port /test-results/ diff --git a/docs/features/async/23-jspi-runtime.md b/docs/features/async/23-jspi-runtime.md index 78c30e3..80023c2 100644 --- a/docs/features/async/23-jspi-runtime.md +++ b/docs/features/async/23-jspi-runtime.md @@ -216,6 +216,10 @@ same-origin serving works. Not fixed in the cleanup — tracked here. ## 10. Migration evidence +- **A/B benchmark vs the asyncify build** (2026-08-14): build time / build + memory / bundle size / load / open / FPS / heap — + [`migration-evidence/jspi-vs-asyncify-bench-2026-08.md`](migration-evidence/jspi-vs-asyncify-bench-2026-08.md) + (raw data in `migration-evidence/bench-data-2026-08/`). - **Workflow results**: `migration-evidence/wf-result-11.json` / `wf-result-12.json` (the durable spike output; the rest of the `.jspi-assets/` spike tree was scratch and is gone — its ignore rule came diff --git a/docs/features/async/migration-evidence/bench-data-2026-08/build-summary-asyncify.csv b/docs/features/async/migration-evidence/bench-data-2026-08/build-summary-asyncify.csv new file mode 100644 index 0000000..ff94020 --- /dev/null +++ b/docs/features/async/migration-evidence/bench-data-2026-08/build-summary-asyncify.csv @@ -0,0 +1,6 @@ +A-S1a,175,0,19584995328 +A-S1b,427,0,22525415424 +A-S2a,165,0,21735530496 +A-S2b,248,0,23429591040 +A-compileonly,91,0,23662247936 +A-S3,819,0,23760617472 diff --git a/docs/features/async/migration-evidence/bench-data-2026-08/build-summary-jspi.csv b/docs/features/async/migration-evidence/bench-data-2026-08/build-summary-jspi.csv new file mode 100644 index 0000000..669b52f --- /dev/null +++ b/docs/features/async/migration-evidence/bench-data-2026-08/build-summary-jspi.csv @@ -0,0 +1,6 @@ +B-S1a,379,0,20131172352 +B-S1b,154,0,20515237888 +B-S2a,122,0,20695916544 +B-S2b,95,0,20612022272 +B-S3,26,2,4479885312 +B-S3,305,0,20694478848 diff --git a/docs/features/async/migration-evidence/bench-data-2026-08/bundle-sizes.json b/docs/features/async/migration-evidence/bench-data-2026-08/bundle-sizes.json new file mode 100644 index 0000000..ba95d3c --- /dev/null +++ b/docs/features/async/migration-evidence/bench-data-2026-08/bundle-sizes.json @@ -0,0 +1,66 @@ +{ + "jspi": { + "kicad_editor.wasm": { + "raw": 98664038, + "gzip": 19469712, + "brotli": 14173019 + }, + "kicad_editor.js": { + "raw": 474692, + "gzip": 116955, + "brotli": 106125 + }, + "wx.js": { + "raw": 74281, + "gzip": 17356, + "brotli": 16439 + }, + "wx-dom.js": { + "raw": 63269, + "gzip": 16857, + "brotli": 15848 + }, + "images.tar.gz": { + "raw": 5370904, + "gzip": 5372562, + "brotli": 5114985 + }, + "TOTAL": { + "raw": 104647184, + "gzip": 24993442, + "brotli": 19426416 + } + }, + "asyncify": { + "kicad_editor.wasm": { + "raw": 118557371, + "gzip": 38473650, + "brotli": 25666194 + }, + "kicad_editor.js": { + "raw": 472325, + "gzip": 116872, + "brotli": 105662 + }, + "wx.js": { + "raw": 74281, + "gzip": 17356, + "brotli": 16439 + }, + "wx-dom.js": { + "raw": 63041, + "gzip": 16773, + "brotli": 15765 + }, + "images.tar.gz": { + "raw": 5370145, + "gzip": 5371803, + "brotli": 5160999 + }, + "TOTAL": { + "raw": 124537163, + "gzip": 43996454, + "brotli": 30965059 + } + } +} diff --git a/docs/features/async/migration-evidence/bench-data-2026-08/perf-bench-asyncify.ndjson b/docs/features/async/migration-evidence/bench-data-2026-08/perf-bench-asyncify.ndjson new file mode 100644 index 0000000..7fe0e5a --- /dev/null +++ b/docs/features/async/migration-evidence/bench-data-2026-08/perf-bench-asyncify.ndjson @@ -0,0 +1,20 @@ +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:46:53.665Z","section":"load","run":1,"loadMs":2629,"wasmFetch":{"durationMs":1053,"transferSize":32139352},"bootMem":{"wasmHeapBytes":556793856,"jsHeapBytes":27600000}} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:46:56.364Z","section":"load","run":2,"loadMs":2610,"wasmFetch":{"durationMs":961,"transferSize":32139352},"bootMem":{"wasmHeapBytes":556793856,"jsHeapBytes":27600000}} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:46:59.036Z","section":"load","run":3,"loadMs":2576,"wasmFetch":{"durationMs":989,"transferSize":32139352},"bootMem":{"wasmHeapBytes":556793856,"jsHeapBytes":27600000}} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:47:01.667Z","section":"load","run":4,"loadMs":2526,"wasmFetch":{"durationMs":987,"transferSize":32139352},"bootMem":{"wasmHeapBytes":556793856,"jsHeapBytes":27600000}} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:47:04.272Z","section":"load","run":5,"loadMs":2521,"wasmFetch":{"durationMs":997,"transferSize":32139352},"bootMem":{"wasmHeapBytes":556793856,"jsHeapBytes":27600000}} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:47:06.873Z","section":"load-cdp-sanity","loadMs":2514} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:47:09.937Z","section":"open-demo","run":1,"openMs":467,"postOpenMem":{"wasmHeapBytes":556793856,"jsHeapBytes":33100000}} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:47:12.982Z","section":"open-demo","run":2,"openMs":468,"postOpenMem":{"wasmHeapBytes":556793856,"jsHeapBytes":33100000}} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:47:16.025Z","section":"open-demo","run":3,"openMs":467,"postOpenMem":{"wasmHeapBytes":556793856,"jsHeapBytes":33100000}} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:47:25.126Z","section":"open-vme","run":1,"bytes":29032252,"openMs":6465,"openPeakHeap":962330624,"postOpenMem":{"wasmHeapBytes":962330624,"jsHeapBytes":56800000}} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:47:34.418Z","section":"open-vme","run":2,"bytes":29032252,"openMs":6582,"openPeakHeap":962330624,"postOpenMem":{"wasmHeapBytes":962330624,"jsHeapBytes":56800000}} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:47:43.512Z","section":"open-vme","run":3,"bytes":29032252,"openMs":6398,"openPeakHeap":1182793728,"postOpenMem":{"wasmHeapBytes":1182793728,"jsHeapBytes":56800000}} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:47:58.746Z","section":"fps-vme","throttle":1,"rep":1,"rafFps":44.5,"distinctFps":7.8} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:48:04.925Z","section":"fps-vme","throttle":1,"rep":2,"rafFps":49.1,"distinctFps":7.8} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:48:10.980Z","section":"fps-vme","throttle":4,"rep":1,"rafFps":32.4,"distinctFps":3} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:48:17.030Z","section":"fps-vme","throttle":4,"rep":2,"rafFps":37.6,"distinctFps":3.1} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:48:23.361Z","section":"fps-vme","throttle":6,"rep":1,"rafFps":39.9,"distinctFps":4.1} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:48:29.439Z","section":"fps-vme","throttle":6,"rep":2,"rafFps":34.3,"distinctFps":2.8} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:48:29.442Z","section":"fps-vme-postmem","postFpsMem":{"wasmHeapBytes":962330624,"jsHeapBytes":60300000}} +{"arm":"asyncify","sha256":"6dab493196d060d7","when":"2026-08-14T09:48:46.855Z","section":"open-jetson","bytes":84806775,"outcome":"loaded","openMs":14584,"openPeakHeap":1904214016,"postOpenMem":{"wasmHeapBytes":1904214016,"jsHeapBytes":116000000}} diff --git a/docs/features/async/migration-evidence/bench-data-2026-08/perf-bench-jspi.ndjson b/docs/features/async/migration-evidence/bench-data-2026-08/perf-bench-jspi.ndjson new file mode 100644 index 0000000..c1988d9 --- /dev/null +++ b/docs/features/async/migration-evidence/bench-data-2026-08/perf-bench-jspi.ndjson @@ -0,0 +1,27 @@ +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:43:04.217Z","section":"load","run":1,"loadMs":1841,"wasmFetch":{"durationMs":692,"transferSize":17051037},"bootMem":{"wasmHeapBytes":386662400,"jsHeapBytes":27600000}} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:43:05.422Z","section":"load","run":2,"loadMs":1117,"wasmFetch":{"durationMs":554,"transferSize":17051037},"bootMem":{"wasmHeapBytes":268435456,"jsHeapBytes":29400000}} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:43:07.469Z","section":"load","run":3,"loadMs":1547,"wasmFetch":{"durationMs":573,"transferSize":17051037},"bootMem":{"wasmHeapBytes":386662400,"jsHeapBytes":27600000}} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:43:09.081Z","section":"load","run":4,"loadMs":1123,"wasmFetch":{"durationMs":574,"transferSize":17051037},"bootMem":{"wasmHeapBytes":386662400,"jsHeapBytes":24500000}} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:43:10.697Z","section":"load","run":5,"loadMs":1538,"wasmFetch":{"durationMs":571,"transferSize":17051037},"bootMem":{"wasmHeapBytes":386662400,"jsHeapBytes":26000000}} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:43:11.887Z","section":"load-cdp-sanity","loadMs":1117} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:43:14.226Z","section":"open-demo","run":1,"openMs":698,"postOpenMem":{"wasmHeapBytes":386662400,"jsHeapBytes":31200000}} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:43:16.208Z","section":"open-demo","run":2,"openMs":693,"postOpenMem":{"wasmHeapBytes":386662400,"jsHeapBytes":31200000}} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:43:18.164Z","section":"open-demo","run":3,"openMs":358,"postOpenMem":{"wasmHeapBytes":386662400,"jsHeapBytes":31200000}} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:43:23.447Z","section":"open-vme","run":1,"bytes":29032252,"openMs":3551,"openPeakHeap":801898496,"postOpenMem":{"wasmHeapBytes":801898496,"jsHeapBytes":56800000}} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:43:28.639Z","section":"open-vme","run":2,"bytes":29032252,"openMs":3538,"openPeakHeap":801898496,"postOpenMem":{"wasmHeapBytes":801898496,"jsHeapBytes":60300000}} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:43:33.963Z","section":"open-vme","run":3,"bytes":29032252,"openMs":3635,"openPeakHeap":801898496,"postOpenMem":{"wasmHeapBytes":801898496,"jsHeapBytes":60300000}} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:43:45.276Z","section":"fps-vme","throttle":1,"rep":1,"rafFps":51.6,"distinctFps":1.6,"note":"SUPERSEDED - sampler watched #canvas, not glcanvas"} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:43:51.293Z","section":"fps-vme","throttle":1,"rep":2,"rafFps":53,"distinctFps":1.8,"note":"SUPERSEDED - sampler watched #canvas, not glcanvas"} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:43:57.442Z","section":"fps-vme","throttle":4,"rep":1,"rafFps":36,"distinctFps":0.7,"note":"SUPERSEDED - sampler watched #canvas, not glcanvas"} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:44:03.520Z","section":"fps-vme","throttle":4,"rep":2,"rafFps":36.2,"distinctFps":0,"note":"SUPERSEDED - sampler watched #canvas, not glcanvas"} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:44:09.587Z","section":"fps-vme","throttle":6,"rep":1,"rafFps":33,"distinctFps":0,"note":"SUPERSEDED - sampler watched #canvas, not glcanvas"} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:44:16.055Z","section":"fps-vme","throttle":6,"rep":2,"rafFps":33.7,"distinctFps":0,"note":"SUPERSEDED - sampler watched #canvas, not glcanvas"} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:44:16.057Z","section":"fps-vme-postmem","postFpsMem":{"wasmHeapBytes":801898496,"jsHeapBytes":60300000}} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:44:26.524Z","section":"open-jetson","bytes":84806775,"outcome":"loaded","openMs":8620,"openPeakHeap":1730805760,"postOpenMem":{"wasmHeapBytes":1730805760,"jsHeapBytes":116000000}} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:52:00.000Z","section":"fps-vme","throttle":1,"rep":1,"rafFps":51.9,"distinctFps":9.6,"note":"glcanvas sampler re-run; when approximate (row reconstructed from run log after test-results wipe)"} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:52:06.000Z","section":"fps-vme","throttle":1,"rep":2,"rafFps":55.7,"distinctFps":9.2,"note":"glcanvas sampler re-run; when approximate"} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:52:12.000Z","section":"fps-vme","throttle":4,"rep":1,"rafFps":39.2,"distinctFps":5.1,"note":"glcanvas sampler re-run; when approximate"} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:52:18.000Z","section":"fps-vme","throttle":4,"rep":2,"rafFps":41.4,"distinctFps":5.2,"note":"glcanvas sampler re-run; when approximate"} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:52:24.000Z","section":"fps-vme","throttle":6,"rep":1,"rafFps":34.3,"distinctFps":3.3,"note":"glcanvas sampler re-run; when approximate"} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:52:30.000Z","section":"fps-vme","throttle":6,"rep":2,"rafFps":41.8,"distinctFps":5.3,"note":"glcanvas sampler re-run; when approximate"} +{"arm":"jspi","sha256":"6f03e62d6d5619c3","when":"2026-08-14T09:52:30.100Z","section":"fps-vme-postmem","postFpsMem":{"wasmHeapBytes":801898496,"jsHeapBytes":64000000}} diff --git a/docs/features/async/migration-evidence/jspi-vs-asyncify-bench-2026-08.md b/docs/features/async/migration-evidence/jspi-vs-asyncify-bench-2026-08.md new file mode 100644 index 0000000..3d89b0f --- /dev/null +++ b/docs/features/async/migration-evidence/jspi-vs-asyncify-bench-2026-08.md @@ -0,0 +1,199 @@ +# JSPI vs Asyncify: editor benchmark (2026-08-14) + +A/B benchmark of the `kicad_editor` app built two ways: + +- **Arm A — asyncify**: `pcbjam` @ `29c61b8` (branch `staging`), emscripten **4.0.2**, + `-sASYNCIFY=1` + host post-link pipeline (finalize → hoist-cpp-catches → + `wasm-opt --asyncify` → `wasm-opt -O1`, Binaryen fork v130). +- **Arm B — JSPI**: worktree @ `9c475a8` (branch `experiment/jspi`), emscripten **6.0.6**, + `-sJSPI`, single-phase link, no post-link transform (only the ~1 s env-shim patch). + +**Stated confound, up front:** the two arms differ by emscripten major (4.0.2 vs +6.0.6) — libc/libc++/linker/JS-glue all moved with it. That skew is inseparable +from the backend switch (strict-JSPI needs emscripten 6); every number below is +"the shipped asyncify build vs the shipped JSPI build", not a pure +suspension-mechanism isolate. What IS matched: same KiCad commit (`4bfed3f1`), +wxWidgets 3.3.1, EH flags (`-fwasm-exceptions -sSUPPORT_LONGJMP=wasm +-sWASM_LEGACY_EXCEPTIONS=1`), mimalloc, memory config (256 MB initial / 4 GB +max), **DEBUG mode on both** (TU `-g -O1`, link `-O1 -g -gseparate-dwarf` — the +default), docker caps 10 CPU / 32 GB, `-j 10`, `BINARYEN_CORES=8`, +`HOIST_KEEP_NAMES` unset, runs serialized on an idle machine. + +Machine: Apple M4 Max, 16 cores, 64 GB; Docker Desktop VM 12 CPU / 28 GiB; +macOS 26.5.2. Raw data: [`bench-data-2026-08/`](bench-data-2026-08/). + +## TL;DR + +| Metric | Asyncify | JSPI | Δ | +|---|---|---|---| +| Editor wasm, raw | 113.1 MB | 94.1 MB | **−17 %** | +| Editor wasm, gzip −9 | 36.7 MB | 18.6 MB | **−49 %** | +| Editor wasm, brotli | 24.5 MB | 13.5 MB | **−45 %** | +| Post-link tail (per build!) | 63.4 s, 6.1 GB RSS | 1.6 s, 49 MB RSS | **−97 %** | +| Clean rebuild (warm ccache) | 165 s | 95 s | **−42 %** | +| Cold load (median of 5) | 2 576 ms | 1 538 ms | **−40 %** | +| Open vme-wren (27.7 MB board) | 6 465 ms | 3 551 ms | **−45 %** | +| Real redraws/s @1× (vme-wren) | 7.8 | 9.4 | **+21 %** | +| Real redraws/s @4× throttle | 3.1 | 5.2 | **+68 %** | +| Wasm heap after big-board open | 962 MB | 802 MB | **−17 %** | +| Jetson 80.9 MB board open | 14.6 s, peak 1.90 GB | 8.6 s, peak 1.73 GB | **−41 %** | + +The asyncify tax was real on every axis: bytes (instrumented code also +compresses ~2× worse), build time (a mandatory 6-GB-RSS host pass per link), +load (bigger download + more code to tier), compute (slower opens, fewer real +frames — the gap *widens* under CPU throttle, the doc-12 signature of +per-operation overhead rather than mere size), and memory. + +## 1. Build time + +Scenarios (identical commands both arms, run serially): **S1** no-change +rebuild (`./docker/build.sh kicad_editor -j 10` twice, 2nd reported), **S2** +clean editor rebuild with warm ccache (`--clean-kicad`, twice), **S3** clean +rebuild with `CCACHE_DISABLE=1` (manual `compose exec`, compile cost only). + +Both trees exhibit an **mtime ping-pong** in the wx re-enter (a reconfigure +regenerates files the next rsync restores → every other build pays a wx +reconfigure). Numbers are therefore given per observed mode; phase splits from +the `@KW@` markers. + +| Scenario | Asyncify | JSPI | +|---|---|---| +| S1 no-change, quiet mode | **175 s** (cfg 40 + link 40 + finalize 4 + **asyncify 58**) | **154 s** (cfg 56 + link 46, tail ≈0) | +| S1 no-change, wx-churn mode | 427 s (wx 89 + cfg 132 + compile 124 + tail 63) | 379 s (first run after container recreate) | +| S2 clean, warm ccache, quiet | **165 s** (1 819 TU in 29 s + tail 61) | **95 s** (1 819 TU in 26 s) | +| S2 clean, wx-churn mode | 248 s | 122 s | +| S3 clean, no ccache (kicad only) | **365 s** (cfg 154 + compile 211) | **304 s** (cfg 136 + compile 168) | +| Container compile-only, steady | 91 s | — (contained in S2) | + +Reading: the container-side compile is broadly comparable (emscripten 6 is +~15-20 % faster on the uncached compile); **the structural difference is the +host tail** — asyncify pays 58-63 s *on every single link*, JSPI pays ~1.6 s. +In the dev loop that is the difference between a ~2.5-minute and a ~4-minute +edit-to-browser cycle (or 7 min when the wx churn mode hits, which the tail +compounds). + +Note: A-S3's wall figure included a 453 s wx rebuild triggered by the bench's +own `touch` of the wx build Makefile (used to pin the wx skip path) — excluded +above; the kicad configure+compile figures are uncontaminated. + +## 2. Build memory + +- **Container peak** (cgroup v2 `memory.peak`, fresh cgroup per run): both arms + 18–24 GiB across all scenarios — dominated by the `-j 10` compile and the + DWARF-heavy `wasm-ld` link, essentially backend-independent. +- **Host post-link tail** (`/usr/bin/time -l`): + - Asyncify: **63.4 s wall, 307 s user, peak RSS 6.09 GiB** + (finalize ≈4 s + hoist+asyncify+`-O1` ≈58 s at `BINARYEN_CORES=8`). + Historical logs show 59–154 s and one OOM-killed build (exit 137). + - JSPI: **1.6 s wall, peak RSS 49 MB** (node env-shim patch). + +## 3. Bundle size + +Same-day builds, DEBUG mode, complete cold-load set +(`kicad_editor.{wasm,js}`, `wx.js`, `wx-dom.js`, `images.tar.gz`): + +| | raw | gzip −9 | brotli | +|---|---|---|---| +| Asyncify wasm | 118 557 371 B (113.1 MB) | 36.7 MB | 24.5 MB | +| JSPI wasm | 98 664 038 B (94.1 MB) | 18.6 MB | 13.5 MB | +| Asyncify total set | 118.8 MB | 42.0 MB | 29.5 MB | +| JSPI total set | 99.8 MB | 23.8 MB | 18.5 MB | + +The compressed delta (−49 % gzip) is much larger than the raw delta (−17 %): +asyncify's spilled-locals/branch-table instrumentation is high-entropy. The +over-the-wire cost of a cold editor load roughly **halves** under JSPI. +Side note: separate DWARF is 2.27 GB vs 1.91 GB (−16 %); dev-tools-only fetch. + +Other apps (existing artifacts, not rebuilt today): pl_editor −19 %, +gerbview −19 %, calculator −26 %, occ_service −1 % (never asyncified), +ngspice_service 0 %. + +## 4. Runtime + +One harness for both arms: the jspi tree's Playwright 1.62.1 `perf` project +(bundled Chromium, no JSPI-specific launch flags), `tests/kicad/ +pcbnew-large-perf.spec.ts`, artifact sets swapped in `tests/apps/kicad/` with +sha256 recorded per row (jspi `6f03e62d…`, asyncify `6dab4931…`). The harness +page seeds KiCad config for both settings versions (9.99 + 10.0) so the +asyncify binary boots wizard-free. Local static server serves wasm gzipped +(hence `transferSize` ≈ the gzip column above). Headless; per doc-12, headless +FPS is a comparative indicator, not an absolute. + +**Cold load** (fresh context ×5, navigation → fully-ready editor): + +| | Asyncify | JSPI | +|---|---|---| +| loadMs median (range) | 2 576 (2 521–2 629) | 1 538 (1 117–1 841) | +| wasm fetch (gzipped wire) | ~990 ms / 32.1 MB | ~570 ms / 17.1 MB | +| wasm heap at boot | 556.8 MB | 386.7 MB | + +CDP-attach sanity: loadMs with a CDP session pre-attached fell inside each +arm's normal range (2 514 / 1 117 ms) — no tier-down artifact from the +throttling channel. + +**Board open** (`Module.kicadOpenFile`, ×3 each): + +| Board | Asyncify | JSPI | +|---|---|---| +| demo (155 KB, 15 fp) | 467 ms | 358–698 ms (par — too small to discriminate) | +| vme-wren (27.7 MB, 1 508 fp, 24 858 seg) | **6 465 ms** median, peak heap 962–1 183 MB | **3 551 ms** median, peak heap 802 MB | +| jetson-agx-thor (80.9 MB, 1 125 fp) | loaded, **14 584 ms**, peak 1.90 GB | loaded, **8 620 ms**, peak 1.73 GB | + +Neither arm OOMs even on the 80.9 MB board — both stay well under the 4 GB cap. + +**Interaction FPS** on vme-wren (Lissajous pan + wheel zoom, 6 s × 2 reps; +`rafFps` = main-thread rAF; `distinctFps` = distinct GAL glcanvas frames at a +~30 Hz pixel-hash sampler — the honest "real redraws" number, since rAF keeps +vsync-ticking when the GAL skips): + +| Throttle | Asyncify raf / distinct | JSPI raf / distinct | +|---|---|---| +| 1× | 44.5–49.1 / **7.8** | 51.9–55.7 / **9.2–9.6** | +| 4× | 32.4–37.6 / **3.0–3.1** | 39.2–41.4 / **5.1–5.2** | +| 6× | 34.3–39.9 / **2.8–4.1** | 34.3–41.8 / **3.3–5.3** | + +The distinct-frame gap grows from +21 % at 1× to +68 % at 4× — the same +"advantage widens under throttle" signature doc-12 used to prove lower +CPU-per-operation (as opposed to just a smaller module). + +**Memory checkpoints** (wasm linear memory; Chromium `usedJSHeapSize` tracked +alongside, differences <10 %): boot 557 vs 387 MB; after vme-wren open 962 vs +802 MB; unchanged after the FPS sweep on both arms. + +## 5. Methodology notes & gotchas + +- Asyncify arm reused the warm `kicad-wasm-main` compose volume (62 GB); its + branch-default `kicad-wasm-staging` volume is a cold stub — running without + `COMPOSE_PROJECT_NAME=kicad-wasm-main` would have benchmarked a multi-hour + cold dep build. The other session's tree was verified idle before/after and + `git status` byte-identical. +- Container `memory.peak` read from a per-run fresh cgroup (`compose stop` + between runs, `KICAD_KEEP_CONTAINER=1` so the read happens before teardown), + plus a 5 s `docker stats` sampler (CSV in bench-data). +- The first S3 attempt died in wx's PCRE `aclocal` regen (manual `compose + exec` skips build.sh's rsync, which perturbs the wx reconfigure check); fixed + by pre-touching the wx build Makefile — which on arm A then triggered the wx + rebuild noted in §1. Symmetric procedure both arms. +- The jspi-arm FPS rows in the archived ndjson appear twice: the first pass + sampled the static `#canvas` (distinct ≈ 0, marked SUPERSEDED); the glcanvas + re-run is authoritative and matches what `perf-utils.measureFpsDetailed` now + does. Arm A ran entirely with the fixed sampler. +- Playwright clears `tests/test-results/` per invocation — it ate the arm B + ndjson during the arm A run (reconstructed from the run logs, two timestamps + approximate). The spec now writes to `tests/bench-results/` instead. +- The live dev app at :3048 was kept on the JSPI build throughout (its + `public/wasm` symlink pointed at a stash during the swap window) and + verified serving the JSPI wasm afterwards. + +## 6. Reproduce + +```bash +# build side (each arm, serialized; see §1 for scenario commands) +KICAD_NO_MONITOR=1 KICAD_KEEP_CONTAINER=1 ./docker/build.sh kicad_editor -j 10 + +# runtime side (from tests/, artifacts staged in tests/apps/kicad/) +cp kicad/qa/data/pcbnew/vme-wren.kicad_pcb tests/apps/kicad/board/ +cp kicad/demos/jetson-agx-thor-baseboard/jetson-agx-thor-baseboard.kicad_pcb tests/apps/kicad/board/ +BENCH_ARM= PERF_LARGE=1 npx playwright test --project=perf pcbnew-large-perf.spec.ts --workers=1 +# → tests/bench-results/perf-bench-.ndjson +``` diff --git a/tests/apps/kicad/pcbnew.html b/tests/apps/kicad/pcbnew.html index ec1874c..be7c3c8 100644 --- a/tests/apps/kicad/pcbnew.html +++ b/tests/apps/kicad/pcbnew.html @@ -130,23 +130,28 @@ // Seed a minimal default config before main() so STARTWIZARD finds // NeedsUserInput()==false and the first-run setup wizard never opens — - // same as eeschema.html / pl_editor.html. + // same as eeschema.html / pl_editor.html. Seeded for BOTH the current + // (10.0) and previous (9.99) settings versions: the perf-bench flow + // (pcbnew-large-perf.spec.ts) swaps in editor builds from other branches + // whose settings version differs, and an unseeded dir would send that + // binary into the wizard and skew every load-time number. var seedKicadConfig = function() { - var cfgDir = '/home/kicad/.config/kicad/kicad/10.0'; - FS.mkdirTree(cfgDir); - var writeIfAbsent = function(path, contents) { try { FS.stat(path); return; } catch (e) { /* absent — seed it */ } FS.writeFile(path, contents); console.log('[KICAD] Seeded ' + path); }; - writeIfAbsent(cfgDir + '/kicad_common.json', JSON.stringify({ - do_not_show_again: { update_check_prompt: true, data_collection_prompt: true } - }, null, 2)); - writeIfAbsent(cfgDir + '/sym-lib-table', '(sym_lib_table\n (version 7)\n)\n'); - writeIfAbsent(cfgDir + '/fp-lib-table', '(fp_lib_table\n (version 7)\n)\n'); - writeIfAbsent(cfgDir + '/design-block-lib-table', '(design_block_lib_table\n (version 7)\n)\n'); + ['9.99', '10.0'].forEach(function(ver) { + var cfgDir = '/home/kicad/.config/kicad/kicad/' + ver; + FS.mkdirTree(cfgDir); + writeIfAbsent(cfgDir + '/kicad_common.json', JSON.stringify({ + do_not_show_again: { update_check_prompt: true, data_collection_prompt: true } + }, null, 2)); + writeIfAbsent(cfgDir + '/sym-lib-table', '(sym_lib_table\n (version 7)\n)\n'); + writeIfAbsent(cfgDir + '/fp-lib-table', '(fp_lib_table\n (version 7)\n)\n'); + writeIfAbsent(cfgDir + '/design-block-lib-table', '(design_block_lib_table\n (version 7)\n)\n'); + }); }; var Module = { diff --git a/tests/kicad/pcbnew-large-perf.spec.ts b/tests/kicad/pcbnew-large-perf.spec.ts new file mode 100644 index 0000000..8a71058 --- /dev/null +++ b/tests/kicad/pcbnew-large-perf.spec.ts @@ -0,0 +1,180 @@ +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-.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): 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); + }); + } + + test('FPS on vme-wren across throttles', async ({ page, testLogger }) => { + test.setTimeout(900000); + await measureLoad(page, '/kicad/pcbnew.html'); + await fetchIntoMemfs(page, VME_URL, '/home/kicad/documents/vme-wren.kicad_pcb'); + await openAndWait(page, '/home/kicad/documents/vme-wren.kicad_pcb', 'board', testLogger, 480000); + 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('fps-vme', { throttle: rate, rep: rep + 1, ...f }); + expect(f.rafFps).toBeGreaterThan(0); + } + } + await setThrottle(cdp, 1); + const mem = await sampleMemory(page); + record('fps-vme-postmem', { postFpsMem: mem }); + }); + + 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), + }); + } + }); +}); diff --git a/tests/kicad/utils/fs-inject.ts b/tests/kicad/utils/fs-inject.ts index d4b9abc..d2b964e 100644 --- a/tests/kicad/utils/fs-inject.ts +++ b/tests/kicad/utils/fs-inject.ts @@ -42,6 +42,36 @@ export async function injectFileIntoMemfs( ); } +/** + * Fetch a same-origin URL inside the page and write it into MEMFS. + * + * For large fixtures (tens of MB) the base64 round-trip through Playwright's + * JSON channel in injectFileIntoMemfs is the bottleneck — an in-page fetch + * from the static server (tests/apps is the serve root) keeps the bytes in + * the browser. Returns the byte count written. + */ +export async function fetchIntoMemfs( + page: Page, + url: string, // e.g. "/kicad/board/vme-wren.kicad_pcb" + memfsPath: string, +): Promise { + const memfsDir = memfsPath.replace(/\/[^/]+$/, '') || '/'; + return await page.evaluate( + async ({ url, memfsDir, memfsPath }) => { + const res = await fetch(url); + if (!res.ok) throw new Error(`fetchIntoMemfs: ${url} -> HTTP ${res.status}`); + const data = new Uint8Array(await res.arrayBuffer()); + // @ts-expect-error — Emscripten FS lives on window via Module + const FS = (window as any).FS; + FS.mkdirTree(memfsDir); + FS.writeFile(memfsPath, data); + console.log(`[KICAD] Fetched ${url} -> ${memfsPath} (${data.length} bytes)`); + return data.length; + }, + { url, memfsDir, memfsPath }, + ); +} + /** * Convenience: read a file from the kicad/ submodule and inject it. */ diff --git a/tests/kicad/utils/perf-utils.ts b/tests/kicad/utils/perf-utils.ts index 1283dd3..f5847fb 100644 --- a/tests/kicad/utils/perf-utils.ts +++ b/tests/kicad/utils/perf-utils.ts @@ -64,7 +64,22 @@ export async function measureOpenRender( const ext = kind === 'board' ? 'kicad_pcb' : 'kicad_sch'; const memfsPath = `/home/kicad/documents/perf-demo.${ext}`; await injectFileIntoMemfs(page, hostPath, memfsPath); + return openAndWait(page, memfsPath, kind, logger, timeout); +} +/** + * Open an ALREADY-INJECTED memfs document and wait until loaded+rendered. + * Split out of measureOpenRender so large fixtures can arrive via + * fetchIntoMemfs (or any other route) and still get the same timed open. + */ +export async function openAndWait( + page: Page, + memfsPath: string, + kind: 'schematic' | 'board', + logger: { consoleLogs: string[]; errors: string[] }, + timeout = 120000, +): Promise { + const stem = memfsPath.replace(/^.*\//, '').replace(/\.[^.]+$/, ''); const t0 = Date.now(); await page.evaluate((p) => { (window as unknown as { Module: KicadModule }).Module.kicadOpenFile(p); @@ -75,13 +90,77 @@ export async function measureOpenRender( } else { const deadline = Date.now() + timeout; while (Date.now() < deadline) { - if (/perf-demo/i.test(await page.title())) break; + if (new RegExp(stem, 'i').test(await page.title())) break; await page.waitForTimeout(200); } } return Date.now() - t0; } +export interface MemorySample { + /** WebAssembly linear memory size (Module.HEAPU8.byteLength). */ + wasmHeapBytes: number; + /** Chromium-only usedJSHeapSize; 0 elsewhere. */ + jsHeapBytes: number; +} + +/** One-shot memory census — portable across builds (HEAPU8 verified reachable in both). */ +export async function sampleMemory(page: Page): Promise { + return await page.evaluate(() => { + const w = window as unknown as { + Module?: { HEAPU8?: { byteLength: number }; wasmMemory?: { buffer: ArrayBuffer } }; + }; + const perf = performance as unknown as { memory?: { usedJSHeapSize: number } }; + return { + wasmHeapBytes: + w.Module?.wasmMemory?.buffer?.byteLength ?? w.Module?.HEAPU8?.byteLength ?? 0, + jsHeapBytes: perf.memory?.usedJSHeapSize ?? 0, + }; + }); +} + +/** + * In-page wasm-heap peak sampler (250 ms). Start before a heavy operation + * (board open), stop after — returns the max linear-memory size observed. + */ +export async function startHeapPeakSampler(page: Page): Promise { + await page.evaluate(() => { + const w = window as unknown as { + Module?: { HEAPU8?: { byteLength: number }; wasmMemory?: { buffer: ArrayBuffer } }; + __heapPeak?: number; + __heapPeakTimer?: number; + }; + if (w.__heapPeakTimer !== undefined) clearInterval(w.__heapPeakTimer); + w.__heapPeak = 0; + w.__heapPeakTimer = setInterval(() => { + const b = + w.Module?.wasmMemory?.buffer?.byteLength ?? w.Module?.HEAPU8?.byteLength ?? 0; + if (b > (w.__heapPeak ?? 0)) w.__heapPeak = b; + }, 250) as unknown as number; + }); +} + +export async function stopHeapPeakSampler(page: Page): Promise { + return await page.evaluate(() => { + const w = window as unknown as { __heapPeak?: number; __heapPeakTimer?: number }; + if (w.__heapPeakTimer !== undefined) clearInterval(w.__heapPeakTimer); + w.__heapPeakTimer = undefined; + return w.__heapPeak ?? 0; + }); +} + +/** Resource-timing attribution for the main wasm fetch (download share of loadMs). */ +export async function getWasmResourceTiming( + page: Page, +): Promise<{ durationMs: number; transferSize: number } | null> { + return await page.evaluate(() => { + const e = performance + .getEntriesByType('resource') + .find((r) => r.name.includes('kicad_editor.wasm')) as PerformanceResourceTiming | undefined; + return e ? { durationMs: Math.round(e.duration), transferSize: e.transferSize } : null; + }); +} + /** CDP CPU throttling (Chromium only): 1 = none, N = N× slower. */ export async function setThrottle(cdp: CDPSession, rate: number): Promise { await cdp.send('Emulation.setCPUThrottlingRate', { rate }); @@ -127,6 +206,79 @@ export async function measureFps(page: Page, seconds: number): Promise { return +(frames / (elapsed / 1000)).toFixed(1); } +/** + * measureFps plus a DISTINCT-frame counter: rAF keeps ticking at vsync even + * when the GAL skips redraws (draw_panel_gal enforces a min redraw period and + * re-arms a timer when it can't keep up), so under load the rAF number can + * decouple from real render throughput. Alongside the rAF loop this samples + * the largest visible canvas at ~30 Hz (48×48 downscale hash — the + * waitForCanvasStable technique; GAL sets preserveDrawingBuffer) and counts + * samples whose content changed. distinctFps is capped by the ~30 Hz sample + * rate; read it as "real redraws per second, up to 30". + */ +export async function measureFpsDetailed( + page: Page, + seconds: number, +): Promise<{ rafFps: number; distinctFps: number }> { + await page.evaluate(() => { + const w = window as unknown as { + __dfPrev?: string; + __dfCount?: number; + __dfSamples?: number; + __dfTimer?: number; + }; + if (w.__dfTimer !== undefined) clearInterval(w.__dfTimer); + w.__dfPrev = undefined; + w.__dfCount = 0; + w.__dfSamples = 0; + const scratch = document.createElement('canvas'); + scratch.width = 48; + scratch.height = 48; + const ctx = scratch.getContext('2d', { willReadFrequently: true })!; + w.__dfTimer = setInterval(() => { + const canvases = Array.from(document.querySelectorAll('canvas')).filter((c) => { + const r = c.getBoundingClientRect(); + return r.width > 0 && r.height > 0 && c !== scratch; + }); + if (!canvases.length) return; + // The GAL draws into a wxGLCanvas (id glcanvas-*), NOT the + // full-window emscripten #canvas — sample the GL surface where the + // board pixels actually change, falling back to the largest canvas. + const gl = canvases.filter((c) => /^glcanvas/.test(c.id)); + const pool = gl.length ? gl : canvases; + const src = pool.reduce((a, b) => { + const ra = a.getBoundingClientRect(); + const rb = b.getBoundingClientRect(); + return ra.width * ra.height >= rb.width * rb.height ? a : b; + }); + try { + ctx.drawImage(src, 0, 0, 48, 48); + const d = ctx.getImageData(0, 0, 48, 48).data; + let h = 0; + for (let i = 0; i < d.length; i += 16) h = ((h << 5) - h + d[i]) | 0; + const hs = String(h); + w.__dfSamples = (w.__dfSamples ?? 0) + 1; + if (w.__dfPrev !== undefined && hs !== w.__dfPrev) w.__dfCount = (w.__dfCount ?? 0) + 1; + w.__dfPrev = hs; + } catch { + /* tainted/zero-size canvas — skip the sample */ + } + }, 33) as unknown as number; + }); + + const t0 = Date.now(); + const rafFps = await measureFps(page, seconds); + const elapsed = (Date.now() - t0) / 1000; + + const distinct = await page.evaluate(() => { + const w = window as unknown as { __dfCount?: number; __dfTimer?: number }; + if (w.__dfTimer !== undefined) clearInterval(w.__dfTimer); + w.__dfTimer = undefined; + return w.__dfCount ?? 0; + }); + return { rafFps, distinctFps: +(distinct / elapsed).toFixed(1) }; +} + /** Write per-app results to tests/test-results/perf-.json (gitignored, CI-uploaded). */ export function recordPerf(app: string, data: Record): void { fs.mkdirSync(RESULTS_DIR, { recursive: true });