C または C++ コードとしてのみ使用可能なライブラリを使用したい場合があります。通常、ここで諦めてしまいます。しかし、Emscripten と WebAssembly(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"]'
は、cwrap()
関数を JavaScript ファイルで使用できるようにコンパイラに指示します。この関数については後で詳しく説明します。-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>
このコードを実行すると、コンソールに 12 番目のフィボナッチ数である「144」が表示されます。
聖杯: C ライブラリのコンパイル
これまで作成してきた C コードは、Wasm を念頭に置いて作成されてきました。ただし、WebAssembly の主なユースケースは、既存の C ライブラリのエコシステムを利用して、デベロッパーがウェブ上で使用できるようにすることです。これらのライブラリは、多くの場合、C の標準ライブラリ、オペレーティング システム、ファイル システムなどに依存しています。Emscripten はこれらの機能のほとんどを提供しますが、いくつかの制限があります。
元の目標である WebP のエンコーダを Wasm にコンパイルすることに戻りましょう。WebP コーデックのソースは C で記述されており、GitHub で入手できます。また、API ドキュメントも充実しています。これはかなり良い出発点です。
$ git clone https://github.com/webmproject/libwebp
まず、encode.h
の WebPGetEncoderVersion()
を JavaScript に公開してみましょう。webp.c
という C ファイルを作成します。
#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>
出力に修正バージョン番号が表示されます。
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 ランド内のイメージにメモリを割り当てるものと、それを解放するものの 2 つがあります。
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 が入力画像と出力画像の両方を格納するのに十分なメモリを確保できないというエラーが発生することがあります。
幸いなことに、この問題の解決策はエラー メッセージに記載されています。コンパイル コマンドに -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);
まとめ
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 モジュールに任意のデータを提供するには、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
となります。