react-native-reanimated
react-native-reanimated copied to clipboard
Performance on Fabric is getting worse over time
Description
The main Issue I and @hannojg found in one of our projects is that commit hook that injects reanimated animated props in shadow tree (on RN flush) is getting really slow over time. It sometimes get's multiple frames to execute. The main problem we noticed is that Props Registry contains all reanimated props/views that were ever animated and not unmounted yet. Imagine you have an animated list with useAnimatedStyle for each item. Most of the time the items are not animated but because they have useAnimatedStyle and they call updateProps at least once we will include them in Commit hook action.
We would like to propose a few solutions to that that reduced the number of junk frames by a lot.
The simplest approach is to get rid of views/props from props registry once the props we get from RN match those from reanimated. If they do we evict them from reanimated registry. Of course normally the props won't magically update and that's why we have the commit hook. However, we can make few things to synchronize them. Here are few ways how to do it:
- We keep state in every view wrapped by createReanimatedComponent. We check on each frame on UI side which props were not updated in the last 2 frames if they were not it means their animations are finished and we can schedule a set state. As setStates are batched on Fabric and we only call them once animations are over they actually end up not adding much overhead. Then on next React Native flush to Shadow Tree we compare if props of the animated node are already the same and if that's the case we don't need to update the node and can evict the props from the registry.
- We can make use of new feature flag on iOS that pushes latest yoga nodes to js.
- We can patch cloneNodeWithChildren to always take latest nodes for children from the shadow tree and inject reanimated animated props in clone method. (Even though we of course may not clone all animated nodes it doesn't matter as we would take latest children nodes that already contains animated changes or will contain on next render which will lead to their eviction in commit hook.
@tomekzaw @kmagiera It would be great to hear your thoughts on it. We are also happy to share exact numbers that we got and exact implementation.
Steps to reproduce
- Render screen with few animated views
- wrap reanimated commit hook in time measurement.
Snack or a link to a repository
/
Reanimated version
latest
React Native version
0.78
Platforms
iOS, Android
JavaScript runtime
None
Workflow
None
Architecture
None
Build type
No response
Device
No response
Host machine
None
Device model
No response
Acknowledgements
Yes
Hey! 👋
It looks like you've omitted a few important sections from the issue template.
Please complete Snack or a link to a repository section.
Hey! 👋
The issue doesn't seem to contain a minimal reproduction.
Could you provide a snack or a link to a GitHub repository under your username that reproduces the problem?
Hi @Szymon20000, thanks for opening this issue. We are aware of all the imperfections of the current implementation for new arch. Keep in mind that this is still the best one we have and we have been incrementally improving it over the course of past 2 years or even more. Some of the problems are rooted in the design of new architecture itself as it doesn't seem like it was designed to support frequent commits from other threads and it doesn't really allow us to propagate changes back to nodes kept by React.js in a controllable manner but we're doing our best to find some other ways to achieve a similar result.
As for ReanimatedCommitHook (and most likely ReanimatedMountHook), right now we're in the middle of a major refactor of createAnimatedComponent which now will wrap the passed view in a native component. This will give us more control over C++ ShadowTree and hopefully will allow us to completely remove ReanimatedCommitHook.
- We can make use of new feature flag on iOS that pushes latest yoga nodes to js.
Could you please specify what's the name of this flag? Do you mean setRuntimeShadowNodeReference or something else?
- We can patch
cloneNodeWithNewChildren
Actually, we used to do that in a very early implementation of Reanimated for New Architecture and we weren't particularly happy about this (in fact, we were very unhappy). Back then it wasn't possible to simply re-use the implementation from react-native and we had to copy big chunks of code, including cloneNodeWithNewProps and cloneNodeWithNewChildrenAndProps. Also, there are other libraries (namely react-native-unistyles) that do similar things (actually, same things, as the logic has been copied from Reanimated and renamed later on) so this doesn't sound like a scalable solution.
Hi @tomekzaw ! I think for now the state per animated component solution can be the easiest one + eviction. We got around 50% less junky frames with this approach. As the result we only apply reanimated props for views that are actually being animated. Most of the time the number is smaller than 2. When it comes to second approach I meant this one https://github.com/facebook/react-native/blob/473e42bbc383fb01981bdfc7085ab923f0c786c0/packages/react-native/ReactCommon/react/renderer/core/ShadowNode.cpp#L315.
@tomekzaw would you like us to open a pr with eviction + state per animated component?
Sure, let's go ahead, I believe @bartlomiejbloniarz can take a look once it's submitted.
cc @hannojg
Could you please specify what's the name of this flag? Do you mean
setRuntimeShadowNodeReferenceor something else?
Yes that was the method we were looking at. During different react native versions that feature flag had different names.
I will open a PR either today or on Monday to show our changes and share some exact performance numbers!
Just to add to the context here, to improve performance we had to bring back the changes from this PR, adding the fast path to immediately update UI props:
- https://github.com/software-mansion/react-native-reanimated/pull/7014
this also helped with performance issues we were seeing
@Szymon20000 do you happen to have the Yarn patch file? Or maybe some more info on how to improve the animations?
I opened a draft PR to discuss our implementation for improving this:
- https://github.com/software-mansion/react-native-reanimated/pull/7601
Here are some of the performance numbers we were seeing with this in a huge / complex react native app:
We have a huge list where each item is animated in, so they are added to the prop registry. Scrolling became slow over time. For this screen we had a 37.43% jank frame rate. With this change the jank frame rate dropped to 19.99%. As a reference, when having completely disabling the commit/mount hooks the jank frame rate is 16.70%.
(I don't want to dilute the scope of this description too much, but we also added back the fast update path where we update none layout props directly instead of running through the commit pipeline, and landed on a jank frame rate of 12.86%)
As a little update, we just tried to use the Runtime Shadow Node Reference Update. It seems to work nicely with reanimated.
We:
- Enabled RSNRU for all threads here: https://github.com/facebook/react-native/blob/e7a9322bdfec9ddc2f08fb8b16872a105c1ae7e4/packages/react-native/ReactCommon/react/renderer/core/ShadowNode.cpp#L29
- We stopped installing the
ReanimatedCommitHookandReanimatedMountHook - We build an example where we loop a animation with reanimated using layout props and additionally do commits from React JS thread:
https://github.com/user-attachments/assets/d27cdada-8cb6-465b-ae9f-c51c5dcd4e76
Code example
import { useEffect, useState } from 'react';
import { Button, View } from 'react-native';
import Reanimated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from 'react-native-reanimated';
export default function App() {
const [marginLeft, setMarginLeft] = useState(0);
const animatedValue = useSharedValue(10);
useEffect(() => {
animatedValue.value = withRepeat(
withTiming(200, { duration: 1000, easing: Easing.linear }),
-1,
true
);
}, [animatedValue]);
const animatedStyles = useAnimatedStyle(
() => ({
height: animatedValue.value,
}),
[]
);
return (
<View style={{ alignItems: 'center', flex: 1, justifyContent: 'center' }}>
<View style={{ position: 'absolute', top: 50 }}>
<Button
title="Rerender"
onPress={() => setMarginLeft((marginLeft + 200) % 400)}
/>
</View>
<Reanimated.View
style={[
animatedStyles,
{
backgroundColor: 'blue',
marginLeft: marginLeft - 200,
width: 60,
},
]}
/>
<HeavyRenderer loopCount={2} />
<HeavyRenderer loopCount={10_000} />
<HeavyRenderer loopCount={300_000} />
</View>
);
}
function HeavyRenderer({ loopCount }) {
// Make rendering slow
for (let i = 0; i < loopCount; i++) {
Math.sqrt(i);
}
return (
<View
style={{
backgroundColor: ['red', 'green', 'blue'][loopCount % 3],
height: 50,
width: 50,
}}
/>
);
}
We are checking now how this will perform in more complex real world examples
@hannojg We also have high hopes for this runtimeShadowNodeReferences update. But there are still some edge cases that need to be thought of before we merge any changes. One of the issues is that if we get rid completely of the commit hook, it might happen that reanimated commits (that are executed on every frame), will stop RN from committing its tree (it will happen when a RN commit takes very long time to evaluate e.g. there is a lot of layout calculations to be done). When I changed the approach without the commit hook to correct for that, I had some animations break.
I didn't have much time to dive deep into this, but I will get back to it soon.
Interesting, which animation from the example app was it exactly? Or an example for where its getting starved.
Because maybe with this approach we can still go for the changes outlined here, where we keep the commit hook but remove shadow nodes with the same props:
https://github.com/software-mansion/react-native-reanimated/pull/7601/files#diff-ce26443d92f6f3e9997a3cf59639181c5d657cd1520442c003287640d59b2f3c
The animation that would break was Chessboard Example. It would sometimes display updates from different frames in a single frame. This is probably because the renderer might observe updates to ShadowNodeReferences from multiple frames, even when it is currently rendering.
To see starvation I just added a long for loop in the layout function when the commit is coming from react. Then it would spin for 1024 attempts in the tryCommit function.
I think that checking if the props are equal is a good idea. If we are not able to get rid of the commit hook, we will try this solution.
Thanks @hannojg @bartlomiejbloniarz and @Szymon20000 for your hard work and investigation! I've been struggling with this too. @DorianMazur I've been using this horrible hack to mitigate it wherever it became unbearable:
export function useHybernatingValue<T>(sharedValue: SharedValue<T>): [SharedValue<T> | T, boolean] {
const [hibernatedValue, setHibernatedValue] = useState<T | null>(null);
const [isHibernating, setIsHibernating] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const clearExistingTimeout = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
const startHibernationTimeout = () => {
timeoutRef.current = setTimeout(() => {
setHibernatedValue(sharedValue.get());
setIsHibernating(true);
}, 1000);
};
useAnimatedReaction(
() => sharedValue.get(),
(currentValue, previousValue) => {
if (currentValue !== previousValue) {
// Value changed, wake up from hibernation
runOnJS(setIsHibernating)(false);
// Clear existing timeout
runOnJS(clearExistingTimeout)();
// Set new timeout to hibernate after 1000ms
runOnJS(startHibernationTimeout)();
}
}
);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return isHibernating && hibernatedValue !== null ? [hibernatedValue, true] : [sharedValue, false];
}
You can plug in any shared / derived value into this hook and plug the return value in your style objects. You'll also have to key the React element based on isHybernating because any animated component with shared values in its styles will incur the tax until it unmounts.
const [hybernatingTranslateX, isHibernating] = useHybernatingValue(translateX);
return (
<Animated.View
key={isHibernating ? 'hibernated' : 'not-hibernated'}
style={{
transform: [{ translateX: hybernatingTranslateX }],
}}>
...
</Animated.View>
);
The downside here is that you'll have <react re-render> delay before the component reacts to the animation, but it is fairly unnoticeable in production mode. The upside is we don't have to patch native code.
@YueLiXing https://docs.swmansion.com/react-native-reanimated/docs/guides/feature-flags#disable_commit_pausing_mechanism
@tomekzaw I have tried the feature flag this week with latest expo 54 (preview 12) release and reanimated 4.1.0
The app starts smooth and works fine but after sometime it becomes laggy and sluggish
https://github.com/user-attachments/assets/cfa07700-77d4-43ff-9558-923e051faed4
@mzaien That looks weird. Can you please share some traces from Hermes profiler and Android profiler so we can see what's going on there? Did you use fast refresh before recording this video?
@tomekzaw Is it possible to temporarily avoid this issue by lowering the version
Hello @tomekzaw , do you have any particular way to trace hermes profiler with expo? I would love to help in resolving this issue to migrate to new arch
Hello @tomekzaw facing similar issue , not sure if its react-navigation causing this or reanimated
Hi all, if you're experiencing performance regressions of animations after enabling the New Architecture, please make sure to check out this page we recently added in Reanimated docs: