【问题标题】:Memory leak when using lambda in Python class在 Python 类中使用 lambda 时的内存泄漏
【发布时间】:2021-05-12 08:47:21
【问题描述】:

如果我在类中使用 lambda 函数,我在 Python 中检测到了内存泄漏。 这是重现泄漏的代码:

import torch
# import numpy as np

class Class1(object):
    def __init__(self, x0):
        self.x0 = x0
        self.obj2 = Class2()
        self._leak_fcn = lambda: self.obj2.fcn()  # source of memory leak!

class Class2(object):
    def fcn(self):
        pass

def fcn(x0):
    obj1 = Class1(x0)
    return x0

def test_fcn():
    shape = (50000000, 3)
    y0 = torch.randn(shape).to(torch.double)
    # y0 = np.random.randn(*shape)
    y = fcn(y0)
    return y

for i in range(1000):
    print(i)
    test_fcn()

即使我将其更改为 numpy(不使用 pytorch),也会发生内存泄漏。 如果注释包含self._leak_fcn 的行,或者如果我将_leak_fcn 写为方法而不是lambda,则不会检测到内存泄漏。 这里发生了什么?

我不确定这个泄漏是来自 Python,还是 PyTorch 和 NumPy 都遭受了同样的泄漏。 仅供参考:我使用的是 Python 3.8.5。

编辑: 我知道这里有内存泄漏,因为如果我长时间运行它,我的内存会被填满(用htop 观察)并且当内存用完时进程会被终止。

【问题讨论】:

  • 你到底在这里称什么为“内存泄漏”?你是怎么观察到的?
  • 我编辑了我的帖子来回答你的问题
  • 据我所知,您的循环与类没有任何关系。它还分配了很多内存而没有垃圾回收。
  • 我以为 Python 中的垃圾回收是自动完成的。此外,如果我将_leak_fcn 编写为方法而不是 lambda 函数,则不会发生泄漏。
  • lambda 的闭包包含对类的引用 ((<cell at 0x7f77b6586190: Class1 object at 0x7f77b65860a0>,),所以我猜你在类和 lambda 之间获得了循环引用。打破它的一种方法是 del obj1._leak_fcnobj1 = Class1(x0) 之后,您将在每次迭代时取回内存。

标签: python numpy memory-leaks pytorch


【解决方案1】:

这里没有泄漏,只是一场关于 Class1 的实例是否被垃圾收集足够快以允许在进程之前释放由这些 Class1 缓冲区间接锚定的 torch 或 numpy 缓冲区的竞赛不再有足够的内存来分配另一个 torch 或 numpy 缓冲区。需要垃圾收集来打破@ThierryLathuille 提到的 PyObject 引用循环。

垃圾回收延迟是问题的事实可以通过简单地将示例中的源更改为从 gc 导入并在程序的最后一个循环中添加对 gc.collect() 的调用来证明,因为这使得这样垃圾收集总是会很快发生(假设您在系统上有足够的可用内存,程序甚至可以通过循环进行一次)。为了证明这一点,将“import gc”添加到程序顶部,并使最后一个循环看起来像这样:

for i in range(1000):
    print(i)
    test_fcn()
    gc.collect()

然后您将看到程序可以运行完成(再次假设您有足够的内存使其至少通过循环一次)。

人们可能想要确认的第二件事是,对于特定配置而言,垃圾收集根本不会很快发生,但最终会发生。情况确实如此,而且可以证明是这样。这样做的方法是减少每个 numpy 或 torch 缓冲区使用的内存,以使程序在垃圾收集开始之前不会耗尽内存,允许释放其中一些缓冲区,但不要减少太多以至于程序可以运行在没有任何 numpy 或 torch 缓冲区被垃圾收集的情况下完成。

要准确了解这些数字是什么,需要了解允许该程序增长到多大。在 Linux 上,一个限制是用作“已提交内存”的总可用内存,但系统可能会配置为允许发生一些过度提交。

这可以通过查看 /proc/meminfo 粗略检查。在我的系统上,从 CommitLimit 中减去 Committed_AS 意味着如果我运行该程序,我将只有不到 10 GB 的可用空间(假设其他程序不会启动或停止或更改它们同时使用的已提交内存量) .

$ grep Commit /proc/meminfo
CommitLimit:     9325344 kB
Committed_AS:     573964 kB

正如已经报道的那样,每个 torch 或 numpy 缓冲区使用的空间大约为 1.2 GB(50,000,000 * 3 * 8),因此即使允许过度使用内存,我也希望我的程序能够存储大约 8 个这些 numpy 或 torch在它崩溃之前缓冲。事实上,在我的系统上(使用 numpy 而不是 torch,从问题中的原始程序开始并注释掉 torch 行并从 numpy 行中删除 #)它在循环中崩溃了大约 10 次:

$ python3 junk.py
0
1
2
3
4
5
6
7
8
9
10
Traceback (most recent call last):
  File "junk.py", line 29, in <module>
    test_fcn()
  File "junk.py", line 23, in test_fcn
    y0 = np.random.randn(*shape)
  File "mtrand.pyx", line 1233, in numpy.random.mtrand.RandomState.randn
  File "mtrand.pyx", line 1390, in  numpy.random.mtrand.RandomState.standard_normal
  File "_common.pyx", line 577, in numpy.random._common.cont
numpy.core._exceptions.MemoryError: Unable to allocate 1.12 GiB for an array with shape (50000000, 3) and data type float64

所以假设我在程序中对 shape() 的参数进行了以下更改,以将 numpy 缓冲区的大小减少 25 倍:

# shape = (50000000, 3)
shape = (2000000, 3)

现在我希望 numpy 缓冲区至少占用 2,000,000 * 3 * 8 = 48,000,000 字节。在我的小型系统上,程序不可能分配 1,000 个但尚未释放的数据(因为这至少需要 48,000,000,000 个字节)。但是,程序以修改后的大小运行完成,表明垃圾收集必须正常工作。

人们可能会问的下一个问题是,无法像 Thierry 那样仅使用源代码发现 python 引用循环的人如何通过分析来解决这个问题。

进行此分析的一种方法是使用 chap(一种在 Linux 上运行的开源工具,其源代码可在 https://github.com/vmware/chap 获得)。

chap 所需的输入是要分析的程序的核心。如上所示,在我的系统上,程序在循环大约 10 次后崩溃了,所以我选择通过在程序之后运行“gcore”来为程序收集一个实时内核(使用带有少量注释更改的原始程序以使用 numpy)循环运行了 8 次左右。

这里是使用 chap 的分析,从 chap 到达 chap 提示符的位置开始。我们知道 numpy 缓冲区很大,所以我们可以通过使用查找至少 0x1000000 字节的任何缓冲区的命令来找到它们。运行该命令显示在收集核心时有 7 个这样的分配:

chap> describe used /minsize 1000000
Anchored allocation at 7fc6255b6010 of size 47868ff0

Anchored allocation at 7fc66ce1f010 of size 47868ff0

Anchored allocation at 7fc6b4688010 of size 47868ff0

Anchored allocation at 7fc6fbef1010 of size 47868ff0

Anchored allocation at 7fc74375a010 of size 47868ff0

Anchored allocation at 7fc78afc3010 of size 47868ff0

Anchored allocation at 7fc7d282c010 of size 47868ff0

7 allocations use 0x1f4adef90 (8,400,007,056) bytes.

如果我们选择其中一个大分配,我们可以看到它是如何被引用的,看看为什么它还在内存中。一种方法是使用以下命令,该命令指定我们应该从给定的分配开始,扩展到包含指向该分配开始(偏移量 0)的指针的任何分配,然后停止:

chap> describe allocation 7fc7d282c010 /extend @0<-=>StopHere
Anchored allocation at 7fc7d282c010 of size 47868ff0

Anchored allocation at 7fc82242c620 of size 50
This allocation matches pattern SimplePythonObject.
This has reference count 1 and python type 0x7fc8220a88e0 (numpy.ndarray)

2 allocations use 0x47869040 (1,200,001,088) bytes.

结果表明只有一个这样的分配,而且,从源代码来看,它的类型是 numpy.ndarray 也就不足为奇了。它匹配 chap 模式 SimplePythonObject 因为类型 numpy.ndarray 的分配不能引用其他 python 对象(大缓冲区不是一个)并且没有垃圾收集头。这样的对象只有在引用计数变为 0 时才会被释放。这里要观察的关键是引用计数为 1,这意味着我们只是在寻找一个对该 numpy.darray 的引用理解为什么它在内存中。

numpy.darray 继续,我们看到有 7 个事物引用了该分配的开始。其中 6 个是 python 类型框架的实例,并且不太可能无趣,因为它们有 6 个,我们也只想解释一个对 numpy.darray 的引用。还有一个与模式 PyDictValuesArray 匹配的分配(因为它保存了拆分 python dict 的值),这个更有趣(记得滚动下面的输出):

chap> describe allocation 7fc82242c620 /extend @0<-=>StopHere
Anchored allocation at 7fc82242c620 of size 50
This allocation matches pattern SimplePythonObject.
This has reference count 1 and python type 0x7fc8220a88e0 (numpy.ndarray)

Anchored allocation at 562868cdae20 of size 268
This allocation matches pattern ContainerPythonObject.
This allocation is not currently tracked by the garbage collector.
This has a PyGC_Head at the start so the real PyObject is at offset 0x18.
This has reference count 0 and python type 0x7fc82393aa00 (frame)

Anchored allocation at 562868e44ba0 of size 218
This allocation matches pattern ContainerPythonObject.
This allocation is not currently tracked by the garbage collector.
This has a PyGC_Head at the start so the real PyObject is at offset 0x18.
This has reference count 0 and python type 0x7fc82393aa00 (frame)

Anchored allocation at 562868e48830 of size 238
This allocation matches pattern ContainerPythonObject.
This allocation is not currently tracked by the garbage collector.
This has a PyGC_Head at the start so the real PyObject is at offset 0x18.
This has reference count 0 and python type 0x7fc82393aa00 (frame)

Anchored allocation at 562868e65b10 of size 238
This allocation matches pattern ContainerPythonObject.
This allocation is not currently tracked by the garbage collector.
This has a PyGC_Head at the start so the real PyObject is at offset 0x18.
This has reference count 0 and python type 0x7fc82393aa00 (frame)

Anchored allocation at 7fc81a1f5030 of size 1f8
This allocation matches pattern ContainerPythonObject.
This allocation is not currently tracked by the garbage collector.
This has a PyGC_Head at the start so the real PyObject is at offset 0x18.
This has reference count 0 and python type 0x7fc82393aa00 (frame)

Anchored allocation at 7fc81e5a3200 of size 1d0
This allocation matches pattern ContainerPythonObject.
This allocation is not currently tracked by the garbage collector.
This has a PyGC_Head at the start so the real PyObject is at offset 0x18.
This has reference count 0 and python type 0x7fc82393aa00 (frame)

Anchored allocation at 7fc8223f9968 of size 28
This allocation matches pattern PyDictValuesArray.
It contains values for a split python dict.

8 allocations use 0xd30 (3,376) bytes.

匹配模式 PyDictValuesArray 的分配必须由 python dict 的 ma_values 字段引用。保存这些值的分配本身不被引用计数,但取决于在 dict 被释放或 dict 不再需要该分配时被释放。从该分配继续,我们可以看到 dict

chap> describe allocation 7fc8223f9968 /extend @0<-=>StopHere
Anchored allocation at 7fc8223f9968 of size 28
This allocation matches pattern PyDictValuesArray.
It contains values for a split python dict.

Anchored allocation at 7fc823a646a8 of size 48
This allocation matches pattern ContainerPythonObject.
The garbage collector considers this allocation to be reachable.
This has a PyGC_Head at the start so the real PyObject is at offset 0x18.
This has reference count 1 and python type 0x7fc823939780 (dict)

2 allocations use 0x70 (112) bytes.

这里值得注意的是 dict 的引用计数为 1(所以再次只需要解释一个引用),垃圾收集器正在跟踪这个对象,并且实际 dict 的标头,而不是前面的垃圾集合标头从字典的偏移量 0x18 开始。这意味着对于对 dict 的引用,我们需要查找指向分配偏移量 0x18 的内容。 (指向分配偏移量 0 的链接通常会被垃圾收集使用)。

使用此信息,我们可以继续查看谁引用了该 dict,并查看该 dict 是否被 Class1 的实例引用。

chap> describe allocation 7fc823a646a8 /extend @18<-=>StopHere
Anchored allocation at 7fc823a646a8 of size 48
This allocation matches pattern ContainerPythonObject.
The garbage collector considers this allocation to be reachable.
This has a PyGC_Head at the start so the real PyObject is at offset 0x18.
This has reference count 1 and python type 0x7fc823939780 (dict)

Anchored allocation at 7fc822428d50 of size 38
This allocation matches pattern ContainerPythonObject.
The garbage collector considers this allocation to be reachable.
This has a PyGC_Head at the start so the real PyObject is at offset 0x18.
This has reference count 1 and python type 0x562868afee88 (Class1)

2 allocations use 0x80 (128) bytes.

考虑到程序,这并不奇怪。 dict 必须用于保存 Class1 实例的各个字段。同样,请注意 Class1 的实例的引用计数为 1,这意味着我们只需解释一个引用即可了解为什么 Class1 的实例仍可能在内存中。

继续,再次注意到分配有垃圾收集器,我们可以看到 Class1 实例被 python cell 类型的实例引用,这又是正在跟踪垃圾收集并且引用计数为 1:

chap> describe allocation 7fc822428d50 /extend @18<-=>StopHere
Anchored allocation at 7fc822428d50 of size 38
This allocation matches pattern ContainerPythonObject.
The garbage collector considers this allocation to be reachable.
This has a PyGC_Head at the start so the real PyObject is at offset 0x18.
This has reference count 1 and python type 0x562868afee88 (Class1)

Anchored allocation at 7fc823a1a870 of size 30
This allocation matches pattern ContainerPythonObject.
The garbage collector considers this allocation to be reachable.
This has a PyGC_Head at the start so the real PyObject is at offset 0x18.
This has reference count 1 and python type 0x7fc82393d6c0 (cell)

2 allocations use 0x68 (104) bytes.

继续,我们可以看到 cell 被一个 tuple 引用,它的引用计数又是 1:

chap> describe allocation 7fc823a1a870 /extend @18<-=>StopHere
Anchored allocation at 7fc823a1a870 of size 30
This allocation matches pattern ContainerPythonObject.
The garbage collector considers this allocation to be reachable.
This has a PyGC_Head at the start so the real PyObject is at offset 0x18.
This has reference count 1 and python type 0x7fc82393d6c0 (cell)

Anchored allocation at 7fc822428fb8 of size 38
This allocation matches pattern ContainerPythonObject.
The garbage collector considers this allocation to be reachable.
This has a PyGC_Head at the start so the real PyObject is at offset 0x18.
This has reference count 1 and python type 0x7fc823936320 (tuple)

2 allocations use 0x68 (104) bytes.

检查 tuple 时,它似乎被 tuplefunction 引用,但引用 tuple 在这种情况下似乎对我们的目的没有兴趣,因为垃圾收集器没有跟踪它,而且引用计数为 0。 函数 肯定很有趣,因为它正在被跟踪,因为它的引用计数为 1,并且因为从源头上我们知道我们正在寻找这样的东西:

chap> describe allocation 7fc822428fb8 /extend @18<-=>StopHere
Anchored allocation at 7fc822428fb8 of size 38
This allocation matches pattern ContainerPythonObject.
The garbage collector considers this allocation to be reachable.
This has a PyGC_Head at the start so the real PyObject is at offset 0x18.
This has reference count 1 and python type 0x7fc823936320 (tuple)

Anchored allocation at 562868aff290 of size 2b8
This allocation matches pattern ContainerPythonObject.
This allocation is not currently tracked by the garbage collector.
This has a PyGC_Head at the start so the real PyObject is at offset 0x18.
This has reference count 0 and python type 0x7fc823936320 (tuple)
 
Anchored allocation at 7fc81a114938 of size 88
This allocation matches pattern ContainerPythonObject.
The garbage collector considers this allocation to be reachable.
This has a PyGC_Head at the start so the real PyObject is at offset 0x18.
This has reference count 1 and python type 0x7fc82393a860 (function)

3 allocations use 0x378 (888) bytes.

从函数继续,我们完成了循环,因为在前面的遍历中已经看到了匹配模式 PyDictKeysArray 的 0x7fc8223f9968 处的分配。

chap> describe allocation 7fc81a114938 /extend @18<-=>StopHere
Anchored allocation at 7fc81a114938 of size 88
This allocation matches pattern ContainerPythonObject.
The garbage collector considers this allocation to be reachable.
This has a PyGC_Head at the start so the real PyObject is at offset 0x18.
This has reference count 1 and python type 0x7fc82393a860 (function)

Anchored allocation at 7fc8223f9968 of size 28
This allocation matches pattern PyDictValuesArray.
It contains values for a split python dict.
 
2 allocations use 0xb0 (176) bytes.

所以我们有一个看起来像这样的循环:

Class1 -> dict -> %PyDictValuesArray -> function -> tuple -> cell -> 

循环中的分配本身并不会占用那么多内存,但它确实需要垃圾回收来释放它,并且该循环中的 %PyDictValuesArray 还包含保存大缓冲区的 numpy.darray。

包括问题作者和 cmets 中的几个人在内的人们已经讨论了避免增长的有效解决方案,因此我将避免讨论修复本身,并将此答案限制在上述对增长原因的分析。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2016-03-16
    • 2016-06-11
    • 2021-01-16
    • 2020-05-13
    • 2010-11-17
    • 1970-01-01
    • 2021-07-02
    • 1970-01-01
    相关资源
    最近更新 更多