ariya.io About Talks Articles

Basics of Memory Access in WebAssembly

7 min read

WebAssembly is getting more popular than ever. The use of WebAssembly outside the confinement of a web browser also starts to gain traction, as evidenced from projects such as WebAssembly Interface. Understanding the memory model of WebAssembly will be important in order to fully comprehend its power as well as its constraints.

A typical demo of C/C++ targeting WebAssembly is a simple function, with primitive inputs and output. Here is one, adder.c:

int add(int a, int b) {
    return a + b;
}

which can be compiled as:

$ emcc adder.c -o adder.js -s EXPORTED_FUNCTIONS='["_add"]'

Beside producing the JavaScript file, adder.js, the above command also produced adder.wasm. Opening the JavaScript file reveals a lot of glue code produced by Emscripten to serve as the bridge between the JavaScript world and the WebAssembly world.

Wasm code

In practice, we do not always need the whole glue code. Minimalistically, adder.wasm can be directly executed in Node.js as follows (seasoned WebAssembly users might cringe at the use of synchronous loading here, but bear with me, it is sufficient for illustrating the concept):

const fs = require('fs');

const wasmSource = new Uint8Array(fs.readFileSync("adder.wasm"));
const wasmModule = new WebAssembly.Module(wasmSource);
const wasmInstance = new WebAssembly.Instance(wasmModule, {
    env: {
        memory: new WebAssembly.Memory({
            initial: 256
        })
    }
});

const result = wasmInstance.exports._add(2, 40);
console.log(result);

If the above script is saved as test-adder.js, running it as node test-adder.js however will trigger an error like the following:

LinkError: WebAssembly Instantiation: Import #0 module="env" function="abortStackOverflow"
error: function import requires a callable

This is because we do not implement the whole imports properly for WebAssembly.Instance. However, since this is supposed to be a minimalistic WebAssembly module, we can fix this by compiling only the code inside adder.c without any additional run-time (note the underscore sign before the function name).

$ emcc -Os adder.c -o adder.wasm -s EXPORTED_FUNCTIONS='["_add"]'

Essentially, -Os optimizes the compiler to generate the minimalistic bytecodes, pruning everything that is not used. We also output straight to adder.wasm here (compared to the earlier example). And finally, we ought to tag _add as one of the exported functions, thus making it visible from the JavaScript world.

We can verify the result by inspecting adder.wasm, i.e. showing its textual format. For Visual Studio Code users, the easiest is by installing the WebAssembly Toolkit extension. Once it is installed, a simple right click on a .wasm file brings up the Show Assembly menu item.

Now running test-adder.js with Node.js will display the expected result, 42.

$ node test-adder.js
42

So far so good. But what if we want to use non-primitive inputs and outputs? For instance, if we want to implement an image processing in WebAssembly, we need to be able to transfer some memory data, e.g. some pixel RGB values in an array. The obvious solution is to rely on Emscripten-generated glue code, along with the powerful bridging feature of Emscripten, thanks to Embind.

But again, how do we do that without the glue code? How does it work if we want to be as close as possible to the bare metal? This is where WebAssembly.Memory becomes very useful. Essentially, it constructs a linear, contiguous array of bytes which can be accessed from both worlds, JavaScript and WebAssembly.

As a simple illustration, assuming we have an array of bytes and want to have it reversed, e.g. [1,2,3] must be turned into [3,2,1]. The C implementation, saved as reverse.c, will look like:

void reverse(unsigned char* p, int len) {
    for (int i = 0; i < len / 2; ++i) {
        unsigned char temp = p[i];
        p[i] = p[len - i - 1];
        p[len - i - 1] = temp;
    }
}

Compile it as usual:

$ emcc -Os reverse.c -o reverse.wasm -s EXPORTED_FUNCTIONS='["_reverse"]'

Now we can use this WebAssembly module from JavaScript:

const fs = require('fs');

const memory = new WebAssembly.Memory({
    initial: 256,
    maximum: 256
});
const heap = new Uint8Array(memory.buffer);
const imports = {
    env: {
        memory: memory
    }
};

const wasmSource = new Uint8Array(fs.readFileSync("reverse.wasm"));
const wasmModule = new WebAssembly.Module(wasmSource);
const wasmInstance = new WebAssembly.Instance(wasmModule, imports);

function reverse(arr) {
    for (let i = 0; i < arr.length; ++i) {
        heap[i] = arr[i];
    }
    wasmInstance.exports._reverse(0, arr.length);

    const result = [];
    for (let i = 0; i < arr.length; ++i) {
        result.push(heap[i]);
    }
    return result;
}

const numbers = [14, 3, 77];
console.log(numbers, 'becomes', reverse(numbers));

What happens here? We create an Uint8Array called heap to ease the writing and reading from the memory block. Inside the reverse function, initially this heap is filled with the input array. After that, we invoke the actual reverse implementation on the WebAssembly side, giving the location and length of the bytes to be processed. Once that is completed, again we convert the elements from the heap as the return value of the function.

Obviously, for a simple example like this, there is no really gain of doing it via WebAssembly. The overhead of transferring the input and converting it back is the bottleneck (although well-trained JavaScript programmers might be able to squeeze the (de)serialization even further). But imagine if the data processing is actually very CPU intensive, everything from the usual number crunching to applying some computer vision algorithms.

For the last illustration, let us assume that the code in the WebAssembly needs to construct an object dynamically. The following C++ function accepts a number and produce the Fibonacci series with the elements no larger the specific parameter max. To showcase the use of the standard C++ library, the implementation does this in two steps. First, just generate the series by pushing to a stack. Second, create an array to hold all the values.

#include <stack>

extern "C"
uint8_t* fibo(int max) {
    std::stack<uint8_t> stack;

    uint8_t p = 1;
    uint8_t q = 1;
    stack.push(p);
    stack.push(q);

    while (true) {
        uint8_t r = p + q;
        if (r > max) break;
        stack.push(r);
        p = q;
        q = r;
    }

    int count = stack.size();
    stack.push(count);

    uint8_t* result = new uint8_t[stack.size()];
    int i = 0;
    while (stack.size() > 0) {
        result[i++] = stack.top();
        stack.pop();
    }
    return result;
}

Compile the code as:

$ emcc -O3 fibo.cpp -o fibo.wasm -s EXPORTED_FUNCTIONS='["_fibo"]'

Since this example is more complex than the previous one, we switch to -O3 to have more performant code being generated (at the cost of increased compile time, which should be relatively negligible).

The resulting fibo.wasm should be about 25 KB bytes. If you want to have a smaller wasm file, append -s MALLOC="emmalloc" to the above emcc invocation as this will bundle the small emmalloc as the chosen memory allocation library. It is rather simplistic (optimized for size), but it is suitable for this particular use cases. For the above example, fibo.wasm will be reduced to just about 8 KB.

To use the above WebAssembly module, run the following code with Node.js:

const fs = require('fs');

const memory = new WebAssembly.Memory({
    initial: 256,
    maximum: 256
});
const heap = new Uint8Array(memory.buffer);

const imports = {
    env: {
        memory: memory,
        DYNAMICTOP_PTR: 4096,
        abort: function(err) {
            throw new Error('abort ' + err);
        },
        abortOnCannotGrowMemory: function(err) {
            throw new Error('abortOnCannotGrowMemory ' + err);
        },
        ___cxa_throw: function(ptr, type, destructor) {
            console.error('cxa_throw: throwing an exception, ' + [ptr,type,destructor]);
        },
        ___cxa_allocate_exception: function(size) {
            console.error('cxa_allocate_exception' + size);
            return false; // always fail
        },
        ___setErrNo: function(err) {
            throw new Error('ErrNo ' + err);
        },
        _emscripten_get_heap_size: function() {
            return heap.length;
        },
        _emscripten_resize_heap: function(size) {
            return false; // always fail
        },
        _emscripten_memcpy_big: function(dest, src, count) {
            heap.set(heap.subarray(src, src + count), dest);
        },
        __table_base: 0,
        table: new WebAssembly.Table({
            initial: 33,
            maximum: 33,
            element: 'anyfunc'
        })
    }
};

const wasmSource = new Uint8Array(fs.readFileSync("fibo.wasm"));
const wasmModule = new WebAssembly.Module(wasmSource);
const wasmInstance = new WebAssembly.Instance(wasmModule, imports);

function fibo(n) {
    const loc = wasmInstance.exports._fibo(n);
    const count = heap[loc];
    const series = [];
    for (let i = 0; i < count; ++i) {
        series[i] = heap[loc + i + 1];
    }
    return series;
}

const n = 77;
console.log('fibo', n, 'is', fibo(n));

Things get a bit more tedious this time. Due to the all memory handling, the import object for the instantiation of our WebAssembly module need to contain some more member variables and functions, although most of them are relatively straightforward and self-explanatory.

$ node test-fibo.js
fibo 77 is [ 55, 34, 21, 13, 8, 5, 3, 2, 1, 1 ]

The array is basically the Fibonacci series (in the reverse order) with the largest number, 55 in this case, not exceeding the specified maximum of 77.

Note that in the above examples, we treat the memory as an array of bytes. Practically, it is also possible to view them as e.g. an array of 32-bit integers, in this case by using Uint32Array on the JavaScript side and the corresponding pointer types on the WebAssembly side. Make sure to check the for more details on the little-endianness and other relevant tidbits.

Now, go and build amazing things with WebAssembly!

Related posts:

♡ this article? Explore more articles and follow me Twitter.

Share this on Twitter Facebook

comments powered by Disqus