NumPy使用不当引起的内存泄漏

背景

以下是一段会引起内存逐步累积的代码:

代码大意:处理n个user的数据,将每个user的数据按照时间排序,取最早的10条的前三列保存

import time
import tracemalloc
import numpy as np

result = []  # 保存结果
n, m = 5, 10000
for i in range(n):
    # 每个user造数据m条
    data = [[f'user{i}', 'operation', int(time.time() * 1000), 'data1', 'data2', 'data3'] for j in range(m)]
    new_data = np.array(sorted(data, key=lambda x: x[2]))
    result.append(new_data[:10, :3])

为了监控性能,我们使用python自带的分析工具tracemalloc跟踪内存分配,使用方法见:

Python-tracemalloc-跟踪内存分配_Rnan-prince的博客-CSDN博客

import time
import tracemalloc
import numpy as np

result = []  # 保存结果
n, m = 5, 10000
tracemalloc.start()
# 处理n个user的数据
for i in range(n):
    # 每个user造数据m条
    data = [[f'user{i}', 'operation', int(time.time() * 1000), 'data1', 'data2', 'data3'] for j in range(m)]
    new_data = np.array(sorted(data, key=lambda x: x[2]))
    result.append(new_data[:10, :3])

    snapshot = tracemalloc.take_snapshot()  # 内存摄像
    top_stats = snapshot.statistics('lineno')  # 内存占用数据获取
    print(f'[Handle user{i} Top 3]')
    for stat in top_stats[:3]:  # 打印占用内存最大的10个子进程
        print(stat)

运行结果:

[Handle user0 Top 3]
D:/MyPython/memory/demo.py:12: size=3047 KiB, count=4, average=762 KiB
D:/MyPython/memory/demo.py:11: size=4368 B, count=78, average=56 B
D:/MyPython/memory/demo.py:13: size=168 B, count=3, average=56 B
[Handle user1 Top 3]
D:/MyPython/memory/demo.py:12: size=6094 KiB, count=8, average=762 KiB
D:/MyPython/memory/demo.py:11: size=5480 B, count=79, average=69 B
D:\Program Files\anaconda3\lib\tracemalloc.py:209: size=848 B, count=2, average=424 B
[Handle user2 Top 3]
D:/MyPython/memory/demo.py:12: size=9141 KiB, count=12, average=762 KiB
D:/MyPython/memory/demo.py:11: size=5480 B, count=79, average=69 B
D:\Program Files\anaconda3\lib\tracemalloc.py:479: size=1776 B, count=25, average=71 B
[Handle user3 Top 3]
D:/MyPython/memory/demo.py:12: size=11.9 MiB, count=17, average=717 KiB
D:/MyPython/memory/demo.py:11: size=5480 B, count=79, average=69 B
D:\Program Files\anaconda3\lib\tracemalloc.py:479: size=1720 B, count=24, average=72 B
[Handle user4 Top 3]
D:/MyPython/memory/demo.py:12: size=14.9 MiB, count=20, average=762 KiB
D:/MyPython/memory/demo.py:11: size=5480 B, count=79, average=69 B
D:\Program Files\anaconda3\lib\tracemalloc.py:479: size=1776 B, count=25, average=71 B

运行这段代码,我们看到11行和12行的内存几乎呈线性不断增长,当n值足够大时,就会使程序崩溃,甚至操作系统会重新启动。

分析代码:每次循环处理user的10000条数据,将每个user的数据按照时间排序,取最早的10条的前三列形成小数组不断加入结果result。按理说,最后只应占有一份大数组data的内存和一份小数组new_data内存,小数组占用内存太小,不可能使内存以如此明显的速度增长。这说明每次大数组被覆盖后,原来的值所占用的内存都没有释放,随着迭代的进行,内存将以大数组的量级累加。

为了解决这个问题,我们尝试如下办法:

1、先尝试删除大数组data

import time
import tracemalloc
import numpy as np

result = []  # 保存结果
n, m = 5, 10000
tracemalloc.start()
# 处理n个user的数据
for i in range(n):
    # 每个user造数据m条
    data = [[f'user{i}', 'operation', int(time.time() * 1000), 'data1', 'data2', 'data3'] for j in range(m)]
    new_data = np.array(sorted(data, key=lambda x: x[2]))[:10, :3]
    result.append(new_data)
    del data

    snapshot = tracemalloc.take_snapshot()  # 内存摄像
    top_stats = snapshot.statistics('lineno')  # 内存占用数据获取
    print(f'[Handle user{i} Top 3]')
    for stat in top_stats[:3]:  # 打印占用内存最大的10个子进程
        print(stat)

2、data变量名覆盖

import time
import tracemalloc
import numpy as np

result = []  # 保存结果
n, m = 5, 10000
tracemalloc.start()
# 处理n个user的数据
for i in range(n):
    # 每个user造数据m条
    data = [[f'user{i}', 'operation', int(time.time() * 1000), 'data1', 'data2', 'data3'] for j in range(m)]
    data = np.array(sorted(data, key=lambda x: x[2]))[:10, :3]
    result.append(data)

    snapshot = tracemalloc.take_snapshot()  # 内存摄像
    top_stats = snapshot.statistics('lineno')  # 内存占用数据获取
    print(f'[Handle user{i} Top 3]')
    for stat in top_stats[:3]:  # 打印占用内存最大的10个子进程
        print(stat)

运行后会发现,这两种方法都无法解决问题,内存依然增长。再尝试使用copy()方法:

3、copy数组data

import time
import tracemalloc
import numpy as np

result = []  # 保存结果
n, m = 5, 10000
tracemalloc.start()
# 处理n个user的数据
for i in range(n):
    # 每个user造数据m条
    data = [[f'user{i}', 'operation', int(time.time() * 1000), 'data1', 'data2', 'data3'] for j in range(m)]
    new_data = np.array(sorted(data, key=lambda x: x[2]))[:10, :3].copy()
    result.append(new_data)

    snapshot = tracemalloc.take_snapshot()  # 内存摄像
    top_stats = snapshot.statistics('lineno')  # 内存占用数据获取
    print(f'[Handle user{i} Top 3]')
    for stat in top_stats[:3]:  # 打印占用内存最大的10个子进程
        print(stat)

 使用copy()后,即使n增大很多,内存也不会有明显增长。

接下来我们来分析一下内存会逐渐累积原因以及解决方案的原理。

Numpy的切片视图(View)

Numpy中对一个大数组进行切片提取时,速度可以非常快,是因为提取出的新变量只是创造了一个“View”,指向原来的数据,并没有将这部分数据复制一份出来。注意:这种视图模式只是切片的特性,如果使用列表进行提取,是会复制一份而不是提供了一个“View”。可以对比下面三段代码的速度:

Python-time.time() 和 time.perf_counter()_Rnan-prince的博客-CSDN博客

m = 100000
data = np.array([[f'user{888}', 'operation', int(time.time() * 1000), 'data1', 'data2', 'data3'] for j in range(m)])

# 1. 切片
s1 = time.perf_counter()
new_data1 = data[:m, :]
e1 = time.perf_counter()
print('==>', e1 - s1)

# 2. copy
s2 = time.perf_counter()
new_data2 = data[:m, :].copy()
e2 = time.perf_counter()
print('==>', e2 - s2)

# 3. 用列表提取
s3 = time.perf_counter()
new_data3 = data[range(m), :]
e3 = time.perf_counter()
print('==>', e3 - s3)

# ==> 1.430000000013365e-05
# ==> 0.005798200000000087
# ==> 0.10793530000000007

切片视图导致新变量和原变量指向相同的元素,因此在修改新变量中元素的值时,原来的变量值也会被修改。如下所示:

a = np.array([1, 2, 3])
b = a[:2]
b[0] = 10
print(a) # [10  2  3]

copy()和列表提取则b的改变不会影响a的值。

综上:“View”不是切片的特性,而是Numpy中切片的特性,list中的切片就默认带有copy()

示例的内存问题,因为new_data只是data的一个视图,所以即使data变量名被新的值覆盖掉,但被append了的new_data仍然指向原来的大数组,导致那个大数组无法被释放。如果之前的程序是用列表提取,不需要copy()也不会有内存问题:

  • 循环引用中两个对象都定义了__del__,这时垃圾回收也无法完成释放
  • 本该释放内存的对象被其他对象引用,造成内存没有被释放

引用计数

python的内存管理主要通过引用计数和垃圾回收完成。引用计数表示每个对象都对应一个计数器,当该对象对应的计数器为0的时候,对象的内存会被释放。一般引用计数可以处理大部分内存释放的问题,但它无法处理循环引用问题,这时就要通过垃圾回收来完成。python中的内存泄漏一般有两种原因:

  • 循环引用中两个对象都定义了__del__,这时垃圾回收也无法完成释放
  • 本该释放内存的对象被其他对象引用,造成内存没有被释放

当一个对象被创建时,计数器值为1。下面每一个事件都会导致一个对象的计数器的值减1

  • del a销毁变量别名
  • a=[]变量别名指向了其他对象
  • 对象离开定义时的作用域,比如函数中定义的局部变量,函数运行结束后引用计数减1
  • 对象所在容器被销毁

一般操作del或者l=[]都只是将引用计数器值减1,而很多时候它们的值本来就是1,所以减1就是0,是0它对应的内存就被释放了。下面是一些会使引用计数器值加1的事件

  • 对象被创建,如a = np.array([1, 2, 3])
  • 新的别名,b=a
  • 对象被放入容器中,l.append(a)
  • 对象作为参数被传入函数

为什么del和变量名覆盖没有解决内存泄露的问题?

>>> import sys
>>> a = np.array(range(10))
>>> sys.getrefcount(a) - 1
1

# numpy.切片
>>> a = np.array(range(10))
>>> b1 = a[:2]
>>> sys.getrefcount(a) - 1
2

# numpy.copy
>>> a = np.array(range(10))
>>> b2 = a[:2].copy()
>>> sys.getrefcount(a) - 1
1

# numpy.列表提取
>>> a = np.array(range(10))
>>> b3 = a[[1, 2]]
>>> sys.getrefcount(a) - 1
1

# 列表.切片
>>> a = list(range(10))
>>> d = a[:2]
>>> sys.getrefcount(l) - 1
1

sys.getrefcount(a) 减1的原因在于变量被传入这个函数导致了计数器加1 。

可以看出Numpy的切片会导致原始变量引用计数加1,其他方式都不会。这就是del无效的原因del只是降低了一次引用计数,并没有将它降低到0。变量名覆盖也是一样,原始变量名无论是被del还是被新的值覆盖,新的值一直都在引用着原始的对象,导致原始对象无法被释放。可以通过变量的base属性查看:

>>> a = np.array(range(10))
>>> b = a[:2]
>>> del a
>>> b.base
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

参考:

与NumPy有关的内存泄漏 - 知乎 

python - Numpy 数组索引与其他数组会产生广播错误 - IT工具网

你可能感兴趣的:(python,numpy,python)