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

9.8 KiB
Raw Permalink Blame 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, 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 flightAsyncify.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), and that pump is itself implemented with an Asyncify suspend (it awaits 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, 06). 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 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).