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 には、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 変数に指定できる値は次のとおりです。

  • -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() 実装です。代替手段として、dlmalloc があります。これは、本格的な malloc() 実装です。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 リポジトリのクローンを node_moduleslibvpx という名前で作成します。

これで、ビルド スクリプトを拡張して 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 のビルド アーティファクトは、初回ビルド後に保持しておくと非常に便利です。

環境変数を使用する方法があります。

# ... 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 を再ビルドしても、そのステップは実際には再実行されません。代わりに、イメージが最後にビルドされたときのレイヤを再利用します。

以前は、アプリをビルドするたびに 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 は不要になりました。