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

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 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
 )
)

AssemblyScript 操場,顯示根據上一個範例產生的 WebAssembly 程式碼。

Binaryen 工具鍊

Binaryen 工具鍊為 JavaScript 開發人員和指令列使用者提供許多實用工具。以下列出部分工具;如需完整清單,請參閱專案的 README 檔案。

  • binaryen.js:獨立的 JavaScript 程式庫,可公開 Binaryen 方法,用於建立及最佳化 Wasm 模組。如需建構作業,請參閱 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 (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();

抽象語法樹狀結構的每一行都包含由 firstOperandoperatorsecondOperand 組成的三元組。針對 ExampleScript 中四個可能的運算子 (即 +-*/),需要在模組中新增函式,並使用 Binaryen 的 Module#addFunction() 方法。Module#addFunction() 方法的參數如下:

  • namestring,代表函式的名稱。
  • functionTypeSignature,代表函式的簽章。
  • varTypesType[],表示其他地區,順序如上。
  • bodyExpression,函式內容。

還有一些細節需要解開和分解,而 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 的編譯作業中,人為導入無效程式碼 (稍後步驟會進行最佳化並移除),新增未匯出的函式即可。

// 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 模組匯出的四個函式:add、divide、multiply 和 subtract (但不包括未公開的無效程式碼)。

最佳化 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 (從不需要的位置移除換行符) 移除。

 (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

為讓您瞭解這些路徑,以下列舉一些不需要專業知識就能理解的路徑:

  • 程式碼摺疊:合併重複的程式碼,避免重複 (例如,如果兩個 if 分支在結尾有一些共用的指令)。
  • DeadArgumentElimination:連結時間最佳化傳遞,可移除函式的引數,前提是函式一律以相同常數呼叫。
  • MinifyImportsAndExports:將匯入和匯出項目縮減為 "a""b"
  • DeadCodeElimination:移除無用的程式碼。

您可以參考最佳化食譜,瞭解如何判斷哪些標記較為重要,值得優先嘗試。舉例來說,有時重複執行 wasm-opt 會進一步縮減輸入內容。在這種情況下,使用 --converge 旗標執行作業會持續疊代,直到不再進行最佳化並達到固定點為止。

示範

如要實際瞭解本文介紹的概念,請使用內嵌的試用版,並提供您想到的任何 ExampleScript 輸入內容。也請務必查看這個範例的原始碼

結論

Binaryen 提供強大的工具包,可將語言編譯為 WebAssembly,並最佳化產生的程式碼。其 JavaScript 程式庫和指令列工具提供彈性,且易於使用。這篇文章說明瞭 Wasm 編譯的核心原則,並強調 Binaryen 的效用和最大化最佳化的潛力。雖然許多自訂 Binaryen 最佳化選項都需要深入瞭解 Wasm 內部結構,但通常預設設定就已相當實用。祝您使用 Binaryen 編譯及最佳化時一切順利!

特別銘謝

本文由 Alon ZakaiThomas LivelyRachel Andrew 審查。