Right now, my side project is to make an A* code that works for Unity’s ECS (this is my main project). I’ve always wondered how many A* calls can be completed in a single frame using a Burst compiled implementation that runs in a job (multithreaded). This post is not about the results as I’m not there yet. My current implementation is still horrible. I did, however, observe a little optimization that I can make while rewriting our in house A* solution.

No need to fix the Open Set

Again, I’m not going to explain the whole A* algorithm here as there are better resources for that else where. I’ll just assume that you’re familiar with it.

A* employs the concept of an open set which is just a collection of positions or nodes that are possible path but haven’t been considered yet. Every node in the open set has an F score associated with it which represents how far the node is from the goal position. Every time we consider a node in the open set, we get the one with the least F score. That’s because a lesser F score means it’s closer to the goal.

Most A* implementations uses a heap data structure for the open set. A heap is a kind of container that remembers what item in it has the least or most value (depending on the comparison function that you pass to it). For A*, we need the node with the least F score. Using a heap is just a perfect fit for the purposes of the algorithm. The heap may be an array or a tree based implementation.

In A*, there’s a special case that says when a neighbor node is already in the open set, we check if it has a lesser F score than the one contained in the set. If it is, we update the F score of the one in the open set, instead of pushing the neighbor node into the set. When a node’s F score is updated, the internal state of the heap is now disrupted. The way the heap maintains the ordering of its items could now be wrong. We have to fix the heap such that it’s internal integrity is still intact. If it’s an array based implementation, we have to move the position of the node so that it is in its right place. It’s the same with the tree based implementation. The node may need to bubble up since it now has a lesser value.

Then I had a thought. What if I can avoid fixing the heap? What if I’ll just push the neighbor with better F score into the heap? The heap will fix itself anyway. The duplicate node with the least F score will always be popped first. When the old node with higher F score is popped, I can just check if it’s already in the close set which would be the case since the one with lesser F score would have been already popped and processed. The old node will always be skipped.

Not fixing the heap would also lead to a better implementation. We can now use immutable structs for the heap entries and A* nodes as we no longer need to mutate them.

So I did all this and the algorithm still checks out. I admit that this is a micro-optimization and the gains are very small, but hey, fixing the heap is still code that you don’t have to execute.

private void ProcessNode(in AStarNode current) { if (IsInCloseSet(current.position)) { // Already in closed set. We no longer process because the same node with lower F // might have already been processed before. Note that we don't fix the heap. We just // keep on pushing nodes with lower scores. return; } // Process neighbors for (int i = 0; i < this.neighborOffsets.Length; ++i) { int2 neighborPosition = current.position + this.neighborOffsets[i]; if (current.position.Equals(neighborPosition)) { // No need to process if they are equal continue; } if (!this.gridWrapper.IsInside(neighborPosition)) { // No longer inside the map continue; } if (IsInCloseSet(neighborPosition)) { // Already in close set continue; } if (!this.reachability.IsReachable(current.position, neighborPosition)) { // Not reachable based from specified reachability continue; } float tentativeG = current.G + this.reachability.GetWeight(current.position, neighborPosition); float h = this.heuristicCalculator.ComputeCost(neighborPosition, this.goalPosition.Value); AStarNode neighborNode = GetNode(neighborPosition); if (neighborNode.hasValue) { // This means that the node is already in the open set // We update the node if the current movement is better than the one in the open set if (tentativeG < neighborNode.G) { // Found a better path. neighborNode = CreateNode(neighborPosition, tentativeG, h, new Maybe<int2>(current.position)); // Just push the node with lesser F. No need to fix the open set. this.openSet.Push(new HeapNode(neighborPosition, neighborNode.F)); } } else { // Not yet in open set neighborNode = CreateNode(neighborPosition, tentativeG, h, new Maybe<int2>(current.position)); this.openSet.Push(new HeapNode(neighborPosition, neighborNode.F)); } } }