Optimize Shadow Tree cloning#6214
Conversation
|
Thanks for this great library!!! |
|
Hi @ReactorCSA! |
|
@bartlomiejbloniarz I've tested this in my app and it causes a crash on screens that use
|
|
@janicduplessis Thank you for testing this PR 🙏 |
|
@janicduplessis could you test your app again? I changed the implementation so that we don't allocate |
|
The issue wasn't caused by the usage of |
|
A bit unrelated, but looking at this code I can see that you can access the props of a component as 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 |
|
I’ll test it again in the next few days |
|
@mrousavy I think here the props as jsi::Value is new props to update the components with, not the current props of the component. |
|
@mrousavy As @janicduplessis mentioned, the props here are the new props. They come from the reanimated updateProps function |
|
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: 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 You can see that in the first video, Screen.Recording.2024-11-08.at.9.47.34.AM.movYou can see that in the next video, Screen.Recording.2024-11-08.at.9.59.32.AM.movHere 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 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 |
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):  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
## 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.

Summary
This PR optimizes the algorithm used to clone the Shadow Tree used in
ReanimatedCommitHookandperformOperations.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:
ShadowNodeFamily::getAncestorsThis way we unfortunately clone some ShadowNodes multiple times. For example for a batch of size
nwe will clone the root nodentimes.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
getSealedmethod always returnstruein 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:
By calculating the subtree first we ensure that in the second phase:
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
getAncestorson 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 theparentfield inShadowNodeFamilyis private, so traversing the tree upwards is only possible throughgetAncestors). 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.tsxthe results are:I also tested through Xcode Instruments how much time does the
performOperationsfunction 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).Test plan
Check the behavior of examples in the
FabricExampleapp. Verify that heavy examples have improved, while simpler examples have not regressed.