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

常に高速で

これまでに紹介した WebAssembly コレクションの記事 を使用すると、C/C++ のライブラリ エコシステムをウェブに導入できます。1 つのアプリで C/C++ ライブラリを幅広く活用するのが squoosh です。 開発されたさまざまなコーデックを使用して画像を圧縮できる C++ から WebAssembly にコンパイルされます。

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

私の経験上、ウェブ上で発生するパフォーマンスの問題のほとんどは、 過度なペイントが発生しますが、アプリは時として 計算コストが高く、時間がかかる作業です。WebAssembly を使用すると、 見てみましょう。

ホットパス

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

この関数は、入力画像のすべてのピクセルを繰り返し、 異なる位置に配置することで回転を実現しています。4,094 ピクセル幅の 4,096 ピクセルの画像(16 メガピクセル)の場合、画像の反復処理を 1,600 万回以上必要 これは「ホットパス」と呼ばれるものです。かなり大きいとは言え、 反復の回数。テストしたブラウザのうち 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 が返されます。 ファイルを宣言します。この宣言は、 「imports」出力のリスト、モジュールが環境から取得する (関数、定数、メモリのチャンク)をホストで使用できるようにし、 含まれている関数の実際のバイナリ命令です。

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

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

メモリ管理

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

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

選択肢が豊富

元の JavaScript 関数については、 WebAssembly-fy で JavaScript 固有の API を使用せずにコードを実行できます。かなり直線的でなければなりません 任意の言語に移植できます。3 つの異なる言語を評価しました WebAssembly にコンパイルできるスクリプト: C/C++、Rust、AssemblyScript唯一の疑問 「生のメモリにどうやってアクセスするのか」です どうでしょうか

C と Emscripten

Emscripten は、WebAssembly ターゲット用の C コンパイラです。エムスクリプトンの目標は、 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 ビット整数の配列。通常、入力画像は 出力画像は入力画像の直後から始まります。 終了:

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 というグルーコードファイルと Wasm モジュールを生成します。 c.wasm と呼ばれます。Wasm モジュールは gzip で最大 260 バイトにしか圧縮されませんが、 グルーコードは gzip 後の約 3.5KB です。いろいろと試した結果、Google Cloud の グルーコードを作成し、標準 API を使用して WebAssembly モジュールをインスタンス化します。 何も使用しない限り、Emscripten では多くの場合可能です。 これは C 標準ライブラリのものです。

Rust

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

これらのツールの一つが wasm-pack です。 rustwasm ワーキング グループwasm-pack コードをウェブ対応のモジュールに変換して すぐに使えるバンドル機能を提供していますwasm-pack はとても 現在のところ、Rust でのみ機能します。グループ: 他の WebAssembly ターゲット言語のサポートの追加を検討中です。

Rust では、C における配列のことをスライスといいます。C の場合と同様に、鍵の スライスに分割できますこれはメモリ安全性モデルに反します。 そのため、ここでは 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-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 チーム: WebAssembly から多くの知見に満ちたデータを抽出 説明します。このツールは Rust 固有のものではなく、 使用されていないセクションや不要なセクションを特定して モジュールの合計ファイルサイズに占める割合「 後者の場合は、Twiggy の top コマンドを使用します。

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

この場合、ファイルサイズの大部分が 割り当てられています。このコードでは動的割り当てを使用していないので、これは驚くべきものでした。 もう一つの大きな要因は「関数名」ですサブセクションにあります

Wasm-Strip

wasm-strip は、WebAssembly Binary Toolkit(略して wabt)のツールです。これには、 WebAssembly モジュールを検査、操作できるツールがいくつか用意されています。 wasm2wat は、バイナリ Wasm モジュールを 記述できます。Wabt には wat2wasm も含まれており、 読み込める形式をバイナリ Wasm モジュールに戻します。Google Cloud で これら 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 コードを書き換えました。 Rust の標準ライブラリに、 #![no_std] 機能。また、これにより動的メモリ割り当てが完全に無効になり、 アロケータ コードを渡します。この 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 が構築と統合に費やした労力です モジュールを squoosh に変換します。数値を割り当てたり、 グラフは作成しませんが、いくつか表示したい 指摘します。

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 を使用してコンパイルされます生成 AI に関する 本番環境では別の言語を使用します。 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 WilliamsSteve KlabnikNick FitzgeraldMax Graey に心より感謝します。