build+perf: wasm-opt the shipped wasm, and measure real frames in CI

emcc only runs Binaryen at link -O2+ (link.py: should_run_binaryen_optimizer
returns OPT_LEVEL >= 2) and we link at -O1, so the shipped module had never seen
wasm-opt at all — it kept its entire 19.56 MB name section, ~20% of the editor
(-sJSPI sets ASYNCIFY=2, which suppresses wasm-ld's --strip-debug, leaving
wasm-opt as the only thing that would drop it). Step 8.2 runs it post-link and
in-container, so CI's cached compile phase covers it and the host post-process
stays pure-host.

Default -O2, picked by measuring every level on the same module: -O0 already
captures 27% of the raw win (it is mostly the name section), -O2 costs 23 s and
gives the best frame rate, and -O3/-O4/-Os/-Oz cost 48-132 s for at most 1.5%
more brotli — -O4 is not even smaller than -O3. Targets that already link -O2/-Oz
(occ_service, kicad_tools) are skipped by testing for the target_features
section, which emcc strips whenever it ran the optimizer itself, so there is no
hard-coded target list to drift. Feature flags come from the module's own
target_features section and so cannot diverge from the link.

The perf specs reported requestAnimationFrame ticks as "FPS". That is not a frame
rate: rAF fires on the compositor's schedule whether or not the GAL redrew, and
it read 120/s on a board where the renderer completed zero frames in six seconds.
measureInteractionFps now counts completed GAL frames — runs of draws to the
default framebuffer, exactly one per frame in every AA mode — and drives a pure
middle-drag pan after a zoom-to-fit. Mixing wheel zoom into the drive made the
result depend on where the wheel left the view: +-20% across identical repeats,
against +-2% for pan alone. The report gains a GAL fps column with a regression
flag on the 1x number; rAF is kept so historical runs stay comparable.

CI has no GPU, so its number is a software-rasteriser redraw rate — a regression
signal, not a user-facing frame rate. Method and measurements in the bench report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016X9eh1s5sTx1o9Em9KBuwR
This commit is contained in:
Viktor Vaczi 2026-08-22 12:53:00 +02:00
commit f419a1fedd
7 changed files with 536 additions and 56 deletions

View file

@ -22,6 +22,25 @@ default), docker caps 10 CPU / 32 GB, `-j 10`, `BINARYEN_CORES=8`,
Machine: Apple M4 Max, 16 cores, 64 GB; Docker Desktop VM 12 CPU / 28 GiB; 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/). macOS 26.5.2. Raw data: [`bench-data-2026-08/`](bench-data-2026-08/).
> **Revised 2026-08-14 (evening).** The build, size, load and board-open numbers
> stand. The **interaction-FPS numbers were wrong and have been re-measured**
> see [§4.4](#44-interaction-fps-re-measured-2026-08-14-evening). The original
> `distinctFps` counter hashed a 48×48 downscale of the canvas, which (a) misses
> redraws that change few pixels and (b) stalls the renderer through the pixel
> readback, badly so under software rasterisation. Frames are now counted from
> the WebGL command stream, the view is zoomed-to-fit before every measurement so
> each run sees the same geometry, and both a real GPU and a software rasteriser
> are reported. Direction of the result is unchanged; the magnitudes are not.
>
> **Second revision, same evening.** The two arms were never built the same way:
> the Asyncify pipeline always finished with a host-side `wasm-opt -O1`, while
> the JSPI build got **no Binaryen pass at all** (emcc only runs wasm-opt at link
> -O2+, and we link at -O1). So every number above compares an optimised module
> against an unoptimised one — in JSPI's favour, since JSPI won anyway. A
> post-link `wasm-opt` step has now been added to the build and re-benchmarked:
> [§6](#6-post-link-wasm-opt). The original unoptimised numbers are kept
> throughout; §7 adds the third arm.
## TL;DR ## TL;DR
| Metric | Asyncify | JSPI | Δ | | Metric | Asyncify | JSPI | Δ |
@ -33,16 +52,27 @@ macOS 26.5.2. Raw data: [`bench-data-2026-08/`](bench-data-2026-08/).
| Clean rebuild (warm ccache) | 165 s | 95 s | **42 %** | | Clean rebuild (warm ccache) | 165 s | 95 s | **42 %** |
| Cold load (median of 5) | 2 576 ms | 1 538 ms | **40 %** | | 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 %** | | 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 %** | | GAL frames/s, vme-wren pan (GPU) | 12.7 | 19.5 | **+54 %** |
| Real redraws/s @4× throttle | 3.1 | 5.2 | **+68 %** | | GAL frames/s, vme-wren zoom (GPU) | 26.2 | 39.5 | **+51 %** |
| GAL frames/s, jetson pan (GPU) | 11.7 | 15.5 | **+32 %** |
| Wasm heap after big-board open | 962 MB | 802 MB | **17 %** | | 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 %** | | Jetson 80.9 MB board open | 14.6 s, peak 1.90 GB | 8.6 s, peak 1.73 GB | **41 %** |
| JSPI wasm, raw — **with `wasm-opt -O1`** | — | **63.3 MB** | **36 %** vs unoptimised JSPI |
| JSPI wasm, brotli — **with `wasm-opt -O1`** | — | **10.6 MB** | **15 %** vs unoptimised JSPI |
(The last two rows are the third arm added in [§6](#6-post-link-wasm-opt); every
other row is the original A/B, both arms as they were actually built.)
The asyncify tax was real on every axis: bytes (instrumented code also 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), 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 load (bigger download + more code to tier), compute (slower opens, ~5070 %
frames — the gap *widens* under CPU throttle, the doc-12 signature of fewer rendered frames), and memory. The frame-rate advantage holds at a roughly
per-operation overhead rather than mere size), and memory. *constant ratio* across CPU throttle rates (+63 % / +51 % / +73 % at 1× / 4× /
6×), i.e. JSPI does less work per frame rather than merely loading a smaller
module. The earlier claim that the gap *widened* under throttle (+21 % → +68 %)
does not survive re-measurement — it was an artefact of the old counter being
clipped at 1×.
## 1. Build time ## 1. Build time
@ -141,30 +171,96 @@ throttling channel.
Neither arm OOMs even on the 80.9 MB board — both stay well under the 4 GB cap. 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; ### 4.4 Interaction FPS (re-measured 2026-08-14 evening)
`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 | **What the first pass got wrong.** `distinctFps` counted samples of a 48×48
|---|---|---| downscale hash of the GAL canvas at ~30 Hz. That under-counts real redraws by
| 1× | 44.549.1 / **7.8** | 51.955.7 / **9.29.6** | 23.5× (a moving crosshair changes too few pixels to survive the downscale), it
| 4× | 32.437.6 / **3.03.1** | 39.241.4 / **5.15.2** | is capped at the 30 Hz sample rate, and the `drawImage`+`getImageData` readback
| 6× | 34.339.9 / **2.84.1** | 34.341.8 / **3.35.3** | it performs is itself expensive — under software rasterisation it was a
significant share of the very thing being measured. The `rafFps` column was
never a frame rate at all: rAF ticks on the compositor's schedule whether or not
the GAL redrew anything.
The distinct-frame gap grows from +21 % at 1× to +68 % at 4× — the same **The corrected metric.** A GAL frame ends with the compositor blitting to the
"advantage widens under throttle" signature doc-12 used to prove lower default framebuffer, so a *run* of draw calls issued while no framebuffer is
CPU-per-operation (as opposed to just a smaller module). bound is exactly one completed frame. The run must be collapsed — the number of
present draws per frame varies with the AA mode (1 under supersampling, 2 under
`AA_NONE`, +1 when the crosshair is drawn) — but a run boundary happens once per
frame in every mode, so no divisor is needed. This is now what
`measureInteractionFps()` in `tests/kicad/utils/perf-utils.ts` reports, and what
CI records.
**FPS on jetson-agx-thor (80.9 MB)** — added 2026-08-14 pm: at this scale BOTH **Two further methodology fixes.** Each pattern gets a discarded warm-up drive
arms saturate outright and the backend difference disappears into noise (the first pass pays first-time tessellation and measures caching, not steady
(asyncify raf 2.15.3 / distinct 0.20.8; JSPI raf 1.95.0 / distinct state), and the view is **zoomed to fit before every drive**. Without the reset a
0.10.7; post-FPS heap 1.90 vs 1.73 GB). With <1 real redraw/s the 6 s preceding wheel-zoom leaves an arbitrary zoom level and the next pattern sees a
windows count 15 frames, i.e. quantization noise. Reading: suspension different amount of geometry — run-to-run spread reached 2×. With it, repeats
overhead is a per-event-loop-turn cost — once per-frame GAL/geometry work is land within ~1% (measured: 21.3 / 21.3 / 21.1 fps over three runs). Zoom-to-fit
hundreds of ms, it no longer discriminates. vme-wren (~27 MB) is the size also means every number below is the *whole board in view*, i.e. the worst case.
class where the backend visibly matters for interaction; jetson is the
"both need render-side optimization" regime. Both arms were re-run back-to-back on the same machine in the same session, from
their own static servers (asyncify served read-only out of the `staging`
worktree's `output/`; the jspi tree's harness page and board fixtures shared by
both, so only the 5-file artifact set differs).
**vme-wren (27.7 MB, 1 508 fp, 24 858 seg) — GAL frames/s:**
| Renderer | Pattern | Asyncify | JSPI | Δ |
|---|---|---|---|---|
| GPU (ANGLE Metal) | crosshair only | 66.4 | 64.9 | 2 % |
| GPU (ANGLE Metal) | wheel zoom | 26.2 | **39.5** | **+51 %** |
| GPU (ANGLE Metal) | middle-drag pan | 12.7 | **19.5** | **+54 %** |
| Software (SwiftShader) | crosshair only | 59.5 | 65.9 | +11 % |
| Software (SwiftShader) | wheel zoom | 11.6 | **39.3** | **+239 %** |
| Software (SwiftShader) | middle-drag pan | 9.9 | 9.5 | 4 % |
**jetson-agx-thor (80.9 MB) — GAL frames/s:**
| Renderer | Pattern | Asyncify | JSPI | Δ |
|---|---|---|---|---|
| GPU (ANGLE Metal) | crosshair only | 66.0 | 63.7 | 3 % |
| GPU (ANGLE Metal) | wheel zoom | 16.0 | **19.4** | **+21 %** |
| GPU (ANGLE Metal) | middle-drag pan | 11.7 | **15.5** | **+32 %** |
| Software (SwiftShader) | any | **0** | **0** | — |
**CPU-throttle sweep** (vme-wren, GPU, middle-drag pan, GAL frames/s):
| Throttle | Asyncify | JSPI | Δ |
|---|---|---|---|
| 1× | 12.5 | 20.4 | **+63 %** |
| 4× | 4.1 | 6.2 | **+51 %** |
| 6× | 2.6 | 4.5 | **+73 %** |
Readings:
- **JSPI's interaction advantage is real and larger than first reported**: ~+50 %
on vme-wren and ~+30 % on jetson, on a real GPU, where the first pass claimed
+21 %.
- **Crosshair-only motion is identical on both arms** (~65 fps everywhere, zero
vertex upload). Moving the cursor only re-composites; it never touches
geometry, so the suspension backend has nothing to do with it. This is the
control case, and it behaving as a control is a good sign for the rest.
- **The earlier "jetson saturates, the backend stops mattering" conclusion was an
artefact** of the broken counter. On a real GPU the backend still separates the
arms by ~30 % at 80.9 MB. What *is* true is that neither arm renders the jetson
board at all under software rasterisation: no frame completes within a 6 s
window, and re-running with a 45 s settle and a 20 s window still yields zero
(rAF 2.8/s, 233 GL calls/s). A single redraw there takes over 20 seconds.
- **The advantage does not widen under throttle** — it is a roughly constant
ratio (+51 % to +73 %, the spread being run noise). That still points at less
work per frame rather than a pure module-size effect, but the original
"+21 % → +68 % widening" reading was an artefact: the old counter's 30 Hz
ceiling and under-counting compressed the measured 1× gap specifically.
- **Software-rasteriser numbers do not rank the arms reliably** — vme-wren pan is
a tie (9.9 vs 9.5) while zoom is a 3.4× gap. Rank the backends on the GPU
numbers; treat the software column as the CI-shaped regression signal it is.
Absolute frame rates here are lower than the old `distinctFps` figures would
suggest at first glance only because zoom-to-fit puts the entire board in view.
Load and board-open times re-measured in the same runs reproduced §4's originals
(e.g. vme-wren open 6 475 vs 2 088 ms; jetson open 14 510 vs 8 094 ms), which is
the cross-check that the new rig measures the same builds as the old one.
**Memory checkpoints** (wasm linear memory; Chromium `usedJSHeapSize` tracked **Memory checkpoints** (wasm linear memory; Chromium `usedJSHeapSize` tracked
alongside, differences <10 %): boot 557 vs 387 MB; after vme-wren open 962 vs alongside, differences <10 %): boot 557 vs 387 MB; after vme-wren open 962 vs
@ -195,7 +291,209 @@ alongside, differences <10 %): boot 557 vs 387 MB; after vme-wren open 962 vs
`public/wasm` symlink pointed at a stash during the swap window) and `public/wasm` symlink pointed at a stash during the swap window) and
verified serving the JSPI wasm afterwards. verified serving the JSPI wasm afterwards.
## 6. Reproduce ## 6. Post-link wasm-opt
**The asymmetry.** Arm A's pipeline ended with `wasm-opt --hoist-cpp-catches`
`--asyncify`**`-O1`** on the host. Arm B ended with nothing: emcc only runs
Binaryen at link `-O2`+ (`tools/link.py`, `should_run_binaryen_optimizer()`
returns `settings.OPT_LEVEL >= 2`), and at -O0/-O1 the pass list comes back empty
so `wasm-opt` is never even spawned. The section tables confirm it — the asyncify
module has **no name section** (its host `wasm-opt` stripped it) while the JSPI
module still carries **19.56 MB** of names, because `-sJSPI` sets `ASYNCIFY=2`,
which suppresses wasm-ld's `--strip-debug`, leaving wasm-opt as the only thing
that would have dropped them.
So §§34 compare an optimised module against an unoptimised one. That understates
JSPI: on a like-for-like code-section basis it was 66.7 MB (never optimised)
against asyncify's 106.3 MB (optimised).
**The build step.** Added to `scripts/kicad/build-kicad-target.sh` as step 8.2,
after the link and before `copy-output`. It runs in-container (wasm-opt ships
with emsdk, so CI's cached `--compile-only` phase covers it and the host
`--postprocess-only` phase stays pure-host). Defaults to `-O1`, matching the
Asyncify-era pipeline; override with `KICAD_WASM_OPT` (`-O2`, `-O1 -g` to keep
the name section, `off` to skip). No feature flags are passed: Binaryen reads the
module's own `target_features` section, so the enabled-feature list cannot drift
from the link. That same section is the skip signal — emcc strips it via
`--strip-target-features` whenever it ran the optimizer itself, so targets that
link at -O2/-Oz (`occ_service`, `kicad_tools`) are detected and skipped rather
than hard-coded by name. A stamp file makes it idempotent across relink-free
rebuilds, and a failure leaves the linked module untouched and fails the build.
**Cost: 9 s**, in a 121 s no-change rebuild (~7 %). For contrast the Asyncify host
tail cost 63.4 s and 6.09 GB RSS per link. The "wasm-opt OOMs on large modules"
comment that justified `-O0` in the release path is Asyncify-era: it predates
JSPI and was written when instrumentation had roughly doubled the function count.
**What it does to the module:** code 66.7 → 51.0 MB, name section 19.56 MB → 0,
`external_debug_info` preserved (so `-gseparate-dwarf` DWARF still resolves).
### 6.1 Size
| Editor wasm | Asyncify | JSPI | JSPI + `wasm-opt -O1` |
|---|---|---|---|
| raw | 118.6 MB | 98.7 MB | **63.3 MB** |
| gzip 9 | 38.5 MB | 19.5 MB | **16.1 MB** |
| brotli | 21.0 MB | 12.5 MB | **10.6 MB** |
Against unoptimised JSPI: **36 % raw, 17 % gzip, 15 % brotli**. Against
asyncify: 47 % raw, 58 % gzip, 49 % brotli. The raw delta is much larger than
the compressed one because most of it is the name section, which compresses well.
### 6.2 Runtime
Three arms, same session, GPU (ANGLE Metal), zoom-to-fit, 6 s drives. Pan is the
headline: it repeats within ~2 %. Zoom was dropped from the harness entirely
after this run — the wheel drive continuously changes how much geometry is
visible, so it spread ±20 % run to run (34.7 / 42.1 / 27.1 on three identical
repeats) and discriminated nothing. The CI perf specs now drive a pure
middle-drag pan for the same reason. Cursor is the control: crosshair-only motion
touches no geometry.
| vme-wren, GPU | Asyncify | JSPI | JSPI + `wasm-opt -O1` |
|---|---|---|---|
| cold load | 2 145 ms | 1 153 ms | **1 063 ms** |
| open board | 6 347 ms | 3 381 ms | 3 317 ms |
| **pan, GAL fps** | 11.9 | 18.3 | **18.1** |
| cursor, GAL fps (control) | 65.4 | 64.8 | 65.3 |
| zoom, GAL fps (noisy) | 20.0 | 32.4 | 23.0 |
| jetson-agx-thor, GPU | Asyncify | JSPI | JSPI + `wasm-opt -O1` |
|---|---|---|---|
| cold load | 2 135 ms | 1 173 ms | **1 050 ms** |
| open board | 14 651 ms | 8 080 ms | 8 013 ms |
| **pan, GAL fps** | 11.4 | 14.3 | **14.4** |
**Reading: `wasm-opt -O1` is a size and startup win, not a frame-rate win.**
Frame rate is unchanged within noise on both boards (18.3 → 18.1 and 14.3 → 14.4
— the two arms are indistinguishable). Cold load improves 810 % (1 153 → 1 063
and 1 173 → 1 050), which is what a 36 % smaller download and less code to tier
buys. Board open is flat, consistent with it being dominated by parsing and
connectivity rather than code quality.
That frame rate does not move is the expected result rather than a
disappointment: the per-frame hot path is scene traversal in code LLVM already
optimised at the translation-unit level, and Binaryen `-O1` on top of clang -O1
mostly removes cross-module redundancy and dead weight. It also means the
original A/B's *interaction* conclusions are not disturbed by the asymmetry — the
JSPI-vs-asyncify frame-rate gap was never an artefact of the missing pass. The
size and load-time comparisons in §§34, however, were: JSPI's real advantage
there is larger than those sections state.
### 6.3 Every optimization level, measured
All seven Binaryen levels run on the same pristine unoptimised module
(98.7 MB), then each served against identical glue and benchmarked. `-O0` is the
control: it optimises nothing, so it isolates what a plain round-trip plus
dropping the name section is worth on its own.
| Level | wasm-opt wall | raw | gzip 9 | **brotli** | pan GAL fps | cold load |
|---|---|---|---|---|---|---|
| none | — | 98.7 MB | 19.47 MB | **12.51 MB** | 18.8 | 1 2571 609 ms |
| `-O0` | 4 s | 72.3 MB | 17.39 MB | **11.21 MB** | 19.5 | ~1 145 ms |
| `-O1` *(shipped default)* | 8 s | 63.3 MB | 16.12 MB | **10.61 MB** | 19.5 | ~1 090 ms |
| `-O2` | 23 s | 60.7 MB | 15.95 MB | **10.66 MB** | **21.1** | ~1 090 ms |
| `-O3` | 98 s | 60.1 MB | 15.83 MB | **10.53 MB** | 20.4 | ~1 070 ms |
| `-O4` | 132 s | 60.1 MB | 15.86 MB | **10.56 MB** | 21.8 | ~1 094 ms |
| `-Os` | 48 s | 60.0 MB | 15.85 MB | **10.53 MB** | 22.1 | ~1 068 ms |
| `-Oz` | 97 s | 57.2 MB | 15.68 MB | **10.49 MB** | 20.4 | ~1 076 ms |
Pan is the mean of two reps where two were run (none, `-O0`, `-O2`, `-Oz`);
repeats agreed within ~5%, and the `settled` time — how long until the app goes
quiet after opening — repeated within 0.4% (e.g. none 6 941 / 6 938 ms against
`-O2` 6 106 / 6 123 ms), which is what gives confidence the gaps are real.
Three things fall out:
- **Most of the size win is not optimisation.** `-O0` — which does no
optimisation at all — already captures 26.7% of the raw reduction, because the
bulk of it is the 19.56 MB name section being dropped.
- **Compressed size is flat from `-O1` onward.** Brotli is what actually goes over
the wire, and every level from `-O1` to `-Oz` lands in a 10.4910.66 MB band —
a 1.6% spread. Raw size keeps falling to `-Oz` (57.2 MB), which matters for
parse and memory but not for download.
- **Build cost explodes for nothing.** `-O3`, `-O4`, `-Os` and `-Oz` cost 48132 s
against `-O2`'s 23 s and `-O1`'s 8 s, and buy at most 1.5% more brotli. `-O4`
is not smaller than `-O3` at all (60 097 004 vs 60 091 979 bytes) while taking
35% longer.
**Recommendation: `-O2` is the value pick** — 23 s for the best measured frame
rate (+13% pan over unoptimised, versus +4% at `-O1`) and essentially the same
download as anything more expensive. The build currently defaults to `-O1` to
mirror the Asyncify-era pipeline; moving it is a one-word change to
`KICAD_WASM_OPT`. Anything past `-O2` is not worth its build time on this module.
### 6.4 Release (non-debug) build
The build that ships is the **debug** one (`DEBUG_BUILD` defaults to 1). This
tests whether dropping debug mode buys anything, with `wasm-opt -O2` held
constant on both sides so the only variable is the compile/link mode:
| | Debug | Release |
|---|---|---|
| KiCad + deps + wx TUs | `-g -O1` | `-O2` |
| link | `-O1 -g -gseparate-dwarf` | `-O0` |
| post-link | `wasm-opt -O2` | `wasm-opt -O2` |
**Consistency first.** `check_stamp()` is only `[ -f "$stamp_file" ]` — it does
not encode the build mode — so flipping `DEBUG_BUILD=0` rebuilds KiCad while
silently reusing debug-built dependencies. That matters beyond lost optimisation:
wxWidgets' ABI depends on its debug level. Every stamp was therefore wiped and wx
forced through reconfigure, and the result verified by checking for DWARF, which
only a `-g` build emits: OCCT (`libTKernel`), cairo, freetype, harfbuzz,
boost_locale, protobuf, wx base, wx core and the KiCad objects all came back
DWARF-free. Since `env.sh` ties `-g -O1` and `-O2` to the same switch, no DWARF
proves the `-O2` branch. (`docker/build.sh` already forwards `DEBUG_BUILD` into
the container for exactly this reason — its comment notes that `--release` alone
reaches KiCad's flag block but never `DEBUG_CFLAGS`/`WX_DEBUG_FLAGS`.)
**Size — release is slightly *worse*:**
| | Debug + `wasm-opt -O2` | Release + `wasm-opt -O2` | Δ |
|---|---|---|---|
| raw | 60.7 MB | 62.5 MB | **+3.0 %** |
| gzip 9 | 15.95 MB | 16.24 MB | +1.8 % |
| brotli | 10.66 MB | 10.80 MB | +1.3 % |
| code section | 48.5 MB | 50.3 MB | +3.7 % |
| data section | 12.0 MB | 12.0 MB | — |
`-O2` inlines more, so it trades size for speed — the code section grows while
data is byte-identical.
**Speed — no measurable win:**
| vme-wren, GPU | Debug + `wasm-opt -O2` | Release + `wasm-opt -O2` |
|---|---|---|
| pan GAL fps (2 reps) | 19.4 / 19.5 | 19.0 / 20.8 |
| cold load | 1 091 / 1 041 ms | 1 067 / 1 081 ms |
| settle after open | 6 790 / 6 907 ms | 6 671 / 6 003 ms |
Frame rate is **+2.3 % on the means, inside run noise** (the release arm's two
reps, 19.0 and 20.8, straddle the debug arm's pair). Cold load is identical.
Settle is ~7 % faster, the only consistent signal.
**Reading: `clang -O2` and `wasm-opt -O2` are largely redundant here — you want
one of them, not both.** The measured jump is from *no* whole-module optimisation
to *any* (+4 % at `wasm-opt -O1`, +13 % at `-O2`); which tool supplies it barely
matters. That also explains the earlier observation that unoptimised debug loaded
much slower than the optimised variants: that gap was the 19.56 MB **name
section** inflating the download, not code quality. With names stripped on both
sides here, load times converge exactly.
So debug mode is not costing meaningful speed, and release costs 13 % more
bytes while giving up all debug info — no separate DWARF, no symbolised stack
traces. On this evidence there is no reason to switch, which is a useful thing to
know: the cheap `wasm-opt` step already captured what was available, and the
remaining render cost is structural (see §6.2's reading), not a missing flag.
**Still not measured:** LTO (`-flto`) and `-msimd128`, neither of which appears
anywhere in the build. LTO is the interesting one — this is a merged multi-app
binary, so there is a large cross-TU inlining surface that neither per-file
`-O2` nor Binaryen can reach. Also unmeasured: a `-O3`/`-Oz` *link* level, whose
metadce pass (gated on `OPT_LEVEL >= 3` or `SHRINK_LEVEL >= 1`, so it does not
engage at `-O2`) would drop unused JS-library and wasm exports.
## 7. Reproduce
```bash ```bash
# build side (each arm, serialized; see §1 for scenario commands) # build side (each arm, serialized; see §1 for scenario commands)

View file

@ -98,6 +98,7 @@ BEGIN {
ord[++ROWN] = "kicad-configure"; lab["kicad-configure"] = "KiCad configure (CMake)" ord[++ROWN] = "kicad-configure"; lab["kicad-configure"] = "KiCad configure (CMake)"
ord[++ROWN] = "kicad-compile"; lab["kicad-compile"] = "KiCad compile" ord[++ROWN] = "kicad-compile"; lab["kicad-compile"] = "KiCad compile"
ord[++ROWN] = "kicad-bitmaps"; lab["kicad-bitmaps"] = "Bitmap resources" ord[++ROWN] = "kicad-bitmaps"; lab["kicad-bitmaps"] = "Bitmap resources"
ord[++ROWN] = "wasm-opt"; lab["wasm-opt"] = "wasm-opt (Binaryen)"
ord[++ROWN] = "copy-output"; lab["copy-output"] = "Copy output" ord[++ROWN] = "copy-output"; lab["copy-output"] = "Copy output"
ord[++ROWN] = "env-shim"; lab["env-shim"] = "ENV merge shim" ord[++ROWN] = "env-shim"; lab["env-shim"] = "ENV merge shim"
for (i = 1; i <= ROWN; i++) ridx[ord[i]] = i for (i = 1; i <= ROWN; i++) ridx[ord[i]] = i

View file

@ -290,10 +290,12 @@ else
BUILD_TYPE="Release" BUILD_TYPE="Release"
EXTRA_FLAGS="-O2 ${KICAD_EH_FLAGS} -matomics -mbulk-memory" EXTRA_FLAGS="-O2 ${KICAD_EH_FLAGS} -matomics -mbulk-memory"
EMBIND_CONFIG_DEFINES="" # Release defines no DEBUG in either TU → vtable layouts already match EMBIND_CONFIG_DEFINES="" # Release defines no DEBUG in either TU → vtable layouts already match
# -O0 at link time skips wasm-opt (which can OOM on large WASM files) # -O0 at link time means emcc does not run wasm-opt itself (it only does at
# Compilation is still -O2 for optimized code, but we skip post-link wasm-opt # -O2+). That is fine: step 8.2 below runs wasm-opt explicitly, so the module
# is optimized either way. The old "wasm-opt OOMs on large modules" reason for
# -O0 was Asyncify-era and no longer applies.
LINKER_DEBUG_FLAGS="-O0 ${KICAD_EH_FLAGS}" LINKER_DEBUG_FLAGS="-O0 ${KICAD_EH_FLAGS}"
log_info "Building KiCad in RELEASE mode (skipping wasm-opt due to memory limits)" log_info "Building KiCad in RELEASE mode (post-link wasm-opt runs in step 8.2)"
fi fi
# Suspension backend: JSPI (native stack switching). The headless CLIs # Suspension backend: JSPI (native stack switching). The headless CLIs
@ -696,6 +698,25 @@ if [ "${APP_NAME}" != "kicad_tools" ] && [ "${APP_NAME}" != "occ_service" ]; the
emmake make bitmap_archive_build emmake make bitmap_archive_build
fi fi
# Step 8.2: post-link Binaryen pass. emcc only runs wasm-opt at link -O2+
# (link.py: should_run_binaryen_optimizer -> OPT_LEVEL >= 2) and we link at -O1,
# so without this the module gets no Binaryen pass at all and keeps its whole
# name section (measured 19.56 MB, ~20% of the editor wasm — -sJSPI sets
# ASYNCIFY=2, which suppresses wasm-ld's --strip-debug, leaving wasm-opt as the
# only thing that would drop it). Skipped for targets that already link -O2/-Oz
# (occ_service, kicad_tools, ngspice_service): emcc strips target_features when
# it ran the optimizer itself, so there is no hard-coded target list to
# maintain. Feature flags come from the module's own target_features section and
# so cannot drift from the link. Override with KICAD_WASM_OPT ("-Oz" for the
# smallest raw module, "-O2 -g" to keep the name section, "off" to skip).
KICAD_WASM_OPT="${KICAD_WASM_OPT:--O2}"
if [ "${KICAD_WASM_OPT}" != "off" ] && grep -aq "target_features" "${LINK_OUT_WASM}"; then
kw_stage wasm-opt
log_info "wasm-opt ${KICAD_WASM_OPT} on ${APP_NAME}.wasm..."
"${EMSDK:-/emsdk}/upstream/bin/wasm-opt" ${KICAD_WASM_OPT} "${LINK_OUT_WASM}" -o "${LINK_OUT_WASM}.tmp"
mv -f "${LINK_OUT_WASM}.tmp" "${LINK_OUT_WASM}"
fi
# Step 9: Create stamp file # Step 9: Create stamp file
create_stamp "${KICAD_STAMP}" create_stamp "${KICAD_STAMP}"
log_info "KiCad ${APP_NAME} build complete!" log_info "KiCad ${APP_NAME} build complete!"

View file

@ -1,12 +1,12 @@
import { test, expect } from './fixtures'; import { test, expect } from './fixtures';
import * as path from 'path'; import * as path from 'path';
import { measureLoad, measureOpenRender, measureFps, setThrottle, recordPerf } from './utils/perf-utils'; import { measureLoad, measureOpenRender, measureInteractionFps, setThrottle, recordPerf } from './utils/perf-utils';
/** /**
* eeschema runtime-perf (TRACK-ONLY, no gating). * eeschema runtime-perf (TRACK-ONLY, no gating).
* *
* Measures the CURRENT build: cold load, open+render of the demo schematic, and * Measures the CURRENT build: cold load, open+render of the demo schematic, and
* sustained pan/zoom FPS across CPU-throttle rates. Numbers are logged and written * sustained pan FPS across CPU-throttle rates. Numbers are logged and written
* to tests/test-results/perf-eeschema.json (CI uploads it). The only assertions are * to tests/test-results/perf-eeschema.json (CI uploads it). The only assertions are
* "the app booted and the doc opened" never a perf threshold (would flake CI). * "the app booted and the doc opened" never a perf threshold (would flake CI).
* Runs on the Chromium 'perf' project (CDP throttling); pass --headed for real-GPU FPS. * Runs on the Chromium 'perf' project (CDP throttling); pass --headed for real-GPU FPS.
@ -29,12 +29,14 @@ test.describe('eeschema perf', () => {
await page.keyboard.press('Escape').catch(() => {}); // eslint-disable-line -- best-effort Escape (may not apply in all states) await page.keyboard.press('Escape').catch(() => {}); // eslint-disable-line -- best-effort Escape (may not apply in all states)
const cdp = await page.context().newCDPSession(page); const cdp = await page.context().newCDPSession(page);
const fps: { throttle: number; fps: number }[] = []; // galFps is the real frame rate (completed GAL frames). fps is the legacy
// rAF tick count, kept so historical CI numbers stay comparable.
const fps: { throttle: number; fps: number; galFps: number }[] = [];
for (const rate of THROTTLES) { for (const rate of THROTTLES) {
await setThrottle(cdp, rate); await setThrottle(cdp, rate);
const f = await measureFps(page, FPS_SECS); const f = await measureInteractionFps(page, FPS_SECS);
console.log(`[perf] eeschema FPS @ ${rate}x = ${f}`); console.log(`[perf] eeschema @ ${rate}x: GAL ${f.galFps} fps (rAF ${f.rafFps})`);
fps.push({ throttle: rate, fps: f }); fps.push({ throttle: rate, fps: f.rafFps, galFps: f.galFps });
} }
await setThrottle(cdp, 1); await setThrottle(cdp, 1);

View file

@ -1,12 +1,12 @@
import { test, expect } from './fixtures'; import { test, expect } from './fixtures';
import * as path from 'path'; import * as path from 'path';
import { measureLoad, measureOpenRender, measureFps, setThrottle, recordPerf } from './utils/perf-utils'; import { measureLoad, measureOpenRender, measureInteractionFps, setThrottle, recordPerf } from './utils/perf-utils';
/** /**
* pcbnew runtime-perf (TRACK-ONLY, no gating). Mirrors eeschema-perf for the board editor. * pcbnew runtime-perf (TRACK-ONLY, no gating). Mirrors eeschema-perf for the board editor.
* *
* Measures the CURRENT build: cold load, open+render of the demo board, and sustained * Measures the CURRENT build: cold load, open+render of the demo board, and sustained
* pan/zoom FPS across CPU-throttle rates tests/test-results/perf-pcbnew.json (CI uploads it). * pan FPS across CPU-throttle rates tests/test-results/perf-pcbnew.json (CI uploads it).
* Runs on the Chromium 'perf' project (pcbnew's big module OOMs Firefox/SpiderMonkey anyway, * Runs on the Chromium 'perf' project (pcbnew's big module OOMs Firefox/SpiderMonkey anyway,
* and CDP throttling is Chromium-only). Only asserts booted + opened; never a perf threshold. * and CDP throttling is Chromium-only). Only asserts booted + opened; never a perf threshold.
*/ */
@ -27,12 +27,14 @@ test.describe('pcbnew perf', () => {
await page.keyboard.press('Escape').catch(() => {}); // eslint-disable-line -- best-effort Escape (may not apply in all states) await page.keyboard.press('Escape').catch(() => {}); // eslint-disable-line -- best-effort Escape (may not apply in all states)
const cdp = await page.context().newCDPSession(page); const cdp = await page.context().newCDPSession(page);
const fps: { throttle: number; fps: number }[] = []; // galFps is the real frame rate (completed GAL frames). fps is the legacy
// rAF tick count, kept so historical CI numbers stay comparable.
const fps: { throttle: number; fps: number; galFps: number }[] = [];
for (const rate of THROTTLES) { for (const rate of THROTTLES) {
await setThrottle(cdp, rate); await setThrottle(cdp, rate);
const f = await measureFps(page, FPS_SECS); const f = await measureInteractionFps(page, FPS_SECS);
console.log(`[perf] pcbnew FPS @ ${rate}x = ${f}`); console.log(`[perf] pcbnew @ ${rate}x: GAL ${f.galFps} fps (rAF ${f.rafFps})`);
fps.push({ throttle: rate, fps: f }); fps.push({ throttle: rate, fps: f.rafFps, galFps: f.galFps });
} }
await setThrottle(cdp, 1); await setThrottle(cdp, 1);

View file

@ -166,10 +166,137 @@ export async function setThrottle(cdp: CDPSession, rate: number): Promise<void>
await cdp.send('Emulation.setCPUThrottlingRate', { rate }); await cdp.send('Emulation.setCPUThrottlingRate', { rate });
} }
/**
* Count completed GAL frames instead of rAF ticks.
*
* A GAL frame ends with the compositor blitting to the DEFAULT framebuffer, so a
* run of draws issued while no framebuffer is bound is exactly one frame. The run
* has to be collapsed: the number of present draws per frame depends on the AA
* mode (1 under supersampling, 2 under AA_NONE, +1 when the crosshair is drawn),
* but a run *boundary* happens once per frame in every mode so no divisor.
*
* Wrapping the prototypes works even though the context already exists: methods
* resolve on the prototype at call time, not at context creation. The initial
* framebuffer binding is assumed to be the default and self-corrects on the first
* bindFramebuffer, which the GAL issues several times per frame.
*/
async function installGalFrameCounter(page: Page): Promise<void> {
await page.evaluate(() => {
const w = window as unknown as { __galFrames?: number; __galHooked?: boolean };
w.__galFrames = 0;
if (w.__galHooked) return;
w.__galHooked = true;
const protos = [
(window as unknown as { WebGL2RenderingContext?: { prototype: object } }).WebGL2RenderingContext,
(window as unknown as { WebGLRenderingContext?: { prototype: object } }).WebGLRenderingContext,
].filter(Boolean) as Array<{ prototype: Record<string, unknown> }>;
const state = new WeakMap<object, { fb: unknown; inPresent: boolean }>();
const st = (ctx: object) => {
let s = state.get(ctx);
if (!s) { s = { fb: null, inPresent: false }; state.set(ctx, s); }
return s;
};
const DRAWS = ['drawArrays', 'drawElements', 'drawArraysInstanced', 'drawElementsInstanced', 'drawRangeElements'];
for (const proto of protos) {
for (const name of ['bindFramebuffer', ...DRAWS]) {
const orig = proto.prototype[name] as ((...a: unknown[]) => unknown) | undefined;
if (typeof orig !== 'function') continue;
const isDraw = DRAWS.indexOf(name) >= 0;
proto.prototype[name] = function (this: object, ...args: unknown[]) {
const s = st(this);
if (!isDraw) {
s.fb = args[1];
if (args[1]) s.inPresent = false;
} else if (s.fb === null || s.fb === undefined) {
if (!s.inPresent) { s.inPresent = true; w.__galFrames = (w.__galFrames ?? 0) + 1; }
} else {
s.inPresent = false;
}
return orig.apply(this, args);
};
}
}
});
}
/** Zoom-to-fit, so every measurement starts from the same visible geometry. */
async function resetViewToFit(page: Page, cx: number, cy: number): Promise<void> {
await page.mouse.move(cx, cy);
await page.keyboard.press('Escape').catch(() => {}); // eslint-disable-line -- best-effort
await page.keyboard.press('Home').catch(() => {}); // eslint-disable-line -- best-effort
await page.waitForFunction(() => true, null, { timeout: 5000 });
}
/**
* Sustained interaction FPS, reported two ways.
*
* `galFps` is the real one: completed GAL frames per second (see
* installGalFrameCounter). `rafFps` is the legacy main-thread requestAnimationFrame
* count, kept only so historical CI numbers stay comparable it is NOT a frame
* rate. rAF ticks on the compositor's schedule whether or not the GAL redrew, so
* it can read 120 while the renderer is completely stalled (measured: the 80 MB
* jetson board on a software rasteriser renders 0 frames while rAF reports 120).
*
* The drive is a pure middle-drag PAN. Wheel zoom used to be mixed into the same
* loop, and it makes the metric unusable: zooming continuously changes how much
* geometry is on screen, so the result depends on where the wheel happens to
* leave the view. Measured spread across three identical repeats was ±20%
* (34.7 / 42.1 / 27.1 fps) with zoom in the loop, versus ±2% (19.9 / 19.0 / 19.6)
* for pan alone. Pan also keeps the workload honest it continuously reveals
* geometry that has to be cached, which is the expensive path.
*
* The view is zoomed to fit first, so every run starts from the same visible
* geometry (the whole board the worst case) rather than inheriting whatever
* zoom level the previous measurement left behind.
*/
export async function measureInteractionFps(
page: Page,
seconds: number,
): Promise<{ rafFps: number; galFps: number }> {
const box = await page.locator(MAIN_CANVAS).boundingBox();
if (!box) return { rafFps: 0, galFps: 0 };
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
await resetViewToFit(page, cx, cy);
await installGalFrameCounter(page);
type W = { __perfFrames: number; __perfRAF?: number; __galFrames?: number };
await page.evaluate(() => {
const w = window as unknown as W;
if (w.__perfRAF !== undefined) cancelAnimationFrame(w.__perfRAF);
w.__perfFrames = 0;
w.__galFrames = 0;
const loop = () => {
w.__perfFrames++;
w.__perfRAF = requestAnimationFrame(loop);
};
w.__perfRAF = requestAnimationFrame(loop);
});
const start = Date.now();
let k = 0;
await page.mouse.move(cx, cy);
await page.mouse.down({ button: 'middle' });
while (Date.now() - start < seconds * 1000) {
await page.mouse.move(cx + Math.round(140 * Math.sin(k / 6)), cy + Math.round(90 * Math.cos(k / 7)));
k++;
}
await page.mouse.up({ button: 'middle' });
const elapsed = Date.now() - start;
const counts = await page.evaluate(() => {
const w = window as unknown as W;
if (w.__perfRAF !== undefined) cancelAnimationFrame(w.__perfRAF);
return { raf: w.__perfFrames, gal: w.__galFrames ?? 0 };
});
const secs = elapsed / 1000;
return { rafFps: +(counts.raf / secs).toFixed(1), galFps: +(counts.gal / secs).toFixed(1) };
}
/** /**
* Sustained interaction FPS: drive real pan/zoom on #canvas (the emscripten input * Sustained interaction FPS: drive real pan/zoom on #canvas (the emscripten input
* surface glcanvas-* can be display:none) for `seconds`, counting main-thread rAF * surface glcanvas-* can be display:none) for `seconds`, counting main-thread rAF
* frames. Whatever throttle is currently set applies. * frames. Whatever throttle is currently set applies.
*
* @deprecated rAF ticks are not GAL frames use measureInteractionFps().galFps.
*/ */
export async function measureFps(page: Page, seconds: number): Promise<number> { export async function measureFps(page: Page, seconds: number): Promise<number> {
const box = await page.locator(MAIN_CANVAS).boundingBox(); const box = await page.locator(MAIN_CANVAS).boundingBox();

View file

@ -2,16 +2,17 @@
* Renders the track-only runtime-perf block for the CI-on-main Discord comment. * Renders the track-only runtime-perf block for the CI-on-main Discord comment.
* *
* The perf e2e (tests/kicad/{eeschema,pcbnew}-perf.spec.ts) already writes * The perf e2e (tests/kicad/{eeschema,pcbnew}-perf.spec.ts) already writes
* test-results/perf-{app}.json schema { app, when, loadMs, openMs, fps:[{throttle,fps}] }. * test-results/perf-{app}.json schema { app, when, loadMs, openMs, fps:[{throttle,fps,galFps}] }.
* We read those, fetch the PREVIOUS successful main run's perf via `gh run * We read those, fetch the PREVIOUS successful main run's perf via `gh run
* download` (so we can show a Δ without committing a baseline — stays * download` (so we can show a Δ without committing a baseline — stays
* no-write-back), and format an aligned monospace table (Discord doesn't render * no-write-back), and format an aligned monospace table (Discord doesn't render
* markdown tables, so it goes in a ``` code block). * markdown tables, so it goes in a ``` code block).
* *
* Track-only: nothing here gates the build. A regression past REGRESSION_PCT on * Track-only: nothing here gates the build. A regression past REGRESSION_PCT on
* the stable metrics (loadMs/openMs) is only flagged (a `*`), never failed. FPS * the stable metrics (loadMs/openMs) is only flagged (a `*`), never failed. Both
* is CPU-bound/noisy on CI's headless SwiftShader path, so it's shown but marked * FPS columns are measured on CI's software rasteriser (ANGLE over Mesa llvmpipe,
* indicative. * under Xvfb there is no GPU on the runner), so they are a regression signal
* only and never a user-facing frame rate.
* *
* CLI (from tests/): * CLI (from tests/):
* tsx tools/screenshots/perf-report.ts [--results DIR] [--prev DIR] [--repo owner/repo] * tsx tools/screenshots/perf-report.ts [--results DIR] [--prev DIR] [--repo owner/repo]
@ -26,7 +27,8 @@ export const PERF_APPS = ['eeschema', 'pcbnew'] as const;
const REGRESSION_PCT = 10; // stable-metric regression past this is flagged with `*` const REGRESSION_PCT = 10; // stable-metric regression past this is flagged with `*`
const CI_WORKFLOW = 'ci-ubicloud.yml'; const CI_WORKFLOW = 'ci-ubicloud.yml';
export type Fps = { throttle: number; fps: number }; /** `fps` is the legacy rAF tick count; `galFps` is the real frame rate. */
export type Fps = { throttle: number; fps: number; galFps?: number };
export type PerfData = { app: string; when?: string; loadMs: number; openMs: number; fps: Fps[] }; export type PerfData = { app: string; when?: string; loadMs: number; openMs: number; fps: Fps[] };
export function readPerf(dir: string): Map<string, PerfData> { export function readPerf(dir: string): Map<string, PerfData> {
@ -101,10 +103,32 @@ function fmtMetric(cur: number, prev?: number): string {
return `${cur} ${arrow}${Math.abs(p).toFixed(0)}%${flag}`; return `${cur} ${arrow}${Math.abs(p).toFixed(0)}%${flag}`;
} }
function fmtFps(fps: Fps[]): string { /**
* GAL fps with a Δ vs the previous main run, flagged on REGRESSION.
*
* Higher is better here, so the sign convention is inverted relative to
* fmtMetric: a DROP past the threshold gets the `*`. Only the 1x-throttle
* number drives the flag it is the one that repeats within ~2% (pan-only
* drive, zoom-to-fit before each measurement), so it is safe to act on.
* CI has no GPU, so this is a software-rasteriser redraw rate: useful precisely
* because it is consistent, not because it is the user-facing frame rate.
*/
function fmtGalFps(fps: Fps[], prev?: Fps[]): string {
const triple = fmtFps(fps, 'galFps');
const cur = fps.find((f) => f.throttle === 1)?.galFps;
const was = prev?.find((f) => f.throttle === 1)?.galFps;
if (cur === undefined || was === undefined || was === 0) return triple;
const p = pct(cur, was); // + = faster than before
const arrow = p > 0 ? '▲' : p < 0 ? '▼' : '·';
const flag = -p > REGRESSION_PCT ? '*' : '';
return `${triple} ${arrow}${Math.abs(p).toFixed(0)}%${flag}`;
}
function fmtFps(fps: Fps[], key: 'fps' | 'galFps' = 'fps'): string {
return [1, 4, 6].map((t) => { return [1, 4, 6].map((t) => {
const hit = fps.find((f) => f.throttle === t); const hit = fps.find((f) => f.throttle === t);
return hit ? Math.round(hit.fps) : ''; const v = hit?.[key];
return v === undefined ? '' : Math.round(v);
}).join('/'); }).join('/');
} }
@ -120,7 +144,7 @@ export function buildPerfReport(opts: { resultsDir?: string; prevDir?: string |
if (cur.size === 0) return { block: '', regressed: false }; if (cur.size === 0) return { block: '', regressed: false };
const prev = opts.prevDir ? readPerf(opts.prevDir) : new Map<string, PerfData>(); const prev = opts.prevDir ? readPerf(opts.prevDir) : new Map<string, PerfData>();
const headers = ['app', 'loadMs (Δ)', 'openMs (Δ)', 'FPS 1/4/6']; const headers = ['app', 'loadMs (Δ)', 'openMs (Δ)', 'GAL fps 1/4/6 (Δ@1x)', 'rAF 1/4/6'];
const rows: string[][] = []; const rows: string[][] = [];
let regressed = false; let regressed = false;
for (const app of PERF_APPS) { for (const app of PERF_APPS) {
@ -129,8 +153,9 @@ export function buildPerfReport(opts: { resultsDir?: string; prevDir?: string |
const p = prev.get(app); const p = prev.get(app);
const loadCell = fmtMetric(c.loadMs, p?.loadMs); const loadCell = fmtMetric(c.loadMs, p?.loadMs);
const openCell = fmtMetric(c.openMs, p?.openMs); const openCell = fmtMetric(c.openMs, p?.openMs);
if (loadCell.endsWith('*') || openCell.endsWith('*')) regressed = true; const galCell = fmtGalFps(c.fps, p?.fps);
rows.push([app, loadCell, openCell, fmtFps(c.fps)]); if (loadCell.endsWith('*') || openCell.endsWith('*') || galCell.endsWith('*')) regressed = true;
rows.push([app, loadCell, openCell, galCell, fmtFps(c.fps, 'fps')]);
} }
if (rows.length === 0) return { block: '', regressed: false }; if (rows.length === 0) return { block: '', regressed: false };
@ -138,7 +163,11 @@ export function buildPerfReport(opts: { resultsDir?: string; prevDir?: string |
const line = (cells: string[]) => cells.map((c, i) => pad(c, widths[i])).join(' '); const line = (cells: string[]) => cells.map((c, i) => pad(c, widths[i])).join(' ');
const body = [line(headers), rows.map((r) => line(r)).join('\n')].join('\n'); const body = [line(headers), rows.map((r) => line(r)).join('\n')].join('\n');
const footnote = `${prev.size ? 'Δ vs previous main run. ' : 'no prior main run for Δ. '}` + const footnote = `${prev.size ? 'Δ vs previous main run. ' : 'no prior main run for Δ. '}` +
`* = >${REGRESSION_PCT}% slower (track-only, non-gating). FPS is CI-headless — indicative only.`; `* = >${REGRESSION_PCT}% slower (track-only, non-gating). ` +
`GAL fps = completed GAL frames on CI's software rasteriser (llvmpipe) — a regression signal, ` +
`NOT user-facing frame rate; its Δ/* are computed on the 1x number. rAF is the legacy tick count, ` +
`kept for continuity: it ticks on the compositor's schedule whether or not anything rendered, so ` +
`it can read 120 while the renderer is stalled.`;
return { block: '**Runtime perf** (eeschema + pcbnew)\n```\n' + body + '\n```\n' + footnote, regressed }; return { block: '**Runtime perf** (eeschema + pcbnew)\n```\n' + body + '\n```\n' + footnote, regressed };
} }