You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A proposal, after the wasm page target (#866) and the array fork of 2.0.22: a WGSL lane, so bend x.bend -o x.html runs its ! on WebGPU.
Metal gets the GPU for free because Apple's compiler takes the emitted C as it is, 64-bit terms included. A browser takes only WGSL: no 64-bit integers, no pointers, no recursion, no tail calls. So the device code has to be emitted in WGSL from the compiled form, the way the JS lane emits JS from it.
What is measured
A prototype runtime of about 300 lines of JS (rt.js) runs the fork tree on WebGPU with the dispatch boundary as the only fence: terms as pairs of u32, a task is a node on the heap, one indirect dispatch per fork level, and the leaves work their subtree sequentially on a lane. With the leaves translated by hand it draws the same frames as the Bend program (same checksums) at 0.3 ms for Mandelbrot 512², 1.8 ms for the voxel raycaster 512² and 2.4 ms for app_ray_tracer_3d at 1024×768, against 4.5 / 42 / 53 ms on ten wasm threads in the same browser (Chrome 153, Apple M5): the pages. Bendcraft's editable world at 512² was 1.3 ms against 18 in wasm and 4 on Metal, before its hand port was retired for not following the program.
The shape
The hand-written leaves are the missing piece. The Metal kernel is already a switch(fid) inside a loop over the segments; the lane would emit that loop in WGSL, over the same segments, with every term operation on two 32-bit words and the heap as a storage buffer. The host stays the wasm page: it uploads the program, dispatches the fork levels, reads the result back; IO stays on the host, as on Metal.
The question
Is this something you want in Bend, and if so, how: a lane next to JS, or a device target of the C lane? I would rather build it your way than twice.
Written by Claude (Anthropic) with Adriel Santana driving; the numbers are from his M5.
Moved here from #866's description, so that the PR describes the page target alone and this issue holds the WebGPU work in one place: first the six reasons a port of the Metal runtime cannot work in WebGPU, then the prototype runtime built around what WebGPU has, with its measurements. The three pages are live at https://adrielsantana.github.io/metal-bending/.
Why a port cannot work
The natural next question is a WGSL lane for !, since WebGPU is the browser's only compute API now. I researched it against what the device runtime actually does (bend_dev: one persistent pipeline, ~4 dispatches per !, 64-bit terms, 32-bit atomics into the same heap, release/acquire handoffs through the heap between lanes of different threadgroups (a task pushed onto another lane's ring, a child's result read by the last child, which continues the parent), coherent(device) stores, a heap the CPU shares zero-copy). Each of these meets a WebGPU limit that is structural, not a porting detail:
No 64-bit integers in WGSL. Terms are u64 (tag, 40-bit loc). Only i32/u32 exist; the proposal has been open since 2019 (gpuweb#273), no browser ships it, and Apple's position is that Mac2 Metal feature sets lack 64-bit math. Every term becomes a vec2<u32> with hand-rolled carries.
Atomics are typed. WGSL has only atomic<u32>/atomic<i32>, and a buffer cannot be bound both as array<atomic<u32>> and as plain storage (buffer-binding aliasing). The runtime's a32_* views into the 64-bit heap therefore mean the whole heap becomes atomics, every read an atomicLoad (a real RMW on the HLSL backend). Untyped atomics (gpuweb#2377) are still open.
No device-scope fence for non-atomic data.WGSL §14.5: storage atomics are relaxed, "all synchronization functions use the Workgroup memory scope", non-atomic storage accesses have workgroup scope. storageBarrier() cannot synchronize across workgroups (gpuweb#3774, #2980); there is no coherent(device), and wgpu confirmed no backend offers a device-scope barrier (wgpu#7445). The runtime's a32 + FENCE handoffs between threadgroups (ring_push with release, the join's a32_sub_rel then a32_acq) have no expression: every cross-workgroup word, payload included, must itself be an atomic with its own flag bits (what Decoupled Fallback does for prefix sums), or the handoff must cross a dispatch boundary.
No forward progress across workgroups. "You cannot assume more than one workgroup executes at a time" (WGSL §15). Today's device runtime never waits on another lane inside a dispatch (WL_SPIN is a leaf's loop polling the error flag; a join is finished by its last child), so it does not need this guarantee, but it sizes CUBE_G to the GPU's core count for occupancy, which WebGPU does not expose. Any port that added waiting would need an occupancy-discovery protocol plus bounded spins with a scalar fallback (Sorensen et al., FSE'17; Smith, Levien, Owens, SPAA'25, which measures forward-progress failures on M1 Max and M3). A lane spinning forever is not a hang but a machine freeze on Apple Silicon ("Deathray", write-up, Apple declined to fix), and Chrome's GPU watchdog kills the device at 25 s on macOS, 15 s on Linux/Android (source).
No shared heap.MAP_READ may only pair with COPY_DST, MAP_WRITE with COPY_SRC; Dawn uses Private storage for everything else, with UMA copies an open issue since 2021 (gpuweb#2388). The zero-cost ! of unified memory becomes writeBuffer + mapAsync per phase, and a round trip is measured in milliseconds even for 4 KB (gpuweb#4432). With ~4 dispatches per !, the fixed fee is where the Mandelbrot's whole frame is today.
No transpiler gets there. Tint's SPIR-V reader and the clang → clspv → Tint chain reject non-32-bit ints, pointers in structs and barrier non-uniformity (HipScript's report); the Vulkan attempts on this runtime found the same. A lane would be a WGSL emitter written by hand for a runtime redesigned around 1–5, not a fourth dialect of the one source.
So it is a new device runtime, not a port. The six points stand as the reasons a port cannot work; the update below is that new runtime, prototyped by hand and measured, and it changes the conclusion I first wrote here (that the GPU's share stays on the desktop).
A runtime built for WebGPU, measured
The result is not a port of the Metal runtime but a runtime written around what WebGPU has, and it runs three of the demos on the GPU in the browser: Mandelbrot, voxel raycaster, demos/app_ray_tracer_3d at 1024×768 (W/A/S/D, Q/E, arrows, the fps in the corner as in the original).
Design. Terms are vec2<u32> (x = loc, y = tag<<24 | aux<<8; a loc fits 32 bits in the browser). A task node is [args.., cont, (pend, idx)] as in the C, with the pending count in a separate array<atomic<u32>>, so the heap stays a plain array<u32>. The only fence between lanes is a dispatch boundary: one indirect dispatch per fork level grows the frontier (a fork pushes its four children onto the next queue), one dispatch works each subtree sequentially on a lane (the runtime's own seq path: WL_FRAME, the K continuations on an explicit interleaved stack, the join segment, FID_EXIT), one dispatch per join level folds back, one walks the quadtree into pixels (window_pix), a fullscreen triangle blits. task_deliver writes into the parent's node and atomicSubs the count; the last child pushes the parent onto the next queue instead of continuing it. Three queues rotate (in, out, one being reset) so a kernel never writes the indirect-args buffer it was launched from, and an atomicMax on the workgroup count at each push makes a flip kernel unnecessary. 15 to 21 dispatches a frame in one command buffer, nothing read back. So: no u64, no untyped atomics, no device-scope fence, no forward progress and no shared heap needed. Points 1 to 6 above stand for a port; they are not a wall for a runtime built this way.
ms per frame
WebAssembly, 10 threads
WebGPU
Mandelbrot 512²
4–5
0.31
Voxel raycaster 512² (fork!(7n) to 4×4 tiles, a DDA of 96 steps per ray)
42 (24 fps)
1.8
demos/app_ray_tracer_3d, 512×384
≤ 16 (60 fps, capped)
0.7
demos/app_ray_tracer_3d, 1024×768
53 (19 fps)
2.4
GPU time of the compute pass from timestamp queries, median of 100 frames submitted back to back, Chrome 153 on the M5 (--enable-webgpu-developer-features, so the timestamps are not quantized). The image is checked against a JavaScript rendering of the same pixel function: the Mandelbrot differs on 10 of 262144 pixels by one escape step (a fused multiply-add), the raycaster on 2 (a tie between two faces, the class of difference REPORT.md in metal-bending already records for Metal), the ray tracer on none. Seven fork levels (16384 lanes, then a sequential subtree per lane) is the sweet spot; every level as its own dispatch, with no sequential pass, is 4.5x slower, and three fork levels 18x. In a 60 Hz loop the GPU clocks down between frames and the pass reads 3 to 5x longer; the table is the throughput.
What is generic, what is by hand. The scheduler, the heap, the joins and the raster are one file (rt.js, ~300 lines of WGSL and ~150 of JS) shared unchanged by the three pages. What each page adds is the leaf (the Mandelbrot's pixel, the raycaster's tile with its 2×2 collapse, Fly.over) and the rule for the children's arguments, translated by hand from the Bend source, since there is no WGSL emitter. That emitter is the real work: the leaf and segment code from Bend's IR to WGSL (Bool.pick as select, F32.to_u32 as the saturating u32(), fuel recursion as a bounded loop, the ! frontier as the dispatch plan above). It does not fit comp.ts at its budget, so it would be a file of its own or a fork. The prototype's allocator is a per-frame arena, enough for a ! whose result is drawn (all four demos here), not for state kept on the device heap across frames.
A note that stood here was wrong. It said window_pix reads past a Pix at an inner level. In the C runtime a Pix is a packed term (TAG_PAK, the colour in the term's own bits), so the walk stops at it and returns the colour; checked on the page target with a 64 x 64 window of four Pix quadrants, the colours literal and then computed each frame: every pixel is its quadrant's. The WGSL prototype boxed its Pix, which is where the confusion came from.
This is great work. Two things from the AMD/HIP lane (the hip branch, #891 but fallen a bit behind my fork) bear directly on it: the "no shared heap" limit in point 5, and the per-frame arena that limits the prototype to results that are drawn.
State that stays on the device across ! calls. HIP under WSL2 has no shared heap either, so the lane keeps a twin: the corpus lives in VRAM and persists across calls, and only what changed crosses the bus. The full protocol is on my fork, with its coherence laws modelled in Bend in demos/proof_gpu_coherence. On a scan/histogram/sort workload over 4M keys, a steady ! moves about 1 MB each way, and the 16 MB input never crosses the bus again after the first call. Not all of it transfers to a browser:
Transfers directly: the device-side write map. A device store into memory that existed before the call sets a bit for its chunk, and after the call the host copies back only the marked chunks. In WebGPU that's one small mapAsync of the map, then only the marked ranges.
Doesn't transfer: page traps. On native, the host finds its own writes, and the chunks it touches, through mprotect faults. Wasm has no page protection, so the host side would need write barriers emitted by the compiler, or explicit dirty ranges per effect.
Needs a different policy: lazy downloads. I fetch device-written chunks on the host's first touch, which blocks. mapAsync can't block on the main thread, so a browser lane would fetch the marked chunks eagerly at the end of the !. That's usually cheap, since a ! call's result is typically a few words and most marked chunks are the device's own scratch, which the host never reads.
So the arena could become a persistent device heap without a shared-memory API, as long as dirty tracking on the host comes from the compiler rather than the OS.
Your dispatch plan matches what I measured on HIP. Profiling the HIP lane, a sizable share of a ! call is host round trips between passes, roughly 0.1–0.2 ms each: 17–21% of a mid-size call's time, and more for programs that need several passes. Your design queues every fork level, work pass and join level as indirect dispatches in one command buffer, with nothing read back in between. That's the shape I'd want the native lanes to move toward too. The sweet spot also matches: on an RX 7800 XT with 16K lanes, a split into 2^12 leaves was fastest, and deeper splits got slower, because each extra fork level cost more than the lanes it filled. You found seven 4-way levels to be the sweet spot.
On where the code lives. You note the emitter won't fit comp.ts at its budget. My HIP branch faced the same problem and moved each lane's runtime into its own file under bend2/lanes/, spliced into the emitted source at build time, with its own size rule in gates/repo.ts. If the maintainers want a WGSL lane in-tree, that layout might be a precedent: the WGSL emitter and rt.js could live beside the other lanes, not inside comp.ts. cc @nicolas-abril
Thank you for the measurements and the prototype. A WebGPU lane is an extra GPU lane, and WONTFIX.txt, CAPACITY, rules those out: "No Vulkan or other extra GPU lanes (#811). Metal and CUDA only." Your own conclusion agrees: the lane needs its own WGSL emitter and runtime next to comp.ts, which we cannot maintain.
Note: an AI wrote this reply after I made the decision. If anything here is wrong, reply and I will review it myself.
Activity
AdrielSantana commentedon Sep 21, 2026
Moved here from #866's description, so that the PR describes the page target alone and this issue holds the WebGPU work in one place: first the six reasons a port of the Metal runtime cannot work in WebGPU, then the prototype runtime built around what WebGPU has, with its measurements. The three pages are live at https://adrielsantana.github.io/metal-bending/.
Why a port cannot work
The natural next question is a WGSL lane for
!, since WebGPU is the browser's only compute API now. I researched it against what the device runtime actually does (bend_dev: one persistent pipeline, ~4 dispatches per!, 64-bit terms, 32-bit atomics into the same heap, release/acquire handoffs through the heap between lanes of different threadgroups (a task pushed onto another lane's ring, a child's result read by the last child, which continues the parent),coherent(device)stores, a heap the CPU shares zero-copy). Each of these meets a WebGPU limit that is structural, not a porting detail:u64(tag, 40-bit loc). Onlyi32/u32exist; the proposal has been open since 2019 (gpuweb#273), no browser ships it, and Apple's position is that Mac2 Metal feature sets lack 64-bit math. Every term becomes avec2<u32>with hand-rolled carries.atomic<u32>/atomic<i32>, and a buffer cannot be bound both asarray<atomic<u32>>and as plain storage (buffer-binding aliasing). The runtime'sa32_*views into the 64-bit heap therefore mean the whole heap becomes atomics, every read anatomicLoad(a real RMW on the HLSL backend). Untyped atomics (gpuweb#2377) are still open.storageBarrier()cannot synchronize across workgroups (gpuweb#3774, #2980); there is nocoherent(device), and wgpu confirmed no backend offers a device-scope barrier (wgpu#7445). The runtime'sa32 + FENCEhandoffs between threadgroups (ring_pushwith release, the join'sa32_sub_relthena32_acq) have no expression: every cross-workgroup word, payload included, must itself be an atomic with its own flag bits (what Decoupled Fallback does for prefix sums), or the handoff must cross a dispatch boundary.WL_SPINis a leaf's loop polling the error flag; a join is finished by its last child), so it does not need this guarantee, but it sizesCUBE_Gto the GPU's core count for occupancy, which WebGPU does not expose. Any port that added waiting would need an occupancy-discovery protocol plus bounded spins with a scalar fallback (Sorensen et al., FSE'17; Smith, Levien, Owens, SPAA'25, which measures forward-progress failures on M1 Max and M3). A lane spinning forever is not a hang but a machine freeze on Apple Silicon ("Deathray", write-up, Apple declined to fix), and Chrome's GPU watchdog kills the device at 25 s on macOS, 15 s on Linux/Android (source).MAP_READmay only pair withCOPY_DST,MAP_WRITEwithCOPY_SRC; Dawn usesPrivatestorage for everything else, with UMA copies an open issue since 2021 (gpuweb#2388). The zero-cost!of unified memory becomeswriteBuffer+mapAsyncper phase, and a round trip is measured in milliseconds even for 4 KB (gpuweb#4432). With ~4 dispatches per!, the fixed fee is where the Mandelbrot's whole frame is today.So it is a new device runtime, not a port. The six points stand as the reasons a port cannot work; the update below is that new runtime, prototyped by hand and measured, and it changes the conclusion I first wrote here (that the GPU's share stays on the desktop).
A runtime built for WebGPU, measured
The result is not a port of the Metal runtime but a runtime written around what WebGPU has, and it runs three of the demos on the GPU in the browser: Mandelbrot, voxel raycaster,
demos/app_ray_tracer_3dat 1024×768 (W/A/S/D, Q/E, arrows, the fps in the corner as in the original).Design. Terms are
vec2<u32>(x = loc, y = tag<<24 | aux<<8; a loc fits 32 bits in the browser). A task node is[args.., cont, (pend, idx)]as in the C, with the pending count in a separatearray<atomic<u32>>, so the heap stays a plainarray<u32>. The only fence between lanes is a dispatch boundary: one indirect dispatch per fork level grows the frontier (a fork pushes its four children onto the next queue), one dispatch works each subtree sequentially on a lane (the runtime's ownseqpath:WL_FRAME, the K continuations on an explicit interleaved stack, the join segment,FID_EXIT), one dispatch per join level folds back, one walks the quadtree into pixels (window_pix), a fullscreen triangle blits.task_deliverwrites into the parent's node andatomicSubs the count; the last child pushes the parent onto the next queue instead of continuing it. Three queues rotate (in, out, one being reset) so a kernel never writes the indirect-args buffer it was launched from, and anatomicMaxon the workgroup count at each push makes a flip kernel unnecessary. 15 to 21 dispatches a frame in one command buffer, nothing read back. So: no u64, no untyped atomics, no device-scope fence, no forward progress and no shared heap needed. Points 1 to 6 above stand for a port; they are not a wall for a runtime built this way.fork!(7n)to 4×4 tiles, a DDA of 96 steps per ray)demos/app_ray_tracer_3d, 512×384demos/app_ray_tracer_3d, 1024×768GPU time of the compute pass from timestamp queries, median of 100 frames submitted back to back, Chrome 153 on the M5 (
--enable-webgpu-developer-features, so the timestamps are not quantized). The image is checked against a JavaScript rendering of the same pixel function: the Mandelbrot differs on 10 of 262144 pixels by one escape step (a fused multiply-add), the raycaster on 2 (a tie between two faces, the class of differenceREPORT.mdin metal-bending already records for Metal), the ray tracer on none. Seven fork levels (16384 lanes, then a sequential subtree per lane) is the sweet spot; every level as its own dispatch, with no sequential pass, is 4.5x slower, and three fork levels 18x. In a 60 Hz loop the GPU clocks down between frames and the pass reads 3 to 5x longer; the table is the throughput.What is generic, what is by hand. The scheduler, the heap, the joins and the raster are one file (
rt.js, ~300 lines of WGSL and ~150 of JS) shared unchanged by the three pages. What each page adds is the leaf (the Mandelbrot's pixel, the raycaster's tile with its 2×2 collapse,Fly.over) and the rule for the children's arguments, translated by hand from the Bend source, since there is no WGSL emitter. That emitter is the real work: the leaf and segment code from Bend's IR to WGSL (Bool.pickasselect,F32.to_u32as the saturatingu32(), fuel recursion as a bounded loop, the!frontier as the dispatch plan above). It does not fitcomp.tsat its budget, so it would be a file of its own or a fork. The prototype's allocator is a per-frame arena, enough for a!whose result is drawn (all four demos here), not for state kept on the device heap across frames.A note that stood here was wrong. It said
window_pixreads past aPixat an inner level. In the C runtime aPixis a packed term (TAG_PAK, the colour in the term's own bits), so the walk stops at it and returns the colour; checked on the page target with a 64 x 64 window of fourPixquadrants, the colours literal and then computed each frame: every pixel is its quadrant's. The WGSL prototype boxed itsPix, which is where the confusion came from.costamatheus97 commentedon Sep 25, 2026
This is great work. Two things from the AMD/HIP lane (the
hipbranch, #891 but fallen a bit behind my fork) bear directly on it: the "no shared heap" limit in point 5, and the per-frame arena that limits the prototype to results that are drawn.State that stays on the device across
!calls. HIP under WSL2 has no shared heap either, so the lane keeps a twin: the corpus lives in VRAM and persists across calls, and only what changed crosses the bus. The full protocol is on my fork, with its coherence laws modelled in Bend indemos/proof_gpu_coherence. On a scan/histogram/sort workload over 4M keys, a steady!moves about 1 MB each way, and the 16 MB input never crosses the bus again after the first call. Not all of it transfers to a browser:mapAsyncof the map, then only the marked ranges.mprotectfaults. Wasm has no page protection, so the host side would need write barriers emitted by the compiler, or explicit dirty ranges per effect.mapAsynccan't block on the main thread, so a browser lane would fetch the marked chunks eagerly at the end of the!. That's usually cheap, since a!call's result is typically a few words and most marked chunks are the device's own scratch, which the host never reads.So the arena could become a persistent device heap without a shared-memory API, as long as dirty tracking on the host comes from the compiler rather than the OS.
Your dispatch plan matches what I measured on HIP. Profiling the HIP lane, a sizable share of a
!call is host round trips between passes, roughly 0.1–0.2 ms each: 17–21% of a mid-size call's time, and more for programs that need several passes. Your design queues every fork level, work pass and join level as indirect dispatches in one command buffer, with nothing read back in between. That's the shape I'd want the native lanes to move toward too. The sweet spot also matches: on an RX 7800 XT with 16K lanes, a split into 2^12 leaves was fastest, and deeper splits got slower, because each extra fork level cost more than the lanes it filled. You found seven 4-way levels to be the sweet spot.On where the code lives. You note the emitter won't fit
comp.tsat its budget. My HIP branch faced the same problem and moved each lane's runtime into its own file underbend2/lanes/, spliced into the emitted source at build time, with its own size rule ingates/repo.ts. If the maintainers want a WGSL lane in-tree, that layout might be a precedent: the WGSL emitter andrt.jscould live beside the other lanes, not insidecomp.ts. cc @nicolas-abrilVictorTaelin commentedon Sep 27, 2026
Thank you for the measurements and the prototype. A WebGPU lane is an extra GPU lane, and WONTFIX.txt, CAPACITY, rules those out: "No Vulkan or other extra GPU lanes (#811). Metal and CUDA only." Your own conclusion agrees: the lane needs its own WGSL emitter and runtime next to comp.ts, which we cannot maintain.
Note: an AI wrote this reply after I made the decision. If anything here is wrong, reply and I will review it myself.