-
Notifications
You must be signed in to change notification settings - Fork 57
Conversation
This PR proposes a "canonical ABI" for interface types. This feature was alluded to in Luke's recent [presentation to the CG][pres], and this PR intends to flesh it out more and get a more formal specification of what it might be. The idea of a canonical ABI is that the adapter language for interface types is one of the most complicated parts of the proposal, but with a canonical ABI we might be able to separate out the adapter language to a future proposal so we can get the goodness of interface types on a smaller time-scale. The ABI proposed here is not intended to be a "one size fits all" ABI. It does not express the full power of interface types as-proposed today (intentionally). It's hoped, though, that the adapter language as proposed in this repository right now can be seen as largely just an optimization over what the canonical ABI specifies. This way languages and tooling can work with the canonical ABI by default, and if necessary for performance a custom ABI can be used with custom adapters in the future. One way to think about this canonical ABI is that it's a generalization of the current `list.lift_canon` into encompassing entire function signatures in addition to lists. This PR describes the canonical ABI with a large amount of Python-like pseudo-code which describes the `lift` and `lower` operations for types, culminating in a `fuse` function which shows precisely how lifting/lowering happens when modules call each other. [pres]: https://docs.google.com/presentation/d/1PSC3Q5oFsJEaYyV5lNJvVgh-SNxhySWUqZ6puyojMi8
# the start of the memory of `src`. The `len` parameter is the length, in | ||
# units of $ty, of the buffer. The pointer must be well aligned. | ||
# | ||
# If the first handle value is not zero then TABLES.get_buffer will validate |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not having followed the origin of this design (apologies), why are these 2 types of specifying a buffer a single IT? Seems to me the common case of local memory should be kept as simple as possible.
Also out of interest, since these are fixed length, if you have a variable amount of data to receive, would you typically use some kind of "call again if the number of bytes read is equal to the size of the buffer" approach, or would you use list<u8>
instead? What if the intended data is utf-8?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nah no worries, these are good questions! I'm not entirely sure what you mean by 2 types as a single interface type, but I'll try to explain a bit more about the background of these types. If I miss the point of your question though let me know!
The push-buffer
was added for the WASI fd_read
sycall effectively, and pull-buffer
for fd_write
. The purpose is to expose the ability of short reads/writes in the API. The other main motivation for these being separate types is for virtualization and efficiency purposes. For example if read
were to return a list<u8>
then that would imply allocating a list. Similarly if write
took a list<u8>
then those who virtualized it would receive the entire input buffer copied into their own memory. With {push,pull}-buffer
these are solved since everything is a "lightweight pointer" where the callee decides how to read from it and how to fill it in.
Reading over this again, the push/pull
buffer types are not intended to be used interchangeably. They're intended to be separate types. I handled them in a similar branch here because their handling is similar, but I realize now that I probably need some tweaks here and there for the push/pull distinction (e.g. if you pass an index for a push buffer as a value of type pull-buffer then that should trap). I'll fix that locally and push up a clarification.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't mean push vs pull, but local memory vs handle (as encoded by the first i32, described by the comments this refers to). That seems to imply conditionals and verification on the receiving end, which at least naively sounds undesirable for just filling a buffer with bytes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah apologies!
The goal here was mainly around forwarding where if you expose a method that takes a push-buffer
then you're called with an i32
handle. If you want to forward that to something you import then all you have to pass is an i32
. Forwarding requires them to be the same type, and so the discrimant byte was added to allow you to pass received handles directly to other modules if desired (to avoid intermediate copies and such).
This does require lifting/lowering to check the i32, however, and that would indeed be a cost baked in, but just for the canonical ABI I think. Once fully fleshed out adapter instructions and such are here then a custom ABI could be used where it's always a local buffer (just a pointer/length) and that's wrapped up and passed along.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess in WASI it is anticipated that passing a buffer thru multiple boundaries is common enough that this "escape hatch" is necessary? One could also argue that for the purpose of being "canonical" you want the simplest, common use case, and that the cost of an extra copy is what will be solved by full adapters.
But anyway, that is probably a discussion for elsewhere. Thanks for the clarification!
This was always intended, just wasn't clear from the pseudo-code!
one issue with passing |
@programmerjake that'll definitely be supported with the full power of interface types and adapter instructions, but I think for the canonical ABI it may not be included. That's ok though in that the canonical ABI is just a stepping stone to get to the final interface types adapter instructions world in the end, so any performance issues (like reusing buffers and such) will all be enabled once the canonical abi isn't the only one you have to adhere to. |
Wow, this is some impressive work! Although I haven't had a chance to work through the details of the newer interface types yet, this looks like it should be forwards compatible with the more advanced kinds of adapters that have come up in discussions. Thanks for putting this together! One technical observation I should make is that the canonical representation of an interface type defined here does not respect subtyping. In particular, a variant with fewer cases is a subtype of a variant with more cases, but their representations will differ. I don't know the usage scenarios y'all have in mind well enough to say whether this is a problem or not, so I'm just noting the fact for those who do. One concern I have is that, if the data you want to exchange is not aligned with the canonical representation, then this involves a lot of copying (supposing the data is in a If that sounds helpful, feel free to follow up with me. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With this tweak, variant_start
contains the case label, which allows the producer and consumer to have different order and number of cases.
@RossTate Good catch with the variant subtyping; the proposed tweaks above should fix that. The really useful thing about the canonical ABI is that it presents a clear target for toolchains analogous to a normal native ABI, without requiring them to think about any more-esoteric "adapter" concepts at all. Even once we have full adapter functions, the canonical ABI will be valuable for this purpose for toolchains that aren't able to invest time in fancier adaptation. There is potential de(serialization) on both sides, but that's what you'd expect with a fixed ABI and matches what people are doing today with wasm+gRPC and the other RPC/message-passing schemes. |
Oh, actually I was referring to something else, but those tweaks are important too! What I was referring to was that there are subtypes
I understand that. I was just pointing out that there's a way to specify simple (i.e. easy-to-fuse) adapter functions that support this ABI, albeit without baking the convention into the specification, as well as a number of other use cases. For example, suppose someone wanted to implement a search for the first file in a directory matching a given regex via a file system whose interface is "get_contained_files : [(handle File)] -> [(list (handle File))]" and "get_file_name : [(handle File)] -> [string]". With the canonical ABI, this will involve a lot of copying. The file system will have to create each list of contained files in ABI form in order to lift it. Then the searcher will have to allocate space to lower that list (and create handles for files its search may never reach), and then the fuser will have to copy the list over. Likewise each file's name will have to be allocated in the searcher and copied over. But with even simple adapter functions, you can avoid all those copies (and only have handles for files you're actively using). With advanced adapter functions, you can do some especially expressive and high-performance things. I'm just pointing out that simple adapter functions can still be very useful/flexible/efficient without demanding engine complexity. |
Could you be more specific so we can fix it? Above you say that a "variant with fewer cases is a subtype of a variant with more cases, but their representations will differ." but note that the lifted representation can be completely different than the lowered representation due to the fact that (with the above tweaks) lifting semantically produces representation-independent abstract values and lowering semantically consumes these abstract values and thus the net result of the
What you're describing sounds like exactly where we started with this proposal, back when it was named "Host Bindings", so it's not a bad idea ;-) We moved away from this design b/c it ultimately only avoids (de)serialization in a small subset of cases (particularly once you consider non-trivial type nesting). Specifying a canonical ABI simply accepts (de)serialization, just like every other cross-wasm-instance communication framework that we're seeing people use today (e.g., gRPC/protobufs, waPC), so the canonical ABI seems to perfectly fit the definition of "MVP". Moreover, once we do have full adapter functions (which I very much think we should have, so we can categorically eliminate unnecessary intermediate (de)serialization), it would be unfortunate to have two distinct ways to adapt. (Trying to design the per-parameter/result combinators as a "subset" of the final adapter functions would imply that we know the design of adapter functions, which we don't.) In contrast, a canonical ABI is already a valuable complement to full adapter functions for the reasons described in |
Co-authored-by: Luke Wagner <mail@lukewagner.name>
Co-authored-by: Luke Wagner <mail@lukewagner.name>
# It's expected that `read_utf8_string` will not trap and will instead | ||
# follow the `TextDecoder.decode` semantics which is what WTF-16 languages | ||
# like JS/Java/C# will want. This means that invalid utf-8 encodings will | ||
# produce replacement characters. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am rather unhappy with this paragraph, as I think that we need to account for encoding problems for many popular languages incl. JavaScript (see).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Noted. Probably we shouldn't discuss this broader technical design question here, but in a separate issue and also in your upcoming June 22 session. For my part, once I get a bit more time, I'll create a new issue with a more comprehensive justification of the current design choice that a string
is a list
of char
and a char
is a Unicode Scalar Value, from which this paragraph derives. (Which error mode we use for TextDecoder.decode
is a separate question -- I could imagine several viable options here.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, just wanted to make sure that we do not yet merge this PR before the group had the opportunity to discuss implications.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@lukewagner I was about to put my thoughts in the CG discussion issue, and I remembered your mention here of a justification writeup. Is this still to come? I'd prefer to read it before commenting myself.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry for the delay; I've been both chasing down some supporting details and also distracted by other things, but I do want to get to it this week. Don't feel obligated to wait for me, though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, I posted a first issue summarizing just the first question (about surrogates) in #135.
What I'm trying to point out is that the canonical representation in linear memory of a type does not respect subtyping. For example, adding more cases makes the representation of an existing case in linear memory larger. Reordering cases changes the numbering used in linear memory. You can fix the former issue, but not the latter issue. It's still not clear on how precisely this canonical ABI will be used, so I can't say whether or not this will matter. I just wanted to ensure there was awareness of it.
I'm not sure why you say this. Simple adapter functions can do arbitrarily nested lists. For example, given an arbitrary nesting of
Well the simple ones would desugar to the advanced ones. More specifically, a simple lifter would desugar to an advanced adapter "producing" just one channel, and a simple lowerer would desugar to an advanced adapter "consuming" just one channel. |
@RossTate Ah hah, I see what you're getting at. I was thinking about subtyping when one component calls another (where there is no problem since each component states its own type which determines its own private canonical ABI). But, separately, it's a good question whether we want to have a single core module compiled against the canonical ABI of a function type |
In order to handle reordering of cases, you'd need As for simple adapters, another advantage of them is that, rather than importing/exporting various name-mangled "intrinsics", you can specify the relevant functions as part of the adapters themselves. Same goes for tables and memories. |
Adding support for subtypes in the canonical ABI seems like a lot of additional complexity. It isn't clear to me that the benefit we get from adding that support is worth this additional complexity. |
Hmm, yes, there seems to be a direct tension between the flexible subtyping we want to allow at the inter-component level (where it's basically "free"), and allowing subtyping at the canonical ABI level where it adds real cost. Observing that syscall ABIs tend not to support flexible subtyping either (when changing a signature, they either are very careful to only extend in binary-compatible ways or else they define a new |
Sounds good. I was going to suggest that you could use variable-sized encodings of the case number to at least let you add arbitrary cases, but then I realized that could introduce a data dependency between record fields (i.e. the offset of one field depends on the contents of another field) that would be problematic for some of the extensions you've said you're hoping to have (such as not requiring fields to be accessed in strictly left-to-right order). So we just have to take incompatibility between subtyping and standardized encoding as a (reasonable) loss and make sure tooling takes that into account (e.g. doesn't represent variant types as simply name->type dictionaries). |
I just realized that the lowering of lists for the canonical ABI seems to assume that the length of the list is always available up front, whereas the |
That's what the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: in the explainer, one of the explained benefits of interface types is coerciveness. I am not entirely sure what this means exactly, but my understanding is that basically you can publish an adaptor module, let some folks use it, and then publish a new version of adapter module, with different, but compatible signatures. And, due to adapter magic, the clients of the old module will be able to use the new module as is.
Am I correct that canonical ABI doesn’t give us this evolvability property? It seems that we don’t encode actual intertype signatures into wasm, so, at runtime, we can-not adapt compatible signatures?
|
||
def variant_discriminant(cases): | ||
match cases.len() { | ||
0 => unreachable, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The explainer currently doesn’t forbid uninhabited enums, so either it or this assumption is wrong. A semi-reasonable type where we might hit this case is Result<u8, !>
.
offset += 4 | ||
} | ||
i64 => { | ||
write_8_bytes(dst, ptr, discrim) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, here and elsewhere, we are handling i64 discrimints, but we always lower discriminats as wasm_i32, so perhaps this should just be unreachable?
for field in $fields | ||
s = align_to(s, align(field)) | ||
s += size(direction, field) | ||
align_to(s, align(record $fields)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This means that empty (zst) records get size/alignment of 1. Seems like it makes sense to explicitly call out ZST behaviors (not sure if a filed with zero fileds is a thing though).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh the intention is that zst types have zero size and alignment of 1. I should probably actually define the align_to
function here, but it would make it clear that zero is aligned to all alignments, so the size here would remain 0.
|
||
variant<$cases> => { | ||
match val { | ||
$case(payload) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Val
seems unbound? Either match $case is a typo and shouldn’t be here, or I am confused as to what this supposed to mean.
gen.requeue(val) # put `val` back in the generator to get yielded first in `lower` | ||
[val_ptr, val_len] = lower(direction, dst, val) | ||
write_4_bytes(dst, ptr, val_ptr) | ||
write_4_bytes(dst, ptr + 4, val_len) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is val_len here the length of the string in USVs, or the length of the string in bytes?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the length of the string in bytes, and I suspect that some better documentation would be good to make that more clear!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To be clear: after adopting a second canonical string encoding, this will need to be changed, with the canonical ABI specifying a realloc
-based strategy (as described in the first few paras of this comment).
module. This is intended to encapsulate, for example, a WASI file descriptor. | ||
This can be used, though, for any object exported from a wasm module or any | ||
object a wasm module imports from a host. Handles are intended to be | ||
un-forgeable capabilties where once a handle is given to a module it's seen as |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
un-forgeable capabilties where once a handle is given to a module it's seen as | |
un-forgeable capabilities where once a handle is given to a module it's seen as |
|
||
(i32, i32) | | ||
(i32, f32) | | ||
(f31, i32) => i32, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(f31, i32) => i32, | |
(f32, i32) => i32, |
|
||
The `lift` function here takes four parameters: | ||
|
||
* `direction` - one of `"export"` or `"import"` which indicates for which |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As an opportunity for simplification for readers here, it seems that using "result" or "param"
in place of "export" or "import"
here and adapting the pseudo code would avoid the whole paraphrase, and would be a bit more straightforward. In general, what do you think of using "result" and "param" everywhere as possible values for the direction
parameter? (It might look a bit weird in the *_from_memory
functions, though, but wouldn't be too terrible either)
* Indices to refer to resources are handled in a LIFO fashion. Indexes are | ||
allocated starting from zero and increasing afterwards as more space is | ||
required. When an index is removed (because an instance said it no longer | ||
needs a resource, then that index is queued up as the next to be allocated). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
needs a resource, then that index is queued up as the next to be allocated). | |
needs a resource), then that index is queued up as the next to be allocated. |
Sorry, I borked the master-to-main rename and deleted the branch this PR which I guess closes the PR. We'll create a new PR and link to it from here shortly. |
I think you can just re-open an re-target to main? |
I must've pressed delete too hard, because I couldn't find a way to resurrect the branch. In the meantime, Alex just opened #140. Sorry again for the hassle! |
This PR proposes a "canonical ABI" for interface types. This feature was
alluded to in Luke's recent presentation to the CG, and this PR
intends to flesh it out more and get a more formal specification of what
it might be.
The idea of a canonical ABI is that the adapter language for interface
types is one of the most complicated parts of the proposal, but with a
canonical ABI we might be able to separate out the adapter language to a
future proposal so we can get the goodness of interface types on a
smaller time-scale.
The ABI proposed here is not intended to be a "one size fits all" ABI.
It does not express the full power of interface types as-proposed today
(intentionally). It's hoped, though, that the adapter language as
proposed in this repository right now can be seen as largely just an
optimization over what the canonical ABI specifies. This way languages
and tooling can work with the canonical ABI by default, and if necessary
for performance a custom ABI can be used with custom adapters in the future.
One way to think about this canonical ABI is that it's a generalization
of the current
list.lift_canon
into encompassing entire functionsignatures in addition to lists.
This PR describes the canonical ABI with a large amount of Python-like
pseudo-code which describes the
lift
andlower
operations for types,culminating in a
fuse
function which shows precisely howlifting/lowering happens when modules call each other.