pcbjam/docs/features/async/11-asyncify-nesting-raytracer.md
2026-06-19 09:22:58 +02:00

149 lines
9.8 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.

# 11 — Asyncify can't nest: why the WASM raytracer is single-core
> **Status:** finding + decision. The multi-core raytracer was built, measured (~67× on
> 10 cores), and **parked** because the only way to make it cooperate with the browser main
> thread — `emscripten_sleep` to yield between work batches — aborts when it runs inside the
> 3D viewer's already-suspended call stack. Authored 2026-06-18 while landing the 3D viewer
> (Route C, CPU raytracer). Code: `kicad/3d-viewer/3d_rendering/raytracing/render_3d_raytrace_base.cpp`
> (and `image.cpp`, `3d_canvas/create_layer_items.cpp`). Parked pool: `git -C kicad stash`
> (`WASM_RAYTRACE_POOL`). Repro: `tests/apps/standalone/raytrace-threads/` +
> `tests/e2e/coroutine-raytrace.spec.ts`.
## TL;DR
- The native raytracer fans work out across a **thread pool** and joins with
`futures.wait()` (`render_3d_raytrace_base.cpp:307`). Several post-process passes
(shading, blur, anti-alias preview, the `EfxFilter` in `image.cpp`, and the zone/segment
builds in `create_layer_items.cpp`) follow the same `spawn N threads → main thread
sleep_for-busy-wait → join` shape.
- The WASM build has **no `-sPROXY_TO_PTHREAD`** and runs the renderer on the **browser main
thread**. That topology breaks the native shape two different ways:
1. **A plain join deadlocks.** `futures.wait()` / `std::this_thread::sleep_for()` blocks
the one thread that the web workers need in order to be scheduled and to post their
results back. Nothing progresses.
2. **A "yield instead of block" join aborts.** The obvious fix — spawn workers, then
`emscripten_sleep(…)` on the main thread to pump the event loop instead of
busy-waiting — **aborts with `Aborted(invalid state: 1)`**. This is the headline
finding: **`emscripten_sleep` cannot nest on an Asyncify context that is already
mid-unwind**, and the 3D viewer always runs inside such a context.
- **Shipped fix:** every parallel section runs **serially on the calling thread** under
`#ifdef __EMSCRIPTEN__`. The renderer is progressive by design (it drains work up to a
per-frame time limit and re-schedules via the render-state machine), so single-core still
paints the board — just slower. This trades the ~67× for correctness and zero new
infrastructure.
## The machine: one Asyncify slot, and the viewer is already using it
This is the same single-`Asyncify.currData` register documented across this dossier
([`02-asyncify-internals.md`](02-asyncify-internals.md),
[`07-decisions-and-outcome.md`](07-decisions-and-outcome.md)). The relevant property here:
- `emscripten_sleep(ms)` is an **Asyncify suspend point**. To return control to the browser
it calls `asyncify_start_unwind`, copies the live C stack into the global suspension
buffer, sets `Asyncify.state = Unwinding (1)`, and throws out to JS. When the timer fires,
JS calls back in and `asyncify_start_rewind` replays the stack.
- Emscripten **asserts that you cannot start a second async operation while one is in
flight** — `Asyncify.state` must be `Normal (0)` at the entry of a new suspend. Starting an
unwind while `state == 1` is exactly the `invalid state: 1` abort.
The 3D viewer never runs from a clean stack. It renders from inside wx's **modal / nested
event-pump** (`ShowModal`, the nested-loop pump — see D5 in
[`07-decisions-and-outcome.md`](07-decisions-and-outcome.md)), and that pump is itself
implemented with an Asyncify suspend (it `await`s a JS `ProcessEvents` ccall). So at the
moment `Redraw()` runs, the stack is **already unwound/suspended once**. A worker-join that
calls `emscripten_sleep` to yield is then a **second** suspend on the **same** context →
`state` is already `1` → abort.
```
wxGUIEventLoop pump ──emscripten_sleep──► state = Unwinding(1) (the pump is parked here)
└─ ProcessEvents → … → EDA_3D_CANVAS::DoRePaint → raytracer Render()
└─ spawn workers; main thread wants to yield
└─ emscripten_sleep ──► start_unwind while state==1 ──► Aborted(invalid state: 1)
```
This is **not** the `currData`-contention bug (that one is about *overlapping distinct*
contexts trampling one buffer). This is simpler and more fundamental: **you cannot suspend a
context that is already suspended.** Asyncify is one level deep, full stop. Fibers each carry
their own buffer, but `emscripten_sleep` always targets the global one.
## What we tried (in order), and why each failed or was rejected
1. **Native shape as-is (thread pool + `futures.wait()` / `sleep_for` join).**
**Deadlock.** Main thread blocks; workers can't be serviced. Never paints.
2. **Spawn workers, `emscripten_sleep` to yield on the main thread instead of busy-waiting.**
**`Aborted(invalid state: 1)`** the instant it runs in the *real* viewer. Worked in a
*standalone* harness (`raytrace-threads/`) only because there the render is called from a
clean stack, not from inside a modal pump — which is precisely why the standalone repro
was misleading and the bug only showed up integrated.
3. **Persistent worker pool + main-thread busy-wait (no `emscripten_sleep` at all).**
**Worked and was fast (~67× on 10 cores).** Rejected anyway: it busy-waits the browser
main thread for the whole render (jank, fans, blocks input), and it adds a standing worker
pool + SAB plumbing to maintain. Parked, not deleted — it's in `git -C kicad stash`
(`WASM_RAYTRACE_POOL`) behind a `WASM_RAYTRACE_POOL` gate, with its repro harness.
4. **Serial on the calling thread (SHIPPED).**
→ No nesting, no busy-wait, no new infra. `processBlocks()` / `shadeWorker()` /
`blurWorker()` / `previewWorker()` / `filterWorker()` are each invoked directly under
`#ifdef __EMSCRIPTEN__`; the native `tp.submit_task(...)` + `futures.wait()` path stays
for non-WASM. Progressive rendering keeps the UI responsive across frames.
## The standalone test suite — and what it does / does NOT prove
`tests/e2e/coroutine-raytrace.spec.ts` drives the one-binary harness
(`tests/apps/standalone/raytrace-threads/`, switchable by URL `#m=`). It maps almost
1:1 onto the ladder above. **Polarity rule: a test is green IFF the mechanism genuinely
runs multi-core (`workersRan > 1`); a freeze or a single-thread fallback is never a green
pass.**
| `#m=` | Variant | Ladder step | Test verdict |
|---|---|---|---|
| 0 | A — detached threads + main-thread busy-wait | step 1 (deadlock) | **negative control**, `test.fail()` — held to `workersRan > 1`, can't meet it (deadlocks → `workersRan=0`), reported as an *expected* failure (self-recovers after a 12 s cap) |
| 1 | B1 — detached + `emscripten_sleep` yield | step 2 | green (multi-core in isolation) |
| 2 | B2 — persistent pool + `emscripten_sleep` | step 2 | green |
| 4 | B1 with stack-local atomics (mirrors the raytracer's shared locals) | step 2 | green |
| 5 | B3 — persistent pool + `sleep_for` busy-wait (the parked port) | step 3 | green |
| 3 | C — serial | step 4 | not a standalone pass; used only as the slower baseline in the speedup test |
**Critical caveat — the suite reproduces Bug 1, not Bug 2.** The harness runs from a clean
`OnInit`, so mode 0 faithfully reproduces the **worker-spawn deadlock** (step 1: a
main-thread busy-wait starves the event loop, the on-demand worker is never created). But
**no** standalone test reproduces the actual current blocker — the `Aborted(invalid state:
1)` Asyncify-nesting abort (step 2 in the *real* viewer) — because that requires rendering
from inside an already-suspended modal pump, which a clean stack never does. So the
emscripten_sleep variants (m=1/2/4) are green here yet abort the real viewer; B3 (m=5) is
green here and *also* works integrated, but is parked for jank (step 3). The suite proves
the *threading mechanisms in isolation*; it is **not** a gate on the shipped viewer, which
stays serial. A test with teeth against Bug 2 would need to fake the enclosing suspend
(render inside a modal-pump `emscripten_sleep`, then attempt the worker-join) — and would
currently be a genuine red.
## The open question (what would unpark the multi-core path)
The build **already ships `emscripten_fiber_swap`** (it's how tool coroutines and the parked
main loop work — [`02`](02-asyncify-internals.md), [`06`](06-design-b-fiber-first-runtime.md)).
A fiber owns its **own** suspension buffer, so a *fiber* swap is not bound by the single
global-slot assertion the way `emscripten_sleep` is. So the real question is:
> Can the worker-join yield via a **nestable** mechanism — a fiber swap, or JSPI — instead of
> `emscripten_sleep`, so it can suspend even though the enclosing modal pump is already
> suspended?
That is unverified and needs its own red test before re-landing. The fiber-first runtime in
[`06-design-b-fiber-first-runtime.md`](06-design-b-fiber-first-runtime.md) is the natural home
for it: if modal pumps and render yields were both scheduler-owned fiber contexts, "render
yields while the pump is parked" becomes a normal context switch instead of an illegal nested
unwind. Until then, single-core is the correct answer.
## Practical guidance for future work in this area
- **Never call `emscripten_sleep` from code that can run inside a modal / nested event loop.**
It will abort, not just block. The viewer, dialogs, and progress reporters all qualify.
- **A standalone harness that calls your code from a clean stack will not reproduce this.**
The nesting only happens through the wx pump. Test integrated, or fake the enclosing suspend.
- **Prefer "drain up to a time budget, then return and let the state machine re-schedule"**
over "block until done." The raytracer already works this way; lean on it.
- **If you need real parallelism**, the path is a *nestable* yield (fiber/JSPI), not
`emscripten_sleep`, **or** `-sPROXY_TO_PTHREAD` so the render runs off the main thread (a
much larger architectural change for this build).