Skip to content

Instantly share code, notes, and snippets.

@magcius
Last active December 19, 2017 15:59
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save magcius/8959ca1f5bbde4bd66b98b6d5dc73112 to your computer and use it in GitHub Desktop.
Save magcius/8959ca1f5bbde4bd66b98b6d5dc73112 to your computer and use it in GitHub Desktop.

WebAssembly: Kicking the Can Down the Road

Web development has always been kinda annoying, and we've always relied on frameworks like jQuery and Angular and now React to make it more palatable. Payload sizes have been growing dramatically in size, but there's always a new technology to help save the day. First minification, then gzip compression. HTTP2 and Server Push, tree shaking all had their times in the limelight, but we're still pushing more and more code into the client. Facebook and Mozilla recently got together to propose a binary AST to slim down sizes and reduce parse times. In the comments, there was an overarching theme: Why not WebAssembly? Don't we already have a binary script format for the web?

To dig into these questions, we'll have to take a closer look at WebAssembly and figure out what it actually is. And in order to take a closer look at WebAssembly, let's start with its predecessor...


asm.js is a formalized subset of JavaScript that several cross-compilers, like emscripten, use. And when I say subset, I mean subset. asm.js cuts out a lot of core JavaScript features. No objects, no strings, no "this". Flow control is limited to loops, if statements, and calling other functions. You can't nest functions or use closures. Variables themselves don't really store objects: they can only contain numbers (with a few exceptions -- we'll get into this later). A super simple example of asm.js in action might look like this:

function SimpleRot13() {
    "use asm"

    // Translates a single uppercase alphabetical character
    // in rot13 style. Takes a single ASCII code as input.
    function rot13char(char) {
        // All parameters in asm.js are required to have type declarations.
        char = char | 0

        // The below basically represents:
        //
        // if (char >= 'A' && char <= 'Z')
        //     return ((char - 'A' + 13) % 26 + 'A');
        // return char;

        if (((char | 0) >= 65) & ((char | 0) <= 90))
            return ((((((char | 0) - 52) | 0) % 26) | 0) + 65) | 0
        return char | 0
    }

    return { rot13char: rot13char }
}

function rot13(S) {
    const asmModule = SimpleRot13()
    return S.split('').map(c => String.fromCharCode(asmModule.rot13char(c.charCodeAt(0)))).join('')
}
console.log(rot13("OR FHER GB QEVAX LBHE BINYGVAR"))

You can try this code by copy/pasting it right now into the developer console on this page.

A few things might pop out at you:

  • asm.js code is littered with | 0 things. It's very dense and hard for a human to parse. Pretty much every single arithmetic operation generates what's known a type known as an "intish", which can't be further used in more arithmetic without casting to another type. The | 0 notation is a way to cast from anything to a signed integer.

  • The type declaration seems to only be about maintaining storage and checking that nothing is polymorphic. All variables appear to come out as intish upon use and require further casting when actually using them. Basic type propagation doesn't appear to exist at all. This, again, makes it extremely inconvenient for humans to write.

  • I'm using comments, and whitespace, and omitting semicolons. People get the impression that asm.js has a stricter syntax than JavaScript, but that's only partially true. asm.js is specified in terms of the standardized JavaScript AST, which is after parsing. So anything before that, like automatic semicolon insertion, is kosher and allowed.

  • It's hard to spot, but boolean operators like "&&" are a no-no. I have to abuse bitwise "&" for this purpose in my code. A more traditional language, looking to short-circuit these operations would unwind this logic into nested if statements.

  • There's a defined format to valid asm.js "modules", as they are called. A function that "uses asm", which has a standardized function signature, it always returns an object of functions, etc. I tried using the new { foo, bar } declaration for the return, but it failed validation as it wasn't a valid AST production. The validation is extremely strict.

  • I'm only handling numbers. Everything else has to be done by "wrapper JavaScript" code which converts things from numbers into useful things. The object construction at the end is something special and can only export functions that were defined directly above.

I also mentioned something about arrays of numbers. Here's something a bit more fancy that uses those:

function FancierRot13(stdlib, foreign, heap) {
    "use asm"

    // "Cast" the untyped ArrayBuffer into an array of unsigned bytes.
    var heap8 = new stdlib.Uint8Array(heap);

    function rot13char(char) {
        // As above.
        char = char | 0
        if (((char | 0) >= 65) & ((char | 0) <= 90))
            return ((((((char | 0) - 52) | 0) % 26) | 0) + 65) | 0
        return char | 0
    }

    function rot13() {
        // Iterate over the heap and rot13 all the characters inside.

        // char *p = heap;
        // while (*p != '\0') {
        //    *p = rot13char(*p);
        //    p++;
        // }
        // *p = '\0';

        var p = 0
        while ((heap8[p] | 0) != 0) {
            heap8[p] = rot13char(heap8[p] | 0) | 0
            p = ((p | 0) + 1) | 0
        }
        heap8[p] = 0;
    }

    return { rot13char: rot13char, rot13: rot13 }
}

function rot13(S) {
    const heap = new ArrayBuffer(0x10000)
    const asmModule = FancierRot13(window, null, heap)

    const heap8 = new Uint8Array(heap);
    function strToHeap(S) {
        for (let i = 0; i < S.length; i++)
            heap8[i] = S.charCodeAt(i)
    }
    function heapToStr() {
        let S = '';
        for (let i = 0; heap8[i] != 0; i++)
            S += String.fromCharCode(heap8[i])
        return S
    }
    strToHeap(S)
    asmModule.rot13()
    return heapToStr()
}
console.log(rot13("OR FHER GB QEVAX LBHE BINYGVAR"))

This does quite a bit more and uses "the heap", which is a large ArrayBuffer of linear memory. In a more traditional compiler target setting, this is where things allocated through malloc and such are stored. There's also a decent chunk of JS wrapper code dedicated to parsing the output of the asm.js function. This is very normal and kind of a big deal: the more communication you want asm.js to do with the actual JavaScript host, the more work you need to do on both sides to communicate over the giant ArrayBuffer. It is possible for asm.js to call host functions through that "foreign" argument, but only if, again, all arguments are numbers. JS code can interpret that number as an index to the heap.

WebAssembly is a very close cousin to asm.js. I've described it as a sort of "binary palette-swap of asm.js" to friends in the past, which is admittedly unfair. WebAssembly did start out as a binary serialization of asm.js, after all. The simple way to imagine it is to look at my rot13 example above, and then do this transformation: swap out my (p | 0) with get_local $p opcodes, swap out p = ...; for set_local $p, heap8[p] for i32.load8_s $p (which loads a signed 8-bit value the heap into a 32-bit integer local), and then swap out the arithmetic operations with their wasm equivalent, and flow control with theirs. In fact, the process is so simple, it can be automated. The WebAssembly team even built asm2wasm, a tool to do it for you.


It's easy to be tricked by similar-sounding words. "Bytecode", "VM", "JIT" might make you think that WebAssembly is similar to JavaScript, or maybe the JVM. But, unfortunately, those VMs work on an "object model". They pass around references to objects, and you can pass them to functions, get and set fields on them, and even pass around first-class functions. WebAssembly takes a different approach: you get a giant chunk of linear memory full of numbers, and it's up to you to ascribe meaning to the bits in them. This is extremely similar to the memory and object model preferred by C and low-level languages. It's certainly possible to implement one in the other, but not always easy or performant to do so, especially when you have an entire platform layer above you written in an object model.

Currently, you have to have some JavaScript manually parse that out of a giant array of numbers and convert that to something usable. Some don't even realize that this is done for you when you follow a typical "WebAssembly tutorial". emscripten, the famous C++-to-JavaScript compiler that started this whole trend, comes with a giant selection of "library" files implementing popular C APIs in JavaScript. And often times the performance bottlenecks aren't in your C++ code, but in these pieces of necessary JavaScript. Seriously, the default OpenGL implementation isn't just a binding to WebGL, but a nearly 8,000 line-of-code desktop GL emulator.


Now, that's not to say there aren't additional features or benefits to WebAssembly. One huge addition is the introduction of an actual int64 type, which I welcome very happily. And spec proposals and improvements for WebAssembly to let them reference the DOM or other APIs are on the way, in the form of the host-bindings and gc proposals.

But I suspect that there will be performance issues where WebAssembly could be slower than JS if not done carefully.... "Slower? Isn't WebAssembly supposed to be fast?" I hear some ask. Well, yes.

Parsing speed and compilation speed can indeed be faster than JavaScript, but the verbosity and difficulty of working in linear memory means that for some use cases, the wins aren't obvious. Additionally, the hard parts of optimizing JS isn't the tight loops, those are optimized just fine, it's the calls back into C++ code, where you have to go from your nice hot, inlined path back to C++ calling conventions, spilling registers on the stack and rearranging everything back in working order. It's why Array.indexOf used to be slower than a for loop in most engines, despite indexOf being "native code". The JS engines mostly fixed this by rewriting Array.indexOf in some form of special JavaScript which lets their JIT compiler do the work.

WebAssembly has this problem, but it has it ten-fold. The current host-binding proposal is extremely verbose and will be likely be slow. Instead of the modern inlined fast paths for DOM getters, host-bindings instead has a slow call to Reflect.get that won't be inlined. Bouncing back and forth between native C++ and WebAssembly, without a way to inline or speed things up will certainly become very expensive over time. And tooling for WebAssembly, like profilers, currently either aren't there or aren't accurately reporting things like this.

GC is also in a bit of a rut. See, there's a few goals for the GC proposal:

  1. Reuse the existing JavaScript GCs and tie in closely with them.
  2. Don't hurt the performance of existing JS on the web.
  3. Don't expose "GC-observable behavior" to the web, even through WebAssembly. Destroying garbage can't have any visible effects.
  4. Keep it fast and flexible to support not only GC objects referenced through code compiled to WASM, but also allow that GC to support other languages that might need it. But again, without observable behavior.

This is a hard, dare I say nearly impossible problem, and it's one that's necessary for the health of the WASM ecosystem. I don't envy any of the engineers working on it. It's one that they tried (and failed) to solve inside asm.js. It's easier to introduce a new binary format than solve the hard problems that need to be solved. WebAssembly is Kicking the Can Down the Road.

Now, there certainly are benefits to WebAssembly's performance profile: it's flat. It's deterministic. It's also pretty easy to AOT and the optimizations are a lot more obvious. Unlike V8 where the speed tricks change from month to month as they optimize their engine, I expect most WASM implementations will be fairly constant in their approach. What this means is that it's on us, the developers, to take special care and make sure we're generating the best code we can. Call me cynical, but even now that Google can't seem to get Gmail to boot in under five seconds for me, I wouldn't necessarily trust the stewards of the web to really champion and push for this.

I'm also not a hater. One oft-quoted response to my WebAudio rant has been: "why not go and help out, if you think you know better?" So I have been. I contribute and provide my feedback (however minimal) on the host-bindings specification and others. I want WebAssembly to work, since it does provide some tremendous benefits and it is a super cool compile target. But we need to stop ascribing magical properties to it, like "... instead of compiling TypeScript to JavaScript, its developers could now compile to WebAssembly." with no rationale for why that would improve performance for TypeScript, or help it as a language.

I want to see hard numbers in the wild. I want to see accurate assessments of tradeoffs of a new piece of technology, rather than being taken in by the hype because it's shiny and new. I want awareness on what WebAssembly does, what emscripten's runtime does, so that people don't get fooled into believing that WebAssembly is an almighty god of a VM that will take over all programming, ever. It won't magically make our phones use less battery. "Binary bytecode VM JIT" isn't some mystical incantation that lowers battery life — there's hard engineering problems involved. Semantics, memory and object models, these things matter, greatly, to the performance characteristics of such a VM. WebAssembly has its heart in the right place, but it's going to be a long, storied battle. As engineers, let's try to use our efforts to help this along.

P.S. For a more thorough and complete example of WebAssembly and asm.js, please observe my hand-written Brainfuck interpreter.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment