- The page has already fetched the shim and seed (main.js loadRuntime). The system Worker fetched the 5 MB shim again. The run message now carries the page's copies (primeRuntime). - site.mjs's GEX_SITE proxy always added COOP/COEP, so an SP dist behind it ran MP. GEX_NO_COI=1 now leaves them off, as it does for the built site. Fetched to a passing demo (serve-dist GEX_SERVE_LOG, gooner): - busybox: MP 13 files / 7527568 B, SP 16 files / 7450725 B; - gears: MP 24 files / 28205364 B, SP 26 files / 27176339 B. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_781bad2ec05143db9067c86b4b2678e2
34 KiB
The single-thread build (no SharedArrayBuffer)
Stream SP. Branch merge/singleproc, worktree wt/singleproc.
The goal is a page that runs the native Debian demo without SharedArrayBuffer. Such a page needs no COOP/COEP headers, so any static host can serve it. It has to keep:
- apt over Wisp;
- fork/exec, pipes and signals;
- multi-threaded guests;
- the terminal;
- the Wayland compositor.
The default build (a Worker per guest thread, SAB everywhere) is unchanged, and runs as before wherever the page is cross-origin isolated.
1. Choice: one JS thread, a JSPI stack per guest thread
Every guest thread and process is a fiber: the guest runs on a JSPI
stack (WebAssembly.promising), and a call that would block suspends that
stack (WebAssembly.Suspending). All fibers share one JS thread, the page's
system Worker (web/src/system-worker.js). Memories are plain, not
shared:
- A process's threads are instances on one memory.
- Every process's kernel instance is on the one global kernel memory.
- They are all in one realm and are passed by reference, never cloned.
Measurements
Microbenchmarks are in scratch/SP/mb. They ran in headless Chrome 153 on
edger under bench.sh (cores 2-8 plus siblings), on a page with no COOP/COEP
(crossOriginIsolated=false, no SAB). The raw JSON is in
scratch/SP/mb/r1-{main,worker}{,-fx}.json. All figures are ns per
operation.
| operation | main thread | dedicated Worker |
|---|---|---|
| plain import call | 17.5 | 16.4 |
| Suspending import, no suspend (inside promising) | 174.1 | 165.7 |
| JSPI suspend+resume round trip, depth 1 | 168.6 | 170.0 |
| … 20 wasm frames deep | 168.2 | 166.8 |
| … 200 wasm frames deep | 166.0 | 171.0 |
| promising export call (new stack), awaited | 1037.6 | 939.0 |
| JSPI context switch, 2 fibers round robin | 311.8 | 271.0 |
| … 8 fibers | 307.0 | 249.0 |
| … 64 fibers | 348.5 | 318.5 |
| hot loop, direct (per iteration) | 2.06 | 2.70 |
| hot loop on a JSPI stack | 2.10 | 2.71 |
| hot loop + back-edge budget poll, refill 65536 | 2.32 | 3.15 |
hot loop + back-edge budget poll, refill 4096 (now() at 0) |
4.47 | 5.82 |
| hot loop + flag-word check per back edge | 3.82 | 4.93 |
| 2000 fibers suspended 20 frames deep: create+suspend, each | 17750 | 19050 |
| 2000 fibers: resume+finish, each | 1700 | 1000 |
| wasm trap caught in JS (stackTraceLimit=0) | 4311 | 4066 |
| MessageChannel round trip (macrotask) | 15355 | 11830 |
scheduler.yield() |
22580 | 18435 |
setTimeout(0), nested (clamped) |
4070500 | 4026500 |
await (microtask) |
165.1 | 144.9 |
| asyncify unwind+rewind, 1 frame | 302.5 | 525.5 |
| asyncify unwind+rewind, 20 frames | 257.5 | 632.5 |
WasmFX resume+suspend, depth 1 (--experimental-wasm-wasmfx) |
110.8 | 88.0 |
| WasmFX, 21 frames deep | 88.1 | 70.0 |
| WasmFX switch, 8 fibers via a scheduler loop | 124.6 | 93.2 |
Maximum recursion depth (8 i64 locals per frame):
| stack | main thread | Worker |
|---|---|---|
| the thread's own stack | 7804 | 3977 |
| a JSPI stack | 7558 | 7550 |
A JSPI stack is about twice as deep as a Worker's own stack.
What the numbers decide
-
JSPI. A suspend/resume round trip costs about 170 ns whatever the depth: 166-171 ns from 1 to 200 frames. A switch between fibers costs 250-350 ns, rising only slightly from 2 to 64 fibers. A blocking guest syscall already costs microseconds in the SAB build, so this is noise next to it.
-
Hot code is free on a JSPI stack.
- Main thread: 2.10 ns/iteration on a JSPI stack against 2.06 direct.
- Worker: 2.71 against 2.70.
- Hence tier 1, fjit and the LP64 libraries run at full speed under JSPI; the page comparison in §6 confirms this on real programs.
-
WasmFX is about 1.9× faster per switch than JSPI on the main thread (88-111 ns against 166-175 ns), and about 2.4× faster on a Worker (70-88 ns against 167-171 ns). It still sits behind a flag in Chrome 153, though. Node 26 (V8 14.6) crashes compiling it in Turbofan. JSPI ships, so JSPI is the choice. WasmFX is the upgrade path once it ships: the fiber layer is small (
Schedin single.mjs), so moving to it later is cheap. -
asyncify costs about as much as JSPI on the main thread (258-303 ns) and 2-4× more on a Worker (526-633 ns). Its real cost is the instrumentation of everything it unwinds through, so it is kept only where it already is: wasm32 fork (§4).
-
Traps cost 4.1-4.3 µs. Translated guests already leave wasm by trap. The single build adds nothing to that path.
-
System Worker, not the main thread. Per-operation costs are close (the Worker is a little faster at switches and macrotasks). The main thread refuses two things the runtime does:
- synchronous
WebAssembly.Modulecompiles above 8 MB; - synchronous XHR with an arraybuffer response.
Running the guests on the page thread would also stall input and drawing. So the page keeps xterm and the compositor's listeners, and the whole system runs in one dedicated Worker.
- synchronous
-
Yielding to the event loop costs 12-15 µs per MessageChannel turn and 18-23 µs per
scheduler.yield().setTimeout(0)nests to a 4 ms clamp and must never be the yield. The design therefore wakes parked fibers with microtasks while an 8 ms slice lasts, and uses one MessageChannel turn after it.
2. How it works
Binaries
- The single build uses the shared build's binaries:
gex_browser.shared.wasm: per-instance stacks and TLS viagex_worker_init;seed.shared.wasm;gex_kernel.wasm.
enableSingle()(shims/browser/js/single.mjs) clears thesharedflag of every memory import and definition as the binaries are compiled. It does this by patchingWebAssembly.compile,validate,instantiate, the streaming variants, andModule/Memory;instanceofkeeps working.- Atomic instructions are valid on a plain memory. The one that is not,
memory.atomic.wait32, is never reached: a fiber never waits inside wasm. - No second build of anything is needed.
Fibers
FiberWorkerrunsworker.mjs'sworkerMainin this realm, behind the samepostMessage/addEventListener/terminatesurface, soProcessHostis unchanged.- Messages are not cloned.
- All host↔fiber traffic goes through one FIFO queue drained by microtasks, bounded to 8 ms per turn. A child's output and its parent's therefore arrive in order, which separate ports never promised.
Blocking
Where the SAB build waits, the single build suspends:
| SAB build | single build |
|---|---|
k_park / k_park_futex (kernel imports, Atomics.wait on wake words) |
Suspending imports that return sched.park(ns) / sched.parkFutex(addr, ns) promises. k_wake / k_futex_wake call the kernel and then resolve the matching parked fibers. A parked fiber re-checks k_wake_seq / k_futex_gen first, so a wake that raced the park is not lost. |
the guest's run loop calls enter(ctx, pc) |
await promising(enter): each guest thread's run loop is a JSPI stack |
translated syscall → gex_syscall (may park in the kernel) |
the syscall import is a Suspending that runs promising(gex_syscall) |
| a wasm32 program's syscall, a provider's call-out | the same Suspending path. For fork, the image is captured in the after hook before the parent resumes (§4). |
park in #runLoop (gex_park(20)) |
await promising(gex_park) |
Scheduling (Sched in single.mjs)
- A parked fiber is a promise held in a global set, or in a per-address futex map.
- A wake resolves it:
- with a microtask while the current 8 ms slice lasts;
- otherwise it is held for one MessageChannel turn, so the event loop gets keyboard, network and paint in between.
- Timeouts use
setTimeout(timeoutsin the stats).
Network
- The fibers share the realm, so they also share the one
WispNetwork(wispNetinextra). No net-bridge is needed. - Each stream event calls
onActivity, which wakes the parked fibers.NodeNetwork(the node harness) does the same. - A process waiting on the network parks: a bounded
gex_park, i.e. a promise.- The SAB build instead takes a MessageChannel turn per loop, because its bytes arrive on the Worker's own event loop.
- Copied into the single build, that turn spun the one thread. An exec'd
nodechild installing about 30k translated modules got a turn only between thirteen spinning node threads.child_process.spawnthen took minutes, and the page run hit its 900 s cap.
Terminal
PageTtysits on the kernel memory as in the SAB build, because the kernel is in this realm.- The page forwards keys and resizes to the system Worker. Every input, resize and signal pokes the scheduler.
Compositor
compositor-worker.jsruns in the system Worker's realm viainRealm, because only that realm can read the guests' memories.- The page keeps the canvas listeners. It transfers the canvas
(OffscreenCanvas) once, then talks through
comp/comp-outmessages. - The scene arena is a plain ArrayBuffer.
- The compositor draws on
requestAnimationFramein the Worker, between fiber slices.
3. Preemption and yield points
A fiber runs until it suspends. To keep one guest from starving the others and the page, the single build yields at two kinds of point:
- Safepoints. Translated code already calls
gex.safepointevery 1024 loop back edges (SAFEPOINT_INTERVAL, shims/browser/src/lib.rs). gexcc code polls (__gex_poll) every 65536 iterations.- In the single build the import is a
Suspending, which costs about 174 ns when it does not suspend, against 17.5 ns for a plain import. - Amortised over 1024 back edges, that is about 0.17 ns per back edge.
- It yields (
sched.maybeYield()) only once the 8 ms slice has run out.
- In the single build the import is a
- System calls. wasm32 programs and providers yield after each syscall
once the slice has run out.
- Each such syscall goes through the Suspending path.
- That adds about 1.2 µs (a
promisingentry at about 1 µs plus a suspend at about 170 ns) on top of what the syscall costs.
No new poll was added to generated code. The microbenchmark prices one:
- a per-back-edge budget counter with a refill of 65536 costs 0.26 ns per iteration on an empty loop (2.32 against 2.06 ns);
- a flag-word load costs 1.8 ns (3.82 ns).
Both are measurable on tight loops. The existing 1024-edge safepoint is the better place to yield.
Wakes wait for the waker to park
Every system call bumps the kernel's wake counter (k_wake, after every
call, so that no wake is ever missed). The SAB build pays for that in
parallel: each parked Worker wakes on its own core, re-checks and parks
again. In the single build every guest system call is itself a promise
(the Suspending path above). Resolving the woken fibers at once queued
their continuations ahead of the waker's own return from that call. So a
writer and its reader traded places on every write, and every other parked
fiber (the shell in wait4) re-checked each time.
Sched therefore keeps the woken in a ready list, and runs them only when:
- the running fiber parks;
- it calls
sched_yield(Sched#yieldNow); or - the event loop turns.
A turn is posted on every wake. It runs only once the microtask queue is empty, i.e. when every fiber is blocked on something other than the scheduler, and at once for a wake from a timer, the network or a key.
yes | head -c 20M | wc -c, node, single build (counts only, gooner) |
parks | wakes |
|---|---|---|
| woken at once | 48 877 | 29 341 |
woken when the waker parks (0a9b89f3) |
2 528 | 1 809 |
Filling the pipe before the reader ran exposed a kernel bug, in both builds:
writevto a full blocking pipe answeredEAGAIN, andreadvon an empty one did the same.blocking_opknew onlywriteandread.- coreutils'
headwrites withwritev, and died "Resource temporarily unavailable". - Both now park on their first non-empty vector and finish with a short count.
4. Fork
- A translated (arm64/x86) process forks as in the SAB build. The child is a new fiber with a copied memory.
- A wasm32 program forks with asyncify, as on the SAB build. The stack is
unwound to the syscall wrapper, the image is captured, and both parent and
child rewind. Under the single build, the image capture runs in the
Suspending wrapper's
afterhook (a.then), before the outer fiber resumes, so the parent cannot run ahead of its own snapshot. - No musl patches were added.
5. SAB and Worker uses, and their single-build paths
| where | what (SAB build) | single build |
|---|---|---|
| shims/browser/js/gex.mjs | shared process memory, Atomics.wait in kernel imports |
plain memory; Suspending kernel imports |
| gex.mjs module store | growable SAB | null (falls back to the per-process registry, as already designed) |
| worker.mjs | a Worker per guest thread; outputAck SAB |
FiberWorker; no ack (one queue keeps the order) |
host.mjs #outputAck |
SAB + Atomics.wait |
off |
| net-bridge.mjs | SAB ring to the page's Wisp | not used: wispNet shared by reference |
| tty.mjs | shared scratch memory | plain (kernel is in-realm) |
| wl-bridge.mjs | SAB scene arena | plain ArrayBuffer |
| compositor.js / compositor-worker.js | Worker reading SAB memories, stats SAB | in-realm in the system Worker; stats mirrored to the page every 500 ms |
| runtime.js kernel | shared kernel memory | plain kernel memory in the system Worker |
| gl-bridge.mjs, mesa-gl.js | SAB ring + Atomics.wait; Mesa is an Emscripten pthread build |
gl: 'compositor': the non-pthread Mesa (/gex/st/) in the system Worker beside the compositor. gl_submit is a Suspending import calling glInRealm.submit on the guest's own memory, with no ring copy. A page-side device (gl as an object) is not supported |
| xwayland-worker.js / hostx.js | Xwayland pthread build, own Worker on kernel SAB | HostX in the system Worker, on the non-pthread JSPI Xwayland (/gex/xwayland-st/): main suspends at each wait, which parks on the scheduler |
| dom-compositor.js | page reads SAB memories | not yet (the canvas compositor works) |
| persist.js, nt-page.js | persist Worker on the kernel SAB; SAB bundle | NT page only, which is not part of this build (the NT layer is legacy) |
The default build is selected exactly as before. Every single-build path
sits behind single.on, and single.on is true only when the realm runs
enableSingle():
- in the system Worker;
- or under node with
GEX_SINGLE=1.
The page picks the single build when either holds:
?single=1is given;- the page has no SAB and the engine has JSPI.
?single=0 refuses it.
6. Results
Correctness (gooner, untimed)
Node harness (GEX_SINGLE=1, SharedArrayBuffer deleted;
scratch/SP/nrun.sh):
- bash, pipes, command substitution, fork,
trap/killUSR1/TERM,sleep & wait; - python3 with 4 threads;
apt-get update/install(real TCP,GEX_NETWORK=1);rg;- nodejs 20.19.2:
node -e "console.log(1+1)"prints 2; - neovim 0.10.4:
- headless edit and write;
vim.fn.system({"echo","hi"}), which forks and execs from inside nvim.
Chrome page without COOP/COEP (GEX_NO_COI=1):
-
apt-get updateoverwss://wisp.mercurywork.shop/; -
apt install ripgrep tcc libc6-dev, thentcc -runandrg; -
foot in the compositor (
?gui=1):- it draws;
- typed input reaches it (
seq 40 42).
The same foot run on the SAB build still passes, so the default compositor is unchanged.
SAB against single, same commands, edger
Run with scratch/SP/page/cmp.sh in headless Chrome 153 under bench.sh.
- The page is the native Debian demo over
wss://wisp.mercurywork.shop/. - "single" is served with no COOP/COEP; "SAB" is the same branch served cross-origin isolated.
- Runs c1 (before
e4779ccaande10ec855) and c2 (after). - Raw output is in
scratch/SP/page/out/c{1,2}-{single,sab}.txt. - All values are ms of wall time as measured in the guest, except where the row says s.
- Network-bound rows (apt) swing 2-4× from run to run with the public Wisp proxy, in both builds.
| command | single c1 | single c2 | SAB c1 | SAB c2 |
|---|---|---|---|---|
true |
135 | 124 | 175 | 156 |
for i in $(seq 100); do x=$(echo $i); done (100 forks) |
7216 | 6646 | 10417 | 10104 |
yes | head -c 20000000 | wc -c |
2205 | 2072 | 441 | 391 |
| python3, 2M-iteration loop (its own clock, s) | 2.53 | 2.05 | 2.44 | 2.39 |
apt-get update |
26266 | 11361 | 10183 | 39357 |
apt-get install ripgrep tcc libc6-dev |
52342 | 50783 | 47936 | 109482 |
rg --version |
1408 | 1402 | 1541 | 1531 |
tcc -run hello.c |
1474 | 1829 | 1839 | 1679 |
rg -c define stdio.h |
2922 | 3154 | 3221 | 2416 |
apt-get install neovim |
17051 | 16450 | 18653 | 31483 |
nvim --version |
3672 | 4135 | 3345 | 3728 |
nvim --headless +q |
6765 | 7163 | 6755 | 6611 |
apt-get install nodejs |
78290 | 104249 | 141265 | 169786 |
node --version |
3380 | 3235 | 3130 | 3464 |
node -e 0 |
59699 | 62812 | 57113 | 56873 |
| node, 3e7-iteration loop (its own clock) | 8427 | 8434 | crashed | 9980 |
What the table shows:
- Hot code. It is not slower on a JSPI stack:
- the python loop takes 2.05-2.53 s single against 2.39-2.44 s SAB;
- the node loop takes 8427-8434 ms single against 9980 ms SAB.
- fork and exec are faster. 100 command substitutions take 6.6-7.2 s single against 10.1-10.4 s SAB, about 1.5× faster. A fork in the single build makes a fiber, not a Worker.
- Pipelines are slower. A three-process pipeline takes 2072-2205 ms
single against 391-441 ms SAB, about 5× slower. It loses the cores its
processes ran on, and every byte crosses the kernel on one thread.
- Profile (node harness, edger, single):
Pipe::readpopped a byte at a time (182 ms), andk_pipe_readzeroed the whole request on every call (alloc_zeroed, 240 ms), of 2970 ms in total. Both are fixed in4fcc6417, which helps both builds. - Scheduling itself is small: 48.9k parks and 29.3k wakes for the 20 MB,
with
wasm-to-jsat 72 ms.
- Profile (node harness, edger, single):
- node start-up (
node -e 0) takes 59.7-62.8 s single against 56.9-57.1 s SAB, 5-10% slower. It installs about 30k translated modules with an async compile each, on one thread instead of seven. - Not single-specific. node's child_process calls fail in both
builds on the page from the provider bug in §7. In single mode it already
fires on
node -e "console.log(1+1)"(rc 137).
The default build is not slower. C's head e4779cca (same wasm,
without this branch's JS; run c3, leg 1, cut short) against this branch's
SAB build (c2). Raw ms:
| command | C's head | this branch, SAB |
|---|---|---|
true |
139 | 156 |
| pipeline (20 MB) | 407 | 391 |
nvim --version |
3921 | 3728 |
nvim --headless +q |
6873 | 6611 |
node --version |
2760 | 3464 |
node -e "console.log(1+1)" |
38949 | 40063 |
node -e 0 |
57034 | 56873 |
| node 3e7 loop (guest clock) | 10216 | 9980 |
node t.js |
80718 | 81141 |
Node harness, edger (whole harness run, ms; scratch/SP/prof):
| command | SAB | single |
|---|---|---|
yes | head -c 20000000 | wc -c |
1562, 1653 | 2453, 2377 |
| 100 command substitutions | 7495, 7538 | 5204, 4927 |
c4: ee504238, before the wake fix (edger, GEX_WALL=900)
Times in ms. Both modes hit the 900 s wall after spawnSync nvim.
| command | single | SAB |
|---|---|---|
true |
127 | 145 |
| 100 forks | 6369 | 10087 |
| pipeline (20 MB) | 1772 | 404 |
| python loop | 2370 | 2500 |
apt-get update |
9822 | 9088 |
apt-get install ripgrep tcc libc6-dev |
77077 | 18282 |
apt-get install neovim |
29778 | 30462 |
apt-get install nodejs |
122984 | 114600 |
node -e 'console.log(1+1)' |
39950 | 38726 |
node -e 0 |
63538 | 56513 |
| node loop | 8945 | 9761 |
node /tmp/t.js |
96536 | 81067 |
The pipeline row is the ping-pong that §3's wake deferral removes; the timed rerun is pending (see §8).
7. Limitations and next steps
-
Provider bug (C), both builds, page only: fixed. The panic was "provider address 0x71dc1a1 keeps losing its bridge", then
gex-emit/src/module.rs:661. C's fe08c546/bc679c69/4f1d86e4 fix it, and they are merged here. While it lasted, a dead fiber holding the process's lock made its siblings trap inmemory.atomic.wait32in the single build (and hang in the SAB build). -
Blocking inside a nested call-out suspends. A function-tier unit's call to code that no unit holds (the
call_outandftab_callimports) is a Suspending import in the single build:#callOutStepsis a generator holding the old#callOutloop.#driveruns it synchronously (SAB build, or under a frame that cannot suspend).#driveAsyncruns it withenterand each guest function asWebAssembly.promising, so the nested guest has its own stack.- Its
#nestentry ends in~.#hardNested()ignores those, so a park, futex wait or syscall inside it suspends the whole chain.
Test:
tests/single-callout/run.sh. The unitlibcb_runcallscb, which reads a pipe a forked child fills 100 ms later and sleeps 2 ms per call. Before the change it trapped with "trying to suspend JS frames … inside syscall 101".Still synchronous:
nt_callback,exc_dispatchandprovider_try. A block under one of those throws an explicit error in the single build. None of the runs here hit them. -
Provider atomic waits on contention. These are the
crt_heap/glibc_mallocheap_lock_slowandgp_waitwaits, and the libprovwait_epoch. They would trap (memory.atomic.wait32on an unshared memory) if they ever wait. In the single build only one fiber runs at a time, so a lock is never contended while its holder is suspended inside it, except across a suspension. This is not seen so far. -
GL and the X server work. Both are built without
-pthread(plain memories), locally from thewinkpin; nothing was pushed.- Mesa:
GEX_MESA_THREADS=0 web/build-mesa-wasm.sh.- Uses
web/mesa-wasm-st-cross.iniand Wink'sstTint; no glthread. - Output:
web/public/gex/st/.
- Uses
- Xwayland:
GEX_XWL_THREADS=0 web/build-xwayland-wasm.sh.- Built with
-sJSPI, and gexos.c's wait is anEM_ASYNC_JS. strip_pthreadremoves the-pthreadthat meson's threads dependency adds.- Output:
web/public/gex/xwayland-st/. - Host packages a distro may lack come from
web/build-xwayland-host-tools.sh(§9).
- Built with
- Checked on gooner (software WebGPU, correctness only):
- gears (single): triangles 640, frames 30, gl error 0, covered 15%.
- native-debian
?gui=1(scratch/SP/page/x.sh), apt over Wisp:xdpyinfoon:0reports X.Org 24.1.9;- xlogo and xeyes are managed and drawn;
- the screenshot is identical to the SAB build's.
web/public/fs/libGLESv2.so.2andlibEGL.so.1were rebuilt. The checked-in ones spoke GL protocol 12, and gl-proto.js speaks 13.
- Mesa:
-
Wakes are wake-all. Every parked fiber re-checks its word, though only once the running fiber parks (§3). This is fine for tens of fibers; a node run peaked at 272.
-
The kernel has 128 stack slots, which caps the number of fibers alive at once.
-
Dead fibers' memories are freed by GC.
8. Final state (stream SP stopped 2026-10-10)
The stream is stopped and will not be restarted. merge/singleproc lands in
master with the multi-process build (MP) the default everywhere. The single
build (SP) stays available behind its option. web/build-all.sh --sp (or
--both) builds its own dist, web/dist-sp, and serves it without
COOP/COEP (§9). Any page without SharedArrayBuffer, or with ?single=1,
runs SP.
What works in SP
These run in a page with no COOP/COEP:
- the native Debian demo, with apt over Wisp (update and install);
- fork, exec, pipes and signals;
- multithreaded guests;
- the xterm terminal;
- the Wayland compositor (foot);
- GL through the non-pthread Mesa (gears: 640 triangles, 30 frames, gl error 0, 15% covered);
- Xwayland in-realm (
xdpyinfo :0, xlogo, xeyes; the screenshot matches MP's).
Measured numbers
All timings below are edger, under bench.sh. The table is the c4 run in §6,
taken before the wake fix (ee504238):
- the
truecommand and 100 forks are faster in SP than in MP; - apt-get update, neovim and the node rows are within 20% either way (SP is 19% slower on
node /tmp/t.js, 96536 vs 81067 ms); - pipelines and the first big apt install are slower in SP.
The wake fix (0a9b89f3, §3) addresses those two slow rows. Its timed rerun
never ran, because the stream stopped first. What it changes, counted under
node on gooner (untimed):
| run | parks | wakes |
|---|---|---|
| 3-process pipeline, before | 48 877 | 29 341 |
| 3-process pipeline, after | 2 528 | 1 809 |
apt-get install ripgrep tcc libc6-dev, before |
310 845 | 90 973 |
apt-get install ripgrep tcc libc6-dev, after |
11 238 | 3 337 |
The apt install gap is the same ping-pong as the pipeline. dpkg and apt talk over pipes in small writes, and before the fix every write resumed the reader at once. The fix cuts parks 28-fold (310 845 to 11 238).
After the fix, a CPU profile of the install (node, single mode) has no scheduler entry near the top. The largest are exec and fork work, which MP pays as well:
moduleContentKeyis 16% of samples. It is a byte-at-a-time JS hash of each native wasm program's bytes (dpkg, apt and their methods), run on every exec by#cgenInstall.adoptProcess,writeSparseand#processImage(fork images) make up about 12%.
In MP those run on the child's own Worker. In SP they run on the one thread.
Known gaps
nt_callback,exc_dispatchandprovider_trystill call nested guest code synchronously. A block under one of them throws an explicit error in SP (§7). Wine-style callbacks are therefore MP-only.- Provider atomic waits on contention would trap on an unshared memory (§7). None was seen.
- The apt and pipeline rows have not been re-timed after the wake fix on edger. Under node the counts above show the cause is gone.
- On gooner (SwiftShader), the gears demo through drive.mjs reports "covered 0%" in MP, on master as well. SP reports 15%. Treat that as a headless-presentation artefact of MP, not an SP property.
Next steps (for whoever picks it up)
- Time
scratch/SP/page/pipe.shandscratch/SP/page/cmp.sh TAG single sabon edger at the merged head (GEX_WALL=900,BENCH_TIMEOUT). - Hash wasm programs once per file (cache the key on the path and mtime, or hash in wasm), not on every exec. This helps both builds.
- Make
nt_callback,exc_dispatchandprovider_trysuspendable, the waycall_outis (§7).
Branches
merge/singleproc: this work.merge/sp-iov37604a2f: the writev/readv park, kernel only, also in merge/singleproc.merge/sp-gllibsc02088a0: the guest GL libraries rebuilt for protocol 13.
9. Building, serving and testing both builds
Layout
Each flavour has its own dist, and neither holds the other's Mesa or Xwayland:
| MP (default) | SP | |
|---|---|---|
| dist | web/dist, served with COOP/COEP |
web/dist-sp, served without |
| shim, kernel, seed | gex/gex_browser.shared.wasm, gex/gex_kernel.wasm, gex/seed.shared.wasm |
the same three files |
| Mesa build tree | target/web-build/mesa-mt |
target/web-build/mesa-st |
| Mesa in the dist | gex/gex-wgpu-replay.* |
gex/st/gex-wgpu-replay.* |
| Xwayland build tree | target/web-build/xwl-mt |
target/web-build/xwl-st |
| Xwayland in the dist | gex/xwayland/ |
gex/xwayland-st/ |
| unstripped wasm | target/web-debug/mp/ |
target/web-debug/sp/ |
rootfs, fs/, providers |
copied into both | copied into both |
The shim, kernel and seed are built once (web/build-assets.sh) and copied
into both dists. SP loads the shared binaries and clears their memories'
shared flag at compile time (§2).
An SP-only shim would not be smaller by much:
- Its fibers need per-instance stacks and TLS. In Rust on wasm those come
only from the
+atomicsbuild-std build that MP uses. - Without the
shared-memoryfeature that build does not compile: talc'sTalcSyncCellrefuses a threaded target with no heap lock. The heap's spinlock is what the feature adds. - The atomic instructions are the rest. There are 15 738 of them in the stripped shim, about 2 bytes each over a plain access, so at most about 31 KB (0.6% of 5 159 727 bytes). They sit mostly in std's TLS and locks, which SP needs anyway.
So there is one build.
No page loads these, so neither dist ships them:
- the plain
gex_browser.wasm; seed.wasm;gex_kernel.plain.wasm.
run-node.mjs and the check scripts use them from web/public.
Sizes
Measured on gooner from this tree. "Runtime" is the dist without rootfs/
and fs/ (177.6 MB and 7.6 MB, the same in both).
MP web/dist |
SP web/dist-sp |
|
|---|---|---|
| whole dist | 213 377 145 B | 213 094 572 B |
| runtime, raw (tar) | 28 200 960 B | 27 914 240 B |
| runtime, zstd -19 | 5 369 300 B | 5 400 608 B |
These are the large files, stripped:
| file | raw | zstd -19 |
|---|---|---|
gex_browser.shared.wasm (52 107 296 B with DWARF) |
5 159 727 B | 1 153 655 B |
gex_kernel.wasm (14 815 564 B with DWARF) |
259 673 B | 69 140 B |
Mesa gex-wgpu-replay.wasm (20 599 167 B with its name section) |
18 891 407 B | 3 305 586 B |
Xwayland.wasm |
2 064 538 B | 573 528 B |
gexhostwl.wasm (12 265 903 B with DWARF) |
144 639 B | 42 643 B |
What a page fetches until the demo has passed. This is counted by
GEX_SERVE_LOG behind serve-dist.mjs --sp-port, on gooner, with every
response no-store:
| demo | MP (web/dist, COOP/COEP) |
SP (web/dist-sp, none) |
|---|---|---|
| busybox (to the shell prompt) | 13 files, 7 527 568 B | 16 files, 7 450 725 B |
| gears (30 frames) | 24 files, 28 205 364 B | 26 files, 27 176 339 B |
In both builds the large items are the same:
- the shim (5 159 727 B);
- the busybox rootfs (1 464 320 B);
- for gears, Mesa (18.9 MB MP, 18.7 MB SP) and the guest's libc and GL libraries.
SP once fetched the shim twice, from the page and again from the system
Worker. The page now hands its copy to the Worker (primeRuntime).
Keeping the name section would add 712 183 B to the shim (5 871 910 B).
Nothing in the runtime reads it, so the dists strip every custom section
(wasm-tools strip -a), except on the seed and the providers, whose gex.*
sections are read. --debug ships them unstripped.
Build
git submodule update --init third_party/mesa third_party/opengl-registry
(cd web && npm ci)
web/build-all.sh --both # or --mp (the default) / --sp
build-all.sh runs these steps:
web/build-assets.sh: shim (plain and shared), kernel, seeds, providers, busybox rootfs and guest GL libraries, intoweb/public. Cargo makes this step incremental.- Mesa for each flavour asked for:
GEX_MESA_THREADS=1|0 web/build-mesa-wasm.sh target/web-build/mesa-mt|st.- Incremental through meson and ninja.
- Wink caches its Tint per variant.
- Xwayland for each flavour:
GEX_XWL_THREADS=1|0 web/build-xwayland-wasm.sh target/web-build/xwl-mt|st.-
The script always builds from scratch, so build-all skips it while a stamp of its inputs is unchanged:
- the script;
web/xwayland/;shims/hostwl/;- the tags.
-
If the distro lacks a piece, it first runs
web/build-xwayland-host-tools.sh target/web-build/xwl-hostand passes that prefix asGEX_XWL_HOST_TOOLS. The pieces it checks:- wayland-scanner, wayland-protocols or xwaylandproto (through
pkg-config); - xorg-macros;
X11/xshmfence.h.
The host-tools build is cached by a
.donestamp. - wayland-scanner, wayland-protocols or xwaylandproto (through
-
- For each flavour, the dist:
- a hard-linked copy of
web/publicintarget/web-build/public-<f>, pruned to that flavour; - the large wasm files replaced by stripped copies, with the unstripped
ones linked into
target/web-debug/<f>/; vite buildfrom that tree (GEX_PUBLIC_DIR) intoweb/distorweb/dist-sp(GEX_OUT_DIR).
- a hard-linked copy of
Flags:
--no-assets,--no-gland--no-xskip steps 1-3 one by one.--dist-onlyskips all three.--debugkeeps the debug sections.GEX_WEB_BUILD=DIRmovestarget/web-build.
web/refresh-dist.sh, which every shim and kernel build calls, rebuilds
whichever of the two dists exist the same way (build-all.sh --<f> --dist-only). After any source change, web/build-all.sh --both rebuilds:
- the runtime, through cargo;
- Mesa, through ninja;
- Xwayland, when its stamp changes;
- both dists.
The native Debian rootfs and its GUI variant come from their own scripts
(web/build-native-debian.sh) into web/public/rootfs, and so into both
dists.
Serve
node web/serve-dist.mjs 8088 --sp-port 8089
This serves web/dist (MP, with COOP/COEP) on 8088 and web/dist-sp (SP,
without them) on 8089, at the same time. GEX_SERVE_LOG=FILE logs each file
served with its size.
Test
-
MP: the default page smoke test,
node web/test/site.mjs(busybox, debian, gears), againstweb/dist. -
SP leg, for the gate:
GEX_WEBGPU_SOFTWARE=1 tests/single/run.sh "$PWD/target/release/gexc"It runs these:
tests/single-callout/run.sh(single and SAB under node; it adds--experimental-wasm-jspion a node that needs it).GEX_DIST=web/dist-sp GEX_NO_COI=1 node web/test/site.mjs <demo>for each ofSP_DEMOS(default: busybox, debian, gears).
Each demo must pass, and its terminal must contain "single-thread build (no SharedArrayBuffer)", so a silent fallback cannot pass.
GEX_WEBGPU_SOFTWARE=1puts WebGPU on SwiftShader for hosts without a GPU. -
Kernel:
cargo test --release -p gex-kernel(vectored_io_on_a_blocked_pipe_parkscovers the writev/readv park). -
Under node:
GEX_SINGLE=1 node --import <delete SharedArrayBuffer> shims/browser/js/run-node.mjs ....GEX_SINGLE_STATS=1prints the scheduler counts.