Writing sophisticated programs targeting Wasm
Building on the previous post, Compiling C to Wasm, I’d like to spend some more time writing about how we are using Wasm for distributing shared code. I’ll describe how we are sending and receiving bytes to and from the Wasm VM and our conventions around allocating memory. OK, let’s get to it.
Writing complex functions
To learn about reading and writing to the memory of the Wasm VM: let’s make a small program which can reverse a string. This example is surprisingly similar to how we are currently using Wasm to share code for Shareup for some of our byte-crunching algorithms.
Data types
Wasm function arguments and return values can only be one of four types: i32
, i64
, f32
, and f64
. That’s it. You cannot pass a string or array or anything like that as a function argument into the Wasm VM.
We’ve embraced this for Shareup and even standardized all our functions to only accept or return i32
s. Not every browser supports 64bit integers yet (BigInt
) so we’ve settled on 32bit integers as our argument and return value type of choice.
If we want to deal with more complex data types, then we must start reading and writing to the Wasm VM’s memory.
Memory
The memory of a Wasm VM is a linear array of bytes: so numbers from 0–255. We can read and write those numbers from inside the VM with our C code and from outside in our host machine with Javascript in a browser, for example. We want to make it easy to “write a few integers starting from this position in the memory” for both the C and Javascript versions.
Allocating memory
When we first started writing our C code we exported malloc
and free
functions to the host machine and wrote a sophisticated Heap
class in Javascript to keep track of memory allocations. This became problematic quickly. If our Javascript code errored (and we didn’t catch
it) then we would never free
the memory we had allocated. It also seemed odd for us to be allocating memory inside a VM from the outside 🤔
We’ve standardized our programs where only the C code inside the Wasm program can allocate memory. This simplifies our bookkeeping and makes sure our code in the host machine isn’t accidentally stepping on the toes of our Wasm code.
As a convention, we almost always end up with at least three functions per “algorithm”:
allocate_thing
thing
free_thing
Reading and writing memory from C
In the previous post I linked to a really great blog article Compiling C to WebAssembly without Emscripten. In that article, the author writes his own malloc
, which is both terrifying and yet kinda freeing to think about. As we’ve worked with Wasm we’ve learned that some allocators are extremely large and bloat our final binary size.
For example: there is a Rust Crate named wee_alloc which is a much smaller allocator to help make the Wasm output of a Rust project smaller. It seems to be widely used as a replacement for the default Rust allocator.
For Shareup, we are using the allocator provided by the WASI SDK.
Using the WASI SDK
The WASI SDK includes a standard library with a malloc
and free
. We’ve been pleased with the small binary size of our compiled programs when using their allocator.
There are some types of system calls that don’t make sense in the context of a Wasm VM. When the WASI SDK sees a syscall like that it adds an “import” to the .wasm
binary output.
Imports are functions the host machine provides to the Wasm VM to inject functionality into the VM for things like reading files, listening to sockets, or generating random numbers. Imports are the only way Wasm can affect the world outside the VM. WASI itself is a standard specification of different imports for different types of syscalls. Many Wasm runtimes have some or all WASI imports implemented and can therefore run any binary compiled for Wasm and WASI automatically. For Shareup, so far we are only using the random_get
import and we provide our own implementation for each platform.
Writing the C code to reverse a string
As I wrote above, for reversing a string we would probably write three functions:
unsigned int allocate_reverse(byte_length: unsigned int)
unsigned int reverse(ptr: void *)
unsigned int free_reverse(ptr: void *)
You can checkout all the code in the repo associated with these Wasm posts.
To begin, we’ll need to include stdlib.h
:
#include <stdlib.h>
For reversing a string, I’ll allocate enough memory for:
- Storing the original string
- Storing the reversed string
- Storing the length of the string
My allocate
function looks like:
__attribute__((export_name("allocate_reverse")))
void ** allocate_reverse(uint32_t length) {
unsigned char *original = malloc(length);
unsigned char *reversed = malloc(length);
uint32_t *stored_length = malloc(sizeof(uint32_t));
*stored_length = length;
void **pointers = malloc(sizeof(size_t) * 3);
pointers[0] = stored_length;
pointers[1] = original;
pointers[2] = reversed;
return pointers;
}
By allocating space for three pointers (malloc(sizeof(size_t) * 3)
) and writing the three 32bit integers inline into that memory, I can return a single integer pointer to the host machine. Over on the host we’ll need to read three 32bit integers starting at the returned pointer address, and then go read and write the memory at those three addresses as needed.
I’m writing the length onto the heap so I can remember it later during the actual reverse and not need to provide it as an argument a second time. You’ll see below how it’s used.
The reverse
function will assume the original string of bytes has already been written to the memory addressed by the original pointer (the second pointer in the array of pointers):
__attribute__((export_name("reverse")))
int reverse(void **pointers) {
const uint32_t length = *(uint32_t *)pointers[0];
const unsigned char *original = (unsigned char *)pointers[1];
unsigned char *reversed = (unsigned char *)pointers[2];
for (int i = 0; i < length; i++) {
int position = length - i - 1;
reversed[position] = original[i];
}
return 0;
}
This is a very naive way to reverse a string. I think it’s good for this example because it resembles a lot of the code we’ve written for Shareup, even though for this particular purpose there are better ways to accomplish reversing a string of bytes (including overwriting the original to save memory).
The free
function is straightforward compared to the above functions:
__attribute__((export_name("free_reverse")))
int free_reverse(void **pointers) {
void *length = pointers[0];
void *original = pointers[1];
void *reversed = pointers[2];
free(length);
free(original);
free(reversed);
return 0;
}
As a convention, we usually return 0 to mean success from functions like free
or reverse
. We might return other unsigned integers to communicate different error states.
A main
function
For our programs, we sometimes have some initial setup we want to perform when the Wasm VM boots. By convention, the WASI SDK will run the main
function if provided. Wasm calls the startup function start
and the WASI SDK defines a start
function which is basically return main()
.
Returning zero from main
means a successful startup. Returning anything else is considered a failure and the Wasm VM will fail to start and the Wasm runtime will most likely throw an exception or error.
For this example we don’t really a main
, and yet:
int main() {
return 0;
}
See the full reverse.c file in the repo.
Compiling
We can use Clang to compile this program by pointing it to the “sysroot" provided by the WASI SDK:
$ cd reverse
$ PROJECT="$(cd $(dirname $0)/.. && pwd)"
$ PATH="$(cd $PROJECT/wasi-sdk/bin && pwd):$PATH"
$ WASI_SYSROOT="$(cd $PROJECT/wasi-sdk/share/wasi-sysroot && pwd)"
$ clang --target=wasm32-wasi --sysroot="${WASI_SYSROOT}" -Oz -flto -Wl,--lto-O3 -o reverse.wasm reverse.c
$ wasm2wat reverse.wasm -o reverse.wat
Reading and writing memory from Typescript
As I showed in the previous post, we can use Deno to try out our reverse code.
Our goal is to run a program like:
$ deno run --quiet --allow-read reverse.ts wow this is cool
Original: wow this is cool
Reversed: looc si siht wow
We can grab the arguments with Deno.args.join(' ')
and send those to our Wasm program. See the full reverse.ts file in the repo.
First, let’s encode the original string into it’s bytes:
const encoder = new TextEncoder()
const originalString = Deno.args.join(' ')
const originalBytes = encoder.encode(originalString)
To make working with the exported functions easier, let’s describe their signatures to Typescript:
type Exports = {
memory: WebAssembly.Memory
allocate_reverse(length: number): number
reverse(pointers: number): number
free_reverse(pointers: number): number
}
Boot the Wasm instance:
const code = await Deno.readFile('reverse.wasm')
const { instance } = await WebAssembly.instantiate(code)
const ex = instance.exports as Exports // cast the exports
const memory = ex.memory
OK, let’s allocate the memory we need:
const pointers = ex.allocate_reverse(originalBytes.byteLength)
Then we’ll need to read the three 32bit unsigned integer pointers from the pointers
address. We can use a DataView for that
// 3 32bit integers is 3 * 4 = 12 bytes
const view = new DataView(memory.buffer, pointers, 12)
const storedLengthPointer = view.getUint32(0, true)
const originalPointer = view.getUint32(4, true)
const reversedPointer = view.getUint32(8, true)
Let’s make sure the length got stored correctly:
const storedLength = (new DataView(memory.buffer, storedLengthPointer, 4)).getUint32(0, true)
if (storedLength !== originalBytes.byteLength) {
console.error('Wrong storedLength, something has gone wrong')
Deno.exit(1)
}
Write the original string bytes into the Wasm VM’s memory using Uint8Array’s set method:
// Get a typed array pointing at this region of memory
const wasmOriginalBytes = new Uint8Array(memory.buffer, originalPointer, storedLength)
// Overwrite this typed array with the original bytes
wasmOriginalBytes.set(originalBytes)
OK, now we are setup to run the reverse
function. We’ll need to make sure we get a 0
return value back:
if (ex.reverse(pointers) !== 0) {
console.error('reverse failed')
Deno.exit(1)
}
Well, if that worked, then we should be able to read the reversed bytes out of the Wasm VM’s memory:
const reversedBytes = Uint8Array.from(new Uint8Array(memory.buffer, reversedPointer, storedLength))
Uint8Array’s from method makes a copy, which is important because we are about to free the memory we allocated:
if (ex.free_reverse(pointers) !== 0) {
console.error('free_reverse failed')
Deno.exit(1)
}
And that’s our three functions. 🎉 We can print out the results and see if it worked how we expected:
const decoder = new TextDecoder()
console.log(`Original: ${originalString}`)
console.log(`Reversed: ${decoder.decode(reversedBytes)}`)
Checkout all the code in the repo. Did your Deno script work as well? Could you make it UTF-8 aware? 🤔
Betting on Wasm allows us to build more with less
We’ve become very comfortable writing our C code this way and integrating the resulting Wasm binary into different platforms. We believe Wasm is a huge multiplier for us: allowing us to write once and run everywhere for some of our most important code. I recommend trying it out for your use case and seeing if it can also be a good fit for your project.
Do you have ideas for using Wasm or feedback about these posts? Let me know and thanks for reading. ✌️