Repository navigation
Pre-mount Android views in the time each frame leaves free, behind a flag - #59057
YevheniiKotyrlo wants to merge 1 commit into
Conversation
|
The refresh rate can change dynamically, so forever locking to the smallest time may be suboptimal - can we try to use the screen's current refresh rate? |
|
Updated in 35f8355: pre-mounting now takes half of the default display's current frame, read from |
|
Follow-up in 3d780fb: below 60 Hz the budget stays at today's 8.33 ms instead of half of the longer frame, since an adaptive panel idling at 1 Hz would otherwise pre-mount for 500 ms, and a display that reports no refresh rate gets 8.33 ms as well. |
While 500ms is extreme, I do think it's reasonable for us to use more than 8ms here when the system has reduced frame rate (eg in low power mode?). Maybe make the max the equivalent of 30hz / 16ms? |
|
9e2fa9c caps the budget at half of a 30 Hz frame (16.67 ms) instead of 8.33 ms; a display that reports no refresh rate keeps 8.33 ms. |
|
@javache has imported this pull request. If you are a Meta employee, you can view this in D125663479. |
|
Sorry for chiming in late. I think this goes in the right direction but I did similar explorations in the past and we could end up regressing current apps in production by under-preallocating views for common 120fps devices, which now would preallocate views for half the time they were doing later. This could be good for scroll performance and animations, but could also be bad for initial load and navigations. Given how sensitive this code path is, if we're going to update it I think we should try to get the best solution possible. In this case, I'd suggest trying idle handlers in the main thread message queue. Instead of checking if there's enough time in an arbitrary deadline, we could just check if there's anything in the queue pending execution (like choreographer callbacks). If there are, we would yield as soon as possible (improving the performance vs. now as we wouldn't go over more items than necessary). If there aren't, we would continue preallocating views, making this more efficient than it is now. Would you be open to trying this approach? In any case, we would need to validate this via a feature flag because it's a very sensitive codepath for performance. Thanks! |
9e2fa9c to
6700008
Compare
|
Release RNTester traces: Release RNTester ( "Vsyncs lost" counts the vsyncs the UI thread drew no frame for; where the frame timeline exists, the vsyncs SurfaceFlinger repeated on screen follow in brackets. "Run past the next vsync" is how far a frame's UI-thread work, its pre-mounting included, ended after the following vsync. "Drawn" counts from the burst's first new shadow node on the JS thread to the end of the frame that draws its mount. Medians, with ranges in brackets. 2400 views beside a native spring,
At 144 Hz The same beside native-driven animations that keep the UI thread busy past every vsync — 800 on the Mi MIX 3, 1600 and 2400 on the S23 and the RedMagic — where the flag pre-mounts for half a frame before each traversal once two frames in a row have been busy:
With 2400 animations on the S23, few views reach the queue before the commit in either build. On the RedMagic, the frame that draws a commit mounted between frames records its display lists at 1.5–1.8 GHz, where Over 160 translucent full-window layers, which keep the render thread busy past each vsync so the UI thread waits in each draw:
Drawn times move from round to round by about as much as the builds differ wherever they are within 10 ms of each other. Every arm on each handsetGalaxy S23, Android 14, 120 Hz 2400 views beside a native spring; 14 bursts on
600 views beside a native spring; 14 bursts on
2400 views on a ScrollView fling; 8 bursts on
2400 views over 160 translucent full-window layers; 15 bursts on
2400 views beside 800 native-driven animations; 14 bursts on
2400 views beside 1600 native-driven animations; 14 bursts on
2400 views beside 2400 native-driven animations; 14 bursts on
RedMagic NX809J, Android 16, 144 Hz 2400 views beside a native spring; 14 bursts on
600 views beside a native spring; 14 bursts on
2400 views on a ScrollView fling; 8 bursts on
2400 views over 160 translucent full-window layers; 14 bursts on
2400 views beside 200 native-driven animations; 14 bursts on
2400 views beside 400 native-driven animations; 14 bursts on
2400 views beside 800 native-driven animations; 14 bursts on
2400 views beside 1600 native-driven animations; 14 bursts on
2400 views beside 2400 native-driven animations; 14 bursts on
Mi MIX 3, Android 10, 60 Hz 2400 views beside a native spring; 14 bursts on
600 views beside a native spring; 14 bursts on
2400 views on a ScrollView fling; 6 bursts on
2400 views over 160 translucent full-window layers; 14 bursts on
2400 views beside 200 native-driven animations; 7 bursts on
2400 views beside 400 native-driven animations; 14 bursts on
2400 views beside 800 native-driven animations; 14 bursts on
The scene: the arms added to RNTester's View examplesEach arm mounts and unmounts function PremountBurstSpinner(): React.Node {
const rotation = React.useRef(new Animated.Value(0)).current;
const spin = React.useRef(
rotation.interpolate({inputRange: [0, 2], outputRange: ['0deg', '360deg']}),
).current;
React.useEffect(() => {
const spring = Animated.spring(rotation, {
toValue: 1,
stiffness: 10,
damping: 0.01,
mass: 1,
useNativeDriver: true,
});
spring.start();
return () => spring.stop();
}, [rotation]);
return (
<Animated.View
style={{
width: 48,
height: 48,
backgroundColor: '#527FE4',
transform: [{rotate: spin}],
}}
/>
);
}
function PremountBurstItems({
count,
mounted,
}: {
count: number,
mounted: boolean,
}): React.Node {
return (
<View style={{flexDirection: 'row', flexWrap: 'wrap', marginTop: 8}}>
{mounted
? Array.from({length: count}, (_, index) => (
<View
key={index}
style={{
width: 8,
height: 8,
margin: 1,
backgroundColor: '#e5e7eb',
}}>
<View style={{width: 4, height: 4, backgroundColor: '#7c3aed'}} />
</View>
))
: null}
</View>
);
}
function PremountBurstBlock({count}: {count: number}): React.Node {
const [mounted, setMounted] = useState(false);
React.useEffect(() => {
const interval = setInterval(() => setMounted(previous => !previous), 1500);
return () => clearInterval(interval);
}, []);
return <PremountBurstItems count={count} mounted={mounted} />;
}
function PremountBurstExample({count}: {count: number}): React.Node {
return (
<View testID="premount-burst">
<PremountBurstSpinner />
<PremountBurstBlock count={count} />
</View>
);
}
const PREMOUNT_FLING_COLUMNS = Array.from({length: 400}, (_, index) => (
<View
key={index}
style={{
width: 48,
height: 96,
backgroundColor: index % 2 === 0 ? '#527FE4' : '#7c3aed',
}}
/>
));
function PremountFlingExample({count}: {count: number}): React.Node {
const [mounted, setMounted] = useState(false);
return (
<View testID="premount-fling">
<ScrollView
horizontal={true}
testID="premount-fling-scroller"
style={{height: 96}}
onMomentumScrollBegin={() => setMounted(previous => !previous)}>
{PREMOUNT_FLING_COLUMNS}
</ScrollView>
<PremountBurstItems count={count} mounted={mounted} />
</View>
);
}
function PremountGpuExample({
count,
layers,
}: {
count: number,
layers: number,
}): React.Node {
const {height} = useWindowDimensions();
return (
<View testID="premount-gpu" style={{height}}>
{Array.from({length: layers}, (_, index) => (
<View
key={index}
pointerEvents="none"
style={[
StyleSheet.absoluteFill,
{backgroundColor: 'rgba(124, 58, 237, 0.02)'},
]}
/>
))}
<PremountBurstSpinner />
<PremountBurstBlock count={count} />
</View>
);
}
function PremountBusyAnimations({count}: {count: number}): React.Node {
const progress = React.useRef(new Animated.Value(0)).current;
const rotate = React.useRef(
progress.interpolate({inputRange: [0, 1], outputRange: ['0deg', '360deg']}),
).current;
React.useEffect(() => {
const loop = Animated.loop(
Animated.timing(progress, {
toValue: 1,
duration: 2000,
easing: Easing.linear,
useNativeDriver: true,
}),
);
loop.start();
return () => loop.stop();
}, [progress]);
return (
<View style={{flexDirection: 'row', flexWrap: 'wrap'}}>
{Array.from({length: count}, (_, index) => (
<Animated.View
key={index}
style={{
width: 6,
height: 6,
margin: 1,
backgroundColor: '#f59e0b',
transform: [{rotate}],
}}
/>
))}
</View>
);
}
function PremountCpuExample({
count,
animated,
}: {
count: number,
animated: number,
}): React.Node {
return (
<View testID="premount-cpu">
<PremountBusyAnimations count={animated} />
<PremountBurstSpinner />
<PremountBurstBlock count={count} />
</View>
);
} |
|
Thanks, I tried idle handlers first. A vsync and an input event only reach the main queue when the looper polls, so a running idle handler can't see the next frame coming: So I've reworked this PR behind |
…flag With enablePremountItemsBetweenFramesAndroid on, the DISPATCH_UI frame callback no longer pre-mounts for half of a 60 Hz frame before the frame's traversal. PreMountItemsBetweenFrames pre-mounts after the frame, in slices posted to the UI thread's looper as asynchronous messages, each starting items for about 1 ms, until the next vsync: from Android 13 the spacing of the frame timelines the Choreographer hands a vsync callback, otherwise the display's refresh rate. After a frame that left no time, it also pre-mounts before the next traversal: for as long as that frame's draw slept, or for half a frame once two frames in a row each took a frame interval of their own work, at most half a frame and half of a 30 Hz frame either way. A commit whose mount items arrive while the slices run is mounted by the next slice, which first runs every pre-mount item left; one the pull model queues as a surface to pull waits for the frame, and mount items wait with it. Slices run only while the frame callback would mount.
e71fb78 to
50115fd
Compare
Summary:
MountItemDispatcher.dispatchPreMountItemspre-mounts views at the start of eachDISPATCH_UIframe callback, before the frame's traversal, untilframeTimeNanos + FRAME_TIME_NS / 2: 8.33 ms on every display. While JavaScript creates views faster than the UI thread pre-allocates them, that is all of a 120 Hz frame, so frames run past the next vsync, and half of a 60 Hz frame, so the rest of the frame sits idle while the views wait for the mount.Behind a new flag,
enablePremountItemsBetweenFramesAndroid(off by default),PreMountItemsBetweenFramespre-mounts in the time each frame leaves free instead:VsyncFrameInterval), which follows the rate the frames run at where the display can report its mode's rate; otherwise it's the display's refresh rate (DisplayFrameInterval, read fromDisplayManager), or a 60 Hz frame while neither is known. A frame, an input event or any other message queued meanwhile waits one slice at most. The slices are asynchronous messages, like the Choreographer's vsync, since a view that invalidates while it draws, such as aScrollViewduring a fling, leaves the next frame's traversal barrier in the queue between frames.Debug.threadCpuTimeNanos()), and a frame that left time gets none, whatever kept its thread off the CPU.requestIdleCallback'stimeoutgives idle work. A frame's own work is timed from its vsync, or from the end of the frame before it when that came later, without what it pre-mounted, so the first frame that fits its interval ends it, and one busy frame costs the next nothing.enableMountingCoordinatorPullModelAndroid, a commit queued as a surface to pull waits for the frame, which pulls it once, as today, and mount items queued meanwhile wait with it, since a dispatch pulls before it mounts.A slice runs only while the frame callback would mount, so none runs once mounting has failed or the UI manager is destroyed. Pausing removes the queued slices on the UI thread, whichever thread
invalidate()runs on, a slice that throws stops mounting like a throwing frame callback, and a frame whose mount fails queues none. With the flag off nothing changes. The C++ drain of the pre-allocation queue (drainPreallocateViewsQueue) still runs at the start of each frame, outside any budget, and onmainit can wait there for the JS thread'sexecuteMount(#59244).I tried idle handlers first: a vsync and an input event reach the main queue only when the looper polls, so an idle handler can't see the next frame coming. In a probe,
MessageQueue.isIdle()stayed true across frames while a handler ran, on every device and system image I tried (API 26 to 37), and a handler that yielded only to queued messages drained a 150 ms backlog in one freeze.In release RNTester on a Galaxy S23 (Android 14) at 120 Hz, a RedMagic NX809J (Android 16) at 144 Hz and a Mi MIX 3 (Android 10) at 60 Hz, with 2400 views mounted beside a native spring, the flag pre-mounts 1.3 to 2.3 times as many views before the commit, loses at most 0.1 vsyncs per burst before it where
mainloses 1.6 on the S23 and 5.5 on the RedMagic, and loses 2.9 to 6.0 fewer vsyncs per burst in all. When native-driven animations keep the UI thread busy past every vsync, it pre-mounts for half a frame before each traversal and draws the mount from 34 ms sooner thanmain(Mi MIX 3) to 10 ms later (S23, 2400 animations). Over 160 translucent layers it pre-mounts at least as many views asmain, and on the Mi MIX 3 it draws the mount 56 ms sooner. The traces, every arm and the scene are in the comment below.Changelog:
[Internal] - Add
enablePremountItemsBetweenFramesAndroid, which pre-mounts Android views in the time each frame leaves free instead of for half of a 60 Hz frame before its traversalTest Plan:
PreMountItemsBetweenFramesTest(new) runs the scheduler under Robolectric with the main looper paused, React Native'sSystemClockmocked and a fake thread clock: nothing pre-mounts inside the frame; pre-mounting stops at the next vsync at 60, 120 and 144 Hz after 0, 2 and 5 ms of frame work; a message queued during a backlog runs between two slices; a traversal barrier left in the queue doesn't hold the slices back; a commit that lands during the slices, or during the slice that runs the last item, mounts before the next frame, and what its mount queues still runs before the vsync, while a commit the pull model queues waits for the frame, and mount items with it; a stale or missing refresh rate still yields every 1 ms; a frame that ran past the next vsync pre-mounts nothing after it; frames whose draw sleeps until the next vsync get that time before their traversal, at most half a frame and half of a 30 Hz frame, for as long as they keep sleeping and items are left, while a frame that left time, one frame busy on the CPU, a thread clock that can't tell, or the first frame after vsyncs that ran no frame callback gets none; frames busy past every vsync pre-mount for half a frame before each traversal from the third, at 60, 120 and 144 Hz, with a thread clock that can't tell too, one busy frame between frames that leave time costs nothing, and the time ends once frames fit their interval though they start late, at a frame with nothing to pre-mount, at vsyncs with no frame callback, and at cancelling; a frame with items left asks for the next frame's interval; a slice runs nothing once mounting is no longer allowed; cancelling removes every queued slice and the measured time; a failing item reaches the exception handler.FabricUIManagerPreMountTest(new) drives the realDISPATCH_UIframe callback: with the flag off, nine 1 ms items run inside the frame, as today; with it on, none inside and 17 before the next 60 Hz vsync, and nine inside the frame after one whose draw slept; afterinitialize()on a 120 Hz display, on Android 13 and on Android 10, the slices follow its frame; pausing cancels the queued slice; a frame whose mount fails pre-mounts nothing after it, and neither does a slice queued before it; invalidating from another thread while a slice runs stops the slices.MountItemDispatcherTest(new) pins today's 8.33 ms from the frame's start, the deadline-bounded step, and a mount batch running every pre-mount item left before itself.DisplayFrameIntervalTest(new) covers reading the default display's frame from 1 to 240 Hz, following a change the display reports, reading one that lands while its listener registers, reading on the UI thread when started from another, and reporting none while the display reports no rate.VsyncFrameIntervalTest(new) covers the frame timelines' spacing, none from a single timeline or from timelines that don't advance, and the spacing answering instead of the display's interval until it's more than two frames old or cancelled.With pre-mounting left where it is today, the scheduler's cases fail for the placement, nine items running inside the frame and none after it; with slices posted as ordinary messages, the barrier case fails with
expected:<[17]> but was:<[2]>; with the slice queued before the frame's mount items, the failure case fails withexpected:<[0]> but was:<[3]>; with a commit left for the next frame, both commit cases fail, and the pulled commit's case fails the same way when a surface to pull doesn't count as a commit, while counting a pull that waits for its root view stops the slices at once; without pre-mounting before the traversal, four frames whose draw slept pre-mount[0, 0, 0, 0]items before their traversals against[0, 9, 9, 9], and five back-to-back frames busy past a 120 Hz vsync pre-mount[0, 0, 0, 0, 0]against[0, 0, 5, 5, 5]../gradlew :packages:react-native:ReactAndroid:testDebugUnitTest -PenableWarningsAsErrors=true: 841 tests, 0 failures, 139 of them incom.facebook.react.fabric./gradlew :packages:react-native:ReactAndroid:compileReleaseKotlin :packages:react-native:ReactAndroid:compileReleaseJavaWithJavac -PenableWarningsAsErrors=true: clean./gradlew :packages:rn-tester:android:app:assembleRelease -PreactNativeArchitectures=arm64-v8a, which compiles the generated C++, for the traces above: cleannode ./scripts/format-kotlin.js --check,node ./scripts/format-java.js --check,yarn featureflags --verify-unchanged,yarn flow-check,yarn test-typescript-legacy, ESLint and Prettier over the flag sources, and the feature flags' Jest suite: cleanI didn't run the full Android
build, the template and HelloWorld app builds, the iOS builds or the e2e jobs locally.