Binaryen: a compiler infrastructure for WebAssembly

Binaryen is a C++ compiler and toolchain library for WebAssembly. It is designed to make compiling to Wasm intuitive, fast, and effective. Binaryen exposes both a C API in a single header and a JavaScript API via binaryen.js. It accepts WebAssembly input as well as a general control flow graph for compilers that need that interface.

Binaryen's internal intermediate representation (IR) is a subset of WebAssembly that compiles down to actual Wasm. The IR uses compact data structures and is designed for completely parallel code generation and optimization, using all available CPU cores. The optimizer includes many passes targeting both code size and speed, including WebAssembly-specific optimizations that general-purpose compilers typically don't perform — effectively a form of Wasm minification.

AssemblyScript: a real-world Binaryen consumer

One notable user of Binaryen is AssemblyScript, which compiles a TypeScript-like language directly to WebAssembly. Using Binaryen as its backend, AssemblyScript converts its high-level input into textual Wasm like this example, which you can try in the playground.

export function add(a: i32, b: i32): i32 {
  return a + b;
}
(module
 (type $0 (func (param i32 i32) (result i32)))
 (memory $0 0)
 (export "add" (func $module/add))
 (export "memory" (memory $0))
 (func $module/add (param $0 i32) (param $1 i32) (result i32)
  local.get $0
  local.get $1
  i32.add
 )
)
The AssemblyScript playground showing the generated WebAssembly code based on the previous example.

Toolchain overview

The Binaryen project ships several tools beyond the core library. The complete set is documented in the project's README; key tools include:

  • binaryen.js: a standalone JavaScript library exposing Binaryen methods for creating and optimizing Wasm modules. Builds are available on npm, GitHub, and unpkg.
  • wasm-opt: loads WebAssembly and runs Binaryen IR optimization passes on the command line.
  • wasm-as and wasm-dis: assemble and disassemble WebAssembly.
  • wasm-ctor-eval: executes functions (or parts of functions) at compile time.
  • wasm-metadce: removes parts of Wasm files based on how the module is used.
  • wasm-merge: combines multiple Wasm files, connecting imports to exports — analogous to a bundler for JavaScript.

Compilation mechanics

Compiling a language generally proceeds through several stages:

  • Lexical analysis: breaking source into tokens
  • Syntax analysis: building an abstract syntax tree
  • Semantic analysis: checking for errors and enforcing language rules
  • Intermediate code generation: producing an abstract representation
  • Code generation: translating to the target language
  • Target-specific optimization: tuning for the platform

In Unix environments, lex and yacc are the classic tools for the first two stages. lex generates lexers that recognize patterns in input source; yacc generates parsers that typically output ASTs.

A minimal worked example

To see how Binaryen fits into the compilation pipeline, consider ExampleScript, a deliberately trivial synthetic language where functions are declared by concrete examples. Write an add() function by expressing 2 + 3; write multiply() by expressing 6 * 12.

ExampleScript's lexer is a single regular expression: /\d+\s*[\+\-\*\/]\s*\d+\s*/. A simplified AST is created via named capturing groups: /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/.

Commands are one per line, so the parser splits input by newline. This covers lexical, syntax, and semantic analysis:

export default class Parser {
  parse(input) {
    input = input.split(/\n/);
    if (!input.every((line) => /\d+\s*[\+\-\*\/]\s*\d+\s*/gm.test(line))) {
      throw new Error('Parse error');
    }

    return input.map((line) => {
      const { groups } =
        /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/gm.exec(
          line,
        );
      return {
        firstOperand: Number(groups.first_operand),
        operator: groups.operator,
        secondOperand: Number(groups.second_operand),
      };
    });
  }
}

Intermediate code generation with Binaryen.js

With the AST in place, the next step is creating an intermediate representation. First, create a new module:

const module = new binaryen.Module();

Each AST line is a triple of firstOperand, operator, secondOperand. For each of the four operators (+, -, *, /), add a function via Module#addFunction(). Its parameters are:

  • name: a string identifying the function
  • functionType: a Signature
  • varTypes: a Type[] for additional locals
  • body: an Expression

For ExampleScript's + operator, use Module#i32.add(), one of several integer operations. Addition needs two operands. To make the function callable, export it with Module#addFunctionExport().

module.addFunction(
  'add', // name: string
  binaryen.createType([binaryen.i32, binaryen.i32]), // params: Type
  binaryen.i32, // results: Type
  [binaryen.i32], // vars: Type[]
  //  body: ExpressionRef
  module.block(null, [
    module.local.set(
      2,
      module.i32.add(
        module.local.get(0, binaryen.i32),
        module.local.get(1, binaryen.i32),
      ),
    ),
    module.return(module.local.get(2, binaryen.i32)),
  ]),
);
module.addFunctionExport('add', 'add');

Processing the full AST yields four functions: add(), subtract(), multiply() based on the integer operations i32.add(), i32.sub(), i32.mul(), and divide() via Module#f64.div(), since ExampleScript also handles floating point results.

for (const line of parsed) {
      const { firstOperand, operator, secondOperand } = line;

      if (operator === '+') {
        module.addFunction(
          'add', // name: string
          binaryen.createType([binaryen.i32, binaryen.i32]), // params: Type
          binaryen.i32, // results: Type
          [binaryen.i32], // vars: Type[]
          //  body: ExpressionRef
          module.block(null, [
            module.local.set(
              2,
              module.i32.add(
                module.local.get(0, binaryen.i32),
                module.local.get(1, binaryen.i32)
              )
            ),
            module.return(module.local.get(2, binaryen.i32)),
          ])
        );
        module.addFunctionExport('add', 'add');
      } else if (operator === '-') {
        module.subtractFunction(
          // Skipped for brevity.
        )
      } else if (operator === '*') {
          // Skipped for brevity.
      }
      // And so on for all other operators, namely `-`, `*`, and `/`.

To demonstrate how dead code gets eliminated later, add a non-exported function that never gets called:

// This function is added, but not exported,
// so it's effectively dead code.
module.addFunction(
  'deadcode', // name: string
  binaryen.createType([binaryen.i32, binaryen.i32]), // params: Type
  binaryen.i32, // results: Type
  [binaryen.i32], // vars: Type[]
  //  body: ExpressionRef
  module.block(null, [
    module.local.set(
      2,
      module.i32.div_u(
        module.local.get(0, binaryen.i32),
        module.local.get(1, binaryen.i32),
      ),
    ),
    module.return(module.local.get(2, binaryen.i32)),
  ]),
);

Before finishing, validate the module with Module#validate():

if (!module.validate()) {
  throw new Error('Validation error');
}

Producing and inspecting the output

Binaryen provides two ways to obtain output: a textual .wat representation in S-expression form for humans, and a binary .wasm file for direct execution in the browser. Logging the exports verifies the module contents:

const textData = module.emitText();
console.log(textData);

const wasmData = module.emitBinary();
const compiled = new WebAssembly.Module(wasmData);
const instance = new WebAssembly.Instance(compiled, {});
console.log('Wasm exports:\n', instance.exports);

The full textual form for a program with all four ExampleScript operations shows that dead code remains present in the module but is not exposed via WebAssembly.Module.exports():

(module
 (type $0 (func (param i32 i32) (result i32)))
 (type $1 (func (param f64 f64) (result f64)))
 (export "add" (func $add))
 (export "subtract" (func $subtract))
 (export "multiply" (func $multiply))
 (export "divide" (func $divide))
 (func $add (param $0 i32) (param $1 i32) (result i32)
  (local $2 i32)
  (local.set $2
   (i32.add
    (local.get $0)
    (local.get $1)
   )
  )
  (return
   (local.get $2)
  )
 )
 (func $subtract (param $0 i32) (param $1 i32) (result i32)
  (local $2 i32)
  (local.set $2
   (i32.sub
    (local.get $0)
    (local.get $1)
   )
  )
  (return
   (local.get $2)
  )
 )
 (func $multiply (param $0 i32) (param $1 i32) (result i32)
  (local $2 i32)
  (local.set $2
   (i32.mul
    (local.get $0)
    (local.get $1)
   )
  )
  (return
   (local.get $2)
  )
 )
 (func $divide (param $0 f64) (param $1 f64) (result f64)
  (local $2 f64)
  (local.set $2
   (f64.div
    (local.get $0)
    (local.get $1)
   )
  )
  (return
   (local.get $2)
  )
 )
 (func $deadcode (param $0 i32) (param $1 i32) (result i32)
  (local $2 i32)
  (local.set $2
   (i32.div_u
    (local.get $0)
    (local.get $1)
   )
  )
  (return
   (local.get $2)
  )
 )
)
DevTools Console screenshot of the WebAssembly module exports showing four functions: add, divide, multiply, and subtract (but not the not exposed dead code).

Optimizing compiled Wasm

Binaryen gives you two avenues for optimizing WebAssembly output. The simplest applies a standard optimization pipeline through Binaryen.js; the second, the wasm-opt command-line tool, exposes the full set of passes for fine-grained control. The trade-off is convenience versus flexibility: Binaryen.js uses default pass sets based on your chosen optimize and shrink levels, while wasm-opt starts from no passes at all and expects you to specify the ones you need.

Using Binaryen.js defaults

To optimize a module in JavaScript, call Module#optimize() and, if needed, set the optimize and shrink level first:

// Assume the `wast` variable contains a Wasm program.
const module = binaryen.parseText(wast);
binaryen.setOptimizeLevel(2);
binaryen.setShrinkLevel(1);
// This corresponds to the `-Os` setting.
module.optimize();

The effect is visible in the textual output for the toy ExampleScript code: the artificial dead code is gone, local.set/local.get pairs have been cleaned up by the SimplifyLocals and Vacuum passes, and the trailing return disappears thanks to RemoveUnusedBrs:

 (module
 (type $0 (func (param i32 i32) (result i32)))
 (type $1 (func (param f64 f64) (result f64)))
 (export "add" (func $add))
 (export "subtract" (func $subtract))
 (export "multiply" (func $multiply))
 (export "divide" (func $divide))
 (func $add (; has Stack IR ;) (param $0 i32) (param $1 i32) (result i32)
  (i32.add
   (local.get $0)
   (local.get $1)
  )
 )
 (func $subtract (; has Stack IR ;) (param $0 i32) (param $1 i32) (result i32)
  (i32.sub
   (local.get $0)
   (local.get $1)
  )
 )
 (func $multiply (; has Stack IR ;) (param $0 i32) (param $1 i32) (result i32)
  (i32.mul
   (local.get $0)
   (local.get $1)
  )
 )
 (func $divide (; has Stack IR ;) (param $0 f64) (param $1 f64) (result f64)
  (f64.div
   (local.get $0)
   (local.get $1)
  )
 )
)

Custom passes with wasm-opt

When binaryen.js’s default sets aren’t enough, wasm-opt is the tool of choice. It’s the most widely used Binaryen utility, integrated into Emscripten, J2CL, Kotlin/Wasm, dart2wasm, and wasm-pack. Run wasm-opt --help for the complete list of options. A quick example:

wasm-opt --help

Among the many passes, a handful have easily understood behavior:

  • CodeFolding: Eliminates duplicate instruction sequences by merging shared endings, for example at the tail of both arms of an if.
  • DeadArgumentElimination: A link-time optimization that removes a function parameter when every call site passes the same constant.
  • MinifyImportsAndExports: Renames imports and exports to minimal names like "a" and "b".
  • DeadCodeElimination: Removes unreachable code.

The optimizer cookbook has practical guidance on which flags to try first. Notably, repeating wasm-opt runs can shrink output further with each pass; the --converge flag automates this until the result stabilizes.

Binaryen’s JavaScript API and command-line tools make it a versatile base for compilers and optimization pipelines. The defaults cover most cases well; deeper tuning of custom passes is possible for those working at the internals level. 🎯