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

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

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

nth 番目のフィボナッチ数を計算する関数を 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 ファイルを作成して、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="/https/web.dev/a.out.js"></script>
<script>
  Module.onRuntimeInitialized = async (_) => {
    const api = {
      version: Module.cwrap('version', 'number', []),
    };
    console.log(api.version());
  };
</script>

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

正しいバージョン番号を示す DevTools コンソールのスクリーンショット

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

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

最初に答えるべきことは、「Wasm land に画像を届けるにはどうすればよいか?」です。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 つに遭遇した場合、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 モジュールに何でも指定するには、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 となります。