Skip to content

Commit bff6b63

Browse files
authored
fix: Weakly reference callables for pub-sub model (#1035)
* Fix memory leaks seen when creating many objects that use pub-sub events API * Store weaker references to bound methods/callables in the events API
1 parent edbccc2 commit bff6b63

File tree

4 files changed

+46
-25
lines changed

4 files changed

+46
-25
lines changed

src/pyhf/events.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import weakref
2+
13
__events = {}
24
__disabled_events = set([])
35

@@ -6,10 +8,16 @@ def noop(*args, **kwargs):
68
pass
79

810

9-
class Callables(list):
11+
class WeakList(list):
12+
def append(self, item):
13+
list.append(self, weakref.WeakMethod(item, self.remove))
14+
15+
16+
class Callables(WeakList):
1017
def __call__(self, *args, **kwargs):
11-
for f in self:
12-
f(*args, **kwargs)
18+
for func in self:
19+
# weakref: needs to be de-ref'd first before calling
20+
func()(*args, **kwargs)
1321

1422
def __repr__(self):
1523
return "Callables(%s)" % list.__repr__(self)

tests/test_events.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,43 @@ def test_subscribe_event():
66
ename = 'test'
77

88
m = mock.Mock()
9-
events.subscribe(ename)(m)
9+
events.subscribe(ename)(m.__call__)
1010

1111
assert ename in events.__events
12-
assert m in events.__events.get(ename)
12+
assert m.__call__ == events.__events.get(ename)[0]()
1313
del events.__events[ename]
1414

1515

1616
def test_event():
1717
ename = 'test'
1818

1919
m = mock.Mock()
20-
events.subscribe(ename)(m)
20+
events.subscribe(ename)(m.__call__)
2121

2222
events.trigger(ename)()
2323
m.assert_called_once()
2424
del events.__events[ename]
2525

2626

27+
def test_event_weakref():
28+
ename = 'test'
29+
30+
m = mock.Mock()
31+
events.subscribe(ename)(m.__call__)
32+
assert len(events.trigger(ename)) == 1
33+
# should be weakly referenced
34+
del m
35+
assert len(events.trigger(ename)) == 0
36+
del events.__events[ename]
37+
38+
2739
def test_disable_event():
2840
ename = 'test'
2941

3042
m = mock.Mock()
3143
noop, noop_m = events.noop, mock.Mock()
3244
events.noop = noop_m
33-
events.subscribe(ename)(m)
45+
events.subscribe(ename)(m.__call__)
3446

3547
events.disable(ename)
3648
assert m.called is False

tests/test_interpolate.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import pyhf
22
import numpy as np
33
import pytest
4-
import mock
54

65

76
@pytest.fixture
@@ -66,25 +65,27 @@ def test_interpolator_structure(interpcode, random_histosets_alphasets_pair):
6665
)
6766

6867

69-
def test_interpolator_subscription(interpcode, random_histosets_alphasets_pair):
68+
def test_interpolator_subscription(mocker, interpcode, random_histosets_alphasets_pair):
7069
histogramssets, alphasets = random_histosets_alphasets_pair
7170
ename = 'tensorlib_changed'
7271

73-
# inject into our interpolator class
7472
interpolator_cls = pyhf.interpolators.get(interpcode)
75-
with mock.patch('{0:s}._precompute'.format(interpolator_cls.__module__)) as m:
76-
interpolator_cls(histogramssets.tolist(), subscribe=False)
77-
assert m.call_count == 1
78-
assert m not in pyhf.events.__events.get(ename, [])
79-
pyhf.events.trigger(ename)()
80-
assert m.call_count == 1
81-
82-
with mock.patch('{0:s}._precompute'.format(interpolator_cls.__module__)) as m:
83-
interpolator_cls(histogramssets.tolist(), subscribe=True)
84-
assert m.call_count == 1
85-
assert m in pyhf.events.__events.get(ename, [])
86-
pyhf.events.trigger(ename)()
87-
assert m.call_count == 2
73+
spy = mocker.spy(interpolator_cls, '_precompute')
74+
75+
interpolator_cls(histogramssets.tolist(), subscribe=False)
76+
assert spy.call_count == 1
77+
assert len(pyhf.events.__events.get(ename, [])) == 0
78+
pyhf.events.trigger(ename)()
79+
assert spy.call_count == 1
80+
81+
# reset and go again
82+
spy.reset_mock()
83+
84+
interpolator_cls(histogramssets.tolist(), subscribe=True)
85+
assert spy.call_count == 1
86+
assert len(pyhf.events.__events.get(ename, [])) == 1
87+
pyhf.events.trigger(ename)()
88+
assert spy.call_count == 2
8889

8990

9091
def test_interpolator_alphaset_change(

tests/test_tensor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ def test_trigger_tensorlib_changed_name(mocker):
429429
pyhf.set_backend(numpy_64)
430430

431431
func = mocker.Mock()
432-
pyhf.events.subscribe('tensorlib_changed')(func)
432+
pyhf.events.subscribe('tensorlib_changed')(func.__call__)
433433

434434
assert func.call_count == 0
435435
pyhf.set_backend(jax_64)
@@ -443,7 +443,7 @@ def test_trigger_tensorlib_changed_precision(mocker):
443443
pyhf.set_backend(jax_64)
444444

445445
func = mocker.Mock()
446-
pyhf.events.subscribe('tensorlib_changed')(func)
446+
pyhf.events.subscribe('tensorlib_changed')(func.__call__)
447447

448448
assert func.call_count == 0
449449
pyhf.set_backend(jax_32)

0 commit comments

Comments
 (0)