Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(container,lazy,hooks): avoid blocking JS thread by refactoring SharedValue.value access #423

Merged

Conversation

AndreiCalazans
Copy link
Contributor

@AndreiCalazans AndreiCalazans commented Jun 18, 2024

Hey @andreialecu and @gkartalis (tagging bc I can't add reviewers), I was introducing this library to solve a UI pattern and noticed blocking JavaScript thread behavior that caused a regression of over 500ms to our initial screen render.

cpu-profile-of-collapsible-tab-view

The flame graph above shows that the JavaScript thread is blocked by executeOnUIRuntimeSync for quite some time impacting the initial render time of a screen by over 500ms.

On closer analysis I found that the problem is caused by reading a SharedValue.value in the main JS thread. This library does this quite often in places that could avoid it. This PR refactors most of the places where it does not need to be a SharedValue while trying to not cause any regressions.

I understand there isn't unit or e2e tests for this library so I tried my best to test the Example app. I posted multiple recordings below of how it currently works with these changes.

I also wrote a detailed blogpost explaining this Reanimated blocking behavior.

You can also inspect the before and after CPU profilings, I stored them here. The CPU profile will show that before these changes there were 57 calls to executeOnUIRuntimeSync when navigating from the main menu to the FlashList example in the Example app and now there are only 14 calls.

Before (57 calls) After (14 calls)
image image

Demos

One Two
demo-1-reduced.mp4
demo-2-reduced.mp4
Three Four
demo-3-reduced.mp4
demo-4-reduced.mp4
Five Six
demo-5-reduced.mp4
demo-6-reduced.mp4
Seven Eight
demo-7-reduced.mp4
demo-8-reduced.mp4

@AndreiCalazans AndreiCalazans marked this pull request as ready for review June 18, 2024 18:16
Comment on lines -316 to +320
React.useEffect(() => {
if (index.value >= tabNamesArray.length) {
onTabPress(tabNamesArray[tabNamesArray.length - 1])
useAnimatedReaction(
() => tabNamesArray.length,
(tabLength) => {
if (index.value >= tabLength) {
runOnJS(onTabPress)(tabNamesArray[tabLength - 1])
}
}
}, [index.value, onTabPress, tabNamesArray])
)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is essentially the same as was just instead of running inside a useEffect it runs inside a useAnimatedReaction to avoid blocking the JS thread.


const focusedTab: ContextType['focusedTab'] =
useDerivedValue<TabName>(() => {
return tabNames.value[index.value]
}, [tabNames])
const calculateNextOffset = useSharedValue(index.value)
const calculateNextOffset = useSharedValue(initialIndex)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This change is safe to do because the initial value passed to useSharedValue will only be captured once on first pass, this is not reactive.

(event: LayoutChangeEvent) => {
const latestHeight = event.nativeEvent.layout.height
if (latestHeight !== height) {
setHeight(latestHeight)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's safe make a layout value a regular React state because updates to this will not cause an animation change, these values are only used as static values within animation triggers.

Copy link

@nandorojo nandorojo Jun 21, 2024

Choose a reason for hiding this comment

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

Is this check necessary? doesn't react not even re-render if the value is the same?

I believe this is actually an anti-pattern, since now getHeight relies on the height variable, causing it to update its reference based on its parent state when this isn't necessary.

For example, you could just dispatch it with a callback too, right?

setHeight(height => height === latestHeight ? height : latestHeight)

However, that becomes the same as just doing setHeight(latestHeight).

Maybe I'm missing something, but I believe that's the case.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe relevant: facebook/react#28779

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also see:
https://legacy.reactjs.org/docs/hooks-reference.html#bailing-out-of-a-state-update

Bailing out of a state update
If you update a State Hook to the same value as the current state, React will bail out without rendering the children or firing effects. (React uses the Object.is comparison algorithm.)

Note that React may still need to render that specific component again before bailing out. That shouldn’t be a concern because React won’t unnecessarily go “deeper” into the tree. If you’re doing expensive calculations while rendering, you can optimize them with useMemo.

The problem here is that all the code in the component will run again unnecessarily, even though it doesn't go "deeper" or actually render anything. By preventing setting the state, we can optimize against that.

@andreialecu
Copy link
Collaborator

Will look over this tomorrow.

Thanks for contributing!

@andreialecu
Copy link
Collaborator

andreialecu commented Jun 19, 2024

Very interesting, I didn't know about this behavior of Reanimated. Is it documented anywhere?

Could it be a bug in Reanimated, maybe a recent regression? I don't remember running into an issue before or hearing about SharedValue blocking JS threads.

@piaskowyk could you kindly shed some light? See Andrei Calazan's blog post and reddit posts:
https://andrei-calazans.com/posts/reanimated-blocking-js-thread/
https://www.reddit.com/r/reactnative/comments/1diy3pc/reanimated_can_block_the_main_javascript_thread/

I remember introducing SharedValue here specifically to improve performance in the past. For example to be able to do this on the UI thread:
9378af6#diff-739a99e40f851900557fbc98026c870d8ac3c2da65ec6fc29d60c161effaf20dR240-R248

@AndreiCalazans
Copy link
Contributor Author

AndreiCalazans commented Jun 19, 2024

Hey @andreialecu, thanks for reviewing.

I also didn't know this until now, not sure if it's a regression since the behavior kinda of makes sense if you think about it. Any time you need to read a SharedValue.value from within the JavaScript thread it needs to bridge the value synchronously from the HostObject so that can have a non-trivial cost (anywhere from 5 to 20ms depending on device).

I want to highlight that the problem is not using SharedValue but repeatedly needing to read it from within the JS thread. The example diff you shared is a good example of the correct pattern which is to take something from the JS thread and run it in worklet to save CPU cost in the JS thread.

In the blogpost I tried to come up with a mental model of when something should be a SharedValue.

@andreialecu
Copy link
Collaborator

Are you using the new architecture?

With Paper/old architecture, as far as I remember, calling SharedValue.value in the past did not guarantee you'd get the actual last value. It was sometimes out of sync with the value from a worklet. Hence why I needed to create the useConvertAnimatedToValue hook, to try to ensure the value is kept in sync as much as possible.

@AndreiCalazans
Copy link
Contributor Author

@andreialecu I'm not using the new arch nor did I test in it.

@AndreiCalazans
Copy link
Contributor Author

@andreialecu note that my changes avoids this interchange of SharedValue to regular React state and solely relies on React state where possible.

@@ -242,7 +236,7 @@ export const Container = React.memo(
)

useAnimatedReaction(
() => headerHeight.value,
() => headerHeight,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does this actually work right? Can reanimated trigger a reaction based on a react state change?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yea, it does work, I added logs to validate.

@andreialecu
Copy link
Collaborator

@andreialecu note that my changes avoids this interchange of SharedValue to regular React state and solely relies on React state where possible.

I understand.

It's just that this transition to animated values was actually done on purpose, and it greatly improved performance. I'm trying to understand what changed recently for this to no longer be the case.

If accessing shared values from JS was indeed such performance-intensive, I think that it would've been discovered by others in the past. I find it odd that this is the first time I'm hearing about this, and I couldn't find other mentions of this potential problem anywhere else.

Reanimated has regressions some times, so it would be great to understand more about this from an upstream maintainer. Perhaps we can ping one on Twitter/X

@andreialecu
Copy link
Collaborator

What I think would help is if you could create a separate, minimal repro of this SharedValue issue, as a quick benchmark. Then we can perhaps run it on various reanimated versions and see if it's indeed a regression.

@AndreiCalazans
Copy link
Contributor Author

AndreiCalazans commented Jun 19, 2024

@andreialecu I understand the pushback. It will be helpful to hear from @piaskowyk to see if my understanding is correct.

I think the Example app is enough of a minimal repro. I pushed here a simple setup that allows us to run performance tests using Flashlight.

I ran these e2e tests with 10 iterations for main and for this branch. All tests were ran on the same Samsung a23 device (considered low end). These are the results:

Screenshot 2024-06-19 at 13 02 53

At a first glance it seems like nothing has changed and this branch is just slightly better, but take a look at when the FPS starts dipping when we navigate to the FlashList example. On main the dip happens at around 3 seconds for the 10 iterations while on the refactored branch it happens around 2.5 seconds this means this branch is half a second faster to load the components and dispatch the paint to UI natively.

FPS Dip on Main FPS Dip on this branch
Screenshot 2024-06-19 at 13 10 08 Screenshot 2024-06-19 at 13 02 53

Flashlight reports:

To visualize these yourself install Flashlight and run flashlight report fileone filetwo

@andreialecu
Copy link
Collaborator

I released this as 8.0.1-rc.0 to npm for the time being, so that we can all test it more easily.

const shouldStartMounted =
typeof _startMounted === 'boolean'
? _startMounted
: focusedTab.value === name
Copy link
Collaborator

Choose a reason for hiding this comment

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

It seems that reading .value in render is not recommended, according to: https://x.com/kzzzf/status/1805222107011317861

This is not your fault though, the code previously did this too.

@andreialecu
Copy link
Collaborator

Released latest changes as 8.0.1-rc.1

CHANGELOG.md Outdated Show resolved Hide resolved
This is a performance optimization to avoid blocking the JavaScript
thread.

BREAKING CHANGE: headerHeight, tabBarHeight, containerHeight, and
contentInset are no longer SharedValues.

If you consume useHeaderMeasurements and/or useTabsContext expect this
to impact you.
@andreialecu andreialecu merged commit 6e9cbd4 into PedroBern:main Jul 5, 2024
1 check failed
@AndreiCalazans AndreiCalazans deleted the andrei/optimize-tab-view branch July 5, 2024 14:22
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.

3 participants