WebAssembly / shared-everything-threads

A draft proposal for spawning threads in WebAssembly
Other
34 stars 1 forks source link

Allowing instantiation of modules that import linear memory as shared, with unshared memory #82

Open dschuff opened 2 weeks ago

dschuff commented 2 weeks ago

First a caveat that this isn't really about shared-everything (i.e. GC) but about linear memory; but since the threads proposal went to phase 4 this is the current catch-all place to discuss shared memory.

Currently if you compile a module that imports a shared memory, instantiation will fail if the memory you pass at instantiation time is non-shared. This seems like an unnecessary restriction, since even if the module has already been compiled with real atomics, those atomics should still work fine if executed in a single-threaded context. This is somewhat analogous to the fact that atomics are allowed on nonshared memories (i.e. this could be seen as the "dynamic" version of that existing "static" allowance), and also to the fact that we allow loosening of growth restrictions at instantiation time (e.g. instantiating with a memory with a larger maximum size compared to what the module was compiled for).

The use case here would be when an app doesn't know whether or not it would be executed in an origin-isolated web context that allows SharedArrayBuffer. So it could use the same module, and then instantiate it one way or the other. Because atomics are allowed on non-shared memories already, this can be worked around today by actually modifying the bytes of the module's import section before instantiation but that's obviously hacky and loses the benefits of streaming compilation. So it would be much better just to loosen the restriction in the spec here. /cc @eyebrowsoffire

conrad-watt commented 2 weeks ago

This is essentially asking for nonshared <: shared, at least for memories, and historically the reason we didn't allow this is that we expected that shared and nonshared memories might have incompatible representations - e.g. choices at compile-time about explicit bounds checks vs guard pages and in-place vs moving memory.grow have historically varied between shared and nonshared memories. Supporting nonshared <: shared would reduce our current implementation flexibility in exchange for more user flexibility.

The use case here would be when an app doesn't know whether or not it would be executed in an origin-isolated web context that allows SharedArrayBuffer.

Are there deploy-time/link-time solutions for this, similar to the ones discussed in ~Binaryen~ (edit: LLVM) for swapping between memory32 and memory64?

EDIT: or maybe the discussions I'm remembering were actually in the context of the custom page sizes proposal...

tlively commented 2 weeks ago

I wonder if it would be worth having a maybe-shared option for memories such that nonshared <: maybe-shared and shared <: maybe-shared. We've discussed this generally in the past and decided it didn't make sense for GC references in particular, but perhaps it could make more sense for memory imports.

There is a link-time solution for this in the sense that you can link your application into a version that uses a shared memory and also a version that uses an unshared memory without recompiling everything, but that doesn't save you from having to serve both and do feature detection to determine which module to download.

conrad-watt commented 2 weeks ago

We've discussed this generally in the past and decided it didn't make sense for GC references in particular, but perhaps it could make more sense for memory imports.

We discussed maybe-shared (IIRC) in the context of immutable strings, where we believed the shared and unshared versions could share a representation (or at least, satisfy eachothers' import type with copying behind the scenes if necessary) even if nonshared <: shared couldn't be allowed in general.

This is a tougher issue, where we have reason to believe shared and nonshared memories shouldn't be mandated to share a representation. Allowing the creation and use of a memory with maybe-shared type in the case that shared and nonshared memories really do need different representations would effectively be requiring that at compilation from wasm to native, each maybe-shared memory access is compiled to both the shared and nonshared path with a conditional check on the representation.

that doesn't save you from having to serve both and do feature detection to determine which module to download.

I'd expect that it's the responsibility (or at least, could be the responsibility) of the server/site owner to decide which version is deployed, since they're statically in control of whether they're enabling cross-origin isolation. The situation isn't as bad as with a bleeding-edge language feature where the site owner doesn't know if the visitor's browser will enable it or not (and so feature detection might be necessary) - with cross-origin isolation the site owner really does know at deploy time whether they've enabled it.

tlively commented 2 weeks ago

each maybe-shared memory access is compiled to both the shared and nonshared path with a conditional check on the representation.

Would implementations be able to fall back to a single more conservative code path (e.g. explicit bounds checks) instead? I'm having trouble thinking of a situation where you would actually have to branch like that.

I'd expect that it's the responsibility (or at least, could be the responsibility) of the server/site owner to decide which version is deployed, since they're statically in control of whether they're enabling cross-origin isolation.

The situation here is that the Wasm is produced as part of a library or framework that does not know whether the site will enable cross-origin isolation or not. This is extremely common!

eyebrowsoffire commented 2 weeks ago

Servers are not actually in full control of whether the sites they serve end up truly crossOriginIsolated. For example, they could he loaded in an iframe and the parent is not crossOriginIsolated.

For context, I am working on flutter dart2wasm support. We do not have much control over the user's deployment and serving strategy, so the goal is to he as robust as possible and try to make deployment simple and configuration free as possible. Currently, a flutter app with dart2wasm is made up of two wasm modules: one that is made up of the user's dart code which is compiled to WasmGC, and a renderer module written in C++. The app module imports the memory of the renderer module.

Currently our renderer is multi-threaded, so the memory the renderer module uses is a SharedArrayBuffer and likewise the import declaration in the app module for this memory specifies it as shared. I am working on a fallback renderer which is single-threaded that actually works in a non-crossOriginIsolated browsing context. Ideally, the application module could remain exactly the same in either scenario, and the JavaScript code that bootstraps the module would just choose to download the single- or multi-threaded renderer module based on whether the browsing context is crossOriginIsolated or not.

However, the friction arises because the application modules import declaration is still marked as shared, which is incompatible with the single-threaded module. In practice, it seems to me like in a non-crossOriginIsolated context, the runtime could just assume that all memories are essentially non-shared and be tolerant of this difference, because the security restrictions in a non-crossOriginIsolated context are designed to prevent memory sharing in the first place. Any extra native code that would need to be generated to account for shared memory interactions are moot in a non-crossOriginIsolated context because those interactions are prevented by design.

conrad-watt commented 2 weeks ago

Servers are not actually in full control of whether the sites they serve end up truly crossOriginIsolated... For context, I am working on flutter dart2wasm support...

Thanks, this scenario makes a lot of sense - and I think it means we can approach this issue more similarly to our previous discussions about feature detection, as @tlively was gesturing at above.

In practice, it seems to me like in a non-crossOriginIsolated context, the runtime could just assume that all memories are essentially non-shared and be tolerant of this difference, because the security restrictions in a non-crossOriginIsolated context are designed to prevent memory sharing in the first place. Any extra native code that would need to be generated to account for shared memory interactions are moot in a non-crossOriginIsolated context because those interactions are prevented by design.

Generally the problem isn't that extra code is needed, it's that we've been assuming that in principle generating code for shared vs nonshared memories could be a completely different process. In this scenario, "assum[ing] that all memories are... non-shared" in a non-crossOriginIsolated context would require the engine to have a separate code path to compile Wasm modules with shared memories differently in such context, which goes against our general expectation that binaries compiled from Wasm code can be cached/shared in a somewhat context-independent way within an engine.

Would implementations be able to fall back to a single more conservative code path (e.g. explicit bounds checks) instead? I'm having trouble thinking of a situation where you would actually have to branch like that.

My understanding is that this isn't something engines have put much engineering towards. If this were achievable ecosystem-wide, we could have considered nonshared <: shared memories directly - we wouldn't need maybe-shared (although maybe it would still be useful to smooth over the problem of re-exporting and attempting to postMessage a shared memory that's "really" nonshared). The main scenario I'd expect a genuine top-level branch in maybe-shared would be if the nonshared memory is implemented using moving/reallocating memory.grow, but the shared memory isn't.

If I just let myself assume the above is achievable, my next concern would be scalability wrt other types of memory we might care about in future, like mappable (i.e. do we try to allow some subtyping relation?). This general problem with "single deployment" in the presence of multiple memory types seems to be another example (cf. relaxed SIMD) of a situation where the feature we really want is feature detection/conditional compilation support, and we're playing whack-a-mole with our previous choices about Wasm's design because we don't have faith in our ability to standardise a conditional compilation feature. I think the committee is in a better place now, so maybe we could try for a real conditional compilation feature again?

conrad-watt commented 2 weeks ago

If this were achievable ecosystem-wide, we could have considered nonshared <: shared memories directly - we wouldn't need maybe-shared (although maybe it would still be useful to smooth over the problem of re-exporting and attempting to postMessage a shared memory that's "really" nonshared).

Oh, I also realise now that if we ever introduce first-class memories in pure Wasm in future, allowing a nonshared memory to be importable as shared would make it hard to prevent the memory from being smuggled into another thread as a first-class value. At the very least, the process of taking a shared reference to a statically-declared memory would require a dynamic type check (as @tlively gestured at above, analogous to the dynamic bounds check we do at instantiation), which seems like a smell. I'd advance this as another argument that what we really want is proper conditional compilation support.

rossberg commented 2 weeks ago

@conrad-watt:

Oh, I also realise now that if we ever introduce first-class memories in pure Wasm in future, allowing a nonshared memory to be importable as shared would make it hard to prevent the memory from being smuggled into another thread as a first-class value.

I was about to say this. Quite plainly, nonshared <: shared is semantically unsound, because it is incompatible with any form of feature that requires shared, be it first-class memories, shared functions, etc. In fact, it would already be a problem for shared functions today, since we must not allow them to access a nonshared memory.

In other words, it would make the entire (static) distinction pointless. Dynamic checks could replace the static distinction, but I'm not sure what an (efficient) semantic for those should be, e.g., in the shared function case. Surely, we don't want to check on every memory access. This seems like a highly undesirable direction.

A common supertype like maybe-shared would be sound, but as @conrad-watt points out that still requires a uniform representation for shared and unshared memories, which may be a rather severe restriction.