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

fix selectorGetter performance issue #1515

Closed
wants to merge 6 commits into from

Conversation

thomaszdxsn
Copy link
Contributor

  • motivation: the getter is call setDepsInStore every time, harm the performance when multiple call the getter
  • notes: this pr just a demo, I don't known is this feasible. please give me a confirm, and I will make some further change with testcases

#914 Please refer this issue for detail

@facebook-github-bot
Copy link
Contributor

Hi @thomaszdxsn!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@fb.com. Thanks!

@thomaszdxsn
Copy link
Contributor Author

By the way, this change is fix my performance issue. But I don't know is it harm other issues?

@thomaszdxsn
Copy link
Contributor Author

@drarmstr give a review, please

@drarmstr
Copy link
Contributor

drarmstr commented Jan 3, 2022

@drarmstr give a review, please

Hi @thomaszdxsn ! Thank you so much for the contribution! I'm still on a bit of a holiday schedule so I'll try to review later this week. We do really appreciate your effort.

@@ -700,7 +700,7 @@ function selector<T>(
): void {
deps.add(newDepKey);

setDepsInStore(store, state, deps, executionId);
// setDepsInStore(store, state, deps, executionId);
Copy link
Contributor

Choose a reason for hiding this comment

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

With this simplification we can just get rid of setNewDepInStore() and call deps.add() directly in getRecoilValue().

@@ -782,6 +782,7 @@ function selector<T>(

try {
result = get({get: getRecoilValue, getCallback});
setDepsInStore(store, state, deps, executionId)
Copy link
Contributor

Choose a reason for hiding this comment

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

Best if this is done outside of this try/catch block. We could just call it before the return, which references depValues, so it might be a nice logical place for it.

@drarmstr drarmstr linked an issue Jan 3, 2022 that may be closed by this pull request
@drarmstr
Copy link
Contributor

drarmstr commented Jan 3, 2022

Ok, I took a look; thanks again @thomaszdxsn ! I've actually thought an optimization like this makes sense, but we need to be careful with asynchronous cases. If a selector has pending dependencies, or is async itself, then it needs to update it's dependencies before relinquishing control; from a glance this may be done as there are calls to setDepsInStore() in the various promise wrapping logic. The thing would be to have tests that confirm that a selector will properly be triggered to re-evaluate due to a dependency change if it is in the pending state either due to a pending dependency or due to itself still pending resolving its promise. So, I think this is safe, but let's test it!

@csantos42 do you have thoughts?

@thomaszdxsn
Copy link
Contributor Author

Ok, I will dive into internals

@drarmstr
Copy link
Contributor

Hi @thomaszdxsn, have you had a chance to dive in further?

@thomaszdxsn
Copy link
Contributor Author

Sorry, I am busy until 2022-01-26. after 01-25 I will give some further progress

@drarmstr
Copy link
Contributor

@thomaszdxsn - In the meantime, #1568 and #1566 do some unrelated cleanup of the selector implementation. Hopefully it should help make it a bit more readable.

@thomaszdxsn
Copy link
Contributor Author

@thomaszdxsn - In the meantime, #1568 and #1566 do some unrelated cleanup of the selector implementation. Hopefully it should help make it a bit more readable.

got it, thanks

@facebook-github-bot facebook-github-bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jan 26, 2022
@facebook-github-bot
Copy link
Contributor

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@thomaszdxsn thomaszdxsn reopened this Jan 26, 2022
@thomaszdxsn
Copy link
Contributor Author

thomaszdxsn commented Jan 26, 2022

Sorry, make a mess so pr is closed.

The codebase is overwhelming for me. please give some mentor

from a glance this may be done as there are calls to setDepsInStore() in the various promise wrapping logic. The thing would be to have tests that confirm that a selector will properly be triggered to re-evaluate due to a dependency change if it is in the pending state either due to a pending dependency or due to itself still pending resolving its promise. So, I think this is safe, but let's test it!

  • Do I need add code for "various promise wrapping log"?
  • Do I need add more testcase? I do test already, and modify some resolvingSel with refresher, it make selector re-evaluate.
  • Or I need add a new testcase, let the selector's deps change to variadic in runtime? like [a, b] -> [a, c, d]

@drarmstr
Copy link
Contributor

drarmstr commented Jan 26, 2022

Sorry, make a mess so pr is closed.

The codebase is overwhelming for me. please give some mentor

from a glance this may be done as there are calls to setDepsInStore() in the various promise wrapping logic. The thing would be to have tests that confirm that a selector will properly be triggered to re-evaluate due to a dependency change if it is in the pending state either due to a pending dependency or due to itself still pending resolving its promise. So, I think this is safe, but let's test it!

  • Do I need add code for "various promise wrapping log"?
  • Do I need add more testcase? I do test already, and modify some resolvingSel with refresher, it make selector re-evaluate.
  • Or I need add a new testcase, let the selector's deps change to variadic in runtime? like [a, b] -> [a, c, d]

Yeah, I'm hoping that no other promise wrapping logic changes are needed here. But we should add more unit testcases in Recoil_selector-test.js or Recoil_selectorHooks-test.js. At least something like:

Selector await's on some Promise, which resolves, then get()'s a new dependency of some atom, then waits again on either another async dependency or Promise (test both). Either way, the selector should still be in a pending state. Then, we change the value of the atom and confirm the selector re-evaluates with the new value of the atom. Based on the new value of the atom the selector can then just resolve and provide a value. The key is that the dynamic dependency discovered after the await was recorded and changes to it cause the selector to re-evaluate.

Also, we started stubbing some performance tests in Recoil_perf-test.js.

@thomaszdxsn
Copy link
Contributor Author

Sorry I discover the new merged code now, Let me merge it

@facebook-github-bot
Copy link
Contributor

@drarmstr has imported this pull request. If you are a Meta employee, you can view this diff on Phabricator.

@drarmstr
Copy link
Contributor

drarmstr commented Feb 7, 2022

@thomaszdxsn - Thanks for the updates so far. I worked closer with your test case and updated it to cover some more cases.

  testRecoil('Dynamic deps will refresh', async () => {
    const myAtom = atom({
      key: 'selector dynamic deps atom',
      default: 'DEFAULT',
    });
    const myAtomA = atom({
      key: 'selector dynamic deps atom A',
      default: new Promise(() => {}),
    });
    const myAtomB = atom({
      key: 'selector dynamic deps atom B',
      default: 'B',
    });
    const myAtomC = atom({
      key: 'selector dynamic deps atom C',
      default: new Promise(() => {}),
    });

    let selectorEvaluations = 0;
    const mySelector = selector({
      key: 'selector dynamic deps selector',
      get: async ({get}) => {
        selectorEvaluations++;
        await Promise.resolve();
        const sw = get(myAtom);
        if (sw === 'A') {
          return 'RESOLVED_' + get(myAtomA);
        }
        if (sw === 'B') {
          return 'RESOLVED_' + get(myAtomB);
        }
        if (sw === 'C') {
          return 'RESOLVED_' + get(myAtomC);
        }
        await new Promise(() => {});
      },
    });

    const wrapperSelector = selector({
      key: 'selector dynamic deps wrapper',
      get: ({get}) => {
        const loadable = get(noWait(mySelector));
        return loadable.state === 'loading' ? 'loading' : loadable.contents;
      },
    });

    let setAtom, setAtomA, setAtomB;
    function ComponentSetter() {
      setAtom = useSetRecoilState(myAtom);
      setAtomA = useSetRecoilState(myAtomA);
      setAtomB = useSetRecoilState(myAtomB);
    }
    const c = renderElements(
      <>
        <ComponentSetter />
        <ReadsAtom atom={wrapperSelector} />
      </>,
    );
    await flushPromisesAndTimers();
    expect(c.textContent).toBe('"loading"');
    expect(selectorEvaluations).toBe(1);

    // Cause re-evaluation to pending state
    act(() => setAtom('TMP'));
    await flushPromisesAndTimers();
    expect(c.textContent).toBe('"loading"');
    expect(selectorEvaluations).toBe(2);

    // Add atomA dependency, which is pending
    act(() => setAtom('A'));
    await flushPromisesAndTimers();
    expect(c.textContent).toBe('"loading"');
    expect(selectorEvaluations).toBe(3);

    // change to atomB dependency
    act(() => setAtom('B'));
    await flushPromisesAndTimers();
    await flushPromisesAndTimers();
    expect(c.textContent).toBe('"RESOLVED_B"');
    expect(selectorEvaluations).toBe(4);

    // Set atomB
    act(() => setAtomB('SETB-0'));
    await flushPromisesAndTimers();
    expect(c.textContent).toBe('"RESOLVED_SETB-0"');
    expect(selectorEvaluations).toBe(5);

    // Change back to atomA dependency
    act(() => setAtom('A'));
    await flushPromisesAndTimers();
    expect(c.textContent).toBe('"loading"');
    expect(selectorEvaluations).toBe(6);

    // Setting B is currently ignored
    act(() => setAtomB('SETB-IGNORE'));
    await flushPromisesAndTimers();
    expect(c.textContent).toBe('"loading"');
    expect(selectorEvaluations).toBe(6);

    // Set atomA
    act(() => setAtomA('SETA'));
    await flushPromisesAndTimers();
    await flushPromisesAndTimers();
    expect(c.textContent).toBe('"RESOLVED_SETA"');
    expect(selectorEvaluations).toBe(7);

    // Setting atomB is ignored
    act(() => setAtomB('SETB-LATER'));
    await flushPromisesAndTimers();
    expect(c.textContent).toBe('"RESOLVED_SETA"');
    expect(selectorEvaluations).toBe(7);

    // Change to atomC, which is pending
    act(() => setAtom('C'));
    await flushPromisesAndTimers();
    expect(c.textContent).toBe('"loading"');
    expect(selectorEvaluations).toBe(8);

    // Setting atomA is ignored
    act(() => setAtomA('SETA-LATER'));
    await flushPromisesAndTimers();
    expect(c.textContent).toBe('"loading"');
    expect(selectorEvaluations).toBe(8);

    // change back to atomA for new value
    act(() => setAtom('A'));
    await flushPromisesAndTimers();
    await flushPromisesAndTimers();
    expect(c.textContent).toBe('"RESOLVED_SETA-LATER"');
    expect(selectorEvaluations).toBe(9);

    // Change back to atomB
    act(() => setAtom('B'));
    await flushPromisesAndTimers();
    expect(c.textContent).toBe('"RESOLVED_SETB-LATER"');
    expect(selectorEvaluations).toBe(10);

    // Set atomB
    act(() => setAtomB('SETB-1'));
    await flushPromisesAndTimers();
    expect(c.textContent).toBe('"RESOLVED_SETB-1"');
    expect(selectorEvaluations).toBe(11);
  });

You'll notice this also adds a wrapper selector using noWait(). It turns out that using a snapshot or just the selector directly causes the component using it to suspend and thus re-evaluate on wake even if the dependencies aren't sufficient. Thee wrapper helps avoid this and reveals this approach isn't working. Restarting the evaluation is reseting the deps list and the partial evaluation promises are never resolving so they don't have a chance to set them either. So, I don't think we can avoid updating deps when calling getRecoilValue()...

But, I still think there is hope here if we can just optimize saveDependencyMapToStore(). For example, maybe updating mergeDependencyMapIntoGraph() to only go through the expensive update if thee graph's dependencies don't already match what it is trying to set it to. So, perhaps it could first check if the work is already done and then abort. What do you think?

@thomaszdxsn
Copy link
Contributor Author

Involve async things make it very complicate. actually I don't use any async in recoil(I have a little async logic, but write as redux-thunk style. etc async then update atom).So I don't have use many API in Recoil like noWait.

I known what you meaning but don't know how to optimize saveDependencyMapToStore

@thomaszdxsn
Copy link
Contributor Author

My scenario is:

  1. A atomFamily representation a linked list nodes
  2. UI as a Tree
  3. Every atom has a collapse/expand field
  4. A Selector calculate display nodes based on collapse and children
  5. Each time user click the collapse button, the selector is reevaluate.

When display nodes show more than 900+, the response time is 500ms+. Because every get trigger mergeDependencyMapIntoGraph() is very expensive

@thomaszdxsn
Copy link
Contributor Author

thomaszdxsn commented Feb 7, 2022

For example, maybe updating mergeDependencyMapIntoGraph() to only go through the expensive update if thee graph's dependencies don't already match what it is trying to set it to. So, perhaps it could first check if the work is already done and then abort...

@drarmstr What you mean if thee graph's dependencies don't already match what it is trying to set it to? does this API can call only once?

@drarmstr
Copy link
Contributor

drarmstr commented Feb 8, 2022

Yeah, even though many have simple use cases we still need to be careful to make sure all the corner cases work for everyone.

The possible optimization is perhaps just checking if the graph needs to be updated before updating it. Thinking is that the updating process, with taking multiple set differences, is more expensive than just first checking if they are already equivalent up-front..

@thomaszdxsn
Copy link
Contributor Author

thomaszdxsn commented Feb 8, 2022

@drarmstr Sorry but what is timing for dependencies don't already match? for my view the deps don't match after every get() called

@thomaszdxsn thomaszdxsn closed this Feb 8, 2022
@thomaszdxsn thomaszdxsn reopened this Feb 8, 2022
@drarmstr
Copy link
Contributor

@drarmstr Sorry but what is timing for dependencies don't already match? for my view the deps don't match after every get() called

Ah, I see, you're trying to cover the case of looping over new dependencies, not just existing dependencies. Yeah, then that idea won't be sufficient either.

Hmm, I guess it's worth diving into the actual graph update algorithm in Recoil_Graph.js, then, as that could likely be heavily optimized...

@drarmstr
Copy link
Contributor

Add better unit test for dynamic dependencies in #1598

@drarmstr
Copy link
Contributor

drarmstr commented Mar 4, 2022

@thomaszdxsn - An idea to optimize the common case, while still supporting all cases, might be to lazily update all of the dependencies that are synchronously requested by the selector from the initial execution of the get() callback, then handle the remaining async dependencies on-demand as now. Hopefully that should help cover your situation and many others while still protecting correctness.

@thomaszdxsn
Copy link
Contributor Author

An idea to optimize the common case, while still supporting all cases, might be to lazily update all of the dependencies that are synchronously requested by the selector from the initial execution of the get() callback, then handle the remaining async dependencies on-demand as now. Hopefully that should help cover your situation and many others while still protecting correctness.

Can you give some pseudo code?

@drarmstr
Copy link
Contributor

drarmstr commented Mar 4, 2022

An idea to optimize the common case, while still supporting all cases, might be to lazily update all of the dependencies that are synchronously requested by the selector from the initial execution of the get() callback, then handle the remaining async dependencies on-demand as now. Hopefully that should help cover your situation and many others while still protecting correctness.

Can you give some pseudo code?

It might be as easy:

  • Set a boolean as true at the top of evaluateSelectorGetter()
  • Call setDepsInStore() at the end of evaluateSelectorGetter(), as you added and set that boolean as false
  • Then, change getRecoilValue() to conditionally call setDepsInStore() based on the boolean being false.

@drarmstr
Copy link
Contributor

drarmstr commented Mar 8, 2022

@thomaszdxsn - Ok, I've got it implemented and it seems to work really well. You don't have to worry about updating the PR, I can take care of merging it. Thanks for all of your help here, this will be a great improvement!

(Also note #1651)

drarmstr pushed a commit to drarmstr/Recoil that referenced this pull request Mar 8, 2022
Summary:
The current algorithm for updating selector dependencies in the data flow graph is expensive.  Currently we update the graph every time a new dependency is added, which scales very poorly.  This optimization only updates the graph with all sycnhronous dependencies after the synchronous selector evaluation completes.

We still have to update the graph after every asynchronous dependency is added since we need to know if we have to re-evaluate the selector if one of them is updated before the selector completely resolves.

This optimization significantly helps the common case for synchronous dependencies:
```
Improvement  Number of synchronous dependencies
2x           100
4x           1,000
40x          10,000
```

facebookexperimental#914 Please refer this issue for detail

Pull Request resolved: facebookexperimental#1515

Test Plan: Unit tests from D34100807 check for proper re-evaluation of updated async dependencies before async selectors resolve.

Differential Revision: https://www.internalfb.com/diff/D33825247?entry_point=27

Pulled By: drarmstr

fbshipit-source-id: 6df53e7bca2a2284c350149c662d7a7e7e31ca52
@drarmstr drarmstr self-assigned this Mar 8, 2022
@thomaszdxsn
Copy link
Contributor Author

@thomaszdxsn - Ok, I've got it implemented and it seems to work really well. You don't have to worry about updating the PR, I can take care of merging it. Thanks for all of your help here, this will be a great improvement!

(Also note #1651)

You are very gorgeous man, thank you very much, I learn a lot of things from this issue

AlexGuz23 pushed a commit to AlexGuz23/Recoil that referenced this pull request Nov 3, 2022
Summary:
The current algorithm for updating selector dependencies in the data flow graph is expensive.  Currently we update the graph every time a new dependency is added, which scales very poorly.  This optimization only updates the graph with all sycnhronous dependencies after the synchronous selector evaluation completes.

We still have to update the graph after every asynchronous dependency is added since we need to know if we have to re-evaluate the selector if one of them is updated before the selector completely resolves.

This optimization significantly helps the common case for synchronous dependencies:
```
Improvement  Number of synchronous dependencies
2x           100
4x           1,000
40x          10,000
```

(Note: to scale to 10,000+ also requires D34633750)

facebookexperimental/Recoil#914 Please refer this issue for detail

Pull Request resolved: facebookexperimental/Recoil#1515

Test Plan:
Unit tests from D34100807 check for proper re-evaluation of updated async dependencies before async selectors resolve.

Test performance of scalability with `Recoil_Perf-test.js`

Reviewed By: mondaychen

Differential Revision: D33825247

Pulled By: drarmstr

fbshipit-source-id: 943b64ef371833c1f77c0185c3c13d239526ad39
snipershooter0701 pushed a commit to snipershooter0701/Recoil that referenced this pull request Mar 5, 2023
Summary:
The current algorithm for updating selector dependencies in the data flow graph is expensive.  Currently we update the graph every time a new dependency is added, which scales very poorly.  This optimization only updates the graph with all sycnhronous dependencies after the synchronous selector evaluation completes.

We still have to update the graph after every asynchronous dependency is added since we need to know if we have to re-evaluate the selector if one of them is updated before the selector completely resolves.

This optimization significantly helps the common case for synchronous dependencies:
```
Improvement  Number of synchronous dependencies
2x           100
4x           1,000
40x          10,000
```

(Note: to scale to 10,000+ also requires D34633750)

facebookexperimental/Recoil#914 Please refer this issue for detail

Pull Request resolved: facebookexperimental/Recoil#1515

Test Plan:
Unit tests from D34100807 check for proper re-evaluation of updated async dependencies before async selectors resolve.

Test performance of scalability with `Recoil_Perf-test.js`

Reviewed By: mondaychen

Differential Revision: D33825247

Pulled By: drarmstr

fbshipit-source-id: 943b64ef371833c1f77c0185c3c13d239526ad39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. performance
Projects
None yet
Development

Successfully merging this pull request may close these issues.

using selectorFamily in a loop is slow
3 participants