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

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

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/bendcraft/ (a first-person voxel game, this target on ten workers) and https://adrielsantana.github.io/metal-bending/ (Mandelbrot, Pong, the triangle, a raycaster, demos/app_ray_tracer_3d), served with coi-serviceworker for the headers.

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. 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; 288 is the 60 Hz ceiling:

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

The live pages, ten workers, frames per second the page counts, with the same program's native frame on Metal where it was measured (Bend 2.0.23):

page fps in Chrome the frame on Metal
Mandelbrot 512², Pong, the triangle 60
Bendcraft, 512² (a voxel world edited through the array fork, a ray a pixel) 31 3 ms
Bendcraft, 512×288 rays in a 1024×576 canvas 53
demos/app_ray_tracer_3d, 1024×768 19 (4 on one core)

Bendcraft's frames are its native build's: the bench's 30 checksums are the same from this tree and from the shipped 2.0.23. 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. 8085 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, the Loc space is 4 GiB, and the corpus, which grows in place on the cores, is one memalign'd block of 1 GiB there, asked for no place and never grown); 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). 64972 ttok of 65000.
  • 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. window_k (the quadtree's depth) and window_host (the pixel walk) live in window_open.c and the Mac, Linux and wasm frames share them; the window's size check is in window_open_run, out of the three platforms. 3620 / 3981 / 378 / 419 ttok of 4000.
  • guide/GUIDE.md, README.md: the target, the headers, the Emscripten need.
  • bun gates/repo.ts: PASS 46 / 46 on 2.0.23. No new files; nothing outside the allow list.

Since it was written

The PR was written against 2.0.16 and rebased through 2.0.22 and 2.0.23 (one commit, squashed). What the runtime's own moves changed here: 2.0.22's corpus grows in place on the cores, which wasm32 cannot do, so there the corpus is one block asked for no place and never grown; window_k and window_host moved from comp.ts into window_open.c, which every window effect follows, to keep comp.ts under its cap; the two-word Env held. What did not change: the page, the workers, the canvas Window, the numbers.

What a next step keeps

  • The host: the page, its Workers, the coi-serviceworker on a static host, the canvas Window with the Mac's key codes. A WGSL lane (A WGSL lane: -o x.html with its ! on WebGPU #920) would keep all of it and add a device path the way the Mac has one: the page dispatches the !'s frontier on WebGPU instead of the pool. The reasons a port of the Metal runtime cannot be that lane, and a runtime built for WebGPU with its measurements (0.3 ms for the Mandelbrot frame, 1.8 for the raycaster's), are in A WGSL lane: -o x.html with its ! on WebGPU #920.
  • window_host could fork, as the Linux frame could.
  • A stack guard on the host under __EMSCRIPTEN__.

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 (Bendcraft's save lasts until the tab closes), 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; the GPU in the browser is A WGSL lane: -o x.html with its ! on WebGPU #920.

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 diff had a review pass (bugs, reuse, simplification, efficiency, depth of each change) before the squash.

@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.

@AdrielSantana

Copy link
Copy Markdown
Author

Rebased onto 2.0.23 (fd7d9e6): no conflicts, bun gates/repo.ts 46 / 46. Checked on Bendcraft's bench: the native build from this tree and the shipped 2.0.23 give the same 30 checksums, and the page runs the game at 31 fps (512²) and 53 (512×288 rays) on ten workers. The description now covers the target alone; the WebGPU sections moved to #920, where that work lives.

@AdrielSantana
AdrielSantana marked this pull request as ready for review September 21, 2026 03:14
@AdrielSantana

Copy link
Copy Markdown
Author

Ready for review.

What it costs the capped files, against main today: comp.ts +36 ttok (64,972 of 65,000), main.ts +884 (8,085 of 10,000), window_open.c +1,382 (3,620 of 4,000), window_frame.c +234 (3,981 of 4,000). Gate 46 / 46. Emscripten is the only new dependency and only -o x.html needs it; every other target is unchanged.

If a target the team does not run is more than the repo should carry, the branch offered in #891 works for me: create a web branch and I will retarget this PR to it and keep it rebased.

@AdrielSantana

Copy link
Copy Markdown
Author

A correction to my comment above: comp.ts costs this PR +119 ttok, not +36. I counted the branch, then based on 2.0.23 (fd7d9e6, comp.ts at 64,853), against a main that had already moved to 64,936. The other three numbers were right.

Rebased onto 2.0.24 (e52cda4): no conflicts, and the gate is 45 / 46. The one failure is the cap: comp.ts is 65,060 of 65,000, main itself being at 64,941. Of the 119 tokens, 38 are three comments and 81 are code: the two-word Env in WL_SIG (wasm cannot tail-call with a struct argument), memalign in pool_try on Emscripten, and four sizes that follow the pointer's width. I have not found 60 tokens to take out without losing one of those, so as it stands the PR needs the cap moved by about 100, or a removal elsewhere, and that is yours to decide.

Checked on Bendcraft with the rebased tree: the page it builds is byte-identical to the one 2.0.23 built, 30 fps on ten workers.

…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.25 (0bafcc5). Same eight files; the gate is 45/46: comp.ts reads 65083 ttok against the 65000 cap, with main itself at 64958, so the page's 125 tokens do not fit under it, and I have not moved the cap.

One line in the rebase is new, in io_wait: the select sets are now at least a whole fd_set (top > 1023 ? top : 1023). Emscripten has no select syscall; its libc shims it over poll and FD_ZEROs each set it is handed, 128 bytes, into the 8 bytes top / 64 * 8 + 8 gives for small fds. The heap corruption showed as "out of memory" on the page, and a bisect of the 18 commits between 2.0.24 and 2.0.25 landed on 80d87d7. A kernel's select reads nfds bits and never writes past them, so macOS and Linux never saw it; tests/io/fifo_eof passes as before, the fd past 1023 included. It is the only change outside the page's files, so it is here rather than in a PR of its own; say if you would rather have it apart.

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