Skip to content

Support fiber API with JSPI - #27638

Open
brendandahl wants to merge 2 commits into
emscripten-core:mainfrom
brendandahl:jspi-fibers
Open

Support fiber API with JSPI#27638
brendandahl wants to merge 2 commits into
emscripten-core:mainfrom
brendandahl:jspi-fibers

Conversation

@brendandahl

Copy link
Copy Markdown
Collaborator

The fiber API currently only supports Asyncify. JSPI allows suspending and resuming WebAssembly execution via native stack switching without the code size and performance overhead of Asyncify bytecode instrumentation.

Mark asyncify_stack parameters as _Nullable in fiber.h since an Asyncify stack buffer is unnecessary under JSPI.

Update the fiber test to run under both Asyncify and JSPI.

The fiber API currently only supports Asyncify. JSPI allows
suspending and resuming WebAssembly execution via native stack
switching without the code size and performance overhead of
Asyncify bytecode instrumentation.

Mark asyncify_stack parameters as _Nullable in fiber.h since
an Asyncify stack buffer is unnecessary under JSPI.

Update the fiber test to run under both Asyncify and JSPI.
@brendandahl

Copy link
Copy Markdown
Collaborator Author

I'm kind of surprised we only have one test for this. I'm guessing this is not very widely used.

Comment thread system/include/emscripten/fiber.h Outdated
Comment thread system/include/emscripten/fiber.h Outdated
Comment thread test/test_fibers.cpp
G.fibers[0].init_with_api(h1, &val);
G.fibers[1].init_with_api(h2, &val);
emscripten_fiber_swap(&G.main, &G.fibers[0].context);
printf("direct-%d-*\n", val);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this new test case? Were we missing coverage of this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the existing test only ever swapped back and forth between child fibers and the main fiber. There was no coverage for swapping directly between two child fibers without going through main. Added a comment to clarify this.

Comment thread src/lib/libasync.js Outdated
Comment thread src/lib/libasync.js
#endif
var userData = {{{ makeGetValue('newFiber', C_STRUCTS.emscripten_fiber_s.user_data, '*') }}};
var start = {{{ makeDynCall('vp', 'entryPoint', true) }}};
start(userData).catch((e) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean the ``entryPoint` must return a Promise? Is that guaranteed?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The third arg is promising = true so this will always return a promise. Added a comment.

@sbc100 sbc100 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very impressive how seemingly simple this is.

@brendandahl

Copy link
Copy Markdown
Collaborator Author

@Akaricchi are you still using fibers in emscripten?

@Akaricchi

Akaricchi commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

@Akaricchi are you still using fibers in emscripten?

Yes, they are fundamental for Taisei Project's web port; in particular they are used by koishi as a coroutine backend. Koishi has a basic test, but it's known to be insufficient. I'll give this PR a try later. If you want to test it yourself, you can check Taisei's github actions workflows to see how to build it for emscripten. Coroutines are only used by the game logic right now, so you'd actually have to play for a bit (or idle in the main menu to trigger demo playback; mind that those desync on the master branch).

@sbc100

sbc100 commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

@Akaricchi I remember back when you added fibre support you were pretty vigilant about getting max performance.

I would be curious if you get a chance if you could confirm if the JSPI version is fast (hopefully it is) for you, and by how much?

@Akaricchi

Copy link
Copy Markdown
Contributor

Sure, I'll run some benchmarks when/if I get this to work with Taisei. The performance of the current asyncify-based version is objectively pretty damn bad when compared to native, but it ended up being acceptable for Taisei. I expect this to be fine too, unless it does something egregious like yielding to the browser event loop for every swap. The code seemed fine at a glance, but I don't know the specifics of how JSPI works.

@sbc100

sbc100 commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Sure, I'll run some benchmarks when/if I get this to work with Taisei. The performance of the current asyncify-based version is objectively pretty damn bad when compared to native, but it ended up being acceptable for Taisei. I expect this to be fine too, unless it does something egregious like yielding to the browser event loop for every swap. The code seemed fine at a glance, but I don't know the specifics of how JSPI works.

JSPI does require a micro-task for every swap, that is kind of fundamentally how it works. This is not the same things as the full browser event loop though, and we would expect it to be cheaper overall than ASYNCIFY (which has its own runtime and code size impacts).

Comment thread src/lib/libasync.js

swap(oldFiber, newFiber) {
return new Promise((resolve) => {
Fibers.fiberResolvers.set(oldFiber, resolve);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I like this dependency on the fiber struct's address. The docs state:

This structure represents a Fiber context continuation. The runtime does not keep references to these objects, they only contain information needed to perform the context switch. The switch operation updates some of the contents, however.

and that is true for the asyncify version. So it's possible to, e.g. realloc() an array of fibers without breaking anything. Perhaps you can fix this by reusing the rewind_id field of asyncify_data_t (embedded into emscripten_fiber_t), e.g. allocate an integer handle for each resolve and associate that instead of the address.

What happens when a fiber is discarded and never resumed though? Is there a zombie entry stuck in the map then?

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.

3 participants