VirtualDOM のパフォーマンステストについて,その後

はじめに

年末に書いたエントリーについて、あちこちで言及がありました*1
師走の忙しさにかまけて勢いで書いてしまったところも多いので、少し補足をしたいと思います。

Virtual DOMってどんだけ早いの?測ってみた - webとかmacとかやってみようか R

まず、最初に断っておきますが、これは Virtual DOM のパフォーマンスを厳密に測定したものではありません。元ネタはこちらです。Blazing-Fast-Html(翻訳: 【翻訳】爆速HTML – Elmでの仮想DOM

AltJS の一つである elm という Haskellっぽく FRP を実現できる関数型言語があります。それを作っている Evan Czaplicki 氏のBlog記事です。彼が作った TodoMVC のパフォーマンス比較が github に公開されているので、それを各ライブラリを最新版にして紹介したのが、上述のエントリーでした。

オリジナルのリポジトリevancz/todomvc-perf-comparison です。すみません、前回リンクを張り忘れていましたね。。まぁ、テストを1回でも実行するとリンクが表示されるのですが。

何を測っているのか?コードリーディング

「このパフォーマンステストはそもそも何を測っているのか?」ということがよく聞かれます。DNSルックアップ?コネクション確立?ネットワーク転送?HTMLパース?JS評価?DOM構築?レンダリング? 現在のWebはページ一つ表示するだけでも様々な要因があります。テストコードが公開されてるのでそれを読めばいいのですが、今回はそこを詳しく見てみましょう。

このテストのメインページはindex.htmlです。ここで読み込んでいるスクリプトは以下の3つ。

  • resources/benchmark-runner.js
  • resources/tests.js
  • resources/manager.js

(※正確には結果のグラフを表示するためにjsapiから GoogleCharts を読み込んでいますが、これはテストとは関係ないために割愛。)

最初にmanager.jsでテストの準備を行います。こちら見ると rパラメータでテスト回数を指定できるようになっているようですね。

テスト対象はtests.jsで定義されています。Suites配列にライブラリ単位でテストスイート追加し、TodoMVC の追加/完了/削除のステップ毎にBenchmarkTestStepオブジェクトを設定しています。

テストの実行はbenchmark-runner.jsに定義されているBenchmarkRunnerが使われます。このstep関数で_runTestAndRecordResults関数を呼ぶことで各テストステップを実行するのですが、テストスイートの初回実行時は iframe にテスト用htmlを読み込み、iframe の onload を待ってから_runTestAndRecordResults関数を呼び出します。

_runTestAndRecordResultsではテストの各ステップを取り出し、_runTest関数で実際のテストステップ(tests.jsで登録したBenchmarkTestStep)を実行します。

_runTestでは、テストステップを呼び出し、その前後の時刻を取得し、実行時間を計測しています。時刻を取得するには Navigation Timing APIperformance.nowが利用可能であればそれを使うようです。

このとき興味深いのが、計測する時間はテストステップ関数の実行時間だけでなく、その直後に 以下のようにsetTimeoutで delay 0 にして非同期の経過時間も取得していました。

    var startTime = now();
    setTimeout(function () {
        setTimeout(function () {
            var endTime = now();
            callback(syncTime, endTime - startTime);
        }, 0)
    }, 0);

これはレンダリング時間を計測しようとしているのでしょうか?それともライブラリ内での遅延処理分を計測するためでしょうか。ちょっとよく分かりません。。教えて詳しい人!

とりあえずこのテストでは、この同期時間と非同期時間を足したものを処理時間として計測しているようです。テスト結果は、各ステップの処理時間の合計をグラフとして表示します。

各ライブラリのテストステップ

各ライブラリのテストステップではおおよそ次のようなことが実行されています。

アイテムの追加

input要素に値をセットして、keydown(またはkeypresskeyup)イベントを keyCode = 13 で発火、というのを100回繰り返します。なお、EmberやAngularでは input要素に値を直接入れるのではなく、Viewモデル経由だったり、inputイベントを発火したりする。

発火するキーイベントや、値のセットがライブラリ毎に微妙に違うのは、おそらくそれぞれの TodoMVC の作り方が違うためだと思われます。

アイテムの完了

document.querySelectorAll('.toggle')チェックボックス要素を取得し、それらを全部click()でクリックイベントを発火させます。

アイテムの削除

対象が '.destroy' になっただけで完了とほぼ同じ。

各ライブラリの TodoMVC

各ライブラリの TodoMVC の実装がどうなっているのか見てみました。基本的には GitHubtastejs/todomvc にあるものをそのまま使っている模様。

一応参考までにリストアップします。

まとめ

だいぶ疲れてきたので、箇条書きでまとめます。

  • 計測しているのは、ネットワーク部分は関係なく、純粋に追加/完了/削除の処理時間のみ。
  • 処理時間は同期処理と非同期処理の合計だが、非同期処理時間の計測にはやや疑問が残る。
  • 各ライブラリのTodoMVC 自体は本家のものやパフォーマンス考慮されたもののため、大きな問題は無さそう。
  • 処理時間はハード/OS/ブラウザの状態に大きく影響されるため、複数回計測した方が良い。

なお、元サイトの画像をよく見ると、

「Average time in milliseconds over 16 runs」 と書いてある。つまり16回やった平均値のグラフのようです。実行回数はr=10のようにURLパラメータで指定します。


同じ VirtualDOM を使っているのに、Om、Mercury、Elm はなぜ React よりも早いのか?についても調べたかったのですが、時間切れのためまた次回。

余談

ところで、この evancz のテストコードも実は別のリポジトリのフォークです。大元は Matt-Esch/mercury-perf。 あれ、この人は、、と気がついた方はさすがですね。React とは別の VirtualDOM 実装 Matt-Esch/virtual-dom を作った方です。Uber で @raynos と共に mercury を作ってるようです。

話が逸れた。で、evancz は Matt-Esch のコードをフォークして、グラフに表示するようにしたようです。テストの実施や計測部分はほとんどいじってないように見えます。

追記

羽田野さん(@futomi)からfacebookでコメント頂きました!

> このとき興味深いのが、計測する時間はテストステップ関数の実行時間だけでなく、その直後に 以下のように setTimeout で delay 0 にして非同期の経過時間も取得していました。


私自身、コードリーディングしたわけではないので、確かなことはいませんが、たぶん、お察しの通り、レンダリングの時間を計測していると思われます。私もよく似たコードを書いたことがあります。

実は、DOM 操作が完了してから、実際にレンダリングが完了するまでには時間差があります。テレビなどの組み込み系デバイスだと、これが顕著に出ます。

しかし、レンダリング完了をスクリプトから知ることができません。では、それをどうやって計測するかというと、ブラウジングコンテキストはシングルスレッドで動作している点を利用します。

レンダリング処理を行っている最中は、シングルスレッドゆえに、タイマーですら待たされます。そこで、DOM 操作が終わった直後に、最短で実行するタイマー(ここでは、setTimeout を 0 msで指定してますが、実際にはブラウザーは 5ms として処理します)をセットします。5ms でレンダリング処理が終わらなければ、このタイマー処理は待たされます。そして、レンダリングが終わり次第、キューに貯められた、このタイマー処理が実行されます。

これでレンダリング完了までの時間を計測することができます。もしレンダリング処理が 5ms 未満で完了してしまうような高性能なマシンだと、計測は若干不利な結果になりるはずです。

ただ、2 回タイマー関数を使っているのは私にも謎です。レンダリングの前に、ブラウザーが何かしているのかもしれませんね。

ありがとうございます!なるほど。やはりそうですか。私もそういったコードをたまに書きます。特にCSSのdisplayプロパティを変更した後など。2回タイマーをネストしてるのはレンダリング完了が間に合わなかった場合のため、2回キューに積んでいるんですかねぇ。Matt-Esch本人に聞いてみたい!