Implement multi-source shortest path query support - #654
Conversation
|
@rjb32 if you get time pls review this PR. |
Thank you very much @patelchaitany! I will review it on Monday |
|
Hey @rjb32! Whenever you get a moment, I'd love to get your thoughts on this PR. Thanks! |
|
Hey @patelchaitany - I have looked at the PR and had a few overall comments:
Overall the code looks good and conforms to our standards well:)! |
| ColumnVector<NodeID>* targetOutputCol, | ||
| ColumnVector<EdgePropType>* distCol, | ||
| ColumnVector<Path>* pathCol) { | ||
| DijkstraHeap heap; |
There was a problem hiding this comment.
All these structures are going to be recreated per runDijkstra call, you could save them as members of the processor class and clear them on entry in the function
| class MultiSourceShortestPathProcessor final : public Processor { | ||
| public: | ||
| using EdgePropType = T::Primitive; | ||
| using DijkstraHeap = std::priority_queue<MultiSourceDijkstraNode<EdgePropType>, |
There was a problem hiding this comment.
Probably better to extract all these type definitions and the runDijkstra mechanics into some utility class that would wrap up everything related to Dijkstra algorithm
| throw PlannerException("Unsupported Edge Weight Type"); | ||
| } | ||
| }; | ||
| ValueTypeDispatcher {edgeType._valueType}.execute(process); |
There was a problem hiding this comment.
Coding style: we prefer to use the parenthesis for constructors instead of braces, such as ValueTypeDispatcher(edgeType._valueType)
|
@rjb32 @sulaimansuhas Feedback addressed! Please take another look and let me know if any further changes are needed. Happy to revise. |
|
Hey @patelchaitany. I looked over your changes and it mostly looks good! I have two comments:
thank you for your good work! |
|
I'm going to expand |
|
Hey @patelchaitany - your code changes sound good, I'll be waiting to review! As for your comments on the shortest path optimisations: If we take the shortest path between node a and z to be a->p->x->z, then for that path we are guaranteed that every subpath of the path is the optimal shortest path from the source node of that subpath to the target. So from this single traversal we can infer that the shortest path from p to z is p->x->z. Given this finding, two optimisations I can think of off the top of my head:
these are the ideas that gave me the intuition for optimisation in this case. I think it'd be a shame to get this PR through without exploring these kind of optimisations as I think they have the potential to reduce a lot of computation. I'd like to hear your thoughts on this, and thank you for all your good work! |
Add multiSourceShortestPath query support that computes shortest paths from a set of source nodes to all reachable targets, returning one row per (source, target) pair with distance and path.
d6adf47 to
dc37525
Compare
|
@sulaimansuhas - subpath caching is in. After each run we cache intermediate-to-target subpaths, then before the next source we check the cache and skip Dijkstra if we already have the answer. Mid-traversal hits are handled as pending results that only finalize when the heap confirms optimality. Right now the cache is query-scoped - thinking a cross-query version keyed to commit hashes (with eviction like |
|
Hey @rjb32 , @sulaimansuhas Is there any changes are required. |
|
Hey @patelchaitany, sorry for taking so long! I looked through the PR it looks pretty good. I'll have some final comments that I'll add later today. |
sulaimansuhas
left a comment
There was a problem hiding this comment.
Hey @patelchaitany, the overall logic of your change is sound. I have left quite a few comments explaining what would be needed to make this mergeable. I think we need a lot more comments around the algorithm - in places my previous comments have been removed where my code was copied. If you are using AI coding tools - I request that you write the comments yourself because we generally find the AI generated comments to not be that clear to human readers, and it will also help you reason about the logic itself.
| void DijkstraRunner<T>::run(const DijkstraHeap<EdgePropType>& initialHeap, | ||
| const DijkstraValueMap<EdgePropType>& initialValues, | ||
| const std::unordered_set<NodeID>& targetNodes, | ||
| bool stopAtFirst, |
There was a problem hiding this comment.
I don't like having the bool flag, I think it should be a number. This would make it trivial to create a top K shortest paths algorithm in the future.
| const DijkstraNode<EdgePropType> val = _heap.top(); | ||
| _heap.pop(); | ||
|
|
||
| const auto it = _heapValueMap.find(val.id); |
There was a problem hiding this comment.
Please add a comment explaining that we are removing the stale values here.
|
|
||
| // Finalize pending cache-hit results whose distance cannot be beaten | ||
| // by any future path (all remaining nodes have distance >= val.distance). | ||
| for (auto pendingIt = _pendingResults.begin(); pendingIt != _pendingResults.end(); ) { |
There was a problem hiding this comment.
Nice! Please add more comments explaining what is happening here. And expand on the why - these are complex algorithms that we want to as readable as possible.
| } | ||
| } | ||
|
|
||
| // Consult the subpath cache: if this settled node has known shortest |
There was a problem hiding this comment.
This comment is not clear at all, please clearly explain the logic behind this cache search better. Something like:
If the current popped node is found in the cache then we know we have the shortest path from our source to the Target Node in the cache entry.
There was a problem hiding this comment.
Also please clarify the explanation on why we need the pending result sets. It is useful but it took me a while to understand it just based on the code.
| const auto cacheIt = cache->find(val.id); | ||
| if (cacheIt != cache->end()) { | ||
| for (const SubpathCacheEntry<EdgePropType>& entry : cacheIt->second) { | ||
| if (!targetNodes.contains(entry.targetNode)) { |
There was a problem hiding this comment.
both these if conditions should be one statement.
| private: | ||
| DijkstraHeap<EdgePropType> _heap; | ||
| DijkstraValueMap<EdgePropType> _heapValueMap; | ||
| std::unordered_set<NodeID> _settledTargets; |
There was a problem hiding this comment.
I think it makes more sense to pass the constructed target nodes to the Utils class as a const, and have the settledTargets passed to the function itself. This way we don't have to create a copy of the targetNodes set every time. This would be especially useful if the target nodes set itself is quite large.
| std::vector<DijkstraResult<EdgePropType>> _results; | ||
| std::unordered_map<NodeID, DijkstraResult<EdgePropType>> _pendingResults; | ||
|
|
||
| ColumnNodeIDs* _inputNodes {nullptr}; |
There was a problem hiding this comment.
We can forward declare all these pointer members.
| } | ||
|
|
||
| template <SupportedType T> | ||
| void DijkstraRunner<T>::expandNode(const DijkstraNode<EdgePropType>& node) { |
There was a problem hiding this comment.
please add back all the comments that were removed here ( see original algo)
| // Start with the cached path suffix: [target, edge, ..., edge, settledNode] | ||
| outputPath = cacheEntry.pathSuffix; | ||
|
|
||
| // Append the predecessor chain from settledNode back to the source. | ||
| auto lastNode = settledNode.prevNode; | ||
| auto edge = settledNode.edge; | ||
| while (lastNode.isValid()) { | ||
| outputPath.push_back(edge.getValue()); | ||
| outputPath.push_back(lastNode.getValue()); | ||
|
|
||
| const auto& pathInfo = _heapValueMap[lastNode]; | ||
| lastNode = pathInfo.prevNode; | ||
| edge = pathInfo.edge; | ||
| } | ||
| } |
There was a problem hiding this comment.
| // Start with the cached path suffix: [target, edge, ..., edge, settledNode] | |
| outputPath = cacheEntry.pathSuffix; | |
| // Append the predecessor chain from settledNode back to the source. | |
| auto lastNode = settledNode.prevNode; | |
| auto edge = settledNode.edge; | |
| while (lastNode.isValid()) { | |
| outputPath.push_back(edge.getValue()); | |
| outputPath.push_back(lastNode.getValue()); | |
| const auto& pathInfo = _heapValueMap[lastNode]; | |
| lastNode = pathInfo.prevNode; | |
| edge = pathInfo.edge; | |
| } | |
| } | |
| // Start with the cached path suffix without the starting node: [target, edge, ..., edge] | |
| outputPath = cacheEntry.pathSuffix | ranges::views::drop_last(1); | |
| reconstructPath(settledNode, outputPath); | |
| } | |
| } |
| struct SubpathCacheEntry { | ||
| NodeID targetNode; | ||
| T distance {0}; | ||
| Path pathSuffix; |
There was a problem hiding this comment.
pathSuffix is not the correct name for this. Path would be fine.
|
@sulaimansuhas, Okay I will address your comments and not try to use AI for comments |
Add multiSourceShortestPath processor that computes shortest paths from a set of source nodes to all reachable targets, returning one row per (source, target) pair with distance and path — extends the existing shortestPath which only returns a single best pair. Closes #617