Skip to content

WebGPU backend + async as a kernel property (asyncMode, mode 'async') - #856

Open
fuzzie360 wants to merge 14 commits into
developfrom
feature/webgpu-backend
Open

WebGPU backend + async as a kernel property (asyncMode, mode 'async')#856
fuzzie360 wants to merge 14 commits into
developfrom
feature/webgpu-backend

Conversation

@fuzzie360

@fuzzie360 fuzzie360 commented Jul 28, 2026

Copy link
Copy Markdown
Member

Summary

Two things, in dependency order:

  1. A WebGPU backend: new GPU({ mode: 'webgpu' }). Kernels compile to WGSL compute shaders running over f32 storage buffers — no float packing, no texture data layout, no fragment-shader detour, because WebGPU has the primitives WebGL made us emulate. The API in this mode is async: kernel(args) returns a Promise (WebGPU readback is inherently async), resolving to the same result shapes the other backends return synchronously. pipeline: true resolves to a GPU-resident WebGPUBufferResult handle that downstream kernels accept without readback. Opt-in only, never auto-selected.

  2. Async as a property any kernel can have — the generalization the WebGPU contract made obvious:

    • asyncMode: true (or .setAsyncMode(true)) makes every call return a Promise on every backend. On webgl2 it buys a genuinely non-blocking readback: readPixels into a PIXEL_PACK_BUFFER, a fence, zero-timeout clientWaitSync polled via MessageChannel tasks (dodging the nested-setTimeout clamp), then getBufferSubData once signaled. webgpu is natively async; cpu/webgl/headlessgl resolve their synchronous result — the calling contract is uniform everywhere.
    • mode: 'async' is auto-selection under that contract: pick the best provable backend now (webgl2 → webgl → cpu), force asyncMode on, and let the first kernel call upgrade to webgpu once an adapter has actually answered — the async contract is exactly what buys the room to probe. A declined or failed upgrade falls back to the proven kernel. Write await kernel(...) once and the same code runs on webgpu, webgl2, or cpu with no fallback rewrite.

Performance

Apple M1 Max, Chromium headed; WebGL2 = ANGLE Metal, WebGPU adapter = apple/metal-3. Results cross-checked between modes (worst relative error ≤ 2.2e-6) before any timing; medians after warmup; every timed run synced by reading back its own result; inputs ping-ponged so no dispatch is elidable. Reproduce: node scripts/benchmark-webgpu.mjs.

Throughput

Workload cpu webgl2 (single) webgpu
matmul 512×512 (incl. readback) 284 ms 3.40 ms (83.5× vs cpu) 1.80 ms (157.8× vs cpu)
matmul 1024×1024 (incl. readback) 2340 ms 18.5 ms (126.5× vs cpu) 6.30 ms (371.5× vs cpu)
4M-element map (incl. readback) 6.25 ms 12.9 ms (0.5× vs cpu) 8.90 ms (0.7× vs cpu)
3-kernel pipeline chain, final readback only 18.5 ms 36.6 ms (0.5× vs cpu) 9.00 ms (2.1× vs cpu)
3-kernel chain, excl. final readback 6.35 ms 5.80 ms

Honest reading: compute-bound kernels run ~2–3× faster than WebGL2 and readback is dramatically cheaper (see the chain row: 4× end-to-end); trivially memory-bound single kernels remain transfer-dominated in every GPU mode, exactly as on WebGL.

Main-thread availability — what asyncMode buys

30 × matmul 512×512 with readback, 4 ms heartbeat running beside it (node scripts/benchmark-async.mjs):

configuration backend wall time main thread blocked worst single stall
webgl2 (sync) WebGL2Kernel 105 ms 101 ms (96.3%) 105 ms
webgl2 + asyncMode WebGL2Kernel 234 ms 3.3 ms (1.4%) 4.8 ms
webgpu WebGPUKernel 77 ms 14.9 ms (19.4%) 9.6 ms
mode 'async' (→ webgpu) WebGPUKernel 65 ms 4.5 ms (7%) 4.9 ms

The sync loop freezes the page for its entire duration in one 105 ms stall. asyncMode trades ~4 ms per-readback latency (Metal fence-completion granularity) for a main thread that never stalls past 5 ms — for UI-adjacent GPU compute, that's the whole game. Pipeline intermediates and await only final results to amortize the latency.

Scope (v1)

WebGPU supports: scalar and Array(2/3/4) returns; 1D/2D/3D outputs (large 1D dispatches fold across Y past the 65,535-workgroup limit); array / TypedArray / Input / nested-array arguments, numbers, booleans; constants; user functions; dynamicOutput; pipeline handles with refcounted buffers. Deferred with clear WebGPU backend does not yet support … errors: kernel maps, graphical, toString(), precision: 'unsigned', Math.random — in mode: 'async' these decline the upgrade and stay on the GL backend rather than erroring. The upgraded kernel is built with dynamicArguments so it is never stricter about argument sizes than the GL kernel-switching it replaces.

Verification

  • New suites: 9 webgpu files (60 async QUnit tests) + test/features/async-mode.js (16 tests) — the async suite covers the Promise contract per backend, rejection-not-throw, the upgrade firing exactly when an adapter answers, chained setters landing before the upgrade decision, pipeline handles across the upgrade, and all three webgl2 transfer shapes against their synchronous reads.
  • The main-thread-availability test is verified discriminating by mutation: wrap the blocking read in Promise.resolve() and exactly that test fails.
  • Full suites: headed M1 browser 3806/0 (0 crashes), Node 2526/0, SwiftShader headless failure set identical to the 131-test known baseline (byte-for-byte diff of clean HEAD vs branch).
  • Bring-up bugs found by the suites and fixed on the branch: pipeline-handle reuse by its producing kernel, immutable output-buffer lifetime, folded-dispatch thread.x corruption past 2²¹ elements, and the upgraded webgpu kernel refusing grown arguments that GL kernel-switching absorbs.
  • README documents mode: 'webgpu', mode: 'async', and asyncMode; index.d.ts now carries the webgpu statics, both new modes, asyncMode, and setAsyncMode.

Adversarially-verified review (round 2)

A 19-agent multi-dimension review (5 specialist finders → per-finding adversarial verifiers, each instructed to refute) produced 34 raw findings; 14 were verified, 12 confirmed with concrete mechanisms, 2 refuted with evidence. All 12 are fixed on the branch (c147681, ec91b1c, 3be52bb), each with a regression test where the failure is testable:

  • Critical, silent wrong results (2): a break behind an if inside a switch case compiled to WGSL that breaks the enclosing loop (now throws, matching the GL backends); outputs/arguments past the device's storage-binding limit resolved fully-shaped all-zeros results (the device now requests the adapter's real limits and throws a named error at the actual ceiling).
  • WGSL codegen (3): mixed int/float ternaries rounded the float branch away (now promote to f32); prefix ++i emitted invalid WGSL (now postfix); helpers named after WGSL builtins lost their definitions to premature name-mangling (registry now keyed by original names).
  • Async-contract seams (5): arguments are sampled at call time again (the review's nastiest find — const p = k(buf); buf[0] = 9 used to poison the queued run); asyncMode now survives kernel switches and CPU fallback; the mode-'async' upgrade only swaps to a webgpu kernel whose full async build succeeded, harvests per-kernel functions correctly, and no longer masks real errors behind a silent re-run; webgpu pipeline handles reaching a kernel that stayed on GL are read back transparently; combineKernels refuses the async contract with a clear error.
  • Robustness (2): device loss now rejects with a rebuild instruction instead of resolving handles over dead buffers; a rejected mapAsync no longer strands pooled staging buffers.

New tests: test/internal/wgsl-codegen.js (7 tests, pure Node, 6 fail with the fixes reverted) plus 7 additions to the async-mode and webgpu-errors suites. Post-fix verification: Node 2539/0, headed M1 3821/0, SwiftShader failure set byte-identical to the develop baseline.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx

fuzzie360 and others added 6 commits July 29, 2026 05:57
…rs, async API

A fourth GPU backend targeting WebGPU compute. Kernels compile to WGSL and
run as compute dispatches over storage buffers, which retires the machinery
WebGL required: no float packing, no texture layout for data, no fragment
shader detour. Single-precision semantics only; f32 storage end to end.

The API is necessarily async -- WebGPU readback is -- so in this mode
kernel(args) returns a Promise, resolved with the same result shapes the
GL backends return synchronously. pipeline:true resolves to a GPU-resident
WebGPUBufferResult handle (refcounted like GL textures) that downstream
kernels accept directly, including the producing kernel itself under
immutable:true, which hands each call its own output buffer. The mode is
opt-in (mode:'webgpu'), never auto-selected: GPU.isWebGPUSupported is the
sync navigator.gpu check and GPU.isWebGPUAvailable() actually requests an
adapter. Deferred for v1, throwing clear errors: kernel maps, graphical
mode, toString serialization, unsigned precision, Math.random.

WGSLFunctionNode extends the base FunctionNode, inheriting all type
inference; emission follows WGSL's strictness (every float literal carries
a decimal point, every cast explicit). Large 1D dispatches fold across Y
when X would exceed the per-dimension workgroup limit, with the flat index
reconstructed from the dispatch width -- and this.thread.* always reads the
reconstructed index, never raw global_invocation_id; mapping thread.x to
gid.x directly corrupted exactly the folded rows of a 4M-element kernel
while every smaller kernel stayed green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
basics, arguments, constants, math, control-flow, returns, dynamic-output,
pipeline and errors, all async (await kernel(), await gpu.destroy()), all
guarded twice: isWebGPUSupported skips registration where navigator.gpu is
absent (Node, headlessgl), and a webgpuAdapter() helper no-ops cleanly
where the API exists but no adapter does (headless Chromium, SwiftShader).
The pipeline file caught two real backend gaps during bring-up: a handle
fed back to its own producing kernel, and immutable output-buffer
handling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
Drives all three modes over the same kernels in one headed-Chromium
session, cross-checks results between modes before timing anything, and
prints the markdown table the PR carries. Methodology follows the lessons
this repo has already paid for: warmup then median of N, every timed run
synced by reading back its own result, inputs ping-ponged so no dispatch
is elidable by the tile cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
…he backend

asyncMode (setting or setAsyncMode) makes every call return a Promise of
the usual result on every backend. webgl2 honors it with a genuinely
non-blocking readback: readPixels into a PIXEL_PACK_BUFFER, a fence, and
zero-timeout clientWaitSync polled through MessageChannel tasks (skipping
the nested-setTimeout clamp), then getBufferSubData once signaled. webgpu
is natively asynchronous; cpu, webgl and headlessgl resolve their
synchronous result so the calling contract is uniform.

mode 'async' is auto-selection under that contract: pick the best
synchronously-provable backend, force asyncMode on, and let the first
kernel call upgrade to webgpu once an adapter has actually answered --
the Promise contract is exactly what buys the room to probe. A declined
or failed upgrade falls back to the proven kernel; the upgraded kernel is
built with dynamicArguments so it is never stricter about argument sizes
than the GL kernel-switching it replaced.

30 rounds of matmul 512x512 with readback, 4 ms heartbeat beside it
(Apple M1 Max, headed Chromium): sync webgl2 blocks the main thread for
96% of the loop in one 105 ms stall; asyncMode webgl2 blocks 1.4% with no
stall over 5 ms, trading ~4 ms per-readback latency for it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
16 tests: the Promise contract on every backend, the setting and setter
forms, toggling between calls, rejection instead of synchronous throw,
mode 'async' auto-selection, the webgpu upgrade happening exactly when an
adapter answers, chained setters landing before the upgrade decision
(caught the upgraded kernel refusing grown arguments the GL path
absorbs), pipeline handles across the upgrade, all three webgl2 transfer
shapes against their synchronous reads, and a main-thread-availability
test verified discriminating by mutation: wrap the blocking read in a
Promise and it fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
Holds the workload fixed (30x matmul 512 with readback) and measures the
time a 4 ms heartbeat beside it was starved -- the number asyncMode
exists to change, which the throughput benchmark cannot see.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
@fuzzie360 fuzzie360 changed the title WebGPU backend: WGSL compute shaders, async kernels, pipeline buffers WebGPU backend + async as a kernel property (asyncMode, mode 'async') Jul 29, 2026
fuzzie360 and others added 7 commits July 29, 2026 10:16
A New-in-2.20.0 section, a supported-backends table with measured
performance factors (webgl and headlessgl measured for this on the same
matmul-1024 workload: 26.9 ms and 19.0 ms against cpu's 2340 ms), and
notice that v3 makes async the default -- with the apology that section
owes: the synchronous design was not forward-thinking, a GPU is an
asynchronous device and the API should have started async. Includes the
five-step migration guide; mode 'async' gives v3 semantics today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
… case

The release-notes bullet list becomes two documentation sections in the
style of the other New! features, with examples and the two-tier feature
detection. The v3 notice now argues the breakage is earned twice over:
measured performance (3x matmul, 4x chains, no 100 ms readback stalls)
and accuracy -- no lossy RGBA packing round-trip, true integer division
instead of fixIntegerDivisionAccuracy patches, integer buffer addressing
instead of float texture-coordinate math, and a smaller driver-bug
surface than GLSL recompiled by whatever each machine ships (#300).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
WebGPU, Asynchronous Kernels and the v3 notice move out of the intro to
the end of the feature documentation; the New-in-2.20.0 notice moves
from the section titles into the sections. The backends table stays up
top as overview. TOC follows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
The whole section -- warning admonition, apology, the case for the
breakage, migration guide -- now sits before the table of contents,
opening with a GitHub [!WARNING] block. First TOC entry points at it;
WebGPU and Asynchronous Kernels stay with the feature docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
- a break behind an if inside a switch case now throws (the GL backends'
  containsBreak guard, ported): emitted into the if-chain lowering, WGSL
  would read it as breaking the enclosing loop -- silent wrong results
- a ternary with an integer consequent and float alternate promotes to
  f32 instead of rounding the alternate (0.5 became 1); getType agrees
- prefix ++/-- emit the postfix statement form, the only one WGSL has
- astCallExpression keeps the ORIGINAL function name for FunctionBuilder
  and type-inference traffic, mangling only the emission: a helper named
  after a WGSL builtin (cross, length) used to lose its definition

test/internal/wgsl-codegen.js pins all four in plain Node -- translation
needs no device -- and fails 6/7 with the fixes reverted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
Past maxStorageBufferBindingSize the submit is silently dropped and the
zero-initialized staging buffer resolves a fully-shaped all-zeros result;
the device now requests the adapter's real limits instead of the 128 MiB
spec default, and output/argument/constant buffers throw a named error at
the device's actual ceiling before allocating.

A lost device flags the shared context; runs and pipeline readbacks
reject with a rebuild instruction instead of resolving handles over dead
buffers forever, and a rejected mapAsync no longer strands its pooled
staging entry busy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
- arguments are sampled at call time again: asyncRun executes the
  build/run synchronously and defers only the readback, so mutating an
  input after the call cannot poison the queued run; the one inherently
  deferred path (the mode-'async' upgrade probe) snapshots its arguments
- asyncMode survives onRequestSwitchKernel and the CPU fallback -- the
  Promise contract no longer flip-flops per argument signature
- the upgrade hook awaits the webgpu kernel's full async build and only
  ever swaps to one that is proven to build; a declined upgrade logs its
  real reason under debug and there is no failed-first-run rerun left to
  mask errors with; per-kernel functions/nativeFunctions/injectedNative
  are harvested from the kernel instance, not the GPU's empty lists
- a webgpu pipeline handle reaching a kernel that stayed on GL is read
  back transparently under the async contract, and the sync path's
  KernelValue error now explains the mixed-backend situation
- combineKernels refuses the async contract with a clear error instead
  of feeding Promises into kernels

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
@robertleeplummerjr

Copy link
Copy Markdown
Member
image

@robertleeplummerjr robertleeplummerjr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It warms my heart to see this today. Wow! Nice work.

gpu.rocks/learn is a free fifteen-lesson course built on this library.
The new section leads with what makes it worth linking: it teaches GPGPU
rather than an API, and says concretely what transfers -- this.thread is
threadIdx/global_invocation_id/get_global_id wearing different clothes,
and the transfer-cost, pipelining, reduction, precision and honest-
measurement lessons are hardware lessons that hold in CUDA and Metal
too. A one-line pointer sits up in the intro, where a newcomer looks.

The site no longer uses a hash router, so links move to the canonical
form the pages themselves declare (link rel=canonical): #/examples ->
/examples, and http -> https everywhere, including package.json's
homepage, which the browser-header banner interpolates into all four
dist bundles. All five gpu.rocks URLs verified 200.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants