使用 Binaryen 编译和优化 Wasm

Binaryen 是采用 C++ 编写的 WebAssembly 编译器和工具链基础架构库。它旨在使编译到 WebAssembly 的过程直观、快速且有效。在这篇博文中,我们将通过名为 ExampleScript 的合成玩具语言示例,了解如何使用 Binaryen.js API 以 JavaScript 编写 WebAssembly 模块。您将了解以下方面的基础知识:创建模块、向模块添加函数以及从模块导出函数。这有助于您了解将实际编程语言编译为 WebAssembly 的整体机制。此外,您还将了解如何使用 Binaryen.js 在命令行中使用 wasm-opt 优化 Wasm 模块。

Binaryen 相关背景信息

Binaryen 在单个头文件中提供了直观的 C API,也可在 JavaScript 中使用。它接受 WebAssembly 表单的输入,但也接受首选的编译器的常规控制流图

中间表示法 (IR) 是指编译器或虚拟机在内部用来表示源代码的数据结构或代码。Binaryen 的内部 IR 使用紧凑的数据结构,旨在使用所有可用的 CPU 核心进行完全并行的代码生成和优化。Binaryen 的 IR 会编译为 WebAssembly,因为它是 WebAssembly 的子集。

Binaryen 的优化器提供了许多途径,可以改进代码大小和速度。这些优化旨在使 Binaryen 足够强大,可以单独用作编译器后端。它包含特定于 WebAssembly 的优化(通用编译器可能不这样做),您可以将其视为 Wasm 缩减大小。

以 AssemblyScript 作为 Binaryen 的示例用户

许多项目都使用 Binaryen,例如 AssemblyScript,它使用 Binaryen 将类似 TypeScript 的语言直接编译为 WebAssembly。在 AssemblyScript Playground 中试用该示例

AssemblyScript 输入:

export function add(a: i32, b: i32): i32 {
  return a + b;
}

由 Binaryen 生成的文本形式的相应 WebAssembly 代码:

(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
 )
)

AssemblyScript Playground,显示了根据上一个示例生成的 WebAssembly 代码。

Binaryen 工具链

Binaryen 工具链为 JavaScript 开发者和命令行用户提供了许多实用工具。下面列出了这些工具中的一部分;您可以在项目的 README 文件中找到所含工具的完整列表

  • binaryen.js:一个独立的 JavaScript 库,可提供用于创建和优化 Wasm 模块的 Binaryen 方法。对于构建,请参阅 npm 上的 binaryen.js(或直接从 GitHubunpkg 下载)。
  • wasm-opt:用于加载 WebAssembly 并运行 Binaryen IR 通道的命令行工具。
  • wasm-aswasm-dis:用于组建和反汇编 WebAssembly 的命令行工具。
  • wasm-ctor-eval:可在编译时执行函数(或函数的某些部分)的命令行工具。
  • wasm-metadce:命令行工具以灵活方式移除 Wasm 文件的某些部分,具体取决于模块的使用方式。
  • wasm-merge:一种命令行工具,可将多个 Wasm 文件合并为单个文件,同时将相应的导入与导出连接起来。类似于 JavaScript 的捆绑器,但适用于 Wasm。

编译为 WebAssembly

将一种语言编译为另一种语言通常涉及几个步骤,其中列出了最重要的步骤:

  • 词法分析:将源代码分解为词元。
  • 语法分析:创建抽象语法树。
  • 语义分析:检查是否存在错误并强制执行语言规则。
  • 中间代码生成:创建一个更抽象的表示法。
  • 代码生成:翻译成目标语言。
  • 针对特定目标的代码优化:针对目标进行优化。

在 Unix 领域,常用的编译工具是 lexyacc

  • lex(词汇分析器生成器)lex 是一种可生成词法分析器(也称为词法分析器或扫描器)的工具。它接受一组正则表达式和相应的操作作为输入,并为识别输入源代码中的模式的词法分析器生成代码。
  • yacc (Yet Another Compiler Compiler)yacc 是一种生成解析器以进行语法分析的工具。它接受编程语言的正式语法描述作为输入,并为解析器生成代码。解析器通常会生成表示源代码层次结构的抽象语法树 (AST)。

有效示例

鉴于本博文的范围,不可能涵盖完整的编程语言,因此为简单起见,请考虑使用一种非常有限且无用的合成编程语言,名为 ExampleScript,该语言通过具体示例表达通用操作。

  • 如需编写 add() 函数,您需要编写一个任意加法运算的示例,例如 2 + 3
  • 例如,如需编写 multiply() 函数,您应编写 6 * 12

根据预警告,这完全没有用,但足够简单,其词法分析器可以成为单个正则表达式:/\d+\s*[\+\-\*\/]\s*\d+\s*/

接下来,需要创建一个解析器。实际上,使用包含已命名捕获组的正则表达式,即可创建非常简化的抽象语法树:/(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/

ExampleScript 命令为每行一个,因此解析器可以通过拆分换行符来逐行处理代码。这足以让您查看之前项目符号列表中的前三个步骤,即词法分析、语法分析和语义分析。这些步骤的代码如下所示。

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),
      };
    });
  }
}

中间代码生成

现在,ExampleScript 程序可以用抽象语法树表示(尽管它相当简化),下一步是创建抽象中间表示法。第一步是在 Binaryen 中创建一个新模块

const module = new binaryen.Module();

抽象语法树的每一行都包含一个由 firstOperandoperatorsecondOperand 组成的三元组。对于 ExampleScript 中四个可能的运算符(即 +-*/)中的每一项,都需要使用 Binaryen 的 Module#addFunction() 方法向模块添加新函数Module#addFunction() 方法的参数如下所示:

  • namestring,表示函数的名称。
  • functionTypeSignature,表示函数的签名。
  • varTypesType[],表示按给定顺序排列的其他本地值。
  • body:一个 Expression,即函数的内容。

您还需要展开和分解一些细节,Binaryen 文档可以帮助您浏览相关空间,但对于 ExampleScript 的 + 运算符,您最终会作为多个可用的整数操作之一使用 Module#i32.add() 方法。加法需要两个运算数,第一个和第二个求和。要使函数实际上可以调用,需要使用 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');

处理抽象语法树之后,该模块包含四个方法,其中三个使用整数,即基于 Module#i32.add()add()、基于 Module#i32.sub()subtract()、基于 Module#i32.mul()multiply() 和基于 Module#f64.div() 的离群值 divide(),因为 ExampleScript 也处理浮点结果。

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 `/`.

如果处理实际代码库,有时会有永远未被调用的死代码。如需在 ExampleScript 编译到 Wasm 的运行示例中人为引入死代码(这些代码将在后续步骤中进行优化和消除),只需添加非导出函数即可。

// 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)),
  ]),
);

现在,编译器即将准备就绪。虽然并非绝对必要,但使用 Module#validate() 方法验证模块绝对是很好的做法。

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

获取生成的 Wasm 代码

为了获取生成的 Wasm 代码,Binaryen 提供了两种方法,用于将文本表示法获取为 S-expression 中的 .wat 文件(作为人类可读的格式),以及二进制表示法作为可以直接在浏览器中运行的 .wasm 文件。二进制代码可以直接在浏览器中运行。若要看到它是否有效,记录导出会有所帮助。

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);

下面列出了 ExampleScript 程序及其所有四个操作的完整文本表示形式。请注意,死代码仍然存在,但它并未公开(如 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)
  )
 )
)

开发者工具控制台的 WebAssembly 模块导出屏幕截图,其中显示了以下四个函数:加法、除法、乘法和减法(但不包括未公开的死代码)。

优化 WebAssembly

Binaryen 提供了两种优化 Wasm 代码的方法。一个位于 Binaryen.js 中,另一个用于命令行。前者默认应用一组标准的优化规则,并允许您设置优化和缩减级别,而后者默认不使用任何规则,但允许进行全面自定义,这意味着,有了足够的实验,您就可以根据代码定制设置以获得最佳效果。

使用 Binaryen.js 进行优化

使用 Binaryen 优化 Wasm 模块的最直接方法是直接调用 Binaryen.js 的 Module#optimize() 方法,并视需要设置优化和缩减级别

// 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();

这样做会移除之前人为引入的死代码,因此 ExampleScript 玩具示例的文本表示形式不再包含它。另请注意,以下优化步骤如何移除 local.set/get 对:SimplifyLocals(其他与局部相关的优化)和 Vacuum(移除明显不需要的代码),以及 RemoveUnusedBrs 移除 return(移除不需要的位置的广告插播时间点)。

 (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)
  )
 )
)

存在许多优化通道,而 Module#optimize() 会使用特定优化和缩减级别的默认集。要进行全面自定义,您需要使用命令行工具 wasm-opt

使用 wasm-opt 命令行工具进行优化

为了全面自定义要使用的卡券,Binaryen 包含 wasm-opt 命令行工具。如需获取所有可能的优化选项的完整列表,请查看该工具的帮助消息。wasm-opt 工具可能是最常用的工具,并被多种编译器工具链用于优化 Wasm 代码,包括 EmscriptenJ2CLKotlin/Wasmdart2wasmwasm-pack 等。

wasm-opt --help

为了帮助您大致了解这些卡券,下面摘录了其中一些无需专业知识也能理解的卡券:

  • CodeFolding:通过合并代码来避免重复代码(例如,如果两个 if 分支末尾具有一些共享指令)。
  • DeadArgumentEliuation:链接时优化传递,用于移除始终使用相同的常量调用的函数。
  • MinifyImportsAndExports:将其缩减为 "a""b"
  • DeadCodeEliality:移除死代码。

我们提供了优化实战宝典,其中包含一些提示,可帮助您确定哪些标志更重要,值得先尝试。例如,有时反复反复运行 wasm-opt 会进一步缩减输入大小。在这种情况下,使用 --converge 标志运行会不断迭代,直到不再发生优化并达到固定点。

演示

如需查看这篇博文中介绍的概念的实际运用,请使用嵌入式演示,提供您能想到的任何 ExampleScript 输入。此外,请务必查看演示的源代码

总结

Binaryen 提供了一个强大的工具包,用于将语言编译为 WebAssembly,并优化生成的代码。其 JavaScript 库和命令行工具具有灵活性和易用性。这篇博文介绍了 Wasm 编译的核心原则,并着重介绍了 Binaryen 的有效性以及实现最大优化的潜力。虽然自定义 Binaryen 优化的许多选项都需要对 Wasm 的内部原理有深入了解,但默认设置通常已经非常实用。现在,您就可以使用 Binaryen 进行编译和优化了!

致谢

这篇博文由 Alon ZakaiThomas LivelyRachel Andrew 审核。