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

C または C++ コードとしてのみ利用可能なライブラリを使用したい場合、従来は、ここであきらめていただきます。今はそうではありません。EmscriptenWebAssembly(または Wasm)が利用できるようになりました。

ツールチェーン

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

Emscripten はこれまで C-to-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 コードが含まれているため、かなり小さくする必要があります。Wasm モジュールの読み込みと初期化は JavaScript ファイルによって処理され、より使いやすい 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 ファイルを作成して、WebPGetEncoderVersion()encode.h から 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 に画像を取得する

エンコーダのバージョン番号を取得するのは良いことですが、実際の画像をエンコードした方がよいでしょう。そうしましょう。

最初に答えなければならない質問は、「どうやって画像をウェイムランドに取り込むか」です。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 Land にコピーする「だけ」です。そのためには、さらに 2 つの関数を公開する必要があります。Wasm land 内の画像にメモリを割り当てるものと、再度解放するものがあります。

    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 Land で画像が利用できるようになりました。次に、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-land バッファを解放できるようになりました。

    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 はすべてに適用できる万能薬ではありませんが、これらのボトルネックのいずれかにぶつかった場合、Wasm は非常に便利なツールです。

ボーナス コンテンツ: シンプルなものを難なく実行する

生成された 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 モジュールに any を提供するには、imports オブジェクト(instantiateStreaming 関数の 2 番目のパラメータ)を使用します。Wasm モジュールは imports オブジェクト内のすべてにアクセスできますが、それ以外のものにはアクセスできません。慣例として、Emscripting によってコンパイルされるモジュールは、読み込み JavaScript 環境から以下のいくつかのことを想定しています。

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