Skip to content

Prevent updating values in cache when they do not change#191

Merged
marcaaron merged 13 commits intomainfrom
marcaaron-preventUnnecessaryChanges
Oct 11, 2022
Merged

Prevent updating values in cache when they do not change#191
marcaaron merged 13 commits intomainfrom
marcaaron-preventUnnecessaryChanges

Conversation

@marcaaron
Copy link
Contributor

@marcaaron marcaaron commented Oct 5, 2022

cc @iwiznia @tgolen

Details

  • There are many cases where we are updating values in the cache that have not changed
  • We are also triggering updates to components when the values have not changed
  • That leads to unnecessary re-renders and writing to storage data that is basically already there
  • Yeah, we are using isEqual() whenever writing data - which seems like it would be "inefficient", but the savings from avoiding re-renders appear much greater than any performance hit caused by the value equality check.

Related Issues

https://github.com/Expensify/Expensify/issues/230464

Automated Tests

Added some automated tests to show that we will prevent updates when the values don't change.

Linked PRs

TODO, but the changes can be tested against main

Benchmarks

While timing a switch_report event on Android these are some before and after readings

Screenshots

Android

Screen Shot 2022-10-05 at 11 59 03 AM

Before

DEBUG Timing:expensify.cash.switch_report.cold 6078
DEBUG Timing:expensify.cash.switch_report.cold 6699
DEBUG Timing:expensify.cash.switch_report.cold 7158
DEBUG Timing:expensify.cash.switch_report.cold 7359
DEBUG Timing:expensify.cash.switch_report.cold 7606

After

DEBUG Timing:expensify.cash.switch_report.cold 2850
DEBUG Timing:expensify.cash.switch_report.cold 2872
DEBUG Timing:expensify.cash.switch_report.cold 3051
DEBUG Timing:expensify.cash.switch_report.cold 3218
DEBUG Timing:expensify.cash.switch_report.cold 3372

iOS

Screen Shot 2022-10-05 at 12 15 40 PM

Before

DEBUG Timing:expensify.cash.switch_report.cold 1708
DEBUG Timing:expensify.cash.switch_report.cold 1724
DEBUG Timing:expensify.cash.switch_report.cold 1739
DEBUG Timing:expensify.cash.switch_report.cold 1631
DEBUG Timing:expensify.cash.switch_report.cold 1656

After

DEBUG Timing:expensify.cash.switch_report.cold 1176
DEBUG Timing:expensify.cash.switch_report.cold 1134
DEBUG Timing:expensify.cash.switch_report.cold 1098
DEBUG Timing:expensify.cash.switch_report.cold 1135
DEBUG Timing:expensify.cash.switch_report.cold 1092

Web

Screen Shot 2022-10-05 at 12 46 45 PM

Before

Timing:expensify.cash.switch_report.cold 297
Timing:expensify.cash.switch_report.cold 276
Timing:expensify.cash.switch_report.cold 281
Timing:expensify.cash.switch_report.cold 278
Timing:expensify.cash.switch_report.cold 284

After

Timing:expensify.cash.switch_report.cold 186
Timing:expensify.cash.switch_report.cold 188
Timing:expensify.cash.switch_report.cold 186
Timing:expensify.cash.switch_report.cold 180
Timing:expensify.cash.switch_report.cold 185

@marcaaron marcaaron self-assigned this Oct 5, 2022
for (let i = stateMappingKeys.length; i--;) {
const subscriber = callbackToStateMapping[stateMappingKeys[i]];
if (!subscriber || !isKeyMatch(subscriber.key, key)) {
if (!subscriber || !isKeyMatch(subscriber.key, key) || (_.isFunction(canUpdateSubscriber) && !canUpdateSubscriber(subscriber))) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

NAB: Is this new logic the same thing as using _.result(subscriber, 'canUpdateSubscriber')? https://underscorejs.org/#result

Maybe not... I think I am thinking of this: https://github.com/Expensify/expensify-common/blob/main/lib/Func.jsx#L13-L19 which would be cool if we could just import it without any additional work :D I don't think that's possible though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

_.result() takes a property name as it's second arg so it's a little different

What you are asking sounds similar to Str.result() and I think would work if we did:

Str.result(canUpdateSubscriber, subscriber)

and would return undefined if the first argument was undefined

// If the value in the cache is the same as what we have then do not update subscribers unless they
// have initWithStoredValues: false then they MUST get all updates even if nothing has changed.
if (!cache.hasValueChanged(key, value)) {
notifySubscribersOnNextTick(key, value, subscriber => subscriber.initWithStoredValues === false);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
notifySubscribersOnNextTick(key, value, subscriber => subscriber.initWithStoredValues === false);
notifySubscribersOnNextTick(key, value, !subscriber.initWithStoredValues);

Why does the third argument need to be a function? Can it be a simple boolean instead?

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 doesn't have to be a function. I had it as a simple boolean, but feel the function is more intuitive and also puts the context for why we are doing what we are doing where we are doing it. Check this commit to get a feel for what I mean:

8c7b7d9

lib/OnyxCache.js Outdated
*/
hasValueChanged(key, value) {
this.addToAccessedKeys(key);
return !_.isEqual(this.storageMap[key], value);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Did you by any chance explore other deep equal solutions? If we're doing this for performance reasons, then maybe we should try to find the fastest one?

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 explored this one dequal and it does seem faster. But decided to use _.isEqual() since we already have the underscore dependency.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So I went and benchmarked a few of these today and tried to compare what I think are our biggest data types here's what I came back with:

Comparing two sets of very large personalDetails with one user displayName changed

_.isEqual() x 1,248 ops/sec ±0.43% (96 runs sampled)
dequal() x 277 ops/sec ±2.30% (86 runs sampled)
deepEqual() x 1,231 ops/sec ±0.65% (93 runs sampled)
fastDeepEqual() x 1,130 ops/sec ±0.84% (93 runs sampled)
microDiff() x 214 ops/sec ±1.58% (87 runs sampled)
Fastest is _.isEqual()

Comparing two report objects one with a different lastVisitedTimestamp

_.isEqual() x 1,857,706 ops/sec ±0.17% (92 runs sampled)
dequal() x 2,556,428 ops/sec ±0.35% (93 runs sampled)
deepEqual() x 2,457,190 ops/sec ±0.33% (96 runs sampled)
fastDeepEqual() x 1,768,857 ops/sec ±0.09% (99 runs sampled)
microDiff() x 795,347 ops/sec ±0.19% (96 runs sampled)
Fastest is dequal()

Comparing two reportActions objects each with 250 comments and one with an additional comment

_.isEqual() x 258,482 ops/sec ±0.39% (95 runs sampled)
dequal() x 3,394 ops/sec ±0.47% (97 runs sampled)
deepEqual() x 260,473 ops/sec ±0.98% (94 runs sampled)
fastDeepEqual() x 268,251 ops/sec ±0.33% (96 runs sampled)
microDiff() x 1,828 ops/sec ±0.41% (99 runs sampled)
Fastest is fastDeepEqual()

So going off of these numbers it looks to me like:

  • dequal is trailing hard for very large objects, but best at small stuff
  • _.isEqual seems pretty comparable to other libs at the large stuff

Based on that I think it's not really worth using dequal or any of the others. I'd be more concerned about performance with these really large objects and underscore isn't too far behind the competition.

Copy link
Contributor

Choose a reason for hiding this comment

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

deepEqual looks like the best option here, no?
Given this is super low level code that will get called everywhere, I think is worth to add a new dependency for the sake of performance

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Cool I'm good with that. I wasn't sure if there was enough of a meaningful difference between that and _.isEquals()

})
.then(() => {
// WHEN merge is called again with an object of equivalent value but not the same reference
Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_POLICY}${1}`, _.clone(collectionUpdate.test_policy_1));
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this makes sense, that a different reference wouldn't trigger the callback. I've seen so many bugs in the past where code thinks a variable is one reference, but it ends up being another, and there is stale data... It makes me leary, but until it's an actual problem, I think it's OK.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Mainly wanted to show here that whether it's a reference or not makes no difference since we are doing a value comparison and not reference comparison. In the previous test, we pass a reference and in the new test we pass a new object. Both have the same value so both should not trigger any data to be set or updates to occur.

I've seen so many bugs in the past where code thinks a variable is one reference, but it ends up being another, and there is stale data... It makes me leary, but until it's an actual problem, I think it's OK.

I don't think we have any problems like this or I have not observed any at least. I am pretty confident we are always creating new object references before saving them to the cache (at least when using the Onyx.merge() method)

e.g.

const newData = Object.assign({}, fastMerge(modifiedData, mergeValue));

@marcaaron
Copy link
Contributor Author

Tested on iOS, Android, and Web and updated the switch_report metrics in the description. Going to take this out of draft.

@marcaaron marcaaron marked this pull request as ready for review October 5, 2022 22:49
@marcaaron marcaaron requested a review from a team as a code owner October 5, 2022 22:49
@marcaaron marcaaron requested a review from tgolen October 5, 2022 22:49
@melvin-bot melvin-bot bot requested review from PauloGasparSv and removed request for a team October 5, 2022 22:49
lib/OnyxCache.js Outdated
*/
hasValueChanged(key, value) {
this.addToAccessedKeys(key);
return !_.isEqual(this.storageMap[key], value);
Copy link
Contributor

Choose a reason for hiding this comment

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

deepEqual looks like the best option here, no?
Given this is super low level code that will get called everywhere, I think is worth to add a new dependency for the sake of performance

@marcaaron
Copy link
Contributor Author

Updated and using the fast-equals package instead of underscore. Going to have to re-test to make sure everything is still working after that (but I think it should be pretty much the same).

@marcaaron
Copy link
Contributor Author

re-tested and ready for another review whenever 🙇

@tgolen
Copy link
Collaborator

tgolen commented Oct 10, 2022

@PauloGasparSv @iwiznia all yours

@marcaaron
Copy link
Contributor Author

Looks like @iwiznia is ooo but does not have any unresolved comments so going to pull the merge trigger on this one.

@marcaaron marcaaron merged commit 4efd75c into main Oct 11, 2022
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.

5 participants