Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

.nornir/gpu-prep-in-the-browser.md

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

  1. facett-graphnav's gpu feature is default = [].
  2. facet-wrapped/Cargo.toml names facett-graphnav with no features, so force_selected's #[cfg(feature = "gpu")] arm is not compiled and every graph takes the CPU lane.
  3. facett-graphview::gpu_layout — which IS compiled into the wheel, because facet-wrapped names graphview features = ["gpu"] — acquires its device with pollster::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:

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.

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 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

  1. Add force_layout_gpu_on-style device injection and a RenderState seam; keep the existing self-acquiring entry point for native callers and tests.
  2. Turn on facett-graphnav/gpu in facet-wrapped and let force_selected route.
  3. 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.
  4. 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.