Repository navigation
Pre-mount Android views in the time each frame leaves free, behind a flag - #59057
YevheniiKotyrlo wants to merge 9 commits 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! |
With enablePremountItemsBetweenFramesAndroid on, the DISPATCH_UI frame callback no longer pre-mounts views before the frame's traversal for half of a 60 Hz frame. PreMountItemsBetweenFrames queues that work for after the frame and runs it in slices of at most 1 ms posted to the UI thread's looper, stopping at the next vsync: the frame's time plus the default display's frame interval (DisplayFrameInterval), or a 60 Hz frame while the display reports no rate. A frame, an input event or any other message waits for one slice at most, so a stale frame interval delays a frame by one slice and one item at most. A commit still runs every pre-mount item left before its mount items, and a failing slice stops mounting the way a failing frame callback does.
With enablePremountItemsBetweenFramesAndroid on, the DISPATCH_UI callback queued its pre-mount slice before tryDispatchMountItems. A frame whose dispatch failed before the batch drained the pre-mount queue, such as a UIManagerListener throwing in willMountItems, still pre-mounted after mounting had stopped. The callback now runs the frame's mount items first and queues what is left after them, so a failed frame leaves nothing to pre-mount, as with the flag off. FabricUIManagerPreMountTest drives the real frame callback with React Native's clock under the test's control: with the flag off, nine 1 ms items run inside the frame and none after it; with it on, none inside and 17 before the next 60 Hz vsync; pausing cancels a queued slice; and a frame whose mount fails pre-mounts nothing after it.
A view that invalidates while it draws, such as a ScrollView during a fling, schedules the next frame's traversal from inside the current one, and its sync barrier stays in the main queue until that frame. Slices posted as ordinary messages waited behind it, so during a fling only the first slice after each frame ran: on a Galaxy S23 and a Mi MIX 3, RNTester pre-mounted about 1 ms per frame while a fling mounted 2400 views, against 6.6 to 7.7 ms with the flag off. Slices are now asynchronous, as the Choreographer's own vsync messages are, and still stop at the next vsync. PreMountItemsBetweenFramesTest posts the barrier ViewRootImpl.scheduleTraversals posts once a frame has queued its slice; with ordinary messages only the first slice runs (2 items instead of 17).
With enablePremountItemsBetweenFramesAndroid on, the frame callback dispatches its mount items before it queues the slices, so a commit whose mount items arrived while the slices ran waited for the next frame callback. With the flag off, the frame's pre-mount loop runs before tryDispatchMountItems, and a commit that lands during it mounts in that frame. A slice now dispatches the mount items it finds waiting, and the slice that runs out of pre-mount items queues one more when a commit landed during it. The scheduler test's looper stops after 1000 tasks, so a slice that never stops fails the test instead of hanging it.
While the render thread waits on SurfaceFlinger for a buffer, the UI thread sleeps in each frame's draw until the render thread takes the frame, so the frames leave no time after them and pre-mounting between frames stalls until the commit's mount batch. With the flag off, the frame callback pre-mounts before the traversal, and that sleep absorbs it. The first slice after a frame measures how long the UI thread slept since the frame's callbacks ran, from the thread's CPU time, and the next frame pre-mounts before its traversal for that sleep plus what it pre-mounted there, less one slice and at most half a frame. A frame that sleeps less than half a slice ends it, and a frame busy on the CPU never starts it.
Debug.threadCpuTimeNanos() reports -1 where the system cannot tell a thread's CPU time. The frame's sleep would then read as its whole wall time, so a frame busy on the CPU would hand the next one time before its traversal; a negative reading now leaves the next traversal alone.
With time left before the traversal and no pre-mount items, the frame read the empty call's few microseconds as pre-mounting and queued a slice to measure its sleep, so frames that kept waiting on the render thread after a burst queued one slice each for as long as they waited.
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,
The same over 160 translucent full-window layers, which keep the render thread busy past each vsync — the UI thread sleeps a median 10.6–11.0 ms (S23), 10.1 ms (RedMagic) and 30.3–30.7 ms (Mi MIX 3) of each traversal waiting for it:
On the Mi MIX 3 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; 14 bursts on
RedMagic NX809J, Android 16, 120 Hz 2400 views beside a native spring; 14 bursts on
600 views beside a native spring; 15 bursts on
2400 views on a ScrollView fling; 8 bursts on
2400 views over 160 translucent full-window layers; 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
The scene: four 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>
);
} |
|
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 |
The description named only the slices after each frame. A frame that follows one whose draw slept also pre-mounts before its traversal, so the description now says so, as PreMountItemsBetweenFrames does.
…sured With disableIdleMountItemFrameCallbackRearmAndroid on, the frame callback stops re-arming once nothing is left to mount, and the sleep a slice had measured after the last frame waited for the next burst's first frame, which spent it before its traversal however many vsyncs had passed. The measured time now lapses once a frame interval has passed since it was measured, so only the frame right after the measured one spends it.
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:DisplayFrameInterval, read fromDisplayManager), or a 60 Hz frame while the display reports no rate. 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()), so a frame busy on the CPU never gets any.A slice still queued when the frame callback pauses is removed, 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) and a RedMagic NX809J (Android 16) at 120 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.4 times as many views before the commit, loses no vsync before it where
mainloses 1.2 to 2.8 per burst at 120 Hz, and loses 2.2 to 3.9 fewer vsyncs per burst in all. When 160 translucent layers keep the render thread past each vsync, it pre-mounts at least as many views asmain, and on the Mi MIX 3 it draws the mount 53 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; 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, for as long as they keep sleeping and items are left, while a 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; 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; pausing cancels the queued slice; a frame whose mount fails pre-mounts nothing after it.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, and reporting none while the display reports no rate.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; 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]../gradlew :packages:react-native:ReactAndroid:testDebugUnitTest -PenableWarningsAsErrors=true: 801 tests, 0 failures./gradlew :packages:react-native:ReactAndroid:compileReleaseKotlin :packages:react-native:ReactAndroid:compileReleaseJavaWithJavac -PenableWarningsAsErrors=true: cleannode ./scripts/format-kotlin.js --check,node ./scripts/format-java.js --check,yarn featureflags --verify-unchanged,yarn flow-check,yarn test-typescript-legacyand the feature flags' Jest suite: cleanyarn test-generated-typescriptfails on Windows, wherebuild-typesnames each module's default export after its absolute path; the generated types hold no feature flag module. I didn't run the full Androidbuild, the template and HelloWorld app builds, the iOS builds or the e2e jobs locally.