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 システムを利用できます。不足しているものがあれば、インストールするだけで、自分のマシンや他のプロジェクトにどう影響するかを心配せずに済みます。問題が発生した場合は、コンテナを破棄して最初からやり直します。一度正常に機能すれば、引き続き動作し、同じ結果を得ることができます。

Docker Registry には、私が幅広く使用している trzeciEmscripten イメージがあります。

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 は、シェルを「フェイル ファスト」モードにします。スクリプト内のいずれかのコマンドがエラーを返すと、スクリプト全体が直ちに中止されます。スクリプトの最後の出力は常に成功メッセージまたはビルド失敗の原因となったエラーであるため、これは非常に便利です。

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

  • -O0: 最適化を行いません。動作しなくなったコードは除外されず、Emscripten は発行する JavaScript コードを圧縮しません。デバッグに適しています。
  • -O3: 積極的に最適化してパフォーマンスを高めます。
  • -Os: 二次的な基準として、パフォーマンスとサイズを積極的に最適化します。
  • -Oz: サイズを積極的に最適化し、必要に応じてパフォーマンスを犠牲にします。

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

emcc コマンドには多数のオプションがあります。なお、emcc は「GCC や clang などのコンパイラのドロップイン代替機能」であると考えられます。したがって、GCC からわかるすべてのフラグは、emcc でも実装される可能性が高くなります。-s フラグは、Emscripten を明示的に構成できる特別なフラグです。使用可能なすべてのオプションは Emscripten の settings.js にありますが、このファイルでは処理が追いつかなくなる可能性があります。以下は、ウェブ デベロッパーにとって最も重要な Emscripten フラグのリストです。

  • --bind は、embind を有効にします。
  • -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 ファイルが作成され、デモページで実際に定数が出力されることがわかります。

DevTools に emscripten によって出力された libvpx の ABI バージョンが表示されています。

また、ビルドプロセスに長い時間がかかることもわかります。ビルドに時間がかかる理由はさまざまです。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 のクローンを作成する必要があります。副作用として 昼寝はもう必要ありません