アプリの JavaScript のホットパスを WebAssembly に置き換える

常に高速です

前回記事では、WebAssembly を使用して C/C++ のライブラリ エコシステムをウェブに導入する方法について説明しました。C/C++ ライブラリを広範に使用しているアプリの 1 つが squoosh です。これは、C++ から WebAssembly にコンパイルされたさまざまなコーデックで画像を圧縮できるウェブアプリです。

WebAssembly は、.wasm ファイルに保存されているバイトコードを実行する低レベルの仮想マシンです。このバイトコードは、JavaScript よりもはるかに迅速にホストシステム用にコンパイルおよび最適化できるように、強力な型付けと構造化が施されています。WebAssembly は、最初からサンドボックス化と埋め込みを念頭に置いたコードを実行する環境を提供します。

私の経験上、ウェブでのパフォーマンスの問題のほとんどは、強制レイアウトと過剰なペイントによって発生しますが、アプリでは、計算コストが高く時間のかかるタスクを実行しなければならないことがあります。ここでは WebAssembly が役に立ちます。

ホットパス

squoosh で、画像バッファを 90 の倍数だけ回転する JavaScript 関数を作成しました。OffscreenCanvas が理想的ですが、対象とするブラウザではサポートされておらず、Chrome ではバグがあります。

この関数は入力画像のすべてのピクセルを反復処理し、出力画像内の別の位置にコピーして回転を実現します。4,094 x 4,096 ピクセルの画像(16 メガピクセル)の場合、内部コードブロックを 1,600 万回以上反復する必要があります。これは「ホットパス」と呼ばれます。かなり多くの反復処理にもかかわらず、テストした 3 つのブラウザのうち 2 つが 2 秒以内にタスクを完了しています。このタイプのインタラクションに許容される期間。

for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    const in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

ただし、1 つのブラウザでは 8 秒以上かかります。ブラウザが JavaScript を最適化する方法は非常に複雑で、エンジンによって最適化対象が異なります。そのままの実行に合わせて最適化するタイプもあれば、DOM との相互作用に合わせて最適化するタイプもあります。この場合、1 つのブラウザで最適化されていないパスに遭遇しています。

一方、WebAssembly は、元の実行速度を中心に構築されています。このようなコードでブラウザ間で高速で予測可能なパフォーマンスを実現するには、WebAssembly が役立ちます。

予測可能なパフォーマンスを実現する WebAssembly

一般的に、JavaScript と WebAssembly は同じピーク パフォーマンスを達成できます。しかし、JavaScript では、このようなパフォーマンスを実現できるのは「高速パス」でなければならず、多くの場合、この「高速パス」を維持するのは困難です。WebAssembly が提供する主なメリットの 1 つは、ブラウザ間であっても予測可能なパフォーマンスです。厳格な型付けと低レベルのアーキテクチャにより、コンパイラはより強力な保証を行うことができるため、WebAssembly コードは 1 回だけ最適化すれば、常に「高速パス」を使用できます。

WebAssembly 用に記述する

以前は、C/C++ ライブラリを WebAssembly にコンパイルして、ウェブでその機能を使用していました。ライブラリのコードはあまり編集しませんでした。ブラウザとライブラリの橋渡しをするために、少量の C/C++ コードを記述しただけです。今回の動機は異なります。WebAssembly の利点を活用できるように、WebAssembly を念頭に置いてゼロから何かを記述したいと考えています。

WebAssembly アーキテクチャ

WebAssembly に記述する場合は、WebAssembly の実際の機能についてもう少し理解しておくと役に立ちます。

WebAssembly.org からの引用:

C コードまたは Rust コードを WebAssembly にコンパイルすると、モジュール宣言を含む .wasm ファイルが生成されます。この宣言は、モジュールが環境から想定する「インポート」のリスト、このモジュールがホストに提供するエクスポートのリスト(関数、定数、メモリのチャンク)、そしてもちろん、その中に含まれる関数の実際のバイナリ命令で構成されます。

調べるまで気付かなかったことがあります。WebAssembly を「スタックベースの仮想マシン」にするスタックは、WebAssembly モジュールが使用するメモリのチャンクに保存されません。このスタックは完全に VM 内部にあり、ウェブ デベロッパーはアクセスできません(DevTools を介する場合を除く)。そのため、追加のメモリをまったく必要とせず、VM 内部スタックのみを使用する WebAssembly モジュールを作成できます。

この例では、追加のメモリを使用して、画像のピクセルに任意でアクセスし、その画像の回転バージョンを生成する必要があります。これが WebAssembly.Memory の用途です。

メモリ管理

通常、追加のメモリを使用すると、そのメモリを何らかの方法で管理する必要が生じます。メモリのどの部分が使用されていますか?どの機能が無料ですか?たとえば C には、n 連続バイトのメモリ空間を見つける malloc(n) 関数があります。この種の関数は「アロケータ」とも呼ばれます。もちろん、使用しているアロケータの実装は WebAssembly モジュールに含める必要があり、ファイルサイズが増加します。これらのメモリ管理関数のサイズとパフォーマンスは、使用するアルゴリズムによって大きく異なる場合があります。そのため、多くの言語では複数の実装(「dmalloc」、「emmalloc」、「wee_alloc」など)から選択できます。

この例では、WebAssembly モジュールを実行する前に入力画像のサイズ(および出力画像のサイズ)がわかっています。ここで、次の機会が見つかりました。従来は、入力画像の RGBA バッファをパラメータとして WebAssembly 関数に渡し、回転した画像を戻り値として返していました。その戻り値を生成するには、アロケータを使用する必要があります。ただし、必要なメモリの合計量(入力画像の 2 倍、入力用と出力用)がわかっているため、JavaScript を使用して入力画像を WebAssembly メモリに配置し、WebAssembly モジュールを実行して 2 番目の回転画像を生成してから、JavaScript を使用して結果を読み取ることができます。メモリ管理をまったく使用せずに済みます。

選択肢が豊富

WebAssembly-fy に使用する元の JavaScript 関数を見ると、JavaScript 固有の API を持たない純粋な計算コードであることがわかります。そのため、このコードを任意の言語に移植するのは比較的簡単です。私たちは、WebAssembly にコンパイルされる 3 つの異なる言語(C/C++、Rust、AssemblyScript)を評価しました。各言語について、メモリ管理機能を使用せずに未加工メモリにアクセスするにはどうすればよいかという唯一の疑問に答える必要があります。

C と Emscripten

Emscripten は、WebAssembly ターゲット用の C コンパイラです。Emscripten の目標は、GCC や clang などの有名な C コンパイラの代替として機能することであり、ほとんどのフラグと互換性があります。これは、既存の C コードと C++ コードを WebAssembly にコンパイルすることを可能な限り簡単にすることを目的とする Emscripten のミッションの中核です。

未加工のメモリへのアクセスは C の性質そのものです。そのためポインタが存在します。

uint8_t* ptr = (uint8_t*)0x124;
ptr[0] = 0xFF;

ここでは、数値 0x124 を符号なし 8 ビット整数(バイト)へのポインタに変換しています。これにより、ptr 変数は、他の配列と同様に使用できる、メモリアドレス 0x124 から始まる配列に変換されます。これにより、個々のバイトにアクセスして読み取りと書き込みを行うことができます。この例では、回転を実現するために並べ替える画像の RGBA バッファを調べます。ピクセルを移動するには、実際には連続する 4 つのバイト(R、G、B、A の各チャンネルに 1 バイトずつ)を一度に移動する必要があります。これを簡単にするために、符号なしの 32 ビット整数の配列を作成します。入力画像は通常、アドレス 4 で開始し、出力画像は入力画像の終了直後に開始します。

int bpp = 4;
int imageSize = inputWidth * inputHeight * bpp;
uint32_t* inBuffer = (uint32_t*) 4;
uint32_t* outBuffer = (uint32_t*) (inBuffer + imageSize);

for (int d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (int d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    int in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

JavaScript 関数全体を C に移植した後、emcc を使用して C ファイルをコンパイルできます。

$ emcc -O3 -s ALLOW_MEMORY_GROWTH=1 -o c.js rotate.c

いつものように、emscripten は c.js というグルーコード ファイルと c.wasm という wasm モジュールを生成します。wasm モジュールは gzip で約 260 バイトになりますが、グルーコードは gzip 後に約 3.5 KB になります。少し調整した後、グルーコードを破棄し、標準の API で WebAssembly モジュールをインスタンス化できました。多くの場合、C 標準ライブラリのものを使用していない限り、Emscripten で可能です。

Rust

Rust は、豊富な型システム、ランタイムなし、メモリ安全性とスレッド安全性を保証する所有権モデルを備えた、新しいモダンなプログラミング言語です。Rust は WebAssembly もコア機能としてサポートしており、Rust チームは WebAssembly エコシステムに多くの優れたツールに貢献してきました。

そのようなツールの 1 つが、rustwasm ワーキング グループによる wasm-pack です。wasm-pack は、コードをウェブ対応のモジュールに変換し、webpack などのバンドラですぐに使用できるようにします。wasm-pack は非常に便利ですが、現時点では Rust でのみ動作します。このグループは、他の WebAssembly ターゲット言語のサポートを追加することを検討しています。

Rust では、スライスは C の配列に相当します。C と同様に、開始アドレスを使用するスライスを作成する必要があります。これは Rust が適用するメモリ安全性モデルに反するため、この方法を実現するには unsafe キーワードを使用する必要があります。これにより、そのモデルに準拠しないコードを記述できます。

let imageSize = (inputWidth * inputHeight) as usize;
let inBuffer: &mut [u32];
let outBuffer: &mut [u32];
unsafe {
    inBuffer = slice::from_raw_parts_mut::<u32>(4 as *mut u32, imageSize);
    outBuffer = slice::from_raw_parts_mut::<u32>((imageSize * 4 + 4) as *mut u32, imageSize);
}

for d2 in 0..d2Limit {
    for d1 in 0..d1Limit {
    let in_idx = (d1Start + d1 * d1Advance) * d1Multiplier + (d2Start + d2 * d2Advance) * d2Multiplier;
    outBuffer[i as usize] = inBuffer[in_idx as usize];
    i += 1;
    }
}

を使用して Rust ファイルをコンパイルする

$ wasm-pack build

約 100 バイトのグルーコードを含む 7.6 KB の wasm モジュールが生成されます(どちらも gzip 後)。

AssemblyScript

AssemblyScript は、TypeScript から WebAssembly へのコンパイラを目指す比較的新しいプロジェクトです。ただし、任意の TypeScript を使用するわけではありません。AssemblyScript は TypeScript と同じ構文を使用しますが、標準ライブラリを独自のものに置き換えます。標準ライブラリは、WebAssembly の機能をモデル化します。つまり、手持ちの TypeScript を WebAssembly にコンパイルすることはできませんが、WebAssembly を記述するために新しいプログラミング言語を学習する必要がないことを意味します。

    for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
      for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
        let in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
        store<u32>(offset + i * 4 + 4, load<u32>(in_idx * 4 + 4));
        i += 1;
      }
    }

rotate() 関数に小さな型サーフェスがあることを考えると、このコードを AssemblyScript に移植するのは比較的簡単でした。load<T>(ptr: usize) 関数と store<T>(ptr: usize, value: T) 関数は、AssemblyScript によって提供され、未加工のメモリにアクセスします。AssemblyScript ファイルをコンパイルするには、AssemblyScript/assemblyscript npm パッケージをインストールして、

$ asc rotate.ts -b assemblyscript.wasm --validate -O3

AssemblyScript は、約 300 バイトの wasm モジュールとグルーコードを提供しません。このモジュールは、標準の WebAssembly API でのみ動作します。

WebAssembly フォレンジック

Rust の 7.6 KB は、他の 2 つの言語と比較すると驚くほど大きくなります。WebAssembly エコシステムには、(作成された言語に関係なく)WebAssembly ファイルを分析し、問題の原因を特定して状況を改善するのに役立つツールがいくつかあります。

トゥイギー

Twiggy は、Rust の WebAssembly チームが提供するもう 1 つのツールで、WebAssembly モジュールから有益なデータを抽出します。このツールは Rust に固有のものではなく、モジュールの呼び出しグラフの検査、未使用または不要なセクションの特定、モジュールの合計ファイルサイズに貢献しているセクションの特定を行うことができます。後者は、Twiggy の top コマンドで実行できます。

$ twiggy top rotate_bg.wasm
Twiggy のインストール画面のスクリーンショット

この場合、ファイルサイズの大部分はアロケータに起因していることがわかります。コードで動的割り当てを使用していないため、これは驚きでした。もうひとつの大きな要因は、「関数名」のサブセクションです。

wasm-strip

wasm-strip は、WebAssembly Binary Toolkit(略して wabt)のツールです。WebAssembly モジュールを検査および操作できるツールがいくつか含まれています。wasm2wat は、バイナリ wasm モジュールを人間が読める形式に変換する逆アセンブラです。Wabt には wat2wasm も含まれています。これにより、人間が読める形式をバイナリ wasm モジュールに戻すことができます。これら 2 つの補完的なツールを使用して WebAssembly ファイルを検査しましたが、wasm-strip が最も有用であることがわかりました。wasm-strip は、WebAssembly モジュールから不要なセクションとメタデータを削除します。

$ wasm-strip rotate_bg.wasm

これにより、rust モジュールのファイルサイズが 7.5 KB から 6.6 KB に(gzip 適用後)削減されます。

Wasm-opt

wasm-optBinaryen のツールです。WebAssembly モジュールを受け取り、バイトコードのみに基づいてサイズとパフォーマンスの両方を最適化しようとします。Emscripten などのツールには すでに実行されているものと実行されていないものがあります通常は、これらのツールを使用してバイト数をさらに削減することをおすすめします。

wasm-opt -O3 -o rotate_bg_opt.wasm rotate_bg.wasm

wasm-opt を使用すると、さらに数バイト削減して、gzip 後の合計サイズを 6.2 KB にできます。

#![no_std]

コンサルトと調査の結果、Rust の標準ライブラリを使用せずに、#![no_std] 機能を使用して Rust コードを書き直しました。また、動的メモリ割り当ても完全に無効になり、モジュールからアロケータ コードが削除されます。この Rust ファイルをコンパイルする

$ rustc --target=wasm32-unknown-unknown -C opt-level=3 -o rust.wasm rotate.rs

wasm-optwasm-strip、gzip の後で 1.6 KB の wasm モジュールが生成されました。C や AssemblyScript で生成されるモジュールよりも大きいものの、軽量とみなすには十分小さいサイズです。

パフォーマンス

ファイルサイズのみに基づいて結論を急ぐ前に、ファイルサイズではなくパフォーマンスを最適化するためにこの作業を進めました。パフォーマンスをどのように測定し、どのような結果が得られたのでしょうか。

ベンチマークを行う方法

WebAssembly は低レベルのバイトコード形式ですが、ホスト固有のマシンコードを生成するにはコンパイラを介して送信する必要があります。JavaScript と同様に、コンパイラは複数のステージで動作します。簡単に説明すると、最初のステージはコンパイルがはるかに高速ですが、生成されるコードは遅くなる傾向があります。モジュールの実行が開始されると、ブラウザは頻繁に使用される部分を検出し、より最適化されたが遅いコンパイラを介してそれらを送信します。

このユースケースでは、画像を回転するコードが 1 回または 2 回使用される点が興味深い点です。そのため、ほとんどの場合、最適化コンパイラのメリットを享受することはできません。これはベンチマークを行う際に留意すべき点です。WebAssembly モジュールを 10,000 回ループで実行すると、非現実的な結果が得られます。現実的な数値を得るには、モジュールを 1 回実行し、その 1 回の実行から得られた数値に基づいて判断する必要があります。

パフォーマンスの比較

言語ごとの速度の比較
ブラウザごとの速度の比較

これらの 2 つのグラフは、同じデータの異なるビューです。最初のグラフではブラウザごとに比較し、2 番目のグラフでは使用言語ごとに比較しています。対数スケールを選択していることに注意してください。また、同じマシンで実行できない 1 つのブラウザを除き、すべてのベンチマークで同じ 16 メガピクセルのテスト画像と同じホストマシンを使用していることも重要です。

これらのグラフをあまり分析しなくても、元のパフォーマンスの問題が解決したことは明らかです。すべての WebAssembly モジュールは 500 ミリ秒以内で実行されます。これは、WebAssembly によって「予測可能」なパフォーマンスを提供するという、当初の計画を裏付けるものです。どの言語を選択しても、ブラウザと言語間の差異は最小限に抑えられます。正確には、すべてのブラウザでの JavaScript の標準偏差は約 400 ミリ秒ですが、すべてのブラウザでのすべての WebAssembly モジュールの標準偏差は約 80 ミリ秒です。

作業量

もう 1 つの指標は、WebAssembly モジュールを作成して squoosh に統合するために費やした労力です。努力に数値を割り当てることは難しいため、グラフは作成しませんが、いくつか注意点があります。

AssemblyScript はスムーズに動作しました。TypeScript を使用して WebAssembly を記述できるため、同僚がコードレビューを非常に簡単に行うことができます。また、グルーフリーの WebAssembly モジュールが生成され、非常に小さく、パフォーマンスも良好です。TypeScript エコシステムのツール(prettier や tslint など)は、そのまま機能する可能性があります。

Rust を wasm-pack と組み合わせて使用することも非常に便利ですが、バインディングとメモリ管理が必要な場合に、大規模な WebAssembly プロジェクトを使用する場合に特に優れています。競争力のあるファイルサイズを実現するために、ハッピーパスから少し逸脱する必要がありました。

C と Emscripten は、非常に小さく高パフォーマンスの WebAssembly モジュールをすぐに作成できますが、グルーコードに飛び込んで必要最低限にまで削減する勇気がないと、合計サイズ(WebAssembly モジュール + グルーコード)が非常に大きくなります。

まとめ

JS のホットパスがあり、WebAssembly とのスピードや整合性を高めるには、どの言語を使用すべきですか。パフォーマンスに関する質問では常に、その答えは「場合によって異なる」です。では、リリースされた内容はどのようなものだったのでしょうか。

比較グラフ

使用した各言語のモジュール サイズとパフォーマンスのトレードオフを比較して、C または AssemblyScript が最適な選択のようです。Rust をリリースすることにしました。この決定には複数の理由があります。つまり、これまでに Squoosh で出荷されたコーデックはすべて、Emscripten を使用してコンパイルされています。WebAssembly エコシステムに関する知識を広げ、本番環境で別の言語を使用したいと考えました。AssemblyScript は優れた代替手段ですが、プロジェクトは比較的新しいため、コンパイラは Rust コンパイラほど成熟していません。

散布図では Rust と他の言語のファイルサイズの差が非常に大きいように見えますが、実際にはそうではありません。2 G を超える場合でも、500 B または 1.6 KB の読み込みにかかる時間は 10 分の 1 秒未満です。Rust は近いうちにモジュール サイズの差を埋めることを期待しています。

ランタイム パフォーマンスに関しては、Rust は AssemblyScript よりもブラウザ全体で平均的に高速です。特に大規模なプロジェクトでは、Rust は手動のコード最適化を必要とせずに高速なコードを生成できます。ただし、使い慣れたツールを使用することもできます。

とはいえ、AssemblyScript は素晴らしい発見でした。これにより、ウェブ デベロッパーは新しい言語を学習しなくても WebAssembly モジュールを作成できます。AssemblyScript チームは対応が迅速で、ツールチェーンの改善に積極的に取り組んでいます。今後も AssemblyScript を注視していきます。

更新: Rust

この記事の公開後、Rust チームの Nick Fitzgerald 氏から、Rust Wasm に関する優れた書籍を紹介していただきました。この書籍には、ファイルサイズの最適化に関するセクションが含まれています。そこに記載されている手順(特に、リンク時最適化と手動パニック処理を有効にする)に沿って、「通常の」Rust コードを記述し、ファイルサイズを増やすことなく Cargo(Rust の npm)の使用に戻すことができました。Rust モジュールの gzip 後の容量は 370B になります詳しくは、Squoosh で開いた PR をご覧ください。

この取り組みにご協力いただいた Ashley Williams 様、Steve Klabnik 様、Nick Fitzgerald 様、Max Graey 様に感謝いたします。