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 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
)
)
Binaryen 工具链
Binaryen 工具链为 JavaScript 开发人员和命令行用户提供了许多有用的工具。以下列出了这些工具的子集;包含工具的完整列表可在项目的 README
文件中找到。
binaryen.js
:一个独立的 JavaScript 库,它公开了 Binaryen 方法,用于创建和优化 Wasm 模块。有关构建,请参阅 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 的 bundler,但用于 Wasm。
编译为 WebAssembly
将一种语言编译为另一种语言通常涉及几个步骤,最重要的步骤如下列表所示
- 词法分析: 将源代码分解为标记。
- 语法分析: 创建抽象语法树。
- 语义分析: 检查错误并强制执行语言规则。
- 中间代码生成: 创建更抽象的表示形式。
- 代码生成: 转换为目标语言。
- 特定于目标的代码优化: 针对目标进行优化。
在 Unix 世界中,常用的编译工具是 lex
和 yacc
lex
(词法分析器生成器):lex
是一种生成词法分析器(也称为 lexer 或扫描器)的工具。它将一组正则表达式和相应的操作作为输入,并生成词法分析器的代码,该分析器识别输入源代码中的模式。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();
抽象语法树的每一行都包含一个三元组,由 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 中存在两种方法来获取 文本表示形式,即 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
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(从不需要的位置删除 break)删除的。
(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 审阅。