emscripten icon indicating copy to clipboard operation
emscripten copied to clipboard

Fix late-binding symbols with JSPI

Open hoodmane opened this issue 11 months ago • 16 comments

Late-binding symbols get a JS stub import that resolves the symbol and then makes an onward call. This breaks JSPI. (It also canonicalizes NaNs by round tripping them through JS).

To fix this, we have to walk the import table and make wasm module stubs for each of the missing function-type symbols. These stubs will call a JS function to resolve the symbol but then insert it into a table and return control back to the wasm stub which has to make the onward call.

This currently relies on --experimental-wasm-type-reflection. To avoid that, we will need to parse the import table of the wasm binary manually. Also, without wasm-type-reflection, we have no way to handle the case where someone calls loadWebAssemblyModule() on a WebAssembly module instead of a Uint8Array.

This half way fixes #23395. I say half way because to be truly fixed the solution shouldn't rely on wasm type reflection. See also #23600.

hoodmane avatar Feb 07 '25 12:02 hoodmane

I say half way because to be truly fixed the solution shouldn't rely on wasm type reflection.

Why do you say this? Is that simply because wasm type reflection is not standardized yet? Presumably if it was standardized this would be good tool to solve this problem?

sbc100 avatar Feb 07 '25 16:02 sbc100

Yes in particular my concern is that I expect jspi to become available without a feature flag before wasm-type-reflection so if we depend on it there will be engines that otherwise would be able to use our jspi but can't because they haven't yet shipped type reflection.

Presumably if it was standardized this would be good tool to solve this problem?

Absolutely it's the right tool.

hoodmane avatar Feb 07 '25 18:02 hoodmane

@sbc100 do you have any specific feedback on this PR?

hoodmane avatar Feb 27 '25 14:02 hoodmane

@sbc100 do you have any specific feedback on this PR?

It feel like a lot of complexity to be adding. Perhaps we can find way to reduce it somehow? I need to take a deeper look at some point.

sbc100 avatar Feb 27 '25 19:02 sbc100

Unfortunately, I don't think we can do anything much simpler than this unless we change the shared object files.

hoodmane avatar Feb 28 '25 10:02 hoodmane

@sbc100 would you mind looking into this when you have a chance? It would be nice to make dynamic linking work correctly with JSPI.

hoodmane avatar Mar 31 '25 13:03 hoodmane

@sbc100 I think this is ready for another round of review when you have a chance.

hoodmane avatar Apr 04 '25 09:04 hoodmane

@sbc100 would appreciate review on this one as well.

hoodmane avatar Apr 16 '25 19:04 hoodmane

@sbc100 this is ready for another round of review

hoodmane avatar Apr 21 '25 19:04 hoodmane

@sbc100 this is ready for another round of review

When discussing this change with @kripken he suggested that we could use an alternative approach which used a little extra machinary in the JS wrapper to avoid the need the wasm code generation. He can probably explain better than me.

I'm not sure if this alternative approach would avoid the wasm type reflection dependency?

sbc100 avatar Apr 21 '25 19:04 sbc100

Late-binding symbols get a JS stub import that resolves the symbol and then makes an onward call. This breaks JSPI.

I wonder if another approach here could be to "forward" the Promise through JS? That is, if we have a call stack with JS in the middle,

wasm (top)
JS (middle)
wasm (bottom)

The VM can't suspend the JS like it can the wasm. All it can do is suspend the wasm on top, which then returns a Promise to the JS in the middle. But that JS can await that Promise, leading to the wasm on the bottom being suspended. So we don't have a single wasm suspension, but two of them, with JS "glueing" them together.

For this to work, the middle JS would need to add the JSPI wrapper stuff for suspending, on both sides. This might not be that simple, I'm really not sure, but maybe it's another option to consider here?

kripken avatar Apr 21 '25 19:04 kripken

When I tried that approach previously I found the performance was unsatisfactory. But there is a decent chance that v8 has improved its stack switching machinery since then. I'll try implementing it that way and we can see if it sucks less than this.

hoodmane avatar Apr 21 '25 19:04 hoodmane

When I tried that approach previously I found the performance was unsatisfactory. But there is a decent chance that v8 has improved its stack switching machinery since then. I'll try implementing it that way and we can see if it sucks less than this.

I think even if the perf isn't great it might be better to have something that works without depending on type reflection (and therefore works without flags).

sbc100 avatar Apr 21 '25 19:04 sbc100

Well I have a way to make this work without type reflection by just directly parsing out the imports section of the binary. It just introduces more complexity so I was planning to implement that in a followup.

hoodmane avatar Apr 21 '25 19:04 hoodmane

So the alternative approach could look something like:

            if (!(prop in stubs)) {
              var resolved;
              var func = (...args) => {
                resolved ||= resolveSymbol(prop);
                try {
                  resolved = WebAssembly.promising(resolved);
                } catch(e) {}
                return resolved(...args);
              }
              stubs[prop] = new WebAssembly.Suspending(func);
            }

hoodmane avatar Apr 21 '25 19:04 hoodmane

The other idea we talked about was doing something similar to wasm-split in binaryen. It would work roughly like this with two modules A and B:

  1. All the calls from A into B would be converted into call_indirect
  2. In A insert a Proxy or stubs in the wasm table
  3. On first call from A->B a) Load B b) Wrap all B exports with promising (and any imports as suspending if needed) c) B replaces the table in A with it's functions d) Call the function in B and resolve with the result

After the load of B, all the call_indirects would no longer go through the JS stubs and be calls from Wasm->Wasm.

brendandahl avatar Apr 21 '25 23:04 brendandahl

After thinking about this more, I still think this is the best approach:

  1. Making an approach like #24161 work both when we can stack switch and we can't will make it as least as bad as this.
  2. Fixing the problem with binaryen as Brendan Dahl suggests seems to require a few but not all dynamic libraries to be compiled separately for JSPI in order to work correctly even if they are not trying to stack switch. But also I think the proposal there won't work when we can't stack switch either.

The fix on this branch isn't that much code and it is all distributed in one place in the main executable. If the main executable requires both dynamic linking and JSPI, the relevant code is injected. If it doesn't require both, the code isn't injected. The same shared libraries can be used whether building with stack switching off, or building with it on but not actually inside a promising export, or inside a promising export.

hoodmane avatar May 18 '25 13:05 hoodmane