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

常に高速で

以前の 記事では、WebAssembly を使用して C/C++ のライブラリ エコシステムをウェブに持ち込む方法について説明しました。C/C++ ライブラリを多用するアプリの 1 つに squoosh があります。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 コードは一度だけ最適化するだけでよく、常に「高速パス」が使用されます。

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 倍、入力用に 1 回、出力用に 1 回)がわかっているため、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 バイトを一度に移動する必要があります(チャネルごとに 1 バイト、R、G、B、A)。これを簡単にするために、符号なし 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 エコシステムに多くの優れたツールに貢献してきました。

これらのツールの一つに、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.6KB の Wasm モジュールを生成します(どちらも gzip 後)。

AssemblyScript

AssemblyScript は、TypeScript から WebAssembly へのコンパイラとなることを目的とした、比較的新しいプロジェクトです。ただし、TypeScript のみを使用するわけではない点に注意してください。 AssemblyScript は TypeScript と同じ構文を使用しますが、標準ライブラリは使用されていません。これらの標準ライブラリは、WebAssembly の機能をモデル化します。つまり、WebAssembly に保存されている TypeScript をただコンパイルすることはできませんが、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 チームが開発したもう一つのツールで、WebAssembly モジュールから多くの洞察に満ちたデータを抽出します。このツールは Rust 固有のものではなく、モジュールのコールグラフなどを検査したり、使用されていないセクションや不要なセクションを特定したり、モジュールの合計ファイルサイズに影響しているセクションを特定したりできます。後者の場合は、Twiggy の top コマンドを使用します。

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

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

Wasm-Strip

wasm-strip は、WebAssembly Binary Toolkit(略して wabt)のツールです。WebAssembly モジュールを検査および操作できるツールがいくつか用意されています。wasm2wat は、バイナリ Wasm モジュールを人が読める形式に変換する逆アセンブラです。Wabt には、人が読める形式をバイナリ Wasm モジュールに戻すことができる wat2wasm も含まれています。これら 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 ステージではコンパイルははるかに速くなりますが、生成されるコードは遅くなります。モジュールの実行が開始されると、ブラウザは頻繁に使用される部分を監視し、最適化され、低速なコンパイラを介してそれらを送信します。

このユースケースの興味深いのは、画像を回転させるコードを 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 モジュールを生成できます。prettier や tslint などの TypeScript エコシステムのツールはおそらく問題なく機能します。

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

C と Emscripten は、非常に小さく高性能な WebAssembly モジュールを最初から作成しましたが、勇気を出してグルーコードに取り掛かり、必要最小限に減らさない限り、合計サイズ(WebAssembly モジュール + グルーコード)はかなり大きくなることになります。

おわりに

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

比較グラフ

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

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

ランタイム パフォーマンスの点では、Rust は AssemblyScript よりもブラウザ全体の平均が高速です。特に大規模なプロジェクトで Rust を使用すると、手動でコードを最適化しなくても、より高速にコードを生成できる可能性が高くなります。しかし、それでも使いやすいツールを使用できなくなるわけではありません。

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

更新: Rust

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

この道のりに尽力してくれた Ashley WilliamsSteve KlabnikNick FitzgeraldMax Graey に心より感謝します。