title | author | created_at | updated_at | license |
---|---|---|---|---|
各種yield方式のパフォーマンス計測 |
syusui_s |
2022-07-25 |
2022-09-07 |
CC-BY-SA 3.0 |
JavaScriptはイベントループモデルを使ってイベントを処理するが、イベントに対するコールバック関数の処理が終わるまでの間はユーザインタラクション(入力やレンダリング等のユーザとのやり取り、対話型処理とも)が行えない。そのため、コールバック関数で重い処理を行うと画面が固まってしまうという問題がある。
これを避けるには、コールバック関数で一度に行う処理の量を減らし、イベントを小分けにするという方法がある。ブラウザはコールバック関数が完了した後、(たとえ次のイベントの処理が待っていたとしても)ユーザインタラクションを優先して処理する。コールバック関数で一度におこなう処理が少なければ、ブラウザは頻繁にユーザインタラクションの機会を与えられ、したがって画面が固まるということが少なくなる。このようにブラウザに処理を戻す行為を「yield(「譲る」の意味)」と呼ぶ。
過去のReactでは、ページ内にたくさんの要素が存在する場合、Reconciliation(差分検知)の処理に時間がかかってしまってユーザインタラクションが行えずフリーズしてしまうという課題があった。React バージョン18(以下、React 18)ではReconciliationの処理を細かく分割し、イベントループ上で処理するようにしている。こまめにyieldを行うことで重いReconciliation処理が走っていたとしてもユーザインタラクションが行えるようになった。
一方、yieldはオーバーヘッドでもある。処理は細かい単位で分割されており、何度もyieldする(ブラウザに処理を戻す)必要がある。分割やyieldを行わない場合と比べて、その分だけ余計に時間が掛かってしまう。
例えば、React 18で書かれたアプリケーションがあったとしてユーザのキー入力を行うたびに重いReconciliation処理が行われるとしよう。ユーザが入力を行っている間はちゃんと文字が画面に表示されるため良い体験を提供できるが、入力が終わった後はReconciliation処理が終わるまで画面にはDOM要素に対する変更が表示されない。入力の反映は早くなったが、描画結果が画面に見えるまでの時間が長くなってしまうというのは好ましくない。
そのため、yieldして次の処理に戻ってくるまでの時間(以下、復帰時間)は早いことが望ましい。言い換えると、ブラウザがユーザインタラクションを終えて次の処理の実行に移るまでの時間である。復帰時間が短れば、オーバーヘッドが小さくなり、全体の処理を早く終えることができる。
この記事では、各種yield方式の復帰時間を計測し、比較と考察をする。
第8世代Core i5, Firefox 103.0b8 (64bit), Chromium を使用した。
(async () => {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const calcSum = (values) => values.reduce((acc, v) => acc + v, 0);
const calcStat = (values) => {
const count = values.length;
const sum = calcSum(values);
const avg = sum / count;
const stddev = Math.sqrt(calcSum(values.map((v) => (avg - v) ** 2)) / count);
return { count, avg, stddev };
};
const benchmark = async (fn, count, interval, trim = 0) => {
if (typeof fn !== 'function') throw new ArgumentError();
if (count < 0) throw new ArgumentError();
if (trim >= count) throw new ArgumentError();
if (interval < 0) throw new ArgumentError();
const results = [];
for (let i = 0; i < count; i++) {
const result = await fn();
results.push(result);
}
results.splice(0, trim);
const stat = calcStat(results);
return { fnName: fn.name || fn.toString(), ...stat };
};
const benchmarkSimpleLoop = () => {
const duration = 5000; // 5 sec
const startAt = performance.now();
let count = 0;
while ((performance.now() - startAt) <= duration) {
count += 1;
}
return count;
};
const benchmarkMessageChannel = () => new Promise((resolve) => {
const duration = 5000; // 5 sec
const startAt = performance.now();
let count = 0;
const channel = new MessageChannel();
channel.port1.onmessage = (msg) => {
if((performance.now() - startAt) <= duration) {
count += 1;
channel.port2.postMessage(null);
} else {
resolve(count);
}
};
channel.port2.postMessage(null);
});
const benchmarkSetTimeout = () => new Promise((resolve) => {
const duration = 5000; // 5 sec
const startAt = performance.now();
let count = 0;
const f = () => {
if((performance.now() - startAt) <= duration) {
count += 1;
setTimeout(f, 0);
} else {
resolve(count);
}
};
setTimeout(f, 0);
});
// Pure while loop
// Firefox: 20M times in 5 sec => 243 ns/yield
// { fnName: "benchmarkSimpleLoop", count: 10, avg: 20_537_431.7, stddev: 809_183.5452663444 }
console.log(await benchmark(benchmarkSimpleLoop, 10, 0, 0));
// MessageChannel (ループ版に比べて50倍遅い)
// Firefox: 432K times in 5 sec => 12 usec/yield
// { fnName: "benchmarkMessageChannel", count: 5, avg: 425_371, stddev: 48_815.20235336529 }
// { fnName: "benchmarkMessageChannel", count: 10, avg: 432_967.9, stddev: 34_729.24720016257 }
// Chromium: 212K times in 5 sec => 24 usec/yield
// {fnName: 'benchmarkMessageChannel', count: 10, avg: 212_536.6, stddev: 7_762.817184502029}
// {fnName: 'benchmarkMessageChannel', count: 10, avg: 211_478.7, stddev: 8_313.273363122375}
// Chrome: 345K times in 5 sec
// {fnName: 'benchmarkMessageChannel', count: 10, avg: 345_379, stddev: 12_030.655543236204}
console.log(await benchmark(benchmarkMessageChannel, 10, 0, 0));
// setTimeout (MessageChannel版と比べて333倍遅い)
// setTimeoutは0msと指定してもコールバックの呼び出しまでに最低でも 4ms 掛かる。
// Firefox: 1105 ms in 5 sec => 4 ms/yield
// { fnName: "benchmarkSetTimeout", count: 10, avg: 1105.9, stddev: 21.956547998262387 }
// Chromium: 1130 ms in 5 sec => 4 ms/yield
// { fnName: 'benchmarkSetTimeout', count: 10, avg: 1130, stddev: 9.9498743710662 }
console.log(await benchmark(benchmarkSetTimeout, 10, 0, 0));
})();
分割された処理に掛かる時間が復帰時間に比べて十分に長いならば、復帰時間によるオーバーヘッドは無視できるほど小さくなる。実際、React 18のReconciliationでは、分割された処理は一つに掛かる時間が短すぎる可能性があるため、分割された処理を連続して行うようにし、5ms経過したらyieldするようにしている。このベンチマークで得られた数値を参考にすると、5ms (= 5000 us) に対し、12〜24 usecは416〜208分の1と短い時間であるため、復帰時間のオーバーヘッドは無視できると考えられる。
このようにyieldの仕組みを使って、小さな単位の処理を切り替えながら処理を進めるというのは、 まさに協調的マルチタスク処理(cooperative multitasking)がやっていることと同じである。 実際、React 18ではreact-schedulerという協調的マルチタスク処理を実現する 内部ライブラリが用意されており、yieldのための基盤として使われている。
この協調的マルチタスク処理は、公式サイトにおいて「並行処理機能」(concurrent features)や「並行レンダラ」(concurrent renderer)という名前でReact 18の主要なアップデートとして取り上げられている。 (並行レンダラと呼ばれる所以は、reconciliationを担当する内部ライブラリのreact-reconcilerがreact-schedulerを使ってreconciliationを実現しているからであろう)