Repository navigation
webasm: pipelined kernels always degrade to cpu — 17 of 30 real GPGPU workloads never reach WebAssembly #868
Description
Follows #866 (WebAssembly backend). Not a defect claim — that PR states plainly that anything the backend cannot take degrades to cpu. This is a measurement of what that costs on a real workload suite, because the fraction turned out much larger than I expected.
Measurement
I added a webasm column to the gpu.rocks benchmark suite — 30 GPGPU workloads, each cross-checked against a plain-JS reference before timing. Apple M1 Max, Chrome 150, branch build of #866 (11b4c0e).
13 of 30 ran on WebAssembly. 17 of 30 silently ran on cpu instead.
Every one of the 17 was rejected by the same guard, and none for any other reason:
// src/backend/web-assembly/kernel.js, build()
if (this.graphical) return this.requestFallback(arguments);
if (this.subKernels && this.subKernels.length) return this.requestFallback(arguments);
if (this.pipeline) return this.requestFallback(arguments);Minimal reproduction — the kernel body is irrelevant, only the setting matters:
new GPU({ mode: 'webasm' }).createKernel(function (a) { return a[this.thread.x] * 2; })
.setOutput([8])(input); // mode 'webasm'
new GPU({ mode: 'webasm' }).createKernel(function (a) { return a[this.thread.x] * 2; })
.setPipeline(true).setOutput([8])(input); // mode 'cpu'setImmutable(true), setPrecision('single'), 3D output, setDynamicOutput, setLoopMaxIterations, nested loops, while, Math.min/max, added functions, integer division and modulo all compile to WebAssembly fine. It is specifically pipeline (and graphical, and sub-kernels).
Why this bites harder than it looks
Pipelining is not an optimisation people sprinkle on — it is how any multi-pass GPGPU algorithm keeps its working set on the device between passes. In this suite the rejected set is essentially "everything iterative":
bitonic-sort, canny, erosion, fft, gray-scott, heat, ising, jacobi, launch-overhead, life, path-trace, reduction, residency, sobel, spectral-filter, topk, wavefront
An FFT is 14 dependent passes; Conway's Life is 96; a Jacobi solve is a stencil stepped hundreds of times. Those are exactly the shapes someone reaches for a compute backend to accelerate, and all of them get plain cpu.
There is an argument that pipelining is meaningless without device memory to pipeline into — wasm has no textures, and a Texture handle is not a thing the emitter could consume. But the observable behaviour is that a kernel written for GL, run under mode: 'webasm', produces correct results at cpu speed while reporting nothing. Two things would help even without supporting it:
- Make it visible.
kernel.kernel.constructor.modeis the only way to discover the degradation. I only caught it because the benchmark asserts the backend it asked for is the backend that ran. A one-timeconsole.warnnaming the reason, or a queryabledegradedReason, would stop this being silent. - Consider accepting
pipelineas a no-op. On this backend the output is already a plain typed array in linear memory; treating a pipelined kernel as an unpipelined one returning its array would let the 17 run, at the cost of the caller's.delete()/.toArray()expectations. That may well be the wrong trade — but silently halving the backend's reach is also a trade.
What the 13 that did run look like
Not the point of this issue, but for calibration — speed-up against plain JS, and against the cpu backend on the same kernel:
| workload | webasm | vs plain JS | vs cpu backend |
|---|---|---|---|
| monte-carlo | 315 ms | 6.95× | 7.06× |
| ode-rk4 | 661 ms | 4.06× | 4.06× |
| gradient-descent | 460 ms | 3.98× | 3.97× |
| sdf-march | 385 ms | 2.30× | 2.60× |
| nbody | 255 ms | 2.25× | 2.27× |
| matmul | 857 ms | 1.63× | 3.62× |
| ncc-template | 776 ms | 1.33× | 6.69× |
Zero WRONG and zero errors across all 30 rows — where it runs, it is correct. Checksums matched the plain-JS reference within 1e-4 relative on every row.
Activity
feat(webasm): accept pipeline kernels; name every cpu degradation (#868)
fuzzie360 commentedon Aug 3, 2026
Both remedies land on
feature/webasm-backend(PR #866, 998744b) — and the bigger one turned out to be free.pipeline: trueis now accepted, not degraded. The precedent was already in the tree: the cpu backend accepts pipeline by returning plain arrays, and cross-backend code falling back to cpu already had to handle that. webasm's readback is a fresh typed-array copy per call, so the contract is the cpu one with stricter reuse semantics (no mutable clobbering). Ping-pong self-feeding is safe — arguments copy into wasm memory before the run, output copies out after — and pinned by tests, along with kernel chaining and result freshness. The 17 rejected workloads' guard is simply gone; onlygraphicaland kernel maps still degrade.The remaining degradations are no longer silent.
requestFallbacknow carries a reason: the console warning names it (Falling back to CPU: kernel maps are not supported on the webasm backend) and it survives the kernel swap askernel.kernel.fallbackReason, exactly the queryable form this issue asked for. The GL backends' fallback sites (unsupported kernel-value types, Array(n) output under unsigned precision) name their reasons too.The benchmark suite's backend assertion should now see
webasmon the 15 pipeline-only rows;histogramandreductionusecreateKernelMap-free pipelines so they qualify as well — the only rows that still legitimately degrade are any using graphical mode or kernel maps. Worth a re-run to get the real coverage number.fuzzie360 commentedon Aug 3, 2026
Re-run done, on published 2.22.0 rather than the branch. The coverage number is the best possible one:
30 of 30 workloads now execute on WebAssembly. Zero degradations, zero WRONG, zero errors.
Not 15 of them, not "all but graphical" — every row in the suite. Nothing left reports a
fellBackTo. Confirmed the same way as before, by asserting the backend that ran is the backend that was asked for, viakernel.kernel.constructor.mode.A full re-record rather than merging the webasm column into the old table, because 2.22.0 is not 2.21.0 plus a backend —
web-gl,web-gl2,cpu,gland three shared core files all changed, carrying #864, #865 and #867. Those add per-invocation locals inside the kernels being timed, so a column-only merge would have left the other columns describing code that no longer exists.The correctness fixes cost essentially nothing. Median change per column between 2.21.0 and 2.22.0, same machine (Apple M1 Max, apple metal-3, Chrome 150), 30 rows each:
The number that got worse, and why that is correct
Worth stating plainly, since it will look like a regression in any chart drawn from these two runs:
WebAssembly did not get slower. The 17 rows it previously declined were the ping-pong ones — Life, heat, Jacobi, Gray–Scott, the FFTs, the sorts — and those are precisely where a single core does worst against a GPU-shaped kernel. The 1.5× was a self-selected sample of the work WebASM happened to be able to take, and it was always going to fall once the sample stopped selecting itself.
The range across the full 30: best 7.0× (Monte Carlo option pricing), worst 0.03× (top-k selection).
That is a more useful column than the flattering one it replaces. A reader deciding whether WebAssembly is a viable fallback needs to know it is roughly a wash with plain JavaScript across a broad workload mix, wins several-fold on compute-bound arithmetic, and loses badly on anything iterative — not that it averages 1.5× on the subset it agreed to run.
Happy for this to close from my side.
bench: the gauntlet runs on pipeline compilation, gpu.js 2.23.0