Skip to content

Commit c891e2b

Browse files
Optimize traverse method (#142)
* Add test that checks that having numpy values in reactive container doesn't throw an error * Use a complementary set for checking ids * Add benchmark that targets the traverse method * Adjust benchmark flow to checkout code from master and compare directly with run from branch
1 parent fac5c71 commit c891e2b

5 files changed

Lines changed: 207 additions & 61 deletions

File tree

.github/workflows/benchmark.yml

Lines changed: 22 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
name: Benchmarks
22

33
on:
4-
push:
5-
branches:
6-
- master
74
pull_request:
85
branches:
96
- master
@@ -21,58 +18,39 @@ jobs:
2118
- name: Set up Python 3.14
2219
uses: actions/setup-python@v5
2320
with:
24-
python-version: '3.14'
21+
python-version: "3.14"
2522

2623
- name: Install dependencies
2724
run: uv sync
2825

29-
# Restore benchmark baseline (read-only for PRs)
30-
- name: Restore benchmark baseline
31-
uses: actions/cache/restore@v4
32-
with:
33-
path: .benchmarks
34-
key: benchmark-baseline-3.14-${{ runner.os }}-${{ github.run_number }}
35-
restore-keys: |
36-
benchmark-baseline-3.14-${{ runner.os }}-
37-
38-
# On master: save baseline results
39-
- name: Run benchmarks and save new baseline
40-
if: github.ref == 'refs/heads/master'
41-
continue-on-error: true
26+
# On PRs: run benchmarks twice (PR code vs master code) and compare
27+
- name: Run benchmarks
4228
run: |
43-
uv run --no-sync pytest bench \
44-
--benchmark-only \
45-
--benchmark-autosave \
46-
--benchmark-sort=mean
47-
48-
LAST_BENCHMARK=$(uv run pytest-benchmark list | tail -1)
49-
cp $LAST_BENCHMARK benchmark.json
50-
# Clear out all benchmarks
51-
rm -f .benchmarks/*/*.json
52-
# Restore just the last benchmark
53-
cp benchmark.json $LAST_BENCHMARK
29+
# Checkout master version of observ directory
30+
git fetch origin master
31+
git checkout origin/master -- observ/
5432
55-
# On master: cache the new baseline results
56-
- name: Save benchmark baseline
57-
if: github.ref == 'refs/heads/master'
58-
uses: actions/cache/save@v4
59-
with:
60-
path: .benchmarks
61-
key: benchmark-baseline-3.14-${{ runner.os }}-${{ github.run_number }}
33+
# Run benchmarks with master code as baseline
34+
uv run --no-sync pytest bench \
35+
--benchmark-only \
36+
--benchmark-save=master \
37+
--benchmark-sort=mean || true
6238
63-
# On PRs: compare against baseline and fail if degraded
64-
- name: Run benchmarks and compare
65-
if: github.event_name == 'pull_request'
66-
run: |
67-
if [ -z "$(uv run --no-sync pytest-benchmark list)" ]; then
68-
echo "No baseline found, not comparing"
69-
uv run --no-sync pytest -v bench
70-
exit
71-
fi
39+
# Restore PR code
40+
git checkout HEAD -- observ/
7241
42+
# Run benchmarks on PR code and compare
7343
uv run --no-sync pytest bench \
7444
--benchmark-only \
7545
--benchmark-compare \
7646
--benchmark-compare-fail=mean:5% \
47+
--benchmark-save=branch \
7748
--benchmark-sort=mean
7849
50+
- name: Upload benchmarks
51+
if: always()
52+
uses: actions/upload-artifact@v4
53+
with:
54+
name: Benchmarks
55+
path: .benchmarks/
56+
include-hidden-files: true

bench/test_traverse.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""
2+
Benchmarks for different traverse implementations.
3+
4+
These benchmarks test various optimization strategies for the traverse function
5+
which is used for deep watching of reactive data structures.
6+
"""
7+
8+
import pytest
9+
10+
from observ import reactive
11+
from observ.watcher import traverse
12+
13+
14+
def create_shallow_structure(size=100):
15+
"""Create a flat dictionary with many keys."""
16+
data = {f"key_{i}": f"value_{i}" for i in range(size)}
17+
return reactive(data)
18+
19+
20+
def create_deep_structure(depth=100):
21+
"""Create a deeply nested dictionary."""
22+
obj = {"value": "leaf"}
23+
for i in range(depth):
24+
obj = {"level": i, "child": obj}
25+
return reactive(obj)
26+
27+
28+
def create_wide_structure(breadth=10, depth=3):
29+
"""Create a wide tree structure."""
30+
31+
def make_tree(current_depth):
32+
if current_depth >= depth:
33+
return {"leaf": True}
34+
return {f"child_{i}": make_tree(current_depth + 1) for i in range(breadth)}
35+
36+
data = make_tree(0)
37+
return reactive(data)
38+
39+
40+
def create_list_structure(size=100):
41+
"""Create a list with nested lists."""
42+
data = [{"index": i, "data": [i, i + 1, i + 2]} for i in range(size)]
43+
return reactive(data)
44+
45+
46+
def create_cyclic_structure(use_reactive=False):
47+
"""Create a structure with cycles."""
48+
obj = {"name": "root"}
49+
child = {"name": "child", "parent": obj}
50+
obj["child"] = child
51+
obj["self"] = obj
52+
return reactive(obj)
53+
54+
55+
def create_mixed_structure(size=50):
56+
"""Create a structure mixing dicts, lists, and sets."""
57+
data = {
58+
"dict": {f"k{i}": i for i in range(size)},
59+
"list": [i for i in range(size)],
60+
"nested": [{"index": i, "values": [i * 2, i * 3]} for i in range(size // 5)],
61+
"set": {i for i in range(size)},
62+
}
63+
return reactive(data)
64+
65+
66+
def create_large_flat_list(size=1000):
67+
"""Create a large flat list."""
68+
data = list(range(size))
69+
return reactive(data)
70+
71+
72+
def create_matrix_structure(rows=50, cols=50):
73+
"""Create a matrix-like structure (list of lists)."""
74+
data = [[i * cols + j for j in range(cols)] for i in range(rows)]
75+
return reactive(data)
76+
77+
78+
# Parametrize all tests with different implementations
79+
@pytest.mark.benchmark(group="traverse_shallow")
80+
def test_traverse_shallow(benchmark):
81+
"""Benchmark traverse on shallow structure (many keys at one level)
82+
with reactive proxies."""
83+
structure = create_shallow_structure(size=100)
84+
benchmark(traverse, structure)
85+
86+
87+
@pytest.mark.benchmark(group="traverse_deep")
88+
def test_traverse_deep(benchmark):
89+
"""Benchmark traverse on deep structure (linear chain) with reactive proxies."""
90+
structure = create_deep_structure(depth=100)
91+
benchmark(traverse, structure)
92+
93+
94+
@pytest.mark.benchmark(group="traverse_wide")
95+
def test_traverse_wide(benchmark):
96+
"""Benchmark traverse on wide tree structure with reactive proxies."""
97+
structure = create_wide_structure(breadth=10, depth=3)
98+
benchmark(traverse, structure)
99+
100+
101+
@pytest.mark.benchmark(group="traverse_list")
102+
def test_traverse_list(benchmark):
103+
"""Benchmark traverse on list structure with reactive proxies."""
104+
structure = create_list_structure(size=100)
105+
benchmark(traverse, structure)
106+
107+
108+
@pytest.mark.benchmark(group="traverse_cyclic")
109+
def test_traverse_cyclic(benchmark):
110+
"""Benchmark traverse on cyclic structure with reactive proxies."""
111+
structure = create_cyclic_structure(use_reactive=True)
112+
benchmark(traverse, structure)
113+
114+
115+
@pytest.mark.benchmark(group="traverse_mixed")
116+
def test_traverse_mixed(benchmark):
117+
"""Benchmark traverse on mixed structure with reactive proxies."""
118+
structure = create_mixed_structure(size=50)
119+
benchmark(traverse, structure)
120+
121+
122+
@pytest.mark.benchmark(group="traverse_large_flat")
123+
def test_traverse_large_flat_list(benchmark):
124+
"""Benchmark traverse on large flat list with reactive proxies."""
125+
structure = create_large_flat_list(size=1000)
126+
benchmark(traverse, structure)
127+
128+
129+
@pytest.mark.benchmark(group="traverse_matrix")
130+
def test_traverse_matrix(benchmark):
131+
"""Benchmark traverse on matrix structure (list of lists) with reactive proxies."""
132+
structure = create_matrix_structure(rows=50, cols=50)
133+
benchmark(traverse, structure)
134+
135+
136+
# Stress test with very large structures using reactive proxies
137+
@pytest.mark.benchmark(group="traverse_stress")
138+
def test_traverse_stress_large_shallow(benchmark):
139+
"""Stress test with large shallow structure using reactive proxies."""
140+
structure = create_shallow_structure(size=1000)
141+
benchmark(traverse, structure)
142+
143+
144+
@pytest.mark.benchmark(group="traverse_stress")
145+
def test_traverse_stress_large_mixed(benchmark):
146+
"""Stress test with large mixed structure using reactive proxies."""
147+
structure = create_mixed_structure(size=200)
148+
benchmark(traverse, structure)

observ/watcher.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,6 @@
1414
from typing import Any, Callable, Generic, Optional, TypeVar, Union
1515
from weakref import WeakSet, ref
1616

17-
try:
18-
import numpy as np
19-
20-
has_numpy = True
21-
except ImportError:
22-
has_numpy = False
23-
2417
from .dep import Dep
2518
from .dict_proxy import DictProxyBase
2619
from .list_proxy import ListProxyBase
@@ -80,7 +73,7 @@ def getter():
8073
return decorator_computed(_fn)
8174

8275

83-
def traverse(obj, seen=None):
76+
def traverse(obj, seen=None, seen_ids=None):
8477
"""
8578
Recursively traverse the whole tree to make sure
8679
that all values have been 'get'
@@ -98,20 +91,26 @@ def traverse(obj, seen=None):
9891
else:
9992
return
10093

94+
if seen is None or seen_ids is None:
95+
seen = []
96+
seen_ids = set()
97+
98+
obj_id = id(obj)
99+
if obj_id in seen_ids:
100+
return
101+
101102
# track which objects we have already seen to support(!) full traversal
102103
# of datastructures with cycles
103-
# NOTE: a set would provide faster containment checks
104+
# NOTE: the set is used to provide faster containment checks
105+
# and the list is used to make sure that ids are not being reused
106+
seen.append(obj)
107+
seen_ids.add(obj_id)
108+
104109
# but these objects are not hashable (except for tuple) because they are mutable
105110
# converting everything to a hashable thing (because nothing is mutated during
106111
# traversal) is an option but way more expensive than just using a list
107-
if seen is None:
108-
seen = []
109-
seen.append(obj)
110112
for v in val_iter:
111-
if has_numpy and isinstance(v, (np.ndarray, np.generic)):
112-
continue
113-
if v not in seen:
114-
traverse(v, seen=seen)
113+
traverse(v, seen=seen, seen_ids=seen_ids)
115114

116115

117116
# Every Watcher gets a unique ID which is used to

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,4 @@ select = [
5959

6060
[tool.pytest.ini_options]
6161
addopts = "--benchmark-columns='mean, stddev, rounds'"
62-
timeout = 3
62+
# timeout = 3

tests/test_usage.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88
from observ.proxy import Proxy
99
from observ.watcher import WrongNumberOfArgumentsError
1010

11+
try:
12+
import numpy as np
13+
14+
has_numpy = True
15+
except ImportError:
16+
has_numpy = False
17+
1118

1219
def test_usage_dict():
1320
a = reactive({"foo": "bar"})
@@ -753,6 +760,20 @@ def __ne__(self, other):
753760
a["foo"] = Foo(3)
754761

755762

763+
@pytest.mark.skipif(not has_numpy, reason="numpy is not installed")
764+
def test_use_skips_numpy():
765+
cb = Mock()
766+
a = reactive({"foo": np.array([1, 0, 0])})
767+
768+
# Check that watching a structure with numpy arrays
769+
# doesn't raise a ValueError
770+
watcher = watch(a, cb, deep=True, sync=True) # noqa: F841
771+
772+
a["foo"][1] = 1
773+
774+
assert cb.call_count == 0
775+
776+
756777
def test_watch_reactive_object():
757778
a = reactive({"foo": "foo"})
758779
cb = Mock()

0 commit comments

Comments
 (0)