Using WebAssembly in Node.js
I’ll demonstrate how to get a few of basic WebAssembly examples working in Node.js, as well as run a number of small benchmarks to demonstrate the performance impact.
Please keep in mind that the code in this tutorial is designed to be run with Node.js 12.14.0 or higher.
How to use WebAssembly with Node.js
Node.js 12 has a global WebAssembly
object which has several helper functions for creating WebAssembly modules. For the purposes of this article, a WebAssembly module is just a collection of functions written in WebAssembly.
$ ~/Workspace/libs/node-v12.14.0-darwin-x64/bin/node
Welcome to Node.js v12.14.0.
Type ".help" for more information.
> WebAssembly
Object [WebAssembly] {
compile: [Function: compile],
validate: [Function: validate],
instantiate: [Function: instantiate]
}
>
To create a WebAssembly module, you need to call WebAssembly.instantiate()
with a Uint8Array that represents the module. Below is an example of instantiating an empty WebAssembly module.
$ ~/Workspace/libs/node-v12.14.0-darwin-x64/bin/node
> WebAssembly.instantiate(new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]));
Promise { <pending> }
>
So at the basic level, creating a WebAssembly module consists of putting the correct hex digits into the instantiate()
function. What do these hex numbers mean? These hex numbers are the preamble that every .wasm
file starts with (.wasm
is the canonical extension for WebAssembly files). Every WebAssembly file must have these bytes, so this is the minimum viable WebAssembly module.
Adding Two Numbers with Wasm
Thankfully, you don’t have to write the bytes yourself. There’s plenty of compilers out there for compiling C, C++, and even Rust to WebAssembly. There’s also an intermediate format called “WebAssembly AST”, or “wast” for short. Here’s what a function that returns the sum of its 2 parameters looks like in wast:
(module
(func (export "addTwo") (param i32 i32) (result i32)
local.get 0
local.get 1
i32.add))
You can use this online tool to compile wast code down into the wasm
binary.
Next, how do you use a .wasm
file in Node.js? In order to use the .wasm
, you need to load the file and convert the Node.js buffer that node’s fs
library returns into an Uint8Array.
const fs = require('fs');
const buf = fs.readFileSync('./addTwo.wasm');
const lib = await WebAssembly.instantiate(new Uint8Array(buf)).
then(res => res.instance.exports);
console.log(lib.addTwo(2, 2)); // Prints '4'
console.log(lib.addTwo.toString()); // Prints 'function addTwo() { [native code] }'
How fast is addTwo
in WebAssembly versus a plain old JavaScript implementation? Here’s a trivial benchmark:
const fs = require('fs');
const buf = fs.readFileSync('./addTwo.wasm');
const lib = await WebAssembly.instantiate(new Uint8Array(buf)).
then(res => res.instance.exports);
const Benchmark = require('benchmark');
const suite = new Benchmark.Suite;
suite.
add('wasm', function() {
lib.addTwo(2, 2);
}).
add('js', function() {
addTwo(2, 2);
}).
on('cycle', function(event) {
console.log(String(event.target));
}).
on('complete', function() {
console.log('Fastest is ' + this.filter('fastest').map('name'));
}).
run();
function addTwo(a, b) {
return a + b;
}
$ ~/Workspace/libs/node-v12.14.0-darwin-x64/bin/node ./test
wasm x 91,797,305 ops/sec ±1.26% (88 runs sampled)
js x 763,373,634 ops/sec ±2.28% (89 runs sampled)
Fastest is js
$
Factorial
WebAssembly doesn’t have any performance benefit over plain old JS in the above example. Let’s do something a little more complex: computing factorials recursively. Here’s a .wast
file that exposes a fac()
function which computes factorial recursively.
(module
(func $_factorial (param i32) (result i32)
(if (i32.lt_s (get_local 0) (i32.const 1))
(then (return (i32.const 1)))
(else
(return (i32.mul (get_local 0) (call $_factorial (i32.sub (get_local 0) (i32.const 1))))))
)
(return (i32.const 1))
)
(func (export "factorial") (param i32) (result i32)
(return (call $_factorial (get_local 0)))
))
You can use this tool to compile the .wasm
.
Below is another trivial benchmark comparing computing 100!
with WebAssembly versus with JavaScript:
const fs = require('fs');
const buf = fs.readFileSync('./factorial.wasm');
const lib = await WebAssembly.instantiate(new Uint8Array(buf)).
then(res => res.instance.exports);
const Benchmark = require('benchmark');
const suite = new Benchmark.Suite;
suite.
add('wasm', function() {
lib.factorial(100);
}).
add('js', function() {
fac(100);
}).
on('cycle', function(event) {
console.log(String(event.target));
}).
on('complete', function() {
console.log('Fastest is ' + this.filter('fastest').map('name'));
}).
run();
function fac(n) {
if (n <= 0) {
return 1;
}
// `x | 0` rounds down, so `2.0001 | 0 === 2`. This helps deal with floating point precision issues like `0.1 + 0.2 !== 0.3`
return (n * fac(n - 1)) | 0;
}
$ ~/Workspace/libs/node-v12.14.0-darwin-x64/bin/node ./test
wasm x 2,214,005 ops/sec ±5.45% (84 runs sampled)
js x 1,019,134 ops/sec ±3.60% (84 runs sampled)
Fastest is wasm
$
Conclusion
WebAssembly demonstrates promise in terms of allowing you to truly optimize JS code in these elementary examples. My benchmarks are fairly basic, and WebAssembly is still in its infancy and not widely adopted, so don’t rush into writing your next web application in wasm. However, now is the time to experiment with WebAssembly, particularly since that it is included in Node.js.