Skip to content

Optimize Shadow Tree cloning#6214

Merged
bartlomiejbloniarz merged 16 commits intomainfrom
@bartlomiejbloniarz/improve-shadow-tree-cloning
Jul 29, 2024
Merged

Optimize Shadow Tree cloning#6214
bartlomiejbloniarz merged 16 commits intomainfrom
@bartlomiejbloniarz/improve-shadow-tree-cloning

Conversation

@bartlomiejbloniarz
Copy link
Copy Markdown
Contributor

@bartlomiejbloniarz bartlomiejbloniarz commented Jul 4, 2024

Summary

This PR optimizes the algorithm used to clone the Shadow Tree used in ReanimatedCommitHook and performOperations.

Current approach

The current algorithm works as follows. After receiving a batch of updates, we iterate over the list and apply the changes one by one. To apply changes we have to:

  1. calculate the path from the affected ShadowNode to the root using ShadowNodeFamily::getAncestors
  2. traverse the path upwards cloning all the nodes up to the root

This way we unfortunately clone some ShadowNodes multiple times. For example for a batch of size n we will clone the root node n times.

Cloning ShadowNodes is expensive, so we had implemented an optimization - whenever a node is unsealed we would change it in place instead of cloning it. Unfortunately this didn't work, since the getSealed method always returns true in Production mode. This is not a bug, but the intended behavior, as sealing is only intended to help finding bugs in the Debug Mode. This still could be salvaged by memoizing which nodes were already cloned by us, but this approach still wouldn't be perfect, as modyfing nodes in place is still a heavy operation.

New approach

To mitigate those issues we split the process into two phases:

  1. calculate the subtree of the ShadowTree that contains all the nodes that we want to update
  2. traverse the ShadowTree and clone nodes (that belong to the subtree) in the (reversed) topological order

By calculating the subtree first we ensure that in the second phase:

  1. we traverse only nodes that absolutely have to be traversed
  2. we clone only nodes that absolutely have to be cloned
  3. we clone every node at most once

With this approach the second phase is performed in the optimal number of operations.

Limitations

The current implementation of phase one (building the subtree) is not optimal. It is implemented by simply calling getAncestors on every node from the batch. This is fortunately not a huge problem, because cloning had a much heavier impact on the performance. To optimize this there will have to be some changes done in RN (because the parent field in ShadowNodeFamily is private, so traversing the tree upwards is only possible through getAncestors). I hope to soon open a suitable PR.

Some examples

I checked the performance of our heavier examples on some devices in the Release Mode. For the BokehExample.tsx the results are:

Phone Example size Before [FPS] After [FPS]
iPhone 12 mini 200 30-40 60
One+ A6 100 10-20 30-40
iPhone 15 Pro 250 30-40 120
Samsung Galaxy S23 100 55-70 120

I also tested through Xcode Instruments how much time does the performOperations function take on the same example. Tests were conducted on the iPhone simulator, but they should give an idea on the order of the number of operations this function makes (and how fast that number grows in relation to the example size).

Example size Before [ms] After [ms] Before/After
1 1.95 2.1 0.92
20 2.4 2.1 1.14
100 5.3 2.3 2.3
250 22 4 5.5
500 77 7.7 10

Test plan

Check the behavior of examples in the FabricExample app. Verify that heavy examples have improved, while simpler examples have not regressed.

Comment thread packages/react-native-reanimated/Common/cpp/Fabric/ReanimatedCommitHook.cpp Outdated
Comment thread packages/react-native-reanimated/Common/cpp/Fabric/ReanimatedCommitHook.cpp Outdated
Comment thread packages/react-native-reanimated/Common/cpp/Fabric/ReanimatedCommitHook.cpp Outdated
Comment thread packages/react-native-reanimated/Common/cpp/Fabric/ShadowTreeCloner.cpp Outdated
Comment thread packages/react-native-reanimated/Common/cpp/Fabric/ShadowTreeCloner.cpp Outdated
Comment thread packages/react-native-reanimated/Common/cpp/Fabric/ShadowTreeCloner.h Outdated
Comment thread packages/react-native-reanimated/Common/cpp/Fabric/ShadowTreeCloner.cpp Outdated
@tomekzaw tomekzaw requested review from tomekzaw and removed request for tomekzaw July 8, 2024 19:57
@ReactorCSA
Copy link
Copy Markdown

Thanks for this great library!!!
My question may be stupid but anyways, will this fix also improve performance on Paper architecture?
Thanks in advance :)

@bartlomiejbloniarz
Copy link
Copy Markdown
Contributor Author

Hi @ReactorCSA!
This optimization is only for the New Architecture, since Shadow Tree is only a New Architecture concept.

@janicduplessis
Copy link
Copy Markdown
Contributor

@bartlomiejbloniarz I've tested this in my app and it causes a crash on screens that use @gorhom/bottom-sheet. Sorry I don't have an exact repro, but here's the crash report I get.

image

@bartlomiejbloniarz
Copy link
Copy Markdown
Contributor Author

@janicduplessis Thank you for testing this PR 🙏

@bartlomiejbloniarz
Copy link
Copy Markdown
Contributor Author

bartlomiejbloniarz commented Jul 17, 2024

@janicduplessis could you test your app again? I changed the implementation so that we don't allocate RawProps on the heap (sadly @tomekzaw was right 😭). From my tests @gorhom/bottom-sheet doesn't crash anymore, but it would be cool to see if your app is fine now 🙏

@bartlomiejbloniarz
Copy link
Copy Markdown
Contributor Author

The issue wasn't caused by the usage of RawProps on its own. It was actually due to using the same RawProps multiple times. This happened because the ChildMap was populated wrongly (i.e. my skill issue). I decided to remove the abstraction and go back to using RawProps.

Comment thread packages/react-native-reanimated/Common/cpp/Fabric/ShadowTreeCloner.h Outdated
@mrousavy
Copy link
Copy Markdown
Contributor

A bit unrelated, but looking at this code I can see that you can access the props of a component as jsi::Values - how did you get to that point?

I'm trying to build a simple component (iOS/Android) where the props layer is implemented in C++/JSI, and I manually pass down the props to ObjC/Java myself - but I couldn't figure out how to get access to raw props as jsi::Values...

@janicduplessis
Copy link
Copy Markdown
Contributor

I’ll test it again in the next few days

@janicduplessis
Copy link
Copy Markdown
Contributor

@mrousavy I think here the props as jsi::Value is new props to update the components with, not the current props of the component.

@bartlomiejbloniarz
Copy link
Copy Markdown
Contributor Author

@mrousavy As @janicduplessis mentioned, the props here are the new props. They come from the reanimated updateProps function

Copy link
Copy Markdown
Collaborator

@tjzel tjzel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! 🚢 🇮🇹

Comment thread packages/react-native-reanimated/Common/cpp/Fabric/PropsWrapper.h Outdated
@zibs
Copy link
Copy Markdown

zibs commented Nov 8, 2024

Hey @bartlomiejbloniarz!

I just ran a git bisect between 3.14.0 and 3.15.0 where this commit is the one that causes an issue in my app: a4c5c5d39ac27c5d04f825af30f172ab8087844d is the first bad commit.

My issue occurs on bridgeless enabled + fabric

I'm attaching two videos of the issue, that seems to be related to custom scroll bars and Safe Areas, although it doesn't matter which version the react-native-safe-area-context is.

You can see that in the first video, 3.14.0, the custom scroll bar/safe areas work fine.

Screen.Recording.2024-11-08.at.9.47.34.AM.mov

You can see that in the next video, 3.15.0 (a4c5c5d39ac27c5d04f825af30f172ab8087844d is the first bad commit), the custom scroll bar/safe areas doesn't work.

Screen.Recording.2024-11-08.at.9.59.32.AM.mov

Here is the most relevant code:

    const onScrollHandler = useAnimatedScrollHandler(event => {
      if (onScroll) {
        runOnJS(onScroll)({nativeEvent: event});
      }
      let newOffset =
        ((event.contentOffset.y + largeTitleOffsetForScrollIndicator) /
          completeScrollbarHeight) *
        (difference + scrollIndicatorSize);

      if (newOffset < 0) {
        newOffset = 0;
      }

      if (newOffset > difference) {
        newOffset = difference;
      }

      scrollIndicatorOffset.value = newOffset;
    });

    const scrollStyle = useAnimatedStyle(() => {
      return {
        height: scrollIndicatorSize - headerOffsetForScrollIndicator,
        transform: [
          {
            translateY: scrollIndicatorOffset.value,
          },
        ],
      };
    });

And I can say that if I remove the translateY: scrollIndicatorOffset.value,, it "works" in the sense that the safe area doesn't break, but it does break my custom scrolling implementation.

Do you have any thoughts on what might be happening here? It would be very much appreciated, as this is one the last remaining things for me to enable bridgeless/fabric.

I will also say that this example is running "react-native": "0.74.5",, but issue is the same on 75 too.

@bartlomiejbloniarz
Copy link
Copy Markdown
Contributor Author

Hi @zibs. I think that this issue is related to #6659. I need some more time to address it properly.

facebook-github-bot pushed a commit to facebook/react-native that referenced this pull request May 28, 2025
Summary:
This PR adds a new cloning method, allowing for updating multiple nodes in a single transaction. It works in two phases:
1. Find which nodes have to be cloned (i.e. nodes given on input and all their ancestors)
2. Clone nodes in the bottom up order - so that every node is cloned exactly once

So the idea is that when we want to update all the red nodes in this picture, we first find the nodes in the green area and the clone only them in the correct order (children are cloned before parents):
![Screenshot 2025-04-10 at 14 31 14](https://github.com/user-attachments/assets/f397eeff-8279-4d0c-bf87-083bfb44b86a)

Adapting this method [brought a huge performance gain to reanimated](software-mansion/react-native-reanimated#6214). I want to upstream it, so that:
1. we can optimize it further, because making it a part of the `ShadowNode` class gives us access to the parent field in `ShadowNodeFamily` so we can traverse the tree upwards, allowing for a optimal implementation of the first phase (in reanimated we repeatedly call `getAncestors`, which revisits some nodes multiple times)
2. the community can use it

A naive approach that calls `cloneTree` for every node is much slower, as it has to repeat many operations.
## Changelog:

[GENERAL] [ADDED] - Added `cloneMultiple` to `ShadowNode` class.

Pull Request resolved: #50624

Test Plan:
I tested it with the following reanimated implementation and everything works fine:

<details>

```c++
const auto callback =
      [&](const ShadowNode &shadowNode,
          const std::optional<ShadowNode::ListOfShared> &newChildren) {
        return shadowNode.clone(
            {mergeProps(shadowNode, propsMap, shadowNode.getFamily()),
             newChildren
                 ? std::make_shared<ShadowNode::ListOfShared>(*newChildren)
                 : ShadowNodeFragment::childrenPlaceholder(),
             shadowNode.getState()});
      };
  return std::static_pointer_cast<RootShadowNode>(
      oldRootNode.cloneMultiple(families, callback));

```

</details>

I would like to add tests for it, but I'm not sure what's the best approach for that in the repo.

Reviewed By: mdvacca

Differential Revision: D75284060

Pulled By: javache

fbshipit-source-id: 0704c4386c3041eb368adf6950d46de197479058
r0h0gg6 pushed a commit to r0h0gg6/react-native-reanimated that referenced this pull request Jul 28, 2025
## Summary

This PR optimizes the algorithm used to clone the Shadow Tree used in
`ReanimatedCommitHook` and `performOperations`.

## Current approach
The current algorithm works as follows. After receiving a batch of
updates, we iterate over the list and apply the changes one by one. To
apply changes we have to:
1. calculate the path from the affected ShadowNode to the root using
`ShadowNodeFamily::getAncestors`
2. traverse the path upwards cloning all the nodes up to the root

This way we unfortunately clone some ShadowNodes multiple times. For
example for a batch of size `n` we will clone the root node `n` times.

Cloning ShadowNodes is expensive, so we had implemented an optimization
- whenever a node is unsealed we would change it in place instead of
cloning it. Unfortunately this didn't work, since the `getSealed` method
always returns `true` in Production mode. This is not a bug, but the
intended behavior, as sealing is only intended to help finding bugs in
the Debug Mode. This still could be salvaged by memoizing which nodes
were already cloned by us, but this approach still wouldn't be perfect,
as modyfing nodes in place is still a heavy operation.

## New approach
To mitigate those issues we split the process into two phases:
1. calculate the subtree of the ShadowTree that contains all the nodes
that we want to update
2. traverse the ShadowTree and clone nodes (that belong to the subtree)
in the (reversed) topological order

By calculating the subtree first we ensure that in the second phase:
1. we traverse only nodes that absolutely have to be traversed
2. we clone only nodes that absolutely have to be cloned
3. we clone every node at most once

With this approach the second phase is performed in the optimal number
of operations.

## Limitations
The current implementation of phase one (building the subtree) is not
optimal. It is implemented by simply calling `getAncestors` on every
node from the batch. This is fortunately not a huge problem, because
cloning had a much heavier impact on the performance. To optimize this
there will have to be some changes done in RN (because the `parent`
field in `ShadowNodeFamily` is private, so traversing the tree upwards
is only possible through `getAncestors`). I hope to soon open a suitable
PR.

## Some examples
I checked the performance of our heavier examples on some devices in the
Release Mode. For the `BokehExample.tsx` the results are:

| Phone  | Example size | Before [FPS] | After [FPS] | 
| -------- | ------- | -------- | ------- |
| iPhone 12 mini  | 200   | 30-40 | 60 |
| One+ A6 | 100   | 10-20 | 30-40 |
| iPhone 15 Pro  | 250 | 30-40 | 120 |
| Samsung Galaxy S23 | 100 | 55-70 | 120 |

I also tested through Xcode Instruments how much time does the
`performOperations` function take on the same example. Tests were
conducted on the iPhone simulator, but they should give an idea on the
order of the number of operations this function makes (and how fast that
number grows in relation to the example size).

| Example size | Before [ms] | After [ms] | Before/After |
| ------- | -------- | ------- | ------- |
| 1   | 1.95 | 2.1 | 0.92 |
| 20   | 2.4 | 2.1 | 1.14 |
| 100   | 5.3 | 2.3 | 2.3 |
| 250   | 22 | 4 | 5.5 |
| 500 | 77 | 7.7 | 10 |

## Test plan
Check the behavior of examples in the `FabricExample` app. Verify that
heavy examples have improved, while simpler examples have not regressed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants