Skip to content

Optimise mesa_signals by skipping signals for empty subscribers to reduce subsequent overheads#3198

Merged
quaquel merged 5 commits intomesa:mainfrom
codebreaker32:optimise/mesa_signals
Jan 24, 2026
Merged

Optimise mesa_signals by skipping signals for empty subscribers to reduce subsequent overheads#3198
quaquel merged 5 commits intomesa:mainfrom
codebreaker32:optimise/mesa_signals

Conversation

@codebreaker32
Copy link
Copy Markdown
Collaborator

@codebreaker32 codebreaker32 commented Jan 23, 2026

Summary

This PR optimizes the mesa_signal module by flattening the internal subscriber storage and introducing a "fast path" that bypasses the signal emission pipeline for Observable properties with no active subscribers.

Motive

When an agent updates an observable (e.g., agent.wealth = 10), the following sequence currently occurs regardless of subscriber status:

  1. Lookup overhead: getattr(self, _wealth) is called to fetch the old_value. This is wasteful if no one is listening.
  2. Allocation overhead: The notify() method creates a new Message object. This allocates memory for a signal payload that will immediately be discarded.
  3. Function Call Overhead: self.notify(...) and self._mesa_notify(message) are called.
  4. The Check: Finally, inside _mesa_notify, the system checks the dictionary.

See #3131 (comment) also.

Implementation

The optimization relies on flattening the data structure to make the subscriber check as cheap as possible

  1. We changed the internal subscribers structure from a nested dictionary (dict[str, dict[str, list]]) to a flat dictionary using tuple keys (dict[tuple[str, str], list]). This reduces pointer chasing and simplifies the lookup logic.

  2. Added a helper method _has_subscribers that utilizes the new flat structure to check for listeners in a single step:

def _has_subscribers(self, name: str, signal_type: str | SignalType) -> bool:
    # Single tuple key lookup
    key = (name, signal_type)
    if key not in self.subscribers:
        return False
    return len(self.subscribers[key]) > 0
  1. Updated BaseObservable.__set__ to check _has_subscribers immediately. If it returns False, the method exits early, skipping getattr, Message allocation, and notification calls.
def __set__(self, instance: HasObservables, value):
    # Early exit if no one is listening
    if not instance._has_subscribers(self.public_name, SignalType.CHANGE):
        return

    instance.notify(
        self.public_name,
        getattr(instance, self.private_name, self.fallback_value),
        value,
        SignalType.CHANGE,
    )

@codebreaker32 codebreaker32 changed the title Optimise <code>mesa_signals</code> by skipping signals for empty subscribers to reduce subsequent overheads Optimise mesa_signals by skipping signals for empty subscribers to reduce subsequent overheads Jan 23, 2026
@github-actions
Copy link
Copy Markdown

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
BoltzmannWealth small 🔵 -1.2% [-1.8%, -0.5%] 🔵 -1.2% [-1.3%, -1.0%]
BoltzmannWealth large 🔵 +5.5% [-4.4%, +16.1%] 🔵 -3.2% [-7.0%, +1.0%]
Schelling small 🔵 -1.5% [-1.9%, -1.0%] 🔵 -0.8% [-1.0%, -0.6%]
Schelling large 🔵 -1.1% [-5.2%, +2.6%] 🔵 -0.4% [-2.4%, +1.5%]
WolfSheep small 🔵 +3.1% [+0.2%, +6.5%] 🔵 -0.2% [-0.6%, +0.2%]
WolfSheep large 🔵 +5.8% [-9.1%, +20.0%] 🔵 +1.7% [+0.3%, +3.4%]
BoidFlockers small 🔵 +0.4% [-0.2%, +1.0%] 🔵 +0.4% [+0.3%, +0.6%]
BoidFlockers large 🔵 -2.8% [-5.0%, -1.0%] 🔵 -0.2% [-0.4%, +0.1%]

@codebreaker32 codebreaker32 requested a review from quaquel January 23, 2026 19:59
def _has_subscribers(self, name: str, signal_type: str | SignalType) -> bool:
"""Check if there are any subscribers for a given observable and signal type."""
key = (name, signal_type)
if key not in self.subscribers:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are using a defaultdict, so this check is not needed. We allways get a list back

Copy link
Copy Markdown
Collaborator Author

@codebreaker32 codebreaker32 Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But If I removed the check and just write if len(self.subscribers[key]) > 0, the defaultdict will automatically insert an empty list [] for every single property(mutate the subscriber) we'll ever check. That will result in unnecessary memory bloat if we'll have large number of keys

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a micro optimization. I doubt it matters in everyday use, where keys probably are. quite small. But fine with me to do it this way.

@quaquel
Copy link
Copy Markdown
Member

quaquel commented Jan 23, 2026

I think this is basically ready to go.

@quaquel quaquel added experimental Release notes label performance Release notes label labels Jan 24, 2026
@quaquel quaquel merged commit c71d2d5 into mesa:main Jan 24, 2026
16 checks passed
@codebreaker32 codebreaker32 deleted the optimise/mesa_signals branch February 17, 2026 16:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

experimental Release notes label performance Release notes label

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants