ブログに戻る

フォロー&ご登録

Lucet インスタンスのライフサイクルとパフォーマンス

Adam Foltzer

Senior Software Engineer

Fastly は先日、当社のネイティブ WebAssembly コンパイラ兼ランタイムの Lucet を発表しました。その発表の中で、Lucet が 50 マイクロ秒とかからず WebAssembly モジュールをインスタンス化できるということに触れ、より高速かつ安全な実行のためのこの新たなテクノロジーが Fastly のエッジクラウドプラットフォームに拡張する上での課題にも対応できることを示しました。この投稿では、Lucet 上で実行される WebAssembly プログラムのライフサイクルの各段階で起こることを共有することで、Lucet のランタイムシステムがどのように動作するのかをご説明します。また、当社がどのように各段階のオーバーヘッドを最小限にしているのかについても詳しくご紹介します。

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

Web ブラウザでは、WebAssembly のダウンロードからプログラム実行までにかかる時間は、ページ読み込み時にユーザーが経験する遅延の一部となっています。ブラウザ実行エンジンは、実行中 (Just-In-Time、JIT) コンパイルを使用して、高速でネイティブなコードの迅速な生成を開始します (WebAssembly プログラムのダウンロード完了前の場合もあります)。1リクエストごとに同じプログラムを何度もリクエストして実行する Terrarium のようなサーバーサイドアプリケーションの場合、コンパイル速度は各リクエストのセットアップ時間や生成したコードのパフォーマンスよりはるかに重要度の低いものです。

Lucet には事前 (Ahead-Of-Time、AOT) コンパイラ lucetc が搭載されており、これは Mozilla が Firebox で WebAssembly や JavaScript JIT エンジンを使えるように開発した Cranelift コードジェネレーターの上に構築されています。WebAssembly プログラムは、lucetc でネイティブの x86-64 共有オブジェクトファイルにコンパイルされて Lucet ランタイムで読み込んで実行できるようになります。Terrarium のようなサーバーでは、この手順は一度しか実行されず、そのコストはサーバーの寿命全体で分割されます。コンパイラの最適化により多くの時間を費やせるのです。

メモリ領域

lucet-wasi コマンドラインインターフェースのような Lucet アプリケーションの中には、一度に 1 つの WebAssembly プログラムだけを実行するよう設計されていますが、私たちは Fastly 規模の大量同時実行に対応するよう Lucet を構築しました。各 Lucet インスタンスには、WebAssembly のヒープやグローバル変数、およびインスタンスメタデータ用のコールスタックや 4KiB のページのための一定のメモリ量が必要です。

インスタンスを作成して破棄するたびにオペレーティングシステムからメモリを割り当てて解放するのではなく、インスタンスをバックアップするために再利用可能なスロットを持つメモリ領域を割り当てます。インスタンスは領域の利用可能スロットを使って作成され、インスタンスが破棄されるとゼロ化されて領域に戻されます。AOT コンパイルと JIT コンパイルのパターンに従い、Fastly は高コストな 1 つのメモリマッピング作業をサーバー寿命全体に分割して、より安価なスロット再利用作業を繰り返し実行するアプローチを採用しました。

インスタンス化

Lucet プログラムをインスタンス化するには次のことを行います。

  • lucetc でコンパイルした共有オブジェクトを動的に読み込む

  • メモリ領域から利用可能なスロットを取得する

  • 正しいパーミッションでインスタンスヒープをセットアップする

  • 共有オブジェクトから初期ヒープ値をコピーする

Fastly のベンチマークシステムでは、lucet-wasi のようなツールは WASI の「Hello World」プログラムの読み込みとインスタンス化に 52 マイクロ秒かかりました。これには単一スロットでメモリ領域を作成する時間も含まれています。

もちろん、Terrarium のようなサーバー環境では共有オブジェクトの読み込みとメモリ領域の作成は一度しか行いません。Fastly のベンチマークシステムでは、メモリスロット取得とヒープ生成ステップの実行にかかる時間は 30 マイクロ秒でした。

Fastly の「Hello World」プログラムには初期ヒープデータがあまりないので、この時間の大部分はメモリスロットとパーミッションの予約に使われています。異なる初期ヒープサイズを持つシンセティックプログラムをいくつか試してみると、この時間は初期ヒープ値のコピーに影響されており、インスタンス化にかかる時間はヒープサイズに比例して増えることがわかりました。

実験的なバックエンド言語「Go」のような WebAssembly をターゲットとするコンパイラの中には、ガベージコレクションに対応するために非常に大きな初期ヒープを持つモジュールを生成するものがあります。幸い、ほとんどの初期ヒープ値が 0 の場合はインスタンス化の時間をある程度節約できます。ここでは、8 ページごとに 0 ではないが密度が低い初期ヒープがあるシンセティックプログラムを実行しました。

どちらのケースでもインスタンス化の時間は初期ヒープサイズに比例して増加していますが、密度の低いヒープでは密度の高いヒープの時間の 1/8 しかかかりません。

インスタンスの実行

インスタンスができたら、エクスポートを行う WebAssembly のゲスト関数を実行できます。新たな Linux プロセスや新規スレッドを生成する代わりに、Fastly はホストアプリケーションのスレッド上でコンテキストスイッチを実行して直接ゲスト関数の実行を開始するようにしています。Lucet の場合、これにはゲストレジスタとコールスタックに関数の引数設定が含まれ、現行スレッドのシグナルマスクを節約してゲストのホストレジスタとスタックを直接スワップアウトします。このコンテキストスイッチは非常に高速で、平均 0.5 マイクロ秒で自明な関数にスワップし返却します。

シグナルハンドラをインストールするために、プロセス内で実行する最初の Lucet インスタンスには別のシステムコールが必要です。Lucet は 0 での割り算のような例外的な条件のためにカスタムシグナルハンドラを使用し、エラーは発生元インスタンスから隔離されるようにしています。コンパイルとメモリ領域作成の話を続けると、ほとんどのサーバーアプリケーションにとっては 1 回限りのコストですが、このハンドラをインストールしなければならない場合でも、インスタンスは平均 4.9 マイクロ秒で実行されます。

破棄

Terrarium のようなサーバーがリクエストを完了すると、サーバーはリクエスト間の流出から状態を守るためにサービス提供したインスタンスをリセットまたは破棄する必要があります。インスタンスを破棄する際、Lucet はメモリ保護をリセットしてインスタンスのスロットにあるメモリをゼロにします。それから平均 23 マイクロ秒でメモリをメモリ領域の空きスロットリストに戻します。

Linux は madvise(2) を使ってオンデマンドでページをゼロにするので、この処理にはシンセティックプログラムで使用するおよそ 35〜40 マイクロ秒/MiB のヒープがかかります。

ランタイムシステムのトータルオーバーヘッド

ステップをまとめると、Lucet を使って WebAssembly プログラムを実行する際に、ランタイムシステムのオーバーヘッドがどのくらいかかるかがわかります。メモリオーバーヘッドは、メタデータ用の 4KiB とコールスタック用の設定可能なメモリ量です。オーバーヘッドの速度はさまざまです。プログラムが使用するヒープスペースや、AOT コンパイルやメモリ領域の作成コストの分割にワークロードが適しているかどうかによって変わります。しかし、この投稿では「Hello World」プログラムを実行しているので、Lucet にかかる時間は以下の通りです。

  • インスタンス化に 30 マイクロ秒

  • コンテキストスイッチに 5 マイクロ秒

  • 破棄に 23 マイクロ秒

Fastly のエッジクラウドの規模では、リクエスト処理の各ステップで非常に高いパフォーマンスが必要です。Lucet は、セットアップとティアダウンのオーバーヘッドを 60µs 未満に抑えながら、エッジでより安全で洗練されたロジックを実現します。

その他のパフォーマンスに関して

この投稿では、Lucet での WebAssembly プログラム実行に関わる手順や Lucet ランタイムシステムにかかるオーバーヘッドについて説明しました。今後の投稿では、コンパイラとランタイムシステムの緊密な共同開発による最適化など、lucetc で生成したコードのパフォーマンスを詳しく見ていきます。

Lucet の GitHub リポジトリをチェックして、感想を聞かせてください!

ベンチマークに関して

上記に記載したパフォーマンスの数値は、Fastly のベンチマークセットを実行するために優れた Rust ポートの基準を使って収集したものです。これは、GitHub の Lucet リポジトリでご確認いただけます。ベンチマークは、デュアルコアの 3.50 GHz Intel Core i7-7567U プロセッサを搭載した専用の 64 ビットの Ubuntu 16.04 システム上で実施され、一貫性を保つためにハイパースレッディングとターボブーストは無効にしました。実際の設定では、こういった機能はパフォーマンスを向上する可能性があるのですが、ベンチマークにおいては相当なノイズとなります。たとえば、512 KiB のヒープインスタンス化の確率密度グラフは、この機能をオフにすると次のようになります。

しかし、両方を有効化すると以下のようになります (X 軸が異なることに注意してください)。