Real threads in the wasm — the plan, and the facts it rests on
nornir:class=design · written 2026-09-19 on loki, after the GPU lanes landed.
RULING 2026-09-28 (owner): a GPU always exists — this plan is PARKED
Read this before any line below. The owner ruled on 2026-09-28:
- A GPU always exists. An integrated GPU counts; SwiftShader and llvmpipe do not. With no hardware adapter, state says "no GPU" plainly — it does not quietly run a CPU lane and call it done.
- GPU-able work goes to WebGPU / wgpu: force layout (all-pairs
layout.wgsland the grid pyramidlayout_grid.wgsl), FDEB (fdeb.wgsl), point prep and the 100k-point graph cloud. - No new CPU fallbacks for that work. Do not delete the existing ones either (
step_cpu,fdeb::bundle, graphnav's gatling FR lane stay: they are the parity references the GPU lanes are tested against, and the lane a GPU-less CI runs). - gatling / wasm workers only for work that cannot go on the GPU, and only when a measurement shows it helps.
So the threads bundle (facet-wrapped-web-threads) and the COOP/COEP front are PARKED. They are un-parked by a MEASUREMENT, not by a design: a browser run that shows a CPU-side stage on the main thread that matters at 100k nodes (the browser_bench harness splits the mount into js_parse_ms, js_stringify_ms, check_ms, first_frame_ms, settle_ms and per-frame app_ui_ms_mean, so the stage has a name when it shows up). The measurements that decide it are in .nornir/facett-benchmarks.md ("GPU lanes at 100k", lane B, 2026-09-29). Everything below is the design as it stood on 2026-09-19; it is kept, not deleted, because it is what gets built if that measurement ever comes.
Why this is not one change
The 2026-09-18 note (gpu-prep-in-the-browser.md) chose WebGPU as the browser's parallelism because real threads need four things at once, in four places:
| # | what | where | state |
| 1 | a fan-out that can run on Web Workers | znippy-zoomies/gatling |
std::thread::scope only (gatling_forkjoin.rs:94,169,248,352,464); on wasm32-unknown-unknown std::thread::spawn is Err(Unsupported) even with atomics, so scope would PANIC, not degrade — today it never spawns because available_parallelism() is Err → 1 worker → inline |
| 2 | a wasm built with shared memory | facet-wrapped, facett-demo | nightly -Zbuild-std=std,panic_abort + -C target-feature=+atomics,+bulk-memory,+mutable-globals; nightly + rust-src + rust-std-wasm32 are installed on loki; wasm-bindgen 0.2.126 supports shared-memory init(module, memory) |
| 3 | a loader that spawns N workers over ONE memory and only when allowed | facet-wrapped loader / index.html |
crossOriginIsolated must be true; otherwise SharedArrayBuffer does not exist and the single-thread bundle must load instead |
| 4 | a host that sends Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp on the TOP page AND the component iframe |
Streamlit (tornado, no header config), gh-pages | neither sends them today; Streamlit needs a header-adding front (proxy or a tornado hook), pages needs _headers support the host may not have |
The design, in order of what can be proven first
- gatling
wasm-threadsfeature.#[cfg(all(target_arch = "wasm32", feature = "wasm-threads"))] use wasm_thread as thread;and everystd::thread::scope/available_parallelismgoes through that alias.wasm_threadspawns a Web Worker per thread over the shared memory and answersavailable_parallelismfromnavigator.hardwareConcurrency. Without the feature nothing changes. Open question to measure, not assume: joining on the MAIN thread.Atomics.wait(and the wasmmemory.atomic.wait32it lowers to) traps on the main thread;wasm_thread's join must spin there. gatling's fan-outs are millisecond-scale (layout steps, scanlines, extrusion) so a spin-join is acceptable if it is a spin; if it parks, the first fan-out hangs the page. A five-line probe page decides this before any port. - A second wasm row,
facet-wrapped-web-threads, declared infacett-build-thingbesidefacet-wrapped-web: same crate, the atomics flags, nightly, a distinctdist-threads/— so the single-thread bundle stays byte-identical and keeps shipping. The build-thing/toml_paritypair holds it. - Loader. Publish
facet_state().threads(1 today). IfcrossOriginIsolated && typeof SharedArrayBuffer !== "undefined"and the threads bundle is present, load it and spawnmin(hardwareConcurrency, 8)workers; else load the plain bundle. The reason is recorded in state (threads_reason: "not-isolated" | "no-bundle" | "ok"), the same honesty rule aslayout_fallback. - Host. For the Streamlit demo: a header-adding front in the container (the image is ours:
xtask/src/containerfiles/streamlit.Containerfile). Caddy or a 40-line tornado wrapper that re-serves Streamlit with the two headers on every response, websockets included. For gh-pages: check whether Codeberg Pages honours a_headersfile; if not, the pages demo stays single-thread and says so. - Proof. The headful robot (
py/tests/robot_streamlit.py --headful) assertsthreads > 1when the page is isolated, andthreads == 1withthreads_reasonset when it is not. Measure one fan-out (graph layout under the GPU threshold, or the SDF scanline raster) single- vs multi-thread in the same browser; the note's rule stands — MEASURE before ranking.
Measured 2026-09-19 (read, not run): wasm_thread refuses the main thread
wasm_thread 0.3.3/src/wasm32/scoped.rs:93 — scope() PANICS unless is_web_worker_thread(): "scope is not allowed on the main thread", because a blocking join lowers to Atomics.wait, which traps on the main thread. Its own mutexes spin (utils.rs:78, SpinLockMutex) for the same reason. So step 1 above cannot be a plain alias: gatling's scope runs on eframe's MAIN thread (the paint path), and wasm_thread::scope there is a panic, not a fan-out.
Two designs remain, and the choice is the decision to make before code:
- A gatling wasm backend that spin-joins on main. Workers are spawned once from JS over the shared memory (the wasm-bindgen-rayon shape);
gatling_for_eachon main pushes its closures to a shared queue and SPINS on an atomic counter until the last worker finishes — legal on the main thread, and gatling's fan-outs are millisecond-scale so the spin is the join. Scoped borrows stay valid because main does not return until every task is done. ~300 lines inznippy-zoomies/gatlingbehindwasm-threads, plus one JS worker script. - Run the whole app in a worker. eframe on an
OffscreenCanvas, main thread only forwards input. Thenwasm_thread::scopeis legal as-is and nothing in gatling changes — but egui's web runner, the component loader'sfacet_*API and the Streamlit handshake all assume the main thread. Bigger, and it moves the whole wheel, not one crate.
The first is the one to build: it changes one crate, keeps the loader, and its failure mode (a spin that never ends) is observable as a stuck threads_reason.
What was tried today
- Nothing compiled yet. The first experiment is the atomics build of
facet-wrappedwith NO code change (cargo +nightly build -Zbuild-std=std,panic_abort --target wasm32-unknown-unknownwith the three target features): it answers whether the dependency tree (wgpu, egui, vello, getrandom, …) even links with shared memory. Result (loki, nightly 2026-09, wgpu 29.0.4): std, wgpu, egui and vello all COMPILE with shared memory; the first failure is facett-core, four errors of one kind:*mut u8 cannot be sent/shared between threads safelyatrender/gpu/graphcloud.rs:1288andrender/gpu/pick_host.rs:166. Those are process-wide statics holding wgpu handles. wgpu'sfragile-send-sync-non-atomic-wasmfeature — which is what lets a wasm build put aDevicein astatictoday — is defined as a no-op whenatomicsis on, so every such static must become a main-thread-only cell (athread_local!, or a wrapper that asserts the main thread) under the threads build. That is the first code change the threads row needs, and it is in facett-core, not in gatling. Crates after facett-core in the graph have not been reached yet; facett-map3d's renderer static and egui-wgpu'sRenderStatestorage are the next suspects. Log:wasm-threads-build.log(scratch), command:RUSTFLAGS="-C target-feature=+atomics,+bulk-memory,+mutable-globals" cargo +nightly build --release --target wasm32-unknown-unknown -Zbuild-std=std,panic_abortinfacet-wrapped/with a separateCARGO_TARGET_DIR.