Binaryen 是 WebAssembly 的编译器和工具链基础架构库,采用 C++ 编写。其目标是让向 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 模拟环境中试用示例。
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
)
)
Binaryen 工具链
Binaryen 工具链为 JavaScript 开发者和命令行用户提供了许多实用工具。下面列出了其中一部分工具;包含的工具的完整列表可在项目的 README
文件中找到。
binaryen.js
:一个独立的 JavaScript 库,用于公开用于创建和优化 Wasm 模块的 Binaryen 方法。如需了解 build,请参阅 npm 上的 binaryen.js(或直接从 GitHub 或 unpkg 下载)。wasm-opt
:用于加载 WebAssembly 并在其上运行 Binaryen IR 传递的命令行工具。wasm-as
和wasm-dis
:用于汇编和反汇编 WebAssembly 的命令行工具。wasm-ctor-eval
:可在编译时执行函数(或函数的部分)的命令行工具。wasm-metadce
:一种命令行工具,可根据模块的使用方式以灵活的方式移除 Wasm 文件的部分内容。wasm-merge
:用于将多个 Wasm 文件合并到单个文件中的命令行工具,并在合并过程中将相应的导入连接到导出。就像 JavaScript 的捆绑工具,但适用于 Wasm。
编译为 WebAssembly
将一种语言编译为另一种语言通常涉及几个步骤,下表列出了最重要的步骤:
- 词法分析:将源代码拆分为词法单元。
- 语法分析:创建抽象语法树。
- 语义分析:检查错误并强制执行语言规则。
- 中间代码生成:创建更抽象的表示法。
- 代码生成:翻译成目标语言。
- 特定于目标的代码优化:针对目标进行优化。
在 Unix 世界中,常用的编译工具是 lex
和 yacc
:
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();
抽象语法树的每一行都包含一个由 firstOperand
、operator
和 secondOperand
组成的三元组。对于 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
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 代码,包括 Emscripten、J2CL、Kotlin/Wasm、dart2wasm、wasm-pack 等。
wasm-opt --help
为了让您对这些卡片有个大致了解,下面列出了一些无需专业知识即可理解的卡片摘要:
- CodeFolding:通过合并代码来避免代码重复(例如,如果两个
if
分支在末尾有一些共享的说明)。 - DeadArgumentElimination:如果函数始终使用相同的常量进行调用,则会移除函数的实参,此操作属于链接时间优化过程。
- MinifyImportsAndExports:将其缩减为
"a"
、"b"
。 - DeadCodeElimination:移除死代码。
我们提供了一份优化手册,其中提供了一些提示,可帮助您确定哪些标志更重要,值得优先尝试。例如,有时反复运行 wasm-opt
会进一步缩减输入。在这种情况下,使用 --converge
标志运行时,系统会一直迭代,直到不再进行进一步优化并达到固定点为止。
演示
如需查看本博文中介绍的概念的实际运作方式,请使用嵌入的演示版,为其提供您能想到的任何 ExampleScript 输入。此外,请务必查看演示的源代码。
总结
Binaryen 提供了一个强大的工具包,用于将语言编译为 WebAssembly 并优化生成的代码。其 JavaScript 库和命令行工具灵活且易于使用。本文演示了 Wasm 编译的核心原则,重点介绍了 Binaryen 的有效性和实现最大优化的潜力。虽然自定义 Binaryen 优化的许多选项都需要深入了解 Wasm 的内部结构,但通常默认设置已经非常不错了。祝您使用 Binaryen 编译和优化顺利!
致谢
此邮件已由 Alon Zakai、Thomas Lively 和 Rachel Andrew 审核。