使用 Binaryen 編譯 Wasm 並進行最佳化

Binaryen 是以 C++ 編寫的 WebAssembly 編譯器和工具鍊基礎架構程式庫,其目的在於讓編譯 WebAssembly 變得簡單、快速且有效。在本文中,我們使用名為 ExampleScript 的合成玩具語言範例,並說明如何使用 Binaryen.js API,在 JavaScript 中編寫 WebAssembly 模組。您將瞭解模組建立、模組新增至函式,以及從模組匯出函式的基本概念。這可讓您瞭解將實際程式設計語言編譯至 WebAssembly 的整體機制。此外,您還會學到如何使用 Binaryen.js 和在指令列中利用 wasm-opt 最佳化 Wasm 模組。

二進位制背景

Binaryen 在單一標頭中提供直覺式的 C API,也可以透過 JavaScript 使用。它接受 WebAssembly 表單的輸入內容,但也接受偏好的編譯器使用一般控制流程圖

中繼表示法 (IR) 是指編譯器或虛擬機器內部用於代表原始碼的資料結構或程式碼。Binaryen 的內部 IR 採用精簡資料結構,並且使用所有可用的 CPU 核心,完全平行處理程式碼產生和最佳化作業。由於是 WebAssembly 的子集,因此 Binaryen 的 IR 將編譯為 WebAssembly。

二進位制最佳化器包含許多票證,可改善程式碼大小和速度。這些最佳化的目標是讓二進位檔功能強大,可單獨做為編譯器使用。其中包含 WebAssembly 專屬的最佳化功能 (一般用途編譯器可能不會採用),您可以將其視為 Wasm 壓縮。

AssemblyScript 做為 Binaryen 的示例使用者

許多專案 (例如 AssemblyScript) 會使用二進位檔,這個指令碼使用 Binaryen,從類 TypeScript 的語言直接編譯為 WebAssembly。在 AssemblyScript Play 中試用範例

組合指令碼輸入:

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 程式碼。

二進位工具鍊

二進位檔工具鍊為 JavaScript 開發人員和指令列使用者提供多種實用工具。下列工具列出了這些工具的其中一部分;您可以在專案的 README 檔案中找到完整的包含工具清單

  • binaryen.js:獨立的 JavaScript 程式庫,公開用於建立和最佳化 Wasm 模組的二進位方法。如需建構項目,請參閱 npm 上的 binaryen.js (或直接從 GitHubunpkg 下載)。
  • wasm-opt:載入 WebAssembly 並執行二進位 IR 的指令列工具。
  • wasm-aswasm-dis:組合及拆解 WebAssembly 的指令列工具。
  • wasm-ctor-eval:可在編譯期間執行函式 (或函式部分內容) 的指令列工具。
  • wasm-metadce:指令列工具可彈性地移除 Wasm 檔案的各個部分,具體做法視模組的使用方式而定。
  • wasm-merge:可將多個 Wasm 檔案合併成單一檔案的指令列工具,將對應的匯入內容如實連接匯出。這如同 JavaScript 的套裝組合,而 Wasm 也是如此。

編譯為 WebAssembly

將一種語言編譯至另一種語言通常包含數個步驟,下列清單中最重要的類別如下:

  • 詞法分析:將原始碼拆分為代碼。
  • 語法分析:建立抽象語法樹狀結構。
  • 語意分析:檢查是否有錯誤並強制執行語言規則。
  • 中繼程式碼產生:建立更為抽象的表示法。
  • 程式碼產生:翻譯成譯文語言。
  • 特定目標的程式碼最佳化:針對目標進行最佳化。

在 Unix 環境中,常用的編譯工具為 lexyacc

  • lex (Lexical Analyzer Generator): lex 是產生詞典分析器的工具,也稱為詞法分析器或掃描器。這會將一組規則運算式和對應的動作做為輸入,並產生一般分析工具的程式碼,以辨識輸入原始碼中的模式。
  • yacc (Yet Other 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 中四個可能的運算子,也就是 +-*/必須在模組中加入新函式,並使用二進位檔的 Module#addFunction() 方法。Module#addFunction() 方法的參數如下:

  • namestring,代表函式的名稱。
  • functionTypeSignature,代表函式的簽章。
  • varTypesType[],代表其他本機,按指定順序顯示。
  • bodyExpression,即函式的內容。

關於解開和分解的更多細節,二進位檔說明文件可協助您瀏覽空間,但最後以範例指令碼的 + 運算子來說,最後會抵達 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.sub() 為基礎的 subtract()、以 Module#f64.div() 為基礎的離群 divide(),以及以 Module#f64.div() 為基礎的離群 divide(),因為 ExampleScript 也可搭配浮點結果使用。multiply()Module#i32.mul()

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 `/`.

如果您處理實際的程式碼集,有時可能會發現完全沒有呼叫的無效程式碼。為了在 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 程式碼,二進位檔中提供了兩種方法,可將文字表示法視為 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 模組匯出項目顯示四個函式:新增、分隔、乘數和減去 (但沒有曝露的無效程式碼)。

最佳化 WebAssembly

二進位編碼提供兩種最佳化 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 玩具範例的文字表示法不再包含該程式碼。此外,也另請注意最佳化步驟 SimplifyLocals (其他本機相關的最佳化項目) 和 Vacuum (移除明顯不需要的程式碼) 來移除 local.set/get 組合,而 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 指令列工具進行最佳化

如要完整自訂使用限制票證,則 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 的成效和最大最佳化潛力。儘管許多自訂二進位模型最佳化的選項都需要深入瞭解 Wasm 的內部知識,但通常預設設定就能順利運作。這樣,我們很高興 使用 Binaryen 編譯和最佳化!

特別銘謝

這篇文章是由 Alon ZakaiThomas LivelyRachel Andrew 審查。