WebAssembly backend: masked f32x4 SIMD + worker threads, one step above cpu - #866
Merged
Conversation
…SIMD, worker threads Four files, no wiring yet (next commit): wasm-builder.js: a dependency-free wasm binary emitter -- types, imports (env.memory + used math), functions, exports, full i32/f32 arithmetic, control flow, memory ops, and the SIMD-128 subset including the mask toolkit (bitselect, and/andnot, any_true, lane comparisons). Signed LEB128 verified by executing boundary constants (-64/-8193/INT32_MIN). function-node.js: WebAssemblyFunctionNode extends the base node. An analysis pass resolves types (replicating the WGSL node's semantics: promotion, / and % always f32, real i32 bitwise), then a bytecode pass per size signature. Math.random is the same PCG as webgpu -- native i32 makes randomSeed bit-exact here too. SIMD emission vectorizes divergent control flow with mask predication: per-lane comparison masks, bitselect blends of branch-assigned locals, any_true active-mask loops for lane-varying trip counts, break/continue/early-return as mask operations; scalar run() and run_simd are bit-identical by construction. kernel.js: memory layout with baked offsets rebuilt per size signature, argument upload, sync run (scalar or SIMD), threaded run under the async contract only (>= 4096 cells), unsigned precision accepted as single, unsupported shapes degrade via requestFallback. worker-pool.js: lazy pool, hardwareConcurrency || cpus || 4, one structured-cloned Module per worker, shared memory, ranges aligned to the SIMD stride, browser Worker / Node worker_threads from one closure-free source. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
kernelOrder is now headlessgl -> webgl2 -> webgl -> webasm, cpu still the final fallback; internalKernels gains 'webasm'; GPU.isWebAssemblySupported; exports, d.ts, README (Supported Backends row and a WebAssembly section honest about GL outranking it in auto-selection and the SharedArrayBuffer isolation caveat). test/features/webasm: 46 Node-runnable tests across five files -- basics, arguments and constants, control flow, random (PCG determinism across pool sizes), simd-and-threads (scalar/SIMD bit-identity incl. divergent kernels, pool instrumentation, threaded == sync). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
Two critical, four major, six minor -- every one confirmed by execution before fixing, every fix verified against the reviewer's own repro. - createKernelMap no longer throws where webasm displaced cpu in the auto chain: the guard lets WebAssemblyKernel fall through, and its build() degrades kernel maps to cpu via requestFallback, restoring what every pre-webasm release did in GL-less environments. - A worker that dies is retired and replaced -- its in-flight tasks reject, the next dispatch spawns a successor, nothing hangs; verified with a simulated death mid-sequence. - Node workers are ref'd only while a setup or task is in flight and unref'd when idle, so a script that runs a threaded kernel exits on its own -- and cannot exit mid-dispatch (the first attempt unref'd during setup and the process left silently; the fix windows setup too). - A kernel revived after destroy re-registers with its GPU, so a second gpu.destroy() reaches the revived pool. - SIMD short-circuit RHS side effects behind a lane-uniform left operand now blend correctly: the RHS is conditionally executed regardless of the left's variance, so its update targets taint unconditionally. Reviewer's exact repros pinned, varying-left regression held. - Assigning to an array-typed argument throws a clear deferral in both emitters instead of a TypeError (SIMD) or a silent memory clobber of the first argument (scalar). - Graphical webasm kernels create their canvas element at construction and the cpu fallback renders into that same element -- but ONLY an uncommitted canvas is inherited: handing a GL-committed canvas to cpu broke its 2d context (the browser suite caught the first version of this fix on the image-array module). - test/features/webasm/fallbacks.js pins every degradation path; control-flow.js gains three shapes compared against per-cell plain-JS references, because the cpu backend is provably wrong on all three (filed as #865, pre-existing). - The dev server now sends the cross-origin-isolation headers, so SharedArrayBuffer exists in the browser suite and the threaded path is actually exercised in Chromium; thread tests runtime-skip where isolation is absent. - README's mode-'async' chain updated (headlessgl -> webgl2 -> webgl -> webasm -> cpu), kernel-map wording made truthful, poolSize documented and declared in index.d.ts. Node 2687/0, headed browser 4059/0 with zero crashes and browser threading live, SwiftShader failure set identical to baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
Same methodology as benchmark-webgpu.mjs -- every mode cross-checked against cpu before timing, ping-ponged inputs, medians -- but no browser: this backend's benchmark runs where the backend does. The divergent workload prices mask predication honestly (both branch sides execute per lane) and SIMD still beats scalar on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
The cpu backend is the suite's reference, and on three legal-JavaScript shapes the reference itself was wrong: - An early return inside a user loop emitted `result[x] = V; continue;`, and the continue targeted the USER's loop, so execution fell through and later statements overwrote the result -- every cell got the post-loop value. The kernel body now sits in a `kernelBody:` labeled block and a root return emits `break kernelBody;`, which exits from any nesting depth in every cell-loop template without touching one. - do-while was emulated as a for-loop with a trailing exit check, which `continue` skipped -- the accumulator repro gave 61 where JS gives 48. The emission is now a native do-while (continue jumps to the test, as JS defines) with the loopMaxIterations cap riding in the condition and a per-node counter name so nesting works. - Arguments were bound once for the whole run, so `base = base + x` leaked into every later cell. Assigned arguments now get a per-cell shadow (`user_X$cell` -- `$` cannot appear in sanitized user names), with reads routed through it in both the identifier and the member-expression emitters, so a reassigned ARRAY argument reads the new array too. test/issues/865-cpu-control-flow.js pins all three against plain-JS references across backends, plus the reassigned-array read, the preserved iteration cap, and nested do-whiles; 5 of 10 runnable assertions fail with the fix reverted. Two pinned function-composition emissions move to the labeled-block form. The cross-backend rows exposed that the GL backends fail the do-while and assigned-argument shapes by their own mechanisms -- filed as #867; those rows run on cpu and webasm until it is fixed. Closes #865 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
… assignable (#867) The GL backends emulate do-while as a for loop with the exit test at the end of the body, so a bare continue skipped the test and reran the body unconditionally. Each loop-level continue now gains a copy of the exit check in front of it — the same transform normalizeLoopHeader already applied to hoist-affected loops, applied pre-trace so the cloned test nodes are traced like the original. Scalar arguments are GLSL uniforms, so assigning to one was a shader compile error. Assigned scalar arguments now get a per-invocation cellShadow_ local at kernel start with references routed through it, mirroring the cpu backend's user_X$cell shadows; the argument-assignment scan moves from the cpu node to the base FunctionNode so both share it. The two cross-backend rows in test/issues/865-cpu-control-flow.js that were scoped to cpu+webasm now run on every backend. Closes #867 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
This was referenced Aug 2, 2026
pipeline: true no longer degrades to cpu — 17 of 30 gpu.rocks workloads were losing the backend to that one guard. There is no device memory to pipeline into, so the contract is the cpu backend's: the result is the plain typed array the run already produces, a fresh copy per call, valid as input to any downstream kernel. Ping-pong self-feeding is safe because arguments copy into wasm memory before the run and output copies out after. The degradations that remain (graphical, kernel maps, texture/image arguments, unsupported return types) are no longer silent: requestFallback takes a reason, the console warning names it, and it stays queryable as `fallbackReason` on the surviving cpu kernel. The GL backends' fallback sites name their reasons too. Closes #868 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
…#869) Measured on the two gpu.rocks shapes reported 20-32x slower than plain JS: the cpu backend runs within 2% of hand-written JavaScript of the same transposed algorithm, and webasm within +/-1.5x (its SIMD gather beats plain JS on the compaction shape). The factor is the gather-for-scatter rewrite doing bin-count/log-factor more reads, paid serially - not an emitter defect. README notes the pricing. Closes #869 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
The likeliest shape of the one-off renderer crash: every size signature instantiates a module over its own WebAssembly.Memory, which is near-invisible to JS heap accounting (the crashed run's main-thread heap read a flat 11-14 MB) and pins a large virtual guard reservation in Chrome, so dead memories from a 30-workload sweep can accumulate faster than a pressure-blind GC retires them. The cache is now LRU-bounded (moduleCacheLimit, default 8) and evicted or destroyed entries are scrubbed: every reference dropped, and for shared entries the worker pool now releases the worker-side instantiations that would otherwise keep the shared buffer alive forever - deferred past the threaded tail so an in-flight dispatch keeps what it captured. Revisiting an evicted signature re-instantiates and stays correct, sync and threaded. Closes #870 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
The two #867 regressions dissolve together: the do-while emulation now ROTATES the loop (exit test at the top, skipped on the first iteration) instead of rewriting continues, so the semantics hold inside switch lowerings and unbraced bodies with no synthetic AST at all. Diagnostics on loc-less synthetic nodes report '[synthetic node]' instead of crashing the error path. Argument shadows harden: Boolean shadows lose the bool() wrap (not an lvalue on assignment targets), Integer shadows cast literal right sides to int, and a `var`-redeclared parameter is one binding again -- the declaration's own local shadows the argument, so the per-cell shadow scan excludes it. The cpu shadow moves out of the user_ namespace (cellShadow_user_X) since cpu names are never sanitized and $cell could collide. webasm: dead browser workers are terminated, not just retired (an uncaught worker exception does not kill the thread, and the zombie pinned every wasm memory it instantiated); destroy() under queued threaded runs rejects cleanly instead of TypeErroring on the scrubbed entry; self-typed arguments (GL textures) to a built kernel force a kernel switch that degrades through the usual fallback instead of crashing in flattenTo; the variance analysis records argument updates in expression position (`let y = a++`) so SIMD hosts run them instead of refusing. The run shortcut's switch loop now delivers the result when the switched kernel itself degrades at build (its run() reports null on fallback); the fallback cpu kernel carries the switch/fallback hooks so later argument-type changes switch instead of throwing. index.d.ts gains fallbackReason, WebAssemblyKernel (moduleCacheLimit, poolSize), and WebAssemblyFunctionNode. Tests: the missing `skip` import no longer crashes the module on non-isolated browsers, SIMD-path assertions gate on wasm SIMD support, and the threaded eviction test now observes the worker-side release it names. New regression files pin every shape above; the GL edge shapes fail 5 ways on the pre-review tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
The smoke suite predated the backend, so device runs validated GL and cpu while exercising zero webasm code. Every per-mode check now also runs on webasm (texture-handle assertions stay GL-only -- webasm's pipeline contract is plain arrays), plus device truths the qunit suite cannot capture there: kernels stay on WebAssemblyKernel rather than silently degrading, the pipeline chain works, asyncMode holds without SharedArrayBuffer (BrowserStack pages are not cross-origin isolated), and env records the simd-vs-scalar run path per device. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
fuzzie360
added a commit
to gpujs/gpu.rocks
that referenced
this pull request
Aug 3, 2026
gpu.js now points at gpujs/gpu.js#feature/webasm-backend (PR gpujs/gpu.js#866) and the table gains a WebASM column between WebGL and CPU — where it sits in gpu.js's own kernelOrder, since any working GL backend outranks it and it outranks the cpu fallback. MERGING A BRANCH-BUILT COLUMN INTO A PUBLISHED-2.21.0 TABLE is only honest if the other columns describe the same library, so that was checked rather than assumed: web-gl, web-gl2, web-gpu, cpu, gl and every shared core file are byte-identical between the branch and the installed 2.21.0. The branch's only changes to shared code are additive — registering the kernel, appending it to kernelOrder (auto-mode only; our columns pin explicit modes), and a graphical fallback canvas fix. --columns <ids> measures named columns across every row and merges just those cells, which is 30 new cells rather than 30 re-measured rows. The plain-JS baseline is re-measured alongside because the runner needs it for the checksum comparison that decides whether a new column is RIGHT rather than merely fast — but it is not merged. It is compared against the stored one, and the merge aborts if the machine itself moved. That guard was wrong twice before it was right. First it used mean and max, so one row drifting 331% blocked a merge where the other 29 averaged 4%; it uses the MEDIAN now, because one row behaving differently is a fact about that row and not evidence the machine changed underneath the table. Second, it refused by exiting, throwing away nine minutes of measurement; it writes the fresh results aside before deciding now. RESULT: 13 of 30 workloads run on WebAssembly, 17 degrade to cpu, zero errors and zero WRONG. Every one of the 17 is rejected by the same guard — pipeline, graphical or sub-kernels — and none for any other reason. Best is monte-carlo at 6.95x plain JS; worst are compaction and histogram, correct but 20-32x SLOWER than the JavaScript they were transpiled from. Filed against the PR: #868 (pipelined kernels always degrade, with what that costs on a real suite), #869 (the two pathological shapes, which the cpu backend shares, so it is likely upstream of the emitter), #870 (a renderer crash seen once in three passes and not reproduced since). The recorder also names the workload a renderer crash happened on, and samples the JS heap alongside status — a detached-frame stack said nothing about what was being measured. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RJujXBsBhXBbQ72tS7E7GV
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A WebAssembly backend:
new GPU({ mode: 'webasm' }), sitting in the automatic fallback chain one step above cpu (headlessgl → webgl2 → webgl → webasm → cpu) — any working GL backend outranks it, so auto modes only reach it where no GL context exists, and everything it cannot take degrades to cpu rather than throwing.Kernels compile to real WebAssembly binaries through an in-house, dependency-free emitter (no toolchain, no WAT text). Three execution tiers:
run(start, end, seed)looping cells in wasm.f32x4, four cells per step, and divergent control flow vectorizes with masks: per-lane comparison masks,v128.bitselectblends of every branch-assigned local,any_trueactive-mask loops for lane-varying trip counts,break/continue/early-returnas mask operations. Every kernel getsrun_simd; scalar and SIMD are bit-identical by construction (no reassociation), which the suite asserts element-for-element.asyncMode/mode: 'async'), outputs ≥ 4096 cells split across a lazy worker pool (hardwareConcurrency, 4 when unreadable; capped by apoolSizesetting) on shared memory — browserWorkerand Nodeworker_threadsfrom one closure-free source, one structured-clonedModuleper worker, the main thread never blocking.Types follow the WGSL node's semantics (numeric promotion,
/always float) — with reali32bitwise ops, a first for this library.Math.randomis the same PCG as webgpu: bit-exact underrandomSeed, including across any worker split.precision: 'unsigned'is accepted and computed as single — wasm has no packed storage to be lossy in.Performance (Apple M1 Max, Node 22 — this backend benchmarks where it runs)
Every mode cross-checked against cpu (≤1e-4 relative) before timing; ping-ponged inputs; medians. Reproduce with
node scripts/benchmark-webasm.mjs.Honest reading: on compute-bound kernels GL remains ~5× faster than even threaded webasm — which is why webasm sits below every GL backend in the chain. But on transfer-bound work (the map row) webasm beats both cpu and GL — no upload, no readback, shared memory — and SIMD's ~2× over scalar holds even on the divergent workload, where mask predication executes both branch sides per lane. Threading scales ~5× over SIMD on 10 cores for compute-bound work.
Adversarially reviewed before this PR
Built by a staged multi-agent workflow (binding integration contract → emitter → translator → SIMD → threads → integration), each phase executing its wasm and proving parity against cpu before handing off. A three-reviewer adversarial pass then confirmed 12 findings by execution — all fixed and re-verified, including: worker death wedging the pool (now retire-and-respawn), Node process pinning by idle workers (ref'd only while work is in flight — the naive fix exits mid-dispatch, which the reviewer predicted), a SIMD short-circuit predication hole (uniform-left
&&/||executing RHS side effects), andcreateKernelMapregressing in GL-less environments.The review also proved the cpu backend wrong against plain JavaScript on three control-flow shapes (early-return-in-loop, do-while-continue, scalar-argument mutation leaking across cells) — filed as #865; the webasm suite pins those shapes against per-cell JS references, and webasm matches JS on all three.
Also fixes #865
The cpu control-flow divergences this PR's review discovered are now fixed here too — the webasm suite's plain-JS-referenced fixtures made the cpu fix's acceptance tests nearly free. Early return inside a loop (labeled-block break), do-while continue (native do-while with the iteration cap in the condition), and per-cell argument shadowing all now match plain JavaScript, pinned across backends in
test/issues/865-cpu-control-flow.js. The same rows exposed GL-side failures of two shapes — filed as #867.Also fixes #867
The GL siblings of two of those shapes are fixed here as well. The GL backends emulate do-while as a for loop with the exit test at the end of the body, so a
continueskipped the test and reran the body unconditionally — each loop-level continue now gains a copy of the exit check in front of it, the same transform the #300 loop normalization already applied to hoist-affected loops. And scalar arguments are GLSL uniforms, so assigning to one was a shader compile error — assigned scalar arguments now get a per-invocationcellShadow_local at kernel start, mirroring the cpu backend's per-cell shadows. The two scoped rows intest/issues/865-cpu-control-flow.jsnow run on every backend.Also fixes #868, #869, #870
The gpu.rocks measurement pass over this branch filed three follow-ups, resolved here. #868:
pipeline: trueno longer degrades to cpu — the result is the plain typed array the run already produces (the cpu backend's own pipeline contract), which returns 17 of 30 benchmark workloads to the backend; the degradations that remain carry a named reason on the console warning and a queryablefallbackReasonon the surviving kernel. #869: measured against hand-written JS of the same transposed algorithm, the cpu backend is within 2% and webasm within ±1.5× — the reported 20–32× is the gather-for-scatter rewrite's own work multiplier paid serially, documented in the README, not an emitter defect. #870: the per-size-signature module cache (each entry pinning aWebAssembly.Memoryinvisible to JS heap accounting) is now LRU-bounded, and evicted or destroyed entries are scrubbed down to the worker-side instantiations.Verification
test/features/webasm/— scalar/SIMD bit-identity including divergent kernels and remainder cells, PCG determinism across pool sizes, threaded == sync, every cpu-degradation path pinned, pool instrumentation asserted.SharedArrayBufferexists in the browser suite and browser threading is genuinely exercised in Chromium, not silently skipped; thread tests runtime-skip where isolation is absent (e.g. BrowserStack targets).🤖 Generated with Claude Code
https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx