Add memory64 and table64 support to the Canonical ABI#624
Add memory64 and table64 support to the Canonical ABI#624adambratschikaye wants to merge 12 commits intoWebAssembly:mainfrom
Conversation
Parameterize the Canonical ABI to handle 32-bit or 64-bit memory addresses and table indices. This is done by adding two new fields to `LiftOptions` to indicate if the `memory64`/`table64` feature is being used in a core module.
alexcrichton
left a comment
There was a problem hiding this comment.
Thanks so much for helping to pick this up! We chatted a bit on Zulip but for posterity I'll mention here about table-related index types how most of them want to remain 32-bits. I'll take a closer look once that's been passed over here, and in the meantime I skimmed over things and noted a few things here and there. I'm sure though that @lukewagner will have thoughts on this too!
design/mvp/Explainer.md
Outdated
| (param $originalSize i32) | ||
| (func (param $originalPtr $addr) | ||
| (param $originalSize $addr) | ||
| (param $alignment i32) |
There was a problem hiding this comment.
For this I might recommend making alignment have type $addr as well for consistency, and I think that matches the signature in Rust/C/etc as well.
design/mvp/Explainer.md
Outdated
| | -------------------------- | ------------------------ | | ||
| | Approximate WIT signature | `func<T>(t: T) -> T.rep` | | ||
| | Canonical ABI signature | `[t:i32] -> [i32]` | | ||
| | Canonical ABI signature | `[t:$idx] -> [i32]` | |
There was a problem hiding this comment.
This'll be a bit subtle, but this'll actually want to be a mapping of i32 as an argument (I talked with you a bit about this already, but the input here is a host-side index so always 32-bit), but the output here should be something variable. Resource "rep"s are generally pointers in linear memory so 64-bit components are going to want 64-bit storage values. In the component model resources are defined with (rep i32) and validation currently requires that i32 is the only one listed there, but this PR will relax that meaning that the resource type itself stores the lowered type which'll get plumbed here.
There was a problem hiding this comment.
Thanks, I've now changed it so that the types which are expected to be pointers to memory are allowed to be either i32 or i64 without forcing them to match the memory type (e.g. the return value here, or in context.{get,set}, or thread.new-indirect). This means we don't need to require the memory canonopt anywhere where it didn't previously exist.
alexcrichton
left a comment
There was a problem hiding this comment.
Looks good to me! I think this'll need minor adjustments over time but overall looks good 👍
| if opts.memory is None: | ||
| return 'i32' |
There was a problem hiding this comment.
Could this have an assert? This in theory shouldn't ever be called dynamically if memory is None
design/mvp/CanonicalABI.md
Outdated
| * `memory` - this is a subtype of `(memory 1)`. In the rest of the explainer, | ||
| `PTR` will refer to either `i32` or `i64` core Wasm types as determined by the | ||
| type of this `memory`. |
There was a problem hiding this comment.
This'll technically want to say it's a subtype of memory 1 or memory i64 1 since those are separate types
design/mvp/CanonicalABI.md
Outdated
| feature (the "stackful" ABI), this restriction is lifted. | ||
| * 🔀 `callback` - the function has type `(func (param i32 i32 i32) (result i32))` | ||
| and cannot be present without `async` and is only allowed with | ||
| * 🔀 `callback` - the function has type `(func (param i32 i32 PTR) (result i32))` |
There was a problem hiding this comment.
I think this one may remain a three i32s since they're all async-related codes/events/etc.
lukewagner
left a comment
There was a problem hiding this comment.
This looks great overall; thanks so much for all the work and adding tests too! There's only one relatively minor, but non-nit, suggestion below that I'm happy to discuss and then re-review based on what we decide.
| # A tuple consisting of the memory contents and the pointer type ('i32' or 'i64') | ||
| memory: Optional[tuple[bytearray, str]] = None |
There was a problem hiding this comment.
To avoid all the cx.opts.memory[0]s below (where it's a bit confusing to know what [0] means in isolation), could you perhaps have:
class MemInst:
bytes: bytearray
addrtype: Literal['i32', 'i64']
def ptr_type(self):
return self.addrtype
def ptr_size(self):
...and in the CanonicalABI.md desciption say that MemInst corresponds to https://webassembly.github.io/spec/core/exec/runtime.html#syntax-meminst?
| def store_string_copy(cx, src, src_code_units, dst_code_unit_size, dst_alignment, dst_encoding): | ||
| dst_byte_length = dst_code_unit_size * src_code_units | ||
| trap_if(dst_byte_length > MAX_STRING_BYTE_LENGTH) | ||
| trap_if(dst_byte_length > max_string_byte_length(cx.opts)) |
There was a problem hiding this comment.
My impression is that, to avoid interfaces that can only be implemented by 64-bit components we should institute a maximum length on strings and lists (dynamically guarded by trap_if() during lifting and assert()ed for incoming values during lowering) that is conservatively defined so that the worst-case byte-length always fits in an i32 and thus we'd never hit this trap_if(). Thus, if you want an interface that passes really-big values, you'd use a stream (or some custom scheme using resource types and methods) to chunk it up, which should often have performance benefits and would allow 32-bit components to have a fighting chance to implement.
There was a problem hiding this comment.
For both lists and strings: Should we also trap when loading if the maximum lengths are exceeded? Just so the trap occurs closer to the source.
There was a problem hiding this comment.
Yes, I think that's right; that way lowering can just assert() that the computed byte-length fits in an i32. This also means thinking carefully about what the lifting-side limit is, taking into account the worst-case for what can happen during lowering (e.g., lifting side has 32-bit pointers, lowering side has 64-bit pointers). I think we can also be somewhat conservative here, since limits can be relaxed over time (as long as they maintain the invariant that computed byte length fits in an i32).
| trap_if(ptr != align_to(ptr, alignment(elem_type))) | ||
| trap_if(ptr + byte_length > len(cx.opts.memory)) | ||
| byte_length = len(v) * elem_size(elem_type, cx.opts) | ||
| trap_if(byte_length >= (1 << (ptr_size(cx.opts) * 8))) |
There was a problem hiding this comment.
Similar comment as with strings applies here too
| def utf16_tag(opts): | ||
| return 1 << (ptr_size(opts) * 8 - 1) |
There was a problem hiding this comment.
If we agree on my comment below on store_string_copy(), then I think the tagged code units might stay as-is as an i32 value.
| OwnType(rt), | ||
| OwnType(rt) | ||
| ]) | ||
| for _ in [0]: |
There was a problem hiding this comment.
Could this for _ in [0]: line be removed and the following lines un-indented?
There was a problem hiding this comment.
Sorry I'd actually meant to fix this before and forgot to push the changes.
| The thread-local storage array's length is currently fixed to contain exactly 2 | ||
| `i32`s or `i64`s with the goal of allowing this array to be stored inline in | ||
| whatever existing runtime data structure is already efficiently reachable from | ||
| ambient compiled wasm code. Because module instantiation is declarative in the |
There was a problem hiding this comment.
Pairing with my comment in Explainer.md, I think the way we should semantically define this (which aligns with what the Python code in this PR implements) is that thread-local storage is 2 i64 values (always), and if you context.get i32, it ignores the high 32 bits and if you context.set i32, it clears the high 32-bits (incidentally, like x64 does for 32-bit instructions) which is observable if you mix them.
| When [wasm-gc] is integrated into the Canonical ABI, `context.{get,set}` will be | ||
| relaxed so that these integral context values can serve as indices into | ||
| guest-managed tables of typed GC references. |
There was a problem hiding this comment.
Can you remove "context.{get,set} will be relaxed so that" (as in: there isn't a plan to change context.{get,set} for wasm-gc)?
| ``` | ||
| where `$f` is the index of a future (not a pointer to one) while while | ||
| `$out-ptr` is a pointer to a linear memory location that will receive an `i32` | ||
| `$out-ptr` is a pointer to a linear memory location that will receive an `i32` |
There was a problem hiding this comment.
Could you revert this and the next hunk?
| of the explainer, `PTR` will refer to either `i32` or `i64` core Wasm types | ||
| as determined by the type of this `memory`. |
There was a problem hiding this comment.
Instead of introducing this PTR convention which, iiuc, is only used in the next bullet and then once near stream_event, could do something sensible for the next bullet and then expand the use of PTR near stream_event to say something along the lines of "... the addrtype of the memory immediate..."?
| @@ -775,8 +785,8 @@ class BufferGuestImpl(Buffer): | |||
| def __init__(self, t, cx, ptr, length): | |||
| trap_if(length > Buffer.MAX_LENGTH) | |||
There was a problem hiding this comment.
This PR leaves Buffer.MAX_LENGTH at 228-1, which I think is a reasonable choice and can been backwards-compatibly relaxed later if need be, but it might be good to call out here, and in the descriptions for canon_stream_{read,write} that the buffer length limit does not vary based on pointer size.
Co-authored-by: Luke Wagner <mail@lukewagner.name>
Co-authored-by: Luke Wagner <mail@lukewagner.name>
Co-authored-by: Luke Wagner <mail@lukewagner.name>
Co-authored-by: Luke Wagner <mail@lukewagner.name>
Co-authored-by: Luke Wagner <mail@lukewagner.name>
Parameterize the Canonical ABI to handle 32-bit or 64-bit memory addresses and table indices. This is done by adding two new fields to
LiftOptionsto indicate if thememory64/table64feature is being used in a core module.