Add jspi-hooks pass - #9102
Conversation
2e83607 to
8e90b89
Compare
Using separate functions seems like it would be better for follow-up optimizations and seems like it might be simpler in the end than using opaque operation codes. Stepping back, is the idea that this would be used for "advanced" JSPI usage like for fibers and multi-stack reentrancy? It seems that these use cases all boil down to some version of green threads. Will "plain" JSPI usage continue to not need this pass? Would it simplify things at all if we can assume that there is only a single, blessed JSPI suspension import? All other "suspending" imports could simply take the returned promise as an externref and then pass it to the single cc @sbc100. |
8e90b89 to
4577ec3
Compare
|
Thanks for the feedback @tlively I've updated the PR to use 4 separate hook functions instead.
Exactly, in particular for Cloudflare we need to support reentrant JSPI with multiple Tokio runtimes via JSPI-scoped thread locals in the same Wasm instance so that durable objects can serve multiple IO-segmented requests. So yes exactly supporting green thread primitives or otherwise via these hooks.
It might reduce the import interface at the cost of the double call and indirection. But imports are already a short list, and the problem is exports listing is the long one that would still be required so it wouldn't really fix the whole problem I don't think. |
Wraps the JSPI boundary of a module with calls to fiber lifecycle hooks that the module itself exports: promising exports (matched by the jspi-exports patterns) call __jspi_enter before and __jspi_exit after the wrapped function, and suspending imports (jspi-imports patterns) call __jspi_suspend before and __jspi_resume after the import. The hooks must run inside the fiber's own wasm frames at the instruction before/after the boundary call, which a JS wrapper cannot do as it only observes the transition a microtask later, when another fiber may already have run. The wrappers keep all state in locals: the i64 token returned by the "before" hook is forwarded to the "after" hook and is otherwise opaque to the pass (Emscripten uses a pointer to its fiber record, hence i64 so that memory64 pointers fit). Exceptional exits call the "after" hook with error=1, then rethrow unchanged (catch_all_ref/throw_ref), so the hook sees the fact of the error but the pass needs no knowledge of any tag. The legacy try/catch form is emitted when the module already uses legacy EH, since engines reject mixing it with try_table, and try_table/exnref otherwise; a module that already mixes both is rejected. Imports are wrapped by moving the import to a new function and turning the original function object into the wrapper, so all existing uses (calls, ref.func, element segments, exports) reach the wrapper directly. With --pass-arg=jspi-dyncalls the pass also exports a wrapped __jspi_dyncall_<sig> trampoline around call_indirect for every function signature in the table, so that hosts making function pointers promising (Emscripten's promising dynCall and embind async functions) do not bypass the hooks.
4577ec3 to
d6483a0
Compare
Are these expected to literally be |
|
No plans to literally remap all thread locals. I suspect we'd want to introduce a new Rust macro utility like |
Backport the JSPI lifecycle hooks, REENTRANT_JSPI fiber stacks and epoll listener API to the 6.0.9 frontend, and take Binaryen from a release carrying the jspi-hooks pass (WebAssembly/binaryen#9102). The release sysroot stamp is dropped after patching so emcc installs the new headers.
|
How does this relate to the co-operative threading ABI that WASI is proposing: https://github.com/WebAssembly/wasi-sdk/blob/main/CoopThreading.md. Could JSPI piggyback on this instead of creating a separate standard? |
This implements a
--jspi-hookspass that wraps the JSPI entry/exit/suspend/resume boundaries of a module with calls to corresponding lifecycle hooks the module itself provides.This is needed in Binaryen itself because it cannot be done at another layer of the system - suspension incurs a microtask, so that any JS wrappers around the suspend JS function will not be correct, and any exit wrapper on the WebAssembly.promising itself will also not have the correct timing.
Only by directly integrating the synchronous timing of the hooks into the Wasm can we get sound tracking of JSPI context switching.
The use cases are various here:
The alternative to this approach would be to more heavily define JSPI instrumentation:
Given where JSPI is in its ecosystem adoption as an emerging convention, a general hooks-based approach feels like the best way to start on these problems for now.
The hooks are four function exports which must be provided by the binary for the transform to work:
They are called on every JSPI enter/exit/suspend/resume operation:
Then similar to the lines of asyncify, the PR here implements for Binaryen that contract as a transform pass:
--pass-arg=jspi-exports@<patterns>: each matching export is retargeted to a wrapper calling__jspi_enterand__jspi_exitaround it.--pass-arg=jspi-imports@<patterns>: each matching import is moved to a new import and the original function object becomes a wrapper calling__jspi_suspendand__jspi_resumeand passing the token between them, so every existing use (calls,ref.func, element segments, exports) reaches the wrapper with no reference rewriting.--pass-arg=jspi-dyncalls(plusjspi-dyncall-sigs@ii,vifor extra signatures) also exports a wrapped__jspi_dyncall_<sig>(fptr, ...)trampoline aroundcall_indirectper function signature in the table, so hosts that make function pointers promising (Emscripten's promisingdynCall, embindasync()) do not bypass the hooks.<sig>is thegetSig()alphabet.When instrumented:
try_table (catch_all_ref)calls the after hooks witherror=1, thenthrow_refs the exception unchanged, whatever its tag, so the pass needs no knowledge of any tag and no imports. Modules already using legacy EH get the legacytry/catch_all/rethrowform, since engines reject mixing the two; a module that already mixes them is rejected with a pointer to--translate-to-exnref.*, comma/newline separators and@fileresponse files like asyncify.The actual behaviors of the JSPI system are otherwise entirely left to the hook runtime implementations, with Emscripten expected to support a C runtime implementation for interfacing with JSPI hooks and also using it to build
REENTRANT_JSPI(PR which is based on this).Due to the generality though, other runtimes could implement their own custom systems similarly.
Tests: lit coverage of import wrapping through direct calls,
ref.func, element segments and exported imports; export retargeting with internal callers untouched; hook exclusion; features; response files; both EH forms; multivalue; trampolines with explicit signatures; the Fatal cases;--roundtrip/-O2stability; and an engine-leveltest/lit/d8run of the wrapped module under real JSPI (V8) checking the full event trace, exception identity through the rethrow,SuspendErrorreaching the resume hook, id forwarding across a real suspension, nested promising entry from a plain import, and a trampoline call.Made with AI assistance under my review