Skip to content

Commit 3f2cafe

Browse files
authoredJul 22, 2019
[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
1 parent 2bd88e3 commit 3f2cafe

File tree

3 files changed

+167
-141
lines changed

3 files changed

+167
-141
lines changed
 

‎packages/scheduler/src/SchedulerFeatureFlags.js

+2
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@
88

99
export const enableSchedulerDebugging = false;
1010
export const enableIsInputPending = false;
11+
export const requestIdleCallbackBeforeFirstFrame = false;
12+
export const requestTimerEventBeforeFirstFrame = false;

‎packages/scheduler/src/forks/SchedulerFeatureFlags.www.js

+2
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@
99
export const {
1010
enableIsInputPending,
1111
enableSchedulerDebugging,
12+
requestIdleCallbackBeforeFirstFrame,
13+
requestTimerEventBeforeFirstFrame,
1214
} = require('SchedulerFeatureFlags');

‎packages/scheduler/src/forks/SchedulerHostConfig.default.js

+163-141
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8-
import {enableIsInputPending} from '../SchedulerFeatureFlags';
8+
import {
9+
enableIsInputPending,
10+
requestIdleCallbackBeforeFirstFrame as requestIdleCallbackBeforeFirstFrameFlag,
11+
requestTimerEventBeforeFirstFrame,
12+
} from '../SchedulerFeatureFlags';
913

1014
// The DOM Scheduler implementation is similar to requestIdleCallback. It
1115
// works by scheduling a requestAnimationFrame, storing the time for the start
@@ -24,65 +28,6 @@ export let requestPaint;
2428
export let getCurrentTime;
2529
export let forceFrameRate;
2630

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-
8631
if (
8732
// If Scheduler runs in a non-DOM environment, it falls back to a naive
8833
// implementation using setTimeout.
@@ -107,6 +52,9 @@ if (
10752
}
10853
}
10954
};
55+
getCurrentTime = function() {
56+
return Date.now();
57+
};
11058
requestHostCallback = function(cb) {
11159
if (_callback !== null) {
11260
// Protect against re-entrancy.
@@ -130,16 +78,25 @@ if (
13078
};
13179
requestPaint = forceFrameRate = function() {};
13280
} 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+
13390
if (typeof console !== 'undefined') {
13491
// TODO: Remove fb.me link
135-
if (typeof localRequestAnimationFrame !== 'function') {
92+
if (typeof requestAnimationFrame !== 'function') {
13693
console.error(
13794
"This browser doesn't support requestAnimationFrame. " +
13895
'Make sure that you load a ' +
13996
'polyfill in older browsers. https://fb.me/react-polyfills',
14097
);
14198
}
142-
if (typeof localCancelAnimationFrame !== 'function') {
99+
if (typeof cancelAnimationFrame !== 'function') {
143100
console.error(
144101
"This browser doesn't support cancelAnimationFrame. " +
145102
'Make sure that you load a ' +
@@ -148,19 +105,29 @@ if (
148105
}
149106
}
150107

151-
let scheduledHostCallback = null;
152-
let isMessageEventScheduled = false;
108+
const requestIdleCallbackBeforeFirstFrame =
109+
requestIdleCallbackBeforeFirstFrameFlag &&
110+
typeof requestIdleCallback === 'function' &&
111+
typeof cancelIdleCallback === 'function';
153112

154-
let isAnimationFrameScheduled = false;
113+
getCurrentTime =
114+
typeof performance === 'object' && typeof performance.now === 'function'
115+
? () => performance.now()
116+
: () => Date.now();
155117

156-
let timeoutID = -1;
118+
let isRAFLoopRunning = false;
119+
let scheduledHostCallback = null;
120+
let rAFTimeoutID = -1;
121+
let taskTimeoutID = -1;
157122

158-
let frameDeadline = 0;
159123
// We start out assuming that we run at 30fps but then the heuristic tracking
160124
// will adjust this value to a faster fps if we get more frequent animation
161125
// 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+
164131
let fpsLocked = false;
165132

166133
// TODO: Make this configurable
@@ -222,20 +189,16 @@ if (
222189
return;
223190
}
224191
if (fps > 0) {
225-
activeFrameTime = Math.floor(1000 / fps);
192+
frameLength = Math.floor(1000 / fps);
226193
fpsLocked = true;
227194
} else {
228195
// reset the framerate
229-
activeFrameTime = 33;
196+
frameLength = 33.33;
230197
fpsLocked = false;
231198
}
232199
};
233200

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 = () => {
239202
if (scheduledHostCallback !== null) {
240203
const currentTime = getCurrentTime();
241204
const hasTimeRemaining = frameDeadline - currentTime > 0;
@@ -244,103 +207,162 @@ if (
244207
hasTimeRemaining,
245208
currentTime,
246209
);
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) {
254211
scheduledHostCallback = null;
255212
}
256213
} catch (error) {
257214
// If a scheduler task throws, exit the current browser task so the
258215
// error can be observed, and post a new task as soon as possible
259216
// so we can continue where we left off.
260-
isMessageEventScheduled = true;
261-
port.postMessage(undefined);
217+
port.postMessage(null);
262218
throw error;
263219
}
264-
// Yielding to the browser will give it a chance to paint, so we can
265-
// reset this.
266-
needsPaint = false;
267220
}
221+
// Yielding to the browser will give it a chance to paint, so we can
222+
// reset this.
223+
needsPaint = false;
268224
};
269225

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).
284247
return;
285248
}
286249

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+
}
297294
}
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;
314296
}
297+
prevRAFTime = rAFTime;
298+
frameDeadline = rAFTime + frameLength;
299+
300+
port.postMessage(null);
315301
};
316302

317303
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);
327350
}
328351
}
329352
};
330353

331354
cancelHostCallback = function() {
332355
scheduledHostCallback = null;
333-
isMessageEventScheduled = false;
334356
};
335357

336358
requestHostTimeout = function(callback, ms) {
337-
timeoutID = localSetTimeout(() => {
359+
taskTimeoutID = setTimeout(() => {
338360
callback(getCurrentTime());
339361
}, ms);
340362
};
341363

342364
cancelHostTimeout = function() {
343-
localClearTimeout(timeoutID);
344-
timeoutID = -1;
365+
clearTimeout(taskTimeoutID);
366+
taskTimeoutID = -1;
345367
};
346368
}

0 commit comments

Comments
 (0)
Please sign in to comment.