Emscripten と npm

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

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

このような場合、Wasm はアプリの中心要素ではなく、パズルピース(もう 1 つのモジュール)です。アプリにはすでに 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 やロールアップなどのバンドラで処理できるため、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() 実装です。別の方法は、完全な 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++ ライブラリは 2 つの部分に分かれています。ライブラリが公開するデータ構造、クラス、定数などを定義するヘッダー(通常は .h ファイルまたは .hpp ファイル)と、実際のライブラリ(通常は .so ファイルまたは .a ファイル)です。コード内でライブラリの 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 イメージの一部になりました。Docker イメージがビルドされ、build.sh を実行する前に使用可能であることを確認するには、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 のクローンを作成する必要があります。副作用として昼寝は もう不要です