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
Draft
AdrielSantana wants to merge 1 commit into
AdrielSantana wants to merge 1 commit into
Conversation
…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
force-pushed
the
web-wasm
branch
from
September 20, 2026 21:36
9f94119 to
e948bfe
Compare
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; Written by Claude (Anthropic) with Adriel Santana driving. |
This was referenced Sep 20, 2026
Author
|
The WebGPU follow-up, a WGSL lane emitted from the same segments, is #920. |
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.
Live: https://adrielsantana.github.io/metal-bending/ (seven CPU pages, served with
coi-serviceworkerfor the headers, and three WebGPU pages: see the update at the end).What
bend file.bend -o file.htmlbuilds the program's C, unchanged, with Emscripten intofile.html,file.jsandfile.wasm: the same runtime as WebAssembly, a Web Worker per core,!on the cores. AWindowdraws 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-callthe segment machine'smusttailcalls become wasm tail calls, the pool's mutex/condvar/atomics becomeSharedArrayBuffer+Atomics, andpreserve_noneis 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.With a window, frames drawn in 5 s (300 display ticks), headless Chrome:
demos/app_pong_game_2dOn 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_3das 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.htmlThreads need cross-origin isolation, so the page must be served with two headers (
file://cannot work):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, thecoi-serviceworkertrick works.Changes
bend2/main.ts:cli_build_web(theemccline) andPAGE, 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 theEnv's two words instead of the struct, on every host (WL_SIG/WL_ALL,WL_OPENrebuildse): wasm passes a struct by a pointer into the caller's frame, and areturn_callpops that frame, so from-O2up a segment read a staleEnv(heap_alloc_misstrapped on an unaligned atomic withe.mem= garbage;-O1only 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_tryasmemalignon wasm, since Emscripten'smmapismemalignplus amemsetof memory that is zero already (140–230 ms per page load for the 1 GiB corpus, measured);window_pixunder#ifndef __METAL_VERSION__, and beside itwindow_k(the quadtree's depth) andwindow_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, withBendWinunder the X11's#ifndef BendWinguard. The page's side is twoEM_JSfunctions inwindow_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 throughMAIN_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;mouseupis 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 intowindow_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
-sINITIAL_MEMORY), no growth, so theHEAP*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.sigaltstack/SIGSEGVare stubs, so a host recursion past 16 MiB corrupts memory instead of failing. AWL_ROOM-style check on the host under__EMSCRIPTEN__is the follow-up.window_hostwalks 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.#bend); the page keeps at most 1024 events for a frame that has not come.ENOTSUP), files hit Emscripten's in-memory FS, sockets are untested. AudioWorklet, OPFS and WebSocket are the paths.navigator.hardwareConcurrencyat 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: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).
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_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.One thing seen on the way.
window_pixwalks whileterm_tag(t) == TAG_CTRand takesH[l + j]withjfrom the pixel's bits, so aPixat an inner level (bend3d'sBlk.one, the raycaster'sone4) 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 atCID_PIX; the C could testterm_aux(t) == CID_PIXin the same place.