GPU preparation in the browser — what exists, what is unreachable, what it would take
nornir:class=design
Written 2026-09-18, from reading the tree. Nothing here is implemented and nothing is benchmarked in a browser. It scopes one defect, recorded as row 13 of facet-wrapped-wheel.md: the wheel's CPU preparation lanes run on one thread, because std::thread::available_parallelism() is Err(Unsupported) on wasm32 and gatling correctly collapses to a single inline worker.
WebGPU does not fix that by itself. WebGPU is the render; layout, extrusion and meshing are preparation, and they happen before a pixel is drawn. A wide GPU canvas can be handed a frame that took one core to prepare.
The three reasons the graph layout is single-core, each one line
facett-graphnav'sgpufeature isdefault = [].facet-wrapped/Cargo.tomlnamesfacett-graphnavwith no features, soforce_selected's#[cfg(feature = "gpu")]arm is not compiled and every graph takes the CPU lane.facett-graphview::gpu_layout— which IS compiled into the wheel, becausefacet-wrappednames graphviewfeatures = ["gpu"]— acquires its device withpollster::block_on(instance.request_adapter(…))(gpu_layout.rs:477, 489, 681, 693). On the browser's main thread that is a deadlock, not a slow path.
(1) and (2) are a feature line. (3) is the real work.
The shape of the fix: borrow the device, do not make one
The demo already has a wgpu::Device and Queue — eframe's WebGPU backend created them before the first frame. gpu_layout ignores them and builds its own instance, adapter and device, which is merely wasteful natively and impossible in wasm.
So the port is an injection seam, not a rewrite of the shader:
// today (native only, blocking):
pub fn force_layout_gpu(n: usize, edges: &[(usize, usize)], iters: usize) -> Option<Vec<[f32; 2]>>
// the seam it needs (works in both):
pub fn force_layout_gpu_on(dev: &wgpu::Device, q: &wgpu::Queue, …) -> Option<Vec<[f32; 2]>>
force_layout_gpu_on already exists — the module's own measured table names it. What is missing is a caller that hands it the host's device instead of one it made itself, and a host seam that carries (&Device, &Queue) from the eframe render state down to the layout call. egui_wgpu::RenderState is that handle natively and in wasm alike.
The readback stays async-friendly: positions come back through buffer.map_async + a poll, which is how the browser wants it and is what the native path already does under its blocking wrapper.
What else is already on the GPU, and what is still CPU preparation
Compute shaders that exist in the tree today:
| lane | where |
| force-directed graph layout | facett-graphview/src/gpu_layout.rs (+ cs_layout WGSL) |
| building extrusion | facett-map3d-flight/src/extrude_gpu.rs + extrude.wgsl |
| atmospheric scattering LUT | facett-map3d-flight/src/scatter_gpu.rs + scatter_lut.wgsl |
| CDLOD terrain morph | facett-map3d-flight/src/cdlod.rs + cdlod_morph.wgsl |
| label collision + indirect draw | facett-core/src/render/gpu/label_collide.rs |
| boids / particles | facett-core/src/render/gpu/particles.rs |
Preparation that is still CPU-side, and is therefore single-core in the browser — candidates in rough order of how well they suit a compute shader:
FDEB edge bundling— DONE 2026-09-19,facett-graphview/src/fdeb_gpu.rs+fdeb.wgslbehind thegpufeature.bundle_gpu_on(&device, &queue, …)returns the sameVec<BundledEdge>asfdeb::bundle; compatibility stays on the CPU (O(E²) once, uploaded as CSR), theiterations × E × mforce loop is the kernel, ping-ponging two storage buffers. Parity test on the RTX 5080: max |Δ| under 1e-2 over a 100×100 field after 60 iterations. MEASURED (loki,timing_cpu_vs_gpu, GPU time includes the CPU compatibility pass and the readback):
| edges × 60 iters | CPU | GPU |
| 500 | 2.46 s | 30 ms |
| 2 000 | 38.2 s | 212 ms |
NATIVE only in practice: the readback blocks (map_async + poll); the browser needs the LayoutJob shape, not written. No caller uses it yet — this is the lane, not the wiring.
-
map3d extrusion in the non-flight crate— STRUCK, 2026-09-18. This entry said "the GPU twin exists one crate over; a wiring question, not a new shader". Both halves were wrong. Measured: 90 000 buildings extrude in 164 ms on ONE core, once, at mount — there is nothing to win. And the twin extrudes flat prisms only, where this lane also builds pitched roofs, colours, relief, water and building stamps, byte-identical to the serial order. See row 13, "The extrusion, measured". The lesson is the one at the bottom of this note, learned the expensive way round: MEASURE before ranking. -
Barnes–Hut tree build (
facett-core/src/barnes_hut.rs) — harder: pointer-ish forest is a poor compute fit. The GPU lane sidesteps it with all-pairs repulsion instead, which is whygpu_layoutbeats the O(n log n) CPU lane at 100k. -
SDF scanline raster (
facett-core/src/render/cpu/mod.rs) — the L0 kernel already has a GPU form behindfacett-core/wgpu; in the wheel the render is already WebGPU, so this matters least. -
Aggregation for the data panes (binning, histograms, top-k) — not written yet in either form; a natural compute lane if the table/stat components ever carry large row counts client-side.
The other road: real threads in wasm
Option 3 from the 2026-09-18 conversation, stated so it is not confused with the above. It is actual threads, not a host call:
- the module is built for
wasm32-unknown-unknownwith atomics + bulk-memory, linked against a sharedmemory; - the JS side starts N Web Workers, each instantiating the same module over the same
SharedArrayBuffer; std::thread::available_parallelism()then answers honestly and gatling fans out with no change to any facett code — which is the appeal.
The cost is the page must be cross-origin isolated (COOP: same-origin + COEP: require-corp). The demo's component is a cross-origin iframe served by Streamlit's own static component server, which sets no such headers, so this is a hosting change before it is a code change. It also means a second wasm artifact (the atomics build) unless every host can be isolated.
Note this is NOT znippy's pattern. znippy's wasm plugins get multicore because the HOST is native Rust under wasmtime (znippy-common/src/plugins/wasm/wasm_loader.rs: env::host_decompress hands bytes to lgz/lbzip2/ljar, worker-per-core, and writes results back through the guest's alloc). In a browser the host is JavaScript on the main thread and cannot fan out synchronously — so the same trick needs workers and Atomics.wait underneath it, which is this option, not a lighter one.
Status, 2026-09-18 (same day)
Steps 1 and 2 below are DONE and on main. One correction to the plan above, found by doing it: borrowing the device was necessary and NOT sufficient. force_layout_gpu_on already took a device, and it still could not run in a browser — iterate ends in poll(wait_indefinitely) and the readback does rx.recv(), and WebGPU has neither (a map callback fires only when the event loop runs). So the seam grew a second half, gpu_layout::LayoutJob: the same dispatches, chunked across polls, read back through a callback. It is held EQUAL to the blocking lane by a test on a real device.
Step 3 is open, and is now askable: state_json().canvas.layout_lane == "gpu-job".
Order of work, if it is taken
- Add
force_layout_gpu_on-style device injection and aRenderStateseam; keep the existing self-acquiring entry point for native callers and tests. - Turn on
facett-graphnav/gpuinfacet-wrappedand letforce_selectedroute. - Measure in a browser — the number nobody has. Until then row 13's claim is a read of the code, and the size of the problem is unknown.
- Only then consider FDEB and the extrusion wiring.
Threads (the cross-origin-isolated build) are a separate decision and should not be bundled with any of the above.