react-native-reanimated icon indicating copy to clipboard operation
react-native-reanimated copied to clipboard

Performance on Fabric is getting worse over time

Open Szymon20000 opened this issue 7 months ago • 16 comments

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:

  1. 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.
  2. We can make use of new feature flag on iOS that pushes latest yoga nodes to js.
  3. 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

  1. Render screen with few animated views
  2. 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

Szymon20000 avatar May 07 '25 17:05 Szymon20000

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.

github-actions[bot] avatar May 07 '25 17:05 github-actions[bot]

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?

github-actions[bot] avatar May 07 '25 17:05 github-actions[bot]

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.

  1. 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?

  1. 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.

tomekzaw avatar May 08 '25 07:05 tomekzaw

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.

Szymon20000 avatar May 08 '25 09:05 Szymon20000

@tomekzaw would you like us to open a pr with eviction + state per animated component?

Szymon20000 avatar May 08 '25 15:05 Szymon20000

Sure, let's go ahead, I believe @bartlomiejbloniarz can take a look once it's submitted.

tomekzaw avatar May 09 '25 03:05 tomekzaw

cc @hannojg

Szymon20000 avatar May 09 '25 07:05 Szymon20000

Could you please specify what's the name of this flag? Do you mean setRuntimeShadowNodeReference or 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!

hannojg avatar May 09 '25 08:05 hannojg

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

hannojg avatar May 09 '25 08:05 hannojg

@Szymon20000 do you happen to have the Yarn patch file? Or maybe some more info on how to improve the animations?

DorianMazur avatar May 20 '25 13:05 DorianMazur

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%)

hannojg avatar May 29 '25 11:05 hannojg

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 ReanimatedCommitHook and ReanimatedMountHook
  • 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 avatar May 29 '25 16:05 hannojg

@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.

bartlomiejbloniarz avatar Jun 02 '25 07:06 bartlomiejbloniarz

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

hannojg avatar Jun 02 '25 07:06 hannojg

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.

bartlomiejbloniarz avatar Jun 02 '25 07:06 bartlomiejbloniarz

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.

AndrewPrifer avatar Jun 02 '25 11:06 AndrewPrifer

Image

YueLiXing avatar Sep 05 '25 07:09 YueLiXing

@YueLiXing https://docs.swmansion.com/react-native-reanimated/docs/guides/feature-flags#disable_commit_pausing_mechanism

tomekzaw avatar Sep 05 '25 08:09 tomekzaw

@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 avatar Sep 05 '25 08:09 mzaien

@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 avatar Sep 05 '25 08:09 tomekzaw

@tomekzaw Is it possible to temporarily avoid this issue by lowering the version

YueLiXing avatar Sep 07 '25 03:09 YueLiXing

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

mzaien avatar Sep 10 '25 20:09 mzaien

Hello @tomekzaw facing similar issue , not sure if its react-navigation causing this or reanimated

deepanshushukla avatar Sep 13 '25 17:09 deepanshushukla

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:

tomekzaw avatar Oct 28 '25 11:10 tomekzaw