Emscripten と npm

この設定に WebAssembly を統合するにはどうすればよいですか?この記事では、C/C++ と Emscripten を例に、この問題を解決します。

WebAssembly(wasm)は、パフォーマンス プリミティブまたは既存の C++ コードベースをウェブで実行する方法としてよく使われます。squoosh.app では、wasm には少なくとも 3 つ目の視点があることを示したいと考えました。それは、他のプログラミング言語の巨大なエコシステムを活用することです。Emscripten を使用すると、C/C++ コードを使用できます。Rust には wasm サポートが組み込まれておりGo チームも取り組んでいます。他の言語にも順次対応していく予定です。

このようなシナリオでは、wasm はアプリの中心ではなく、パズルのピース、つまり別のモジュールです。アプリにはすでに JavaScript、CSS、画像アセット、ウェブ中心のビルドシステムがあり、React などのフレームワークも使用しているかもしれません。この設定に WebAssembly を統合するにはどうすればよいですか?この記事では、C/C++ と Emscripten を例として、この問題を解決します。

Docker

Emscripten を使用する際に Docker が非常に役立つことがわかりました。C/C++ ライブラリは、ビルドされたオペレーティング システムで動作するように記述されることがよくあります。一貫性のある環境は非常に便利です。Docker を使用すると、Emscripten で動作するようにすでに設定され、すべてのツールと依存関係がインストールされた仮想化 Linux システムを取得できます。不足しているものがあれば、自分のマシンや他のプロジェクトに影響することを気にせずにインストールできます。問題が発生した場合は、コンテナを破棄して最初からやり直します。1 回動作すれば、その後も動作し、同じ結果が得られることが保証されます。

Docker レジストリには、私が頻繁に使用している trzeci による Emscripten イメージがあります。

npm との統合

ほとんどの場合、ウェブ プロジェクトのエントリ ポイントは npm の package.json です。通常、ほとんどのプロジェクトは npm install && npm run build でビルドできます。

一般に、Emscripten によって生成されたビルド アーティファクト(.js ファイルと .wasm ファイル)は、単なる JavaScript モジュールおよび単なるアセットとして扱われるべきです。JavaScript ファイルは webpack や rollup などのバンドラーで処理できます。wasm ファイルは、画像などの他の大きなバイナリ アセットと同様に扱う必要があります。

そのため、Emscripten ビルド アーティファクトは、「通常の」ビルド プロセスが開始される前にビルドする必要があります。

{
    "name": "my-worldchanging-project",
    "scripts": {
    "build:emscripten": "docker run --rm -v $(pwd):/src trzeci/emscripten
./build.sh",
    "build:app": "<the old build command>",
    "build": "npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

新しい build:emscripten タスクは Emscripten を直接呼び出すことができますが、前述のように、ビルド環境の一貫性を確保するために Docker を使用することをおすすめします。

docker run ... trzeci/emscripten ./build.sh は、trzeci/emscripten イメージを使用して新しいコンテナを起動し、./build.sh コマンドを実行するように Docker に指示します。build.sh は、次に作成するシェル スクリプトです。--rm は、実行が完了した後にコンテナを削除するように Docker に指示します。これにより、古いマシンイメージのコレクションが時間の経過とともに蓄積されることはありません。-v $(pwd):/src は、Docker に現行ディレクトリ($(pwd))をコンテナ内の /src にミラーリングすることを指示します。コンテナ内の /src ディレクトリ内のファイルに加えた変更は、実際のプロジェクトに反映されます。これらのミラーリングされたディレクトリは「バインド マウント」と呼ばれます。

build.sh を見てみましょう。

#!/bin/bash

set -e

export OPTIMIZE="-Os"
export LDFLAGS="${OPTIMIZE}"
export CFLAGS="${OPTIMIZE}"
export CXXFLAGS="${OPTIMIZE}"

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C++ code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    src/my-module.cpp

    # Create output folder
    mkdir -p dist
    # Move artifacts
    mv my-module.{js,wasm} dist
)
echo "============================================="
echo "Compiling wasm bindings done"
echo "============================================="

ここでは多くのことを分析できます。

set -e はシェルを「fail fast」モードにします。スクリプト内のコマンドがエラーを返すと、スクリプト全体が直ちに中止されます。スクリプトの最後の出力は常に成功メッセージか、ビルドの失敗の原因となったエラーになるため、これは非常に役立ちます。

export ステートメントでは、いくつかの環境変数の値を定義します。これらを使用すると、追加のコマンドライン パラメータを C コンパイラ(CFLAGS)、C++ コンパイラ(CXXFLAGS)、リンカー(LDFLAGS)に渡すことができます。これらはすべて OPTIMIZE を介してオプティマイザー設定を受け取り、すべてが同じ方法で最適化されるようにします。OPTIMIZE 変数には、次の 2 つの値を使用できます。

  • -O0: 最適化を行いません。不要なコードは削除されず、Emscripten は出力する JavaScript コードを縮小化しません。デバッグに便利です。
  • -O3: パフォーマンスを重視して積極的に最適化します。
  • -Os: パフォーマンスを重視して積極的に最適化し、サイズを二次的な基準として使用します。
  • -Oz: サイズを積極的に最適化し、必要に応じてパフォーマンスを犠牲にします。

ウェブの場合、ほとんどは -Os をおすすめします。

emcc コマンドには、独自のオプションが多数あります。emcc は「GCC や clang などのコンパイラのドロップイン代替」であることに注意してください。そのため、GCC で使用できるフラグは、emcc でも実装されている可能性が高くなります。-s フラグは、Emscripten を具体的に構成できるという点で特殊です。使用可能なすべてのオプションは Emscripten の settings.js にありますが、このファイルは非常に大きくなる可能性があります。ウェブ デベロッパーにとって特に重要な Emscripten フラグを以下に示します。

  • --bindembind を有効にします。
  • -s STRICT=1 は、非推奨のビルド オプションのサポートをすべて終了します。これにより、コードが前方互換性のある方法でビルドされます。
  • -s ALLOW_MEMORY_GROWTH=1 を使用すると、必要に応じてメモリを自動的に増やすことができます。この記事の執筆時点では、Emscripten は最初に 16 MB のメモリを割り当てます。コードがメモリのチャンクを割り当てる際に、このオプションは、メモリが枯渇したときにこれらのオペレーションで wasm モジュール全体を失敗させるか、グルーコードが割り当てに対応するために合計メモリを拡張することを許可するかを決定します。
  • -s MALLOC=... は、使用する malloc() 実装を選択します。emmalloc は、Emscripten 専用の小さくて高速な malloc() 実装です。代替は、完全な malloc() 実装である dlmalloc です。多数の小さなオブジェクトを頻繁に割り当てる場合や、スレッドを使用する場合は、dlmalloc に切り替える必要があります。
  • -s EXPORT_ES6=1 は、JavaScript コードを任意のバンドラーで動作するデフォルトのエクスポートを含む ES6 モジュールに変換します。-s MODULARIZE=1 の設定も必要です。

次のフラグは必ずしも必要ではなく、デバッグ目的でのみ役立ちます。

  • -s FILESYSTEM=0 は Emscripten に関連するフラグで、C/C++ コードがファイル システム オペレーションを使用するときに、ファイル システムをエミュレートする機能です。コンパイルするコードを分析して、ファイル システム エミュレーションをグルーコードに含めるかどうかを決定します。ただし、この分析が間違っている場合もあり、必要のないファイル システム エミュレーションのために 70 kB というかなり大きな追加のグルーコードを支払うことになります。-s FILESYSTEM=0 を使用すると、Emscripten にこのコードを含めないように強制できます。
  • -g4 を指定すると、Emscripten は .wasm にデバッグ情報を含め、wasm モジュールのソースマップ ファイルも出力します。Emscripten を使用したデバッグの詳細については、デバッグのセクションをご覧ください。

このように、この設定をテストするために、小さな my-module.cpp を作成しましょう。

    #include <emscripten/bind.h>

    using namespace emscripten;

    int say_hello() {
      printf("Hello from your wasm module\n");
      return 0;
    }

    EMSCRIPTEN_BINDINGS(my_module) {
      function("sayHello", &say_hello);
    }

index.html:

    <!doctype html>
    <title>Emscripten + npm example</title>
    Open the console to see the output from the wasm module.
    <script type="module">
    import wasmModule from "./my-module.js";

    const instance = wasmModule({
      onRuntimeInitialized() {
        instance.sayHello();
      }
    });
    </script>

(すべてのファイルを含む gist はこちらです)。

すべてをビルドするには、次のコマンドを実行します。

$ npm install
$ npm run build
$ npm run serve

localhost:8080 に移動すると、DevTools コンソールに次の出力が表示されます。

C++ と Emscripten を介して出力されたメッセージを示す DevTools。

C/C++ コードを依存関係として追加する

ウェブアプリ用の C/C++ ライブラリをビルドする場合は、そのコードをプロジェクトの一部にする必要があります。コードはプロジェクトのリポジトリに手動で追加することも、npm を使用してこのような依存関係を管理することもできます。ウェブアプリで libvpx を使用するとします。libvpx は、.webm ファイルで使用されるコーデックである VP8 で画像をエンコードするための C++ ライブラリです。ただし、libvpx は npm になく、package.json もないため、npm を使用して直接インストールすることはできません。

この問題を解決するために、napa があります。napa を使用すると、任意の git リポジトリ URL を依存関係として node_modules フォルダにインストールできます。

napa を依存関係としてインストールします。

$ npm install --save napa

napa をインストール スクリプトとして実行してください。

{
// ...
"scripts": {
    "install": "napa",
    // ...
},
"napa": {
    "libvpx": "git+https://github.com/webmproject/libvpx"
}
// ...
}

npm install を実行すると、napa は libvpx GitHub リポジトリのクローンを libvpx という名前で node_modules に作成します。

ビルド スクリプトを拡張して libvpx をビルドできるようになりました。libvpx は configuremake を使用してビルドされます。幸いなことに、Emscripten を使用すると、configuremake で Emscripten のコンパイラを使用できます。この目的のために、ラッパー コマンド emconfigureemmake があります。

# ... above is unchanged ...
echo "============================================="
echo "Compiling libvpx"
echo "============================================="
(
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
# ... below is unchanged ...

C/C++ ライブラリは、ライブラリが公開するデータ構造、クラス、定数などを定義するヘッダー(従来は .h または .hpp ファイル)と、実際のライブラリ(従来は .so または .a ファイル)の 2 つの部分に分割されます。コードでライブラリの VPX_CODEC_ABI_VERSION 定数を使用するには、#include ステートメントを使用してライブラリのヘッダー ファイルを含める必要があります。

#include "vpxenc.h"
#include <emscripten/bind.h>

int say_hello() {
    printf("Hello from your wasm module with libvpx %d\n", VPX_CODEC_ABI_VERSION);
    return 0;
}

問題は、コンパイラが vpxenc.h を探す場所を認識していないことです。これは -I フラグの目的です。コンパイラにヘッダー ファイルをチェックするディレクトリを指示します。また、コンパイラに実際のライブラリ ファイルも指定する必要があります。

# ... above is unchanged ...
echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C++ code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s ASSERTIONS=0 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    -I ./node_modules/libvpx \
    src/my-module.cpp \
    build-vpx/libvpx.a

# ... below is unchanged ...

ここで npm run build を実行すると、プロセスが新しい .js と新しい .wasm ファイルをビルドし、デモページが実際に定数を出力することがわかります。

emscripten を介して出力された libvpx の ABI バージョンを示す DevTools。

また、ビルドプロセスに時間がかかることにも気づくでしょう。ビルド時間が長くなる理由はさまざまです。libvpx の場合、ソースファイルが変更されていない場合でも、ビルド コマンドを実行するたびに VP8 と VP9 の両方のエンコーダとデコーダをコンパイルするため、時間がかかります。my-module.cpp に小さな変更を加えただけでも、ビルドに長い時間がかかります。libvpx のビルド アーティファクトは、一度ビルドされたら保持しておくと非常に便利です。

この方法の 1 つは、環境変数を使用することです。

# ... above is unchanged ...
eval $@

echo "============================================="
echo "Compiling libvpx"
echo "============================================="
test -n "$SKIP_LIBVPX" || (
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="
# ... below is unchanged ...

(すべてのファイルを含む gist をご覧ください)。

eval コマンドを使用すると、ビルド スクリプトにパラメータを渡して環境変数を設定できます。$SKIP_LIBVPX が設定されている場合(任意の値に)、test コマンドは libvpx のビルドをスキップします。

これで、モジュールをコンパイルできますが、libvpx の再構築はスキップされます。

$ npm run build:emscripten -- SKIP_LIBVPX=1

ビルド環境をカスタマイズする

ライブラリによっては、ビルドに追加のツールが必要になることがあります。Docker イメージで提供されるビルド環境にこれらの依存関係がない場合は、自分で追加する必要があります。たとえば、doxygen を使用して libvpx のドキュメントもビルドするとします。Doxygen は Docker コンテナ内では使用できませんが、apt を使用してインストールできます。

build.sh でこれを行うと、ライブラリをビルドするたびに doxygen を再ダウンロードして再インストールすることになります。これは無駄であるだけでなく、オフラインでプロジェクトに取り組むこともできなくなります。

ここでは、独自の Docker イメージをビルドすることをおすすめします。Docker イメージは、ビルドステップを記述する Dockerfile を作成することでビルドされます。Dockerfile は非常に強力で、多くのコマンドがありますが、ほとんどの場合、FROMRUNADD のみを使用すれば十分です。この例の場合は、次のようになります。

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen

FROM を使用すると、開始点として使用する Docker イメージを宣言できます。ベースとして trzeci/emscripten(これまで使用してきたイメージ)を選択しました。RUN を使用すると、コンテナ内でシェル コマンドを実行するように Docker に指示します。これらのコマンドによってコンテナに加えられた変更は、Docker イメージの一部になります。build.sh を実行する前に Docker イメージがビルドされ、使用可能になっていることを確認するには、package.json を少し調整する必要があります。

{
    // ...
    "scripts": {
    "build:dockerimage": "docker image inspect -f '.' mydockerimage || docker build -t mydockerimage .",
    "build:emscripten": "docker run --rm -v $(pwd):/src mydockerimage ./build.sh",
    "build": "npm run build:dockerimage && npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

(すべてのファイルを含む gist をご覧ください)。

これにより、Docker イメージがビルドされます(まだビルドされていない場合のみ)。その後は以前と同じように実行されますが、ビルド環境で doxygen コマンドが使用可能になり、libvpx のドキュメントもビルドされるようになります。

まとめ

C/C++ コードと npm が自然に適合しないのは当然ですが、追加のツールと Docker が提供する分離機能を使用すると、かなり快適に動作させることができます。この設定はすべてのプロジェクトで機能するわけではありませんが、ニーズに合わせて調整できる適切な出発点となります。改善点がありましたら、共有してください。

付録: Docker イメージレイヤの活用

別の解決策として、これらの問題の多くを Docker と Docker のスマートなキャッシュ保存アプローチでカプセル化する方法があります。Docker は Dockerfile をステップごとに実行し、各ステップの結果に独自のイメージを割り当てます。このような中間画像は「レイヤ」と呼ばれることがよくあります。Dockerfile のコマンドが変更されていない場合、Dockerfile を再ビルドしても、Docker はそのステップを再実行しません。代わりに、イメージが最後にビルドされたときのレイヤが再利用されます。

以前は、アプリをビルドするたびに libvpx を再ビルドしないように、ある程度の作業が必要でした。代わりに、libvpx のビルド手順を build.sh から Dockerfile に移動して、Docker のキャッシュ メカニズムを利用できます。

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen git && \
    mkdir -p /opt/libvpx/build && \
    git clone https://github.com/webmproject/libvpx /opt/libvpx/src
RUN cd /opt/libvpx/build && \
    emconfigure ../src/configure --target=generic-gnu && \
    emmake make

(すべてのファイルを含む gist をご覧ください)。

docker build の実行時にバインド マウントがないため、git を手動でインストールして libvpx を複製する必要があります。副作用として、napa は不要になりました。