Skip to content

WebAssembly backend: masked f32x4 SIMD + worker threads, one step above cpu - #866

Merged
fuzzie360 merged 12 commits into
developfrom
feature/webasm-backend
Aug 3, 2026
Merged

WebAssembly backend: masked f32x4 SIMD + worker threads, one step above cpu#866
fuzzie360 merged 12 commits into
developfrom
feature/webasm-backend

Conversation

@fuzzie360

@fuzzie360 fuzzie360 commented Aug 2, 2026

Copy link
Copy Markdown
Member

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:

  • Scalar — a compiled run(start, end, seed) looping cells in wasm.
  • SIMDf32x4, four cells per step, and divergent control flow vectorizes with masks: per-lane comparison masks, v128.bitselect blends of every branch-assigned local, any_true active-mask loops for lane-varying trip counts, break/continue/early-return as mask operations. Every kernel gets run_simd; scalar and SIMD are bit-identical by construction (no reassociation), which the suite asserts element-for-element.
  • Threaded — under the async contract (asyncMode / mode: 'async'), outputs ≥ 4096 cells split across a lazy worker pool (hardwareConcurrency, 4 when unreadable; capped by a poolSize setting) on shared memory — browser Worker and Node worker_threads from one closure-free source, one structured-cloned Module per worker, the main thread never blocking.

Types follow the WGSL node's semantics (numeric promotion, / always float) — with real i32 bitwise ops, a first for this library. Math.random is the same PCG as webgpu: bit-exact under randomSeed, 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.

Workload cpu headlessgl webasm scalar webasm SIMD webasm threaded
matmul 512×512 331.8 ms 4.5 ms (73.6×) 159.4 ms (2.1×) 84.6 ms (3.9×) 16.2 ms (20.5×)
4M-element map 6.9 ms 15.9 ms (0.4×) 10.4 ms (0.7×) 4.7 ms (1.5×) 3.5 ms (2.0×)
divergent piecewise, 1M cells 7.6 ms 4.2 ms (1.8×) 6.5 ms (1.2×) 4.7 ms (1.6×) 1.7 ms (4.4×)

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), and createKernelMap regressing 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 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 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-invocation cellShadow_ local at kernel start, mirroring the cpu backend's per-cell shadows. The two scoped rows in test/issues/865-cpu-control-flow.js now 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: true no 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 queryable fallbackReason on 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 a WebAssembly.Memory invisible to JS heap accounting) is now LRU-bounded, and evicted or destroyed entries are scrubbed down to the worker-side instantiations.

Verification

  • 62 Node-runnable tests in 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.
  • The dev server now sends cross-origin-isolation headers, so SharedArrayBuffer exists 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).
  • Node 2687/0, headed M1 browser 4059/0 (0 crashes), SwiftShader headless failure set byte-identical to the develop baseline.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx

fuzzie360 and others added 5 commits August 3, 2026 03:50
…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
fuzzie360 and others added 6 commits August 3, 2026 08:18
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
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
fuzzie360 merged commit 7a3ceb8 into develop Aug 3, 2026
3 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant