Binaryen は、C++ で記述された WebAssembly 用のコンパイラとツールチェーン インフラストラクチャ ライブラリです。WebAssembly へのコンパイルを直感的、高速、効果的に行うことを目的としています。この投稿では、ExampleScript という架空のおもちゃの言語の例を使用して、Binaryen.js API を使用して JavaScript で WebAssembly モジュールを作成する方法を説明します。モジュールの作成、モジュールへの関数の追加、モジュールからの関数のエクスポートの基本について説明します。これにより、実際のプログラミング言語を WebAssembly にコンパイルするメカニズム全体について理解できます。また、Binaryen.js を使用して Wasm モジュールを最適化する方法と、wasm-opt
を使用してコマンドラインで最適化する方法についても説明します。
Binaryen の背景
Binaryen には、単一のヘッダーに直感的な C API があり、JavaScript から使用することもできます。WebAssembly 形式の入力を受け付けますが、それを好むコンパイラ向けに一般的な制御フロー グラフも受け付けます。
中間表現(IR)は、コンパイラまたは仮想マシンがソースコードを表すために内部で使用するデータ構造またはコードです。Binaryen の内部 IR はコンパクトなデータ構造を使用し、使用可能なすべての CPU コアを使用して、完全に並列なコード生成と最適化を行うように設計されています。Binaryen の IR は WebAssembly のサブセットであるため、WebAssembly にコンパイルされます。
Binaryen のオプティマイザーには、コードサイズと速度を改善できる多くのパスがあります。これらの最適化は、Binaryen をコンパイラ バックエンドとして単独で使用できるほど強力にすることを目的としています。これには、汎用コンパイラでは実行されない WebAssembly 固有の最適化が含まれています。これは Wasm の最小化と考えることができます。
Binaryen のユーザーの例としての AssemblyScript
Binaryen は、AssemblyScript など、多くのプロジェクトで使用されています。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
: Wasm モジュールの作成と最適化のために Binaryen メソッドを公開するスタンドアロンの JavaScript ライブラリ。ビルドについては、npm の binaryen.js をご覧ください(または、GitHub または unpkg から直接ダウンロードしてください)。wasm-opt
: WebAssembly を読み込み、Binaryen IR パスを実行するコマンドライン ツール。wasm-as
とwasm-dis
: WebAssembly をアセンブルおよび逆アセンブルするコマンドライン ツール。wasm-ctor-eval
: コンパイル時に関数(または関数の一部)を実行できるコマンドライン ツール。wasm-metadce
: モジュールの使用方法に応じて、柔軟な方法で Wasm ファイルの一部を削除するコマンドライン ツール。wasm-merge
: 複数の Wasm ファイルを 1 つのファイルに統合し、対応するインポートをエクスポートに接続するコマンドライン ツール。JavaScript のバンドラーと同様ですが、Wasm 用です。
WebAssembly へのコンパイル
ある言語を別の言語にコンパイルするには、通常、いくつかの手順が必要です。最も重要な手順を次に示します。
- 字句解析: ソースコードをトークンに分割します。
- 構文解析: 抽象構文ツリーを作成します。
- セマンティック分析: エラーを確認し、言語ルールを適用します。
- 中間コードの生成: より抽象的な表現を作成します。
- コード生成: ターゲット言語に変換します。
- ターゲット固有のコード最適化: ターゲットに合わせて最適化します。
Unix の世界では、コンパイルによく使用されるツールは lex
と yacc
です。
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 つずつ記述されているため、パーサーは改行文字で分割してコードを 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();
抽象構文木の各行には、firstOperand
、operator
、secondOperand
で構成される 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()
)です。ExampleScript は浮動小数点の結果も扱うため、Module#f64.div()
に基づく divide()
という外れ値もあります。
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 つの方法があります。1 つは、テキスト表現を .wat
ファイルとして S 式で取得する方法です。これは人間が読める形式です。もう 1 つは、バイナリ表現を .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)
)
)
)
WebAssembly の最適化
Binaryen には、Wasm コードを最適化する 2 つの方法があります。Binaryen.js 自体とコマンドラインの 2 つがあります。前者は、デフォルトで標準の最適化ルールセットを適用し、最適化レベルと縮小レベルを設定できます。後者は、デフォルトでルールを使用しませんが、完全にカスタマイズできます。つまり、十分なテストを行うことで、コードに基づいて最適な結果が得られるように設定を調整できます。
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
ツールは、おそらく最も人気のあるツールであり、Emscripten、J2CL、Kotlin/Wasm、dart2wasm、wasm-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 Zakai、Thomas Lively、Rachel Andrew によってレビューされました。