Skip to content

Commit 05f4dd0

Browse files
committed
[PyROOT] Set memory policy to "strict"
PyROOT has many memory leaks, which is a major pain point for people using it for long-running scripts in batch jobs. One source of memory leaks was indentified to be the "heuristic memory policy" of PyROOT. This means that PyROOT assumes that every non-const pointer member function argument was interpreted as the object taking ownership if the argument. For examle, take the non-owning RooLinkedList container. It has a `RooLinkedList::Add(RooAbsArg *arg)` method. PyROOT wrongly assumes that this means the RooLinkedList takes ownership of arg, and it drops the PyROOT overship. Nobody feels responsible for deleting the object anymore, and there is a memory leak or `arg`. That particular leak was reported in this forum post: https://root-forum.cern.ch/t/memory-leak-in-fits/56249 Function parameters of type `T *` are very common in ROOT, and only rarely do they imply ownership transfer. So changing the memory policy to "strict" would surely fix also many other memory leaks that are not reported so far. In fact, upstream cppyy doesn't even have this heuristic memory policy anymore! So moving PyROOT also to the strict memory policy closes the gap between PyROOT and cppyy. The potential drawback of this change are crashes in usercode if memory is not properly managed. But these problems should either be fixed by: * the user * dedicated pythonizations for these methods to manage shared ownership via Python reference counters (i.e., setting the parameter as an attribute of the object that the member function was called on) This follows up on PR #4294, in particular it reverts 3a12063.
1 parent 727e162 commit 05f4dd0

File tree

9 files changed

+123
-17
lines changed

9 files changed

+123
-17
lines changed

README/ReleaseNotes/v636/index.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,60 @@ The following people have contributed to this new version:
4646

4747
## Python Interface
4848

49+
### Changed ownership policy for non-`const` pointer member function parameters
50+
51+
If you have a member function taking a raw pointer, like `MyClass::foo(T *obj)`,
52+
PyROOT was so far assuming that calling this method on `my_instance`
53+
transfers the ownership of `obj` to `my_instance`.
54+
55+
However, this resulted in many memory leaks, since many functions with such a
56+
signature actually don't take ownership of the object.
57+
58+
To avoid such memory leaks, PyROOT now doesn't make this guess anymore as of
59+
ROOT 6.32. Because of this change, some double-deletes or dangling references
60+
might creep up in your scripts. These need to be fixedby properly managing
61+
object lifetime with Python references.
62+
63+
You can fix the dangling references problem for example via:
64+
65+
1. Assigning the object to a python variable
66+
2. Creating an owning collection that keeps the objects alive
67+
3. Writing a pythonization for the member function that does the ownership
68+
transfer if needed
69+
70+
The double-delete problems can be fixed via:
71+
72+
1. Drop the ownership on the Python side with `ROOT.SetOwnership(obj, False)`
73+
3. Writing a pythonization for the member function that drops the ownership on the Python side as above
74+
75+
This affects for example the `TList::Add(TObject *obj)` member function, which
76+
will not transfer ownership from PyROOT to the TList anymore. The new policy
77+
fixes a memory leak, but at the same time it is not possible anymore to create
78+
the contained elements in place:
79+
80+
```python
81+
# A TList is by default a non-owning container
82+
my_list = ROOT.TList()
83+
84+
85+
# This is working, but resulted in memory leak prior to ROOT 6.32:
86+
obj_1 = ROOT.TObjString("obj_1")
87+
my_list.Add(obj_1)
88+
89+
90+
# This is not allowed anymore, as the temporary would be
91+
# deleted immediately leaving a dangling pointer:
92+
my_list.Add(ROOT.TObjString("obj_2"))
93+
94+
# Python reference count to contained object is now zero,
95+
# TList contains dangling pointer!
96+
```
97+
98+
**Note:** You can change back to the old policy by calling
99+
`ROOT.SetMemoryPolicy(ROOT.kMemoryHeuristics)` after importing ROOT, but this
100+
should be only used for debugging purposes and this function might be removed
101+
in the future!
102+
49103
## RDataFrame
50104

51105
## RooFit

bindings/pyroot/pythonizations/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ if(roofit)
4040
ROOT/_pythonization/_roofit/_roomcstudy.py
4141
ROOT/_pythonization/_roofit/_roomsgservice.py
4242
ROOT/_pythonization/_roofit/_roonllvar.py
43+
ROOT/_pythonization/_roofit/_rooplot.py
4344
ROOT/_pythonization/_roofit/_rooprodpdf.py
4445
ROOT/_pythonization/_roofit/_roorealvar.py
4546
ROOT/_pythonization/_roofit/_roosimultaneous.py

bindings/pyroot/pythonizations/python/ROOT/_facade.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,11 +195,6 @@ def _finalSetup(self):
195195
if not self.gROOT.IsBatch() and self.PyConfig.StartGUIThread:
196196
self.app.init_graphics()
197197

198-
# Set memory policy to kUseHeuristics.
199-
# This restores the default in PyROOT which was changed
200-
# by new Cppyy
201-
self.SetMemoryPolicy(self.kMemoryHeuristics)
202-
203198
# Redirect lookups to cppyy's global namespace
204199
self.__class__.__getattr__ = self._fallback_getattr
205200
self.__class__.__setattr__ = lambda self, name, val: setattr(cppyy.gbl, name, val)

bindings/pyroot/pythonizations/python/ROOT/_pythonization/_roofit/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from ._roomcstudy import RooMCStudy
4747
from ._roomsgservice import RooMsgService
4848
from ._roonllvar import RooNLLVar
49+
from ._rooplot import RooPlot
4950
from ._rooprodpdf import RooProdPdf
5051
from ._roorealvar import RooRealVar
5152
from ._roosimultaneous import RooSimultaneous
@@ -78,6 +79,7 @@
7879
RooMCStudy,
7980
RooMsgService,
8081
RooNLLVar,
82+
RooPlot,
8183
RooProdPdf,
8284
RooRealVar,
8385
RooSimultaneous,

bindings/pyroot/pythonizations/python/ROOT/_pythonization/_roofit/_rooabspdf.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,19 @@ def _pack_cmd_args(*args, **kwargs):
3131
assert len(kwargs) == 0
3232

3333
# Put RooCmdArgs in a RooLinkedList
34-
cmdList = ROOT.RooLinkedList()
34+
cmd_list = ROOT.RooLinkedList()
3535
for cmd in args:
3636
if not isinstance(cmd, ROOT.RooCmdArg):
3737
raise TypeError("This function only takes RooFit command arguments.")
38-
cmdList.Add(cmd)
38+
cmd_list.Add(cmd)
3939

40-
return cmdList
40+
# The RooLinkedList passed to functions like fitTo() is expected to be
41+
# non-owning. To make sure that the RooCmdArgs live long enough, we attach
42+
# then as an attribute of the output list, such that the Python reference
43+
# counter doesn't hit zero.
44+
cmd_list.owning_pylist = args
45+
46+
return cmd_list
4147

4248

4349
class RooAbsPdf(RooAbsReal):
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Authors:
2+
# * Jonas Rembser 09/2023
3+
4+
################################################################################
5+
# Copyright (C) 1995-2020, Rene Brun and Fons Rademakers. #
6+
# All rights reserved. #
7+
# #
8+
# For the licensing terms see $ROOTSYS/LICENSE. #
9+
# For the list of contributors see $ROOTSYS/README/CREDITS. #
10+
################################################################################
11+
12+
13+
class RooPlot(object):
14+
def addObject(self, obj, *args, **kwargs):
15+
import ROOT
16+
17+
# PyROOT transfers the ownership to the RooPlot.
18+
ROOT.SetOwnership(obj, False)
19+
return self._addObject(obj, *args, **kwargs)

bindings/pyroot/pythonizations/test/tcollection_listmethods.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@ class TCollectionListMethods(unittest.TestCase):
1414
# Helpers
1515
def create_tcollection(self):
1616
c = ROOT.TList()
17+
pylist = []
1718
for _ in range(self.num_elems):
1819
o = ROOT.TObject()
1920
# Prevent immediate deletion of C++ TObjects
2021
ROOT.SetOwnership(o, False)
2122
c.Add(o)
23+
pylist.append(o)
2224

25+
# To prevent memory leaks
26+
c._owned_objects = pylist
2327
return c
2428

2529
# Tests
@@ -69,6 +73,14 @@ def test_remove(self):
6973
with self.assertRaises(ValueError):
7074
c.remove(o1)
7175

76+
# The TList destructor will call TList::Clear(), which triggers a
77+
# ROOT-internal garbage collection routine implemented in
78+
# TCollection::GarbageCollect(). This can segfault if Python already
79+
# garbage collected the objects in the TList itself, because then the
80+
# TList has dangling pointers. To avoid this, we call Clear() now,
81+
# before the reference count of the objects in the list goes to zero.
82+
c.Clear()
83+
7284
def test_extend(self):
7385
c1 = self.create_tcollection()
7486
c2 = self.create_tcollection()
@@ -105,6 +117,14 @@ def test_count(self):
105117
self.assertEqual(c.count(o1), 2)
106118
self.assertEqual(c.count(o2), 1)
107119

120+
# The TList destructor will call TList::Clear(), which triggers a
121+
# ROOT-internal garbage collection routine implemented in
122+
# TCollection::GarbageCollect(). This can segfault if Python already
123+
# garbage collected the objects in the TList itself, because then the
124+
# TList has dangling pointers. To avoid this, we call Clear() now,
125+
# before the reference count of the objects in the list goes to zero.
126+
c.Clear()
127+
108128

109129
if __name__ == '__main__':
110130
unittest.main()

bindings/pyroot/pythonizations/test/tseqcollection_listmethods.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,26 @@ class TSeqCollectionListMethods(unittest.TestCase):
1414
# Helpers
1515
def create_tseqcollection(self):
1616
sc = ROOT.TList()
17+
sc_pylist = []
1718
for i in reversed(range(self.num_elems)):
18-
sc.Add(ROOT.TObjString(str(i)))
19+
sc_pylist.append(ROOT.TObjString(str(i)))
20+
sc.Add(sc_pylist[-1])
1921

22+
# A TList is by default non-owning. To make sure that the objects live
23+
# long enough, we attach then as an attribute of the output list, such
24+
# that the Python reference counter doesn't hit zero.
25+
sc.owning_pylist = sc_pylist
2026
return sc
2127

2228
# Tests
2329
def test_insert(self):
2430
sc = self.create_tseqcollection()
2531

2632
# Insert with positive index
27-
o = ROOT.TObject()
28-
sc.insert(1, o)
33+
o1 = ROOT.TObject()
34+
sc.insert(1, o1)
2935
self.assertEqual(sc.GetEntries(), self.num_elems + 1)
30-
self.assertEqual(sc.At(1), o)
36+
self.assertEqual(sc.At(1), o1)
3137

3238
# Insert with negative index (starts from end)
3339
o2 = ROOT.TObject()
@@ -83,7 +89,8 @@ def test_pop(self):
8389
sc2.pop(1.0)
8490

8591
# Pop a repeated element
86-
sc2.append(ROOT.TObjString('2'))
92+
o1 = ROOT.TObjString('2')
93+
sc2.append(o1)
8794
elem = sc2.pop()
8895
self.assertEqual(sc2.At(0), elem)
8996

@@ -144,9 +151,9 @@ def test_index(self):
144151
self.assertEqual(sc.index(elem), i)
145152

146153
# Check element not in collection
147-
o = ROOT.TObjString(str(self.num_elems))
154+
o1 = ROOT.TObjString(str(self.num_elems))
148155
with self.assertRaises(ValueError):
149-
sc.index(o)
156+
sc.index(o1)
150157

151158

152159
if __name__ == '__main__':

tutorials/math/fit/combinedFit.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,16 @@ def __call__(self, par):
122122
fB.SetFitResult(result, iparB)
123123
fB.SetRange(rangeB().first, rangeB().second)
124124
fB.SetLineColor("kBlue")
125-
hB.GetListOfFunctions().Add(fB)
125+
# The function list takes ownership of the added elements, so we are cloning
126+
# the function to avoid a double delete.
127+
hB.GetListOfFunctions().Add(fB.Clone())
126128
hB.Draw()
127129

128130
c1.cd(2)
129131
fSB.SetFitResult(result, iparSB)
130132
fSB.SetRange(rangeSB().first, rangeSB().second)
131133
fSB.SetLineColor("kRed")
132-
hSB.GetListOfFunctions().Add(fSB)
134+
hSB.GetListOfFunctions().Add(fSB.Clone())
133135
hSB.Draw()
134136

135137
c1.SaveAs("combinedFit.png")

0 commit comments

Comments
 (0)