WebGPU backend + async as a kernel property (asyncMode, mode 'async') - #856
Open
fuzzie360 wants to merge 14 commits into
Open
WebGPU backend + async as a kernel property (asyncMode, mode 'async')#856fuzzie360 wants to merge 14 commits into
fuzzie360 wants to merge 14 commits into
Conversation
…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
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
Member
robertleeplummerjr
approved these changes
Jul 29, 2026
robertleeplummerjr
left a comment
Member
There was a problem hiding this comment.
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
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
Two things, in dependency order:
A WebGPU backend:
new GPU({ mode: 'webgpu' }). Kernels compile to WGSL compute shaders running overf32storage 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 aPromise(WebGPU readback is inherently async), resolving to the same result shapes the other backends return synchronously.pipeline: trueresolves to a GPU-residentWebGPUBufferResulthandle that downstream kernels accept without readback. Opt-in only, never auto-selected.Async as a property any kernel can have — the generalization the WebGPU contract made obvious:
asyncMode: true(or.setAsyncMode(true)) makes every call return aPromiseon every backend. On webgl2 it buys a genuinely non-blocking readback:readPixelsinto aPIXEL_PACK_BUFFER, a fence, zero-timeoutclientWaitSyncpolled via MessageChannel tasks (dodging the nested-setTimeoutclamp), thengetBufferSubDataonce 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), forceasyncModeon, 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. Writeawait 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
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
asyncModebuys30 × matmul 512×512 with readback, 4 ms heartbeat running beside it (
node scripts/benchmark-async.mjs):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 clearWebGPU backend does not yet support …errors: kernel maps,graphical,toString(),precision: 'unsigned',Math.random— inmode: 'async'these decline the upgrade and stay on the GL backend rather than erroring. The upgraded kernel is built withdynamicArgumentsso it is never stricter about argument sizes than the GL kernel-switching it replaces.Verification
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.Promise.resolve()and exactly that test fails.immutableoutput-buffer lifetime, folded-dispatchthread.xcorruption past 2²¹ elements, and the upgraded webgpu kernel refusing grown arguments that GL kernel-switching absorbs.mode: 'webgpu',mode: 'async', andasyncMode;index.d.tsnow carries the webgpu statics, both new modes,asyncMode, andsetAsyncMode.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:breakbehind anifinside 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).++iemitted invalid WGSL (now postfix); helpers named after WGSL builtins lost their definitions to premature name-mangling (registry now keyed by original names).const p = k(buf); buf[0] = 9used to poison the queued run);asyncModenow 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;combineKernelsrefuses the async contract with a clear error.mapAsyncno 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