@samitouri / QOS-React-2 / commits / 3f2cafe8be

[WIP][Scheduler] Use rIC to post first callback (#16166)

Scheduler uses `requestAnimationFrame` to post tasks to the browser. If this happens at the beginning of a frame, the callback might not fire until the subsequent frame, even if the main thread is idle. Our theory was that this wouldn't be an issue in practice, because once the first rAF fires, we schedule the next rAF as early as possible in that frame. Combined with our heuristic for computing frame deadlines, we shouldn't get any idle time in between frames — only before the *first* frame. This reasoning holds true when you have a small number of large tasks, such as the ones posted by React. The idle time before the task starts is negligible relative to the lifetime of the entire task. However, it does not hold if you have many small tasks posted over a span of time, perhaps spawned by a flurry of IO events. In this case, instead of single, contiguous rAF loop preceded by an idle frame, you get many rAF loops preceded by many idle frames. Our new theory is that this is contributing to poor main thread utilization during page loads. To try to reclaim as much idle time as possible, this PR adds two experimental flags. The first one adds a `requestIdleCallback` call to start the rAF loop, which will fire before rAF if there's idle time left in the frame. (We still call rAF in case there isn't. We start working in whichever event fires first.) The second flag tries a similar strategy using `setTimeout(fn, 0)`. If the timer fires before rAF, we'll assume that the main thread is idle. If either `requestIdleCallback` or `setTimeout` fires before rAF, we'll immediately peform some work. Since we don't have a real frame time that we can use to compute the frame deadline, we'll do an entire frame length of work. This will probably push us past the vsync, but this is fine because we can catch up during the next frame, by which point a real rAF will have fired and the loop can proceed the same way it does today. Test plan: Try this on Facebook to see if it improves load times

Andrew Clark committed Jul 22, 2019 at 13:12 UTC 3f2cafe8bef0922525b445f7bd2e23ea75c817cf
3 files changed +167 -141
packages/scheduler/src/SchedulerFeatureFlags.js
+2
@@ -8,3 +8,5 @@
8
9 export const enableSchedulerDebugging = false;
10 export const enableIsInputPending = false;
11 +export const requestIdleCallbackBeforeFirstFrame = false;
12 +export const requestTimerEventBeforeFirstFrame = false;
packages/scheduler/src/forks/SchedulerFeatureFlags.www.js
+2
@@ -9,4 +9,6 @@
9 export const {
10 enableIsInputPending,
11 enableSchedulerDebugging,
12 + requestIdleCallbackBeforeFirstFrame,
13 + requestTimerEventBeforeFirstFrame,
14 } = require('SchedulerFeatureFlags');
packages/scheduler/src/forks/SchedulerHostConfig.default.js
+163 -141
@@ -5,7 +5,11 @@
5 * LICENSE file in the root directory of this source tree.
6 */
7
8 -import {enableIsInputPending} from '../SchedulerFeatureFlags';
8 +import {
9 + enableIsInputPending,
10 + requestIdleCallbackBeforeFirstFrame as requestIdleCallbackBeforeFirstFrameFlag,
11 + requestTimerEventBeforeFirstFrame,
12 +} from '../SchedulerFeatureFlags';
13
14 // The DOM Scheduler implementation is similar to requestIdleCallback. It
15 // works by scheduling a requestAnimationFrame, storing the time for the start
@@ -24,65 +28,6 @@ export let requestPaint;
28 export let getCurrentTime;
29 export let forceFrameRate;
30
27 -const hasNativePerformanceNow =
28 - typeof performance === 'object' && typeof performance.now === 'function';
29 -
30 -// We capture a local reference to any global, in case it gets polyfilled after
31 -// this module is initially evaluated. We want to be using a
32 -// consistent implementation.
33 -const localDate = Date;
34 -
35 -// This initialization code may run even on server environments if a component
36 -// just imports ReactDOM (e.g. for findDOMNode). Some environments might not
37 -// have setTimeout or clearTimeout. However, we always expect them to be defined
38 -// on the client. https://github.com/facebook/react/pull/13088
39 -const localSetTimeout =
40 - typeof setTimeout === 'function' ? setTimeout : undefined;
41 -const localClearTimeout =
42 - typeof clearTimeout === 'function' ? clearTimeout : undefined;
43 -
44 -// We don't expect either of these to necessarily be defined, but we will error
45 -// later if they are missing on the client.
46 -const localRequestAnimationFrame =
47 - typeof requestAnimationFrame === 'function'
48 - ? requestAnimationFrame
49 - : undefined;
50 -const localCancelAnimationFrame =
51 - typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : undefined;
52 -
53 -// requestAnimationFrame does not run when the tab is in the background. If
54 -// we're backgrounded we prefer for that work to happen so that the page
55 -// continues to load in the background. So we also schedule a 'setTimeout' as
56 -// a fallback.
57 -// TODO: Need a better heuristic for backgrounded work.
58 -const ANIMATION_FRAME_TIMEOUT = 100;
59 -let rAFID;
60 -let rAFTimeoutID;
61 -const requestAnimationFrameWithTimeout = function(callback) {
62 - // schedule rAF and also a setTimeout
63 - rAFID = localRequestAnimationFrame(function(timestamp) {
64 - // cancel the setTimeout
65 - localClearTimeout(rAFTimeoutID);
66 - callback(timestamp);
67 - });
68 - rAFTimeoutID = localSetTimeout(function() {
69 - // cancel the requestAnimationFrame
70 - localCancelAnimationFrame(rAFID);
71 - callback(getCurrentTime());
72 - }, ANIMATION_FRAME_TIMEOUT);
73 -};
74 -
75 -if (hasNativePerformanceNow) {
76 - const Performance = performance;
77 - getCurrentTime = function() {
78 - return Performance.now();
79 - };
80 -} else {
81 - getCurrentTime = function() {
82 - return localDate.now();
83 - };
84 -}
85 -
31 if (
32 // If Scheduler runs in a non-DOM environment, it falls back to a naive
33 // implementation using setTimeout.
@@ -107,6 +52,9 @@ if (
52 }
53 }
54 };
55 + getCurrentTime = function() {
56 + return Date.now();
57 + };
58 requestHostCallback = function(cb) {
59 if (_callback !== null) {
60 // Protect against re-entrancy.
@@ -130,16 +78,25 @@ if (
78 };
79 requestPaint = forceFrameRate = function() {};
80 } else {
81 + // Capture local references to native APIs, in case a polyfill overrides them.
82 + const performance = window.performance;
83 + const Date = window.Date;
84 + const setTimeout = window.setTimeout;
85 + const clearTimeout = window.clearTimeout;
86 + const requestAnimationFrame = window.requestAnimationFrame;
87 + const cancelAnimationFrame = window.cancelAnimationFrame;
88 + const requestIdleCallback = window.requestIdleCallback;
89 +
90 if (typeof console !== 'undefined') {
91 // TODO: Remove fb.me link
135 - if (typeof localRequestAnimationFrame !== 'function') {
92 + if (typeof requestAnimationFrame !== 'function') {
93 console.error(
94 "This browser doesn't support requestAnimationFrame. " +
95 'Make sure that you load a ' +
96 'polyfill in older browsers. https://fb.me/react-polyfills',
97 );
98 }
142 - if (typeof localCancelAnimationFrame !== 'function') {
99 + if (typeof cancelAnimationFrame !== 'function') {
100 console.error(
101 "This browser doesn't support cancelAnimationFrame. " +
102 'Make sure that you load a ' +
@@ -148,19 +105,29 @@ if (
105 }
106 }
107
151 - let scheduledHostCallback = null;
152 - let isMessageEventScheduled = false;
108 + const requestIdleCallbackBeforeFirstFrame =
109 + requestIdleCallbackBeforeFirstFrameFlag &&
110 + typeof requestIdleCallback === 'function' &&
111 + typeof cancelIdleCallback === 'function';
112
154 - let isAnimationFrameScheduled = false;
113 + getCurrentTime =
114 + typeof performance === 'object' && typeof performance.now === 'function'
115 + ? () => performance.now()
116 + : () => Date.now();
117
156 - let timeoutID = -1;
118 + let isRAFLoopRunning = false;
119 + let scheduledHostCallback = null;
120 + let rAFTimeoutID = -1;
121 + let taskTimeoutID = -1;
122
158 - let frameDeadline = 0;
123 // We start out assuming that we run at 30fps but then the heuristic tracking
124 // will adjust this value to a faster fps if we get more frequent animation
125 // frames.
162 - let previousFrameTime = 33;
163 - let activeFrameTime = 33;
126 + let frameLength = 33.33;
127 + let prevRAFTime = -1;
128 + let prevRAFInterval = -1;
129 + let frameDeadline = 0;
130 +
131 let fpsLocked = false;
132
133 // TODO: Make this configurable
@@ -222,20 +189,16 @@ if (
189 return;
190 }
191 if (fps > 0) {
225 - activeFrameTime = Math.floor(1000 / fps);
192 + frameLength = Math.floor(1000 / fps);
193 fpsLocked = true;
194 } else {
195 // reset the framerate
229 - activeFrameTime = 33;
196 + frameLength = 33.33;
197 fpsLocked = false;
198 }
199 };
200
234 - // We use the postMessage trick to defer idle work until after the repaint.
235 - const channel = new MessageChannel();
236 - const port = channel.port2;
237 - channel.port1.onmessage = function(event) {
238 - isMessageEventScheduled = false;
201 + const performWorkUntilDeadline = () => {
202 if (scheduledHostCallback !== null) {
203 const currentTime = getCurrentTime();
204 const hasTimeRemaining = frameDeadline - currentTime > 0;
@@ -244,103 +207,162 @@ if (
207 hasTimeRemaining,
208 currentTime,
209 );
247 - if (hasMoreWork) {
248 - // Ensure the next frame is scheduled.
249 - if (!isAnimationFrameScheduled) {
250 - isAnimationFrameScheduled = true;
251 - requestAnimationFrameWithTimeout(animationTick);
252 - }
253 - } else {
210 + if (!hasMoreWork) {
211 scheduledHostCallback = null;
212 }
213 } catch (error) {
214 // If a scheduler task throws, exit the current browser task so the
215 // error can be observed, and post a new task as soon as possible
216 // so we can continue where we left off.
260 - isMessageEventScheduled = true;
261 - port.postMessage(undefined);
217 + port.postMessage(null);
218 throw error;
219 }
264 - // Yielding to the browser will give it a chance to paint, so we can
265 - // reset this.
266 - needsPaint = false;
220 }
221 + // Yielding to the browser will give it a chance to paint, so we can
222 + // reset this.
223 + needsPaint = false;
224 };
225
270 - const animationTick = function(rafTime) {
271 - if (scheduledHostCallback !== null) {
272 - // Eagerly schedule the next animation callback at the beginning of the
273 - // frame. If the scheduler queue is not empty at the end of the frame, it
274 - // will continue flushing inside that callback. If the queue *is* empty,
275 - // then it will exit immediately. Posting the callback at the start of the
276 - // frame ensures it's fired within the earliest possible frame. If we
277 - // waited until the end of the frame to post the callback, we risk the
278 - // browser skipping a frame and not firing the callback until the frame
279 - // after that.
280 - requestAnimationFrameWithTimeout(animationTick);
281 - } else {
282 - // No pending work. Exit.
283 - isAnimationFrameScheduled = false;
226 + // We use the postMessage trick to defer idle work until after the repaint.
227 + const channel = new MessageChannel();
228 + const port = channel.port2;
229 + channel.port1.onmessage = performWorkUntilDeadline;
230 +
231 + const onAnimationFrame = rAFTime => {
232 + if (scheduledHostCallback === null) {
233 + // No scheduled work. Exit.
234 + isRAFLoopRunning = false;
235 + prevRAFTime = -1;
236 + prevRAFInterval = -1;
237 + return;
238 + }
239 + if (rAFTime - prevRAFTime < 0.1) {
240 + // Defensive coding. Received two rAFs in the same frame. Exit and wait
241 + // for the next frame.
242 + // TODO: This could be an indication that the frame rate is too high. We
243 + // don't currently handle the case where the browser dynamically lowers
244 + // the framerate, e.g. in low power situation (other than the rAF timeout,
245 + // but that's designed for when the tab is backgrounded and doesn't
246 + // optimize for maxiumum CPU utilization).
247 return;
248 }
249
287 - let nextFrameTime = rafTime - frameDeadline + activeFrameTime;
288 - if (
289 - nextFrameTime < activeFrameTime &&
290 - previousFrameTime < activeFrameTime &&
291 - !fpsLocked
292 - ) {
293 - if (nextFrameTime < 8) {
294 - // Defensive coding. We don't support higher frame rates than 120hz.
295 - // If the calculated frame time gets lower than 8, it is probably a bug.
296 - nextFrameTime = 8;
250 + // Eagerly schedule the next animation callback at the beginning of the
251 + // frame. If the scheduler queue is not empty at the end of the frame, it
252 + // will continue flushing inside that callback. If the queue *is* empty,
253 + // then it will exit immediately. Posting the callback at the start of the
254 + // frame ensures it's fired within the earliest possible frame. If we
255 + // waited until the end of the frame to post the callback, we risk the
256 + // browser skipping a frame and not firing the callback until the frame
257 + // after that.
258 + requestAnimationFrame(nextRAFTime => {
259 + clearTimeout(rAFTimeoutID);
260 + onAnimationFrame(nextRAFTime);
261 + });
262 + // requestAnimationFrame is throttled when the tab is backgrounded. We
263 + // don't want to stop working entirely. So we'll fallback to a timeout loop.
264 + // TODO: Need a better heuristic for backgrounded work.
265 + const onTimeout = () => {
266 + frameDeadline = getCurrentTime() + frameLength / 2;
267 + performWorkUntilDeadline();
268 + rAFTimeoutID = setTimeout(onTimeout, frameLength * 3);
269 + };
270 + rAFTimeoutID = setTimeout(onTimeout, frameLength * 3);
271 +
272 + if (prevRAFTime !== -1) {
273 + const rAFInterval = rAFTime - prevRAFTime;
274 + if (!fpsLocked && prevRAFInterval !== -1) {
275 + // We've observed two consecutive frame intervals. We'll use this to
276 + // dynamically adjust the frame rate.
277 + //
278 + // If one frame goes long, then the next one can be short to catch up.
279 + // If two frames are short in a row, then that's an indication that we
280 + // actually have a higher frame rate than what we're currently
281 + // optimizing. For example, if we're running on 120hz display or 90hz VR
282 + // display. Take the max of the two in case one of them was an anomaly
283 + // due to missed frame deadlines.
284 + if (rAFInterval < frameLength && prevRAFInterval < frameLength) {
285 + frameLength =
286 + rAFInterval < prevRAFInterval ? prevRAFInterval : rAFInterval;
287 + if (frameLength < 8.33) {
288 + // Defensive coding. We don't support higher frame rates than 120hz.
289 + // If the calculated frame length gets lower than 8, it is probably
290 + // a bug.
291 + frameLength = 8.33;
292 + }
293 + }
294 }
298 - // If one frame goes long, then the next one can be short to catch up.
299 - // If two frames are short in a row, then that's an indication that we
300 - // actually have a higher frame rate than what we're currently optimizing.
301 - // We adjust our heuristic dynamically accordingly. For example, if we're
302 - // running on 120hz display or 90hz VR display.
303 - // Take the max of the two in case one of them was an anomaly due to
304 - // missed frame deadlines.
305 - activeFrameTime =
306 - nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime;
307 - } else {
308 - previousFrameTime = nextFrameTime;
309 - }
310 - frameDeadline = rafTime + activeFrameTime;
311 - if (!isMessageEventScheduled) {
312 - isMessageEventScheduled = true;
313 - port.postMessage(undefined);
295 + prevRAFInterval = rAFInterval;
296 }
297 + prevRAFTime = rAFTime;
298 + frameDeadline = rAFTime + frameLength;
299 +
300 + port.postMessage(null);
301 };
302
303 requestHostCallback = function(callback) {
318 - if (scheduledHostCallback === null) {
319 - scheduledHostCallback = callback;
320 - if (!isAnimationFrameScheduled) {
321 - // If rAF didn't already schedule one, we need to schedule a frame.
322 - // TODO: If this rAF doesn't materialize because the browser throttles,
323 - // we might want to still have setTimeout trigger rIC as a backup to
324 - // ensure that we keep performing work.
325 - isAnimationFrameScheduled = true;
326 - requestAnimationFrameWithTimeout(animationTick);
304 + scheduledHostCallback = callback;
305 + if (!isRAFLoopRunning) {
306 + isRAFLoopRunning = true;
307 +
308 + // Start a rAF loop.
309 + requestAnimationFrame(rAFTime => {
310 + if (requestIdleCallbackBeforeFirstFrame) {
311 + cancelIdleCallback(idleCallbackID);
312 + }
313 + if (requestTimerEventBeforeFirstFrame) {
314 + clearTimeout(idleTimeoutID);
315 + }
316 + onAnimationFrame(rAFTime);
317 + });
318 +
319 + // If we just missed the last vsync, the next rAF might not happen for
320 + // another frame. To claim as much idle time as possible, post a callback
321 + // with `requestIdleCallback`, which should fire if there's idle time left
322 + // in the frame.
323 + //
324 + // This should only be an issue for the first rAF in the loop; subsequent
325 + // rAFs are scheduled at the beginning of the preceding frame.
326 + let idleCallbackID;
327 + if (requestIdleCallbackBeforeFirstFrame) {
328 + idleCallbackID = requestIdleCallback(() => {
329 + if (requestTimerEventBeforeFirstFrame) {
330 + clearTimeout(idleTimeoutID);
331 + }
332 + frameDeadline = getCurrentTime() + frameLength;
333 + performWorkUntilDeadline();
334 + });
335 + }
336 + // Alternate strategy to address the same problem. Scheduler a timer with
337 + // no delay. If this fires before the rAF, that likely indicates that
338 + // there's idle time before the next vsync. This isn't always the case,
339 + // but we'll be aggressive and assume it is, as a trade off to prevent
340 + // idle periods.
341 + let idleTimeoutID;
342 + if (requestTimerEventBeforeFirstFrame) {
343 + idleTimeoutID = setTimeout(() => {
344 + if (requestIdleCallbackBeforeFirstFrame) {
345 + cancelIdleCallback(idleCallbackID);
346 + }
347 + frameDeadline = getCurrentTime() + frameLength;
348 + performWorkUntilDeadline();
349 + }, 0);
350 }
351 }
352 };
353
354 cancelHostCallback = function() {
355 scheduledHostCallback = null;
333 - isMessageEventScheduled = false;
356 };
357
358 requestHostTimeout = function(callback, ms) {
337 - timeoutID = localSetTimeout(() => {
359 + taskTimeoutID = setTimeout(() => {
360 callback(getCurrentTime());
361 }, ms);
362 };
363
364 cancelHostTimeout = function() {
343 - localClearTimeout(timeoutID);
344 - timeoutID = -1;
365 + clearTimeout(taskTimeoutID);
366 + taskTimeoutID = -1;
367 };
368 }