-
Notifications
You must be signed in to change notification settings - Fork 5
Testing for Memory Leaks
One of the more tricky kinds of bugs to track down in Python programs (including Crystal) is a memory leak. Crystal allocates and deallocates a huge number of objects over its lifetime while it is downloading new URL resources to a project.
Here, I'll outline a general strategy of:
- Get an interactive Python interpreter inside the Python process that is running Crystal
- Start Crystal with a large project and do a complex operation (like downloading a large group)
- In that interpreter, use Heapy (guppy3) to profile memory use
Just use Crystal's built-in shell:
poetry run python3 src/main.py --shell
In src/main.py
just before # Run GUI
add the lines:
import code, threading; threading.Thread(target=code.interact).start()
Then when you run Crystal from source, using python src/main.py
, you'll immediately get a Python interpreter on the command-line in addition to the regular GUI.
Use pyrasite to attach to an existing process. Under the covers it uses the gdb
tool to attach to a running Python process - and therefore has the same limitations as that approach.
pyrasite works well on Linux (including Docker containers) and older versions of macOS that have fewer application sandboxing features. I doubt it works on Windows since it uses gdb.
Inside a Docker container, run with docker run --cap-add=SYS_PTRACE ...
:
$ PYTHON_CRYSTAL_PID=...
$ pip3 install pyrasite ; apt-get update ; apt-get install -y gdb
$ pyrasite-shell $PYTHON_CRYSTAL_PID
On macOS, you'll need to additionally codesign both gdb
and the python
binary for the version of Crystal you're trying to attach to. And for Python 3.8 it appears you need to use a Python process with debugging symbols included, which I haven't figured out how to make work.
In general, when debugging pyrasite
, try to run:
$ PYTHON_CRYSTAL_PID=...
$ gdb -p $PYTHON_CRYSTAL_PID # can fail if ptrace unavailable, binary is unsigned (if macOS), or other reasons
(gbd) call PyGILState_Ensure() # can fail if Python not compiled with debugging symbols
(gbd) call PyRun_SimpleString("print('success')")
(gbd) call PyGILState_Release($1)
(gbd) quit
https://stackoverflow.com/a/133384/604063
- Run Crystal from source
$ python3 src/main.py
- Open a large project, like "OtakuWorld + The Big KiSS Page + Jenniverse.crystalproj"
- Start downloading a large group like "Jenniverse Forum Topic" (26,394 members)
- While the download is in progress, use the interactive Python interpreter to profile the running process
You will need to have Heapy installed first. Crystal 1.6.0 and later includes it by default.
$ pip3 install guppy3
In the interactive Python interpreter, import Heapy once:
>>> from guppy import hpy; h = hpy()
Then whenever you want to take a memory sample, use:
>>> import gc; gc.collect(); heap = h.heap(); heap; _.more
3135053
Partition of a set of 4679418 objects. Total size = 527,707,524 bytes.
Index Count % Size % Cumulative % Kind (class / dict of class)
0 294638 6 71062223 13 71062223 13 str
1 105108 2 65587392 12 136649615 26 collections.deque
2 735874 16 51500536 10 188150151 36 list
3 528338 11 38040336 7 226190487 43 types.BuiltinMethodType
4 105108 2 37838240 7 264028727 50 dict of threading.Condition
5 317136 7 32982144 6 297010871 56 dict of crystal.model._WeakTaskRef
6 222763 5 30295768 6 327306639 62 wx._core.TreeItemId
7 144139 3 20756016 4 348062655 66 dict of crystal.ui.tree.NodeView
8 317136 7 15222528 3 363285183 69 crystal.model._WeakTaskRef
9 105712 2 15222528 3 378507711 72 dict of crystal.model.Resource
<482 more rows. Type e.g. '_.more' to view.>
Index Count % Size % Cumulative % Kind (class / dict of class)
10 105105 2 15135120 3 393642831 75 dict of concurrent.futures._base.Future
11 222763 5 14256832 3 407899663 77 crystal.ui.tree.NodeViewPeer
12 78624 2 11321856 2 419221519 79 dict of crystal.ui.tree2.NodeView
13 2 0 10732560 2 429954079 81 collections.OrderedDict
14 72033 2 7491432 1 437445511 83 dict of crystal.browser.entitytree._LoadingNode
15 71915 2 7479160 1 444924671 84 dict of crystal.browser.entitytree.NormalResourceNode
16 144139 3 6918672 1 451843343 86 crystal.ui.tree.NodeView
17 65790 1 6842160 1 458685503 87 dict of crystal.browser.tasktree.TaskTreeNode
18 26481 1 6143592 1 464829095 88 dict of crystal.task.DownloadResourceBodyTask
19 26481 1 6143592 1 470972687 89 dict of crystal.task.DownloadResourceTask
<472 more rows. Type e.g. '_.more' to view.>
If any object types seem suspicious, examine the shortest path from one of the objects to the GC root:
>>> heap[9].byid[0].shpaths # Resource (not actually suspicious)
0: hpy().Root.??.f_back.f_back.f_back.f_back.f_locals['task'].__dict__['_abstract_resource'].__dict__['project'].__dict__['_resources']['http://otakuworld.com/'].__dict__
1: hpy().Root.??.f_back.f_back.f_back.f_back.f_locals['task'].__dict__['_abstract_resource'].__dict__['project'].__dict__['_root_resources'].keys()[0].__dict__
2: hpy().Root.??.f_back.f_back.f_back.f_back.f_locals['task'].__dict__['_resource'].__dict__['project'].__dict__['_resources']['http://otakuworld.com/'].__dict__
3: hpy().Root.??.f_back.f_back.f_back.f_back.f_locals['task'].__dict__['_resource'].__dict__['project'].__dict__['_root_resources'].keys()[0].__dict__
Here an executing Python stack frame (via chains of f_back
) has a local variable task
that contains a chain of references eventually to a Project object which contains all Resource objects (including this particular Resource object).
You can also profile memory growth specifically by setting a checkpoint. Then subsequent memory samples will only show new objects since the checkpoint:
>>> h.setref() # create checkpoint
Heapy also documents a h.heapu()
call that can hypothetically see leaked native C/C++ objects (such as from wxPython), but it doesn't seem to give readable output in guppy3. Maybe the original guppy module (which doesn't support Python 3.x) has a working version...