使用 Binaryen 编译和优化 Wasm

Binaryen 是 WebAssembly 的编译器和工具链基础架构库,采用 C++ 编写。其目标是让向 WebAssembly 编译变得直观、快速且高效。在本文中,我们将通过一个名为 ExampleScript 的合成玩具语言示例,介绍如何使用 Binaryen.js API 在 JavaScript 中编写 WebAssembly 模块。您将了解创建模块、向模块添加函数以及从模块导出函数的基础知识。通过本课程,您将了解将实际编程语言编译为 WebAssembly 的整体机制。此外,您还将学习如何使用 Binaryen.js 和在命令行中使用 wasm-opt 优化 Wasm 模块。

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 模拟环境中试用示例

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 方法。如需了解 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(又一个编译器编译器)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 中提供了两种方法,用于将文本表示法作为 .wat 文件以 S 表达式的人类可读格式获取,并将二进制表示法作为 .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 玩具示例的 Wasm 版本的文本表示法不再包含该代码。另请注意,优化步骤 SimplifyLocals(与局部变量相关的各种优化)和 Vacuum(移除明显不需要的代码)如何移除 local.set/get 对,以及 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 分支在末尾有一些共享的说明)。
  • DeadArgumentElimination:如果函数始终使用相同的常量进行调用,则会移除函数的实参,此操作属于链接时间优化过程。
  • MinifyImportsAndExports:将其缩减为 "a""b"
  • DeadCodeElimination:移除死代码。

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

演示

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

总结

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

致谢

此邮件已由 Alon ZakaiThomas LivelyRachel Andrew 审核。