Binaryen を使用した Wasm のコンパイルと最適化

Binaryen は、C++ で記述された WebAssembly 用のコンパイラおよびツールチェーン インフラストラクチャ ライブラリです。WebAssembly へのコンパイルを直感的かつ高速かつ効果的に行うことを目的としています。この投稿では、ExampleScript というおもちゃの合成言語を例に、Binaryen.js API を使用して JavaScript で WebAssembly モジュールを記述する方法を学習します。モジュールの作成、モジュールへの関数の追加、モジュールからの関数のエクスポートの基本について説明します。これにより、実際のプログラミング言語を WebAssembly にコンパイルする全体的な仕組みを理解できます。さらに、Binaryen.js とコマンドラインの wasm-opt の両方を使用して Wasm モジュールを最適化する方法についても説明します。

Binaryen の背景

Binaryen は、1 つのヘッダーに直感的な C API が含まれ、JavaScript から使用することもできます。WebAssembly フォームでの入力を受け入れますが、コンパイラがそれを好む一般的な制御フローグラフも受け入れます。

中間表現(IR)は、ソースコードを表すためにコンパイラまたは仮想マシンが内部的に使用するデータ構造またはコードです。Binaryen の内部 IR はコンパクトなデータ構造を使用し、使用可能なすべての CPU コアを使用して完全に並列のコード生成と最適化を行うように設計されています。Binaryen の IR は、WebAssembly のサブセットであるため、WebAssembly にコンパイルされます。

Binaryen のオプティマイザーには、コードのサイズと速度を改善できる多数のパスがあります。これらの最適化は、Binaryen を単独でコンパイラ バックエンドとして使用するのに十分なパワーを提供することを目的としています。これには WebAssembly 固有の最適化が含まれています(汎用コンパイラでは実行されない可能性があります)。これは Wasm 圧縮と考えることができます。

Binaryen のサンプル ユーザーとしての AssemblyScript

Binaryen は、Binaryen を使用して TypeScript のような言語から直接 WebAssembly にコンパイルする AssemblyScript など、多くのプロジェクトで使用されています。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
 )
)

前の例に基づいて生成された WebAssembly コードが表示されている AssemblyScript プレイグラウンド。

Binaryen ツールチェーン

Binaryen ツールチェーンは、JavaScript デベロッパーとコマンドライン ユーザーの双方にとって有用なツールをいくつか提供します。以下に、これらのツールのサブセットを示します。含まれるツールの全リストは、プロジェクトの README ファイルから入手できます。

  • binaryen.js: Wasm モジュールの作成と最適化用の Binaryen メソッドを公開するスタンドアロンの JavaScript ライブラリ。ビルドについては、npm の binaryen.js をご覧ください(または、GitHub または unpkg から直接ダウンロードします)。
  • wasm-opt: WebAssembly を読み込み、Binaryen IR パスを実行するコマンドライン ツール。
  • wasm-aswasm-dis: WebAssembly のアセンブルと逆アセンブルを行うコマンドライン ツール。
  • wasm-ctor-eval: コンパイル時に関数(または関数の一部)を実行できるコマンドライン ツール。
  • wasm-metadce: モジュールの使用方法に応じた柔軟な方法で Wasm ファイルの一部を削除するコマンドライン ツール。
  • wasm-merge: 複数の Wasm ファイルを 1 つのファイルにマージし、対応するインポートをそれと同じようにエクスポートに接続するコマンドライン ツール。JavaScript 用のバンドラに似ていますが、Wasm 用です。

WebAssembly へのコンパイル

ある言語を別の言語にコンパイルするには、一般的にいくつかの手順が必要です。その中で最も重要なものを以下に示します。

  • 語彙分析: ソースコードをトークンに分割します。
  • 構文分析: 抽象構文ツリーを作成します。
  • セマンティック分析: エラーをチェックし、言語ルールを適用します。
  • 中間コード生成: より抽象的な表現を作成します。
  • コード生成: ターゲット言語に翻訳します。
  • ターゲット固有のコードの最適化: ターゲットに合わせて最適化します。

Unix 環境では、コンパイルによく使用されるツールは lexyacc です。

  • lex(語彙アナライザ生成ツール): lex は、語彙アナライザ(レキサーまたはスキャナとも呼ばれます)を生成するツールです。正規表現と対応するアクションを入力として受け取り、入力ソースコード内のパターンを認識する字句解析ツール用のコードを生成します。
  • yacc(Yet Another Compiler Compiler): yacc は、構文解析用のパーサーを生成するツールです。プログラミング言語の正式な文法の記述を入力として受け取り、パーサーのコードを生成します。パーサーは通常、ソースコードの階層構造を表す抽象構文ツリー(AST)を生成します。

実際の例

この投稿の範囲を考慮すると、プログラミング言語全体を網羅することは不可能であるため、わかりやすくするために、一般的なオペレーションを具体的な例によって表現する ExampleScript という、非常に限定的で役に立たない合成プログラミング言語について考えてみましょう。

  • add() 関数を作成するには、任意の加算(2 + 3 など)の例をコード化します。
  • multiply() 関数を記述するには、たとえば 6 * 12 を記述します。

事前警告によると、まったく役に立たないものの、語彙アナライザが 1 つの正規表現 /\d+\s*[\+\-\*\/]\s*\d+\s*/ であれば十分シンプルです。

次に、パーサーが必要です。実際、名前付きキャプチャ グループ/(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/)を含む正規表現を使用すると、抽象構文ツリーの非常に簡素化されたバージョンを作成できます。

ExampleScript コマンドは 1 行に 1 つずつ入力されるため、パーサーは改行文字でコードを行単位で処理できます。前の箇条書きリストの最初の 3 つのステップ(語彙解析、構文解析、セマンティック分析)はこれで十分です。これらのステップのコードは、次のリストにあります。

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 という 3 つの要素があります。ExampleScript の 4 つの演算子(+-*/)のそれぞれについて、Binaryen の Module#addFunction() メソッドで新しい関数をモジュールに追加する必要がありますModule#addFunction() メソッドのパラメータは次のとおりです。

  • name: string。関数の名前を表します。
  • functionType: Signature。関数のシグネチャを表します。
  • varTypes: Type[]。指定された順序で追加のローカルを示します。
  • body: Expression。関数の内容。

アンワインドして分割するための詳細については、Binaryen のドキュメントを参照してください。ただし、ExampleScript の + 演算子の場合、最終的には使用可能な整数演算の 1 つとして Module#i32.add() メソッドになります。加算には、第 1 と第 2 の総和の 2 つのオペランドが必要です。関数を実際に呼び出し可能にするには、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');

抽象構文ツリーを処理した後、モジュールには整数を扱う 4 つのメソッドが含まれます。3 つのメソッドは、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 には 2 つのメソッドがあります。テキスト表現を人が読める形式の 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);

4 つすべてのオペレーションを含む 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)
  )
 )
)

加算、除算、乗算、減算の 4 つの関数を示す WebAssembly モジュールのエクスポートを示す DevTools コンソールのスクリーンショット(公開されていないデッドコードではない)。

WebAssembly の最適化

Binaryen は、Wasm コードを最適化する 2 つの方法を提供しています。1 つは Binaryen.js 自体にあり もう 1 つはコマンドラインです前者では、デフォルトで最適化ルールの標準セットが適用され、最適化レベルと縮小レベルを設定できます。後者では、デフォルトではルールは使用されませんが、完全なカスタマイズが可能です。つまり、十分なテストを行うことで、コードに基づいて最適な結果が得られるように設定を調整できます。

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 ツールはおそらくツールの中で最も人気があり、EmscriptenJ2CLKotlin/Wasmdart2wasmwasm-pack など、いくつかのコンパイラ ツールチェーンで Wasm コードを最適化するために使用されます。

wasm-opt --help

パスの感触をつかむために、専門的な知識がなくても理解できるパスの一部を以下に抜粋します。

  • CodeFolding: コードをマージして重複を回避します(2 つの if アームの最後に共有された命令がある場合など)。
  • DeadArgumentElimination: 常に同じ定数で呼び出されている関数に対する引数を削除するリンク時最適化パス。
  • MinifyImportsAndExports: "a""b" に圧縮します。
  • DeadCodeElimination: デッドコードを削除します。

最適化クックブックでは、どのフラグがより重要で、最初に試す価値があるかを特定するためのヒントがいくつか用意されています。たとえば、wasm-opt を繰り返し実行すると、入力がさらに圧縮されることがあります。このような場合、--converge フラグを指定して実行することで、それ以上の最適化が行われずに固定ポイントに達するまで、反復が継続されます。

デモ

この投稿で紹介したコンセプトの実際の動作を確認するには、埋め込みのデモを試してください。考えられる任意の ExampleScript 入力を渡してください。また、必ずデモのソースコードを表示してください。

まとめ

Binaryen は、言語を WebAssembly にコンパイルし、生成されたコードを最適化するための強力なツールキットです。JavaScript ライブラリとコマンドライン ツールが柔軟で使いやすいため、この投稿では、Wasm コンパイルの基本原則を紹介し、Binaryen の有効性と最大限の最適化の可能性を取り上げました。Binaryen の最適化をカスタマイズするオプションの多くは、Wasm の内部に関する深い知識を必要としますが、通常はデフォルト設定で十分に機能します。これで Binaryen を使用した コンパイルと最適化が進みました

謝辞

この投稿は、Alon ZakaiThomas LivelyRachel Andrew によってレビューされました。