Passing and Returning Structured Datatypes to and From Go WebAssembly Module

Written by hacker5061306 | Published 2023/06/08
Tech Story Tags: wasm | webassembly | go | tinygo | api | karmem | server-side-code | web-development

TLDRWebAssembly (Wasm) is a technology with numerous attractive features. It is multiplatform, offers near-native performance, and can be used both in browsers and on the server-side. However, due to its relative youth, certain basic tasks can be more challenging than expected. One such difficult task is passing and returning complex objects to and from WebAssembly modules.via the TL;DR App

WebAssembly (Wasm) is a remarkable technology with numerous attractive features. It is multiplatform, offers near-native performance, and can be used both in browsers and on the server side.

However, due to its relative youth, certain basic tasks can be more challenging than expected, particularly for newcomers. One such unexpectedly difficult task is passing and returning complex objects to and from WebAssembly modules. This challenge arises because WebAssembly only supports primitive datatypes such as int32, int64, float32, and float64.

Consequently, passing complex objects like arrays, strings, and structs with named fields ultimately boils down to the problem of passing arrays of bytes and applying serialization/deserialization algorithms to the data.

The general approach to achieving this is quite simple. First, allocate memory on the guest side (the Wasm module side) and copy the request's data from the host to that memory buffer. Then, pass the pointer to that memory along with the buffer size to the guest. The guest can process the data based on its own business logic and generate a result. Next, allocate memory for the result, copy the result's bytes into that buffer, and return a similar pair (pointer + buffer size) from the WebAssembly module to the host. Finally, it is important to remember to properly free all previously allocated memory buffers.

When it comes to memory management in WebAssembly, it heavily depends on the language [Footnote - 1] used for compiling to WebAssembly instructions. Some languages have built-in garbage collectors (GC), while others do not. Additionally, selecting a serialization format and library to interpret the passed bytes is not a trivial task. Ultimately, when it comes to writing the actual code that works correctly and can be safely deployed to production, things can become somewhat complicated, to say the least.

This problem is exacerbated by the lack of comprehensive and clear information available on the Internet. Despite official WebAssembly resources, documentation from various WebAssembly runtimes, and software engineers' blogs, the information regarding this task is often vague or incomplete. For instance, while there are some recipes available for Rust or JavaScript, not all of them are applicable to Go (which is the focus of this article). In other cases, we may come across examples of how to pass a string or array of data into the WebAssembly module, but finding good examples of returning similar strings from WebAssembly is challenging. Additionally, some examples illustrate the principles but lack proper memory management, rendering them useless and not suitable for production.

In this article, we will walk through the solution to the task described above. We cannot cover all the diversity of languages and Wasm runtimes, so focus just on the following.

We will write our guest application in Go, compile it to Wasm with TinyGo compiler, and embed it with Wasmtime runtime into the host application which will be written also in Go. For serialization, we will use Karmem [Footnote - 2] which is a format and a library very similar to the well-known Protobuf.

Table of Contents

  • API of the guest application.
  • Memory management on the guest side
  • Prepare request and pass it from host to guest
  • The rest of the guest application implementation
  • Accept the result on the host side
  • How to run the complete example
  • Conclusion
  • Footnotes

API of the guest application

Our guest application will accept complex objects of DataRequest type, which in Karmem language could be described as this:

struct DataRequest inline {
    Numbers []int32;
    K int32;
}

The DataRequest type has two fields: an array of integers Numbers and a number K. Our guest application will do very simple following business logic: return only those numbers which are greater than the given K number. So, our guest application will return objects of DataResponse type:

struct DataResponse inline {
    NumbersGreaterK []int32;
}

These datatype definitions are located in the api.km file. We need to call Karmem code generator with a command:

hackernoon_article_1/api$ go run karmem.org/cmd/karmem build --golang -o "v1" api.km

This command generates for us a file api/v1/api_generated.go which contains Go code for serialization and deserialization of DataRequest and DataResponse struct types. Karmem has very intuitive API, for example, here is a piece of code that creates a DataRequest and serializes it to []byte:

import "api/v1"

req := v1.DataRequest{
    Numbers: []int32{10, 43, 13, 24, 56, 16},
    K: 42,
}
writer := karmem.NewWriter(4 * 1024)
if _, err := req.WriteAsRoot(writer); err != nil {
	panic(err)
}
reqBytes := writer.Bytes()

Deserialization could be accomplished in a similar (mirrored) manner:

import "api/v1"

reader := karmem.NewReader(reqBytes)
req := new(v1.DataRequest)
req.ReadAsRoot(reader)

Now we are able to convert our requests and responses to and from arrays of bytes. Let's proceed to the next steps.

Memory management on the guest side

Before we begin to directly pass the []byte data to the Wasm module, let's look at some details of memory management of our guest application. According to the description of our general approach, we need to:

  • allocate a buffer of guest's memory on the host side (need to copy request's bytes there),

  • allocate a buffer of guest's memory on the guest side (need to copy response's bytes there),

  • deallocate (free) previously allocated memory buffer on the host side (both buffers will be deallocated on the host side).

Thus all we need here is a pair of functions: Malloc and Free [Footnote - 3] (which are very similar to those used in the C language) exported by the guest application. Here is the Malloc function:

var allocatedBytes = map[uintptr][]byte{}

//go:export Malloc
func Malloc(size uint32) uintptr {
	buf := make([]byte, size)
	ptr := &buf[0]
	unsafePtr := uintptr(unsafe.Pointer(ptr))
	allocatedBytes[unsafePtr] = buf
	return unsafePtr
}

Comment //go:export Malloc is not just a comment but a TinyGo way to mark the functions that should be exported out from the resulting Wasm module. The allocatedBytes map holds all the references to all allocated memory buffers so GC will not come and collect them until they will be freed. The only non-trivial part here could be this ‘magic’: unsafePtr := uintptr(unsafe.Pointer(ptr)).

This is simply a way in Go to get the raw (and thus ‘unsafe’) pointer to some object. We need raw pointer because we should treat it like an integer number so we are able to pass it to (and from) the Wasm.

Implementation of the Free function is trivial, it simply deletes references to previously allocated buffers from the allocatedBytes map:

//go:export Free
func Free(ptr uintptr) {
	delete(allocatedBytes, ptr)
}

Also, we have a helper function to access the allocated memory buffers on the guest side:

func getBytes(ptr uintptr) []byte {
	return allocatedBytes[ptr]
}

See the mem.go for the complete source code of the memory management module of the guest application. All this memory management code along with the rest of the guest application code will be compiled into Wasm instructions with the TinyGo compiler. The exact command will be presented a little bit later in this article.

Prepare request and pass it from host to guest

All the required preparations are done, so in this paragraph, we are ready to see the details of the host application code. We skip details of the Wasm runtime initialization because this is not the main focus of the article. The initialization is encapsulated in the function newWasmInstance and we call it in the very beginning:

instance, store, mem, err := newWasmInstance("../guest/guest.wasm")
if err != nil {
    panic(err)
}

The newWasmInstance does all the initialization needed according to the Wasmtime Getting started documentation and returns references to the Wasm VM instance as well as to its store and linear memory.

Next, we get references to the three exported guest function objects that we will need:

malloc := instance.GetFunc(store, "Malloc")
free := instance.GetFunc(store, "Free")
processRequest := instance.GetFunc(store, "ProcessRequest")

They are the memory management Malloc and Free functions (those discussed in the previous paragraph) and the ProcessRequest function which is the guest's function that implements the guest's API.

Conceptually ProcessRequest accepts an instance of a DataRequest struct type and returns an instance of a DataResponse struct type. But in fact, it accepts two 32-bit integers and returns one 64-bit integer.

Here is its signature as declared in the guest's sources:

//go:export ProcessRequest
func ProcessRequest(reqPtr uintptr, reqLen uint32) uint64 {
  // ...
}

The two integers that ProcessRequest function accepts are:

  • reqPtr is the address to the beginning of the memory buffer where the serialized DataRequest bytes are copied to

  • reqLen is the size of that buffer.

The resulting 64-bit integer holds bit representation of the two 32-bit integers that represent the address of the buffer and its size where serialized bytes of DataResponse are copied to. The reason why it is a single 64-bit integer instead of a tuple of two 32-bit integers is that it is super unclear how TinyGo treats data when the function returns complex tuple-like results. It was not documented at the moment of writing this article (or simply I could not find it). So this should be considered as a workaround hack (that works very well) to return a pair of 32-bit integers from a function that is exported from a Wasm module.

// Here `reqBytes` is a []byte array with the DataRequest serialized bytes
reqBytesLen := int32(len(reqBytes))
ptrReq, err := malloc.Call(store, reqBytesLen)
if err != nil {
    panic(err)
}

int32PtrReq := ptrReq.(int32)
copy(
    mem.UnsafeData(store)[int32PtrReq : int32PtrReq+reqBytesLen],
    reqBytes,
)

respPtrLen, err := processRequest.Call(store, int32PtrReq, reqBytesLen)
if err != nil {
    panic(err)
}

free.Call(store, int32PtrReq)

// `respPtrLen` points to the `DataResponse`, we will use it in the next steps. TO BE CONTINUED...

Here, we call Malloc function that allocates the memory buffer of the exact size to fit the reqBytes data, copy that bytes data to the buffer, and call the ProcessRequest function. Right after that, we call Free on the allocated memory buffer.

There is a lot of typecasting happening here and this deserves a bit of explanation. For example, ProcessRequest function as you may have noticed accepts two 32-bit integers of unsigned types: uintptr and uint32. But Wasm supports only signed int32 types. You may see it if you inspect (for example with wasmer inspect guest.wasm command, more info here about their CLI tool) the Wasm module:

Type: wasm
Size: 86.3 KB
<...>
Exports:
  Functions:
    <...>
    "ProcessRequest": [I32, I32] -> [I64]
<...>

That is why we have to do all the typecasting on the host side explicitly, it is not performed automatically because Go does not allow this. Here is the full source code of the host's main.go module.

The rest of the guest application implementation

So far so good, we have sent the address to the DataRequest serialized bytes and the number of those bytes to the guest. Let's see how guest handles this data:

//go:export ProcessRequest
func ProcessRequest(reqPtr uintptr, reqLen uint32) uint64 {
    reader := karmem.NewReader(getBytes(reqPtr))
    req := new(v1.DataRequest)
    req.ReadAsRoot(reader)

    resp := doProcessRequest(req)

    writer := karmem.NewWriter(4 * 1024)
    if _, err := resp.WriteAsRoot(writer); err != nil {
        panic(err)
    }
    respBytes := writer.Bytes()
    respBytesLen := uint32(len(respBytes))
    ptrResp := Malloc(respBytesLen)
    respBuf := getBytes(ptrResp)
    copy(respBuf, respBytes)
    return packPtrAndSize(ptrResp, respBytesLen) // NOTE: It is the host's responsibility to free this memory!
}

First, the guest asks its memory management ‘subsystem’ to find a corresponding []bytes array by calling getBytes(reqPtr) function. Second, the Karmem library is used to deserialize the DataRequest struct instance and as a result the req variable references to it. After that, the doProcessRequest function is called which contains all the ‘business logic’ of our guest application.

Here it is (it is trivial, but the main point here is that it produces an instance of a DataResponse struct):

func doProcessRequest(req *v1.DataRequest) *v1.DataResponse {
    result := make([]int32, 0)
    for _, number := range req.Numbers {
        if number > req.K {
            result = append(result, number)
        }
    }
    resp := v1.DataResponse{
        NumbersGreaterK: result,
    }
    return &resp
}

At the end of the ProcessRequest function, some interesting things happen. We take the respBytes, call the Malloc function to allocate the memory buffer (which is Wasm's linear memory, but the host also is perfectly able to read from it) and copy the DataResponse serialized (with Karmem) bytes of data into that buffer.

Then, we call the packPtrAndSize function that ‘joins’ two 32-bit integers into one 64-bit integer and return it to the host. The implementation of the packPtrAndSize has some bit manipulation:

func packPtrAndSize(ptr uintptr, size uint32) (ptrAndSize uint64) {
    return uint64(ptr)<<uint64(32) | uint64(size)
}

It takes a 64-bit integer and writes the 32-bits of the ptr variable to the high bits of it. It also writes the 32-bits of the size variable to the low bits of the 64-bit integer. Then returns the 64-bit integer as a result. The full source code of the guest's main.go module could be found here.

One very important thing in this part of the article is that the memory buffer that was allocated by the guest for the DataResponse result should be deallocated by the host at some point in time when it will be not needed anymore. How this is implemented in code will be seen right in the next paragraph. The reasoning behind this way of managing memory is that Wasm module (compiled by TinyGo) has it's own GC onboard.

If we simply return the address of the byte buffer that returned Karmem to us (the respBytes variable), there is no guarantee that GC will not collect this memory after the ProcessRequest function completes. And this could be fatal if garbage collection of that memory will happen before the host will use the response.

After all the sources of the guest application is in their place, we can call the TinyGo compiler and build the Wasm module with a command:

hackernoon_article_1/guest$ tinygo build -target=wasi -o guest.wasm .

This should produce a hackernoon_article_1/guest/guest.wasm binary file which is used by the host application.

Accept the result on the host side

Let's look at what happens on the host side right after the ProcessRequest function call:

// `respPtrLen` points to the `DataResponse`, we will use it in the next steps. TO BE CONTINUED...
respPtr, respLen := unpackPtrAndSize(uint64(respPtrLen.(int64)))

resp := new(v1.DataResponse)
respBytes := mem.UnsafeData(store)[int32(respPtr) : int32(respPtr)+int32(respLen)]
resp.ReadAsRoot(karmem.NewReader(respBytes))

fmt.Printf("NumbersGreaterK=%v\n", resp.NumbersGreaterK)

free.Call(store, respPtr) // This memory was allocated on the guest side, we free it on the host side here

The resulting 64-bit variable respPtrLen is being ‘unpacked’ into two 32-bit integers which are the address of the memory buffer and the size of that buffer. The unpackPtrAndSize function that does the opposite thing that was made by packPtrAndSize:

func unpackPtrAndSize(ptrSize uint64) (ptr uintptr, size uint32) {
    ptr = uintptr(ptrSize >> 32)
    size = uint32(ptrSize)
    return
}

Then, we call Karmem to deserialize the bytes in the resulting buffer into the DataResponse struct instance resp and use the data from there (in our example this usage is a simple printing of the resp.NumbersGreaterK to the standard output). After we used the resulting data we call Free on the respPtr because it is the host's responsibility to free that memory buffer that was allocated by the guest code on the guest side.

End of story!

How to run the complete example

The complete sources of the example discussed in this article are in my GitHub repo. What you need to do as a prerequisite is:

  • Install the latest Go (I used go version go1.19.5 linux/amd64) according to their official instructions.
  • Install the latest TinyGo (I used tinygo version 0.27.0 linux/amd64 (using go version go1.19.5 and LLVM version 15.0.0)) according to their official instructions.
  • Make sure you have both go and tinygo executables in your PATH.

I have created a bunch of Makefiles in the git repo, so theoretically all you have to do is to execute:

hackernoon_article_1$ make

Which should do the following:

  • generate the Karmem serialization/deserialization code from the api.km definitions
  • call the TinyGo compiler and build the guest.wasm Wasm module
  • run the host Go application

Here is the output on my machine:

[email protected]:~/hackernoon_article_1$ make
cd ./host && make
make[1]: Entering directory '/home/vitvlkv/hackernoon_article_1/host'
cd ../api && make
make[2]: Entering directory '/home/vitvlkv/hackernoon_article_1/api'
go run karmem.org/cmd/karmem build --golang -o "v1" api.km
make[2]: Leaving directory '/home/vitvlkv/hackernoon_article_1/api'
cd ../guest && make
make[2]: Entering directory '/home/vitvlkv/hackernoon_article_1/guest'
cd ../api && make
make[3]: Entering directory '/home/vitvlkv/hackernoon_article_1/api'
make[3]: Nothing to be done for 'all'.
make[3]: Leaving directory '/home/vitvlkv/hackernoon_article_1/api'
tinygo build -target=wasi -o guest.wasm .
make[2]: Leaving directory '/home/vitvlkv/hackernoon_article_1/guest'
WASMTIME_BACKTRACE_DETAILS=1 go run .
Numbers=[10 43 13 24 56 16], K=42
NumbersGreaterK=[43 56]
make[1]: Leaving directory '/home/vitvlkv/hackernoon_article_1/host'

As you can see, we passed to the guest array of Numbers=[10 43 13 24 56 16] and integer K=42 and received back an array NumbersGreaterK=[43 56] with two integers that are larger than the given K in the input Numbers array. This is exactly what we wanted our guest application to do!

Conclusion

This article proposes a solution to a common problem faced by software engineers working with WebAssembly: passing non-primitive datatypes to and from a Wasm module. This solution has been effectively applied at TakeProfit Inc., where I currently work. WebAssembly has a steep learning curve, but it also holds great potential as a technology. I hope this article will be helpful to someone in their work or personal projects.

Footnotes

[1] If we'd write our guest application in Rust, then the whole task could be solved in a much simpler manner, using their wasm-bindgen library.

[2] JSON format could be used too, but it should be noted that Go's standard encoding/json library doesn't work in TinyGo, because TinyGo does not support reflection. People who need to use JSON without schema in Wasm usually go with gson library. tinyjson seems to be a good alternative for cases where the schema of all JSON messages is known. For this article I was looking for something like Protobuf but unfortunately, their Go's implementation does not work with TinyGo. Karmem is very close to Protobuf conceptually, that is why I decided to use it.

[3] When TinyGo compiles Go sources into the Wasm module it automatically adds some own implementations of malloc and free functions to the exports list (they could be observed if you inspect the Wasm module with some corresponding tool like Wasmer inspect). We do not use them for two reasons: 1) TinyGo promises nothing about the stability of exported malloc and free 2) we have to call Malloc for storing the result somehow on the guest side, it is unclear how to call standard malloc this way but very straightforward with our own custom Malloc.

The lead image for this article was generated by HackerNoon's AI Image Generator via the prompt "A computer screen with web assembly displayed"


Written by hacker5061306 | Develop Indie language and runtime at TakeProfit.com also enjoy skating and making DIY ergonomic keyboards.
Published by HackerNoon on 2023/06/08