Reactに有利なベンチマークを作ってみた　【ハードモード】

# 前回のおさらい

https://qiita.com/uhyo/items/35cb243557df5e1a87fc

前回の記事では、同じアプリケーションを6つのUIライブラリで実装し、Reactに有利な状況設定で作られたベンチマークを走らせると当然Reactが勝つという結果をお伝えしました。

そのベンチマークでは、「レンダリングのために高い負荷がかかっている状況でもユーザーが快適に入力を行えるかどうか」を測りました。

Reactでは、レンダリングのジョブを中断してユーザーの入力を処理するスケジューリングの機構が備わっているため、高負荷の状況でもユーザーの入力に高速に応答することができました。

また、今回書くベンチマークアプリは「最大限自然かつ簡潔」という条件で実装したため、スケジューリングがライブラリ本体に組み込まれているReactのみが有利な結果となりました。実はReact以外のライブラリは1文字入力されるたびに律儀にDOMに反映していましたが、Reactは本体からスケジューリング機構（トランジション）が提供されているのをいいことに、最後以外はDOMへの反映をスキップしていました。他のライブラリを使用する方は、「うちのライブラリだってちゃんとチューニングすれば高速になる」と思ったことでしょう。

実際、記事の公開後、VueやSvelteを使用する方から「チューニングした結果速くなった」との報告が寄せられています。これらは、チューニングによって高負荷を克服し、ユーザーの入力に間に合うような高速なレンダリングを実現した結果だと考えられます。

# ハードモードなベンチマーク

しかし、レンダリングの高速化によってReactに追いついた場合、さらに高負荷な状況では再びReactが有利になるはずです。Reactが有利な土俵に戻すために、今回は単純な高速化では克服できなさそうな負荷を用意しました。具体的には:

- データ量を12倍にしました。（ただし、今回のレギュレーションではCPUスロットリングを行わないことにしたため実質的には3倍くらいです。）
- 入力担当者を訓練し、キー入力の間隔を100msから64msに短縮しました。

また、計測部分も見直し、種目を2つ用意しました。どちらの指標も、理想値（負荷やオーバーヘッドが全くなくレンダリングが一瞬で終わる場合の結果）は0で、それと比較したオーバーヘッドを表す値です。

- **inputDelay**: 負荷がない状況に比べてどれくらい入力に時間がかかったかを示します。スケジューリングがダイレクトに効く種目です。
- **renderingOverhead**: キー入力開始から最終的なレンダリング結果が表示されるまでにかかった時間から、キー入力にかかった時間（64ms × 3）を引いた時間です。スケジューリングだけでなくレンダリングの自体の速さも含めた総合力が問われる種目です。

そのほかにも、レンダリングの負荷が減るようにCSSの調整を施したりしたものがGitHubリポジトリのhard-mode-benchmarkディレクトリに用意されています。

https://github.com/uhyo/ui-library-benchmark/tree/hard-mode-benchmark

データ量が12倍になったベンチマークアプリはこちらにデプロイされています。

https://13b09fc9.ui-library-benchmark.pages.dev/

## レギュレーション

より厳しくなったベンチマークにぜひ挑戦していただきたいのですが、~~React有利な状況を保つため~~意味のあるベンチマークにするためにいくつかのレギュレーションを設定しています。

### 1. CPUスロットリングはしない

前回の記事ではGoogle Chromeの4x slowdownを設定していましたが、今回はスロットリング無しを計測環境としています。

これは、前回の記事のベンチマークでなぜかM1 Macの使用者の環境ではReactが遅いという現象を受けてのことです。M1 MacとReact自体の相性が悪い可能性もありますが、スロットリングが悪さをしているという説も指摘されたのでスロットリングはやめました。

### 2. Itemコンポーネントが受け取るのは`id`と`searchQuery`

多分検索のロジックを全部親コンポーネントに持って行った方が速いと思うのですが、それだとベンチマークで差が付かなそうな上にReactのスケジューリング機構が有利にならないので、Itemコンポーネントが`id`と`searchQuery`を受け取るというレギュレーションにしました。

このようなインターフェースを持つ`Item`をデータの数だけ並べる必要がありますが、そのレンダリングを削減したりキャッシュを工夫したりストアにデータを乗せたりなどのチューニングは自由です。

:::note
3として「レンダリングが完了するまでメインスレッドを解放しない」という縛りもありましたが、この縛りはベンチマークの改良（v2）によって撤廃されました。
:::

# ベンチマーク結果（チューニング前）

前回の記事そのままのコードに対して新しいベンチマークを適用しました（筆者のMacbook Pro （非M1）上で計測）。

ベンチマークでは、データ量1倍・6倍・12倍の3パターンについて上の時間を計測し、それらを重み付けて合計したものがスコアとなります。重みはデータ量に反比例し、データ量1倍に対しては重み12、データ量6倍に対しては重み2、データ量12倍に対しては重み1となります。

:::note
7/15 21:30頃、コメントでの提案を受けてベンチマーク内容を更新しました。 (v2)
データ量が多い場合はこれまで紹介したようにReact有利ですが、それを克服するためにdebounceを入れると今度はデータ量が少ない場合のスコアが悪化してしまうという、より総合力が問われる内容になりました。
:::


結果の傾向は前回のベンチマークと変わっていませんが、特にinputDelayの項目でReactが他のライブラリに大きく差をつけています。また、AngularがSvelteに差をつけて健闘しました。

| | React | Angular | Svelte | Preact | SolidJS | Vue |
| - | - | - | - | - | - | - |
| inputDelay | 817.8 ms | 3069 ms | 3529 ms | 3781 ms | 4806 ms | 4688 ms |
| renderingOverhead | 3432 ms | 3456 ms | 4184 ms | 4664 ms | 5821 ms | 5901 ms |

<details>
<summary>当初のベンチマーク結果 (v1)</summary>

当初のベンチマークでは、データ量が12倍の場合のみ計測していました。

| | React | Svelte | Angular | Preact | SolidJS | Vue |
| - | - | - | - | - | - | - |
| inputDelay | 384.6 ms | 1565 ms | 1598 ms | 1889 ms | 2164 ms | 2345 ms |
| renderingOverhead | 1242 ms | 1912 ms | 1903 ms | 2400 ms | 2713 ms | 2955 ms |

</details>


# 高速化に挑戦してみよう！

興味がある方は、以上のレギュレーションの中で高速化に挑戦してみてください。純粋なレンダリング高速化で筆者の鼻を明かすのもよし、Reactのスケジューリングに負けない機構を自前で実装するもよし、レギュレーションの抜け穴を見つけてReactを打ち負かすのもよしです。

また、記事の主旨としては、あくまでReactがどのような状況が得意なのか皆さんに知っていただき、特に仮想DOMもただオーバーヘッドがあるだけではなく一長一短なのだということ理解していただくことです。

書いているうちにだんだん上記の縛りが厳しすぎる気もしてきたので、どれかの縛りを外してみたら自分のライブラリが速くなったなど、自分の推しのライブラリがどのような状況が得意なのかを探ってみるのもよいでしょう。

ベンチマークスクリプトもGitHubで手に入ります。

https://github.com/uhyo/ui-library-benchmark/blob/hard-mode-benchmark/bench.js

# おまけ: 筆者がチューニングに挑戦してみた

筆者が各実装のチューニングに挑戦してみるコーナーです。ただ、力尽きて全部のチューニングはできなかったのでとりあえずReactとSvelteをチューニングしてみました。ソースコードはこちらです。

https://github.com/uhyo/ui-library-benchmark/tree/2dee9f9307c1b1cb27539acbf3ee17e126a016a7

## 結果

チューニング後のベンチマーク結果は次の通りです。

| | React | Svelte | 
| - | - | - |
| inputDelay | 89.30 ms | 51.40 ms |
| renderingOverhead | 2369 ms | 3493 ms |

チューニング内容は次のように単純なものです。

- React: `React.memo` を適切に使用する。
- Svelte: inputに対してsearchQueryの更新をdebounceして遅延する。（当初のレギュレーションではdebounceは禁止でしたが、ベンチマークの改良によりこの縛りが必要なくなりました。また、普通にこの縛りの中でチューニングするのが自分には無理でした。）

## 分析

どちらも、入力を阻害する要因を無くしたためinputDelayはかなり0に近づいており、Svelteが勝っています。レンダリングのオーバーヘッドがSvelteのほうが少ないのでしょう。

一方で、renderingOverheadはまだReactに分があります。これは、今回のベンチマークはdebounceする実装に不利になっているからです。Reactは最適にスケジューリングしてくれるので、その分でReactが勝っていると思われます。

筆者の元気不足により他の実装はチューニングできていません。みなさまの挑戦をお待ちしています。

# おまけ: Q&A

**Q. 途中結果のレンダリングをReactだけサボっていたのはずるいのでは？**

A. 少なくともReactにおけるUXに対する考え方としては、ユーザーが求めるのは最終的な結果であって、それを早く出せるのなら途中結果を省略してもよい（UXが高い）という考え方です。特に、もうユーザーによるキー入力が進んでいて古くなった結果を律儀に画面に出してもあまり嬉しくありません。なので、他のライブラリでもどんどん途中結果をサボってReactに対抗しましょう。上の筆者によるSvelteのチューニングでもそうしていますが、サボりもうまくやらないとスコアが悪化してしまうので注意してください。Reactではトランジションがあるので、関数呼び出しひとつで最適にサボることができます。

**Q. 実務ではこんなに大量にレンダリングしないで画面内だけレンダリングするなどの工夫をするのでは？**

A. ベンチマークなんてそんなものです。実務ならば、十分な工夫をすればどのライブラリでも遜色なく高いUXを出せるでしょう。しかし同時に、ライブラリがどのような面に力を入れているのかあぶり出すのにベンチマークは優れた材料だと思いませんか。実際、Reactでは関数呼び出しひとつで（Reactが考える）最適なUXを出すことができます。