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

[Performance] JSI Storage implementation #95

Closed
wants to merge 7 commits into from

Conversation

kidroca
Copy link
Contributor

@kidroca kidroca commented Aug 7, 2021

Details

Extract a storage interfacing contract
This allow hooking Onyx to different storage providers like react-native-mmkv
Implement storage providers for AsyncStorage and MMKV
Update native platforms to use MMKV for storage while desktop/web still uses AsyncStorage

Related Issues

$ Expensify/App#4492

Automated Tests

Covered by existing tests. No new functionality added or altered

Trying this locally

Checkout from this hash: kidroca/Expensify.cash@8950ce5
Thanks to the extracted provider interface you can easily switch between storage providers by editing the import here: node_modules/react-native-onyx/lib/storage/index.native.js

  • to use Async storage: import StorageProvider from './providers/AsyncStorageProvider';
  • to use MMKV: import StorageProvider from './providers/MMKV_Provider';

Benchmarks

iOS (Before with AsyncStorage)

Flow init chat switch
Onyx Total 14.14sec 2.81sec
App Render total 1.16sec 0.82sec
App last render at 4.22sec 4.00sec
Onyx:get total 10.18sec 1.43sec
Onyx:get last call at 3.42sec 1.65sec
Onyx:set total 0.94sec 0.16sec
Onyx:set last call at 3.48sec 1.65sec

iOS (After with MMKV)

Flow init chat switch
Onyx Total 6.53sec 2.79sec
App Render total 1.14sec 0.82sec
App last render at 4.29sec 4.84sec
Onyx:get total 3.00sec 1.45sec
Onyx:get last call at 3.18sec 2.50sec
Onyx:set total 0.70sec 0.14sec
Onyx:set last call at 3.26sec 2.51sec

Android (Before with AsyncStorage)

Flow init chat switch
Onyx Total 70.73sec 12.46sec
App Render total 7.02sec 4.56sec
App last render at 20.45sec 10.06sec
Onyx:get total 39.80sec 7.02sec
Onyx:get avg 205.934ms 292.693ms
Onyx:get last call at 15.41sec 3.74sec
Onyx:set total 8.74sec 0.84sec
Onyx:set last call at 15.80sec 3.74sec

Android (After with MMKV)

Flow init chat switch
Onyx Total 50.16sec 13.49sec
App Render total 6.89sec 5.11sec
App last render at 20.24sec 10.97sec
Onyx:get total 32.15sec 7.82sec
Onyx:get avg 166.902ms 325.948ms
Onyx:get last call at 15.54sec 3.80sec
Onyx:set total 3.92sec 0.81sec
Onyx:set last call at 16.01sec 3.82sec

Linked PRs

kidroca added 5 commits August 7, 2021 18:09
Captured existing storage interface from Onyx to separate file
Extracted AsyncStorage usage to provider.js
When we have provider.js and provider.native.js we need
to have the mock implemented at the provider level
instead of the AsyncStorage level
@kidroca kidroca force-pushed the kidroca/jsi-storage branch from 177cf92 to 51ab035 Compare August 7, 2021 21:18
@quinthar
Copy link

quinthar commented Aug 7, 2021

Wow, I can't wait to see this on android!

@kidroca
Copy link
Contributor Author

kidroca commented Aug 7, 2021

Sorry, I've realised that the "After" benchmark was running on a freshly populated DB, while my "Before" benchmark used whatever I previously had
After clearing storage and repopulating with the API responses. Re-running the Before benchmarks there isn't much difference besides total Onyx time (Updated the benchmarks above with the latest data)
The most important metric is "App last render at" this is the time of the last view update that happened
I wan't to note that the app displays content earlier than "last render at" though I don't have a way to capture it in the metrics ATM. It seems with JSI the initial content is visible slightly faster on iOS

I'm still to try this on Android, will post results tomorrow

@kidroca
Copy link
Contributor Author

kidroca commented Aug 7, 2021

We should also document a benchmark process for capturing init times as timings can vary a lot based on content
ATM is not possible to have repeatable benchmark across different people

Initial storage state

  • starting with a clean DB if you don't scroll a chat you'll only fetch and persist about 8 pages of data
  • while if you scroll up you will fill more data to storage as past actions are downloaded
  • then when you init all storage data for the active chat will be read to memory. This is generally a problem (what if you have 1000 pages in local storage)
  • the problem with benchmarking is reading consistent amount of data during init. Reading 8 pages in memory can be much faster than reading 50 pages for the same chat.
  • using 8 pages is easier and more consistent than trying to scroll up to a certain point in history (e.g. 50 pages) or scroll up to the first message and only then start the benchmark

chat content

  • images, plain text, html/md markup, emojis all affect rendering and loading speeds
  • My benchmarks flow ran against 8 pages of reportID 71955477 and can produce very different results on 8 pages of a different report

I think there should be account created for benchmark purposes that everyone (working on perf) can use
The account should be seeded with a chat with variety of messages that can be used for the purpose of initialising on it, or switching to it and capturing time
This will allow repeatable results across different devs

It will also allow to make some benchmark automation or semi-automation - currently I'm spending

  • 20% of time to make multiple runs (10-15) and produce benchmarks
  • 20% of time to translate those to Excel. I extract a file with multiple csv tables, then import these in excel, then create summary tables that select ranges from the raw results
  • 10% analising data
  • 10% optimising the data collection process

This leaves about 40% of my time to come up with ideas/proposals and code
Take for example "all storage data for the active chat will be read to memory" this is something that should be documented in a separate issues I have piled a few such items on my backlog, I'll be opening tickets about them first chance I have

I think the extraction to excel can be coded, we already have the csv data, instead of manually printing it and then copying it to excel it can be send to google sheets using some API. Or the least it can be saved to a .csv file that can be synced somewhere
Then instead of manually triggering data snapshots it can be hooked to happen at a given point past app start for example always the 10th second past init for iOS, or always the 20th second past init for Android
At some point this can even become an End to End regression test - if "last rendered at" time has increased we have a regression

@kidroca
Copy link
Contributor Author

kidroca commented Aug 7, 2021

I think there should be account created for benchmark purposes that everyone (working on perf) can use
The account should be seeded with a chat with variety of messages that can be used for the purpose of initialising on it, or switching to it and capturing time

Or maybe instead of sharing a pre seeded account, there can be a dev command / API call that will seed my account with a sample chat

Copy link
Contributor Author

@kidroca kidroca left a comment

Choose a reason for hiding this comment

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

Added some inline explanations

Comment on lines -2 to +4
import AsyncStorage from '@react-native-async-storage/async-storage';
import Str from 'expensify-common/lib/str';
import lodashMerge from 'lodash/merge';
import Storage from './storage';
Copy link
Contributor Author

@kidroca kidroca Aug 7, 2021

Choose a reason for hiding this comment

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

Instead of using AsyncStorage or MMKV directly, we use the Storage facade

Copy link
Contributor

Choose a reason for hiding this comment

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

Would love to see a simpler version of this PR where we do something like

import AsyncStorage from './AsyncStorage

Then have

index.js - import / export from @react-native-async-storage/async-storage
index.native.js - export a custom AsyncStorage

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think we should use the name AsyncStorage if the underlying storage implementation could be of a different library. A more generic term should be used so it's clear we're not tied to AsyncStorage

Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure it matters for the purposes of the POC - the code will run the same there will just be less of it.

@@ -0,0 +1,3 @@
import * as Storage from '@react-native-async-storage/async-storage/jest/async-storage-mock';
Copy link
Contributor Author

Choose a reason for hiding this comment

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

In tests storage is mocked by by the AsyncStorage's mock
Currently it matches perfectly with our storage interface as the interface is based on the AsyncStorage api

Comment on lines +5 to +9
/**
* @interface
* The interface Onyx uses to communicate with the storage layer
*/
class ProviderInterface {
Copy link
Contributor Author

@kidroca kidroca Aug 7, 2021

Choose a reason for hiding this comment

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

This can serve as an abstract class and even provide some base functionality like parsing/stringifying data

ATM no-one is extending or using the class created here it serves for documentation purposes

  • declaring a new Provider and annotating it with @implements {ProviderInterface} would prompt to implement all methods (Webstorm™)
  • Providers will also inherit all method documentation (and param types)

Copy link
Contributor

Choose a reason for hiding this comment

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

no-one is extending or using the class created here it serves for documentation purposes

Can we maybe explore this when there is a clear need? It feels like an unnecessary distraction for the current exercise.

@kidroca
Copy link
Contributor Author

kidroca commented Aug 9, 2021

I've added full Android benchmarks (in the issue description: #95 (comment)). I've re-run both before and after cases on a clean install app with no existing storage data

My benchmark data doesn't show any improvement for "time to interactive", and there doesn't seem to be any performance improvement while using the app (judging by feel), but anyone can try it out relatively easy and see for themselves

To try this locally:

  1. Checkout from this hash: kidroca/Expensify.cash@8950ce5
  2. Thanks to the extracted provider interface you can easily switch between storage providers by editing the import here: node_modules/react-native-onyx/lib/storage/index.native.js.
    • to use Async storage: import StorageProvider from './providers/AsyncStorageProvider';
    • to use MMKV: import StorageProvider from './providers/MMKV_Provider';

The current code uses this RN implementation https://github.com/mrousavy/react-native-mmkv there is another: https://github.com/ammarahm-ed/react-native-mmkv-storage. But both use MMKV and JSI so I don't think there would be much difference

@kidroca
Copy link
Contributor Author

kidroca commented Aug 9, 2021

I think the data captured here is confirming the performance issues of withOnyx regarding rendering discussed here: Expensify/App#4101 and that storage access is not the main bottleneck at the moment

What JSI can help with regarding rendering is: We can add a getAllSync method to the storage provider interface which would allow us to set initial withOnyx state instantly. This simplifies the connect procedure and would skip some re-renders that happen

  • JSI exposes sync interface - this covers iOS and Android
  • browser local storage exposes sync interface as well - this covers web/desktop

@kidroca
Copy link
Contributor Author

kidroca commented Aug 9, 2021

IMO we should consider merging the changes that allow switching the storage provider even if we delete storage/index.native.js and still use AsyncStorage everywhere

These changes would allows to hook and try different providers at any time

@kidroca kidroca marked this pull request as ready for review August 9, 2021 13:51
@kidroca kidroca requested a review from a team as a code owner August 9, 2021 13:51
@MelvinBot MelvinBot requested review from HorusGoul and removed request for a team August 9, 2021 13:51
@quinthar
Copy link

quinthar commented Aug 9, 2021

Wow, that's a disappointing surprise. I guess this is why we benchmark!

Though I guess the "silver lining" is that the problem is in our code, so it's a lot easier to fix... once we find it.

@marcaaron
Copy link
Contributor

What JSI can help with regarding rendering is: We can add a getAllSync method to the storage provider interface which would allow us to set initial withOnyx state instantly. This simplifies the connect procedure and would skip some re-renders that happen

Random thought, but maybe we don't need the JSI at all to test an imperfect version of this. We can first:

  1. Check to see if all withOnyx() mapped values are cached (meaning we can get them sync)
  2. Set them sync with loading: false and prevent the multiple setState() calls after mount

I would guess there are very few places where we are trying to access values that are not cached and in most cases would be able to get data sync from the cache? In some cases, we would need to read from storage but those feel like outliers.

Started a draft here last week experimenting with this idea but haven't had a chance to properly test it out
https://github.com/Expensify/react-native-onyx/compare/marcaaron-withOnyxConnectSync

IMO we should consider merging the changes that allow switching the storage provider even if we delete storage/index.native.js and still use AsyncStorage everywhere

While the changes are interesting I think we can dig them back up if there is a need for them later.

@marcaaron
Copy link
Contributor

I like the thought about coming up with some sort of standard test or test account (at least) to measure performance. It's a pain, but the only reliable way to test "app startup time" is to run release builds on actual devices. I've gotten into the habit of doing all testing on release builds because dev builds are not particularly optimized and feel very inconsistent.

@quinthar
Copy link

quinthar commented Aug 9, 2021 via email

@kidroca
Copy link
Contributor Author

kidroca commented Aug 9, 2021

Basically, let's do the simplest possible test to start, hopefully to show that tencent knocks the socks off AsyncStorage in an artificial test, and then gradually make the test more and more real to see where it loses its advantage.

I can run this in the stand alone benchmark app that was made to benchmark AsyncStorage times
I have some different sample data available and can run the same tests using the tencent implementation
Best async storage results are

  • 8ms read time for 9kB values (average of 10,000 reads)
  • 16ms write time for 9kB values (average of 10,000reads)

From what I've seen working on the current PR, it's very likely tencent will be bellow 1ms for the same data

@kidroca
Copy link
Contributor Author

kidroca commented Aug 9, 2021

@marcaaron
It's seems we're putting this PR in the bin, should I continue work on the requested changes here?

@marcaaron
Copy link
Contributor

It's seems we're putting this PR in the bin

Wait, let's not give up on it yet! Have we tried an Android release build? I was about to do that.

Maybe just hold off on the changes I'm asking about for now.

@marcaaron
Copy link
Contributor

Ok testing Android build now but doesn't seem to work so far I have...

  1. Follow this comment -> [Performance] JSI Storage implementation #95 (comment)
  2. npm install react-native-mmkv as it's a peer dependency
  3. Do all of these changes -> https://github.com/mrousavy/react-native-mmkv/blob/master/INSTALL.md

App builds OK but crashes immediately. Am I missing something?

@kidroca
Copy link
Contributor Author

kidroca commented Aug 9, 2021

Everything you need is here

Checkout from this hash: kidroca/Expensify.cash@8950ce5

checkout that hash and do npm install then npm run android
Everything is configured.
Otherwise if you're trying to install this yourself there are some bundling issues and the latest version of that library does not work on Android, besides what's written in INSTALL.md there are more steps as due to JSI there is no autolinking - there's an issue about it

That's why the hash I've shared uses "react-native-mmkv": "=1.1.6" that's the latest version that works on Android
The other MMKV library is no better in this aspect. It also needs custom config due to Reanimated and has no autolinking...

@marcaaron
Copy link
Contributor

Got it thanks. I was confused because the link just has the package.json with an updated version of react-native-onyx

@marcaaron
Copy link
Contributor

After setting this up on Android and running a release build I can confirm things don't feel faster in any obvious way. Startup time and chat switching look to be about the same as they are on main.

I think it makes sense to continue to investigate with this strategy:

let's do the simplest possible test to start, hopefully to show that tencent knocks the socks off AsyncStorage in an artificial test, and then gradually make the test more and more real to see where it loses its advantage.

Though, I'm not entirely sure what that process will look like.

@kidroca
Copy link
Contributor Author

kidroca commented Aug 10, 2021

Though, I'm not entirely sure what that process will look like.

This is the only thing that comes to my mind because that's what we did for AsyncStorage: #95 (comment)

I can run this in the stand alone benchmark app that was made to benchmark AsyncStorage times
I have some different sample data available and can run the same tests using the tencent implementation

@marcaaron
Copy link
Contributor

Can we close this out? Or are there any other steps we were looking to take with this investigation?

@kidroca
Copy link
Contributor Author

kidroca commented Dec 22, 2021

We can close this
I was keeping this for the storage provider pattern, but it's already added through the File handling updates

@kidroca kidroca closed this Dec 22, 2021
@marcaaron
Copy link
Contributor

Thanks!

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