Skip to content

Commit 819ccd4

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 5b7b6cc commit 819ccd4

9 files changed

Lines changed: 123 additions & 17 deletions

File tree

README/ReleaseNotes/v634/index.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,60 @@ The [TUnfold package](https://www.desy.de/~sschmitt/tunfold.html) inside ROOT is
140140

141141
## PyROOT
142142

143+
### Changed ownership policy for non-`const` pointer member function parameters
144+
145+
If you have a member function taking a raw pointer, like `MyClass::foo(T *obj)`,
146+
PyROOT was so far assuming that calling this method on `my_instance`
147+
transfers the ownership of `obj` to `my_instance`.
148+
149+
However, this resulted in many memory leaks, since many functions with such a
150+
signature actually don't take ownership of the object.
151+
152+
To avoid such memory leaks, PyROOT now doesn't make this guess anymore as of
153+
ROOT 6.32. Because of this change, some double-deletes or dangling references
154+
might creep up in your scripts. These need to be fixedby properly managing
155+
object lifetime with Python references.
156+
157+
You can fix the dangling references problem for example via:
158+
159+
1. Assigning the object to a python variable
160+
2. Creating an owning collection that keeps the objects alive
161+
3. Writing a pythonization for the member function that does the ownership
162+
transfer if needed
163+
164+
The double-delete problems can be fixed via:
165+
166+
1. Drop the ownership on the Python side with `ROOT.SetOwnership(obj, False)`
167+
3. Writing a pythonization for the member function that drops the ownership on the Python side as above
168+
169+
This affects for example the `TList::Add(TObject *obj)` member function, which
170+
will not transfer ownership from PyROOT to the TList anymore. The new policy
171+
fixes a memory leak, but at the same time it is not possible anymore to create
172+
the contained elements in place:
173+
174+
```python
175+
# A TList is by default a non-owning container
176+
my_list = ROOT.TList()
177+
178+
179+
# This is working, but resulted in memory leak prior to ROOT 6.32:
180+
obj_1 = ROOT.TObjString("obj_1")
181+
my_list.Add(obj_1)
182+
183+
184+
# This is not allowed anymore, as the temporary would be
185+
# deleted immediately leaving a dangling pointer:
186+
my_list.Add(ROOT.TObjString("obj_2"))
187+
188+
# Python reference count to contained object is now zero,
189+
# TList contains dangling pointer!
190+
```
191+
192+
**Note:** You can change back to the old policy by calling
193+
`ROOT.SetMemoryPolicy(ROOT.kMemoryHeuristics)` after importing ROOT, but this
194+
should be only used for debugging purposes and this function might be removed
195+
in the future!
196+
143197
### Typesafe `TTree::SetBranchAddress()` for array inputs
144198

145199
If you call `TTree::SetBranchAddress` with NumPy array or `array.array` inputs, ROOT will now check if the array type matches with the column type.

bindings/pyroot/pythonizations/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ if(roofit)
3838
ROOT/_pythonization/_roofit/_roomcstudy.py
3939
ROOT/_pythonization/_roofit/_roomsgservice.py
4040
ROOT/_pythonization/_roofit/_roonllvar.py
41+
ROOT/_pythonization/_roofit/_rooplot.py
4142
ROOT/_pythonization/_roofit/_rooprodpdf.py
4243
ROOT/_pythonization/_roofit/_roorealvar.py
4344
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
@@ -211,11 +211,6 @@ def _finalSetup(self):
211211
if not self.gROOT.IsBatch() and self.PyConfig.StartGUIThread:
212212
self.app.init_graphics()
213213

214-
# Set memory policy to kUseHeuristics.
215-
# This restores the default in PyROOT which was changed
216-
# by new Cppyy
217-
self.SetMemoryPolicy(self.kMemoryHeuristics)
218-
219214
# Redirect lookups to cppyy's global namespace
220215
self.__class__.__getattr__ = self._fallback_getattr
221216
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
@@ -52,6 +52,7 @@
5252
from ._roomcstudy import RooMCStudy
5353
from ._roomsgservice import RooMsgService
5454
from ._roonllvar import RooNLLVar
55+
from ._rooplot import RooPlot
5556
from ._rooprodpdf import RooProdPdf
5657
from ._roorealvar import RooRealVar
5758
from ._roosimultaneous import RooSimultaneous
@@ -83,6 +84,7 @@
8384
RooMCStudy,
8485
RooMsgService,
8586
RooNLLVar,
87+
RooPlot,
8688
RooProdPdf,
8789
RooRealVar,
8890
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/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(ROOT.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(ROOT.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)