Photo by Tim Johnson on Unsplash
WASI, Communication between Node.js and WASM modules with the WASM buffer memory
My journey to free WASM from my browser
Table of contents
- Requirements
- Why this blog post?
- When a simple "Hello World" is not trivial
- The WASM memory buffer is your friend.
- Calling a WASM function from Node.js with numbers
- Calling a WASM function from Node.js with strings
This blog post explains how to use the WASM buffer memory to communicate between the host application and the WebAssembly module.
The previous blog post is WASI, first steps.
Requirements
You need to install the following:
GoLang and TinyGo (previous blog post: "Install Go and TinyGo").
One or more WASI runtime (previous blog post: "Install the WASI runtimes").
Why this blog post?
👋 this paragraph is long to read.
When a simple "Hello World" is not trivial
When you want to call a function of a WASM module from a host application using a WASI Runtime SDK, some simple things, like using a string as a function parameter and/or as a return value of the same function, are not trivial.
If you need an Introduction to WASI, have a look at this blog post: WASI, first steps
Usually, you could think that it's easy to use strings with functions:
In fact, you cannot:
Because right now, you can only use numbers with the functions of a WASM module:
The WASI specifications are evolving, and in the near (I hope) future, this kind of limitation will be removed. Of course, there is a workaround.
The WASM memory buffer is your friend.
The instance of a WASM module "shares" a memory buffer with the host. This WASM memory buffer is a way to pass data between the WASM module and the host application (host runtime).
So, here is the main principle on how to exchange data (a string) between the WASM module and the host application:
1- Copy the string to the WASM memory buffer
The host copies the value of the string to the memory and gets the position (
pos
) and the size (size
) of the string in the WASM memory buffer.As we can only use numbers with the WASM functions, the host calls the WASM
Hello
function with the position (pos
) of the string in memory and the size (size
) of the string as parameters.
2- Read the string from the memory buffer
The
Hello
WASM function, with the position (pos
)and the size (size
) can read the value of the string (name
) in the memory buffer.And create a return value (
msg
).
3- Write the returned value to the WASM memory buffer
To make the string value (
msg
) available for the host, first, we need to copy it to the memory buffer.Then the
Hello
function will need to return the position and the string size to the host.But we can return only one value!
4- Put two values into only one value
To return the position and the string size into only one value, the solution is to use the bit-shifting method.
What is bit shifting, and how to use it?
If we use the binary representations of the position and the size of the string:
We can "shift left" (
<<
) the bits of the position (pos
) by pushing zeros (32 zeros
) to the right and keep the bits of the position to the left.Then, we can apply a "OR" (
|
) with the size (size
) to put its binary representation to the right.Then we can return only one value to the host.
5- Decode the position and the size from only one value
To decode the returned value (v
) of the Hello
function into two values, the position (p
) and the size (s
) of the string, we need to use the bit shifting and masking method. And then, the host will be able to read the value of the string in the WASM memory buffer.
What is bit masking, and how to use it?
The host needs the position (
pos
) and the size (size
) of the string to read its value in memory. Then, the host needs to extract the two values from the value returned by theHello
function.Then, we can "shift right" (
>>
) the bits of the returned value with32 zeros
to "extract" the position (pos
).Then, we can apply a "AND mask" (
& mask
) of32 ones
to extract the size (size
).
Now, the host can read the memory buffer.
Fine! Enough theory; let's move on to practice.
Calling a WASM function from Node.js with numbers
Node.js comes with the WASI API, providing an implementation of the WebAssembly System Interface specification (it's a preview). That means Node.js can execute and interact with WASM modules.
Write a WASM function
We will first write a WASM module with an add
function that takes two numbers and returns the sum. Create a new GoLang module and a hello.go
file (in a directory):
go mod init function/hello
touch hello.go
This is the content of the hello.go
file:
package main
func main() {}
//export add
func add(a int, b int) int {
return a + b
}
👋 Important: to make the
add
function callable from the host, we need to add theexport add
comment above the function.
Build and run the function
tinygo build -o hello.wasm -scheduler=none --no-debug -target wasi ./hello.go
wasmer hello.wasm --invoke add 12 30 # 42
Call the add
function from Node.js
Loading and instantiating a WASM module and calling the function from JavaScript is pretty simple. Let's create a new file named index.mjs
with the below content:
"use strict";
import * as fs from 'fs'
import { WASI } from 'wasi'
const wasi = new WASI()
const importObject = { wasi_snapshot_preview1: wasi.wasiImport };
(async () => {
const wasm = await WebAssembly.compile(fs.readFileSync("./function/hello.wasm")); // 1️⃣
const instance = await WebAssembly.instantiate(wasm, importObject); // 2️⃣
wasi.start(instance); // 3️⃣
const sum = instance.exports.add(20, 22); // 4️⃣
console.log("🤖 sum:", sum); // 5️⃣
})()
### Remarks:
- 1️⃣ Load the WASM module.
- 2️⃣ Instantiate the WASM module.
- 3️⃣ Start the WASM module instance.
- 4️⃣ All the exported WASM functions are available on the `exports` field of the instance like any other JavaScript functions. So, we can call the `add` function with 2 parameters.
- 5️⃣ Print the result of the `add` function.
And to execute the Node.js program, type the following command:
node --experimental-wasi-unstable-preview1 --no-warnings index.mjs
You should see this output:
🤖 sum: 42
The complete source code is here: https://gitlab.com/wasmkitchen/wasi-nodejs-step-0
Now let's move on to something more fun.
Calling a WASM function from Node.js with strings
Write a new WASM function
We will write a WASM module with a helloWorld
function that takes two numbers (the position and the size of the string in memory passed by the host) and returns a single value (containing the size and the position of the returned message in memory). Create a new GoLang module and a hello.go
file (in a directory):
go mod init function/hello
touch hello.go
This is the content of the hello.go
file:
package main
import (
"unsafe"
)
// main is required for TinyGo to compile to Wasm.
func main() {}
//export helloWorld
func helloWorld(bufferPosition *uint32, length int) uint64 { // 1️⃣
nameBytes := readBufferFromMemory(bufferPosition, length) // 2️⃣
message := "👋 Hello World " + string(nameBytes) + " 🌍"
return copyBufferToMemory([]byte(message)) // 3️⃣
}
// readBufferFromMemory returns a buffer from the WebAssembly memory buffer
func readBufferFromMemory(bufferPosition *uint32, length int) []byte { // 4️⃣
buffer := make([]byte, length)
pointer := uintptr(unsafe.Pointer(bufferPosition))
for i := 0; i < length; i++ {
s := *(*int32)(unsafe.Pointer(pointer + uintptr(i)))
buffer[i] = byte(s)
}
return buffer
}
// copyBufferToMemory copies a buffer to the WebAssembly memory buffer
func copyBufferToMemory(buffer []byte) uint64 { // 5️⃣
bufferPtr := &buffer[0]
unsafePtr := uintptr(unsafe.Pointer(bufferPtr))
ptr := uint32(unsafePtr)
size := uint32(len(buffer))
return (uint64(ptr) << uint64(32)) | uint64(size) // 6️⃣
}
### Remarks:
- 1️⃣ The `helloWorld` function takes 2 parameters and returns a single value.
- 2️⃣ Read the buffer memory at the `bufferPosition` address.
- 3️⃣ Copy the buffer to the WebAssembly memory buffer.
- 4️⃣ `readBufferFromMemory` returns a buffer from the WebAssembly memory buffer.
- 5️⃣ `copyBufferToMemory` copies a buffer to the WebAssembly memory buffer and returns its memory position and size into only one value.
- 6️⃣ "pack" the position and the size into a single value. **Rember the bit `shift left or` operation**.
Build the function
tinygo build -o hello.wasm -scheduler=none --no-debug -target wasi ./hello.go
Call the helloWorld
function from Node.js
Let's create a new file named index.mjs
with the below content:
"use strict";
import * as fs from 'fs'
import { WASI } from 'wasi'
const wasi = new WASI()
const importObject = { wasi_snapshot_preview1: wasi.wasiImport };
(async () => {
const wasm = await WebAssembly.compile(fs.readFileSync("./function/hello.wasm"));
const instance = await WebAssembly.instantiate(wasm, importObject);
wasi.start(instance);
// 🖐 Prepare the string parameter
const stringParameter = "Bob Morane 🤗";
const bytes = new TextEncoder("utf8").encode(stringParameter); // 1️⃣
// The TinyGo `malloc` is automatically exported
const ptr = instance.exports.malloc(bytes.length); // 2️⃣
const mem = new Uint8Array( // 3️⃣
instance.exports.memory.buffer, ptr, bytes.length
);
mem.set(bytes); // 4️⃣
// Call the `helloWorld` TinyGo function
// Get a kind of pair of values
const helloWorldPointerSize = instance.exports.helloWorld(ptr, bytes.length); // 5️⃣
const memory = instance.exports.memory;
const completeBufferFromMemory = new Uint8Array(memory.buffer); // 6️⃣
const MASK = (2n**32n)-1n; // 7️⃣
// Extract the values of the pair (using the mask)
const ptrPosition = Number(helloWorldPointerSize >> BigInt(32)); // 8️⃣
const stringSize = Number(helloWorldPointerSize & MASK); // 9️⃣
console.log("🤖 Position:", ptrPosition);
console.log("🤖 Size:", stringSize);
// Extract the string from the memory buffer
const extractedBuffer = completeBufferFromMemory.slice( // 1️⃣0️⃣
ptrPosition, ptrPosition+stringSize
);
console.log("🤖 extractedBuffer:", extractedBuffer) // 1️⃣1️⃣
// Decode the buffer
const str = new TextDecoder("utf8").decode(extractedBuffer) // 1️⃣2️⃣
console.log(str)
})()
### Remarks:
- 1️⃣ Cast the string parameter to a `Uint8Array`.
- 2️⃣ Reserve memory space for the value (`malloc` is automatically exported at build time - this is a TinyGo particularity). The result is the **position** in memory
- 3️⃣ Access to the memory buffer.
- 4️⃣ Copy the `Uint8Array` to the WebAssembly memory buffer.
- 5️⃣ Call the `helloWorld` function with the **position** and **size/length** of the string as parameters, and get a single value as result containing the position and the size of the returned value.
- 6️⃣ Create a `Uint8Array` from the memory buffer.
- 7️⃣ Define a MASK (of `32 ones`) to extract the **position** and **size** of the string.
- 8️⃣ Do a `shift right` bit operation to extract the **position** of the string.
- 9️⃣ Do a `AND MASK` bit operation to extract the **size** of the string.
- 1️⃣0️⃣ Extract the part of the buffer containing the string.
- 1️⃣1️⃣ Print the buffer.
- 1️⃣2️⃣ Decode the buffer to get the string.
- 1️⃣3️⃣ Print the string.
And to execute the Node.js program, type the following command:
node --experimental-wasi-unstable-preview1 --no-warnings index.mjs
You should see this output:
🤖 Position: 66368
🤖 Size: 37
🤖 extractedBuffer: Uint8Array(37) [
240, 159, 145, 139, 32, 72, 101, 108,
108, 111, 32, 87, 111, 114, 108, 100,
32, 66, 111, 98, 32, 77, 111, 114,
97, 110, 101, 32, 240, 159, 164, 151,
32, 240, 159, 140, 141
]
👋 Hello World Bob Morane 🤗 🌍
The complete source code is here: https://gitlab.com/wasmkitchen/wasi-nodejs
🎉 Thanks for reading! It was a little bit long for only some strings 😆 but I hope you enjoyed it. In the next blog post, we'll see another way to communicate with the WASM module.
Some very good blog posts that help me a lot:
A practical guide to WebAssembly memory by Radu Matei from Fermyon.
Interacting with WebAssembly memory by Nish Tahir.
Getting data in and out of WASI modules by Peter Malmgren.