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 遊樂場中嘗試範例。
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
是產生字詞分析器 (也稱為字詞分析器或掃描器) 的工具。它會將一組規則運算式和對應的動作做為輸入內容,並為字彙分析器產生程式碼,以便辨識輸入原始碼中的模式。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-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
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 (各種與 locals 相關的最佳化步驟) 和 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 審查。