-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
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
Cache worklets
#6758
Cache worklets
#6758
Conversation
Let's use name Static Worklets for those. Let's also handle it with |
Just curious — what determines cache-ability? |
@gaearon Immutable closure |
FWIW, I was playing around with these changes in the Rainbow app and was able to enable caching for nearly all worklets, with no visible issues. Could be I’m missing certain cases or problems it creates, but As far as I can tell, it resolves a considerable number of frame drops on both iOS and Android, which without these changes happen consistently. There was a much more pronounced improvement with the full caching. In my testing (limited), 99% of worklets were being cached. We use Reanimated pretty heavily. Gestures in particular were noticeably smoother. |
Thanks for testing and the insights! 🙌 We're thinking about enabling caching for all worklets, but we want to make sure it won't cause any issues. So, we plan to look into it more thoroughly first. |
@christianbaroni You mentioned that you encountered an issue where caching breaks |
Here's the patch I'm using. I haven't observed any issues with this implementation:
|
Thanks for all provided informations! That makes sense now - caching all shareables (including shared values) isn't correct. I've tested it intensively recently, and it seems like we can cache all worklets. This should result in a noticeable performance improvement. |
@piaskowyk Just to be clear, previously they destroyed their closure not after the invocation, but after the given worklet instance was disposed. For instance, a worklet from |
Okay, to clarify further 😅: the closure lives as long as the UI copy of the worklet method exists on the UI runtime. Whenever a worklet needs to be copied from one runtime to another, the closure is recreated. |
Would it be a good idea at this point to patch this into our app (tentatively in prod as well)? I don't have a good mental model about what exactly is being cached here and what makes it safe (and why it wasn't being cached in the past). I.e. what is the exact tradeoff. I still don't fully understand what's implied by a closure being "immutable" earlier (are we talking about bindings or objects they point to? deep or shallow? etc) |
Oh wait, you did add details to the PR description, let me read through those. |
@gaearon I think it would work. I guess you adhere to good practices of React in the Bluesky app so you wouldn't be impacted by slight change of (never defined) behavior. |
The "stateless vs stateful example" in the PR description is very illuminating. I agree this does seem to be borderline undefined behavior. I guess the previous behavior is a little less fragile but the optimization may very well be worth it. Especially considering the compiler linter should catch these cases. |
One thing I noticed experimenting with more aggressive caching is that the vast majority of the values being repeatedly processed were static JS constants defined at the module scope and used in worklets, e.g.: const ITEM_HEIGHT = 40; I was thinking perhaps the babel plugin could identify these, which could then allow caching and sharing them across worklets. Our worklets use significantly more static JS values than dynamic ones (I’d guess 100:1), and the current closure mechanism seems to make no distinction. Is this a correct understanding of the current behavior? When I enabled caching for worklets and all JS values, I saw, I’d say, ~double the performance gain vs. worklet caching alone — I’m thinking due to all static values being cached. |
@christianbaroni In my opinion expanding the Reanimated Babel Plugin is not the way to go. The environments in which the developers build React/React Native can be very different. That brings a lot of issues when we decide to 'assume' something in the Plugin. Common const elements located at module level are cached in The problem is not about the React Runtime at all. All values from the React Runtime that are going to be serialized are serialized only once (which brings its own problems at times). This pull request addresses de-serialization, which used to happen on each worklet instantiation on the UI Runtime. Good thing about the change is that it's reducing differences in behavior between Worklet Runtimes and the React Runtime. While Worklet Runtime are actual instances of runtimes which follow ECMAScript etc., the API we provide to operate on them makes them behave different than you'd expect. This is the course we've been trying to steer towards for a long time and I hope we can stay on it. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you only cache once
Is there an alpha release we can try? |
@gaearon you can try nightly build tomorrow. |
Tried updating to the nightly but the app instacrashes on an Android device on every start in Release mode. In Development it's fine. Not sure if related to this PR.
|
Thanks for the info! I'll check to see if it's related and figure out how to fix it 🫡 |
* Undo perf hackfix * Bump Reanimated to include software-mansion/react-native-reanimated#6758 * Bump to 3.17.0-nightly-20241211-17e89ca24
Confirmed this fixes the perf issue we were seeing |
## Summary Currently, every time we want to execute a shareable worklet, we need to call `toJSValue()` to convert Reanimated's Shareable into a runnable JavaScript function. This operation can be quite expensive for larger methods that have many dependencies in their closure (such as objects and other worklets). Previously, the result of `toJSValue()` wasn't cached, which meant we had to convert the same shareable multiple times, especially on every call to `runOnUI()` and in response to events - potentially on every frame. This happens because the part of the code - `runGuarded` - is called frequently. You can see this code [here](https://github.com/software-mansion/react-native-reanimated/blob/3.17.0-rc.0/packages/react-native-reanimated/Common/cpp/worklets/WorkletRuntime/WorkletRuntime.h#L36). This PR introduces the retention of all worklets and caches the result of `toJSValue()` per runtime.⚠️ This change is potentially risky, and it's challenging to predict if there are any edge cases where caching everything might not be appropriate. However, at this moment, we haven't found any regressions related to memory issues or crashes.⚠️ This PR changes the default behavior of worklets. Previously, worklets were stateless and destroyed their closure after every invocation, but now the closure will persist as long as the worklet lives. #### stateless vs stateful example ```js export default function Example() { let counter = {value: 0}; const workletFunction = () => { 'worklet'; counter.value++; console.log(counter); }; return <Button title="click" onPress={runOnUI(workletFunction)}/>; } ``` **Previous output** ``` {"value": 1} {"value": 1} {"value": 1} ``` **Current output** ``` {"value": 1} {"value": 2} {"value": 3} ``` However, after the render, the worklet and their closure will be created again. #### Issue reproduction example <details> <summary>code</summary> ```js import { Text, StyleSheet, View, Button } from 'react-native'; import React from 'react'; import Animated, { withSpring, useAnimatedScrollHandler, withClamp, withDecay, withDelay, withTiming, useAnimatedRef, scrollTo, runOnUI } from 'react-native-reanimated'; function mleko() { 'worklet'; console.log('mleko'); } export default function EmptyExample() { const aref = useAnimatedRef<Animated.ScrollView>(); const onScroll = useAnimatedScrollHandler({ onBeginDrag: () => { 'worklet' withSpring; withClamp; withDecay; withDelay; withTiming; }, }); return ( <View style={styles.container}> <Button onPress={() => { runOnUI(() => { mleko(); withTiming; console.log('runOnUI'); scrollTo(aref, 0, 100, true); })(); }} title='click' /> <Animated.ScrollView onScroll={onScroll} ref={aref}> {Array.from({ length: 1000 }, (_, i) => ( <Text key={i}>Item_____________ {i}</Text> ))} </Animated.ScrollView> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', marginTop: 100, }, }); ``` </details> #### Memory test example I have tested whether this change leads to memory leaks, and according to my tests, the behavior remains exactly the same as before. Here is my test example: <details> <summary>code</summary> ```js import { Text, StyleSheet, View, Button } from 'react-native'; import {useRef} from 'react'; import Animated, { withSpring, useAnimatedScrollHandler, withClamp, withDecay, withDelay, withTiming, useAnimatedRef, scrollTo, runOnUI } from 'react-native-reanimated'; export default function EmptyExample() { const aref = useAnimatedRef<Animated.ScrollView>(); const onScroll = useAnimatedScrollHandler({ onBeginDrag: () => { 'worklet' withSpring; withClamp; withDecay; withDelay; withTiming; }, }); const ref = useRef(0); function test1() { const obj = {a: 5}; for(let i = 0; i < 10000; i++) { runOnUI(() => { const a = 5 + obj.a; if (a < 5) { console.log('a', a); } })(); } } return ( <View style={styles.container}> <Button onPress={test1} title='test1' /> <Button onPress={() => { global.gc(); runOnUI(() => { global.gc(); })(); }} title='gc' /> <Button onPress={() => { runOnUI(() => { withTiming; scrollTo(aref, 0, 100, true); })(); }} title='scroll' /> <Animated.ScrollView onScroll={onScroll} ref={aref}> {Array.from({ length: 1000 }, (_, i) => ( <Text key={i}>Item_____________ {i}</Text> ))} </Animated.ScrollView> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', marginTop: 100, }, }); ``` </details> To test for memory leaks, you can follow these steps: 1. Add a counter of shareable worklets ```diff +int ShareableWorklet::objCounter = 0; ShareableWorklet::ShareableWorklet(jsi::Runtime &rt, const jsi::Object &worklet) : ShareableObject(rt, worklet) { valueType_ = WorkletType; + objCounter++; } ShareableWorklet::~ShareableWorklet() { + objCounter--; } ``` 2. Add breakpoints to the constructor and destructor. 3. Press the `test1` button a few times. 4. Press the `gc` (garbage collection) button a few times. 5. Check if the counter returns to the value it had at the beginning. 6. Note: The counter should never reach 0 because there are some internal worklets that exist in the global scope and should never be destructed during the lifetime of the React Context. #### Additional tests cases I've also checked for any regressions in our Example app, but everything seems to be functioning normally. <details> <summary>code</summary> ```js import { Text, StyleSheet, View, Button } from 'react-native'; import React, {useEffect, useRef} from 'react'; import Animated, { withSpring, useAnimatedScrollHandler, withClamp, withDecay, withDelay, withTiming, useAnimatedRef, scrollTo, runOnUI, runOnJS, useSharedValue } from 'react-native-reanimated'; function mleko() { 'worklet'; console.log('mleko'); } export default function EmptyExample() { const aref = useAnimatedRef<Animated.ScrollView>(); const onScroll = useAnimatedScrollHandler({ onBeginDrag: () => { 'worklet' withSpring; withClamp; withDecay; withDelay; withTiming; }, }); const ref = useRef(0); const sv = useSharedValue(0); function test1() { sv.value++; function tmp(arg: number) { sv.value++; console.log('tmp', ref.current, sv.value); } const obj = {a: 5}; for(let i = 0; i < 10000; i++) { runOnUI(() => { const a = 5 + obj.a; if (a < 5) { console.log('a', a); } })(); } } const remoteObject = {a: 5}; function test2() { sv.value++; remoteObject.a++; function a({a, b}: {a: number, b: number} = {a: 5, b: 10}) { sv.value++; console.log('a', a, b, ref, remoteObject, sv.value); } function schedule(obj: any) { sv.value++; console.log('schedule', obj, remoteObject, sv.value); runOnUI((tmp) => { sv.value++; console.log('runOnUI', tmp, sv.value); runOnJS(a)(tmp as any); })({a: 3, b: 4}); } runOnUI(() => { sv.value++; runOnJS(schedule)({a: 1, b: 2}); })(); } useEffect(() => { // setInterval(() => { // ref.current++; // console.log('ref', ref.current); // }, 1000); }, []); return ( <View style={styles.container}> <Button onPress={test1} title='test1' /> <Button onPress={test2} title='test2' /> <Button onPress={() => { global.gc(); runOnUI(() => { global.gc(); })(); }} title='gc' /> <Button onPress={() => { runOnUI(() => { mleko(); withTiming; console.log('runOnUI'); scrollTo(aref, 0, 100, true); })(); }} title='scroll' /> <Animated.ScrollView onScroll={onScroll} ref={aref}> {Array.from({ length: 1000 }, (_, i) => ( <Text key={i}>Item_____________ {i}</Text> ))} </Animated.ScrollView> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', marginTop: 100, }, }); ``` </details>
## Summary Currently, every time we want to execute a shareable worklet, we need to call `toJSValue()` to convert Reanimated's Shareable into a runnable JavaScript function. This operation can be quite expensive for larger methods that have many dependencies in their closure (such as objects and other worklets). Previously, the result of `toJSValue()` wasn't cached, which meant we had to convert the same shareable multiple times, especially on every call to `runOnUI()` and in response to events - potentially on every frame. This happens because the part of the code - `runGuarded` - is called frequently. You can see this code [here](https://github.com/software-mansion/react-native-reanimated/blob/3.17.0-rc.0/packages/react-native-reanimated/Common/cpp/worklets/WorkletRuntime/WorkletRuntime.h#L36). This PR introduces the retention of all worklets and caches the result of `toJSValue()` per runtime.⚠️ This change is potentially risky, and it's challenging to predict if there are any edge cases where caching everything might not be appropriate. However, at this moment, we haven't found any regressions related to memory issues or crashes.⚠️ This PR changes the default behavior of worklets. Previously, worklets were stateless and destroyed their closure after every invocation, but now the closure will persist as long as the worklet lives. #### stateless vs stateful example ```js export default function Example() { let counter = {value: 0}; const workletFunction = () => { 'worklet'; counter.value++; console.log(counter); }; return <Button title="click" onPress={runOnUI(workletFunction)}/>; } ``` **Previous output** ``` {"value": 1} {"value": 1} {"value": 1} ``` **Current output** ``` {"value": 1} {"value": 2} {"value": 3} ``` However, after the render, the worklet and their closure will be created again. #### Issue reproduction example <details> <summary>code</summary> ```js import { Text, StyleSheet, View, Button } from 'react-native'; import React from 'react'; import Animated, { withSpring, useAnimatedScrollHandler, withClamp, withDecay, withDelay, withTiming, useAnimatedRef, scrollTo, runOnUI } from 'react-native-reanimated'; function mleko() { 'worklet'; console.log('mleko'); } export default function EmptyExample() { const aref = useAnimatedRef<Animated.ScrollView>(); const onScroll = useAnimatedScrollHandler({ onBeginDrag: () => { 'worklet' withSpring; withClamp; withDecay; withDelay; withTiming; }, }); return ( <View style={styles.container}> <Button onPress={() => { runOnUI(() => { mleko(); withTiming; console.log('runOnUI'); scrollTo(aref, 0, 100, true); })(); }} title='click' /> <Animated.ScrollView onScroll={onScroll} ref={aref}> {Array.from({ length: 1000 }, (_, i) => ( <Text key={i}>Item_____________ {i}</Text> ))} </Animated.ScrollView> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', marginTop: 100, }, }); ``` </details> #### Memory test example I have tested whether this change leads to memory leaks, and according to my tests, the behavior remains exactly the same as before. Here is my test example: <details> <summary>code</summary> ```js import { Text, StyleSheet, View, Button } from 'react-native'; import {useRef} from 'react'; import Animated, { withSpring, useAnimatedScrollHandler, withClamp, withDecay, withDelay, withTiming, useAnimatedRef, scrollTo, runOnUI } from 'react-native-reanimated'; export default function EmptyExample() { const aref = useAnimatedRef<Animated.ScrollView>(); const onScroll = useAnimatedScrollHandler({ onBeginDrag: () => { 'worklet' withSpring; withClamp; withDecay; withDelay; withTiming; }, }); const ref = useRef(0); function test1() { const obj = {a: 5}; for(let i = 0; i < 10000; i++) { runOnUI(() => { const a = 5 + obj.a; if (a < 5) { console.log('a', a); } })(); } } return ( <View style={styles.container}> <Button onPress={test1} title='test1' /> <Button onPress={() => { global.gc(); runOnUI(() => { global.gc(); })(); }} title='gc' /> <Button onPress={() => { runOnUI(() => { withTiming; scrollTo(aref, 0, 100, true); })(); }} title='scroll' /> <Animated.ScrollView onScroll={onScroll} ref={aref}> {Array.from({ length: 1000 }, (_, i) => ( <Text key={i}>Item_____________ {i}</Text> ))} </Animated.ScrollView> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', marginTop: 100, }, }); ``` </details> To test for memory leaks, you can follow these steps: 1. Add a counter of shareable worklets ```diff +int ShareableWorklet::objCounter = 0; ShareableWorklet::ShareableWorklet(jsi::Runtime &rt, const jsi::Object &worklet) : ShareableObject(rt, worklet) { valueType_ = WorkletType; + objCounter++; } ShareableWorklet::~ShareableWorklet() { + objCounter--; } ``` 2. Add breakpoints to the constructor and destructor. 3. Press the `test1` button a few times. 4. Press the `gc` (garbage collection) button a few times. 5. Check if the counter returns to the value it had at the beginning. 6. Note: The counter should never reach 0 because there are some internal worklets that exist in the global scope and should never be destructed during the lifetime of the React Context. #### Additional tests cases I've also checked for any regressions in our Example app, but everything seems to be functioning normally. <details> <summary>code</summary> ```js import { Text, StyleSheet, View, Button } from 'react-native'; import React, {useEffect, useRef} from 'react'; import Animated, { withSpring, useAnimatedScrollHandler, withClamp, withDecay, withDelay, withTiming, useAnimatedRef, scrollTo, runOnUI, runOnJS, useSharedValue } from 'react-native-reanimated'; function mleko() { 'worklet'; console.log('mleko'); } export default function EmptyExample() { const aref = useAnimatedRef<Animated.ScrollView>(); const onScroll = useAnimatedScrollHandler({ onBeginDrag: () => { 'worklet' withSpring; withClamp; withDecay; withDelay; withTiming; }, }); const ref = useRef(0); const sv = useSharedValue(0); function test1() { sv.value++; function tmp(arg: number) { sv.value++; console.log('tmp', ref.current, sv.value); } const obj = {a: 5}; for(let i = 0; i < 10000; i++) { runOnUI(() => { const a = 5 + obj.a; if (a < 5) { console.log('a', a); } })(); } } const remoteObject = {a: 5}; function test2() { sv.value++; remoteObject.a++; function a({a, b}: {a: number, b: number} = {a: 5, b: 10}) { sv.value++; console.log('a', a, b, ref, remoteObject, sv.value); } function schedule(obj: any) { sv.value++; console.log('schedule', obj, remoteObject, sv.value); runOnUI((tmp) => { sv.value++; console.log('runOnUI', tmp, sv.value); runOnJS(a)(tmp as any); })({a: 3, b: 4}); } runOnUI(() => { sv.value++; runOnJS(schedule)({a: 1, b: 2}); })(); } useEffect(() => { // setInterval(() => { // ref.current++; // console.log('ref', ref.current); // }, 1000); }, []); return ( <View style={styles.container}> <Button onPress={test1} title='test1' /> <Button onPress={test2} title='test2' /> <Button onPress={() => { global.gc(); runOnUI(() => { global.gc(); })(); }} title='gc' /> <Button onPress={() => { runOnUI(() => { mleko(); withTiming; console.log('runOnUI'); scrollTo(aref, 0, 100, true); })(); }} title='scroll' /> <Animated.ScrollView onScroll={onScroll} ref={aref}> {Array.from({ length: 1000 }, (_, i) => ( <Text key={i}>Item_____________ {i}</Text> ))} </Animated.ScrollView> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', marginTop: 100, }, }); ``` </details>
## Summary Currently, every time we want to execute a shareable worklet, we need to call `toJSValue()` to convert Reanimated's Shareable into a runnable JavaScript function. This operation can be quite expensive for larger methods that have many dependencies in their closure (such as objects and other worklets). Previously, the result of `toJSValue()` wasn't cached, which meant we had to convert the same shareable multiple times, especially on every call to `runOnUI()` and in response to events - potentially on every frame. This happens because the part of the code - `runGuarded` - is called frequently. You can see this code [here](https://github.com/software-mansion/react-native-reanimated/blob/3.17.0-rc.0/packages/react-native-reanimated/Common/cpp/worklets/WorkletRuntime/WorkletRuntime.h#L36). This PR introduces the retention of all worklets and caches the result of `toJSValue()` per runtime.⚠️ This change is potentially risky, and it's challenging to predict if there are any edge cases where caching everything might not be appropriate. However, at this moment, we haven't found any regressions related to memory issues or crashes.⚠️ This PR changes the default behavior of worklets. Previously, worklets were stateless and destroyed their closure after every invocation, but now the closure will persist as long as the worklet lives. #### stateless vs stateful example ```js export default function Example() { let counter = {value: 0}; const workletFunction = () => { 'worklet'; counter.value++; console.log(counter); }; return <Button title="click" onPress={runOnUI(workletFunction)}/>; } ``` **Previous output** ``` {"value": 1} {"value": 1} {"value": 1} ``` **Current output** ``` {"value": 1} {"value": 2} {"value": 3} ``` However, after the render, the worklet and their closure will be created again. #### Issue reproduction example <details> <summary>code</summary> ```js import { Text, StyleSheet, View, Button } from 'react-native'; import React from 'react'; import Animated, { withSpring, useAnimatedScrollHandler, withClamp, withDecay, withDelay, withTiming, useAnimatedRef, scrollTo, runOnUI } from 'react-native-reanimated'; function mleko() { 'worklet'; console.log('mleko'); } export default function EmptyExample() { const aref = useAnimatedRef<Animated.ScrollView>(); const onScroll = useAnimatedScrollHandler({ onBeginDrag: () => { 'worklet' withSpring; withClamp; withDecay; withDelay; withTiming; }, }); return ( <View style={styles.container}> <Button onPress={() => { runOnUI(() => { mleko(); withTiming; console.log('runOnUI'); scrollTo(aref, 0, 100, true); })(); }} title='click' /> <Animated.ScrollView onScroll={onScroll} ref={aref}> {Array.from({ length: 1000 }, (_, i) => ( <Text key={i}>Item_____________ {i}</Text> ))} </Animated.ScrollView> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', marginTop: 100, }, }); ``` </details> #### Memory test example I have tested whether this change leads to memory leaks, and according to my tests, the behavior remains exactly the same as before. Here is my test example: <details> <summary>code</summary> ```js import { Text, StyleSheet, View, Button } from 'react-native'; import {useRef} from 'react'; import Animated, { withSpring, useAnimatedScrollHandler, withClamp, withDecay, withDelay, withTiming, useAnimatedRef, scrollTo, runOnUI } from 'react-native-reanimated'; export default function EmptyExample() { const aref = useAnimatedRef<Animated.ScrollView>(); const onScroll = useAnimatedScrollHandler({ onBeginDrag: () => { 'worklet' withSpring; withClamp; withDecay; withDelay; withTiming; }, }); const ref = useRef(0); function test1() { const obj = {a: 5}; for(let i = 0; i < 10000; i++) { runOnUI(() => { const a = 5 + obj.a; if (a < 5) { console.log('a', a); } })(); } } return ( <View style={styles.container}> <Button onPress={test1} title='test1' /> <Button onPress={() => { global.gc(); runOnUI(() => { global.gc(); })(); }} title='gc' /> <Button onPress={() => { runOnUI(() => { withTiming; scrollTo(aref, 0, 100, true); })(); }} title='scroll' /> <Animated.ScrollView onScroll={onScroll} ref={aref}> {Array.from({ length: 1000 }, (_, i) => ( <Text key={i}>Item_____________ {i}</Text> ))} </Animated.ScrollView> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', marginTop: 100, }, }); ``` </details> To test for memory leaks, you can follow these steps: 1. Add a counter of shareable worklets ```diff +int ShareableWorklet::objCounter = 0; ShareableWorklet::ShareableWorklet(jsi::Runtime &rt, const jsi::Object &worklet) : ShareableObject(rt, worklet) { valueType_ = WorkletType; + objCounter++; } ShareableWorklet::~ShareableWorklet() { + objCounter--; } ``` 2. Add breakpoints to the constructor and destructor. 3. Press the `test1` button a few times. 4. Press the `gc` (garbage collection) button a few times. 5. Check if the counter returns to the value it had at the beginning. 6. Note: The counter should never reach 0 because there are some internal worklets that exist in the global scope and should never be destructed during the lifetime of the React Context. #### Additional tests cases I've also checked for any regressions in our Example app, but everything seems to be functioning normally. <details> <summary>code</summary> ```js import { Text, StyleSheet, View, Button } from 'react-native'; import React, {useEffect, useRef} from 'react'; import Animated, { withSpring, useAnimatedScrollHandler, withClamp, withDecay, withDelay, withTiming, useAnimatedRef, scrollTo, runOnUI, runOnJS, useSharedValue } from 'react-native-reanimated'; function mleko() { 'worklet'; console.log('mleko'); } export default function EmptyExample() { const aref = useAnimatedRef<Animated.ScrollView>(); const onScroll = useAnimatedScrollHandler({ onBeginDrag: () => { 'worklet' withSpring; withClamp; withDecay; withDelay; withTiming; }, }); const ref = useRef(0); const sv = useSharedValue(0); function test1() { sv.value++; function tmp(arg: number) { sv.value++; console.log('tmp', ref.current, sv.value); } const obj = {a: 5}; for(let i = 0; i < 10000; i++) { runOnUI(() => { const a = 5 + obj.a; if (a < 5) { console.log('a', a); } })(); } } const remoteObject = {a: 5}; function test2() { sv.value++; remoteObject.a++; function a({a, b}: {a: number, b: number} = {a: 5, b: 10}) { sv.value++; console.log('a', a, b, ref, remoteObject, sv.value); } function schedule(obj: any) { sv.value++; console.log('schedule', obj, remoteObject, sv.value); runOnUI((tmp) => { sv.value++; console.log('runOnUI', tmp, sv.value); runOnJS(a)(tmp as any); })({a: 3, b: 4}); } runOnUI(() => { sv.value++; runOnJS(schedule)({a: 1, b: 2}); })(); } useEffect(() => { // setInterval(() => { // ref.current++; // console.log('ref', ref.current); // }, 1000); }, []); return ( <View style={styles.container}> <Button onPress={test1} title='test1' /> <Button onPress={test2} title='test2' /> <Button onPress={() => { global.gc(); runOnUI(() => { global.gc(); })(); }} title='gc' /> <Button onPress={() => { runOnUI(() => { mleko(); withTiming; console.log('runOnUI'); scrollTo(aref, 0, 100, true); })(); }} title='scroll' /> <Animated.ScrollView onScroll={onScroll} ref={aref}> {Array.from({ length: 1000 }, (_, i) => ( <Text key={i}>Item_____________ {i}</Text> ))} </Animated.ScrollView> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', marginTop: 100, }, }); ``` </details>
Summary
Currently, every time we want to execute a shareable worklet, we need to call
toJSValue()
to convert Reanimated's Shareable into a runnable JavaScript function. This operation can be quite expensive for larger methods that have many dependencies in their closure (such as objects and other worklets). Previously, the result oftoJSValue()
wasn't cached, which meant we had to convert the same shareable multiple times, especially on every call torunOnUI()
and in response to events - potentially on every frame.This happens because the part of the code -
runGuarded
- is called frequently. You can see this code here.This PR introduces the retention of all worklets and caches the result of
toJSValue()
per runtime.stateless vs stateful example
Previous output
Current output
However, after the render, the worklet and their closure will be created again.
Issue reproduction example
code
Memory test example
I have tested whether this change leads to memory leaks, and according to my tests, the behavior remains exactly the same as before. Here is my test example:
code
To test for memory leaks, you can follow these steps:
test1
button a few times.gc
(garbage collection) button a few times.Additional tests cases
I've also checked for any regressions in our Example app, but everything seems to be functioning normally.
code