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

cmd/compile: create GOARCH=wasm32 #63131

Open
johanbrandhorst opened this issue Sep 20, 2023 · 49 comments
Open

cmd/compile: create GOARCH=wasm32 #63131

johanbrandhorst opened this issue Sep 20, 2023 · 49 comments
Milestone

Comments

@johanbrandhorst
Copy link
Member

johanbrandhorst commented Sep 20, 2023

Background

The GOARCH=wasm architecture was first introduced alongside GOOS=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 and unsafe.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:

type iovec struct {
    buf *byte
    len uint32
}

Because GOARCH=wasm has 64 bit pointers we have currently defined this type as:

type iovec struct {
    buf uint32
    len uint32
}

which gives the correct alignment but has issues:

  • The conversion from *byte to uint32 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 using runtime.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:

    iov := &iovec{
        buf: uint32(uintptr(unsafe.Pointer(unsafe.SliceData(buf)))),
        len: uint32(len(buf)),
    }
    
    

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 new wasip1/wasm32 port. int, uint and uintptr 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:

type iovec struct {
    buf *byte // pointers are 32 bits
    len int   // int is 32 bits
}

iov := &iovec{
    buf: unsafe.SliceData(buf),
    len: len(buf),
}

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 the go: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.

@johanbrandhorst johanbrandhorst added Proposal arch-wasm WebAssembly issues labels Sep 20, 2023
@gopherbot gopherbot added this to the Proposal milestone Sep 20, 2023
@eliben
Copy link
Member

eliben commented Sep 21, 2023

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?

@cherrymui
Copy link
Member

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.

@evanphx
Copy link
Contributor

evanphx commented Sep 21, 2023

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.

@evanphx
Copy link
Contributor

evanphx commented Sep 21, 2023

@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.

@cherrymui
Copy link
Member

@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.

https://github.com/dgryski/go-wasi/tree/dgryski/wasm32/

Thanks for sharing this! This includes some interesting changes that are more internal to the compiler and irrelevant to the system API, such as cf5a3fa . 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.

@johanbrandhorst
Copy link
Member Author

johanbrandhorst commented Sep 22, 2023

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.

@evanphx
Copy link
Contributor

evanphx commented Sep 22, 2023

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.

@joeshaw
Copy link
Contributor

joeshaw commented Sep 22, 2023

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.)

FYI, in TinyGo's WASI support, both int and uintptr are 32 bits.

@cherrymui
Copy link
Member

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.

@joeshaw
Copy link
Contributor

joeshaw commented Sep 22, 2023

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?

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.

@cherrymui
Copy link
Member

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.

@fitzgen
Copy link

fitzgen commented Sep 22, 2023

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.

@fitzgen
Copy link

fitzgen commented Sep 22, 2023

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.

@evanphx
Copy link
Contributor

evanphx commented Sep 22, 2023

@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.

@ydnar
Copy link

ydnar commented Sep 22, 2023

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 uintptrwould 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.

@evanphx
Copy link
Contributor

evanphx commented Sep 22, 2023

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.

@fitzgen
Copy link

fitzgen commented Sep 22, 2023

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 i64s they need to be truncated to actually access the memory.

@evanphx
Copy link
Contributor

evanphx commented Sep 22, 2023

@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.

@ydnar
Copy link

ydnar commented Sep 22, 2023

@fitzgen do you have any links to those benchmarks?

The performance benefit seems worth noting in the proposal.

@fitzgen
Copy link

fitzgen commented Sep 22, 2023

@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.

@evanphx
Copy link
Contributor

evanphx commented Sep 22, 2023

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.

@fitzgen
Copy link

fitzgen commented Sep 22, 2023

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... 🧐

@cfallin
Copy link

cfallin commented Sep 22, 2023

(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.

@johanbrandhorst
Copy link
Member Author

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.

@cherrymui
Copy link
Member

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).

@ydnar
Copy link

ydnar commented Sep 25, 2023

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?

@cfallin
Copy link

cfallin commented Sep 25, 2023

@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.

@rsc
Copy link
Contributor

rsc commented Oct 24, 2023

So are we talking about needing three wasm ports (pure-32, pure-64, and the current weird hybrid)?

@cherrymui
Copy link
Member

Personally I think eventually we could have two, a pure 32-bit and a pure 64-bit.

  • 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.
  • Another possibility is adding GOARCH=wasm32 for pure 32-bit port, keeping the current GOARCH=wasm for compatibility, and later retargeting it to a 64-bit port, with some migration plan.

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.

@ydnar
Copy link

ydnar commented Oct 24, 2023

  • 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?

@johanbrandhorst
Copy link
Member Author

johanbrandhorst commented Oct 24, 2023

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.

@bcmills
Copy link
Contributor

bcmills commented Oct 24, 2023

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. 😅

@rsc
Copy link
Contributor

rsc commented Oct 25, 2023

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?

@dmitshur
Copy link
Contributor

dmitshur commented Nov 8, 2023

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:

  • Go 1.11 introduced the js/wasm port. It was marked experimental and the syscall/js package marked exempt from the Go compatibility promise to permit future adjustments based on experience. Go 1.14 introduced fairly significant breaking changes, which was 3 major releases later.
  • Go 1.5 added the darwin/arm64 port for the experimental Go mobile project. Go 1.16 added first-class support for macOS on arm64, and redefined 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.

@johanbrandhorst
Copy link
Member Author

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?

@rsc
Copy link
Contributor

rsc commented Nov 8, 2023

@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.

@dmitshur
Copy link
Contributor

dmitshur commented Nov 8, 2023

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.

@ydnar
Copy link

ydnar commented Nov 9, 2023

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".

@cherrymui
Copy link
Member

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.

@rsc
Copy link
Contributor

rsc commented Nov 10, 2023

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.

@rsc rsc changed the title proposal: cmd/compile: create GOARCH=wasm32 cmd/compile: create GOARCH=wasm32 Nov 10, 2023
@rsc rsc modified the milestones: Proposal, Backlog Nov 10, 2023
@cherrymui
Copy link
Member

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 uintptrs, 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).

@ydnar
Copy link

ydnar commented Feb 4, 2024

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.

+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:

@gopherbot
Copy link

Change https://go.dev/cl/578355 mentions this issue: cmd/compile: layout changes for wasm32, structs.HostLayout

@gopherbot
Copy link

Change https://go.dev/cl/581316 mentions this issue: cmd/compile: wasm32-specific structs.HostLayout changes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: Accepted
Development

No branches or pull requests