Skip to content

Commit e2ce5f1

Browse files
committed
docs(dev): durable record of the wasm per-call findings and the fix
Written to Markdown because hive was down; the kanban cards carry the same content when it is up. Confirms the worker-thread fix rather than leaving it a hypothesis: the JIT's 14x per-call penalty is entirely the process's first thread, because glibc answers pthread_getattr_np there by parsing /proc/self/maps. Spawning one thread in main and running the existing body on it recovers it, and cljw's own stack guard is anchor-relative rather than an OS query, so ADR-0157 is not in the way. Also records what the change does NOT fix (D-585's per-call re-parse, still 2.9x against the interpreter on a worker thread) and the benchmark blind spot that hid all of it.
1 parent c3b1605 commit e2ce5f1

1 file changed

Lines changed: 112 additions & 0 deletions

File tree

‎.dev/wasm_percall_findings.md‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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

Comments
 (0)