WebAssembly / shared-everything-threads

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

Accessing non-shared locals insides shared-barrier #47

Open tlively opened 4 months ago

tlively commented 4 months ago

At the meeting today we discussed how it would be useful for shared-barrier to work like let and introduce new non-shared locals that could be accessed only inside the barrier block. However, let had a bunch of usability issues that caused us to discard it in favor of tracking the initialization state of non-nullable locals.

I realized that we could use the same strategy for non-shared locals inside shared-barrier blocks. Non-shared locals could be declared alongside other locals in shared-suspendable functions, but would not be accessible except inside shared-barrier blocks. They would start out as uninitialized at the beginning of each shared-barrier and would have to be explicitly initialized (potentially with null values) before they could be accessed. Alternatively, the beginning of the shared-barrier could implicitly set defaultable non-shared locals to their default values.

This seems to neatly solve the problem of accessing non-shared locals inside shared-barriers using only the existing mechanism of initialization tracking. It also eliminates one of the differences between shared-suspendable and shared-fixed functions by letting them declare the same kinds of locals, which seems nice.

tlively commented 4 months ago

Taking this a step further, you could say that there truly is no difference between shared-suspendable and shared-fixed functions, just shared functions that are all suspendable by default but are also allowed to have unshared parameters and locals. The unshared parameters can always be safely accessed; it is impossible for a function with unshared parameters to be called except inside a shared barrier or unshared function. Unshared locals can be accessed only in shared barriers as described above.

We could optionally make shared functions with unshared parameters have implicit shared barriers around their bodies to save code size, but that’s probably too magical. Regardless, engines could optimize out explicit shared barriers in such functions and just have them reset any unshared locals since the engine would know that there must already be a shared barrier on the stack.

conrad-watt commented 4 months ago

Thinking about the following program...

func (unshared x) {
    (unshared local a)

    (shared-barrier
        (local.set a)
    )

    // Do some stuff including possible shared suspensions / resumptions
    (local.get x) // seems suspicious to be allowed to do this if we resumed in another thread

    (shared-barrier
        (local.get x)
        (local.get a)
    )

}

I think the only way to make the above work is to make shared-barrier reset relevant non-shared locals ~and parameters~ to default/uninitialised values (as suggested in the OP). This looks to me like a new kind of "thread-bound local", which I wouldn't necessarily be against.

~Note in the above it's not always safe to be able to access the unshared parameter x, because we may have already suspended and resumed in a new thread. I think unshared parameters actually end up needing to be restricted very similarly to unshared locals, with the possible exception that a "shared-barrier at the function boundary" shared-fixed semantics may allow them to be accessed outside an explicit shared-barrier block~

EDIT: ah, I get the point about unshared parameters - if they were provided, there must already be an outer shared-barrier. But in this case it's strange to require an additional shared-barrier to access regular unshared locals. I agree it's necessary to make shared-suspendable with unshared locals work.

EDIT2: one further point - if the reason to avoid separate shared-suspendable and shared-fixed types is to avoid different funcrefs that can't be call_indirect'd in a mixed way, the presence of a non-shared parameter in the function argument seems to undercut this, as it effectively behaves as an implicit shared-fixed (requiring a shared-barrier for any function with this signature to be called).

tlively commented 4 months ago

I think the only way to make the above work is to make shared-barrier reset relevant non-shared locals to default/uninitialised values (as suggested in the OP).

Right, the neat thing here is that unshared locals can never be live outside of or across shared-barriers by construction.

the presence of a non-shared parameter in the function argument seems to undercut this, as it effectively behaves as an implicit shared-fixed (requiring a shared-barrier for any function with this signature to be called).

But this is totally fine because all shared functions with the same signatures can have the same type. If one function takes a shared argument and another had a similar signature but the argument is unshared, then of course they’re going to have different function types.

eqrion commented 4 months ago

This is an interesting idea. I sort of understand the local managment as let but every let in the function must use the same set of locals that are declared in the function definition. I think it's workable from an engine perspective.

One unfortunate piece here is that we'd have to pay the complexity cost of shared-barrier before actually having shared continuations in engines. One thing I liked about the two separate func types was that it would let us start with just shared-fixed and then add shared-suspendable if/when shared continuations become a thing.

But then again, timelines for standards are hard to predict. Maybe we'll have stack-switching finished before this proposal?

conrad-watt commented 3 months ago

@eqrion I'm personally beginning to feel that starting out with shared-fixed might be the right way to go. If we expect that shared-suspendable will definitely need a block-level shared-barrier (with @tlively's suggested semantics) in order to manipulate locals, then we already need both validation modes in the spec (the validation mode inside a shared-barrier is pretty much the same as shared-fixed), so exposing both modes as different function types isn't a significant further stretch.

EDIT: and starting with just shared-fixed punts a lot of complexity until the stack-switching proposal is worked out.

This would likely mean that shared-fixed and shared-suspendable would need to be different funcref types, but I think this is fine - I don't think "mixed" call-indirect is totally vital, and anyone really wanting this could wrap a shared-fixed function as shared-suspendable with just an additional barrier (either by pre-processing the module, or less efficiently at Wasm link-time if necessary)

tlively commented 3 months ago

This would likely mean that shared-fixed and shared-suspendable would need to be different funcref types...

Why is that? My understanding is that we would want to differentiate the definitions to determine the validation mode for their bodies, but why would references to the two kinds of functions need to have different types?

Apart from that point, starting with shared-fixed SGTM, although I do think we should continue to flesh out a full design for shared-suspendable as well just to make sure we don't paint ourselves into a corner.

conrad-watt commented 3 months ago

Why is that? My understanding is that we would want to differentiate the definitions to determine the validation mode for their bodies, but why would references to the two kinds of functions need to have different types?

If we start with shared-fixed, then introducing shared-suspendable later needs to be done either by reinterpreting every shared-fixed function body as introducing an implicit shared-barrier, or by requiring an explicit shared-barrier wrapping all calls from shared-suspendable to shared-fixed (i.e. shared-fixed is only directly callable in shared-fixed validation mode).

I would currently be in favour of the latter semantics for two reasons.

  1. It avoids a potentially wide-reaching codegen change for existing shared-fixed functions, which may have unexpected performance effects even in code that doesn't interact with shared-suspendable (which IMO could be the vast majority of code).
  2. I'd expect most calls from shared-suspendable to shared-fixed will need to be wrapped in a shared-barrier anyway, so that the shared-suspendable function can provide nonshared stack arguments to the call (IMO the need to do this if any function arguments are nonshared already makes mixed shared-suspendable/shared-fixed indirect calls less realistic).

EDIT: thinking through things further, I think I could be ok with the "implicit shared-barrier" approach if we had robust experimental evidence that it wouldn't cause trouble for existing code. In any case, the choice between the two approaches is also something that could be punted until the stack switching proposal is finalised.

tlively commented 3 months ago

Makes sense.

  1. It avoids a potentially wide-reaching codegen change for existing shared-fixed functions, which may have unexpected performance effects even in code that doesn't interact with shared-suspendable (which IMO could be the vast majority of code).

I've been assuming that shared-suspendable would be much more common and that shared-fixed would only be used in rare edge cases involving unshared host interop, but of course that is only possible if we introduce them both simultaneously.