Repository: projectmesa/mesa

Issue:
SingleGrid position_agent very slow for large grids
It's fine for square widths of around 20 cells, but when you 200 it slows right down.

It appears to be caused by the line `agent.random.choice(sorted(self.empties))` where it sorts the empties first. I guess it does this so you can replicate the results using random seed?

If you don't care about replication however, it's unreasonably slow and can be fixed by using `agent.random.choice(list(self.empties))` instead (speeding it enormously).

I'm not sure of the design decisions here, but maybe it's worth having two functions to make it clear or passing some sort of bool?

https://mesa.readthedocs.io/en/stable/mesa.html#mesa.space.SingleGrid.position_agent



Discussion:
In 3.8:
```python
In [1]: a = set([1,2,3,5,4])

In [2]: list(a)
Out[2]: [1, 2, 3, 4, 5]

In [3]: a.add(-5)

In [4]: a.add(10)

In [5]: list(a)
Out[5]: [1, 2, 3, 4, 5, 10, -5]

In [6]: sorted(a)
Out[6]: [-5, 1, 2, 3, 4, 5, 10]

In [7]: list(a)
Out[7]: [1, 2, 3, 4, 5, 10, -5]
```
it seems that the output of `list(a)` is deterministic. Maybe `set` used to be nondeterministic in the earlier version of Python.
Oh interesting, thanks! I couldn't find any specific release notes about set's now being ordered. As of 3.7+ (when dicts became ordered) they definitely weren't guaranteed. https://stackoverflow.com/questions/61414947/why-dont-python-sets-preserve-insertion-order

Guido says "Sets remain unordered. (Why? The usage patterns are different. Also, different implementation.)" so cool if that's changed.
For comparison, this is how Agents.jl do a move to empty: https://github.com/JuliaDynamics/Agents.jl/blob/c9a7fa13493c0ba24aacd0a6a6de4ba19394d181/src/spaces/discrete.jl#L80-L88. This is how the empty positions information is obtained: https://github.com/JuliaDynamics/Agents.jl/blob/c9a7fa13493c0ba24aacd0a6a6de4ba19394d181/src/spaces/discrete.jl#L57-L64. It seems expensive to have to generate the empty positions every single time. Maybe @Libbum can explain why Agents.jl chose it to be this way.

It could be that in many models, move to empty is not a necessary movement condition. Agents can overlap with each other. But why not make 2 different kinds of grids where one has the empties cached, while the other doesn't cache.
From an Agents.jl perspective this wasn't an active choice&mdash;it hasn't caused us concern at all though. `random_empty` is used in Schelling, but not too many other models use it, so it hasn't been a place in the base framework we need to optimise.

A cache on empties, for Agents.jl, would end up meaning additional operations each step for all models, on the off chance that someone *may* need lots of `random_empty` calls (and these are causing performance drops).

**If** we had to deal with this: I'd say we'd do something similar to what @rht is suggesting: we'd add a flag to our grid that was off by default and returned `nothing` for any function call that expects the cache. Switching the flag on would do such an update.

That's at least what we would test as a first go...
I have checked the models in https://github.com/projectmesa/mesa/tree/main/examples. Only Schelling uses `move_to_empty` as well. Sugarscape does its own check for the neighborhood, and for Boltzmann wealth model, agents positions can overlap.
I think we should pick a default in Mesa where there is no `empties` caching as well.

And then there is a 3rd differentiating feature, when the grid is sparsely populated, which means that it is most performant to just pick any random coordinate, and then check if it is empty, then repeat until I find an empty coordinate, and so there is no need to iterate through the entire grid to calculate empty coordinates in `random_empty()`. The grid with `empties` is only performant when the grid is densely populated with many agents.
> And then there is a 3rd differentiating feature, when the grid is sparsely populated, which means that it is most performant to just pick any random coordinate, and then check if it is empty, then repeat until I find an empty coordinate, and so there is no need to iterate through the entire grid to calculate empty coordinates in random_empty(). The grid with empties is only performant when the grid is densely populated with many agents.

That's a pretty smart idea. Even if you're not running sparse grids under the hood, you could get an idea of sparsity via `num_agents/size(space)`. My guess is you could be better off with this method until the check hits `> 0.66-0.75`.
Bump. (Waiting for consensus on which action to take...)
@Libbum made a ~5x speedup in Agents.jl by optimizing `random_empty()`: https://github.com/JuliaDynamics/Agents.jl/pull/541, where the method can also be applied to Mesa's `Grid`, `NetworkGrid`, etc.
This will be a breaking change because of the change in the empty position algorithm.
@rht I like your proposal.

Resolving patch:
diff --git a/mesa/space.py b/mesa/space.py
--- a/mesa/space.py
+++ b/mesa/space.py
@@ -39,6 +39,12 @@
 FloatCoordinate = Union[Tuple[float, float], np.ndarray]
 
 
+def clamp(x: float, lowest: float, highest: float) -> float:
+    # This should be faster than np.clip for a scalar x.
+    # TODO: measure how much faster this function is.
+    return max(lowest, min(x, highest))
+
+
 def accept_tuple_argument(wrapped_function):
     """Decorator to allow grid methods that take a list of (x, y) coord tuples
     to also handle a single position, by automatically wrapping tuple in
@@ -410,12 +416,46 @@ def is_cell_empty(self, pos: Coordinate) -> bool:
         x, y = pos
         return self.grid[x][y] == self.default_val()
 
-    def move_to_empty(self, agent: Agent) -> None:
+    def move_to_empty(
+        self, agent: Agent, cutoff: float = 0.998, num_agents: Optional[int] = None
+    ) -> None:
         """Moves agent to a random empty cell, vacating agent's old cell."""
         pos = agent.pos
         if len(self.empties) == 0:
             raise Exception("ERROR: No empty cells")
-        new_pos = agent.random.choice(sorted(self.empties))
+        if num_agents is None:
+            try:
+                num_agents = agent.model.schedule.get_agent_count()
+            except AttributeError:
+                raise Exception(
+                    "Your agent is not attached to a model, and so Mesa is unable\n"
+                    "to figure out the total number of agents you have created.\n"
+                    "This number is required in order to calculate the threshold\n"
+                    "for using a much faster algorithm to find an empty cell.\n"
+                    "In this case, you must specify `num_agents`."
+                )
+        new_pos = (0, 0)  # Initialize it with a starting value.
+        # This method is based on Agents.jl's random_empty() implementation.
+        # See https://github.com/JuliaDynamics/Agents.jl/pull/541.
+        # For the discussion, see
+        # https://github.com/projectmesa/mesa/issues/1052.
+        # This switch assumes the worst case (for this algorithm) of one
+        # agent per position, which is not true in general but is appropriate
+        # here.
+        if clamp(num_agents / (self.width * self.height), 0.0, 1.0) < cutoff:
+            # The default cutoff value provided is the break-even comparison
+            # with the time taken in the else branching point.
+            # The number is measured to be 0.998 in Agents.jl, but since Mesa
+            # run under different environment, the number is different here.
+            while True:
+                new_pos = (
+                    agent.random.randrange(self.width),
+                    agent.random.randrange(self.height),
+                )
+                if self.is_cell_empty(new_pos):
+                    break
+        else:
+            new_pos = agent.random.choice(sorted(self.empties))
         self._place_agent(new_pos, agent)
         agent.pos = new_pos
         self._remove_agent(pos, agent)


Test patch:
diff --git a/tests/test_grid.py b/tests/test_grid.py
--- a/tests/test_grid.py
+++ b/tests/test_grid.py
@@ -229,6 +229,7 @@ def setUp(self):
                 a = MockAgent(counter, None)
                 self.agents.append(a)
                 self.grid.place_agent(a, (x, y))
+        self.num_agents = len(self.agents)
 
     def test_enforcement(self):
         """
@@ -242,11 +243,14 @@ def test_enforcement(self):
 
         # Place the agent in an empty cell
         self.grid.position_agent(a)
+        self.num_agents += 1
         # Test whether after placing, the empty cells are reduced by 1
         assert a.pos not in self.grid.empties
         assert len(self.grid.empties) == 8
         for i in range(10):
-            self.grid.move_to_empty(a)
+            # Since the agents and the grid are not associated with a model, we
+            # must explicitly tell move_to_empty the number of agents.
+            self.grid.move_to_empty(a, num_agents=self.num_agents)
         assert len(self.grid.empties) == 8
 
         # Place agents until the grid is full
@@ -254,13 +258,14 @@ def test_enforcement(self):
         for i in range(empty_cells):
             a = MockAgent(101 + i, None)
             self.grid.position_agent(a)
+            self.num_agents += 1
         assert len(self.grid.empties) == 0
 
         a = MockAgent(110, None)
         with self.assertRaises(Exception):
             self.grid.position_agent(a)
         with self.assertRaises(Exception):
-            self.move_to_empty(self.agents[0])
+            self.move_to_empty(self.agents[0], num_agents=self.num_agents)
 
 
 # Number of agents at each position for testing