コンテンツにスキップ

ベンチマーク

apps/synstate-dom-benchmark は、js-framework-benchmark の操作(表の行の作成・置き換え・部分更新・選択・入れ替え・削除・追加・全消去)で、synstate-dom を Solid、Preact + Signals、Preact、React、VanJS と比べています。値どうしがグラフでつながる 3 つのシナリオでも比べていて、そのうち 2 つは長さの違う diamond を持ち、残りの 1 つはほとんどの変更が途中で止まります。

以下の数値はその RESULTS.md から埋め込んだもので、1 台のマシンで計測しています。この計測の中で実装どうしを比べるための数値なので、js-framework-benchmark が公開している表とは比べられません。

計測環境は Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36 です。各値は 8 回の実行の中央値です。実行ごとにライブラリを計る順番を変えていて、各実行の値もその中で計った回の中央値です。

小さいほど速いです。

操作(中央値 ms) synstate-dom Solid Preact + Signals Preact React VanJS
1,000 行を作る 24.85 19.70 22.95 20.95 23.70 23.20
1,000 行をすべて置き換える 31.10 21.75 30.15 25.75 27.70 24.85
1,000 行の 10 行ごとに更新する 9.95 9.85 10.65 10.75 10.45 9.95
行を選択する 4.60 4.40 5.70 4.80 4.90 39.10
2 行を入れ替える 7.80 5.65 6.45 6.10 21.40 5.90
1 行を削除する 6.45 5.40 6.05 5.70 5.50 5.15
10,000 行を作る 280.00 178.95 265.00 220.05 461.00 240.30
1,000 行に 1,000 行を追加する 34.90 24.00 29.45 27.80 28.40 28.00
1,000 行を消す 8.10 6.05 5.60 6.75 6.95 6.55

各ライブラリの時間を synstate-dom の時間で割ったものです。1.00× より小さければ synstate-dom より速く、大きければ遅いことを表します。

操作 Solid Preact + Signals Preact React VanJS
1,000 行を作る 0.79× 0.92× 0.84× 0.95× 0.93×
1,000 行をすべて置き換える 0.70× 0.97× 0.83× 0.89× 0.80×
1,000 行の 10 行ごとに更新する 0.99× 1.07× 1.08× 1.05× 1.00×
行を選択する 0.96× 1.24× 1.04× 1.07× 8.50×
2 行を入れ替える 0.72× 0.83× 0.78× 2.74× 0.76×
1 行を削除する 0.84× 0.94× 0.88× 0.85× 0.80×
10,000 行を作る 0.64× 0.95× 0.79× 1.65× 0.86×
1,000 行に 1,000 行を追加する 0.69× 0.84× 0.80× 0.81× 0.80×
1,000 行を消す 0.75× 0.69× 0.83× 0.86× 0.81×

ライブラリごとの最も遅い操作

Section titled “ライブラリごとの最も遅い操作”

各ライブラリが最も遅れる操作と、その操作での最速のライブラリに対する倍率です。SynState のベンチマークも、速さを同じように最悪のケースで見ています。

ライブラリ 最も遅い操作 最速のライブラリに対する倍率
synstate-dom 10,000 行を作る 1.56×
Solid 1,000 行を消す 1.08×
Preact + Signals 10,000 行を作る 1.48×
Preact 10,000 行を作る 1.23×
React 2 行を入れ替える 3.79×
VanJS 行を選択する 8.89×

synstate-dom が目指すのは、すべての行での最速ではなく、シンプルな API でそこそこ速く、遅くなる場面が少ないことです。最も遅い操作でも最速のライブラリの 1.56 倍で、Solid・Preact・Preact + Signals には及びませんが、React(3.79 倍)と VanJS(8.89 倍)を大きく下回ります。

この計測では、synstate-dom は操作によって Solid の 1.01 倍から 1.56 倍の時間がかかりました。数行だけが変わる操作(10 行ごとの更新、行の選択)では最速に近く、多くの行を作る操作でいちばん差が開きます。ほかのライブラリと比べてどの操作が速く、どの操作が遅いかは上の表を見てください。

  • 行の作成は、コンパイラがないことがいちばん効いてくるところです。Solid は行ごとにコンパイル済みの <template> を複製しますが、synstate-dom はノードごとに createElement を呼び、動的な葉をそれぞれ購読で束縛します。さらに行ごとに synstate のノードも作ります。要素のためのノードが 1 つ(位置を受け取る行ではもう 1 つ作りますが、この表の行は受け取りません。制御構文の For を参照)と、2 つの束縛それぞれの map のノードです。行の作成と破棄にかかる時間はノード数にほぼ比例して増えます。
  • 部分更新は、実装によって通る経路が違います。Solid・Preact + Signals・VanJS はラベルごとに signal を持っていて、変わる 100 個に書き込みます。synstate-dom はドキュメントでおすすめしているとおり配列全体を置き換え、各行の item$ が前回の要素と比べるので、要素が変わった 100 行だけが DOM に書き込みます。Preact と React は新しい状態から表を再描画し、props が変わった memo の行だけが描画し直されます。
  • 行の選択では、Solid は createSelector のおかげで 2 行にしか触れず、Preact と React は memo の行を 2 つ再描画します。synstate-dom では各行が選択状態を map のノードを通して束縛しているので、1,000 個の map がすべて実行され、結果が変わった 2 行だけがそれを下流に流してクラスを切り替えます。Preact + Signals も同じように、行ごとの useComputed を再計算します。

js-framework-benchmark では各行が自分のデータだけに依存しているので、ライブラリが変更をどんな順番で伝えるかはほとんど結果に効いてきません。そこで、少数の入力からグラフを通して値を派生させる画面を用意しました。最初の 2 つでは、1 つの入力が長さの違う複数の経路を通って同じ値に届きます(diamond)。3 つ目では、ほとんどの変更が、前と同じ値になったところで止まります。どれもたくさんの値を計算して少しだけ表示するので、時間の大半は DOM ではなく伝播にかかります。

  • ローン:30 年間、毎月返済するローンです。年ごとの行と、毎月の返済額・総利息・完済までの月数の要約を表示します。各月の利息は前月の残高から、返済元金は前月の残高とその利息から、新しい残高は前月の残高と返済元金から計算するので、360 か月分が diamond の連鎖になり、金利と返済額はすべての月に入ってきます。金利を変えるとすべての月が変わり、181 か月目に繰上返済すると連鎖の後半が、349 か月目に繰上返済すると最後の 1 年が変わります。
  • 工程表:1,000 個のタスクのクリティカルパスです。各タスクはそれより前の 1〜3 個のタスクに依存し、それぞれ所要日数を持っています。前進計算で各タスクの最早開始と最早終了を、プロジェクトの終わりからの後退計算で最遅開始を求め、その差が余裕日数になります。画面にはプロジェクトの終了日、クリティカルなタスクの数、最後の 50 タスクを表示します。最初のタスクを延ばすとほぼすべての値が変わりますが、中ほどのタスクを延ばした場合は周りのタスクの余裕日数が変化を吸収するので、変わる値は数十個で済みます。
  • ズーム:地図上の 10,000 地点のラベルです。ズームは 0.1 刻みで設定します。ズームから整数の詳細度(段階)を求め、各ラベルの大きさは段階だけから計算します。各地点は自分の順位に応じた段階から表示され、段階が上がるごとに大きくなります。画面にはズーム、段階、表示中のラベル数、最初の 50 地点を表示します。同じ段階の中でズームしても段階の値は変わらないので、画面上のズームの表示だけが変わります。次の段階までズームすると、ほとんどのラベルが変わります。

どのシナリオでも、画面の構築(グラフ全体の構築)にかかる時間も計っています。

操作(中央値 ms) synstate-dom Solid Preact + Signals Preact React VanJS
ローン:表を作る 2.71 1.57 1.76 0.99 0.97 2.26
ローン:金利を変える 0.96 1.10 0.87 0.74 0.69 2.92
ローン:181 か月目に繰上返済する 0.53 0.61 0.45 0.46 0.42 誤り
ローン:349 か月目に繰上返済する 0.13 0.14 0.14 0.25 0.23 0.65
工程表:工程表を作る 11.07 3.51 3.94 1.91 1.56 9.69
工程表:最初のタスクを延ばす 3.30 4.00 1.82 1.11 1.07 誤り
工程表:中ほどのタスクを延ばす 0.06 0.84 0.31 0.36 0.33 0.65
工程表:終わり近くのタスクを延ばす 0.20 0.85 0.47 0.59 0.54 0.40
ズーム:地図を作る 7.62 3.39 4.34 1.36 1.32 10.85
ズーム:同じ段階の中でズームする 0.08 0.69 0.48 0.37 0.31 0.09
ズーム:次の段階までズームする 2.40 3.26 1.92 1.12 1.12 5.94

synstate-dom に対する倍率(シナリオ)

Section titled “synstate-dom に対する倍率(シナリオ)”
操作 Solid Preact + Signals Preact React VanJS
ローン:表を作る 0.58× 0.65× 0.37× 0.36× 0.83×
ローン:金利を変える 1.15× 0.91× 0.77× 0.72× 3.04×
ローン:181 か月目に繰上返済する 1.15× 0.85× 0.87× 0.79× –
ローン:349 か月目に繰上返済する 1.08× 1.08× 1.92× 1.77× 5.00×
工程表:工程表を作る 0.32× 0.36× 0.17× 0.14× 0.88×
工程表:最初のタスクを延ばす 1.21× 0.55× 0.34× 0.32× –
工程表:中ほどのタスクを延ばす 14.00× 5.17× 6.00× 5.50× 10.83×
工程表:終わり近くのタスクを延ばす 4.25× 2.35× 2.95× 2.70× 2.00×
ズーム:地図を作る 0.44× 0.57× 0.18× 0.17× 1.42×
ズーム:同じ段階の中でズームする 8.63× 6.00× 4.63× 3.88× 1.13×
ズーム:次の段階までズームする 1.36× 0.80× 0.47× 0.47× 2.48×
  • まずは正しさです。 各操作の後に、描画されたセルを単純なループで計算した値と比べ、違う値を描画したライブラリには時間の代わりに「誤り」と書いています。VanJS は派生 state をラウンド単位で再計算します。各ラウンドでは、前のラウンドで変わった state を読む派生をやり直し、100 ラウンドで打ち切ります。そのため、変更がそれより先まで連鎖をたどる必要があるときは、古い値が画面に残ってしまいます。synstate-dom、Solid、Preact + Signals は、1 回の変更につき各値を、それが依存する値がすべて決まった後に 1 回だけ計算します。
  • React と Preact にはグラフがありません。 入力を 1 つの state に持ち、描画のたびにすべての値をループで計算します。なので、変更がどこまで届くかにかかわらず、1 回の変更で計算全体を 1 回なぞることになります。この規模ならそれでも十分安いので、グラフを使う実装が超えるべき基準としてここに並べています。
  • 前と同じ値になったところで止まるかどうか。 Solid と Preact + Signals は、前と等しい値を先へ流しません。synstate の map も流しません。ほかの値から計算する値は map で、入力が複数あるものはその前に combine を置いて作っているので、中ほどのタスクを延ばしたり同じ段階の中でズームしたりしても、同じ値になったところで止まります。
  • 止まることがどれだけ効くかは、変更にどう気づくかで決まります。 Solid と Preact + Signals では、書き込みがまず下流のすべてに「古いかもしれない」という印を付け、その後で各値を元の値と照らし合わせます。そのため、段階のところで止まる変更でも 10,000 個のラベルをすべて訪れます。synstate は値を流したノードの子しか訪れないので、同じ段階の中でのズームは、その下にある地図がどれだけ大きくても同じ時間で済みます。VanJS も、すでに持っている値を代入された state は伝播しないので、そこで止まります。React と Preact はラベルを段階に依存する useMemo に持っていて、51 行を描画し直します。

ここでの変更は 1 回あたり 1 ms をかなり下回ります。そのためシナリオでは、ブラウザが 4 ms に丸めてしまう setTimeout ではなく MessageChannel で次のタスクを待ち、操作とその逆を交互に行う 20 回の変更の平均を 1 サンプルとしています。

js-framework-benchmark の操作では、どの実装も同じ表をマウントします。要素ごとに 1 行で、要素の id とラベルを 2 つのセルに置き、選択中の行に danger クラスを付けます。計測の前に、同じ操作をした後にすべての実装が同じ行を描画することを確かめています。

各操作は、呼び出してからマクロタスクが 1 つ過ぎ、ブラウザが表をレイアウトし直すまでを計ります。VanJS と Preact は変更をマイクロタスクで描画するので、この待ちを入れています。待ちはすべての実装が同じように払うので、どの値もほぼ同じだけずれます。

ライブラリは本番ビルド(mode: 'production' と production の export 条件)と本番用の JSX ランタイムを使い、Vitest のブラウザモードのヘッドレス Chromium で動かしています。

どの実装も、そのライブラリ自身のドキュメントか、js-framework-benchmark のエントリーがこういう表を書くときのやり方に従っています。

  • synstate-dom:行は createState の中の 1 つのイミュータブルな配列で、For が各行の値をそこから派生させます。選択状態は、各行が pipe(map(...)) で束縛する state で、変換した値の束縛はどれもこの書き方です。ドキュメントでおすすめしている書き方そのままで、チューニングはしていません。
  • Solid:ラベルごとの signal、行オブジェクトに対する For、選択には createSelector を使うので、選択で触れるのは 2 行だけです。vite-plugin-solid でコンパイルします。
  • Preact + Signals:ラベルごとの signal と、行ごとに useComputed で求めるクラスを、どちらも signal として DOM に束縛します。リストはキー付きで再描画します。
  • Preact と React:行と選択状態を、トップの useState で 1 つのイミュータブルな状態として持ちます。各行は、ラベルと選択中かどうかを受け取る memo コンポーネントです。React は変更ごとに flushSync で描画し、Preact はマイクロタスクで描画します。
  • VanJS:ラベルごとの state と、行ごとのクラス関数を使います。リストは手で管理し、行の要素を直接移動・削除します。

CI では実行していません。共有のランナーで計った時間にはあまり意味がなく、結果をアサートするベンチマークは不安定になるだけだからです。同じマシンでも 1 回ごとに 10〜30% ばらつくので、ここの数値は複数回の中央値を取る benchmark:repeat で計ったものです。

ターミナルウィンドウ
pnpm run ws:build # synstate-dom と synstate は dist/ から import します
cd apps/synstate-dom-benchmark
pnpm run benchmark:repeat # 5 回の中央値を取って RESULTS.md を書き直します
cd ../synstate-dom-docs
pnpm run gen:benchmark # RESULTS.md をこのページに埋め込みます