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


    背景

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

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

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

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

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

    1. import time
    2. import tracemalloc
    3. import numpy as np
    4. result = [] # 保存结果
    5. n, m = 5, 10000
    6. tracemalloc.start()
    7. # 处理n个user的数据
    8. for i in range(n):
    9. # 每个user造数据m条
    10. data = [[f'user{i}', 'operation', int(time.time() * 1000), 'data1', 'data2', 'data3'] for j in range(m)]
    11. new_data = np.array(sorted(data, key=lambda x: x[2]))
    12. result.append(new_data[:10, :3])
    13. snapshot = tracemalloc.take_snapshot() # 内存摄像
    14. top_stats = snapshot.statistics('lineno') # 内存占用数据获取
    15. print(f'[Handle user{i} Top 3]')
    16. for stat in top_stats[:3]: # 打印占用内存最大的10个子进程
    17. 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

    1. import time
    2. import tracemalloc
    3. import numpy as np
    4. result = [] # 保存结果
    5. n, m = 5, 10000
    6. tracemalloc.start()
    7. # 处理n个user的数据
    8. for i in range(n):
    9. # 每个user造数据m条
    10. data = [[f'user{i}', 'operation', int(time.time() * 1000), 'data1', 'data2', 'data3'] for j in range(m)]
    11. new_data = np.array(sorted(data, key=lambda x: x[2]))[:10, :3]
    12. result.append(new_data)
    13. del data
    14. snapshot = tracemalloc.take_snapshot() # 内存摄像
    15. top_stats = snapshot.statistics('lineno') # 内存占用数据获取
    16. print(f'[Handle user{i} Top 3]')
    17. for stat in top_stats[:3]: # 打印占用内存最大的10个子进程
    18. print(stat)

    2、data变量名覆盖

    1. import time
    2. import tracemalloc
    3. import numpy as np
    4. result = [] # 保存结果
    5. n, m = 5, 10000
    6. tracemalloc.start()
    7. # 处理n个user的数据
    8. for i in range(n):
    9. # 每个user造数据m条
    10. data = [[f'user{i}', 'operation', int(time.time() * 1000), 'data1', 'data2', 'data3'] for j in range(m)]
    11. data = np.array(sorted(data, key=lambda x: x[2]))[:10, :3]
    12. result.append(data)
    13. snapshot = tracemalloc.take_snapshot() # 内存摄像
    14. top_stats = snapshot.statistics('lineno') # 内存占用数据获取
    15. print(f'[Handle user{i} Top 3]')
    16. for stat in top_stats[:3]: # 打印占用内存最大的10个子进程
    17. print(stat)

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

    3、copy数组data

    1. import time
    2. import tracemalloc
    3. import numpy as np
    4. result = [] # 保存结果
    5. n, m = 5, 10000
    6. tracemalloc.start()
    7. # 处理n个user的数据
    8. for i in range(n):
    9. # 每个user造数据m条
    10. data = [[f'user{i}', 'operation', int(time.time() * 1000), 'data1', 'data2', 'data3'] for j in range(m)]
    11. new_data = np.array(sorted(data, key=lambda x: x[2]))[:10, :3].copy()
    12. result.append(new_data)
    13. snapshot = tracemalloc.take_snapshot() # 内存摄像
    14. top_stats = snapshot.statistics('lineno') # 内存占用数据获取
    15. print(f'[Handle user{i} Top 3]')
    16. for stat in top_stats[:3]: # 打印占用内存最大的10个子进程
    17. print(stat)

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

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

    Numpy的切片视图(View)

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

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

    1. m = 100000
    2. data = np.array([[f'user{888}', 'operation', int(time.time() * 1000), 'data1', 'data2', 'data3'] for j in range(m)])
    3. # 1. 切片
    4. s1 = time.perf_counter()
    5. new_data1 = data[:m, :]
    6. e1 = time.perf_counter()
    7. print('==>', e1 - s1)
    8. # 2. copy
    9. s2 = time.perf_counter()
    10. new_data2 = data[:m, :].copy()
    11. e2 = time.perf_counter()
    12. print('==>', e2 - s2)
    13. # 3. 用列表提取
    14. s3 = time.perf_counter()
    15. new_data3 = data[range(m), :]
    16. e3 = time.perf_counter()
    17. print('==>', e3 - s3)
    18. # ==> 1.430000000013365e-05
    19. # ==> 0.005798200000000087
    20. # ==> 0.10793530000000007

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

    1. a = np.array([1, 2, 3])
    2. b = a[:2]
    3. b[0] = 10
    4. 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和变量名覆盖没有解决内存泄露的问题?

    1. >>> import sys
    2. >>> a = np.array(range(10))
    3. >>> sys.getrefcount(a) - 1
    4. 1
    5. # numpy.切片
    6. >>> a = np.array(range(10))
    7. >>> b1 = a[:2]
    8. >>> sys.getrefcount(a) - 1
    9. 2
    10. # numpy.copy
    11. >>> a = np.array(range(10))
    12. >>> b2 = a[:2].copy()
    13. >>> sys.getrefcount(a) - 1
    14. 1
    15. # numpy.列表提取
    16. >>> a = np.array(range(10))
    17. >>> b3 = a[[1, 2]]
    18. >>> sys.getrefcount(a) - 1
    19. 1
    20. # 列表.切片
    21. >>> a = list(range(10))
    22. >>> d = a[:2]
    23. >>> sys.getrefcount(l) - 1
    24. 1

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

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

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

    参考:

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

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

  • 相关阅读:
    工作小记1
    React 全栈体系(十二)
    [C语言、C++]数据结构作业:用双向链表实现折返约瑟夫问题
    网络安全(黑客)自学
    【机器学习】决策树系统 | 决策树基本原理,最优划分属性,剪枝处理
    大规模语言模型高效参数微调--BitFit/Prefix/Prompt 微调系列
    SQL Server Query Store Settings (查询存储设置)
    python图像处理 —— 实现图像滤镜效果
    SpringBoot2.0---------------9、SpringBoot请求映射使用与原理
    Vue教程
  • 原文地址:https://blog.csdn.net/qq_19446965/article/details/132866346