pcbjam/docs/features/async/11-asyncify-nesting-raytracer.md

149 lines
9.8 KiB
Markdown
Raw Normal View History

# 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).