When we think “a fast AOT WebAssembly compiler and runtime”, we typically think about V8, Wasmer, WasmEdge or Wasmtime.
All of these have in common that they are large, complicated pieces of software, that come with a lot of overhead, and only work on a limited set of platforms.
But how about transpiling WebAssembly code to C source code, and leveraging the state-of-the-art optimization passes of C compilers?
This is the approach taken by the wasm2c
tool from the WABT package, as well as the single-file WebAssembly transpiler used to bootstrap the Zig compiler.
The output of these tools is really a line-by-line conversion of the WebAssembly code to dumb, unoptimized C code.
There are instant benefits to this. First, the output is kinda human readable, which is useful for debugging. A WebAssembly function shows up as a regular C function, that can be directly called from C or any language with a C FFI.
Take existing C code, compile it to WebAssembly, transpile it back to C, and you get the same code, but sandboxed. The transformation acts as a sanitizer that improves safety by restricting the range of virtual memory accessible to each instance.
Of course, that works with any WebAssembly module, no matter what original languages it was written in.
With this approach, assembling different WebAssembly modules also becomes very easy.
Startup time is negligible. There’s no overhead. No runtime either. Just WebAssembly functions directly transpiled to C functions, that are trivial to embed in any project.
The reponsibility to compile that source code to native code is left to a regular C compiler. If the generated C source code is portable enough, this is also a great way to compile and run WebAssembly on embedded targets and new operating systems, that require custom compilers.
This approach can also greatly improve security and reliability. Because they don’t perform any optimization, WebAssembly to C transpilers are extremely small and simple. And the resulting code can even be compiled with formally-verified C compilers such as CompCert for high assurance code generation.
But how about features, usability and performance?
w2c2
w2c2 is probably the most advanced of these transpilers.
Among other things, it supports many WebAssembly extensions, including WASI-core and threads.
This is a fantastic piece of software, but it unfortunately requires a bit of work to setup and use.
Installing w2c2
Clone the w2c2
repository:
git clone <https://github.com/turbolent/w2c2>
cd w2c2
Compile it:
mkdir build
cd build
cmake -DCMAKE_BUILD_TYPE=Release ..
make
Define where the files will be installed:
export W2C2_DIR=/tmp/w2c2
Install them:
install -d "$W2C2_DIR"/{bin,lib,include/{w2c2,wasi}}
install -s w2c2/w2c2 "$W2C2_DIR"/bin/
install wasi/libw2c2wasi.a "$W2C2_DIR"/lib/
install -m 0644 ../w2c2/w2c2_base.h "$W2C2_DIR"/include/w2c2/
install -m 0644 ../wasi/wasi.h "$W2C2_DIR"/include/wasi/
Alright, let’s get back to the root directory, and add w2c2
to the PATH
environment variable:
cd ../..
export PATH="$W2C2_DIR"/bin:$PATH
rehash # only needed on some shells such as zsh
On macOS, the w2c2
command weights about 150KB only. Yes, that’s all we need to compile WebAssembly modules.
For reference, the wasmer
executable alone is 42MB large.
Creating a WebAssembly file
Next, let’s clone the Zig project boilerplate in order to get an example WebAssembly module:
mkdir test && cd test
zig init
zig build -Dtarget=wasm32-wasi -Doptimize=ReleaseSmall \
-Dcpu=baseline+bulk_memory+sign_ext+nontrapping_fptoint
The resulting WebAssembly module can be found in zig-out/bin/test.wasm
.
By defaut, WebAssembly modules created by Zig aim at maximizing portability, so they target the baseline
virtual CPU, that doesn’t enable any WebAssembly extension.
However, bulk memory, sign extension and non-trapping floating point are not a problem for w2c2
, so we enable them.
The above example doesn’t use threads, but since Zig supports WASI threads, we could add +atomic
to the list of features in order to run multithreaded WebAssembly code.
Compiling WebAssembly to C
Now that we have a test.wasm
WebAssembly module, the time has come to convert it to C.
mkdir transpiled && cd transpiled
w2c2 -p ../zig-out/bin/test.wasm test.c
The above command creates the test.c
and test.h
files, containing a C version of our WebAssembly module.
Since the module was named test
, automatically generated functions are given test
prefix. This allows multiple modules to be used together in the same application without name collisions.
Let’s try compiling this to native code, using a C compiler.
zig cc test.c -I. -I"$W2C2_DIR"/include -L"$W2C2_DIR"/lib -lw2c2wasi
error: undefined reference to symbol_main
error: undefined reference to symbol _wasiMemory
note: referenced in /private/tmp/w2c2/lib/libw2c2wasi.a(wasi.c.o)
error: undefined reference to symbol_trap
note: referenced in /Users/j/.cache/zig/o/5868ac77d407c9e2eae52a37fd79e706/test.o
The code contains our translated functions, but we still need to instantiate the module and call functions in order to get an application we can actually run.
Let’s create a main.c
file to do so:
#include <stdio.h>
#include <stdlib.h>
#include <w2c2_base.h>
#include <wasi.h>
#include "test.h"
void trap(Trap trap)
{
fprintf(stderr, "TRAP: %s\n", trapDescription(trap));
abort();
}
wasmMemory *wasiMemory(void *instance)
{
return test_memory((testInstance *) instance);
}
extern char **environ;
int main(int argc, char *argv[])
{
testInstance i;
testInstantiate(&i, NULL);
if (!wasiInit(argc, argv, environ)) {
fprintf(stderr, "failed to initialize WASI\n");
return 1;
}
test__start(&i);
testFreeInstance(&i);
return 0;
}
In WebAssembly commands, the entry point is named _start()
. Functions transpiled to C are named <module name>_<function name>
, so in order to call the _start()
function, we simply call test__start()
with the instance as an argument.
Now that we have a main function, let’s compile all this, and link the w2c2
WASI-core implementation by the way since our test example uses it.
zig cc -o test -O3 -s main.c test.c \
-I. -I"$W2C2_DIR"/include/w2c2 -I"$W2C2_DIR"/include/wasi \
-L"$W2C2_DIR"/lib -lw2c2wasi
Done!
We can now run our application:
./test
All your codebase are belong to us.
Yep, that’s it! Our WebAssembly module got converted into a small, self-contained, native executable. Executable size and memory usage are ridiculously small compared to traditional runtimes.
How about speed?
Certainly, code blindly transpiled to C cannot be as efficient as a dedicated WebAssembly compiler, right?
Let’s compile the famous libsodium test/benchmark suite to WebAssembly:
zig build -Denable_benchmarks -Dtarget=wasm32-wasi -Doptimize=ReleaseFast \
-Dcpu=baseline+bulk_memory+sign_ext+nontrapping_fptoint
The resulting tests are placed into the zig-out/bin
directory.
Now, let’s try a pretty heavy one, such as the Ed25519 signature test: zig-out/bin/sign.wasm
.
The tests print the time they take to complete, excluding initialization and finalization.
First, with wasmtime
, enabling all the supported extensions:
❯ time wasmtime run --wasm-features=all test.wasm
625928560000
wasmtime run --wasm-features=all /tmp/test.wasm 124.76s user 0.02s system 99% cpu 2:05.21 total
Then, after transpiling the module to C using w2c2
and compiling it with zig cc
:
❯ time ./test
361604070000
./test 73.38s user 0.01s system 99% cpu 1:13.65 total
You got that right. A naive transpilation to C almost always beats dedicated WebAssembly compilers, sometimes by a large margin. That may change over time, though.
Downsides
Of course, there are downsides. An obvious one being that this is incompatible with just-in-time compilation or singlepass compilation. Compilation is not fast, since an initial transpilation pass is required.
The wasm-to-C approach would thus not be a good fit for running arbitrary code in a Web browser.
But no matter what the runtime is, with the wasm32
target, memory accesses are limited to 32-bit offsets.
Taking advantage of this and virtual memory, runtimes usually add guard pages around each instance’s memory in order to catch accesses outside the reserved areas.
But the code currently generated by w2c2
doesn’t automatically setup these guard pages. This is still left to the application.
As an alternative to guard pages, for wasm64
or on platforms without virtual memory, some traditional WebAssembly compilers can decorate memory accesses with bound-checking code. w2c2
currently can’t, but it totally could.
In spite of having excellent support for WebAssembly extensions, WASI-core and WASI-threads, w2c2
intentionally doesn’t have bells and whistles, some of them being critical for some applications. For example, traditional runtimes often support gas metering and preemption, which are mandatory for many applications.
Is it for you?
If the justification to use WebAssembly is the ability to improve code safety, then w2c2
(and the generic wasm-to-C approach) is for you. The result will be small, efficient and easy to use in any language with a C FFI.
On platforms not supported by big runtimes, w2c2
may also work for you, and the resulting native code will be faster than interpreters such as wasm3
.
For all other cases, traditional runtimes still offer way more features and flexibility.