Open johanbrandhorst opened 1 year ago
What is the plan for when 64-bit will be finally supported for the browser? Which additional port(s) will be added? What about when WASI starts supporting 64-bit too?
Thanks for the proposal. SGTM overall. A few questions:
on the server side, all existing hosts use a 32 bit architecture, and it has become clear that it is here to stay.
I guess this is probably true. Some supporting links would be great. It is unlikely for the server side to have a plan to go to 64-bit?
uses 32 bit pointers and integers
Integers are interesting. Wasm support 64-bit integer operations. I assume we'll continue to use those for 64-bit integer operations (Go's int64
and uint64
), which should be more efficient than decomposing to 32-bit operations? Will int
and uint
become 32-bit? Do int64
and uint64
have 4-byte alignment (like we have for other 32-bit architectures)?
Currently, how much do users need to directly interact with this? I guess in many cases this is hidden in the internals of low-level packages (like runtime and syscall), and users will just use high-level APIs? Maybe this matters mostly for people using wasmimport
'd functions that are not included in packages like os and syscall?
How much work user would need to do to migrate from the current 64-bit wasm to wasm32? Is there a plan to help user migrate, maybe with some automation?
For the implementation side, I guess we can share a good amount of code for both 32-bit and 64-bit Wasm, at least for the toolchain? Do you have an estimate of the amount of the code that is needed?
Thanks.
Hi @cherrymui
Since WASI is one of the primary motivators behind this additional port, I'll use it as an example. Referencing https://github.com/WebAssembly/WASI/blob/main/legacy/preview1/docs.md, you'll see they (thankfully) are explicit in their integer sizes.
The question of the width of the Go int
type is absolutely relevant, but I think it ends up being mostly relevant in how it interplays with uintptr
and pointer sizes. Since we're talking about going from the large version today to a smaller version, it's unlikely to cause any issues.
The user interacts for this are pretty minimal so we're not worried about it having much an an impact. I'll say that as far as migrations are concerned, people who use Go's wasm support today are having to migrate existing code today because of the use of 64bit pointers today. So when wasm32 is introduced, they'll be able to effectively undo those migrations and go back to doing it the same way they do with all the other wasm compiled code.
Coding wise, our hope is that because of the structure of the Go compiler abstracting most of this, it should be a fairly small change. But that change is likely to be diffuse amongst a bunch of places.
Next week during Gophercon I'm hoping to do pick up where @dgryski left off here https://github.com/dgryski/go-wasi/tree/dgryski/wasm32/ and see how far it can be pushed forward quickly. That should give us a better idea of how much more is required.
@eliben I wish I had a better of how long before we see wasm64 uptick in browsers. To be honest, we sort of expected to see it by now.
I think that beyond browser's adopting, one of the bigger roadblocks to adoption is the wasm ABI interactions with javascript. Let's assume for a moment that the javascript code wants to pass a string to a function implemented in webassembly. The wasm ABI requires today that javascript know the exact byte layout, in memory, that the webassembly code will see the string in memory. In fact it requires it so much, that the caller has to write the string into the linear memory that is shared with webassembly, and then pass the offset of the value in the linear memory byte array, which the webassembly sees as a pointer. 😓
Put another way, wasm doesn't provide any ABI abstractions between JS and Webassembly code, which exacerbates issues like the number of bytes a pointer takes up because JS is constantly manually constructing these values by hand.
@evanphx thanks!
Referencing https://github.com/WebAssembly/WASI/blob/main/legacy/preview1/docs.md
Thanks for the link. From the API it is clear that pointer is 32-bit. For integers, it seems the system APIs use explicitly sized types like u32
. I think for the Go side we may also want to use explicitly sized integer types like uint32
when mapping the system APIs. E.g. for the example above, iovec
probably should be defined as
type iovec struct {
buf *byte // pointers are 32 bits
len uint32 // map to wasm u32
}
instead of using int
or uint
for the second field.
The question of the width of the Go int type is absolutely relevant, but I think it ends up being mostly relevant in how it interplays with uintptr and pointer sizes.
Yes, for the completeness of the port, we need to decide the size of Go's int
and uint
types. Currently on all platforms that the gc toolchain supports, int
and uintptr
have the same width (although the spec doesn't require it and gccgo does support platforms where they differ). So it probably makes sense to define int
and uint
32-bit. (The proposal probably needs to mention it.)
We also need to determine the alignment of 64-bit types like int64
, uint64
(and perhaps float64
). Currently on all other 32-bit platforms they are 4-byte aligned. It looks like the WASI API https://github.com/WebAssembly/WASI/blob/main/legacy/preview1/docs.md requires 64-bit values 8-byte aligned (e.g. filesize: u64
has "Alignment: 8"). So should we choose to align them to 8-byte in Go? This would probably need some work, to get an alignment beyond pointer size, but probably makes sense and worth it as we start a new port. Another option is that they are usually 4-byte aligned in Go code, but we explicitly align them when interacting with the system API.
Thanks for sharing this! This includes some interesting changes that are more internal to the compiler and irrelevant to the system API, such as https://github.com/golang/go/commit/cf5a3faafebd11cd36015d6892ff7f53335c6cd7 . This switches to use 32-bit operations for 32-bit values. Currently in the current Wasm port, the "registers" (for the Go compiler, see the original design doc) are 64-bit. Do you plan to switch to 32-bit "registers"? A mixture of 32-bit and 64-bit (which seems more complex to me)? Is there a clear benefit for changing the internals to 32-bit? I assume 64-bit operations are just as efficient when the Wasm execution engine is running on a 64-bit machine (probably most servers).
Thanks.
What is the plan for when 64-bit will be finally supported for the browser? Which additional port(s) will be added? What about when WASI starts supporting 64-bit too?
When the browser supports 64-bit addresses, I think the existing js/wasm port should be well positioned to take advantage of it, though it is not yet clear to me what the mechanism would be. There would be no need for an additional port for the js
OS, in my opinion.
If the WASI ecosystem starts moving towards 64 bit (which there is little interest in, as far as I can tell, though I don't have any hard sources on that) the existing wasip1/wasm port would be well suited to target that environment. This is why the potential deprecation of this port would have to be considered in a separate proposal, as I think we'd need to very clearly prove that 64 bit WASI hosts are not going to be relevant to users to make such a change.
I guess this is probably true. Some supporting links would be great. It is unlikely for the server side to have a plan to go to 64-bit?
I would love to provide some supporting links but unfortunately I've only heard this from various people working closer to the runtimes, so I don't know that I have any concrete evidence for it. Wasmtime does support 64 bit addresses behind a flag. Regardless, all runtimes use 32 bit by default today, so there is a gap in the user experience when using Go. As mentioned before, we'd keep the 64 bit arch around and only seriously consider deprecating it if we knew for sure 64 bit runtimes were not going to become a standard in the ecosystem.
Integers are interesting. Wasm support 64-bit integer operations. I assume we'll continue to use those for 64-bit integer operations (Go's int64 and uint64), which should be more efficient than decomposing to 32-bit operations? Will int and uint become 32-bit? Do int64 and uint64 have 4-byte alignment (like we have for other 32-bit architectures)?
int
, uint
and uintptr
would become 32 bit. I've updated the proposal. I'm not sure yet about the 64 bit integer type alignments, as Evan says we're still experimenting with the implementation.
Hi @cherrymui
I think for the Go side we may also want to use explicitly sized integer types like uint32 when mapping the system APIs. Yup! We're doing that today, we only use explicitly sized int types when interfacing with WASI.
Currently on all platforms that the gc toolchain supports, int and uintptr have the same width ... A good call out, we'll look to keep that constant by making
int
32bits wide.So should we choose to align them to 8-byte in Go? Another great call out, we had only been scratching around the edges of this. The idea of doing the wasm32 port is to bring Go's ABI into sync with other wasm languages, so we probably should 8-byte align them. We'll do a little research to double check what clang and rust are doing here as well.
Do you plan to switch to 32-bit "registers"? The registers we use are wasm locals, which do hold a type. But nicely, each local can have it's own different type, just like locals in a normal programming language. This means we can easily experiment with using mixed locals. But I expect, all and all, that leaving the locals as uint64 will be the right thing to do, so that 64bit ops are nice and obvious, and 32bit ops overlay correctly.
One thing we'll have to double check (and I'm pretty sure about this) is that 32bit integer ops will wrap around correctly at the 32bit boundary even when a 64bit local is involved.
Currently on all platforms that the gc toolchain supports,
int
anduintptr
have the same width (although the spec doesn't require it and gccgo does support platforms where they differ). So it probably makes sense to defineint
anduint
32-bit. (The proposal probably needs to mention it.)
FYI, in TinyGo's WASI support, both int
and uintptr
are 32 bits.
Thanks. SGTM overall. We could determine the compiler's implementation detail later (e.g. on a CL, as it is internal to the compiler).
One more question: it sounds to me the issue is mostly about interacting with the system API. Besides that, the current 64-bit wasip1/wasm port works reasonably well? Instead of a whole new port, would it be possible to solve it in a API level? Like, add a syscall/wasm32.Ptr[T]
that maps to 32-bit pointers on the Wasm side, with a helper function to do the conversion (say wasm32.P
converts a *T
to Ptr[T]
)? And make wasmimport
directive accept wasm32.Ptr
. We could also make the GC do the right thing for tracking wasm32.Ptr
. This way, one can write wasm32.P(unsafe.SliceData(buf))
, which is slightly more code than without it, but maybe not too bad? Is this too much boilerplate?
I guess passing (pointer to) struct is still tricky. But I think that is a tricky problem anyway. On other platforms, when interacting with the system API or C, we still need to ensure the data are laid out the same on both side. In general we don't want to assume a Go struct has identical layout as a C struct. We can do the same for here. And it is usually only needed in a few low-level packages. With the current wasm port, where int64
and uint64
have the alignment matching WASI API, with the addition of the Ptr
type, it probably shouldn't be hard to write Go data structures that match the WASI layout.
What is the down side of handling it in an API level?
Thanks.
One more question: it sounds to me the issue is mostly about interacting with the system API. Besides that, the current 64-bit wasip1/wasm port works reasonably well? Instead of a whole new port, would it be possible to solve it in a API level? Like, add a
syscall/wasm32.Ptr[T]
that maps to 32-bit pointers on the Wasm side, with a helper function to do the conversion (saywasm32.P
converts a*T
toPtr[T]
)? And makewasmimport
directive acceptwasm32.Ptr
. We could also make the GC do the right thing for trackingwasm32.Ptr
. This way, one can writewasm32.P(unsafe.SliceData(buf))
, which is slightly more code than without it, but maybe not too bad? Is this too much boilerplate?
This is what we've done in the Fastly Compute Go SDK, except to uint32
as that's what's currently required: https://github.com/fastly/compute-sdk-go/blob/c3a63de93dcb2cf090f431846d601c1302886c3e/internal/abi/prim/prim.go#L28-L34
Previously we were passing pointers to structs (in TinyGo), but this was a largely mechanical change and the use of generics does offer us the type safety we would have otherwise lost going straight to uint32
.
As for whether a port is necessary or not, it's unfortunate that every pointer wastes 4 bytes, especially as these serverless compute platforms are fairly limited in terms of memory.
Thanks. So it sounds like a wrapper Ptr
type doesn't sound too bad?
Memory savings could be a benefit for a full 32-bit port. The original proposal doesn't seem to discuss it. Maybe that could be added, if the authors think that is important? Do you have an estimate of how much memory it could save?
Thanks.
Regarding the general theme I'm sensing in this thread that it makes sense to always target 64-bit memories when they are available:
Note that 64-bit Wasm memories can't benefit from virtual memory guard pages to elide bounds checks, they need to be explicitly bounds checked which is slower (about ~1.5x slower on Wasmtime and SpiderMonkey, for example, depending on the benchmark; I'd expect similar or worse in other engines).
Additionally, when pointers are 64 bits, sizes of various structs get larger, and you ultimately get worse locality. I don't have a link, but there have been benchmarks that are actually faster in wasm32 than x86_64 because of these effects.
Therefore, unless your program actually needs the additional heap capacity, it generally makes sense to target 32-bit memories even when 64-bit memories are available.
I'm definitely a Go outsider, but what I would expect Go to aim for, from the perspective of someone pretty involved in Wasm, is to have an endstate like this:
GOARCH=wasm32
targets 32-bit Wasm memories. Pointers are 32-bit. You can still use i64.add
etc wasm instructions for 64-bit integer arithmetic. It will indeed be more performant than decomposing into 32-bit operations. This is the recommended default for targeting Wasm.
GOARCH=wasm64
targets 64-bit Wasm memories. Pointers are 64-bit. This is recommended only for applications that need the additional heap capacity.
The strange in-between-32-and-64-bit GOARCH=wasm
target is deprecated and phased out.
@cherrymui
Like, add a syscall/wasm32.Ptr[T] that maps to 32-bit pointers on the Wasm side
A fascinating thought. We hadn't gone down the "special kind of pointer" route and it absolutely merits consideration. To get an idea about it, I think we'll need to lay out all the places that we'd expect the ABI to leak out to JS and see if wasm32.Ptr could handle all of them.
In this case, the compiler can track the use of the slice pointer, and there is no risk that the GC will collect the objects before calling the imported function. There is also stronger type safety since there is no need to bypass the compiler to perform unsafe type conversions.
Allowing a go:wasmimport
function to accept pointers or uintptr
would be an ergonomic improvement, in addition to letting the same code compile to either wasm
, wasm32
, or wasm64
.
This may or may not be relevant, but hypothetical WASI Preview 2 (wasip2
) support requires the host to be able to allocate memory in the guest for passing higher-level types as arguments or return values. I’m guessing the Go runtime should probably handle the allocation to interact correctly with the GC.
The strange in-between-32-and-64-bit GOARCH=wasm target is deprecated and phased out.
The current wasm
GOARCH isn't in-between, it's all 64bit.
The current
wasm
GOARCH isn't in-between, it's all 64bit.
Unless it is targeting 64-bit Wasm memories, I don't see how that is possible. All the load/store instructions that interact with a 32-bit memory take 32-bit addresses, so even if pointers are stored as i64
s they need to be truncated to actually access the memory.
@fitzgen Ah, I see what you're saying. Fair enough, when we pull a pointer value out of the heap and then when we go to deref it, yes, that value is truncated to 32bits.
@fitzgen do you have any links to those benchmarks?
The performance benefit seems worth noting in the proposal.
@fitzgen do you have any links to those benchmarks?
Unfortunately I don't, It was from a long while ago. Might have been on https://arewefastyet.com back in the day. I believe the benchmark was creating and manipulating a large binary tree. Assuming it had a representation like struct Node { uint32_t val; Node* left; Node* right; }
you'd get a 12-byte struct on wasm32 and a 24-byte struct on x86-64, so it isn't really that surprising that the wasm32 could be faster.
One big place that locality and density shows up is on the stack or really any linear data structure. For languages like Rust that heavily manage stack and linear structures, the cache line hits probably have a fairly significant effect.
Best I can find is https://twitter.com/kripken/status/1262092956070109185:
the lua-binarytrees benchmark is faster in wasm than native x64, since (1) 32-bit ptrs, (2) and wasm malloc just reserves room in an array (no OS page ops - unfair to native!)
wasm->wasm2c->native is even faster! 40% faster than the normal native, 30% less RAM... 🧐
(Hi, another Wasm-focused person here -- I work on Wasmtime/Cranelift). Re: benchmarks, a good approximation for the cost that wasm64 carries is the cost of explicit bounds-checks over a virtual memory-based sandbox (reserve 4GB and use 32-bit offsets only). @fitzgen has worked on this in Wasmtime a lot and in this issue noted the factor is 1.52x-1.56x for a complex program (a JS runtime inside the sandbox). That is an optimistic lower bound on the impact that wasm64 would have: wasm64 additionally inflates pointer sizes with the implied effects on cache efficiency.
Thanks for everyone for your comments on performance implications of a 32 bit port. I've made a minor update to the proposal to mention that we expect there to be performance benefits to a 32 bit port, though we might have to do some measurements to quantify that. I just want to explicitly mention that a wasm64 port is out of scope of this proposal, but could be something we consider in the future.
Thanks for the discussion. I'm not sure I really understand the bounds check issue? Is that about Go's current wasm
port, or 64-bit wasm in general? As @fitzgen mentioned above, in the current Go port, when accessing memory, the address is truncated to 32-bit before dereferencing.
I agree that the current wasm
port having 64-bit pointers on the Go side but 32-bit on the Wasm side is not ideal. If we add a wasm32
port, maybe we want to change the wasm
port to be full 64-bit (which will be a separate discussion).
Given the wasip1/wasm
port is subject to relaxed compatibility rules, what would the ramifications of changing the wasm
arch to 32-bit, and reserve wasm64
for the future?
@cherrymui it's a general Wasm issue: 64-bit memories are slow in Wasm, and so it's best not to use them unless really needed (and it's not clear how to improve this, so it may be an issue for a while). The reason is that the Wasm runtime has to compile a bounds-check into the native code generated from the Wasm bytecode -- it doesn't matter what code the Wasm toolchain generates. Wasm32 memories are faster because there are better techniques -- we can reserve an area of virtual memory up to 4GB and catch a SIGSEGV to trap out-of-bounds accesses instead.
I am a bit confused about what the state of the current GOARCH=wasm is, but is it possible to make that one be the full-32-bit version, and then have GOARCH=wasm64 as the full-64-bit version? That would be consistent with our other architectures, like arm/arm64 and mips/mips64.
This proposal has been added to the active column of the proposals project and will now be reviewed at the weekly proposal review meetings. — rsc for the proposal review group
It would be possible to change GOARCH=wasm to 32bit, but it'd be a very visible breaking change for users (both of GOOS=js and GOOS=wasip1). For js
users it would mostly be visible as a change of the size of integers, but for wasip1
users it would also change the way their applications interact with any custom host functions. I would prefer to introduce wasm32
and avoid breaking users. It's unfortunate that it would break with convention. I imagine that wasm32
will be the primary target for the foreseeable future for applications that want to run on WASI, but I'm not sure that warrants breaking all existing Go Wasm users and switching the architecture.
@johanbrandhorst I am still a bit confused. It sounds like the current GOARCH=wasm is neither 32-bit nor 64-bit? Should we introduce both wasm32 and wasm64 and delete wasm entirely?
@rsc the current GOARCH=wasm port is pretty much a 64-bit port on the Go side. Go's int
and pointer sizes are all 64-bit, and have 64-bit alignments. But as most Wasm runtimes has 32-bit address space, we limit our address space to 4GB, and use 32-bit Wasm "instructions" to operate on addresses.
I think the proposed wasm32 port is that Go's int
and pointer are 32-bit, and for addresses we also use 32-bit Wasm "instructions".
So are we talking about needing three wasm ports (pure-32, pure-64, and the current weird hybrid)?
Personally I think eventually we could have two, a pure 32-bit and a pure 64-bit.
The question is which migration path is easier. The retargeting will be a breaking change either way, but if we can have a relatively easy migration path, that may be okay.
- One possibility is retargeting the current wasm port to 32-bit, maybe with a GOEXPERIMENT and some migration plan, and later introducing a 64-bit port when there is a need and 64-bit Wasm runtime are more widely available.
Complexity of implementation aside, this seems reasonable as it reserves wasm64
for the future, and allows existing GOARCH=wasm
users to migrate to 32-bit ints/pointers.
Would an implementation of this essentially be GOEXPERIMENT=wasm32
that would flip GOARCH=wasm
to use 32-bit ints and pointers for N major version(s) of Go with the goal of making the experiment the default at some point?
I'm leaning more towards the second suggested option, introducing wasm32 now and maybe retargeting wasm to pure 64 bit at a later stage. Using GOEXPERIMENT to switch the arch will be difficult to implement and confusing to use, IMO.
The question is which migration path is easier. The retargeting will be a breaking change either way, but if we can have a relatively easy migration path, that may be okay.
It seems to me that the easiest migration path would be to introduce a wasm64
that is entirely 64-bit, so that GOARCH=wasm
clearly means “hybrid 32/64" and not “depends on what Go release you're talking about”.
But then there is the question of build constraints. In a world where the WASM ports are wasm32
and wasm64
, what does the existing wasm
build tag mean? I could easily see it meaning wasm32 || wasm64
(so that wasm
files that don't care about the size of ints and pointers can continue to work unmodified), but then the constraint for files that want to target exactly GOARCH=wasm
would become wasm && !(wasm32 || wasm64)
, which is more than a little awkward.
But, then again, the constraint wasm || wasm32 || wasm64
is also more than a little awkward. It's not obvious to me which one is better. 😅
I don't think the meaning of build tags changes: they mean just that one specific GOARCH value. There is no single tag that matches more than one. For example we have arm and arm64 but there's no build tag for "either arm".
It sounds like we are entrenched enough that we can't redefined GOARCH=wasm, so if we are going to introduce a 32-bit-only version, GOARCH=wasm32 sounds like the right answer.
If we do GOARCH=wasm32, are there any remaining objections?
introducing wasm32 now and maybe retargeting wasm to pure 64 bit at a later stage
The current pattern seems to be that {arch} typically means 32-bit, and {arch}{bits} is for 64-bit. At least for arch in arm, mips, loong, riscv. Having 'wasm' become 64-bit and wasm32 for 32-bit seems somewhat counter to that.
From https://pkg.go.dev/cmd/go#hdr-Build_constraints:
For GOARCH=wasm, GOWASM=satconv and signext correspond to the wasm.satconv and wasm.signext feature build tags.
Have you considered whether it could work well to use the GOWASM
environment variable instead of GOEXPERIMENT
for this? For example, in Go 1.N GOWASM=hybrid64bit
could be the equivalent of what's being proposed as GOOS=wasip1 GOARCH=wasm
if it is important to preserve the current hybrid behavior, and GOOS=wasip1 GOARCH=wasm
could become fully 32-bit by default with a release note announcing this.
Notably that would come with gowasm.hybrid64bit
build constraint that can be used to constrain some code to be either fully 32-bit or the current hybrid.
About being entrenched, as some points of reference:
darwin/arm64
away from iOS to this new port. This was 11 major releases later.In both those cases, it seems that preference was given to making the GOOS and GOARCH values optimal for the future, paying the short-term transition costs.
The wasip1/wasm port was introduced in Go 1.21, one major release ago, and also marked as experimental. The "p1" refers to Preview 1 in the wasi_snapshot_preview1 spec, so it's expected at some point there may be a GOOS=wasip2 or GOOS=wasi added if that spec isn't the final. Do you have a sense of whether that next 32-bit Go WASI port would continue to use wasip2/wasm32
, or would it switch back to wasip2/wasm
?
It seems unfortunate if adding a new wasip1/wasm32
port and GOARCH value—with its downsides of changing the meaning of any existing files with a "_wasm32.go" suffix—is still the least bad way to proceed with implementing this optimization, but it seems fine if there's indeed no better way. Thanks.
Thank you for the precedent examples regarding changing of a GOOS/GOARCH meaning, that's illuminating. I still worry about changing the meaning of GOOS=wasm
so soon. We know very little about our users, and while we do indeed reserve the ability to make breaking changes, I think we should still do it very carefully. It would pain me to hear that users are avoiding the Go Wasm ports because we use the "we're experimental, there will be breaking changes" excuse. Requiring users to understand GOWASM
will further increase the feeling that Go Wasm isn't as easy to use or reliable as the official ports, not to mention the difficulty of educating users about it. The existing uses of this variable seem very niche (I admit I didn't even know about them).
Regarding wasip2
, as I see it we should probably initially only support wasip2/wasm32
. Ideally, we'd have had the 32bit port ready by the time we released wasip1, but we can't change that. Theoretically we could make wasip2/wasm
mean pure 32bit and then just rid ourselves of wasip1/wasm
after some time, though there are no plans to retire or change js/wasm
, so would it be okay to have wasip2/wasm
mean 32bit and js/wasm
mean 64 bit? The same "hybrid 64 bit" concerns do not apply in the browser AFAIK. Inconsistent or no, I'd prefer it to be wasip2/wasm32
and the eventual wasi/wasm32
so that users know what they're targeting.
As a first step that would mean introducing wasip1/wasm32
. I don't expect there to be many _wasm32.go
files in the wild that would be affected by the new port, do you?
@dmitshur, I hear you concerns about the 32 suffix, but it still seems like the best of a few bad options. It's not a huge deal that wasm is weird in yet another way.
Sounds good to me. It seems using GOARCH=wasm32 for GOOS=wasip1 and potential future WASI ports is prioritizing user experience in the future, then, which I think is the right call. Thanks for taking the points I raised into account.
Are there plans to continue supporting the existing hybrid arch past a certain point?
If not, then it seems reasonable to implement wasm32
, then at some point deprecate wasm
or transition the definition of wasm
to mean "32 bit".
This proposal is just adding GOARCH=wasm32. The existing GOARCH=wasm is not changed and continues to be supported for now. At some point we may choose to deprecate it, but that would be a separate proposal.
No change in consensus, so accepted. 🎉 This issue now tracks the work of implementing the proposal. — rsc for the proposal review group
The proposal is to create a new GOARCH value, wasm32, which looks like a 32-bit CPU to Go programs and uses 32-bit wasm external interfaces. The current GOARCH=wasm uses 32-bit wasm external interfaces too, but it looks like a 64-bit CPU to Go programs.
Perhaps some day there will be a GOARCH=wasm64 that looks like a 64-bit CPU and uses 64-bit wasm external interfaces, but it is not this day.
When looking at #64856 I realized that it could be more of an issue for wasm32. Currently in Go's wasm port, a "PC" is encoded as PC_F<<16 + PC_B
, where PC_F
is the function index, and PC_B
is the block index. "PC"s are generally stored as uintptr
s, which is 64-bit in the current wasm port. So we have more than enough bits for the function index. (One exception is the type descriptor's method table, which uses 32-bit relative offsets, so it only leaves 16 bits for the function index, i.e. at most 65536 functions. This is #64856, which CL https://go.dev/cl/552835 is addressing, relying the fact that the method table just needs to target function entires.)
If we do wasm32, uintptr
will be 32-bit, so we only have 16 bits for the function index, which limits us to at most 65536 functions. So we need to change the PC encoding scheme (or accept the limit, which doesn't sound nice, as it doesn't scale).
We also need to determine the alignment of 64-bit types like
int64
,uint64
(and perhapsfloat64
). Currently on all other 32-bit platforms they are 4-byte aligned. It looks like the WASI API https://github.com/WebAssembly/WASI/blob/main/legacy/preview1/docs.md requires 64-bit values 8-byte aligned (e.g.filesize: u64
has "Alignment: 8"). So should we choose to align them to 8-byte in Go? This would probably need some work, to get an alignment beyond pointer size, but probably makes sense and worth it as we start a new port. Another option is that they are usually 4-byte aligned in Go code, but we explicitly align them when interacting with the system API.
+1 for aligning 64-bit values on 8 bytes.
The current CL has workarounds for passing an 8-byte aligned pointer to a uint64
in a wasmimport
call: https://go-review.googlesource.com/c/go/+/560118
This is particularly relevant for WASI Preview 2, which uses a richer type system (like lists (slices), records (structs), etc.). Its canonical ABI specifies type alignment and struct layouts, which map identically to Go, assuming 8-byte alignment of 64-bit values. This allows the caller to pass pointers to Go structs without conversion boilerplate.
We’re starting with TinyGo, with the intention that this informs how Go can eventually support the Component Model and WASI Preview 2. Some examples (this works in our fork of TinyGo): https://github.com/ydnar/wasm-tools-go/tree/main/wasi
Edit:
Change https://go.dev/cl/578355 mentions this issue: cmd/compile: layout changes for wasm32, structs.HostLayout
Change https://go.dev/cl/581316 mentions this issue: cmd/compile: wasm32-specific structs.HostLayout changes
Since there haven't been many updates on this recently, I'll give a quick update: The latest version of the prototype for this work is still in the CL stack introduced by https://go-review.googlesource.com/c/go/+/570835/1. Unfortunately, due to conflicting time commitments, we haven't been able to get this CL to a point where it's ready to be merged. As it is, everything builds successfully and the resulting compiler can produce wasm32 binaries, some which run fine and some which crash catastrophically. We haven't been able to iron out the last few bugs, so for now this effort is somewhat stalled.
We think that the easiest way to get this almost-done CL stack over the line now would be to go back to tip and merge changes across gradually. Something like this:
wasm
arch logic with logic from wasm32
in the above CL stack. Since Wasm runtimes support both 32 bit and 64 bit binaries, it should be possible to gradually transform the existing wasm
arch from 64 bit to 32 bit, all the while making sure that all the tests pass as expected.wasm32
passing all the tests under the guise of the wasm
arch name.wasm32
to alive alongside wasm
instead of replacing it.Since this is sort of a from-scratch rewrite, we think other interested parties may be able to participate in this effort, independently or by joining with our effort. If you are interested in taking this on, please reach out in the #webassembly channel on Gophers Slack. It isn't too late to get wasm32 into Go 1.24, but time is running out.
Background
The
GOARCH=wasm
architecture was first introduced alongsideGOOS=js
in 2019. The choice was made to use a 64 bit architecture because of WebAssembly’s (Wasm) native support for 64 bit integer types and the existing Wasm proposal to introduce a 64 bit memory address space, as detailed in the original Go Wasm design doc. The assumption at the time was that the Wasm ecosystem was going to switch to a 64 bit address space over time, and that Go would benefit from having used a 64 bit architecture from the start.On the browser side, Firefox and Chrome both have experimental support for 64 bit memory, and it seems poised to become stable in the coming year. However, on the server side, all existing hosts use a 32 bit architecture, and it has become clear that it is here to stay.
In most uses of Wasm with Go, the architecture is transparent to the user, but with the introduction of
go:wasmimport
in #38248 and #59149, knowing the memory layout of input and output parameters becomes very important, as described in #59156. A short term solution of restricting the types of input and output parameters to scalar types andunsafe.Pointer
was adopted to make this clear to users, but this user experience is not a desirable long term solution.As an example, the fd_write import from
wasi_snapshot_preview1
accepts an *iovec of this type:Because
GOARCH=wasm
has 64 bit pointers we have currently defined this type as:which gives the correct alignment but has issues:
The conversion from
*byte
touint32
causes the compiler to lose track of the pointer, potentially causing the GC to reclaim the memory before the imported function is called. It requires usingruntime.KeepAlive
on inner pointers passed to imported function calls to ensure that they remain alive.It creates a poor and error prone user experience, as the conversion of the pointer type looks like this:
This issue is already affecting early adopters of the wasip1 port: Fastly SDK.
Performance
A 32 bit architecture would allow the compiled Go code to be more performant, due to the effects of locality, the larger size of certain structs and the difficulty of avoiding bounds checking. It would also use less memory.
Proposal
Create a new
GOARCH=wasm32
architecture which uses 32 bit pointers and integers. The architecture would only be supported in a newwasip1/wasm32
port.int
,uint
anduintptr
would all be 32 bit in length.The maintainers of the existing
wasip1/wasm
port (@johanbrandhorst , @Pryz, @evanphx) volunteer to become maintainers of this new port.Discussion
The introduction of
GOARCH=wasm32
would allow us to write safer code, because the pointer size matches what the host expects:In this case, the compiler can track the use of the slice pointer, and there is no risk that the GC will collect the objects before calling the imported function. There is also stronger type safety since there is no need to bypass the compiler to perform unsafe type conversions.
This provides a much improved user experience for users writing wrappers for host provided functions.
The existing
wasip1/wasm
port would remain and retain thego:wasmimport
type restrictions. It may eventually become deprecated and removed, in accordance with the Go porting policy. Such a change would be subject to a separate proposal.There are no plans for introducing a
js/wasm32
port.