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

常に高速です。

以前の 記事では、WebAssembly を使用して C/C++ のライブラリ エコシステムをウェブに取り込む方法について説明しました。C/C++ ライブラリを幅広く使用するアプリの一つが 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 の主なメリットの一つは、どのブラウザでもパフォーマンスが予測可能であることです。厳密な入力と低レベルのアーキテクチャにより、コンパイラによる保証が強化されます。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 では malloc(n) 関数を使用して 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)を評価しました。各言語について答えなければならないことは、メモリ管理機能を使用せずに RAW メモリにアクセスするにはどうすればよいのか、ということです。

C と Emscripten

Emscripten は、WebAssembly ターゲット用の C コンパイラです。Emscripten の目標は、GCC や clang などの有名な C コンパイラのドロップイン代替として機能し、ほぼフラグ互換であることです。既存の C / C++ コードを WebAssembly にできるだけ簡単にコンパイルできるようにすることを目指すため、これは Emscripten の使命の中核です。

RAW メモリへのアクセスは 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 バイトのグルーコード (いずれも gzip の後に) を含む 7.6 KB の wasm モジュールが生成されます。

AssemblyScript

AssemblyScript は、TypeScript-to-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 から提供されています。Google の 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 のインストールのスクリーンショット

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

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 ステージではコンパイルははるかに速くなりますが、コードの生成が遅くなる傾向があります。モジュールの実行が開始されると、ブラウザは頻繁に使用される部分を監視し、それらの部分を、より最適化されているが低速なコンパイラを介して送信します。

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

パフォーマンスの比較

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

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

これらのグラフをあまり分析しなければ、元のパフォーマンスの問題であるすべての 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 が最適であると考えられます。Google は Rust を出荷することにしました。この決定には複数の理由があります。Squoosh で出荷されているすべてのコーデックは、今のところ Emscripten を使用してコンパイルされています。私たちは、WebAssembly エコシステムに関する知識を広げ、本番環境で別の言語を使用したいと考えていました。AssemblyScript は強力な代替手段ですが、プロジェクトは比較的新しく、コンパイラは Rust コンパイラほど成熟していません。

Rust と他の言語サイズとでファイルサイズの違いは散布図ではかなり大きいですが、実際にはそれほど大きな違いではありません。2G の容量でも 500 バイトまたは 1.6 KB を読み込む場合、1 秒 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 WilliamsSteve KlabnikNick FitzgeraldMax Graey に感謝します。