常に高速です
前回の記事では、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 が提供する主なメリットの一つは、ブラウザ間であっても予測可能なパフォーマンスです。厳格な型付けと低レベルのアーキテクチャにより、コンパイラはより強力な保証を行うことができるため、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 に変換する元の 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
Twiggy は、Rust の WebAssembly チームが提供するもう 1 つのツールで、WebAssembly モジュールから有益なデータを抽出します。このツールは Rust に固有のものではなく、モジュールの呼び出しグラフの検査、未使用または不要なセクションの特定、モジュールの合計ファイルサイズに貢献しているセクションの特定を行うことができます。後者は、Twiggy の top
コマンドで実行できます。
$ twiggy top rotate_bg.wasm
この場合、ファイルサイズの大部分はアロケータに起因していることがわかります。コードで動的割り当てを使用していないため、これは驚きでした。もうひとつの大きな要因は、「関数名」のサブセクションです。
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-opt
は Binaryen のツールです。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-opt
、wasm-strip
、gzip の後で 1.6 KB の wasm モジュールが生成されました。C と AssemblyScript によって生成されたモジュールよりも大きいものの、軽量と見なされるほど小さいです。
パフォーマンス
ファイルサイズのみに基づいて結論を急ぐ前に、ファイルサイズではなくパフォーマンスを最適化するためにこの作業を進めました。パフォーマンスをどのように測定し、どのような結果が得られたのでしょうか。
ベンチマークを行う方法
WebAssembly は低レベルのバイトコード形式ですが、ホスト固有のマシンコードを生成するには、コンパイラを介して送信する必要があります。JavaScript と同様に、コンパイラは複数のステージで動作します。簡単に説明すると、最初のステージはコンパイルがはるかに高速ですが、生成されるコードは遅くなる傾向があります。モジュールの実行が開始されると、ブラウザは頻繁に使用される部分を検出し、より最適化されたが遅いコンパイラを介してそれらを送信します。
このユースケースでは、画像を回転するコードが 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 モジュールを生成でき、非常に小さく、パフォーマンスも良好です。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
)の使用に戻すことができました。gzip 圧縮後の Rust モジュールは 370 B になります。詳しくは、Squoosh で開いた PR をご覧ください。
この取り組みにご協力いただいた Ashley Williams 様、Steve Klabnik 様、Nick Fitzgerald 様、Max Graey 様に感謝いたします。