Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Toolchain side of RTTs for JS API? #280

Closed
kripken opened this issue Feb 22, 2022 · 22 comments
Closed

Toolchain side of RTTs for JS API? #280

kripken opened this issue Feb 22, 2022 · 22 comments

Comments

@kripken
Copy link
Member

kripken commented Feb 22, 2022

I've been having trouble wrapping my head around how toolchains (bundlers, optimizers) would use the RTT-based approach for the JS API. Perhaps the most concrete writeup of that is this comment by @rossberg

#279 (comment)

So I will use that as a starting point. This comment at the end I think describes the types of situations that worry me:

To be sure, this does not prevent the modules from mixing up or returning the wrong kind of struct objects if they're buggy (the Wasm type system does not enforce that), but that's the norm in JavaScript.

But I am not sure that this is a problem only with buggy code.

Using the $point and $size from the example in that link, imagine that each type is defined in its own module, and that those modules do not interact. That situation is easy to handle as described there using RTTs. If a bundler wants to merge those modules together, there is no problem: the RTTs effectively distinguish the origin of the code. (Or, if not using RTTs, the bundler could keep the types separate - which is fine as they do not interact. And then JS info could be tied to the wasm type.)

If the modules do interact then the situation can become a lot more complex. By "interact" I mean that some wasm code actually uses the fact that the $point and $size types get merged - the types are "mixed". That is, some $point instance ends up in a location declared as $size or vice versa.

One issue occurs in a real-world pattern (which I've seen in Wasm GC code) of keeping a cache of (immutable) objects. If we mix the types, then a $point might end up in the cache of code originating from the $size module (simply because a $point might be passed in, and the cache mechanism see it as the first appearance of that pair of values). And then the cache might end up used later in a way that escapes all the way out to JS, potentially surprising it with the type.

A similar situation arises if $point code calls into $size code, and the latter ends up doing an allocation (say, adding vectors to produce a new one). The $size-originating code doesn't know that the value will actually flow out to $point code and from there to JS. So it will allocate a $size and JS will be surprised.

The problem is deciding which RTT to use, and the answer isn't inside the wasm. It isn't even answerable by a call out to JS, because of the caching issue mentioned before (that is, JS might make a call into wasm to get a $size, and when the wasm runs it calls out to JS to ask what to allocate, but the object ends up in a cache which is only returned later - when we want a $point).

These situations are not buggy wasm code. Each wasm module is valid, and the wasm modules interact in a valid way. Only JS ends up confused here, and the confusion can be difficult to debug and to reason about. Working around it would require refactoring of valid wasm code (like removing caches). At the toolchain level, I am worried because I don't see how to make this work properly by design.

In the systems I am familiar with problems of this nature are avoided by keeping a more firm connection between the wrapped types (in our case, wasm types) and the wrapper types (in our case, JS types). Options there include keeping a 1:1 mapping between the two, or other things (I believe @jakobkummerow 's idea in this comment is a valid option in that space). Maybe I am not familiar with some existing toolchain technique here, however, or have missed something?

@rossberg
Copy link
Member

The sole purpose of the Wasm type system is to inform the engine about data representations and enable efficient code generation. Other than that, GC types are not intended to provide any more type information than you'd get by laying out data in linear memory yourself.

It's still morally assembly code you're interacting with, and should be treated as such. In other words, JS glue code needs to know what it's doing, especially wrt types.

In your scenario, I think you are implicitly assuming that it ought be possible to invoke some form of reflection on Wasm objects. I realise that in JS it is common practice to be fast and loose with forgetting contextual information and depend on recovering it later by reflection. But that is not an appropriate pattern in a language without reflection, like Wasm. (If the objects were represented by i32 pointers into Wasm linear memory, you wouldn't expect that ability either.)

Consequently, I would argue that there indeed is a bug somewhere in the scenario. Where it lies exactly depends on the contracts you are assuming for each module. For example, I would assume that a module that imports an external RTT for its structs does so on purpose, and wants to treat these structs as distinct from any others created from a different RTT, and this would be part of its informal contract. Hence, a module that combines the two others better makes sure it does not "mix" their types, at least not without keeping track of which is which itself. Otherwise it is in violation of some of the other contracts, despite this being "valid" Wasm.

@jakobkummerow
Copy link
Contributor

The sole purpose of the Wasm type system is to inform the engine about data representations and enable efficient code generation. Other than that, GC types are not intended to provide any more type information than you'd get by laying out data in linear memory yourself.

And at the same time, there is a consistent desire to let as many source-language-level casts as possible piggyback on Wasm-level casts...

I would assume that a module that imports an external RTT for its structs does so on purpose, and wants to treat these structs as distinct from any others created from a different RTT

That sounds like essentially the concept I called "option three" in #279: host annotations affect how Wasm views/treats its types. That could be an explicit type system feature (as I sketched), or this (kinda vague) requirement that module bundlers/optimizers refrain from "mixing"/merging/deduplicating things (e.g. types, functions, caches, ...) that per their Wasm semantics would be totally fine to merge.

I share @kripken 's concerns: this seems brittle.

And it reminds me of issues we discussed before around the difficulty of mapping nominal type expectations (of source and/or embedding languages) onto the canonicalizing behavior of the Wasm type system. The "iso-recursive hybrid" approach makes the canonicalization itself much cheaper, but doesn't address these impedance mismatches. So I guess one way or another we'll have to get back to that discussion. There have been various ideas tossed around for providing some sort of "opt-out" from canonicalization, whether it's "private types" or relying on "this allocation uses an imported RTT" or a few other ideas from past discussions.

@kripken
Copy link
Member Author

kripken commented Feb 23, 2022

@rossberg

In your scenario, I think you are implicitly assuming that it ought be possible to invoke some form of reflection on Wasm objects.

I don't think I am - everything seems very static here - but maybe it is implied in my thinking somewhere, can you elaborate?

Consequently, I would argue that there indeed is a bug somewhere in the scenario. Where it lies exactly depends on the contracts you are assuming for each module. For example, I would assume that a module that imports an external RTT for its structs does so on purpose, and wants to treat these structs as distinct from any others created from a different RTT, and this would be part of its informal contract. Hence, a module that combines the two others better makes sure it does not "mix" their types, at least not without keeping track of which is which itself. Otherwise it is in violation of some of the other contracts, despite this being "valid" Wasm.

Perhaps. I am finding it hard to actually describe the contracts here in terms of concrete wasm, which I think we need for us to have a non-brittle toolchain story.

Meanwhile I realized that the key issue is that this scenario combines two interop techniques:

  • Type merging for wasm-wasm interop.
  • RTTs for wasm-JS interop.

And it looks like it is not safe to use both at once in general. So we'd need to describe when it is actually safe (which as I said, I am not sure yet how to design), or we would need to avoid using those two techniques together (which can work - e.g. 1:1 type mappings as mentioned before can make things robust - but seems counter to the goals of those techniques).

@rossberg
Copy link
Member

rossberg commented Mar 1, 2022

@jakobkummerow:

And at the same time, there is a consistent desire to let as many source-language-level casts as possible piggyback on Wasm-level casts...

Perhaps, but of course that's not a sufficient criterion. There are many things we could add along the lines of certain higher-level features, which would obviously benefit source languages with similar features.

Wasm's primary criterion for including features so far was: do common CPUs provide it? And a secondary one was: is this something only the engine can do? We need to somehow lift those principles to the abstraction level of the GC proposal.

@kripken:

I am finding it hard to actually describe the contracts here in terms of concrete wasm, which I think we need for us to have a non-brittle toolchain story.

The vast majority of a typical module's contract is not expressible within Wasm's type system and will never be. The same is still true for languages with way more sophisticated type systems. But unlike those user-facing languages, Wasm's type system is not even intended for verifying correctness. Why would we make an exception for this one property?

And it looks like it is not safe to use both at once in general.

Depends on your definition of "safe". Of course it can be safe in the sense of being correct, but there is no reason to expect Wasm to check that for you.

The rule of thumb is to not expect any more application-domain checking from the GC type system than you get from linear memory. Its sole purpose is to describe data layout to the engine.

@kripken
Copy link
Member Author

kripken commented Mar 1, 2022

@rossberg

Wasm's type system is not even intended for verifying correctness. Why would we make an exception for this one property?

To be clear, I am not expecting wasm to verify correctness here.

My concern is composability: I can have wasm modules A and B that work together properly (using type merging), and also wasm B works together with JS code C (using RTTs), but a problem arises when I try to use A+B+C at once as described above. As a toolchain developer I want to know how we are thinking about building such an ecosystem that works properly.

The rule of thumb is to not expect any more application-domain checking from the GC type system than you get from linear memory.

For comparison with linear memory: As you say, we don't expect wasm to check things for us - for example, if the C ABI differs between modules, things can break. But the above A+B+C problem does not happen there because we have a linear object layout that is nominal and fixed, and then we share memory in the same way whether that is JS or wasm. Do you have an idea for how to get similar results with both type merging + RTTs?

@rossberg
Copy link
Member

rossberg commented Mar 2, 2022

@kripken:

But the above A+B+C problem does not happen there because we have a linear object layout that is nominal and fixed.

I'm afraid I don't follow. In what sense are objects in linear memory "nominal"? And in what sense is their layout fixed? Where you have an object reference with GC memory, you merely have an i32 with linear memory objects. How does that provide more information for disambiguation in the scenario you describe?

@kripken
Copy link
Member Author

kripken commented Mar 2, 2022

@rossberg

Sorry, I realize now the term "nominal" may have been confusing. Maybe an example is best. Assume we have a struct in C/C++/Rust/etc. of

struct Time {
  int hours;
  int seconds;
}

Then we need an ABI to determine how that maps to linear memory. Say that hours is at offset 0 and seconds at offset 4. By "nominal and fixed" I just meant that the type name Time is a global descriptor used across the linked modules (in their source code), and that we have a fixed notion of how to access its data (the offsets). So I guess I was using the term somewhat metaphorically - it's not present in the wasm, of course (as you said, wasm does not check stuff like this).

This would then be used as follows. Both wasm and JS code that wants to interop with this simply needs to have access to the memory + to know that ABI. For example, in Emscripten we'd write something like this in JS:

function printTime(ptr) {
  console.log('Hours:', HEAP32[ptr + C_STRUCTS.Time.hours >> 2]);
  console.log('Seconds:', HEAP32[ptr + C_STRUCTS.Time.seconds >> 2]);
}

That gets emitted into final JS code like this:

function printTime(ptr) {
  console.log('Hours:', HEAP32[ptr >> 2]);
  console.log('Seconds:', HEAP32[ptr + 4 >> 2]);
}

In C, we'd write stuff like

void printTime(Time* time) {
  printf("Hours: %d\n", time->hours);
..

and that memory access would be compiled into

;; get hours
(i32.load offset=0
  (local.get $time))

When either C or JS want to access Time data we know exactly how to do it using that fixed layout - there is no ambiguity. They know they are talking about the same type, and they have an ABI for it, and so we can link A+B+C without issue.

@fgmccabe
Copy link

fgmccabe commented Mar 2, 2022

Do you really mean >>2? Or should it be <<2?

@kripken
Copy link
Member Author

kripken commented Mar 2, 2022

I did mean >>2. HEAP32 is a typed array view for 32-bit integers (Int32Array), and so if the pointer is to address 1024 then element 1024>>2 == 256 in that typed array is the right one to access. That is, HEAP32[256] is a 32-bit integer covering addresses [1024, 1028) (easier to reason about from 0 maybe: HEAP32[0] is the first 4 bytes of memory, [0, 4)). Here is some example emscripten code using it:

https://github.com/emscripten-core/emscripten/blob/774c56d9c0b2dfd7a3e308dfcf6e726d96d8ecc4/src/postamble.js#L153-L157

@rossberg
Copy link
Member

rossberg commented Mar 3, 2022

@kripken, that all makes sense, but I don't understand how this relates to the original problem. Wouldn't this code transfer to a GC-based solution just fine? All that would change is that instead of indexing the HEAP32 array, you'd use some API-provided accessors on the pointer.

The problem you are describing in the OP seems to have something to do with not knowing which RTT to use, but none of the operations in the code snippets above would need to pick an RTT. That would only occur if they were allocating a new object (and if they cared about having different RTTs in the first place, which is up to the host code anyway).

Furthermore, your C example seems to assume to know that it is operating on a Time struct, not a Size struct. So wouldn't it also know which RTT to pick if it had to?

@kripken
Copy link
Member Author

kripken commented Mar 4, 2022

@rossberg

Wouldn't this code transfer to a GC-based solution just fine? All that would change is that instead of indexing the HEAP32 array, you'd use some API-provided accessors on the pointer.

Do you mean some getter/setter export functions, the no-frills approach #279? Yes, I can see how that can work. And in that case allocation would work as well (even with type merging) if we don't have RTTs to worry about.

but none of the operations in the code snippets above would need to pick an RTT

Exactly: when not using RTTs (in either linear memory or GC) I think I can see how things work. In this issue though my question was how we can do the same thing when using RTTs (together with type merging), which I don't understand yet. And the background is I am asking about RTTs because you said it was the plan of record for JS API integration, so I'd like to undertstand the toolchain side of it. Does that make sense?

If my question isn't clear enough, perhaps you can point me to some docs of existing toolchains that work using RTTs + type merging?

If that's not convenient, then maybe I can phrase the question another way. In the linear memory example here the ABI is a clear contract. If we find that things don't work, then we know how to debug it. In the example, we'd check that accesses of field zero read an i32 from offset 0, and for field 1 the offset is 4. So if we see that one toolchain is emitting an offset of 8, we can go back to them and say "this is wrong for the ABI which is defined as ...". How can we do something similar using RTTs + type merging, if we hit a bug like the original A+B+C example? You responded to that earlier,

Consequently, I would argue that there indeed is a bug somewhere in the scenario. Where it lies exactly depends on the contracts you are assuming for each module.

I am looking for how to define those contracts when using RTTs + type merging.

Furthermore, your C example seems to assume to know that it is operating on a Time struct, not a Size struct.

Ah, I could have kept the C example closer to the original example from earlier. Perhaps that would have been clearer, sorry. Getting back to the original example of types Point and Size, but looking at it in linear memory, imagine that wasm module A defines a function get_magnitude(Point*) which returns sqrt(x^2 + y^2), and wasm module B defines a function that does the same but on a Size. The two wasm functions may be identical even though they operate on different source types (they read the same offsets etc.). And it is safe for a bundler/optimizer to merge these functions into a single one (which saves code size). And it remains safe to have caching mechanisms like mentioned before (again, without RTTs there is no problem of knowing which RTT to pick during allocation) - but those appear to cause problems when using RTTs and GC type merging.

@rossberg
Copy link
Member

rossberg commented Mar 7, 2022

And in that case allocation would work as well (even with type merging) if we don't have RTTs to worry about.

Well, you don't have to "worry" about RTTs, unless you choose to. It's an option you can pick or ignore.

There are two scenarios:

  • Either you don't care about RTT customisation. Then you simply use canonical RTTs everywhere. Then it still works exactly the same as if RTTs where implicit, no effect on type merging. The only difference is that the code makes explicit where RTT values are consumed, instead of hiding them behind the scenes, as a high-level language would.

  • Or you explicitly want custom RTTs. The only point of custom RTTs is to customise the host-side appearance of Wasm objects. So presumably, host-side code would only do that if it specifically wants to distinguish these types. In your scenario, that doesn't seem to be the case, but I may misunderstand something. In any case, you are defining manual accessors, so there wouldn't be much point in also defining custom RTTs.

Put differently: either the program wants to treat certain Wasm types as structurally interchangeable – then using different custom RTTs would be inconsistent, and canonical ones are what you want. Or it wants to treat them as if they were "nominally" different – then distinct custom RTTs are useful, but confusing and mixing the types would be an application bug, even though Wasm does not check that for you. Both choices can be expressed with RTTs, but the programmer needs to decide which it is. (Without RTTs, they can't, the only choice would be the former.)

With that in mind, I believe the "bug" in your scenario is that different modules seem to have inconsistent ideas about whether Point and Size represent nominal or structural types, conceptually. Or am I missing something?

@tlively
Copy link
Member

tlively commented Mar 8, 2022

If the modules do interact then the situation can become a lot more complex. By "interact" I mean that some wasm code actually uses the fact that the $point and $size types get merged - the types are "mixed". That is, some $point instance ends up in a location declared as $size or vice versa.

Put another way: It is possible for anything operating at the Wasm level, for example some sort of per-Wasm-type instrumentation, to mix $point and $size, but at that level $point and $size are indistinguishable, so there is no way to un-mix them and any code expecting to be able to un-mix them is wrong. At higher levels where $point and $size are distinct, treating them as interchangeable is wrong.

@rossberg
Copy link
Member

rossberg commented Mar 8, 2022

If lower levels already mix them then higher levels cannot unmix them. If they ought to be kept separate at some given level, then all levels below have to keep them separate as well. I wouldn't know how else it can work, regardless of RTTs.

@kripken
Copy link
Member Author

kripken commented Mar 14, 2022

(sorry for late response, I was unavailable)

@rossberg

either the program wants to treat certain Wasm types as structurally interchangeable – then using different custom RTTs would be inconsistent, and canonical ones are what you want. Or it wants to treat them as if they were "nominally" different – then distinct custom RTTs are useful, but confusing and mixing the types would be an application bug, even though Wasm does not check that for you. Both choices can be expressed with RTTs, but the programmer needs to decide which it is. (Without RTTs, they can't, the only choice would be the former.)

I think I see now, thanks. So we would not expect to interoperate between these two techniques (distinct RTTs and type merging, or the two types of RTT approaches you mention).

Would we encourage people to build all their modules with distinct RTTs, for maximal ability to customize host appearance? It seems like if we don't then people using JS might be limited in what they can do with modules that they receive (if we have a package manager for wasm modules this would quickly become an issue). And if only some modules use RTTs then they can't be mixed with modules without them, based on the discussion here. That kind of worries me, to have effectively two different ABIs in the ecosystem.

But I see that while I was away the direction is to focus on the no-frills approach. That seems reasonable to me. So I guess this issue can either be closed or we can leave it open for post-MVP times.

@rossberg
Copy link
Member

Would we encourage people to build all their modules with distinct RTTs, for maximal ability to customize host appearance?

It's up to the module that defines it, similar to types in other languages. The main criterion probably is whether the type just represents a simple piece of transparent data in an auxiliary fashion, or do its values embody additional invariants that are specific to this use case and represent their own abstraction?

@kripken
Copy link
Member Author

kripken commented Mar 16, 2022

Thanks @rossberg It's hard for me to follow that, I guess because it's fairly abstract and I'm not familiar with anything similar in the linear memory toolchain space to compare it to. But as I seem to be the only one unsure about this there's probably no need to get into any more detail here.

@jakobkummerow
Copy link
Contributor

@kripken You're not the only one who's concerned/unsure. The issues debated here are the key reason why I'm strongly supportive of our recent decision to have the "no-frills" approach be our initial solution. We'll have to have serious discussions if/when we pick up any possible future proposals that involve custom RTTs; luckily we can postpone those discussions until then. Generally speaking, having structural/canonicalizing behavior on the Wasm side while exposing fully nominal behavior to JavaScript (or other hosts) seems... difficult to get right, to put it mildly.

@tlively
Copy link
Member

tlively commented Mar 16, 2022

Would we encourage people to build all their modules with distinct RTTs, for maximal ability to customize host appearance?

I think the people doing the configuration to customize the host appearance would typically be the same people building the modules. For example, library authors would use JS GC customization to provide a nicer JS interface to their WasmGC library. So we wouldn't have to encourage anything in particular—library authors would just do whatever makes the most sense for their libraries.

It seems like if we don't then people using JS might be limited in what they can do with modules that they receive (if we have a package manager for wasm modules this would quickly become an issue)

I don't understand the problems you're seeing with package managers and bundlers. From a bundler's point of view, the canonical RTT used when not customizing the JS interface is just another distinct RTT value like all the custom imported RTTs, and the tool merging the Wasm modules will not get those distinct RTTs mixed up.

@kripken
Copy link
Member Author

kripken commented Mar 16, 2022

@tlively

I think the people doing the configuration to customize the host appearance would typically be the same people building the modules.

Makes sense. But it seems like they have a bunch of choices, like writing modules using Interface Types, or using RTTs, or using plain exports in the no-frills approach, or to only depend on type merging at the wasm level. What worries me is if say most wasm module builders don't care much about JS (maybe they are building for the server side). If they end up using type merging then JS and the Web will be left out. That's an ecosystem risk I think if JS integration is opt-in and different than wasm support.

To some extent this concern relates to Interface Types etc. as well. But there is something about RTT operations that is "invasive" - it's not just something at the boundary, and not just some extra exports for getters/setters, instead building with RTT support means using different instructions like *.new_with_rtt and also the module must receive, store, and use the RTTs appropriately (as an example, right now j2wasm output has none of those things in it).

From a bundler's point of view, the canonical RTT used when not customizing the JS interface is just another distinct RTT value like all the custom imported RTTs, and the tool merging the Wasm modules will not get those distinct RTTs mixed up.

It sounds like you have a more specific idea of how RTTs would be used than me. Would modules always import RTTs (as opposed to using RTT-free operations or not importing them) and the outside provide the canonical RTT when nothing more specific is needed? I think maybe that's what you're getting at here, and I think it might work (to decide at runtime about this stuff, assuming the whole ecosystem does it), but I'm not sure.

Overall I feel like if I saw some concrete examples of all this it might become more clear to me. But as I said above, I worry about wasting our time if all this is post-MVP.

@tlively
Copy link
Member

tlively commented Mar 16, 2022

re: invasiveness of RTTs: yes, I definitely agree they are invasive. That's why I still prefer not including them in the MVP (#275) and why I also filed WebAssembly/gc-js-customization#1 about decoupling RTTs for casting from the JS customization mechanism.

What worries me is if say most wasm module builders don't care much about JS (maybe they are building for the server side). If they end up using type merging then JS and the Web will be left out. That's an ecosystem risk I think if JS integration is opt-in and different than wasm support.

I see, yes, there would be ecosystem risk here. By only using canonical RTTs in an upstream wasm-only package, downstream JS users would not be able to add a custom JS API in a world where RTTs and js customization are coupled.

Would modules always import RTTs (as opposed to using RTT-free operations or not importing them) and the outside provide the canonical RTT when nothing more specific is needed?

The way I understand it, non-canonical RTTs would generally be imported to facilitate customization. In the case where the Wasm producer decides to use canonical RTTs instead (without customization), they could either use rtt.canon or equivalently use rtt-free instructions without having to import anything.

@tlively
Copy link
Member

tlively commented Jun 16, 2022

Since we decided to move RTTs out of the MVP, I'm going to close this issue for now.

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

No branches or pull requests

5 participants