使用 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 缩小。

以 Binaryen 的示例用户身份使用 AssemblyScript

许多项目都使用 Binaryen,例如 AssemblyScript,该项目使用 Binaryen 直接从类似 TypeScript 的语言编译到 WebAssembly。在 AssemblyScript 园地中试用示例

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 库,用于公开 Binaryen 方法,以创建和优化 Wasm 模块。对于 build,请参阅 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() 方法的参数如下:

  • name:一个 string,表示函数的名称。
  • functionType:一个 Signature,表示函数的签名。
  • varTypes:一个 Type[],按给定顺序指示其他语言区域。
  • 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 表达式的形式获取文本表示形式,即以人类可读的格式获取 .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 模块导出项,包括四个函数:add、divide、multiply 和 subtract(但不包括未公开的死代码)。

优化 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 玩具示例的 Wasm 版本的文本表示形式不再包含该死代码。另请注意,local.set/get 对是如何通过优化步骤 SimplifyLocals(各种与本地变量相关的优化)和 Vacuum(移除明显不需要的代码)移除的,以及 return 是如何通过 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)
  )
 )
)

有许多优化传递,并且 Module#optimize() 使用特定的优化和缩小级别的默认设置。如需进行全面自定义,您需要使用命令行工具 wasm-opt

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

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

wasm-opt --help

为了让您了解通行证,以下是一些无需专业知识即可理解的通行证示例:

  • CodeFolding:通过合并重复代码来避免代码重复(例如,如果两个 if 分支在末尾有一些共享指令)。
  • DeadArgumentElimination:一种链接时优化传递,用于移除函数的实参(如果该函数始终使用相同的常量进行调用)。
  • MinifyImportsAndExports:将其缩小为 "a""b"
  • DeadCodeElimination:移除不再需要的代码。

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

演示

如需查看本文中介绍的概念的实际应用,请试用嵌入式演示,向其提供您能想到的任何 ExampleScript 输入。另请务必查看演示的源代码

总结

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

致谢

此博文已由 Alon ZakaiThomas LivelyRachel Andrew 审核。