pcbjam/docs/wasm-exceptions-experiment.md
Istvan Matejcsok ce9e569fd9 docs: CI build perf findings + parked wasm-exceptions experiment
- ci-build-slowness-findings.md: the full investigation log — measured
  phase breakdowns of the 4h05m baseline, the v121 lock convoy, the
  badly-built official Binaryen Linux tarballs (4-13x slow asyncify),
  allocator/THP/arm64 dead ends, and the validated 1h14m41s result.
- wasm-exceptions-experiment.md: -fwasm-exceptions would shrink the
  asyncify set at the root (pcbnew 338 MB -> 92 MB raw) but is blocked
  by an LLVM br_table codegen bug in emscripten 4.0.2; full resume
  recipe + plumbing patch included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:50:57 +02:00

255 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Experiment: native wasm exceptions (-fwasm-exceptions) to shrink the asyncify/-O2 critical path
**Status: PARKED** (2026-06-11). Compiles and links end-to-end, but blocked by an LLVM
codegen bug in emscripten 4.0.2 — see "The blocker" below. The code changes were tried
locally and then dropped; the full patch is preserved in the appendix of this doc,
together with everything needed to resume.
## Why this matters
After the 3.3x CI win (see `ci-build-slowness-findings.md`), the critical path of the
full build is pcbnew's host-side wasm-opt chain: **asyncify ~5 min + `-O2` ~52 min**
(self-built Binaryen v130 on the Hetzner ccx53). That `-O2` time is a direct function of
how big the asyncified module is — and the module is big because of how exceptions work.
Emscripten's default **JS-based exception handling** routes every potentially-throwing
call that appears inside a try/catch through a JavaScript `invoke_*` trampoline.
Asyncify must treat every JS round-trip as a potential suspension point, so in an
exception-heavy codebase (KiCad + OpenCASCADE + wxWidgets) it ends up instrumenting
nearly every function: pcbnew is **338 MB pre-`-O2`**.
**Native wasm exceptions** (`-fwasm-exceptions`) keep throw/catch entirely inside wasm:
- no `invoke_*` trampolines, no `dynCall` machinery (verified gone in the experiment),
- asyncify's instrumentation set collapses to the genuinely-suspending call graph,
- raw linked pcbnew.wasm measured at **92 MB** (vs 338 MB) before any wasm-opt pass.
Expected payoff: a several-fold reduction in the asyncify + `-O2` wall time on the
critical path, plus smaller shipped binaries.
Browser support is not a concern: Chrome 95+, Firefox 100+, Safari 15.2+.
## What it takes (no KiCad C++ changes)
Pure compile/link-flag change. The one hard rule: **every C++ object in the link must
agree on the EH model** — deps (boost, anything with setjmp like cairo), wxWidgets, and
all KiCad targets. The working flag set, threaded through every compile AND link:
```
-fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=0
```
The appendix below contains the full plumbing as a patch, gated behind `KICAD_WASM_EH=1`
(default 0 = byte-identical to today): `scripts/common/env.sh`, `scripts/deps/build-boost.sh`,
`scripts/deps/build-cairo.sh`, `scripts/build-wxuniversal-wasm.sh`,
`scripts/kicad/build-kicad-target.sh`, and `docker/build.sh` (env passthrough into the
container). Save the appendix diff to a file and `git apply` it to restore.
### Why each flag, and the failures that taught us (in order hit)
1. **`-fwasm-exceptions` alone fails immediately**: harfbuzz (C, uses setjmp) dies with
`clang: error: invalid argument '-fwasm-exceptions' not allowed with
'-enable-emscripten-sjlj'`. JS-based setjmp/longjmp and wasm EH can't mix →
add `-sSUPPORT_LONGJMP=wasm` to every compile.
2. **Stale deps bite at link**: pcbnew link failed with `undefined symbol:
emscripten_longjmp`, traced via `llvm-nm` to `libcairo.a` (its png path uses
setjmp/longjmp). The deps scripts skip-if-stamped, so flag changes do NOT trigger
rebuilds — `rm build-wasm/stamps/cairo.stamp` (and any other C dep with sjlj) and
rebuild. A full `--clean` deps build is the safe path.
3. **`wasm-emscripten-finalize` / `wasm-opt -all` parse failure**: `[parse exception:
popping from empty stack]` on the linked module, with both the emsdk-bundled
binaryen and official v130. emscripten 4.0.2 defaults `WASM_LEGACY_EXCEPTIONS=true`
(the old, pre-standard `exnref`-less encoding) → add `-sWASM_LEGACY_EXCEPTIONS=0`
(it's a `[compile+link]` setting — must be on every compile too, full rebuild).
## The blocker: LLVM codegen bug in emscripten 4.0.2
With all three flags and a fully clean rebuild, pcbnew compiles and links (92 MB module,
no `invoke_*`/`dynCall`), but finalize still fails with the same parse exception — and
this time Binaryen is not at fault. Validating the raw module with V8 itself
(container node 22.16, `node --experimental-wasm-exnref`, `WebAssembly.compile`) gives
the definitive error:
```
Compiling function #96546:"ShapeUpgrade_SplitSurface::Build(bool)" failed:
br_table: label arity inconsistent with previous arity 0 @+52067104
```
The clang/LLVM shipped in emscripten **4.0.2** emits an invalid `br_table` instruction
(branch targets with mismatched stack arity) in OpenCASCADE code when compiling under
wasm EH. The module is malformed at the source; no Binaryen version or flag can fix it.
Latest Binaryen release is still v130, so there is no newer tarball to try either.
## How to resume
1. Bump `ARG EMSCRIPTEN_VERSION=4.0.2` in `docker/Dockerfile` — emsdk **5.0.7** (latest
5.x, conservative) or 6.0.0 — to pick up a newer LLVM with the br_table fix.
Expect ~2.53h for image + full clean deps/wx/kicad rebuild locally.
2. `git apply` the patch from the appendix, then
`KICAD_WASM_EH=1 BINARYEN_VERSION=130 ./docker/build.sh pcbnew --build-deps -j 8`
(use a separate `COMPOSE_PROJECT_NAME` to keep the main build volume intact).
3. If finalize + asyncify + `-O2` succeed: measure the wasm-opt chain vs the current
5 min + 52 min, then a **small Hetzner repro** (never `all` for experiments), then
full e2e.
4. **e2e audit required even if green**: asyncify cannot suspend from inside a `catch`
block (binaryen issue #4470). Any KiCad path that calls a suspending function
(file dialogs, sleeps, network) inside a catch handler will trap. Build once with
`-sASYNCIFY_ASSERTIONS` / asyncify-asserts and exercise the e2e suite to flush
these out before adopting.
## Risk notes
- An emsdk bump changes the compiler for the *whole* project — it must be validated for
the normal (JS-EH) build too, not just this experiment.
- JSPI (the long-term replacement for asyncify) was evaluated and is not viable yet:
Firefox still flag-gates it.
## Appendix: the full plumbing patch
Applies cleanly on top of `a563746`. Save the block below to a file and `git apply` it.
````diff
diff --git a/docker/build.sh b/docker/build.sh
index 268b03d..456f906 100755
--- a/docker/build.sh
+++ b/docker/build.sh
@@ -182,7 +182,8 @@ compile_app() {
# -e EMSDK=/emsdk: `docker compose exec` bypasses the entrypoint that sources
# emsdk_env.sh, so the build shell would lack emcc/embuilder on PATH. Setting
# EMSDK lets scripts/common/env.sh source /emsdk/emsdk_env.sh and activate the toolchain.
- docker compose -f docker/docker-compose.yml exec -e EMSDK=/emsdk kicad-wasm-builder \
+ docker compose -f docker/docker-compose.yml exec -e EMSDK=/emsdk \
+ -e KICAD_WASM_EH="${KICAD_WASM_EH:-0}" kicad-wasm-builder \
"/workspace/scripts/kicad/build-${app}.sh" "${ARGS[@]}"
# Copy output to host-accessible directory.
diff --git a/scripts/build-wxuniversal-wasm.sh b/scripts/build-wxuniversal-wasm.sh
index 29b7f20..385b356 100755
--- a/scripts/build-wxuniversal-wasm.sh
+++ b/scripts/build-wxuniversal-wasm.sh
@@ -137,9 +137,16 @@ if [ $NEEDS_CONFIGURE -eq 1 ]; then
echo "Building wxWidgets in RELEASE mode"
fi
+ # EH model must match the rest of the build (see env.sh KICAD_WASM_EH).
+ if [ "${KICAD_WASM_EH:-0}" = "1" ]; then
+ WX_EH_FLAG="-fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=0"
+ else
+ WX_EH_FLAG="-fexceptions"
+ fi
+
# Include emscripten cache sysroot for zlib headers
- export CFLAGS="-DZ_HAVE_UNISTD_H=1 -I$EM_CACHE_SYSROOT/include ${WX_DEBUG_FLAGS} -fexceptions -pthread -matomics -mbulk-memory"
- export CXXFLAGS="-DZ_HAVE_UNISTD_H=1 -I$EM_CACHE_SYSROOT/include -I$PCRE2_INCLUDE ${WX_DEBUG_FLAGS} -fexceptions -pthread -matomics -mbulk-memory"
+ export CFLAGS="-DZ_HAVE_UNISTD_H=1 -I$EM_CACHE_SYSROOT/include ${WX_DEBUG_FLAGS} ${WX_EH_FLAG} -pthread -matomics -mbulk-memory"
+ export CXXFLAGS="-DZ_HAVE_UNISTD_H=1 -I$EM_CACHE_SYSROOT/include -I$PCRE2_INCLUDE ${WX_DEBUG_FLAGS} ${WX_EH_FLAG} -pthread -matomics -mbulk-memory"
export LDFLAGS="-L$EM_CACHE_SYSROOT/lib/wasm32-emscripten"
emconfigure "$WX_SOURCE/configure" \
diff --git a/scripts/common/env.sh b/scripts/common/env.sh
index 90fb61a..0445f99 100755
--- a/scripts/common/env.sh
+++ b/scripts/common/env.sh
@@ -82,7 +82,22 @@ else
export DEBUG_LDFLAGS=""
fi
-export DEBUG_BUILD BUILD_TYPE DEBUG_CFLAGS DEBUG_LDFLAGS
+# KICAD_WASM_EH=1 (EXPERIMENTAL): native WebAssembly exceptions instead of
+# emscripten's JS-based EH. JS-EH routes every potentially-throwing call inside
+# a try/catch through a JS invoke_* trampoline, which forces asyncify to
+# instrument nearly the whole exception-heavy codebase (pcbnew: 338 MB pre-O2).
+# Wasm EH (-fwasm-exceptions, Chrome 95+/Firefox 100+/Safari 15.2+) keeps
+# exceptions inside wasm — no invoke_*, far smaller asyncify set. ALL C++ must
+# agree on the EH model (deps + wx + kicad): this var feeds every compile.
+# Known limit: asyncify cannot suspend from inside a catch block (binaryen #4470).
+if [ "${KICAD_WASM_EH:-0}" = "1" ]; then
+ # -sSUPPORT_LONGJMP=wasm: setjmp/longjmp must use the same (wasm) machinery;
+ # without it emcc injects -enable-emscripten-sjlj, which clang rejects in
+ # combination with -fwasm-exceptions.
+ export DEBUG_CFLAGS="${DEBUG_CFLAGS} -fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=0"
+fi
+
+export DEBUG_BUILD BUILD_TYPE DEBUG_CFLAGS DEBUG_LDFLAGS KICAD_WASM_EH
# Parallel jobs (default to 1 for memory-constrained environments like Docker)
# Can be overridden with -j N flag or by setting JOBS/PARALLEL_JOBS env vars
diff --git a/scripts/deps/build-boost.sh b/scripts/deps/build-boost.sh
index 8905e79..04da340 100755
--- a/scripts/deps/build-boost.sh
+++ b/scripts/deps/build-boost.sh
@@ -72,6 +72,11 @@ else
BOOST_DEBUG_FLAGS="-O2"
fi
+# EH model must match the rest of the build (see env.sh KICAD_WASM_EH).
+if [ "${KICAD_WASM_EH:-0}" = "1" ]; then
+ BOOST_DEBUG_FLAGS="${BOOST_DEBUG_FLAGS} -fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=0"
+fi
+
# Create user-config.jam for Emscripten
cat > user-config.jam << EOF
using clang : emscripten
diff --git a/scripts/deps/build-cairo.sh b/scripts/deps/build-cairo.sh
index 2580320..9d17e15 100755
--- a/scripts/deps/build-cairo.sh
+++ b/scripts/deps/build-cairo.sh
@@ -65,6 +65,13 @@ else
MESON_DEBUG_FLAGS="'-O2'"
fi
+# Cairo is C, but its png path uses setjmp/longjmp — the SJLJ machinery must
+# match the link (see env.sh KICAD_WASM_EH): without this, libcairo.a keeps
+# emscripten_longjmp references that are undefined under -sSUPPORT_LONGJMP=wasm.
+if [ "${KICAD_WASM_EH:-0}" = "1" ]; then
+ MESON_DEBUG_FLAGS="${MESON_DEBUG_FLAGS}, '-sSUPPORT_LONGJMP=wasm', '-sWASM_LEGACY_EXCEPTIONS=0'"
+fi
+
# Cairo uses meson
cat > cross-file.txt << EOF
[binaries]
diff --git a/scripts/kicad/build-kicad-target.sh b/scripts/kicad/build-kicad-target.sh
index 6ace02b..0dc10f2 100755
--- a/scripts/kicad/build-kicad-target.sh
+++ b/scripts/kicad/build-kicad-target.sh
@@ -193,25 +193,34 @@ log_info "Building KiCad ${APP_NAME} ${KICAD_VERSION} for WASM..."
# Step 5: Set build type
# Use environment DEBUG_BUILD if set, otherwise check local --debug flag
-# -fexceptions is required because wxWidgets is built with exceptions enabled
+# Exceptions are required because wxWidgets is built with exceptions enabled.
+# EH model: JS-based (-fexceptions, default) or native wasm EH
+# (-fwasm-exceptions, KICAD_WASM_EH=1 — see env.sh). Must match deps + wx.
# -matomics -mbulk-memory are required for shared memory (pthreads)
# NOTE: We use -O1 for debug builds because -O0 produces WASM with too many
# locals for V8/Chrome to compile (error: "local count too large").
# -O1 keeps debug info but optimizes enough to stay under V8's limits.
+if [ "${KICAD_WASM_EH:-0}" = "1" ]; then
+ EH_FLAG="-fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=0"
+ log_info "Using native WebAssembly exceptions (-fwasm-exceptions)"
+else
+ EH_FLAG="-fexceptions"
+fi
+
if [ "${DEBUG_BUILD:-0}" = "1" ] || [ $DEBUG -eq 1 ]; then
BUILD_TYPE="Debug"
- EXTRA_FLAGS="-g -O1 -fexceptions -matomics -mbulk-memory"
+ EXTRA_FLAGS="-g -O1 ${EH_FLAG} -matomics -mbulk-memory"
# -gseparate-dwarf puts debug info in a separate .debug.wasm file
# This keeps the main WASM small (~200MB) while preserving full debug info
# DevTools loads the debug file on-demand when debugging
- LINKER_DEBUG_FLAGS="-O1 -g -gseparate-dwarf -fexceptions"
+ LINKER_DEBUG_FLAGS="-O1 -g -gseparate-dwarf ${EH_FLAG}"
log_info "Building KiCad in DEBUG mode (separate DWARF for smaller main binary)"
else
BUILD_TYPE="Release"
- EXTRA_FLAGS="-O2 -fexceptions -matomics -mbulk-memory"
+ EXTRA_FLAGS="-O2 ${EH_FLAG} -matomics -mbulk-memory"
# -O0 at link time skips wasm-opt (which can OOM on large WASM files)
# Compilation is still -O2 for optimized code, but we skip post-link wasm-opt
- LINKER_DEBUG_FLAGS="-O0 -fexceptions"
+ LINKER_DEBUG_FLAGS="-O0 ${EH_FLAG}"
log_info "Building KiCad in RELEASE mode (skipping wasm-opt due to memory limits)"
fi
````