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

常に高速です

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

WebAssembly は、格納されたバイトコードを実行する低レベルの仮想マシンです。 .wasm ファイル内。このバイトコードは、次のように厳密に型付けされ、構造化されています。 ホストシステムに合わせてコンパイルと最適化が JavaScript は可能です。WebAssembly は、事前構成済みのコードを実行できる サンドボックスと組み込みを 最初から念頭に置いてください

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

ホットパス

squoosh では、画像バッファを 90 度単位で回転する JavaScript 関数を作成しました。しばらく OffscreenCanvas は次のような用途に適しています。 これはターゲットとしていたブラウザでは サポートされていません バグが多い

この関数は、入力画像のすべてのピクセルを反復処理し、出力画像の別の位置にコピーして回転を実現します。4,094 ピクセル幅の 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.org を引用するには:

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

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

この例では、任意のアクセスを許可するため、追加のメモリを使用する必要があります。 変換し、その画像の回転バージョンを生成します。これは、 説明しました。WebAssembly.Memory

メモリ管理

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

このケースでは、入力画像の寸法(つまり、 (出力画像の寸法など)を事前に指定しておく必要があります。ここでは、 従来は入力画像の RGBA バッファを パラメータを WebAssembly 関数に渡して、戻り値として回転した画像を返す あります。この戻り値を生成するには、アロケータを利用する必要があります。 ただし、必要なメモリの総量(入力サイズの 2 倍)がわかっているため、 1 回は入力、もう 1 回は出力)、つまり入力画像を JavaScript を使用して WebAssembly メモリを使用する場合は、WebAssembly モジュールを実行して 2 番目の画像を回転し、JavaScript を使用して結果を読み取ります。メモリ管理をまったく使用せずに済みます。

選択肢が多彩に

元の JavaScript 関数については、 WebAssembly-fy で JavaScript 固有の API を使用せずにコードを実行できます。そのため、このコードを任意の言語に移植するのは比較的簡単です。私たちは、WebAssembly にコンパイルされる 3 つの異なる言語(C/C++、Rust、AssemblyScript)を評価しました。唯一の疑問 「生のメモリにどうやってアクセスするのか」です どうすればよいでしょうか

C と Emscripten

Emscripten は、WebAssembly ターゲット用の C コンパイラです。Emscripten の目標は、GCC や clang などの有名な C コンパイラの代替として機能することであり、ほとんどのフラグと互換性があります。これは Emscripten の使命の中核となる部分です。 既存の C / C++ コードを WebAssembly にコンパイルする際は、 考えています

生のメモリへのアクセスは、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 に移植したら、C ファイルをコンパイルできます。 emcc の場合:

$ 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 になります。いろいろと試した結果、Google Cloud の グルーコードを作成し、標準 API を使用して WebAssembly モジュールをインスタンス化します。 何も使用しない限り、Emscripten では多くの場合可能です。 これは C 標準ライブラリのものです。

Rust

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

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

Rust では、スライスは C の配列に相当します。C と同様に、追加の 1 行を スライスに分割できますこれはメモリ安全性モデルに反します。 そのため、ここでは 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-to-WebAssembly コンパイラを目指した若いプロジェクトです。ただし、任意の TypeScript を使用するわけではありません。AssemblyScript は TypeScript と同じ構文を使用しますが、標準ライブラリを独自のものに置き換えます。標準ライブラリは、 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 チームが提供するもう 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 段階は、 コンパイルは速くなりますが、生成されるコードは遅くなります。モジュールの実行が開始されると、ブラウザは頻繁に使用される部分を検出し、より最適化されたが遅いコンパイラを介してそれらを送信します。

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

パフォーマンスの比較

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

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

これらのグラフをあまり分析せずに、元のグラフを パフォーマンスの問題: すべての WebAssembly モジュールの実行時間は 500 ミリ秒以下。この WebAssembly を使用すると、予測可能な 向上しますどの言語を選択しても、ブラウザと言語間の差異は最小限に抑えられます。正確に表すと、JavaScript の標準偏差は 最大 400 ミリ秒ですが、Google のあらゆるブラウザの標準偏差は すべてのブラウザでの WebAssembly モジュールの実行にかかる時間は最大 80 ミリ秒です。

作業量

もう一つの指標は Google が構築と統合に費やした労力です スクオッシュに変換します。努力に数値を割り当てることは難しいため、グラフは作成しませんが、いくつか注意点があります。

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

Rust を wasm-pack と組み合わせることも非常に便利ですが、 大規模な WebAssembly プロジェクトでは、バインディングとメモリ管理が できます。競争力を高めるには、ハッピーパスから少し逸脱する必要がありました。 表示されます。

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

まとめ

JS ホットパスがあり、それを 一貫性を保ちやすくなりますパフォーマンスに関する質問の場合、答えは「状況によって異なる」です。では、リリースされた内容はどのようなものだったのでしょうか。

<ph type="x-smartling-placeholder">
</ph> 比較グラフ

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

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

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

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

更新: Rust

この記事の公開後、Nick Fitzgerald は Rust チームから、優れた Rust Wasm 書籍を紹介してもらいました。この書籍には、次の内容が含まれています。 ファイルサイズの最適化に関するセクションをご覧ください。コースの (最も注目すべき点は、リンク時間の最適化と手動の パニック処理など)により、「通常の」Rust コードを記述して、 ファイルサイズを肥大化しない Cargo(Rust の npm)Rust モジュールの終了 gzip 圧縮後 370 バイト、詳しくは、Squoosh で開いた PR をご覧ください。

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