Skip to content

A page target: -o file.html builds the runtime as WebAssembly, a worker a core, and a Window on its canvas - #866

Draft
AdrielSantana wants to merge 1 commit into
bendlang:mainfrom
AdrielSantana:web-wasm
Draft

AdrielSantana wants to merge 1 commit into
bendlang:mainfrom
AdrielSantana:web-wasm

Conversation

@AdrielSantana

@AdrielSantana AdrielSantana commented Sep 19, 2026

Copy link
Copy Markdown

Live: https://adrielsantana.github.io/metal-bending/ (seven CPU pages, served with coi-serviceworker for the headers, and three WebGPU pages: see the update at the end).

What

bend file.bend -o file.html builds the program's C, unchanged, with Emscripten into file.html, file.js and file.wasm: the same runtime as WebAssembly, a Web Worker per core, ! on the cores. A Window draws on the page's canvas and reads its keyboard and mouse; frames are paced by the display (requestAnimationFrame), as the Mac's display sync.

The JavaScript target runs on one core, and so does the playground in #859 (it runs the JS target in a Worker). The C runtime already compiles as C11 with pthreads and 32-bit atomics, so the browser gap was the target, not the language: with -pthread -mtail-call the segment machine's musttail calls become wasm tail calls, the pool's mutex/condvar/atomics become SharedArrayBuffer + Atomics, and preserve_none is ignored (wasm has no callee-saved registers).

Measured

Apple M5 (10 cores), Chrome 153, Emscripten 6.0.9, Node 24, Bend 2.0.16. A windowless 512×512 Mandelbrot, 50 iterations, quadtree built with parallel calls, summed to a checksum; IO.now() around the computation only, five frames with varying input, median. Every cell has the same checksum.

ms per frame 1 thread 4 threads 10 threads
JS target (bun) 530
WebAssembly in Chrome 26 7–8 4–5
WebAssembly in Node 26 7–8 5
native binary (clang) 22 6 6

With a window, frames drawn in 5 s (300 display ticks), headless Chrome:

page 1 thread 2 threads 4 threads 10 threads
Mandelbrot 512², recomputed every frame 143 288 288 288
demos/app_pong_game_2d 283

On the live page (frames per second the page counts, 10 threads): Mandelbrot 60, Pong 60, Triangle 60, Bendcraft (a first-person editable voxel world, 512²) 60, a 512² voxel raycaster 24 (the Metal build does 60), demos/app_ray_tracer_3d as is (1024×768) 19 on ten cores and 4 on one, and the same program with the camera scaled to 512×384 60 on ten and 14 on one.

288 in 5 s is the 60 Hz ceiling. The first frame costs ~70 ms extra while the Workers start.

Try it

brew install emscripten            # or emsdk; 3.1.35+ for tail calls
bun bend2/main.ts demos/app_pong_game_2d/main.bend -o pong/pong.html

Threads need cross-origin isolation, so the page must be served with two headers (file:// cannot work):

cd pong && python3 -c '
import http.server as h
class H(h.SimpleHTTPRequestHandler):
    def end_headers(s):
        s.send_header("Cross-Origin-Opener-Policy", "same-origin")
        s.send_header("Cross-Origin-Embedder-Policy", "require-corp")
        super().end_headers()
h.ThreadingHTTPServer(("127.0.0.1", 8000), H).serve_forever()'

Then open http://127.0.0.1:8000/pong.html (the selector under the canvas, or ?threads=1, to compare). On GitHub Pages, which sets no headers, the coi-serviceworker trick works.

Changes

  • bend2/main.ts: cli_build_web (the emcc line) and PAGE, the page it writes: the canvas, a thread-count selector (it reloads with ?threads=N, clamped to the cores, since the runtime sizes its pool at start), the frames per second the program delivered, a line per print; help text. 7469 ttok of 10000.
  • bend2/comp.ts: a segment takes the Env's two words instead of the struct, on every host (WL_SIG/WL_ALL, WL_OPEN rebuilds e): wasm passes a struct by a pointer into the caller's frame, and a return_call pops that frame, so from -O2 up a segment read a stale Env (heap_alloc_miss trapped on an unaligned atomic with e.mem = garbage; -O1 only worked by luck). On arm64 and x86-64 a two-pointer struct already travels in two registers, and the benchmark's compute segments compile to the same instructions either way, so the signature is one for all rather than a wasm #ifdef. Also the runtime's sizes for wasm32 (STACK_LEN, CORPUS_LEN, CORPUS_MIN: wasm commits what it maps, and the Loc space is 4 GiB); pool_try as memalign on wasm, since Emscripten's mmap is memalign plus a memset of memory that is zero already (140–230 ms per page load for the 1 GiB corpus, measured); window_pix under #ifndef __METAL_VERSION__, and beside it window_k (the quadtree's depth) and window_host (the pixel walk), which the Mac, Linux and wasm frames now share instead of a copy each. 61872 ttok of 62000.
  • bend2/effs/window_{open,frame,close,set_title}.c: an #elif defined(__EMSCRIPTEN__) branch each, between the Linux one and the stub, with BendWin under the X11's #ifndef BendWin guard. The page's side is two EM_JS functions in window_open.c (the canvas and its listeners; a frame's blit and event drain on the next animation frame, or a 16 ms timer while the tab is hidden), called on the main thread through MAIN_THREAD_EM_ASM; the program's thread waits on a futex the page signals. Key codes, buttons and the five-word events are the Mac's, as on Linux; mouseup is heard on the window, so a button released off the canvas still comes up, and a button past the middle one is dropped. The window's size check moved into window_open_run, out of the three platforms. 3450 / 3989 / 378 / 419 ttok of 4000.
  • guide/GUIDE.md, README.md: the target, the headers, the Emscripten need.
  • bun gates/repo.ts: PASS 46 / 46. The native build gives the same checksums on the Mac, and the two-word signature compiles the benchmark's compute segments to the same arm64 instructions.

Nothing was added outside the allow list; no new files.

Limits, stated

  • Memory is 2 GiB, fixed (-sINITIAL_MEMORY), no growth, so the HEAP* views the page's JS reads stay valid; corpus 1 GiB, halved down to 256 MiB if the allocation fails; 16 MiB stack per thread (native reserves 2 GiB lazily; wasm cannot). iOS Safari gives a page far less than 2 GiB; untested there.
  • No stack guard: sigaltstack/SIGSEGV are stubs, so a host recursion past 16 MiB corrupts memory instead of failing. A WL_ROOM-style check on the host under __EMSCRIPTEN__ is the follow-up.
  • window_host walks the quadtree on the program's thread, single-threaded (~3 ms at 512²), as Linux without CUDA; the Mac does it on Metal. It could fork.
  • One window per page (the canvas is #bend); the page keeps at most 1024 events for a frame that has not come.
  • Audio, files and sockets have no browser branch: audio compiles to the stub (ENOTSUP), files hit Emscripten's in-memory FS, sockets are untested. AudioWorklet, OPFS and WebSocket are the paths.
  • Safari caps navigator.hardwareConcurrency at 8; Firefox and Safari 18.2+ have the needed wasm features (threads, tail calls) but only Chrome was measured.
  • ! runs on the cores in this PR. The section below is what a GPU lane would take, and the update after it measures a prototype of one.

Why not WebGPU (yet)

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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).
  5. 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.
  6. 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).

How this was made

Written by Claude (Anthropic) with Adriel Santana driving, as this repo's runtime was; every number above was measured on his M5. The fifth commit is a review pass over the diff (bugs, reuse, simplification, efficiency, depth of each change). The browser-side research (threads, tail calls, memory, WebGPU) is summarized in the sections above with its sources.

Update: WebGPU, measured

After writing the section above, Adriel asked me to try anyway. 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.

One thing seen on the way. window_pix walks while term_tag(t) == TAG_CTR and takes H[l + j] with j from the pixel's bits, so a Pix at an inner level (bend3d's Blk.one, the raycaster's one4) is read one to three words past its single word. The image looks right because the next words in a class-0 page are usually neighbouring pixels. The WGSL raster stops at CID_PIX; the C could test term_aux(t) == CID_PIX in the same place.

…er a core, and a Window draws on its canvas (Emscripten)

The page shows its frames per second and picks its thread count from a
selector, which reloads with ?threads=N since the runtime sizes its pool
at start; it survives a hidden tab and a button released off the canvas.
Every host passes a segment the Env's two words: a tail call on wasm pops
the frame a struct is passed through, so -O2 and up read a stale Env, and
the compute segments compile to the same arm64 instructions either way.
window_k and window_host, in the window_open effect that every window
effect follows, fill a frame on the host for Linux and the page. On wasm32
a map is a commit, so the corpus is one memalign'd block of 1 GiB, asked
for no place and never grown, and a stack is 16 MiB; mmap would memset
what is zero already.

Rebased onto 2.0.22 as one commit.
@AdrielSantana

Copy link
Copy Markdown
Author

Rebased onto 2.0.22 (94ee9ba) and squashed to one commit. The corpus now grows in place on the cores, which wasm32 cannot do, so there it is one memalign'd block of 1 GiB, asked for no place and never grown; window_k and window_host moved from comp.ts to the window_open effect, which every window effect follows, to keep comp.ts under its cap. bun gates/repo.ts passes; tests/run/array_fork.bend and stencil3d.bend print their lines natively through this tree; the page runs the 2.0.22 Bendcraft, its world an array shared through the fork, at 56 fps on ten workers in Chrome, the same frames as the native build.

Written by Claude (Anthropic) with Adriel Santana driving.

@AdrielSantana

Copy link
Copy Markdown
Author

The WebGPU follow-up, a WGSL lane emitted from the same segments, is #920.

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.

1 participant