WebAssembly is an exciting new language that many JavaScript engines have added support for. WebAssembly promises to make it much easier to compile languages like C and C++ to something that runs in the browser. However, I'm most excited about the ability to write optimized custom arithmetic and buffer manipulations, like, say, fast decimal floating point arithmetic in JavaScript without having to wait for TC39 to get around to it. In this article, I'll show you how to get a couple rudimentary WebAssembly examples running in Node.js, and run a couple trivial benchmarks to show the performance impact.
Note that the code in this article is intended to be run with Node.js 12.14.0.
What is WebAssembly Anyway?
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
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, or you can just download the compiled .wasm
from me.
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
or just download it here.
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
$
Moving On
In these rudimentary examples, WebAssembly shows promise in terms of allowing you to really optimize JS code. My benchmarks are quite rudimentary and WebAssembly is still unstable and not well adopted, so don't rush to try to write your next web app in wast. However, now's the time to play with WebAssembly, especially since it is available in Node.js.
I wrote this article (and many others) after a dose of Ciltep (non-affiliate link here). Ciltep helps you stay focused and learn fast, without jitters or crashes. Give it a shot before you take a deep dive into WebAssembly.