|
| 1 | +# Wasm per-call cost: findings, and the fix |
| 2 | + |
| 3 | +Written 2026-09-05. Durable copy of work that would otherwise live only in hive |
| 4 | +kanban, which was down when this was written. If hive is up, the cards |
| 5 | +`[CLJW-WASM-ENGINE-DEFAULT]`, `[CLJW-WASM-WORKER-THREAD]`, |
| 6 | +`[CLJW-WASM-BENCH-BLIND]`, `[CLJW-WASM-EXPORTSIG-CACHE]` and |
| 7 | +`[ZWASM-PERCALL-TRACK]` carry the same content. |
| 8 | + |
| 9 | +## The measurement |
| 10 | + |
| 11 | +20k calls per cell, one process, same trivial `add` export, Ultra 9 185H, |
| 12 | +ReleaseSafe `-Dwasm`. Reproduce with |
| 13 | +`./zig-out/bin/cljw .dev/bench/ffi_boundary/engine_thread_matrix.clj`. |
| 14 | + |
| 15 | +| cell | ns/call | |
| 16 | +|----------------------|---------| |
| 17 | +| JIT, main thread | 16547 | |
| 18 | +| JIT, worker thread | 1181 | |
| 19 | +| interp, main thread | 399 | |
| 20 | +| interp, worker thread| 413 | |
| 21 | + |
| 22 | +For context, from `.dev/bench/ffi_boundary/`: a Clojure function call is 184 ns, |
| 23 | +so the interpreter's 399 ns crossing is 2.1x a local call. The boundary is |
| 24 | +cheap. The default is not. |
| 25 | + |
| 26 | +## Two independent causes, both upstream, both already documented there |
| 27 | + |
| 28 | +zwasm ADR-0209 (`.dev/decisions/0209_percall_latency_bench.md` in the zwasm |
| 29 | +tree; clone at `~/PP/referential-projects/zwasm`) names both. Do not re-report |
| 30 | +them upstream. |
| 31 | + |
| 32 | +- **D-584** dominates on Linux. `computeStackLimit` runs on every JIT |
| 33 | + invocation, and glibc's `pthread_getattr_np` answers for the process's FIRST |
| 34 | + thread by opening and parsing `/proc/self/maps`. Any other thread answers from |
| 35 | + the thread descriptor. zwasm measures 26.8 us initial thread, 561-578 ns |
| 36 | + worker thread, 3.1-3.4 ns on aarch64-macos. The interpreter caches the value, |
| 37 | + which is why it is thread-indifferent above. |
| 38 | +- **D-585** is the residual. The embedding API resolves the export by name on |
| 39 | + every call and `findExportFunc` parses the whole module to do it. Linear in |
| 40 | + module size (687-859 ns/KiB upstream). Public issue zwasm#208. |
| 41 | + |
| 42 | +Neither is fixed as of zwasm v2.6.0 / upstream main: `computeStackLimit` is |
| 43 | +still called at `entry.zig` 248 and 272 and `entry_buffer_write.zig` 86. |
| 44 | + |
| 45 | +A warning worth repeating, because this session walked into it. ADR-0209 says: |
| 46 | + |
| 47 | +> "A reviewer checking this on Linux alone will find the two paths agree within |
| 48 | +> 2%, which reads as 'the re-parse does not matter'. That agreement is D-584's |
| 49 | +> constant, well over 100x larger, swamping D-585." |
| 50 | +
|
| 51 | +The mirror-image error is just as easy: attributing the whole Linux cost to the |
| 52 | +re-parse because the re-parse is the part you read. Two costs on one path cannot |
| 53 | +be separated by reading either one. Get a number that isolates the candidate. |
| 54 | + |
| 55 | +## The fix: cljw should not run on the initial thread |
| 56 | + |
| 57 | +Spawn one thread in `main.zig` with an explicit stack size, run the existing |
| 58 | +main body on it, join. The runtime stays SINGLE-THREADED; it just stops being |
| 59 | +thread 1. One spawn at startup, tens of microseconds, once. It buys ~14x on |
| 60 | +every JIT wasm invocation on Linux and costs nothing on macOS, where D-584 does |
| 61 | +not exist. |
| 62 | + |
| 63 | +The win is already reachable from Clojure with no cljw change: wrapping |
| 64 | +crossing-heavy work in `@(future ...)` measured 12.5x. That is a workaround, not |
| 65 | +the fix, because a user should not have to know this. |
| 66 | + |
| 67 | +### Feasibility, already checked |
| 68 | + |
| 69 | +- cljw's stack guard is **anchor-relative**, not an OS query: |
| 70 | + `stack_base - @frameAddress()` (`src/eval/backend/vm.zig:114`, |
| 71 | + `src/eval/backend/tree_walk.zig:449`). It measures consumption below an anchor |
| 72 | + captured at entry, so it works on any thread provided the anchor is captured |
| 73 | + there. ADR-0157's self-calibrating guard is not an obstacle. |
| 74 | +- GC stack scanning also anchors on `@frameAddress()` |
| 75 | + (`src/runtime/gc/mark_sweep.zig:380`). Single-threaded on a different thread |
| 76 | + scans the same one stack it always did. No change in kind. |
| 77 | + |
| 78 | +### Still to check before landing |
| 79 | + |
| 80 | +- Explicit stack size on the spawned thread. Do not inherit a default smaller |
| 81 | + than the 8 MB the main thread had, or ADR-0157's calibration shifts under it. |
| 82 | +- Signal handlers and guard pages (ADR-0019 crash policy) installed expecting |
| 83 | + thread 1. |
| 84 | +- Anything assuming the initial thread: REPL / terminal handling, atexit, macOS |
| 85 | + specifics. |
| 86 | +- The nREPL, `future` and agent paths already spawn threads. Confirm the move |
| 87 | + does not double-nest them or change their parent. |
| 88 | + |
| 89 | +This is ADR-level, since it changes where the whole runtime executes. Draft the |
| 90 | +ADR with the mandatory devil's-advocate fork per CLAUDE.md, then land it behind |
| 91 | +a full gate. |
| 92 | + |
| 93 | +### What it does not fix |
| 94 | + |
| 95 | +Even on a worker thread the JIT is 2.9x the interpreter on a trivial callee. |
| 96 | +That is D-585, untouched by this change. Engine choice stays shape-dependent: |
| 97 | +the JIT wins 15.5x on compute-in-wasm (`bench/wasm_jit_vs_interp.sh`) and loses |
| 98 | +on crossing-heavy work at any thread. A single default cannot serve both, which |
| 99 | +is the open question in `[CLJW-WASM-ENGINE-DEFAULT]`. |
| 100 | + |
| 101 | +## The methodological defect behind all of this |
| 102 | + |
| 103 | +Every workload in `bench/wasm_bench.sh` calls a `*_bench` export that carries |
| 104 | +its own iteration loop, so each run crosses the boundary exactly once. A |
| 105 | +per-crossing cost of any size is invisible to the entire suite. That is how a |
| 106 | +~19 us per-call cost sat behind a suite reporting cljw at 1.4-4.4x wasmtime. |
| 107 | + |
| 108 | +zwasm reached the identical conclusion about its own harness (ADR-0209 D1) and |
| 109 | +built `bench/latency/percall_runner.zig` in response: instantiate once, call |
| 110 | +many times, time only the calls, report median with min and max, and record the |
| 111 | +per-platform constant separately so a regression in it is distinguishable from a |
| 112 | +regression in the engine. Copy that shape rather than inventing one. |
0 commit comments