モジュールの分割

wasm-splitとSPLIT_MODULE Emscripten統合はどちらも開発中であり、頻繁に変更され、新しい機能が追加される可能性があります。このページは最新の変更に合わせて更新されます。

大規模なコードベースには、実際にはほとんど使用されないか、アプリケーションのライフサイクルの初期にはまったく使用されない多くのコードが含まれていることがよくあります。このような未使用のコードをロードすると、アプリケーションの起動が著しく遅れる可能性があるため、アプリケーションの起動後までコードのロードを遅らせることが望ましいです。これに対する優れたソリューションの1つは動的リンクを使用することですが、これはアプリケーションを共有ライブラリにリファクタリングする必要があり、パフォーマンスのオーバーヘッドも伴うため、常に実現可能とは限りません。モジュールの分割は、モジュールが通常どおりビルドされた後に、プライマリモジュールとセカンダリモジュールの2つの部分に分割される別の方法です。プライマリモジュールは最初にロードされ、アプリケーションの起動に必要なコードが含まれていますが、セカンダリモジュールには後で必要になるか、まったく必要にならないコードが含まれています。セカンダリモジュールは、必要に応じて自動的にロードされます。

wasm-splitは、モジュールの分割を実行するBinaryenツールです。wasm-splitを実行した後、プライマリモジュールは元のモジュールと同じインポートとエクスポートを持ち、元のモジュールのドロップイン置換として機能することを目的としています。ただし、セカンダリモジュールに分割された各セカンダリ関数に対して、プレースホルダー関数をインポートします。セカンダリモジュールがロードされる前、セカンダリ関数の呼び出しは、代わりに適切なプレースホルダー関数を呼び出します。プレースホルダー関数は、セカンダリモジュールのロードとインスタンス化を担当し、インスタンス化されると、すべてのプレースホルダー関数を元のセカンダリ関数に自動的に置き換えます。セカンダリモジュールがロードされた後、それをロードしたプレースホルダー関数は、対応する新しくロードされたセカンダリ関数を呼び出し、結果を呼び出し元に返す役割も担います。したがって、セカンダリモジュールのロードはプライマリモジュールに対して完全に透過的であり、単に関数の呼び出しに時間がかかったように見えます。

現在、モジュールの分割の唯一のワークフローには、実行される関数のプロファイルを収集するために元のモジュールをインストルメント化し、いくつかの興味深いワークロードでそのインストルメント化されたモジュールを実行し、結果のプロファイルを使用してモジュールの分割方法を決定することが含まれます。wasm-splitは、プロファイルされたワークロードのいずれかの実行中に実行された関数をすべてプライマリモジュールに残し、その他のすべての関数をセカンダリモジュールに分割します。

Emscriptenには、-sSPLIT_MODULEオプションによって有効になるwasm-splitとのプロトタイプ統合があります。このオプションは、プロファイルの収集の準備が整うように、wasm-splitインストルメンテーションが適用された元のモジュールを出力します。また、セカンダリモジュールのロードを担当するプレースホルダー関数を、出力されたJSに挿入します。その後、開発者は適切なワークロードを実行し、プロファイルを収集し、wasm-splitツールを使用して分割を実行する必要があります。モジュールが分割された後、最初のコンパイルによって生成されたJSをさらに変更することなく、すべてが正常に動作します。

基本例

Nodeを使用してSPLIT_MODULEを使用する基本的な例を説明します。「Webでの実行」セクションでは、Webでも実行できるように例を調整する方法について説明します。

アプリケーションコードを以下に示します。

// application.c

#include <stdio.h>
#include <emscripten.h>

void foo() {
  printf("foo\n");
}

void bar() {
  printf("bar\n");
}

void unsupported(int i) {
  printf("%d is not supported!\n", i);
}

EM_JS(int, get_number, (), {
  if (typeof prompt === 'undefined') {
    prompt = require('prompt-sync')();
  }
  return parseInt(prompt('Give me 0 or 1: '));
});

int main() {
  int i = get_number();
  if (i == 0) {
    foo();
  } else if (i == 1) {
    bar();
  } else {
    unsupported(i);
  }
}

このアプリケーションは、ユーザーに入力を促し、ユーザーが提供した内容に応じてさまざまな関数を実行します。prompt-sync npmモジュールを使用して、NodeとWeb間のプロンプティング動作を移植可能にしています。プロファイリング中に提供する入力によって、関数がプライマリモジュールとセカンダリモジュールの間にどのように分割されるかが決まることがわかります。

-sSPLIT_MODULEを使用してアプリケーションをコンパイルできます。

$ emcc application.c -o application.js -sSPLIT_MODULE

通常のapplication.wasmとapplication.jsファイルに加えて、application.wasm.origファイルも生成されます。application.wasm.origは、通常のEmscriptenビルドが生成する元の変更されていないモジュールですが、application.wasmはプロファイルの収集のためにwasm-splitによってインストルメント化されています。

インストルメント化されたモジュールには、プロファイルを書き込むメモリ内バッファのポインタと長さを引数として取る追加のエクスポート関数__write_profileがあります。__write_profileはプロファイルの長さを返し、指定されたバッファが十分に大きい場合にのみデータを書き込みます。__write_profileは、JSから外部的に、またはアプリケーション自体から内部的に呼び出すことができます。ここでは簡潔にするために、メイン関数の最後に呼び出すだけですが、グローバルオブジェクトのデストラクタなど、メイン関数後に呼び出される関数はプロファイルに含まれないことに注意してください。

プロファイルを書き込む関数と新しいメイン関数を以下に示します。

EM_JS(void, write_profile, (), {
  var __write_profile = wasmExports.__write_profile;
  if (!__write_profile) {
    return;
  }

  // Get the size of the profile and allocate a buffer for it.
  var len = __write_profile(0, 0);
  var ptr = _malloc(len);

  // Write the profile data to the buffer.
  __write_profile(ptr, len);

  // Write the profile file.
  var profile_data = HEAPU8.subarray(ptr, ptr + len);
  const fs = require("fs");
  fs.writeFileSync('profile.data', profile_data);

  // Free the buffer.
  _free(ptr);
});

int main() {
  int i = get_number();
  if (i == 0) {
    foo();
  } else if (i == 1) {
    bar();
  } else {
    unsupported(i);
  }
  write_profile();
}

__write_profileエクスポートが存在する場合にのみ、プロファイルの書き込みを試みることに注意してください。これは、インストルメント化された分割されていないモジュールだけが__write_profileをエクスポートするため重要です。分割されたモジュールには、プロファイリングインストルメンテーションまたはこのエクスポートは含まれません。

新しいwrite_profile関数は、mallocとfreeがJSで使用可能であることに依存するため、コマンドラインで明示的にエクスポートする必要があります。

$ emcc application.c -o application.js -sSPLIT_MODULE -sEXPORTED_FUNCTIONS=_malloc,_free,_main

これでアプリケーションを実行して、profile.dataファイルを作成できます。次のステップは、wasm-splitとプロファイルを使用して元のモジュールapplication.wasmを分割することです。

$ wasm-split --enable-mutable-globals --export-prefix=% application.wasm.orig -o1 application.wasm -o2 application.deferred.wasm --profile=profile.data

これらのオプションがすべて何をするのかを説明します。

--enable-mutable-globals

このオプションは、変更可能なWasmグローバル変数(C/C++グローバル変数とは対照的)のインポートとエクスポートを許可する、mutable-globalターゲット機能を有効にします。wasm-splitは、主モジュールと副モジュール間で変更可能なグローバル変数を共有する必要があるため、この機能の有効化が必要です。

--export-prefix=%

これは、主モジュールから副モジュールへモジュール要素を共有するためにwasm-splitが作成するすべての新しいエクスポートに追加されるプレフィックスです。このプレフィックスを使用して、「真の」エクスポートと、副モジュールで使用されるためだけに存在するエクスポートを区別できます。Emscriptenのwasm-split統合では、プレフィックスとして特に「%」を使用することが期待されています。

-o1 application.wasm

主モジュールをapplication.wasmに書き込みます。これは、Emscriptenによって以前に生成された計装済みモジュールを上書きすることに注意してください。そのため、アプリケーションは計装済みモジュールではなく、分割されたモジュールを使用するようになります。

-o2 application.deferred.wasm

副モジュールをapplication.deferred.wasmに書き込みます。Emscriptenは、副モジュールの名前が主モジュールの名前と同じで、「.wasm」が「.deferred.wasm」に置き換えられていることを期待しています。

--profile=profile.data

wasm-splitに、profile.dataのプロファイルを使用して分割をガイドするように指示します。

application.jsをnodeで再度実行すると、アプリケーションは以前と同様に動作することがわかりますが、プロファイルされたワークロードで使用されたもの以外のコードパスを実行すると、アプリケーションはプレースホルダー関数が呼び出され、遅延モジュールがロードされたことを示すコンソールメッセージを出力します。

複数のワークロードのプロファイリング

wasm-splitは、複数のプロファイリングワークロードからのプロファイルを単一のプロファイルにマージして分割をガイドすることをサポートしています。いずれかのワークロードで実行された関数はすべて主モジュールに残され、その他の関数はすべて副モジュールに分割されます。

このコマンドは、任意の数のプロファイル(ここではprofile1.dataとprofile2.dataのみ)を単一のプロファイルにマージします。

$ wasm-split --merge-profiles profile1.data profile2.data -o profile.data

マルチスレッドプログラム

デフォルトでは、wasm-split計装によって収集されたデータはWasmグローバル変数に格納されるため、スレッドローカルです。しかし、マルチスレッドプログラムでは、すべてのスレッドからプロファイル情報を収集することが重要です。そのため、--in-memory wasm-splitフラグを使用して、共有メモリに共有プロファイル情報を収集するようにwasm-splitに指示できます。これにより、アドレス0から始まるメモリを使用してプロファイル情報が格納されるため、プログラムがそのメモリ領域を上書きするのを防ぐために、-sGLOBAL_BASE=NをEmscriptenに渡す必要もあります。ここでNは、モジュール内の関数の数以上の値です。

分割後、マルチスレッドアプリケーションは現在、各スレッドで副モジュールを個別にフェッチしてコンパイルします。コンパイルされた副モジュールは、Emscriptenが主モジュールをスレッドにポストメッセージするような方法で、各スレッドにポストメッセージされません。適切なCache-Controlヘッダーが設定されている場合、ワーカーからの副モジュールのダウンロードはキャッシュから処理されるため、これはそれほど悪いことではありませんが、これを改善することは今後の取り組みです。

Web上での実行

WebアプリケーションでSPLIT_MODULEを使用する際に考慮すべき複雑さの1つは、副モジュールを遅延読み込みと非同期読み込みの両方でロードできないことです。つまり、メインブラウザースレッドで遅延読み込みできません。その理由は、プレースホルダー関数は主モジュールの関数に対して完全に透過的である必要があるため、副関数を同期的にロードして呼び出すまで戻ることはできないからです。

この制限に対する1つの回避策は、副モジュールを事前にロードしてインスタンス化し、メインブラウザースレッドでインスタンス化される前に副関数が呼び出される可能性がないことを確認することです。ただし、これは確認が難しい場合があります。別の修正方法は、主モジュールにAsyncify変換を実行して、プレースホルダー関数が副モジュールのロードを非同期的に待機している間にJSイベントループに戻るようにすることです。これはwasm-splitのロードマップにありますが、このソリューションのサイズとパフォーマンスのオーバーヘッドはまだわかりません。

遅延読み込みに関するこの制限により、SPLIT_MODULEを使用してアプリケーションを実行する最良の方法は、ワーカースレッド(たとえば、-sPROXY_TO_PTHREADを使用)です。PROXY_TO_PTHREADモードでは、アプリケーションメインスレッドに加えてブラウザメインスレッドのプロファイルを収集することが重要です。ブラウザメインスレッドは、プロキシされたメイン関数をラップするシムや、ブラウザメインスレッドにプロキシされた呼び出しの処理に関与する関数など、アプリケーションメインスレッドでは実行されないいくつかの関数を実行するためです。複数のスレッドからプロファイルを収集する方法については、前のセクションを参照してください。

もう1つの小さな複雑さは、プロファイルデータをブラウザ内からファイルにすぐに書き込むことができないことです。代わりに、開発者マシンに送信する必要があります。たとえば、開発サーバーに投稿するか、コンソールからそのbase64エンコーディングをコピーします。

base64ソリューションを実装するコードを次に示します。

var profile_data = HEAPU8.subarray(ptr, ptr + len);
var binary = '';
for (var i = 0; i < profile_data.length; i++) {
    binary += String.fromCharCode(profile_data[i]);
}
console.log("===BEGIN===");
console.log(window.btoa(binary));
console.log("===END===");

その後、プロファイルファイルは次を実行して作成できます。

$ echo [pasted base64] | base64 --decode > profile.data

または

$ base64 --decode [base64 file] > profile.data

動的リンクとの使用

モジュール分割は動的リンクと組み合わせて使用できますが、これら2つの機能を正しく連携させるには、開発者の介入が必要です。wasm-splitは多くの場合、プレースホルダー関数のためのスペースを作成するためにテーブルを拡張する必要がありますが、これは計装済みモジュールと分割済みモジュールのテーブルサイズが異なることを意味します。通常、これは問題ではありませんが、MAIN_MODULE/SIDE_MODULEの動的リンクサポートでは現在、テーブルサイズをEmscriptenが生成するJSに焼き込む必要があるため、テーブルサイズは安定している必要があります。

テーブルサイズが計装済みモジュールと分割済みモジュールで同じであることを確認するには、-sINITIAL_TABLE=N Emscripten設定を使用します。ここでNは目的のテーブルサイズです。次に、wasm-splitを使用して分割を実行する際には、--initial-table=Nをwasm-splitに渡して、分割済みモジュールにも正しいテーブルサイズがあることを確認します。

指定されたテーブルサイズが小さすぎる場合、分割後に主モジュールがロードされるとエラーメッセージが表示されます。指定するテーブルサイズを、十分な大きさになるまで調整します。実行時に余分なスペースを占める以外に、必要以上に大きいテーブルサイズを指定することの欠点はありません。

副モジュールのカスタムロード

副モジュールの遅延読み込みのデフォルトのロジックは、「loadSplitModule」カスタムフック関数を実装することでオーバーライドできます。フックはプレースホルダー関数から呼び出され、副モジュールの[インスタンス、モジュール]ペアを返す役割を果たします。フックは、ロードするファイルの名前(例:「my_program.deferred.wasm」)、モジュールをインスタンス化するインポートオブジェクト、および呼び出されたプレースホルダー関数に対応するプロパティを引数として受け取ります。追加のログ出力を行うデフォルトの実装と同じことを行う例を示します。

Module["loadSplitModule"] = function(deferred, imports, prop) {
    console.log('Custom handler for loading split module.');
    console.log('Called with placeholder ', prop);

    return instantiateSync(deferred, imports);
}

モジュールが事前にロードされている場合、このフックはフェッチしてコンパイルするのではなく、単にモジュールをインスタンス化できます。ただし、事前にロードされたモジュールも事前にインスタンス化されている場合、プレースホルダー関数はパッチされて最初に呼び出されなくなるため、このカスタムフックも呼び出されません。

副モジュールを事前にインスタンス化する場合は、インポートオブジェクトは次のようになります。

{'primary': wasmExports}

デバッグ

wasm-splitには、分割済みモジュールのデバッグを容易にするためのいくつかのオプションがあります。

-v

分割時に、主関数と副関数を印刷します。プロファイルをマージする際には、マージされたプロファイルに寄与しないプロファイルを印刷します。

-g

主モジュールと副モジュールの両方で名前を保持します。このオプションがないと、wasm-splitは代わりに名前を削除します。

--emit-module-names

-gを使用しない場合でも、スタックトレースで主モジュールと副モジュールを区別するために、モジュール名を作成して出力します。

--symbolmap

主モジュールと副モジュールの個別のマップファイルを出力し、関数インデックスと関数名をマッピングします。 –emit-module-namesと組み合わせると、これらのマップを使用してスタックトレースを再シンボライズできます。関数名がwasm-splitでマップに出力できるようにするには、–profiling-funcsをEmscriptenに渡します。

--placeholdermap

プレースホルダー関数インデックスとその対応する副関数をマッピングするマップファイルを出力します。これは、副モジュールのロードの原因となった関数を特定するのに役立ちます。

今後の変更

このドキュメントにまだ組み込まれていない変更と新機能のリストです。

Asyncify計装との統合に取り組んでおり、これにより、メインブラウザースレッドで副モジュールを非同期的にロードできるようになります。