-
-
Notifications
You must be signed in to change notification settings - Fork 18
Topological order update #122
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
Conversation
5d6b546 to
bb79c39
Compare
13998f0 to
cdc8bc7
Compare
cdc8bc7 to
f90fd3c
Compare
|
|
||
| /// DFS topological sorting. | ||
| @MainActor | ||
| internal func topologicalSort(key: AtomKey, store: AtomStore) -> ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Global main actor func just to access main actor isolated store.graph, why not make it extension of AtomStore which already is on mainactor.. (not big fan of global functions with specific context)
[nit]
Also, do you think topologicalSort is a proper name given it returns edges? I can suggest to make it something more fitting the caller site → update(atom...) , e.g. childrenToUpdate() -> edges, redundants etc, and then hide the specific technique method like topologicallySorted() -> edges..
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Global main actor func just to access main actor isolated store.graph, why not make it extension of AtomStore which already is on mainactor.
Sounds good to me.
do you think topologicalSort is a proper name given it returns edges?
I believe how it is sorted is important for the caller as the additional algorithm on the StoreContext doesn't work except with topological sorting.
Also, any other naming wouldn't justify it returning the pair of edges and redundant.
More descriptive naming like topologicallySortedEdgesWithRedundantPath would make it clear but it sounds too much.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm already on a bunch of refactoring in another branch, so will apply it there.
rasberik
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good to me, left few nit comments
| // Perform side effects first. | ||
| let state = getState(of: atom, for: key) | ||
| let context = AtomCurrentContext(store: self, coordinator: state.coordinator) | ||
| atom.updated(newValue: newValue, oldValue: oldValue, context: context) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Q: Does this mean we're changing the timing of the side effects, and in case usage accidentally relies on the current order (performing side effects at the last of this method), it might get affected?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, before the change, it runs a side effect after finishing updating downstream, but now it runs immediately after updating its value.
In the first place, the order in which the atoms were updated was not guaranteed, so it's not possible that they were used in a way that depended on the update order. (Even if there were, it wouldn't work properly)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the first place, the order in which the atoms were updated was not guaranteed, so it's not possible that they were used in a way that depended on the update order. (Even if there were, it wouldn't work properly)
Understood, thank you!
Pull Request Type
Description
Problem
Currently, the transitive updates of the downstream when atom is updated are done in random order. Also, if an equivalent dependency (atom or subscription) appears multiple times in the dependency graph, it will be updated by that number.
This not only causes redundant recalculation of atoms and updates of views but can even cause glitches due to atoms being updated while some of the dependencies are still out of date.
For example, the following case:
graph TD; A-->B; A-->C; B-->D; C-->D; C-->E; D-->E;In this case, the worst update order would be:
A -> B -> D -> E -> C -> D -> E -> Ewhich updates
Ethree times, andDtwice, while the first update ofDconsumes an outdated state ofCasCis yet to be updated at the time, and then accordinglyEconsumes an outdated value ofC&Dtwice.This is a significant issue in the performance of apps that use Atoms for state management.
Solution
Introduce a topological sorting algorithm to give the correct order for transitive updates.
In the example given above, the topological sorted order will be:
A -> B -> C -> D -> Eor
A -> C -> B -> D -> Ewhich doesn't contain redundant transitive updates while guaranteeing that all updates of its dependencies are completed before the atom is updated.
Since there is no reasonable factor between
BandCthat determines their update order, which pattern of those it will be is random.Algorithm
DFS topological sorting [ref].
DFS is a better fit to the library's data structure than BFS.
Although DFS implementations typically use recursive functions and are vulnerable to stack overflows, Atom's dependencies cannot realistically be that deep. Also, Swift's -O compile should optimize the recursive function to be equivalent to an implementation using
while.Normally, DFS aborts downstream traversal at the point where the vertex has already been traced, but this library doesn't abort and always traverses to the end of the graph while avoiding pushing the redundant edges to the sorted array. This is necessary to re-evaluate redundant edges later.
In the case of the above example, the sorted order is
A -> B -> C -> D -> E, and let's say that when the state ofDis updated, it is revealed that there's no need to update downstream as the new value has not changed from the old value (assuming thatDis an atom withchangesmodifier). Even then, its downstream,Eneeds to be transitively updated as it depends onC, otherwiseEwill not reflect the changes ofC.To avoid this problem, redundant edges omitted during sorting are collected, and when the above situation occurs, it checks if the recorded edges contain the target vertex to determine whether it still needs updating.
The redundant edges in the example are:
B -> DC -> EPerformance
The complexity of the algorithm is
O(|V|+|E|)where V = Vertices, E = Edges, which is done in a linear time.It is conceptually a bit slower than the current implementation but the result of the benchmark testing I have done showed that the performance from both clock time and memory perspective is almost the same as before in general cases while ensuring that it prevents additional causes of low performance such as redundant builds of atoms/views that are the most important part of app performance.