mrousavy / react-native-style-utilities

Fully typed hooks and utility functions for the React Native StyleSheet API

Home Page:https://gist.github.com/mrousavy/0de7486814c655de8a110df5cef74ddc

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

What is the best way to benchmark?

somebody32 opened this issue · comments

Hi @mrousavy,

In my app, I rely heavily on "inline" styles similar to what Tailwind does for the web but in RN. So I have a lot of arrays like:

<View style={[tw.f[1], tw.bg.red[500]]} />
// which is the same as {flex: 1, backgroundColor: "some hex for the special red color"}

so I got curious about possible perf penalties of that approach and started investigating if wrapping all those arrays in useMemo would be beneficial.

Trying a super-blunt test with rendering 10k views showed, surprisingly that useMemo performs worse in terms of memory while not giving any speed benefit at all, either if you render static styles or dynamic ones (which depends on some prop).

So I'm curious what was your take on that? Did you perform any benchmarks on your own?

Here is the code for the harness, which is pretty basic:

import React, {
  useEffect,
  useState,
  Profiler,
  ProfilerOnRenderCallback,
} from 'react';
import { View, ScrollView } from 'react-native';
import performance from 'react-native-performance';


// test cases, see later
import Test1 from './Test1';
import Test2 from './Test2';

const traceRender: ProfilerOnRenderCallback = (
  id, // the "id" prop of the Profiler tree that has just committed
  phase, // either "mount" (if the tree just mounted) or "update" (if it re-rendered)
  actualDuration, // time spent rendering the committed update
  baseDuration, // estimated time to render the entire subtree without memoization
  startTime, // when React began rendering this update
  commitTime, // when React committed this update
  interactions // the Set of interactions belonging to this update
) =>
  performance.measure(id, {
    start: performance.timeOrigin + startTime,
    duration: actualDuration,
  });

const formatValue = (value: number, unit?: string) => {
  switch (unit) {
    case 'ms':
      return `${value.toFixed(1)}ms`;
    case 'byte':
      return `${(value / 1024 / 1024).toFixed(1)}MB`;
    default:
      return value.toFixed(1);
  }
};

function App() {
  let [flip, setFlip] = useState(true);

  useEffect(() => {
    //let metrics = performance.getEntriesByName('App.render()');
    //let metric = metrics[metrics.length - 1];
    // console.log(formatValue(metric.duration, undefined));

    requestAnimationFrame(() => {
      setFlip((s) => !s);
    });
  }, [flip]);

  return (
    // uncomment to see traces
    //<Profiler id="App.render()" onRender={traceRender}>
    <ScrollView style={{ flex: 1, paddingVertical: 40, paddingHorizontal: 20 }}>
      <View
        style={{
          flex: 1,
          flexDirection: 'row',
          flexWrap: 'wrap',
          padding: 4,
          backgroundColor: flip ? 'green' : 'red',
        }}>
        <Test1 isGreen={!flip} />
      </View>
    </ScrollView>
    //</Profiler>
  );
}

export default App;

Test1.tsx

import React from 'react';
import { View } from 'react-native';

export default ({ isGreen }) => {
  return (
    <>
      <View
        style={{
          height: 4,
          width: 4,
          margin: 4,
          backgroundColor: isGreen ? 'green' : 'red',
        }}
      />
...9999 more views like that

Test2.tsx

import React, { useMemo } from 'react';
import { View } from 'react-native';

export default ({ isGreen }) => {
  return (
    <>
      <View
        style={useMemo(
          () => ({
            height: 4,
            width: 4,
            margin: 4,
            backgroundColor: isGreen ? 'green' : 'red',
          }),
          [isGreen]
        )}
      />
...9999 more views like that

Both tests ran on iPhoneXS and have the same perf metrics (around 4fps with static styles, and 1-2 fps with dynamic, and 50-55 fps on UI)
But non-memoized version uses around 150mb, while memoized 200

Hi @somebody32, nice research!

In your example you flip the value isGreen, which re-builds the entire object in useMemo (since it's dependencies have changed). There is no re-using of the object created in a previous render.

useMemo only avoids re-creation of the object when the dependencies haven't changed, so when you re-render this entire view because another prop has changed, useMemo will be faster. That's the benchmarks I've done, but I can't remember any exact numbers.

Try passing in another prop (dummyProp or something), change that, and check performance - let me know about the numbers! Also let me know about render times (maybe via performance.now()) instead of FPS

I should probably add a note that useMemo has some (very small) memory costs itself.

I think I haven't made it fully clear in the original post, but I've tried both static and dynamic paths: ie: full reuse of the style (just re-render parent) and full re-render of children via isGreen. The results were consistent in both cases, useMemo one was performing either identical or even slightly slower while consuming more memory.

In terms of FPS vs performance.now(): I had both approaches, as you can see in the original code there is a hook to react-native-performance to measure render times. I can pass the original numbers, but they were consistent with the fps: initial render in about 2s, subsequent rerenders of 120-200ms, with useMemo performing 10-15% slower

@somebody32 in order to test the effectiveness of memoization you need to pass another prop that isn't related to the subject of memoization (the useStyle hook in this case) then ensure the hook dependencies stay unchanged and trigger renders by changing the dummy props instead.

Anyway another possible benchmark is to compare:

  • a React.memo'ized component without the useStyle hook
  • same as above but with useStyle
  • non memoized component with useStyle alone