Wasm に C ライブラリをエンスクリプトする

C または C++ コードとしてのみ使用できるライブラリを使用する場合もあります。通常、ここであきらめてしまいます。EmscriptenWebAssembly(または Wasm)が登場したことで、状況は変わりました。

私は、既存の C コードを Wasm にコンパイルする方法を探すことを目標としました。LLVM の Wasm バックエンドに関するノイズがあったので、詳しく調べてみました。この方法ではシンプルなプログラムをコンパイルできますが、C の標準ライブラリを使用する場合や、複数のファイルをコンパイルする場合は、問題が発生する可能性があります。この経験から、次のような大きな教訓を得ました。

Emscripten は以前は C から asm.js へのコンパイラでしたが、その後 Wasm をターゲットとするまでに成熟し、内部で公式の LLVM バックエンドに切り替えるプロセス中です。Emscripten には、C の標準ライブラリの Wasm 互換の実装も用意されています。Emscripten を使用する多くの隠れた作業を伴うため、ファイル システムをエミュレートし、メモリ管理を提供し、OpenGL を WebGL でラップします。これは、自分で開発する必要のない多くの作業です。

肥大化を心配しなければならないように思えますが(私も心配しました)、Emscripten コンパイラは不要なものをすべて削除します。私のテストでは、生成された Wasm モジュールは、含まれるロジックに対して適切なサイズになっています。Emscripten チームと WebAssembly チームは、今後さらにサイズを小さくすることに取り組んでいます。

Emscripten は、ウェブサイトの手順に沿って入手するか、Homebrew を使用して入手できます。私のように Docker 化されたコマンドのファンで、WebAssembly を試すためにシステムにインストールしたくない場合は、代わりによくメンテナンスされている Docker イメージを使用できます。

    $ docker pull trzeci/emscripten
    $ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>

簡単なプログラムをコンパイルする

ほぼ標準的な例として、n 番目のフィボナッチ数を計算する C の関数を作成しましょう。

    #include <emscripten.h>

    EMSCRIPTEN_KEEPALIVE
    int fib(int n) {
      if(n <= 0){
        return 0;
      }
      int i, t, a = 0, b = 1;
      for (i = 1; i < n; i++) {
        t = a + b;
        a = b;
        b = t;
      }
      return b;
    }

C を知っていれば、関数自体に驚くことはありません。C は知らないが JavaScript は知っているという場合でも、ここで何が起こっているのか理解できるはずです。

emscripten.h は Emscripten が提供するヘッダー ファイルです。EMSCRIPTEN_KEEPALIVE マクロにアクセスするために必要なものだけですが、はるかに多くの機能を提供します。このマクロは、関数が使用されていないように見えても、関数を削除しないようにコンパイラに指示します。このマクロを省略すると、コンパイラは関数を最適化します。結局、誰も使用していない関数です。

これらをすべて fib.c というファイルに保存しましょう。これを .wasm ファイルに変換するには、Emscripten のコンパイラ コマンド emcc を使用する必要があります。

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c

このコマンドについて詳しく見てみましょう。emcc は Emscripten のコンパイラです。fib.c は C ファイルです。ここまでは順調です。-s WASM=1 は、asm.js ファイルではなく Wasm ファイルを提供するように Emscripten に指示します。-s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' は、JavaScript ファイルで cwrap() 関数をそのまま使用するようにコンパイラに指示します。この関数については後で説明します。-O3 は、コンパイラに積極的に最適化するよう指示します。ビルド時間を短縮するために小さい値を選択することもできますが、コンパイラが未使用のコードを削除しないため、生成されるバンドルが大きくなります。

コマンドを実行すると、a.out.js という名前の JavaScript ファイルと a.out.wasm という名前の WebAssembly ファイルが作成されます。Wasm ファイル(または「モジュール」)には、コンパイルされた C コードが含まれており、かなり小さくする必要があります。JavaScript ファイルは、Wasm モジュールの読み込みと初期化を行い、より優れた API を提供します。必要に応じて、C コードの記述時に通常オペレーティング システムが提供するスタック、ヒープ、その他の機能も設定します。そのため、JavaScript ファイルは 19 KB(gzip 圧縮で約 5 KB)と少し大きくなります。

簡単な処理を実行する

モジュールを読み込んで実行する最も簡単な方法は、生成された JavaScript ファイルを使用することです。このファイルを読み込むと、Module グローバルが使用できるようになります。cwrap を使用して、パラメータを C に適したものに変換し、ラップされた関数を呼び出す JavaScript ネイティブ関数を作成します。cwrap は、関数名、戻り型、引数の型を、この順序で引数として受け取ります。

    <script src="a.out.js"></script>
    <script>
      Module.onRuntimeInitialized = _ => {
        const fib = Module.cwrap('fib', 'number', ['number']);
        console.log(fib(12));
      };
    </script>

このコードを実行すると、コンソールに「144」と表示されます。これは 12 番目のフィボナッチ数です。

至高の目標: C ライブラリのコンパイル

これまでに作成した C コードは、Wasm を念頭に置いて作成されています。ただし、WebAssembly のコア ユースケースは、既存の C ライブラリのエコシステムを活用して、デベロッパーがウェブで使用できるようにすることです。これらのライブラリは、多くの場合、C の標準ライブラリ、オペレーティング システム、ファイル システムなどに依存しています。Emscripten にはこれらの機能のほとんどが用意されていますが、いくつかの制限事項があります。

元の目標である WebP のエンコーダを Wasm にコンパイルしましょう。WebP コーデックのソースは C で記述されており、GitHub で入手できます。また、API ドキュメントも豊富に用意されています。良い出発点になると思います。

    $ git clone https://github.com/webmproject/libwebp

最初は簡単な例から始めましょう。webp.c という C ファイルを作成して、encode.hWebPGetEncoderVersion() を JavaScript に公開してみましょう。

    #include "emscripten.h"
    #include "src/webp/encode.h"

    EMSCRIPTEN_KEEPALIVE
    int version() {
      return WebPGetEncoderVersion();
    }

この関数を呼び出すためにパラメータや複雑なデータ構造を必要としないため、libwebp のソースコードをコンパイルできるかどうかをテストするのに適したシンプルなプログラムです。

このプログラムをコンパイルするには、-I フラグを使用して libwebp のヘッダー ファイルを見つける場所をコンパイラに伝え、必要な libwebp のすべての C ファイルを渡す必要があります。正直なところ、見つけられるすべての C ファイルを渡し、不要なものをすべて削除するようにコンパイラに依存しました。うまく機能しているようです。

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
        -I libwebp \
        webp.c \
        libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c

これで、新しいモジュールを読み込むための HTML と JavaScript のみが必要です。

<script src="/a.out.js"></script>
<script>
  Module.onRuntimeInitialized = async (_) => {
    const api = {
      version: Module.cwrap('version', 'number', []),
    };
    console.log(api.version());
  };
</script>

出力に修正バージョン番号が表示されます。

正しいバージョン番号が表示された DevTools コンソールのスクリーンショット。

JavaScript から Wasm に画像を取得する

エンコーダのバージョン番号を取得するのはいいのですが、実際の画像をエンコードするほうが印象的ですよね。では、そのようにします。

最初に答えなければならない質問は、イメージを Wasm にどのように取り込むかです。libwebp のエンコード API を見ると、RGB、RGBA、BGR、BGRA のバイトの配列を想定していることがわかります。幸い、Canvas API には getImageData() があり、RGBA の画像データを含む Uint8ClampedArray を取得できます。

async function loadImage(src) {
  // Load image
  const imgBlob = await fetch(src).then((resp) => resp.blob());
  const img = await createImageBitmap(imgBlob);
  // Make canvas same size as image
  const canvas = document.createElement('canvas');
  canvas.width = img.width;
  canvas.height = img.height;
  // Draw image onto canvas
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0);
  return ctx.getImageData(0, 0, img.width, img.height);
}

これで、JavaScript から Wasm にデータをコピーするだけです。そのためには、2 つの関数を追加で公開する必要があります。Wasm ランド内の画像のメモリを割り当てる関数と、そのメモリを解放する関数です。

    EMSCRIPTEN_KEEPALIVE
    uint8_t* create_buffer(int width, int height) {
      return malloc(width * height * 4 * sizeof(uint8_t));
    }

    EMSCRIPTEN_KEEPALIVE
    void destroy_buffer(uint8_t* p) {
      free(p);
    }

create_buffer は RGBA 画像のバッファを割り当てます。つまり、ピクセルあたり 4 バイトです。malloc() によって返されるポインタは、そのバッファの最初のメモリセルのアドレスです。ポインタが JavaScript に戻されると、単なる数値として扱われます。cwrap を使用して関数を JavaScript に公開したら、その数値を使用してバッファの先頭を見つけ、画像データをコピーできます。

const api = {
  version: Module.cwrap('version', 'number', []),
  create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
  destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
const image = await loadImage('/image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);

グランド フィナーレ: 画像をエンコードする

イメージは Wasm ランドで利用できるようになります。WebP エンコーダを呼び出して処理を開始します。WebP のドキュメントを見ると、WebPEncodeRGBA が最適なようです。この関数は、入力画像へのポインタとそのサイズ、0 ~ 100 の品質オプションを受け取ります。また、出力バッファも割り振られます。WebP 画像の処理が完了したら、WebPFree() を使用して解放する必要があります。

エンコード オペレーションの結果は、出力バッファとその長さです。C の関数では、(メモリを動的に割り当てない限り)配列を戻り型に指定できないため、静的グローバル配列を使用しました。クリーンな C ではありません(実際、Wasm ポインタが 32 ビット幅であることに依存しています)。ただし、シンプルにするために、これは妥当なショートカットだと思います。

    int result[2];
    EMSCRIPTEN_KEEPALIVE
    void encode(uint8_t* img_in, int width, int height, float quality) {
      uint8_t* img_out;
      size_t size;

      size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);

      result[0] = (int)img_out;
      result[1] = size;
    }

    EMSCRIPTEN_KEEPALIVE
    void free_result(uint8_t* result) {
      WebPFree(result);
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_pointer() {
      return result[0];
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_size() {
      return result[1];
    }

これで、エンコード関数を呼び出し、ポインタと画像サイズを取得して独自の JavaScript ランド バッファに配置し、プロセスで割り当てたすべての Wasm ランド バッファを解放できます。

    api.encode(p, image.width, image.height, 100);
    const resultPointer = api.get_result_pointer();
    const resultSize = api.get_result_size();
    const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
    const result = new Uint8Array(resultView);
    api.free_result(resultPointer);

画像のサイズによっては、Wasm が入力画像と出力画像の両方に適合するメモリを増やすことができないエラーが発生することがあります。

エラーが表示されている DevTools コンソールのスクリーンショット。

幸い、この問題の解決策はエラー メッセージに記載されています。コンパイル コマンドに -s ALLOW_MEMORY_GROWTH=1 を追加するだけです。

このように、WebP エンコーダをコンパイルし、JPEG 画像を WebP にトランスコードしました。正常に動作していることを確認するには、結果バッファを blob に変換して <img> 要素で使用します。

const blob = new Blob([result], { type: 'image/webp' });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);

新しい WebP 画像の栄光をご覧あれ

DevTools のネットワーク パネルと生成された画像。

まとめ

C ライブラリをブラウザで動作させるのは簡単ではありませんが、全体的なプロセスとデータフローの動作を理解すると、簡単になり、驚くような結果が得られます。

WebAssembly により、処理、数値処理、ゲームなど、ウェブでの新たな可能性が広がります。Wasm は万能の解決策ではなく、ボトルネックの 1 つに直面したときに非常に役立つツールです。

ボーナス コンテンツ: 簡単な処理を難しい方法で実行する

生成された JavaScript ファイルを回避したい場合は、回避できる場合があります。フィボナッチの例に戻りましょう。自分で読み込んで実行するには、次の操作を行います。

<!DOCTYPE html>
<script>
  (async function () {
    const imports = {
      env: {
        memory: new WebAssembly.Memory({ initial: 1 }),
        STACKTOP: 0,
      },
    };
    const { instance } = await WebAssembly.instantiateStreaming(
      fetch('/a.out.wasm'),
      imports,
    );
    console.log(instance.exports._fib(12));
  })();
</script>

Emscripten によって作成された WebAssembly モジュールには、メモリを指定しない限り、使用できるメモリがありません。Wasm モジュールに任意の値を提供する方法は、instantiateStreaming 関数の 2 番目のパラメータである imports オブジェクトを使用する方法です。Wasm モジュールは、imports オブジェクト内のすべてのものにアクセスできますが、それ以外のものにはアクセスできません。慣例により、Emscripting によってコンパイルされたモジュールは、読み込み JavaScript 環境から次のものを想定しています。

  • まず、env.memory があります。Wasm モジュールは外部世界を認識しないため、処理に必要なメモリを取得する必要があります。「WebAssembly.Memory」と入力します。これは、(必要に応じて拡張可能な)リニアメモリの一部を表します。サイズ設定パラメータは「WebAssembly ページ単位」で指定します。つまり、上記のコードでは 1 ページのメモリが割り当てられ、各ページのサイズは 64 KiB です。maximum オプションを指定しない場合、メモリは理論上無制限に増加します(Chrome では現在、2 GB のハード制限があります)。ほとんどの WebAssembly モジュールでは、最大値を設定する必要はありません。
  • env.STACKTOP は、スタックの増加を開始する場所を定義します。スタックは、関数呼び出しを行うことと、ローカル変数にメモリを割り当てるために必要です。この小さなフィボナッチ プログラムでは動的メモリ管理を行っていないため、メモリ全体をスタックとして使用できます。つまり、STACKTOP = 0 です。