perf: optimize atom store for unmounted atom reads and DAG traversal#3259
perf: optimize atom store for unmounted atom reads and DAG traversal#3259edkimmel wants to merge 1 commit intopmndrs:mainfrom
Conversation
Two key performance optimizations: 1. Store-level epoch cache for unmounted atoms: Repeated store.get() calls on unmounted derived atoms no longer recursively walk the entire dependency chain when no mutations have occurred. A per-store epoch counter (incremented on each store.set()) allows storeGet to skip the expensive dep walk entirely when the epoch is unchanged. 2. Inline getMountedOrPendingDependents: The hot-path functions invalidateDependents and recomputeInvalidatedAtoms now iterate mounted.t and atomState.p directly, avoiding a Set allocation on every call during DAG traversal. https://claude.ai/code/session_01Urj5ZjLjsGhU5Ujb3oE8or
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
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. |
|
I'm almost forgetting the past discussion, but if I remember correctly, we don't plan to merge this as this is only beneficial for some use cases. Thanks to new building blocks, I think you can create an ecosystem library to implement this capability (if not, we should fix the building blocks). I can invite you to https://github.com/jotaijs, let me know if you are interested. |
|
@dai-shi Ah, that's a fun development! I'll have to take a look. |
|
@dai-shi The current benchmarks do not test wide and deep dependency trees. Here's an example addition I would still encourage looking at the problem of redundant tree traversal during that first render before effects mount in core jotai, independent of my proposals. Every read scales linearly with depth/width of the dependency tree, but could be cut down to O(1) with a guarantee that data has not changed since the last read.
|
|
Thanks for the follow-up.
Ah, I think I misunderstood something. This should be the general improvement for deep/wide use cases (at the cost of complexity). Please send another PR to add the benchmark file.
Thanks. Sure, I'll take another deeper look soon. Meanwhile, do you think you can reproduce with a failing test (without relying on "time")?
Does the guarantee hold? Okay, I think I get it. I see, it's store epoch to guarantee. I think it's valid. Clever. But, I'm not sure if it covers 100%. We can update store async. The implementation needs to be re-designed. I'll take care of it as I can't tell my preference easily (and it may end up with something like yours). If you can help the work, the failing test should be really helpful. |
|
Can you refactor this to use Building Blocks? I would start by moving the store epoch to buildingBlocks and verified atomEpoch to atomState. We want both to be accessible by stores that compose these building blocks. We don't rely on derived stores anymore, since stores are now composed by building blocks, so I would drop that requirement and leave it up to the developer to integrate with store cache - but we have to ensure the default behavior is always correctness so if they choose to do nothing, it will just result in extra readAtomState traversals. |
| } | ||
| visiting.add(a) | ||
| // Push unvisited dependents onto the stack | ||
| for (const d of getMountedOrPendingDependents(a, aState, mountedMap)) { |
There was a problem hiding this comment.
Nice. Can we remove getMountedOrPendingDependents completely or is it still used somewhere?
| const entry = epochState.verified.get(atom) | ||
| if ( | ||
| entry && | ||
| entry[0] === epochState.epoch && | ||
| entry[1] === cachedState.n | ||
| ) { |
There was a problem hiding this comment.
| const entry = epochState.verified.get(atom) | |
| if ( | |
| entry && | |
| entry[0] === epochState.epoch && | |
| entry[1] === cachedState.n | |
| ) { | |
| const [storeEpoch, atomEpoch] = epochState.verified.get(atom) || [] | |
| if ( | |
| storeEpoch === epochState.epoch && | |
| atomEpoch === cachedState.n | |
| ) { |
|
Can you revisit the benchmarks? I would like to understand the performance of various atom trees:
|
| const atomStateMap = buildingBlocks[0] | ||
| const cachedState = atomStateMap.get(atom) | ||
| if (cachedState && ('v' in cachedState || 'e' in cachedState)) { |
There was a problem hiding this comment.
| const atomStateMap = buildingBlocks[0] | |
| const cachedState = atomStateMap.get(atom) | |
| if (cachedState && ('v' in cachedState || 'e' in cachedState)) { | |
| const ensureAtomState = buildingBlocks[11] | |
| const atomState = ensureAtomState(atom) | |
| if (isAtomStateInitialized(atomState)) { |
|
Note that I'll revisit this and #3265 when I get time (maybe next months). I hope to find a minimal solution that should be comfortable. Meanwhile, feel free to continue experiments, but current plan is not merge this PR.
Sounds good as an experiment. And, if it can be done with a third-party library, it is very nice as an experiment too. |
commit: |
|
| Playground | Link |
|---|---|
| React demo | https://livecodes.io?x=id/FNZ5ZBBJE |
See documentations for usage instructions.

Related Bug Reports or Discussions
Fixes discussion # #2334
Summary
The discussion linked is two years old now, time flies.
My old suggestions and approaches no longer work with the big store rewrite, and I do not believe they ever worked with async atoms. My goals are the same, albeit I did use AI to figure out how exactly it could be re-implemented in this new jotai world.
React SSR and React Hydration are slow due to repeated tree traversal reading unmounted atom values. During the synchronous react render, there aren't any mutations to the store to warrant re-traversal, so a caching strategy was desired.
There has been some slight performance regressions over time that have added up in the dependency updates flow since the initial conversion to topological sort a few years ago.
Two key performance optimizations:
Store-level epoch cache for unmounted atoms: Repeated store.get() calls on unmounted derived atoms no longer recursively walk the entire dependency chain when no mutations have occurred. A per-store epoch counter (incremented on each store.set()) allows storeGet to skip the expensive dep walk entirely when the epoch is unchanged.
Inline getMountedOrPendingDependents: The hot-path functions invalidateDependents and recomputeInvalidatedAtoms now iterate mounted.t and atomState.p directly, avoiding a Set allocation on every call during DAG traversal.
Check List
pnpm run fixfor formatting and linting code and docsBenchmark Results (vs main)