pcbjam/docs/features/wasm-exceptions/02-measurements.md
Viktor Vaczi 66ce367703 docs: asyncify currData + wasm-exceptions research dossiers
Two research dossiers under docs/features:
- async/: Asyncify currData contention — the clipboard crash and post-idle
  tool hang as one disease (single global suspension slot, three producers),
  asyncify internals walkthroughs, prior art, arbiter designs A/B.
- wasm-exceptions/: -fexceptions vs -fwasm-exceptions — measured invoke-driven
  share of the asyncify tax on pcbnew (59% raw / 64% gzip; download 64.5->36 MB
  if migrated), KiCad dialog-in-catch audit (85 direct / 93 review of 636,
  catch_audit.py included), binaryen toolchain status (partial EH support since
  v125; TryTable unsupported), and a catch-arm-hoisting pre-pass design that
  would obsolete the KiCad-side refactor. Cross-linked with the parked
  KICAD_WASM_EH end-to-end experiment (docs/wasm-exceptions-experiment.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:02:59 +02:00

5.1 KiB
Raw Blame History

02 — Measurements: our controlled experiment + published benchmarks

§1. Our experiment (pcbnew, 2026-06-10)

Question: how much of the asyncify size tax is invoke-driven (i.e., would vanish under wasm-EH)?

Method: take the saved pre-asyncify artifact output/pcbnew.finalized.wasm (111 MB, post-link, post-finalize, pre-asyncify) and run the production post-link pipeline (apply-asyncify.sh: wasm-opt --asyncify + wasm-opt -O2, Binaryen v121) three ways, into output/measure/ (originals untouched):

  1. O2-only (no asyncify) — the floor; not a shippable config (the app needs asyncify).
  2. Asyncify, env.invoke_* dropped from the imports list — the wasm-EH proxy.
  3. Asyncify, full imports — the shipped configuration (control).

Control sanity check: variant 3 reproduced the production pcbnew.wasm size exactly (187 MB), so the pipeline replication is faithful.

Results:

Variant Raw gzip -9
O2-only floor 77.6 MB 19.8 MB
Asyncify w/o invokes (wasm-EH proxy) 122.1 MB 36.0 MB
Asyncify full (shipped) 187.3 MB 64.5 MB

Decomposition:

  • Total asyncify tax today: +109.7 MB raw = +141% over floor (+44.7 MB gzip). That is ~2× the worst-case figures in the published literature — KiCad is an extreme asyncify case, and the invoke graph is why.
  • Invoke-driven share of the tax: 59% raw (65.2 MB), 64% gzip (28.5 MB). This is the part wasm-EH eliminates.
  • The remaining no-invoke tax (+44.5 MB, +57% over floor) is the legitimate cost of the real suspend chains (fibers + modal/clipboard sleeps) — in line with the published ~50% — and is kept under any design.

Headline: wasm-EH ≈ download 64.5 → 36.0 MB (44%), module 187 → 122 MB (35%), before counting runtime. The real wasm-EH build differs from the proxy in both directions: it also deletes the invoke wrappers + JS-EH glue outright (smaller still) while adding wasm-EH's own try/catch instructions (~+4% per published prototype data).

Caveats: (a) the no-invoke proxy is semantically unsound under -fexceptions — with invokes present in JS but uninstrumented in wasm, the first suspension crossing a try frame corrupts state; measurement artifact only, never ship. (b) The finalized input is a May 26 snapshot while today's apps are newer — irrelevant for the relative comparison (same input, same tool, only the imports list varies). (c) Runtime gain is not directly measurable from these artifacts; the size-tracks-speed heuristic (Zakai) plus the published reports below are the basis for the runtime estimate.

§2. Published benchmarks and field reports (researched 2026-06-10)

No clean head-to-head JS-EH vs wasm-EH benchmark of a large C++ app exists; triangulate:

Asyncify instrumentation tax (the thing our invoke graph inflates):

Source Workload Numbers
Alon Zakai, "Pause and Resume… Asyncify" (2019, canonical) multiple benchmarks size ~+50% typical, ≤2× worst; speed tracks size; 22% Fannkuch; 5× pathological when one huge function dominates (SQLite 150→300 KB)
wa-sqlite (production, measured) SQLite wasm default asyncify +70.6%+35.3% after narrowing instrumentation to the ~6 actually-async entry points (auto-detection had instrumented 700+ functions)
Wasmer PHP/WordPress (production) PHP setjmp/longjmp via asyncify 1.5× size, 2× slower; replacing with wasm-EH SjLj: 2× faster cold start (120→60 ms), separately 4× (100→25 ms)
WordPress Playground auto-detected asyncify list ~70,000 functions instrumented, startup +4.5 s, fixed by manual list

JS-EH (invoke_*) tax:

Source Workload Numbers
OpenCascade.js (CAD kernel — closest cousin to KiCad's code character) STEP file load global JS-EH ~10 min → ~2 min (~5×) with narrowed EH; "performance degradation and file size impact is currently massive"
emscripten discussion #17526 JS-EH → wasm-EH "not just 2× faster, but an order of magnitude" (anecdote)
Emscripten docs (Box2D) -fno-rtti -fno-exceptions 15% size (RTTI+EH combined; only weak isolation)

wasm-EH's own cost (what we'd pay instead):

Source Numbers
Heejin Ahn, TPAC slides (prototype) +4% whole-module instructions, +11% in exception-using functions
V8 / Emscripten docs non-throwing path ~zero cost by design

Most transferable to us: wa-sqlite's 70.6→35.3 (the narrowing effect — ours measured 5964%) and OpenCascade's ~5× (exception-dense C++ file loading — our LoadBoard path). Triangulated runtime expectation for KiCad: 1.53× on load/parse/geometry paths, more on exception-dense I/O paths.

§3. Reproducing

# control (uses the project script unmodified):
./scripts/common/apply-asyncify.sh output/pcbnew.finalized.wasm output/measure/pcbnew.asyncify-full.wasm
# proxy: same script with ASYNCIFY_IMPORTS="env.__asyncjs__*,env.emscripten_fiber_swap"
# floor: $(scripts/common/get-wasm-opt.sh | tail -1) -O2 in.wasm -o out.wasm
# then: ls -l + gzip -9c | wc -c