Skip to content

[DRAFT] Prototype proxy-based shallow equality selector perf optimization #2246

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

Open
wants to merge 4 commits into
base: master
Choose a base branch
from

Conversation

markerikson
Copy link
Contributor

Summary

This PR:

  • Copy-pastes the useSyncExternalStoreWithSelector method from the use-sync-external-store package and converts it to TS
  • Updates useSelector to use our inlined version
  • Applies the modifications from perf(hooks): Optimize useSyncExternalStoreWithSelector selector calls facebook/react#32040 , which:
    • Track root-state-level selector field accesses via a Proxy
    • Upon updates, do comparisons of those fields in the old and new state to determine if any of the read fields changed
    • Only re-run the selector if a read field changed

The intent is to improve app-wide selector performance by skipping selectors that are unlikely to be producing a new result.

Background

React and Redux have always been "80%" solutions. They work fine in most cases, but they aren't the most optimized option perf-wise, and it takes work to squeeze out extra perf.

Redux is by definition an O(n) subscription setup. N selected components means N subscriber callbacks and selectors run on every dispatch. This is fine with hundreds of connected components, but as it gets into the thousands, this can become noticeable overhead.

Auto-Tracking Experiment

2 years ago I very briefly played with the idea of trying to Proxy-wrap selectors - first in Reselect, then in React-Redux:

That was a single 4-hour in-flight hacking session. I reused a bunch of "auto-tracking" code from the Ember world. The idea was:

  • keep a single Proxy-wrapped root state copy at the top of the component tree, pass down in the subscription
  • any selectors accessing that force creation of nested auto-tracking for every touched field
  • when a store update happens, we do an immutable-reconciliation thingy with the state copy and mark fields as dirty
  • then by the time we run subscribers, we know if the fields they last read got updated or not, and could smartly bail out of even running the selectors

My thought was that we'd ship some opt-in approach (param for <Provider> or an alternate impl, plus a specific useTrackedSelector hook), to avoid the runtime and bundle overhead unless you wanted it.

The bigger question was whether the cost of doing that tracking + reconciliation would be less than the cost of skipping the subscribers.

My quick hack session got something sorta-kinda working and some tests passing, but also looked like the perf cost of doing that work could be awfully expensive.

More Research

Since then I've kept an eye out for signals libraries that looked like they might be useful (and still have several ideas I want to play with there at some point).

However, I also saw a relevant PR earlier this year:

This directly changed useSyncExternalStoreWithSelector to do shallow root field comparisons, rather than trying to do deep nested tracking.

Based on some conversations I've had with folks who had very complex Redux apps (20K+ connected components), it sounded like just bailing out of irrelevant first-level state changes could be a decent bang for the buck.

That PR got closed, so I figured I'd try porting the changes to React-Redux to try them out.

Status

At the moment, all our existing tests pass. I've run some local checks on the existing https://github.com/reduxjs/react-redux-benchmarks using the new version, and it seems generally equivalent so far. At least it's not worse, but also not sure it's actually helping improve that much.

I'll have to do some more perf testing and get a better sense of whether it actually is helping or not.

Not sure what a final productionized form might look like. I'm not terribly keen on either fully forking uSESWS, or flat-out changing useSelector to use the new behavior all the time. I could imagine it being a new hook instead, so you'd have to explicitly import it and use it intentionally. It's also entirely possible this is just a questionable line of research to start with, and not worth shipping.

Copy link

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

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.

1 participant