Repository: jonbinney/deep_rabbit_hole
Pull request #190: Performance improvements for bfs
Author: login: alejandromarcu
Created: 2025-05-07T00:08:29Z | Merged: 2025-05-07T00:16:14Z

Fixing some low hanging fruit that made Simple run 4x faster.  Thanks Jon for the plug in, was also nice to see the speed improvement shown there.

Initially from main I ran this to have a baseline:
```
(.venv) (base) amarcu@MacBookPro deep_rabbit_hole % time /Users/amarcu/code/deep_rabbit_hole/.venv/bin/python /Users/amarcu/code/deep_rabbit_hole/deep_quoridor/src/play.py -p simple greedy -t 10 -r progressbar computationtimes
Progress: [==================================================] 100.0% (10/10)
+---------+-------------------+---------------+---------------+
|  Player | Average Time (ms) | Min Time (ms) | Max Time (ms) |
+---------+-------------------+---------------+---------------+
| simple  |      229.824      |     18.878    |    522.378    |
|  greedy |       1.915       |     0.754     |     3.641     |
+---------+-------------------+---------------+---------------+
/Users/amarcu/code/deep_rabbit_hole/.venv/bin/python  -p simple greedy -t 10   69.28s user 0.76s system 98% cpu 1:11.18 total
```

If I run it with -O to not use the asserts I get a significant speed bost:
```
(.venv) (base) amarcu@MacBookPro deep_rabbit_hole % time /Users/amarcu/code/deep_rabbit_hole/.venv/bin/python -O /Users/amarcu/code/deep_rabbit_hole/deep_quoridor/src/play.py -p simple greedy -t 10 -r progressbar computationtimes
Progress: [==================================================] 100.0% (10/10)
+---------+-------------------+---------------+---------------+
|  Player | Average Time (ms) | Min Time (ms) | Max Time (ms) |
+---------+-------------------+---------------+---------------+
| simple  |       87.507      |     8.287     |    182.370    |
|  greedy |       0.823       |     0.384     |     1.414     |
+---------+-------------------+---------------+---------------+
/Users/amarcu/code/deep_rabbit_hole/.venv/bin/python -O  -p simple greedy -t   26.50s user 0.54s system 97% cpu 27.837 total
```

But I didn't want to just remove all the asserts, so first instead of doing `assert np.sum(np.abs(np.subtract(position1, position2))) == 1, "Positions must be adjacent"` that seems to be the main culprit, I created the `_are_adjacent`, which is much more efficient (but of course as not good as not using the asserts).

```
(.venv) (base) amarcu@MacBookPro deep_rabbit_hole % time /Users/amarcu/code/deep_rabbit_hole/.venv/bin/python /Users/amarcu/code/deep_rabbit_hole/deep_quoridor/src/play.py -p simple greedy -t 10 -r progressbar computationtimes
Progress: [==================================================] 100.0% (10/10)
+---------+-------------------+---------------+---------------+
|  Player | Average Time (ms) | Min Time (ms) | Max Time (ms) |
+---------+-------------------+---------------+---------------+
| simple  |       98.961      |     9.204     |    209.603    |
|  greedy |       0.903       |     0.437     |     1.588     |
+---------+-------------------+---------------+---------------+
/Users/amarcu/code/deep_rabbit_hole/.venv/bin/python  -p simple greedy -t 10   29.83s user 0.46s system 98% cpu 30.844 total
```

I moved the `and not visited[new_row, new_col]` in _bfs condition before the `is_wall_between` check, and that gave it another nice boost:

```
  (.venv) (base) amarcu@MacBookPro deep_rabbit_hole % time /Users/amarcu/code/deep_rabbit_hole/.venv/bin/python /Users/amarcu/code/deep_rabbit_hole/deep_quoridor/src/play.py -p simple greedy -t 10 -r progressbar computationtimes
Progress: [==================================================] 100.0% (10/10)
+---------+-------------------+---------------+---------------+
|  Player | Average Time (ms) | Min Time (ms) | Max Time (ms) |
+---------+-------------------+---------------+---------------+
| simple  |       75.949      |     8.157     |    137.911    |
|  greedy |       0.899       |     0.412     |     1.555     |
+---------+-------------------+---------------+---------------+
/Users/amarcu/code/deep_rabbit_hole/.venv/bin/python  -p simple greedy -t 10   22.29s user 0.36s system 99% cpu 22.752 total
```

I did the same in `_dfs` but things didn't change much.
Then, the top lines of the profiling were like this:
```
   128866    6.893    0.000   17.542    0.000 quoridor.py:273(_bfs)
 33864658    5.179    0.000    5.179    0.000 quoridor.py:231(is_position_on_board)
  7713616    4.852    0.000    8.773    0.000 quoridor.py:209(is_wall_between)
   461234    3.743    0.000    5.303    0.000 quoridor.py:262(_is_wall_potential_block)
  2084548    1.590    0.000    1.590    0.000 {method 'reduce' of 'numpy.ufunc' objects}
  7713616    1.552    0.000    1.552    0.000 quoridor.py:195(_are_adjacent)
   360930    1.077    0.000    2.938    0.000 simple.py:43(compute_wall_weight)
```

So I tried re-writing _bfs to make things manual and avoid extra checks, function overheads, etc, etc, now the ocde is longer but got another significant boost:
```
(.venv) (base) amarcu@MacBookPro deep_rabbit_hole % time /Users/amarcu/code/deep_rabbit_hole/.venv/bin/python /Users/amarcu/code/deep_rabbit_hole/deep_quoridor/src/play.py -p simple greedy -t 10 -r progressbar computationtimes
Progress: [==================================================] 100.0% (10/10)
+---------+-------------------+---------------+---------------+
|  Player | Average Time (ms) | Min Time (ms) | Max Time (ms) |
+---------+-------------------+---------------+---------------+
| simple  |       52.728      |     6.341     |     91.073    |
|  greedy |       0.873       |     0.407     |     1.627     |
+---------+-------------------+---------------+---------------+
/Users/amarcu/code/deep_rabbit_hole/.venv/bin/python  -p simple greedy -t 10   15.81s user 0.32s system 97% cpu 16.548 total
```

So, the average time for simple went from initially 229ms to 52ms (4.4x) and the total running time from 71s to 16.5s (4.3x).


This is what we have now in the profile if we want to keep optimizing:
```
   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
   128866    5.306    0.000    6.081    0.000 quoridor.py:255(_bfs)
   461234    3.680    0.000    5.179    0.000 quoridor.py:244(_is_wall_potential_block)
  2084548    1.502    0.000    1.502    0.000 {method 'reduce' of 'numpy.ufunc' objects}
   360930    1.071    0.000    2.922    0.000 simple.py:43(compute_wall_weight)
   721860    0.814    0.000    1.424    0.000 _linalg.py:2575(norm)
762842/82459    0.797    0.000    1.803    0.000 quoridor.py:316(_dfs)
   668733    0.785    0.000   10.035    0.000 quoridor.py:399(is_action_valid)
  1122060    0.719    0.000    0.933    0.000 quoridor.py:200(is_wall_between)
   623277    0.661    0.000    1.582    0.000 quoridor.py:184(can_place_wall)
```



Diff:
diff --git a/deep_quoridor/src/agents/simple.py b/deep_quoridor/src/agents/simple.py
index ee17d51c..a7a6e06c 100644
--- a/deep_quoridor/src/agents/simple.py
+++ b/deep_quoridor/src/agents/simple.py
@@ -33,7 +33,7 @@ class SimpleParams(SubargsBase):
     discount_factor: float = 0.99
 
 
-def adjust_wall_position(position: tuple[int, int]) -> tuple[int, int]:
+def adjust_wall_position(position: tuple[int, int]) -> tuple[float, float]:
     """
     Compute a wall position that reflects where its center is relative to player positions.
     """
diff --git a/deep_quoridor/src/quoridor.py b/deep_quoridor/src/quoridor.py
index 0defa6bf..acb954a5 100644
--- a/deep_quoridor/src/quoridor.py
+++ b/deep_quoridor/src/quoridor.py
@@ -192,35 +192,31 @@ def can_place_wall(self, player: Player, position: tuple[int, int], orientation:
 
         return (self._grid[self._get_wall_slice(position, orientation)] == Board.FREE).all()
 
+    def _are_adjacent(self, position1: tuple[int, int], position2: tuple[int, int]) -> bool:
+        dr = position1[0] - position2[0]
+        dc = position1[1] - position2[1]
+        return ((dr == 1 or dr == -1) and dc == 0) or ((dc == 1 or dc == -1) and dr == 0)
+
     def is_wall_between(self, position1: tuple[int, int], position2: tuple[int, int]) -> bool:
         """
         Check if there is a wall between two positions.
         """
-        assert np.sum(np.abs(np.subtract(position1, position2))) == 1, "Positions must be adjacent"
-
-        position1_on_board = self.is_position_on_board(position1)
-        position2_on_board = self.is_position_on_board(position2)
-        assert position1_on_board or position2_on_board, "At least one position must be on the board"
+        assert self._are_adjacent(position1, position2), "Positions must be adjacent"
 
-        if position1_on_board and position2_on_board:
-            wall_position = (position1[0] + position2[0] + 2, position1[1] + position2[1] + 2)
+        wall_position = (position1[0] + position2[0] + 2, position1[1] + position2[1] + 2)
+        if 0 <= wall_position[0] < self._grid.shape[0] and 0 <= wall_position[1] < self._grid.shape[1]:
             return self._grid[*wall_position] == Board.WALL
-        else:
-            # By convention we treat the border as a "wall". This is makes checking jumps more convenient, since
-            # players are allowed to jump diagonally if they are adjacent to another player and the border of the
-            # board is on the other side of that player.
-            return True
+
+        # By convention we treat the border as a "wall". This is makes checking jumps more convenient, since
+        # players are allowed to jump diagonally if they are adjacent to another player and the border of the
+        # board is on the other side of that player.
+        return True
 
     def get_old_style_walls(self):
         return copy.copy(self._old_style_walls)
 
     def is_position_on_board(self, position: tuple[int, int]) -> bool:
-        return (
-            (position[0] >= 0)
-            and (position[0] < self.board_size)
-            and (position[1] >= 0)
-            and (position[1] < self.board_size)
-        )
+        return 0 <= position[0] < self.board_size and 0 <= position[1] < self.board_size
 
     def _wall_position_to_grid_index(self, position: tuple[int, int], orientation: WallOrientation) -> tuple[int, int]:
         """
@@ -277,26 +273,43 @@ def _bfs(self, start_position, target_row, visited):
 
         queue = [(row, col, 0)]
         visited[row, col] = True
+        N = self.board_size
 
         while queue:
             curr_row, curr_col, steps = queue.pop(0)
-
-            for new_row, new_col in [
-                (curr_row + 1, curr_col),
-                (curr_row - 1, curr_col),
-                (curr_row, curr_col - 1),
-                (curr_row, curr_col + 1),
-            ]:
-                if (
-                    self.is_position_on_board((new_row, new_col))
-                    and not self.is_wall_between((curr_row, curr_col), (new_row, new_col))
-                    and not visited[new_row, new_col]
-                ):
-                    visited[new_row, new_col] = True
-                    if target_row == new_row:
-                        return steps + 1
-                    else:
-                        queue.append((new_row, new_col, steps + 1))
+            # Iterate in the 4 directions, and if we can move to that position and we haven't already visited it, add it to the queue.
+            # This was done in a for loop before, but making everything explicit makes it significantly faster, and this method is called
+            # very often.
+
+            # Down
+            wall_row = curr_row * 2 + 2
+            wall_col = curr_col * 2 + 2
+            new_row = curr_row + 1
+            if new_row < N and not visited[new_row, curr_col] and self._grid[wall_row + 1, wall_col] != Board.WALL:
+                visited[new_row, curr_col] = True
+                if target_row == new_row:
+                    return steps + 1
+                queue.append((new_row, curr_col, steps + 1))
+
+            # Up
+            new_row = curr_row - 1
+            if new_row >= 0 and not visited[new_row, curr_col] and self._grid[wall_row - 1, wall_col] != Board.WALL:
+                visited[new_row, curr_col] = True
+                if target_row == new_row:
+                    return steps + 1
+                queue.append((new_row, curr_col, steps + 1))
+
+            # Right
+            new_col = curr_col + 1
+            if new_col < N and not visited[curr_row, new_col] and self._grid[wall_row, wall_col + 1] != Board.WALL:
+                visited[curr_row, new_col] = True
+                queue.append((curr_row, new_col, steps + 1))
+
+            # Left
+            new_col = curr_col - 1
+            if new_col >= 0 and not visited[curr_row, new_col] and self._grid[wall_row, wall_col - 1] != Board.WALL:
+                visited[curr_row, new_col] = True
+                queue.append((curr_row, new_col, steps + 1))
 
         return -1
 
@@ -327,8 +340,8 @@ def _dfs(self, start_position, target_row, visited):
         for new_row, new_col in moves:
             if (
                 self.is_position_on_board((new_row, new_col))
-                and not self.is_wall_between((row, col), (new_row, new_col))
                 and not visited[new_row, new_col]
+                and not self.is_wall_between((row, col), (new_row, new_col))
             ):
                 dfs = self._dfs((new_row, new_col), target_row, visited)
                 if dfs != -1:


Reviews:
user: login: jonbinney
body: LGTM, thanks @alejandromarcu !
state: APPROVED
author_association: OWNER
submitted_at: 2025-05-07T00:16:08Z
user: login: alejandromarcu
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T00:21:34Z

Review comments:
diff_hunk: @@ -33,7 +33,7 @@ class SimpleParams(SubargsBase):
     discount_factor: float = 0.99
 
 
-def adjust_wall_position(position: tuple[int, int]) -> tuple[int, int]:
path: deep_quoridor/src/agents/simple.py
user: login: jonbinney
body: Thanks for fixing this - I think i have pylance misconfigured because it doesn't complain about types at all.
created_at: 2025-05-07T00:10:43Z
author_association: OWNER
diff_hunk: @@ -192,35 +192,31 @@ def can_place_wall(self, player: Player, position: tuple[int, int], orientation:
 
         return (self._grid[self._get_wall_slice(position, orientation)] == Board.FREE).all()
 
+    def _are_adjacent(self, position1: tuple[int, int], position2: tuple[int, int]) -> bool:
+        dr = position1[0] - position2[0]
+        dc = position1[1] - position2[1]
+        return ((dr == 1 or dr == -1) and dc == 0) or ((dc == 1 or dc == -1) and dr == 0)
+
     def is_wall_between(self, position1: tuple[int, int], position2: tuple[int, int]) -> bool:
         """
         Check if there is a wall between two positions.
         """
-        assert np.sum(np.abs(np.subtract(position1, position2))) == 1, "Positions must be adjacent"
-
-        position1_on_board = self.is_position_on_board(position1)
-        position2_on_board = self.is_position_on_board(position2)
-        assert position1_on_board or position2_on_board, "At least one position must be on the board"
+        assert self._are_adjacent(position1, position2), "Positions must be adjacent"
 
-        if position1_on_board and position2_on_board:
-            wall_position = (position1[0] + position2[0] + 2, position1[1] + position2[1] + 2)
path: deep_quoridor/src/quoridor.py
user: login: jonbinney
body: Checking that the wall position is in bounds instead of checking both positions is a nice improvement here. I did a little testing and found that there's a slight but noticeable improvement from removing the overhead of a function call.
created_at: 2025-05-07T00:13:32Z
author_association: OWNER
diff_hunk: @@ -33,7 +33,7 @@ class SimpleParams(SubargsBase):
     discount_factor: float = 0.99
 
 
-def adjust_wall_position(position: tuple[int, int]) -> tuple[int, int]:
path: deep_quoridor/src/agents/simple.py
user: login: alejandromarcu
body: It's nice to have it working, a lot of times the "error" is inocuous like this one but sometimes it points you to a potential bug
created_at: 2025-05-07T00:21:34Z
author_association: COLLABORATOR

PR comments:
user: login: alejandromarcu
created_at: 2025-05-07T00:22:26Z
body: @jonbinney , I'll be curious to see if you also see a 4x perf improvement in simple, if you try it lmk
author_association: COLLABORATOR
user: login: jonbinney
created_at: 2025-05-07T00:35:00Z
body: @alejandromarcu yep i do!

Before this PR:
```
+---------+-------------------+---------------+---------------+
|  Player | Average Time (ms) | Min Time (ms) | Max Time (ms) |
+---------+-------------------+---------------+---------------+
| simple  |      413.231      |     33.607    |    928.710    |
|  greedy |       3.305       |     1.316     |     6.488     |
+---------+-------------------+---------------+---------------+
```

After:
```
+---------+-------------------+---------------+---------------+
|  Player | Average Time (ms) | Min Time (ms) | Max Time (ms) |
+---------+-------------------+---------------+---------------+
| simple  |       93.223      |     11.271    |    157.391    |
|  greedy |       1.388       |     0.699     |     2.461     |
+---------+-------------------+---------------+---------------+
```
author_association: OWNER
user: login: alejandromarcu
created_at: 2025-05-07T05:17:25Z
body: Great! but the bad news is that to make it to depth 4 for example we would need about 100x improvement in speed. I think it's mainly measuring the distances from the pawns to the target when max_depth = 0 and checking for so many wall placements.
author_association: COLLABORATOR