Skip to content

Commit

Permalink
Cache worklets (#6758)
Browse files Browse the repository at this point in the history
## 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>
  • Loading branch information
piaskowyk authored and tjzel committed Dec 13, 2024
1 parent 73a876d commit 196e1e8
Show file tree
Hide file tree
Showing 2 changed files with 8 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ jsi::Value makeShareableClone(
if (value.isObject()) {
auto object = value.asObject(rt);
if (!object.getProperty(rt, "__workletHash").isUndefined()) {
shareable = std::make_shared<ShareableWorklet>(rt, object);
if (shouldRetainRemote.isBool() && shouldRetainRemote.getBool()) {
shareable =
std::make_shared<RetainingShareable<ShareableWorklet>>(rt, object);
} else {
shareable = std::make_shared<ShareableWorklet>(rt, object);
}
} else if (!object.getProperty(rt, "__init").isUndefined()) {
shareable = std::make_shared<ShareableHandle>(rt, object);
} else if (object.isFunction(rt)) {
Expand Down
3 changes: 2 additions & 1 deletion packages/react-native-reanimated/src/shareables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,8 @@ Offending code was: \`${getWorkletCode(value)}\``);
}
const clone = WorkletsModule.makeShareableClone(
clonedProps,
shouldPersistRemote,
// retain all worklets
true,
value
) as ShareableRef<T>;
shareableMappingCache.set(value, clone);
Expand Down

0 comments on commit 196e1e8

Please sign in to comment.