フロントエンドのパフォーマンス改善:Web WorkerとWebAssemblyの効果測定

1. はじめに

こんにちは。ATOM事業部フロントエンドテックリード兼デザイナーの河原です。
今回はフロントエンドの重たい処理をWeb WorkerやWebAssemblyを用いてどのように改善できるかを検証し、その効果を測定しました。

ブラウザは基本的にシングルスレッドモデルで動作します。レンダリング、JavaScriptの実行、CSSの解釈などページを表示するために必要な処理が1つのメインスレッドで行われます。 JavaScriptで重たい処理を行うと、他の処理が待たされUXが著しく悪化します。
今回はJavaScriptで重い処理をする必要がある状況を仮定し、それを改善する手法を実装して処理時間を計測しました。

Web Worker

スクリプトの処理をメインスレッドとは別のスレッドに移し、バックグラウンドでの実行を可能にする仕組み。 これにより、重たい処理をメインスレッド外で処理することが可能となります。
ただし、メインスレッドとメモリ空間を共有できないため、データのやり取りにオーバーヘッドが伴います。

WebAssembly

ブラウザでC、C++、Rustなどの低レベル言語からコンパイルされたバイナリコードを実行する技術。 これにより、高速な計算処理が可能となりパフォーマンスが向上。それぞれの言語のエコシステムも利用可能。
ただし、JavaScriptとのやり取りにオーバーヘッドが伴います。 また、DOM操作やHTTPリクエストはJavaScriptを介して行う必要があるなど制約があります。

2. 改善と計測

それでは、実際にパフォーマンス改善を試していきましょう。 改善したいシナリオは次のとおりです。

  1. APIサーバーから3万件のレコードを取得(データサイズは45MB程度)
  2. 取得した全レコードに対して変換処理を行う(全体で3秒程度かかる)
変換処理では表示のためのデータ加工と非効率なフィボナッチ計算をしています。
(重い処理の良い例が思いつきませんでした)。
また、3万件を一度にフェッチせず分割してフェッチする改善方法や、変換処理自体のロジック改善は今回の検証では行わない前提とします。


上記シナリオに対して5つのサンプルを実装しました。
次章以降の個別のサンプル説明では実装は一部のみを提示し詳細な説明は割愛します。次のリポジトリにすべてのコードをおいています。

github.com

計測の詳細は次のとおり。

計測環境・条件
  • MacBook Pro(M2)で計測
  • ローカルでサーバを動かし、同PCにてChromeで計測
  • フロント・サーバとものNext.jsで作成
  • WebAssemblyはRustで作成
  • 計測方法
  • ユーザ操作から変換処理が完了するまでにかかった時間を計測(レンダリングは含まない)
  • console.timeで計測。3回の平均値を結果とする。
  • 傾向をみるためだけのゆるい計測

2.1. メインスレッドで処理する

まずは基本ケースとしてメインスレッドで何も考えずに処理するサンプルです。

コードの一部
const data = await fetchJson("/api/examples"); // APIサーバからフェッチ
const parsed = convertExampleList(data.list);  // 変換処理(3万レコードで約3sかかる)
実行中の画面

処理時間

3554 ms

評価

実行中の画面を見ると、ボール(白丸)のアニメーションが停止している期間が確認できます。ここが変換処理を行っている期間です。 この期間はユーザ操作が待たされるため画面が固まっているように感じます。

変換処理中のプロファイル結果です。メインスレッドをJavascriptの処理がほぼ専有していることがわかります。

一部のアニメーションは停止していない?
実行中の画面を見ると、ボールのアニメーションは停止してますが、影の部分は動いています。これはなぜでしょうか?

CSSアニメーションは、ブラウザの最適化によってメインスレッド(CPU)ではなくGPUで処理されることがあります。 GPUで処理されると、アニメーションがスムーズに動作し、メインスレッドの負荷が軽減されます。 アニメーションに限らず、GPUを活用することはパフォーマンス改善に非常に効果的です。 アニメーションがGPUで処理されるかどうかは、変更されるCSSプロパティによって異なります。 代表的なプロパティとしては`transform`や`opacity`があり、これらはリフローやリペイントを引き起こさないため、通常はGPUで処理されます。

今回のサンプルでは、ボールの部分は`top`プロパティを変更しています。`top`の変更はリフローを引き起こし、メインスレッド(CPU)で処理されます。 これを`transform: translateY`などで表現すると、GPUで処理される可能性が高くなります。 今回は、メインスレッドの専有状態を視覚的に確認できるよう、あえて`top`を変化させています。

※なお、ブラウザの設定等環境によってはGPUが使われない場合があります。

2.2. 変換処理をWeb Workerで処理する

次は時間のかかる変換をWeb Workerで処理するサンプルです。

コードの一部
// Main
workerRef.current = new Worker(new URL("./worker.ts", import.meta.url));
workerRef.current.onmessage = (e: MessageEvent<ExampleItem[]>) => {
  setItems(e.data);
};
...
const data = await fetchJson("/api/examples");
workerRef.current?.postMessage({ items: data.list });

// Worker
const parsed = convertExampleList(items);
postMessage(parsed);
実行中の画面

処理時間

3608 ms

評価

「2.1. メインスレッドで処理する」と比較すると55ms 遅くなりましたが、画面の固まりは解消しました。

遅くなった要因はメインスレッドとWorkerとのやり取りに時間がかかっているためです。 3万件のデータをやり取りするため、メッセージのシリアライ・デシリアライズ等に時間がかかっていると考えられます。 実際に、メインスレッドとWorker間のデータのやり取りだけにかかる時間を計測したところ、69ms かかっていました。

2.3. データのフェッチもWeb Workerで処理する

次はフェッチもWorkerで処理するサンプルです。 これにより、メインスレッドとWorker間でやりとりする頻度とデータ量を削減することができます。

コードの一部
// Main
workerRef.current = new Worker(new URL("./worker.ts", import.meta.url));
workerRef.current.onmessage = (e: MessageEvent<ExampleItem[]>) => {
  setItems(e.data);
};
...
workerRef.current?.postMessage({});

// Worker
const data = await fetchJson("/api/examples");
const parsed = convertExampleList(data.list as ExampleItem[]);
postMessage(parsed);
実行中の画面

処理時間

3556 ms

評価

「2.1. メインスレッドで処理する」と比較して処理時間にほとんど差がなく、画面の固まりも解消しました。

2.4. WebAssemblyを利用する(メインスレッド)

次は WebAssemblyを利用するサンプルです。メインスレッドで実行します。

コードの一部
Typescript
const wasm = await import("~/../wasm/pkg");
const res = await wasm.fetch_examples();
WebAssembly(Rust)
#[wasm_bindgen]
pub async fn fetch_examples() -> Result<JsValue, JsValue> {
    let opts = RequestInit::new();
    opts.set_method("GET");
    opts.set_mode(RequestMode::Cors);

    let request = Request::new_with_str_and_init("/api/examples", &opts)?;

    let window = web_sys::window().unwrap();
    JsFuture::from(window.fetch_with_request(&request)).await?
    let resp: Response = resp_value.dyn_into().unwrap();
    let json: JsValue = JsFuture::from(resp.json()?).await?;

    let mut res: ExampleResponse = from_value(json).unwrap();
    for item in &mut res.list {
        convert_example_item(item);
    }
    Ok(to_value(&res).unwrap())
}
実行中の画面

処理時間

2614 ms

評価

「2.1. メインスレッドで処理する」と比較して 940ms 早くなりました。 しかし、メインスレッドで動作するため、短くなったとはいえ画面は固まったままです。

2.5. WebAssemblyとWeb Workerを利用する

最後にWebAssemblyをWeb Workerで実行するサンプルです。

コードの一部
Typescript
// Worker
const wasm = await import("~/../wasm/pkg");
const res = await wasm.fetch_examples();
postMessage(res.list);
WebAssembly(Rust)
#[wasm_bindgen]
pub async fn fetch_examples() -> Result<JsValue, JsValue> {
   ...
   // フェッチ部分がワーカースレッドを考慮した実装となる。それ以外は2.4と同じ。
   let global = js_sys::global().unchecked_into::<web_sys::WorkerGlobalScope>();
   JsFuture::from(global.fetch_with_request(&request)).await?
   ...
}
実行中の画面

処理時間

2679 ms

評価

「2.1. メインスレッドで処理する」と比較して 875ms 早くなりました。 また、画面の固まりも解消しました。

今回試した中では、一番良い結果となりました。

3. まとめ

計測結果のまとめです。

改善案 処理時間 備考
2.1. メインスレッドで処理する 3554ms 画面が固まる(NG)
2.2. 変換処理をWeb Workerで処理する 3608ms
2.3. データのフェッチもWeb Workerで処理する 3556ms
2.4. WebAssemblyを利用する(メインスレッド) 2614ms 画面が固まる(NG)
2.5. WebAssemblyとWeb Workerを利用する 2679ms

今回のサンプルでは、WebAssemblyは処理時間そのものを早くし、Web Workerは画面が固まることを解消しました。 どちらもパフォーマンス改善に効果的であることが確認できました。
ただし、どちらも万能な解決策ではなく、状況においては期待ほどの性能向上が見られない場合があります。 また、特にWebAssemblyに言えますが、学習コストが高いため、開発/保守にかかる費用が増加するデメリットもあります。

これらの技術はまだ発展の途上にあり、将来のアップデートで、さらに強力かつ使いやすいツールになることが期待できます。
例えば、Web Workerではスレッド間でメモリが共有できる「SharedArrayBuffer」が現状ではセキュリティリスクからの制約があったり、文字列のやり取りの手間がかかったりと使いづらい点があります。 この点が解消されるとスレッド間のやり取りのオーバーヘッドが削減され、さらに使いやすくなります。
また、WebAssemblyでは、Web Workerに依存せずより低レベルで効率的な並列処理を可能にする「Core Wasm Thread」に期待してます(すでに一部使えるのかな?)。

進化のスピードが速くキャッチアップが追いついていませんが、今後も継続的にキャッチアップし、検証を進めていきたいと思います。