Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Bug]: plt.figure(), plt.close() leaks memory #23701

Closed
nschloe opened this issue Aug 22, 2022 · 10 comments · Fixed by #23712
Closed

[Bug]: plt.figure(), plt.close() leaks memory #23701

nschloe opened this issue Aug 22, 2022 · 10 comments · Fixed by #23712
Milestone

Comments

@nschloe
Copy link
Contributor

nschloe commented Aug 22, 2022

Bug summary

MWE:

import matplotlib.pyplot as plt

while True:
    plt.figure()
    plt.close()

Either run this code and monitor the mem consumption on top, or run

mprof run test.py
mprof plot

(with memory-profiler) to get

screenshot

Might be related to #22448.

Operating system

Ubuntu 22.10

Matplotlib Version

3.5.3

Matplotlib Backend

GTK4Agg

Python version

3.10.6

Jupyter version

No response

Installation

pip

@nschloe
Copy link
Contributor Author

nschloe commented Aug 22, 2022

The following is with

matplotlib.use('GTK3Agg')

screenshot

@timhoffm
Copy link
Member

timhoffm commented Aug 22, 2022

Have you tried other backends? Is this GTK and/or AGG specific? Interesting would be "qtagg", "Tkagg", "agg" and "gtk4cairo".

@tacaswell
Copy link
Member

The issue is that you are never letting the GUI event loop run so there are a growing number of GUI windows just hanging out in the background waiting for the event loop to run so they can finish their tear down cycle (because most UI frameworks have a way to notify that things are being destroyed etc).

If you need to run tight loops like this either use a non-interactive backend (like 'agg' or 'svg') which will never create the GUI objects or call plt.pause(.1) every so often to let the GUI clean up.

@nschloe
Copy link
Contributor Author

nschloe commented Aug 22, 2022

I've tried the suggestion. Outcome: All backends have the problem, but 'agg' and 'svg' run a lot faster than the rest. I've tried

  • 'agg'
  • 'gtk3agg'
  • 'gtk3cairo'
  • 'gtk4agg'
  • 'gtk4cairo'
  • 'qtagg'
  • 'qtcairo'
  • 'svg'

When adding plt.pause(1.0) (a full second), no clean-up appears to happen. This is with 'agg'; observe the plateaus:

agg-pause-1-0

Code:

import matplotlib
import matplotlib.pyplot as plt

matplotlib.use("GTK4Agg")

# [
#     "GTK3Agg",
#     "GTK3Cairo",
#     "GTK4Agg",
#     "GTK4Cairo",
#     "MacOSX",
#     "nbAgg",
#     "QtAgg",
#     "QtCairo",
#     "Qt5Agg",
#     "Qt5Cairo",
#     "TkAgg",
#     "TkCairo",
#     "WebAgg",
#     "WX",
#     "WXAgg",
#     "WXCairo",
#     "agg",
#     "cairo",
#     "pdf",
#     "pgf",
#     "ps",
#     "svg",
#     "template",
# ]

for k in range(4000):  # higher numbers for agg, svg
    plt.figure()
    plt.close()
    if k % 1000 == 0:
        plt.pause(1.0)

@tacaswell
Copy link
Member

Can you please try adding gc.collect() (to clean up an reference cycles) and put the pause before the close (some of the backends do not actually run the event loop if the all of the managers are gone).

Can you also try with https://github.com/matplotlib/matplotlib/blob/main/tools/memleak.py on your system?

import gc
import matplotlib.pyplot as plt
import matplotlib as mpl
mpl.use('agg')

while True:
    plt.figure()
    plt.close()
    gc.collect()

This stays under 90MB for me (where as with out the gc.collect() it did indeed start to run away).

@tacaswell
Copy link
Member

import matplotlib.figure as mfigure


while True:
    mfigure.Figure()

Also seems to work so likely something in the Manager class

@nschloe
Copy link
Contributor Author

nschloe commented Aug 22, 2022

  • agg without gc (100k iterations):

    agg

  • agg with gc (100k iterations, gc after every 1000 its):

    agg-gc

@nschloe
Copy link
Contributor Author

nschloe commented Aug 22, 2022

python3 memleak.py agg 100 out.pdf

screenshot

@nschloe
Copy link
Contributor Author

nschloe commented Aug 22, 2022

mfigure Also seems to work so likely something in the Manager class

Indeed:

import gc
import matplotlib.pyplot as plt
import matplotlib as mpl
import matplotlib.figure as mfigure

mpl.use('agg')

for k in range(100000):
    mfigure.Figure()
    plt.close()

gives

screenshot

-- no explicit gc.

@tacaswell
Copy link
Member

That long delay at the end is probably GC finally running on the way out

plt.close() is no effect in that second example because the Figure was never registered with


I understand the problem, TL;DR either use manual gc.collect() or set gc.set_threshold(100) because we internally use gc.collect(1) which breaks CPyhons garbage collection (I'll be opening up a PR soon).

  1. https://devguide.python.org/internals/garbage-collector/index.html is the best write of the GC out there
  2. a details that is not included is that the logic to sort out which generation to run is only run if the threshold for the lowest generation is met (cpython source)
  3. we have a gc.collect(1) in
    # Full cyclic garbage collection may be too expensive to do on every
    # figure destruction, so we collect only the youngest two generations.
    # see: https://github.com/matplotlib/matplotlib/pull/3045
    gc.collect(1)
    which is sweeping out lower generations
  4. if we do not have a code path between the plt.close() that does not make 700 objects then we never pass that first test and CPython never tries to run its "what generation to run?" logic (cpython source) and we have this run-away "leak"

You can reproduce this issue with:

import gc
import matplotlib.figure as mfigure


print("about to start")
for j in range(100_000):
    fig = mfigure.Figure()
    gc.collect(1)
    print(gc.get_count())

A way to "fix" this is

from itertools import count
import gc
import matplotlib.pyplot as plt
import matplotlib as mpl
mpl.use('agg')


class MyObject:
    ...
    
gc.collect()
print("about to start")
for j in range(100):
    print(gc.get_count())
    plt.figure()
    print(gc.get_count())
    plt.close()
    print(gc.get_count())
    c = [MyObject() for j in range(700)]

which will force enough generation 0 objects to be created that it will cause a full gc eventually.

@tacaswell tacaswell added this to the v3.5.4 milestone Aug 23, 2022
tacaswell added a commit to tacaswell/matplotlib that referenced this issue Aug 23, 2022
Matplotlib has a large number of circular references (between figure and
manager, between axes and figure, axes and artist, figure and canvas, and ...)
so when the user drops their last reference to a `Figure` (and clears it from
pyplot's state), the objects will not immediately deleted.

To account for this we have long (goes back to
e34a333 the "reorganize code" commit in 2004
which is the end of history for much of the code) had a `gc.collect()` in the
close logic in order to promptly clean up after our selves.

However, unconditionally calling `gc.collect` and be a major performance
issue (see matplotlib#3044 and
matplotlib#3045) because if there are a
large number of long-lived user objects Python will spend a lot of time
checking objects that are not going away are never going away.

Instead of doing a full collection we switched to clearing out the lowest two
generations.  However this both not doing what we want (as most of our objects
will actually survive) and due to clearing out the first generation opened us
up to having unbounded memory usage.

In cases with a very tight loop between creating the figure and destroying
it (e.g. `plt.figure(); plt.close()`) the first generation will never grow
large enough for Python to consider running the collection on the higher
generations.  This will lead to un-bounded memory usage as the long-lived
objects are never re-considered to look for reference cycles and hence are
never deleted because their reference counts will never go to zero.

closes matplotlib#23701
@tacaswell tacaswell modified the milestones: v3.5.4, v3.6.0 Aug 25, 2022
melissawm pushed a commit to melissawm/matplotlib that referenced this issue Dec 19, 2022
Matplotlib has a large number of circular references (between figure and
manager, between axes and figure, axes and artist, figure and canvas, and ...)
so when the user drops their last reference to a `Figure` (and clears it from
pyplot's state), the objects will not immediately deleted.

To account for this we have long (goes back to
e34a333 the "reorganize code" commit in 2004
which is the end of history for much of the code) had a `gc.collect()` in the
close logic in order to promptly clean up after our selves.

However, unconditionally calling `gc.collect` and be a major performance
issue (see matplotlib#3044 and
matplotlib#3045) because if there are a
large number of long-lived user objects Python will spend a lot of time
checking objects that are not going away are never going away.

Instead of doing a full collection we switched to clearing out the lowest two
generations.  However this both not doing what we want (as most of our objects
will actually survive) and due to clearing out the first generation opened us
up to having unbounded memory usage.

In cases with a very tight loop between creating the figure and destroying
it (e.g. `plt.figure(); plt.close()`) the first generation will never grow
large enough for Python to consider running the collection on the higher
generations.  This will lead to un-bounded memory usage as the long-lived
objects are never re-considered to look for reference cycles and hence are
never deleted because their reference counts will never go to zero.

closes matplotlib#23701
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants