Skip to content

Commit

Permalink
perf: don't compare before/after values in evented dataclass/model wh…
Browse files Browse the repository at this point in the history
…en no signals connected (#235)

* wip

* test: fix test

* docs: update comment

* test: add another test

* feat: do for dataclasses too

* fix: fix pydantic1

* perf: add benchmark

* docs: update docs

* docs: fix indents
  • Loading branch information
tlambert03 authored Sep 18, 2023
1 parent 7caa5de commit 837406e
Show file tree
Hide file tree
Showing 9 changed files with 208 additions and 47 deletions.
3 changes: 3 additions & 0 deletions asv.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"python -m pip install build",
"python -m build --wheel -o {build_cache_dir} {build_dir}"
],
"install_command": [
"python -m pip install {wheel_file}[pydantic]"
],
"matrix": {
"env": {
"HATCH_BUILD_HOOKS_ENABLE": "1"
Expand Down
33 changes: 33 additions & 0 deletions benchmarks/benchmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,36 @@ def time_emit_to_item(self, n: int) -> None:

def time_emit_to_partial(self, n: int) -> None:
self.emitter6.changed.emit(1)


class EventedModelSuite:
params = [10, 100]

def setup(self, n: int) -> None:
try:
from psygnal import EventedModel
except ImportError:
self.model = None
return

class Model(EventedModel):
x: int = 1

self.model = Model

def time_setattr_no_connections(self, n: int) -> None:
if self.model is None:
return

obj = self.model()
for i in range(n):
obj.x = i

def time_setattr_with_connections(self, n: int) -> None:
if self.model is None:
return

obj = self.model()
obj.events.x.connect(callback)
for i in range(n):
obj.x = i
39 changes: 20 additions & 19 deletions docs/dataclasses.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@

A common usage of signals in an application is to notify other parts of the
application when a particular object or value has changed. More often than not,
these values are attributes on some object. Dataclasses are a very common way
these values are attributes on some object. Dataclasses are a very common way
to represent such objects, and `psygnal` provides a convenient way to add the
observer pattern to any dataclass.

## What is a dataclass

A "data class" is a class that is primarily used to store a set of data. Of
course, *most* python data structures (e.g. `tuples`, `dicts`) are used to store
course, _most_ python data structures (e.g. `tuples`, `dicts`) are used to store
a set of data, but when we refer to a "data class" we are generally referring to
a class that formally defines a set of fields or attributes, each with a name,
a current value, and (preferably) a type. The values could represent anything,
a current value, and (preferably) a type. The values could represent anything,
such as a configuration of some sort, a set of parameters, or a set of data
that is being processed.

Expand All @@ -21,7 +21,7 @@ that is being processed.
Python 3.7 introduced [the `dataclasses`
module](https://docs.python.org/3/library/dataclasses.html), which provides a
decorator that can be used to easily create such classes with a minimal amount
of boilerplate. For example:
of boilerplate. For example:

```python
from dataclasses import dataclass
Expand Down Expand Up @@ -94,7 +94,7 @@ def my_callback(age: int):
john.age_changed.connect(my_callback)
```

But there's a lot of boilerplate here. We have to define a signal and
But there's a lot of boilerplate here. We have to define a signal and
create a setter method that emits that signal (for each field!).
This is where `psygnal`'s dataclass support comes in handy.

Expand All @@ -105,8 +105,8 @@ any time a field value is changed, a signal will be emitted. psygnal's
[`SignalGroupDescriptor`][psygnal.SignalGroupDescriptor] does this by:

1. Inspecting the object to determine what the (mutable) fields are (psygnal has
awareness of multiple dataclass libraries, including the standard library's
`dataclasses` module)
awareness of multiple dataclass libraries, including the standard library's
`dataclasses` module)
2. Creating a [`SignalGroup`][psygnal.SignalGroup] with a
[`SignalInstance`][psygnal.SignalInstance] for each field name
3. Adding the `SignalGroup` as a new attribute on the object
Expand All @@ -116,7 +116,7 @@ with the new value as the first argument.

There are two (related) APIs for adding events to dataclasses:

1. Add a [`SignalGroupDescriptor`][psygnal.SignalGroupDescriptor] as a class attribute.
1. Add a [`SignalGroupDescriptor`][psygnal.SignalGroupDescriptor] as a class attribute.

!!! example

Expand Down Expand Up @@ -177,11 +177,11 @@ There are two (related) APIs for adding events to dataclasses:
events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor()
```

2. Decorate the class with the [`@evented` decorator][psygnal.evented].
2. Decorate the class with the [`@evented` decorator][psygnal.evented].

> *Under the hood, this just adds the `SignalGroupDescriptor` as a class
> _Under the hood, this just adds the `SignalGroupDescriptor` as a class
> attribute named "events" for you, as shown above). Prefer the class
> attribute pattern to the decorator when in doubt.*
> attribute pattern to the decorator when in doubt._
!!! example

Expand Down Expand Up @@ -259,7 +259,7 @@ def on_age_changed(age: int):
john.age = 31 # prints: John's age changed to 31.
```

You can also connect to the `SignalGroup` itself to listen to *any*
You can also connect to the `SignalGroup` itself to listen to _any_
changes on the object:

```python
Expand All @@ -278,7 +278,7 @@ If you use the `SignalGroupDescriptor` API, it is easier for type checkers
because you are explicitly providing the `events` namespace for the SignalGroup.

By default, type checkers and IDEs will not know about the signals that are
dynamically added to the class by the `@evented` decorator. If you'd like
dynamically added to the class by the `@evented` decorator. If you'd like
to have your type checker or IDE know about the signals, you can add an
annotation as follows:

Expand Down Expand Up @@ -329,9 +329,10 @@ The following table shows the minimum time it took (on my computer) to set an
attribute on a dataclass, with and without signal emission. (Timed using `timeit`,
with 20 repeats of 100,000 iterations each).

| dataclass | without signals | with signals | penalty (fold slower) |
| --------- | ------- | ------- | ----- |
| `pydantic` | 0.300 µs | 1.860 µs | 6.20 |
| `dataclasses` | 0.023 µs | 1.316 µs | 57.37 |
| `msgspec` | 0.021 µs | 1.545 µs | 72.61 |
| `attrs` | 0.020 µs | 1.531 µs | 76.13 |
| dataclass | without signals | with signals | penalty (fold slower) |
| ------------- | --------------- | ------------ | --------------------- |
| `pydantic v1` | 0.386 µs | 0.902 µs | 2.33 |
| `pydantic v2` | 1.533 µs | 2.145 µs | 1.39 |
| `dataclasses` | 0.015 µs | 0.371 µs | 24.55 |
| `msgspec` | 0.026 µs | 0.561 µs | 21.85 |
| `attrs` | 0.014 µs | 0.540 µs | 37.85 |
29 changes: 22 additions & 7 deletions src/psygnal/_evented_model_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,17 +352,32 @@ def __setattr__(self, name: str, value: Any) -> None:
# fallback to default behavior
return self._super_setattr_(name, value)

# grab current value
# if there are no listeners, we can just set the value without emitting
# so first check if there are any listeners for this field or any of its
# dependent properties.
# note that ALL signals will have at least one listener simply by nature of
# being in the `self._events` SignalGroup.
signal_instance: SignalInstance = getattr(self._events, name)
deps_with_callbacks = {
dep_name
for dep_name in self.__field_dependents__.get(name, ())
if len(getattr(self._events, dep_name)) > 1
}
if (
len(signal_instance) < 2 # the signal itself has no listeners
and not deps_with_callbacks # no dependent properties with listeners
and not len(self._events) # no listeners on the SignalGroup
):
return self._super_setattr_(name, value)

# grab the current value and those of any dependent properties
# so that we can check if they have changed after setting the value
before = getattr(self, name, object())
# if we have any dependent attributes, we need to grab their values
# before we set the new value, so that we can emit events for them
# after the new value is set (only if they have changed).
deps_before: Dict[str, Any] = {
dep: getattr(self, dep) for dep in self.__field_dependents__.get(name, ())
dep: getattr(self, dep) for dep in deps_with_callbacks
}

# set value using original setter
signal_instance: SignalInstance = getattr(self._events, name)
with signal_instance.blocked():
self._super_setattr_(name, value)

Expand All @@ -371,7 +386,7 @@ def __setattr__(self, name: str, value: Any) -> None:
if not _check_field_equality(type(self), name, after, before):
signal_instance.emit(after) # emit event

# also emit events for any dependent computed property setters as well
# also emit events for any dependent attributes that have changed as well
for dep, before_val in deps_before.items():
after_val = getattr(self, dep)
if not _check_field_equality(type(self), dep, after_val, before_val):
Expand Down
29 changes: 22 additions & 7 deletions src/psygnal/_evented_model_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,17 +338,32 @@ def __setattr__(self, name: str, value: Any) -> None:
# fallback to default behavior
return self._super_setattr_(name, value)

# grab current value
# if there are no listeners, we can just set the value without emitting
# so first check if there are any listeners for this field or any of its
# dependent properties.
# note that ALL signals will have sat least one listener simply by nature of
# being in the `self._events` SignalGroup.
signal_instance: SignalInstance = getattr(self._events, name)
deps_with_callbacks = {
dep_name
for dep_name in self.__field_dependents__.get(name, ())
if len(getattr(self._events, dep_name)) > 1
}
if (
len(signal_instance) < 2 # the signal itself has no listeners
and not deps_with_callbacks # no dependent properties with listeners
and not len(self._events) # no listeners on the SignalGroup
):
return self._super_setattr_(name, value)

# grab the current value and those of any dependent properties
# so that we can check if they have changed after setting the value
before = getattr(self, name, object())
# if we have any dependent attributes, we need to grab their values
# before we set the new value, so that we can emit events for them
# after the new value is set (only if they have changed).
deps_before: Dict[str, Any] = {
dep: getattr(self, dep) for dep in self.__field_dependents__.get(name, ())
dep: getattr(self, dep) for dep in deps_with_callbacks
}

# set value using original setter
signal_instance: SignalInstance = getattr(self._events, name)
with signal_instance.blocked():
self._super_setattr_(name, value)

Expand All @@ -357,7 +372,7 @@ def __setattr__(self, name: str, value: Any) -> None:
if not _check_field_equality(type(self), name, after, before):
signal_instance.emit(after) # emit event

# also emit events for any dependent attributes as well
# also emit events for any dependent attributes that have changed as well
for dep, before_val in deps_before.items():
after_val = getattr(self, dep)
if not _check_field_equality(type(self), dep, after_val, before_val):
Expand Down
2 changes: 1 addition & 1 deletion src/psygnal/_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ def disconnect(self, slot: Callable | None = None, missing_ok: bool = True) -> N

def __repr__(self) -> str:
"""Return repr(self)."""
name = f" {self.name!r}" if self.name else ""
name = f" {self._name!r}" if self._name else ""
instance = f" on {self.instance!r}" if self.instance else ""
nsignals = len(self.signals)
signals = f"{nsignals} signals" if nsignals > 1 else ""
Expand Down
7 changes: 4 additions & 3 deletions src/psygnal/_group_descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,9 +247,10 @@ def _setattr_and_emit_(self: object, name: str, value: Any) -> None:
if name == signal_group_name:
return super_setattr(self, name, value)

group = getattr(self, signal_group_name, None)
signal = cast("SignalInstance | None", getattr(group, name, None))
if signal is None:
group: SignalGroup | None = getattr(self, signal_group_name, None)
signal: SignalInstance | None = getattr(group, name, None)
# don't emit if the signal doesn't exist or has no listeners
if group is None or signal is None or len(signal) < 2 and not len(group):
return super_setattr(self, name, value)

with _changes_emitted(self, name, signal):
Expand Down
2 changes: 1 addition & 1 deletion src/psygnal/_signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ def name(self) -> str:

def __repr__(self) -> str:
"""Return repr."""
name = f" {self.name!r}" if self.name else ""
name = f" {self._name!r}" if self._name else ""
instance = f" on {self.instance!r}" if self.instance is not None else ""
return f"<{type(self).__name__}{name}{instance}>"

Expand Down
Loading

0 comments on commit 837406e

Please sign in to comment.