Skip to content

Commit b64796b

Browse files
Use plain sets for watcher dep bookkeeping
Watcher._deps and Watcher._new_deps were WeakSets. WeakSet is implemented in pure Python, so the two membership checks and the add in Watcher.add_dep -- which run for every dep touched on every watcher evaluation -- each cost a Python function call, as does iterating in cleanup_deps. Profiling showed this bookkeeping at 32-40% of runtime in watcher-heavy workloads. Plain sets do the same operations in C. Strong references to deps are safe here: - Dep._subs remains a WeakSet, so there are no reference cycles and watcher lifecycle is unchanged: deleting a watcher still unsubscribes it from its deps. - A dep whose container has been garbage collected is inert (nothing can notify it anymore) and is released on the watcher's next re-evaluation (cleanup_deps), on deactivation, or when the watcher itself is collected. Until then it keeps only a small Dep object alive, not the container. Benchmarks (local, macOS, vs master): - deep_watch_mutate_leaf: 1.33-1.49x faster - notify_watchers: 1.49-1.61x faster - read_dict_getitem_tracked: 1.58x faster - untracked reads and plain baselines: unchanged Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 88841a4 commit b64796b

2 files changed

Lines changed: 40 additions & 3 deletions

File tree

observ/watcher.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from functools import partial, wraps
1414
from itertools import count
1515
from typing import Any, Callable, Generic, Optional, TypeVar, Union
16-
from weakref import WeakSet, ref
16+
from weakref import ref
1717

1818
from .dep import Dep
1919
from .proxy import Proxy, proxy
@@ -219,7 +219,13 @@ def __init__(
219219
# or a list of proxies
220220
if deep is None:
221221
deep = True
222-
self._deps, self._new_deps = WeakSet(), WeakSet()
222+
# Plain sets: WeakSet operations are implemented in Python and
223+
# dominate the cost of re-collecting deps on every evaluation.
224+
# Strong references are safe here: deps don't reference watchers
225+
# strongly (Dep._subs is a WeakSet), and a dep whose container
226+
# was garbage collected is dropped on the next cleanup_deps()
227+
# or when the watcher is deactivated or collected.
228+
self._deps, self._new_deps = set(), set()
223229
self._tasks = set()
224230

225231
self.sync = sync

tests/test_deps.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from observ import computed, reactive
1+
from unittest.mock import Mock
2+
3+
from observ import computed, reactive, watch
24
from observ.proxy import proxy_db
35

46

@@ -53,3 +55,32 @@ def prop():
5355

5456
state.clear()
5557
assert len(proxy_db.attrs(state)["keydep"]) == 3
58+
59+
60+
def test_deps_released_after_reevaluation():
61+
# Watchers hold strong references to their deps, so check that
62+
# deps of containers that the watched expression no longer visits
63+
# are released when the watcher re-evaluates
64+
state = reactive({"foo": {"bar": 5}})
65+
watcher = watch(lambda: state, Mock(), sync=True, deep=True)
66+
67+
# The deep watcher depends on the outer and the nested container
68+
assert len(watcher._deps) == 2
69+
70+
del state["foo"]
71+
72+
# The sync watcher re-evaluated and dropped
73+
# the dep of the nested container
74+
assert len(watcher._deps) == 1
75+
76+
77+
def test_deps_released_on_deactivation():
78+
state = reactive({"foo": 5})
79+
watcher = watch(lambda: state["foo"], Mock(), sync=True)
80+
81+
assert len(watcher._deps) > 0
82+
83+
# Deactivating the watcher releases its deps
84+
watcher()
85+
86+
assert len(watcher._deps) == 0

0 commit comments

Comments
 (0)