WebAssembly を最適化する 6 つの方法

重要ポイント

  • 多くの言語が Wasm をサポートしていますが、一部の言語は他の言語よりも高速です。
  • 一部のコンパイラは、効率と速度のために Wasm の最適化をネイティブでサポートしています。
  • wasm-opt ツールは、作成に使用された元の言語に関係なく、Wasm バイナリを最適化できます。
  • JIT 対応のランタイムを使用すると、使用しているハードウェア プラットフォームによっては、ランタイムのパフォーマンスを向上させることができます。
  • 一部の Wasm ランタイムは、ネイティブの実行速度に到達するためにアプリケーションを事前 (AOT) にコンパイルすることさえできます。
  • 実験的な Wizer プロジェクトでは、Wasm バイナリを事前に初期化して起動にかかる時間を短縮することで、パフォーマンスをさらに向上させています。
  • 私たちの実践では、適切な最適化により、Wasm バイナリ サイズを 10 分の 1 に縮小できることがわかりました。

WebAssembly (多くの場合、Wasm と省略されます) はバイナリ実行形式です。 Rust、C、JavaScript、Python、Ruby、.NET 言語など、さまざまな言語を Wasm 経由で実行できます。

さらに、Wasm は幅広いハードウェアとオペレーティング システムで実行できます。 この仕様は、高速、コンパクト、そして何よりも安全になるように設計されています。

2022 年、Wasm はさまざまな状況で登場しました。 元々はブラウザー用に設計されたものですが、組み込みプログラミング、プラグイン、クラウド、およびエッジ コンピューティングに役立つことが判明しました。

これらのさまざまなユース ケースに共通することの 1 つは、パフォーマンスが非常に重要であるということです。 実行可能ファイルをすばやくロードすることはパフォーマンスの一部であるため、多くの場合、ファイル サイズはそのままのパフォーマンスに直接影響します。

この記事では、Wasm のパフォーマンスとファイル サイズを最適化する 6 つの方法を見ていきます。

言語の選択

各プログラミング言語には独自のニュアンスがあり、そのうちの 1 つは、言語を実行するために必要なランタイムの大きさです。 軽量な面では、C や Rust などの低レベルのシステム言語では、実行時のオーバーヘッドがわずかしか必要ありません。

Swift などの他のコンパイル済み言語は、大量のランタイムを必要とします。 Swift バイナリは、多数のビルトイン ビヘイビアが含まれているという理由だけで、かなり大きくなる場合があります。 Java と .NET も、同様の理由でバイナリ サイズが大きくなる傾向があります。

説明のために、Rust と Swift の「hello world」プログラムを見てみましょう。

Rust では、基本的な「hello world」プログラムは次のようになります。

fn main() {
    println!("Hello, world!");
}

でコンパイル cargo build —target wasm32-wasi、このバイナリは 2.0M です。 (これは最適化されていないバイナリです。後でこのファイル サイズに戻ります。)

Swift での同様のプログラムを次に示します。

print("Hello, World!n")

コマンドを使用して Swiftwasm プロジェクトでこれを Wasm にコンパイルします。 swiftc -target wasm32-unknown-wasi hello.swift -o hello.wasm 9.1M の画像を生成します。 これにより、Swift バージョンは同等の Rust バージョンよりも 4 倍以上大きくなります。

したがって、選択する言語は、バイナリのファイル サイズと起動時間に (少なくともある程度は) 影響します。 ただし、これはファイル サイズに関する最終的な言葉ではありません。 バイナリ サイズをさらに最適化する方法があります。

コンパイラ フラグを使用して最適化する

一部のコンパイラは、生成するバイナリを最適化できる組み込みのコンパイラ フラグを提供します。 長年の C および C++ ユーザーは、これに慣れています。 また、Rust や Zig などの新しい言語も最適化オプションを提供します。

前のセクションでは、単純な 3 行の Rust プログラムを見てきました。 デフォルトでコンパイルしたとき cargocommand、2.0Mバイナリを生成しました。 しかし、別のフラグを追加することで、そのサイズを縮小できます。 cargo build --target wasm32-wasi --release. これにより、1.9M バイナリが生成されます。 このような小さなプログラムでは、Rust の洗練されたランタイムでは、多くを削ることはできません。 ただし、より大きなプロジェクトでは、 –release フラグを使用するとファイル サイズを大幅に縮小できます。 たとえば、リリース フラグを指定せずに Bartholomew CMS をコンパイルすると 84M のバイナリが生成されますが、リリース フラグを使用すると 7M に縮小されます。 それは大きな節約です。

Rust のリリース ターゲットは、単にファイル サイズを縮小するだけではありません。 また、デバッガーや分析ツールで使用されるシンボルが削除されるため、実行速度が向上します。 これは、本番環境でコードを実行している場合、ほとんどの場合、価値のある機能です。 Bartholomew の完全な 84M バージョンを起動すると、実行に最大 1 秒かかる場合がありますが、最適化されたバージョンを使用すると、わずか数ミリ秒に短縮されます。

wasm-opt によるサイズの最適化

上記のセクションでは、一部のコンパイラが最適化フラグをどのように提供するかを見てきました。 しかし、それらすべてがそうであるわけではありません。 さらに、いくつかの最適化を生成できるコンパイラでさえ、積極的に最適化しない場合があります。

Wasm 最適化ツールは、Wasm バイナリの堅牢な分析を実行し、Wasm 実行可能ファイルのファイル サイズやパフォーマンス特性をさらに最適化できます。 Binaryen プロジェクトは、wasm-opt オプティマイザーなど、Wasm を操作するためのコマンド ライン ツールを多数提供しています。

前に、サイズが 9.1M の Swift プログラムを見てきました。 wasm-opt を実行するとどうなるか見てみましょう -O hello.wasm -o hello-optimized.wasm. このコマンドは、次の名前の最適化されたバイナリを生成します hello-optimized.wasm. 結果のサイズは 4.0M で、50% 以上削減されています。

wasm-opt ツールは、重複コードの削除からコードの再編成まで、バイナリに対して数十の最適化を実行します。 ここでのコードとは、編集するソース コードではなく、Wasm の指示を意味します。 したがって、wasm-opt を実行してもソース Swift コードは変更されません。 Wasm バイナリを書き換えるだけです。 この方法で最適化すると、ファイル サイズが確実に削減されますが、実行時のパフォーマンスも向上します。 私のシステムでは、最適化された「hello world」プログラムは、最適化されていないプログラムの 2 倍の速さで実行されました。

実際、wasm-opt は、既に最適化された Rust コードをさらに最適化できます。 前のセクションの 1.9M Rust バイナリで実行すると、さらにコンパクトな 1.6M バイナリが生成されます。 このような単純なケースでは、パフォーマンスは向上しませんでした。 どちらも 10 分の 1 秒で実行されます。 しかし、より大きなRustバイナリは、おそらく wasm-opt.

ランタイムの問題

Wasm は柔軟なバイナリ形式です。 これは wasm3 などのインタープリターによって実行でき、コードの小さなチャンクを順番に読み取って実行します。 しかし、Wasmtime などの他の Wasm ランタイムは、JIT (Just-In-Time) コンパイルと呼ばれるテクノロジを使用して実行を高速化します。

「hello world」の例のような小規模なプログラムや、Raspberry Pi などのリソースに制約のあるデバイスでは、実行する作業量とリソースの使用量が最も少ないため、インタープリターが望ましいことがよくあります。

しかし、Bartholomew CMS のような大規模なプログラムの場合、JIT スタイルのランタイムはインタープリターよりも優れたパフォーマンスを発揮します。 この不一致の理由は、プログラムのメモリ内表現を最適化するために、JIT コンパイラが起動時および初期実行中に余分な作業を行うためです。 そして、この最適化は、コードが実行され続けるにつれて現れます。 ただし、JIT プロセスには時間がかかるため、ほんの一瞬しか実行されない小さなプログラムでは、パフォーマンスが低下するように見えることがあります。

どのように選択しますか? 従来の経験則は次のとおりです。Raspberry Pi よりも小さい制約のあるデバイスで実行している場合は、インタープリターを使用します。 それ以外の場合は、JIT 対応のランタイムを優先してください。

ランタイムに関しては、もう 1 つのトリックがあります。

Ahead-Of-Time (AOT) コンパイル

JIT ランタイムは、起動時にメモリ内の最適化を実行します。 しかし、一度最適化を実行し、それらの最適化をディスクに書き戻して、次にプログラムを実行するときにそれらの最適化を利用できるとしたら? この戦略は、Ahead-Of-Time (AOT) コンパイルと呼ばれます。

AOT コンパイルには大きな欠点があります。この段階で行われる最適化は、前に wasm-opt で見たものとは種類が異なります。 AOT では、最適化はマシン固有です。 これらの最適化では、オペレーティング システムとプロセッサ アーキテクチャが考慮されます。つまり、これらの最適化を実行すると、Wasm バイナリは移植できなくなります。 さらに、各ランタイムにはこれらの最適化のための独自のフォーマットがあるため、1 つの Wasm ランタイムで AOT コンパイルされたプログラムは、他の Wasm ランタイムでは実行できなくなります。

Wasmtime ランタイムは、Wasm モジュールを AOT 形式にコンパイルできます。 たとえば、実行できます wasmtime compile hello.wasm Swift の例をコンパイルします。 これにより、次の名前の新しいファイルが生成されます hello.cwasm Wasmtimeで実行できます。

繰り返しになりますが、「hello world」の例のような単純なプログラムの場合、AOT コンパイルは大きなメリットはありません。 しかし、重要なプログラムを扱う場合、AOT コンパイルは、インタープリターまたは JIT 対応の実行よりも高いパフォーマンス数値を達成します。 ただし、パフォーマンスを向上させるために Wasm ランタイム自体の多くの要素がバイナリにコンパイルされるため、ほとんどの AOT コンパイラは、同等の Wasm よりも大きなバイナリを生成する可能性があることに注意してください。

Wasm に AOT コンパイラをいつ使用するかを知るための非常に具体的な経験則があります。Wasm ランタイム、オペレーティング システム、およびアーキテクチャのまったく同じ構成でのみプログラムが実行されることがわかっている場合にのみ使用してください。 Wasm モジュールは、通常の Wasm 形式で配布する必要があり、インストール手順時またはインストール後に AOT コンパイルする必要があります。

バイナリの事前初期化

5番目で最後の最適化手法は、このロットの中で最も独特です。 Wasm はスタックベースの仮想マシンであり、いつでも停止でき、ディスクに書き出すこともでき、後で再開できます。 (これにはいくつかの制限がありますが、ここでは重要ではありません。) Wasm のこの機能には、興味深いアプリケーションがあります。

起動時に毎回実行する必要があるコードの部分がある場合があります。 このコードは、変数のデフォルト値の設定やデータ構造のインスタンスの作成など、平凡なことを行う場合があります。 コードが実行されるたびに、この同じビットの初期化ロジックを実行する必要があります。 そして、実行するたびに、プログラムの結果の状態は同じになります。 変数が同じ値に初期化されるか、データ構造が同じ状態に初期化されます。

最初の初期化を実行し、Wasm の状態をフリーズしてディスクに書き戻す方法があったとしたらどうでしょうか? 次にプログラムが実行されるときに、初期化ステップを実行する必要はありません。 それはすでに行われているでしょう!

これが Wizer プロジェクトの背後にある考え方です。 Wizer は、コードに初期化ブロックで注釈を付ける方法を提供します。初期化ブロックは一度実行してから、初期化後の新しい Wasm バイナリに書き出すことができます。 AOT コンパイルとは異なり、結果として得られるバイナリは依然として単純な古い Wasm バイナリであるため、この手法は移植可能です。

Wizerの使い方は少し難しいかもしれません。 しかし、.NET のようなシステムは、Wizer から大きな恩恵を受けることができます。

すべてをまとめる

Fermyon での経験に基づくと、最適化は開発者ツールとクラウド ランタイムの両方にとって重要ですが、2 つのケースは大きく異なります。

開発者側のベスト プラクティスは、コンパイラが提供する最適化ツールをできるだけ多く使用することです。 たとえば、私たちは常に —release Rust コードをコンパイルするときのフラグ。 開発者が複数の言語を使用して WebAssembly マイクロサービスと Web アプリケーションを構築できるオープン ソースの Spin ツールには、言語ごとのテンプレートにこれらの最適化が含まれています。 また、ローカル コンパイル パスに wasm-opt を含めると、特に実行時間が長い言語で役立つことがわかりました。

開発プロセスでは、JIT 対応のランタイムを使用します。 開発段階での AOT コンパイルにはほとんど価値がありません。

サーバー側は違います。 たとえば、当社の SaaS ベースの Wasm ランタイム プラットフォームである Fermyon Cloud は、Wasm バイナリのみを受け入れますが、クラウド クラスターに展開すると、それらのバイナリは AOT コンパイルされます。 これは、ホスト ランタイムの構成が正確にわかる瞬間であるため、信頼できる方法で可能です。 Wasm ファイルが Arm64 システムに展開されている場合、Intel アーキテクチャで実行されることを気にせずに、それに応じて AOT コンパイルできます。

Wizerに関しては、.NETの場合にのみ使用します.NETは、この最適化から多大な恩恵を受けます.

結論

パフォーマンスとファイル サイズのために Wasm を最適化する 6 つの異なる方法を選択しました。 それぞれの方法には長所と短所があり、これらの方法の多くを組み合わせて、さらにメリットを得ることができます。 本番の Wasm 環境にこれらの手法を採用すると、有益な場合があります。

.

Leave a Comment

Your email address will not be published. Required fields are marked *