Skip to content

Commit fe2b100

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 752bfcb commit fe2b100

9 files changed

Lines changed: 127 additions & 19 deletions

File tree

README/ReleaseNotes/v632/index.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,63 @@ Please use the higher-level functions `RooAbsPdf::createNLL()` and `RooAbsPdf::c
208208
## PROOF Libraries
209209

210210

211+
## PyROOT
212+
213+
### Changed ownership policy for non-`const` pointer member function parameters
214+
215+
If you have a member function taking a raw pointer, like `MyClass::foo(T *obj)`,
216+
PyROOT was so far assuming that calling this method on `my_instance`
217+
transfers the ownership of `obj` to `my_instance`.
218+
219+
However, this resulted in many memory leaks, since many functions with such a
220+
signature actually don't take ownership of the object.
221+
222+
To avoid such memory leaks, PyROOT now doesn't make this guess anymore as of
223+
ROOT 6.32. Because of this change, some double-deletes or dangling references
224+
might creep up in your scripts. These need to be fixedby properly managing
225+
object lifetime with Python references.
226+
227+
You can fix the dangling references problem for example via:
228+
229+
1. Assigning the object to a python variable
230+
2. Creating an owning collection that keeps the objects alive
231+
3. Writing a pythonization for the member function that does the ownership
232+
transfer if needed
233+
234+
The double-delete problems can be fixed via:
235+
236+
1. Drop the ownership on the Python side with `ROOT.SetOwnership(obj, False)`
237+
3. Writing a pythonization for the member function that drops the ownership on the Python side as above
238+
239+
This affects for example the `TList::Add(TObject *obj)` member function, which
240+
will not transfer ownership from PyROOT to the TList anymore. The new policy
241+
fixes a memory leak, but at the same time it is not possible anymore to create
242+
the contained elements in place:
243+
244+
```python
245+
# A TList is by default a non-owning container
246+
my_list = ROOT.TList()
247+
248+
249+
# This is working, but resulted in memory leak prior to ROOT 6.32:
250+
obj_1 = ROOT.TObjString("obj_1")
251+
my_list.Add(obj_1)
252+
253+
254+
# This is not allowed anymore, as the temporary would be
255+
# deleted immediately leaving a dangling pointer:
256+
my_list.Add(ROOT.TObjString("obj_2"))
257+
258+
# Python reference count to contained object is now zero,
259+
# TList contains dangling pointer!
260+
```
261+
262+
**Note:** You can change back to the old policy by calling
263+
`ROOT.SetMemoryPolicy(ROOT.kMemoryHeuristics)` after importing ROOT, but this
264+
should be only used for debugging purposes and this function might be removed
265+
in the future!
266+
267+
211268
## Language Bindings
212269

213270

bindings/pyroot/pythonizations/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ if(roofit)
4343
ROOT/_pythonization/_roofit/_roomcstudy.py
4444
ROOT/_pythonization/_roofit/_roomsgservice.py
4545
ROOT/_pythonization/_roofit/_roonllvar.py
46+
ROOT/_pythonization/_roofit/_rooplot.py
4647
ROOT/_pythonization/_roofit/_rooprodpdf.py
4748
ROOT/_pythonization/_roofit/_roorealvar.py
4849
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
@@ -173,11 +173,6 @@ def _finalSetup(self):
173173
if not self.gROOT.IsBatch() and self.PyConfig.StartGUIThread:
174174
self.app.init_graphics()
175175

176-
# Set memory policy to kUseHeuristics.
177-
# This restores the default in PyROOT which was changed
178-
# by new Cppyy
179-
self.SetMemoryPolicy(self.kMemoryHeuristics)
180-
181176
# Redirect lookups to cppyy's global namespace
182177
self.__class__.__getattr__ = self._fallback_getattr
183178
self.__class__.__setattr__ = lambda self, name, val: setattr(gbl_namespace, 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
@@ -51,6 +51,7 @@
5151
from ._roomcstudy import RooMCStudy
5252
from ._roomsgservice import RooMsgService
5353
from ._roonllvar import RooNLLVar
54+
from ._rooplot import RooPlot
5455
from ._rooprodpdf import RooProdPdf
5556
from ._roorealvar import RooRealVar
5657
from ._roosimultaneous import RooSimultaneous
@@ -82,6 +83,7 @@
8283
RooMCStudy,
8384
RooMsgService,
8485
RooNLLVar,
86+
RooPlot,
8587
RooProdPdf,
8688
RooRealVar,
8789
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: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import unittest
22

33
import ROOT
4-
from libcppyy import SetOwnership
54

65

76
class TCollectionListMethods(unittest.TestCase):
@@ -15,12 +14,16 @@ class TCollectionListMethods(unittest.TestCase):
1514
# Helpers
1615
def create_tcollection(self):
1716
c = ROOT.TList()
17+
pylist = []
1818
for _ in range(self.num_elems):
1919
o = ROOT.TObject()
2020
# Prevent immediate deletion of C++ TObjects
21-
SetOwnership(o, False)
21+
ROOT.SetOwnership(o, False)
2222
c.Add(o)
23+
pylist.append(o)
2324

25+
# To prevent memory leaks
26+
c._owned_objects = pylist
2427
return c
2528

2629
# Tests
@@ -70,6 +73,14 @@ def test_remove(self):
7073
with self.assertRaises(ValueError):
7174
c.remove(o1)
7275

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+
7384
def test_extend(self):
7485
c1 = self.create_tcollection()
7586
c2 = self.create_tcollection()
@@ -106,6 +117,14 @@ def test_count(self):
106117
self.assertEqual(c.count(o1), 2)
107118
self.assertEqual(c.count(o2), 1)
108119

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+
109128

110129
if __name__ == '__main__':
111130
unittest.main()

bindings/pyroot/pythonizations/test/tseqcollection_listmethods.py

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

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

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

2733
# Insert with positive index
28-
o = ROOT.TObject()
29-
sc.insert(1, o)
34+
o1 = ROOT.TObject()
35+
sc.insert(1, o1)
3036
self.assertEqual(sc.GetEntries(), self.num_elems + 1)
31-
self.assertEqual(sc.At(1), o)
37+
self.assertEqual(sc.At(1), o1)
3238

3339
# Insert with negative index (starts from end)
3440
o2 = ROOT.TObject()
@@ -84,7 +90,8 @@ def test_pop(self):
8490
sc2.pop(1.0)
8591

8692
# Pop a repeated element
87-
sc2.append(ROOT.TObjString('2'))
93+
o1 = ROOT.TObjString('2')
94+
sc2.append(o1)
8895
elem = sc2.pop()
8996
self.assertEqual(sc2.At(0), elem)
9097

@@ -145,9 +152,9 @@ def test_index(self):
145152
self.assertEqual(sc.index(elem), i)
146153

147154
# Check element not in collection
148-
o = ROOT.TObjString(str(self.num_elems))
155+
o1 = ROOT.TObjString(str(self.num_elems))
149156
with self.assertRaises(ValueError):
150-
sc.index(o)
157+
sc.index(o1)
151158

152159

153160
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)