-
Notifications
You must be signed in to change notification settings - Fork 669
Unoptimized memoization behaviour when passing multiple arguments #739
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
Comments
I don't think I follow why this would be an issue. What matters is what the "input selectors" extract, and if the Can you give an actual repro of this problem? |
repro of the problem: https://codesandbox.io/p/devbox/serverless-sky-y9dhwn?workspaceId=ws_7Uz69aHwQChHg4Ja3sh53K Just to add that this problem is both a CPU and a memory problem. CPU because the input selectors get invalidated, and memory because the unary selectors get a new memoization entry for each unique extra argument.
Yes, but normally it should not be executed at all if passed the same argument twice. |
Hey @markerikson , I have been working with @kahoot-karl on that issue. The cache key in weakMapMemoize is using all In the sentence you quoted, I am not sure if |
I do see one issue with the example itself. Yes, all the input functions for const superHeavyComputation = jest.fn((input) => {
return { a: "A", b: "B" };
}); which is a problem because now the input function is always returning a new reference. In general, though: yes, If that's a problem in your case, then I think the best approach is to override the |
@markerikson I tried to come up with a repro close from reselect best practices https://codesandbox.io/p/sandbox/gracious-jackson-x4q877
yes it is a problem because we are using the same component with a different id, something like 100 times, and we update the store multiple times per second.
Thanks for the suggestion. That is something we are evaluating. |
Overriding argsMemoize for this patch is very tricky because the memoization function cannot introspect dependencies recursively. We thought it might be attractive to support from a library that specializes in memoization pov, since this is an optimisation that could be introduced on the library-side with no drawbacks to the end user. |
You mentioned that the patch resulted in "a great speedup". If you record a CPU profile, where is the extra time being spent? Is it just in comparisons? Is that the varying inputs are actually leading to complete selector recalculations, and therefore unnecessary component re-renders? I still feel like I'm missing something about this problem description. Even if the args check runs, I would assume that the actual selectors don't run, because the input function for the
That's what I'm saying. Yes, the args checks will need to rerun, because for every cache entry there are new references that were used to generate that level of caching. (And since the state is the first arg, it forms the root of the caching tree). But the input selectors ought to be extracting the same values, and so the combiner function shouldn't be running because those values haven't changed. |
@markerikson thank you for your patience with this issue so far. It make sense to try to find out why this would lead to such bad performance. It seems like all the input selectors follow the proper rules: I added some I am speculating that the weakmapmemoization slows down things by allocating a lot of memory storing the same thing over and over, but I will try to get back to you shortly to profile and experiment a bit more. |
personally i would be against reselect including some sort of one-size-fits-all solution - in userland, you could perhaps have some sort of const forceArity = <Args extends any[], Return>(fn: (...args: Args) => Return, arity: Args["length"]) => (...args: Args) => fn(...args.slice(0, arity))
const selectSomeData = createSelector([
forceArity(heavySelectorThatDependsOnDeepChainOfSelectors, 1),
(state, id) => id
], (heavyResult, id) => /* combiner code */ but obviously that would need to be applied on a case by case basis. |
Ok, I think I understand the problem now a bit better.
So even though going through the memoized input selectors is "cheap", what is usually linear complexity, is now quadratic complexity, because for every new entry "N + 1" requires N extra passes through the full input selector tree. With the optimization of not passing the extra argument unnecessarily, this work is only done once per entry insertion and not N times (where N is the number of entries). We have verified that we are following best practices in the sense that each new entry only renders one React component, and that the state updates do not cause selector combiners to invalidate (as mentioned above). We've drilled down this problem with a benchmark running in node.js and for (let i = 0; i < 100; i++) {
const depBefore = heavySelector.dependencyRecomputations();
addEntry(i);
const depAfter = heavySelector.dependencyRecomputations();
console.log('new dep computations', depAfter - depBefore);
} With the normal version of This starts becoming noticeable in the benchmark already around 50 entries and was evident at 100, where the benchmark time is about 1.5 seconds compared to 400 ms with our custom patch (after the fix, the problematic selector did not add any significant time to the benchmark, time was spent in other places). I hope I'm making myself clear, let me know if I might be missing something, or if you need something else double-checked. |
Faced the same problem. We have some selectors that are used as input for almost every other selectors so cache invalidation became a real issue for us. Gladly such "hot" selectors in our app depend mostly on state so I created special
For other cases I can always just do:
but yeah, keeping track of it is not very pleasant task |
@misha-erm Here is our workaround that doesn't require you to keep track of it manually. type CreateSelectorFunction<StateType = any> = <InputSelectors extends SelectorArray<StateType>, Result>(
inputSelectors: [...InputSelectors],
combiner: Combiner<InputSelectors, Result>
) => Selector<GetStateFromSelectors<InputSelectors>, Result, GetParamsFromSelectors<InputSelectors>> &
NonNullable<unknown>;
export const createSelector: CreateSelectorFunction = (inputs, combiner) => {
return reselectCreateSelector(
inputs.map((input) => {
if (input.length > 2) {
throw new Error('More than one free argument not supported (selector can have at most 2 arguments)');
}
const isUnary =
(input as OutputSelector).dependencies?.every((dependency) => dependency.length <= 1) ||
(!('dependencies' in input) && input.length <= 1);
return isUnary ? (state) => input(state) : (state, props) => input(state, props);
}) as typeof inputs,
combiner
);
}; |
Hey!
I am encountering undesirable memoization behaviour which doesn’t match expectations from the documentation, in particular the behaviour of WeakMapMemoize.
I read the issue at #635 carefully and this seems like a different problem/scenario.
We were happy to use weakMapMemoize so we don't need to create private instances of a selector when using them with props, like so:
However, assume that
selectSomeData
was defined like this:Then the first selector and its corresponding input selectors get invalidated every time it encounters a new value of
id
, althoughheavySelectorThatDependsOnDeepChainOfSelectors
only accepts one argument so the result will obviously be the same.Quoting the documentation from https://reselect.js.org/api/weakmapmemoize/
This might be obvious to some, or our use case is maybe too niche, but I would have loved to know this before since we’re using a wide range of values of the second arguments that will break the memoization.
We have applied a local patch to our own code like so:
Mass-replacing that selector in our application leads to great speed-ups.
Could you consider introducing a similar optimization into the core library? Or perhaps just clarify the docs.
Thank you
The text was updated successfully, but these errors were encountered: