用memory_profiler对Python代码进行剖析

3,149 阅读9分钟

当你的Python程序使用了太多的内存时,你会怎么做?你如何找到你的代码中的内存分配点,特别是大块的内存分配?事实证明,这些问题通常没有一个简单的答案,但有一些工具可以帮助你找出你的代码在哪里分配内存。在这篇文章中,我将重点介绍其中一个。memory_profiler.

memory_profiler 工具在精神上类似于(并受其启发)的line_profiler工具,我也写过关于它的文章line_profiler 告诉你每一行花_了_多少时间,而memory_profiler 告诉你_每_一行分配(或释放)了多少内存。这使你能够看到每一行代码的真正影响,并获得内存使用的感觉。虽然这个工具很有帮助,但要有效地使用它,还需要了解一些事情。我将在这篇文章中介绍一些细节。

安装

memory_profiler 是用Python编写的,可以用pip安装。该软件包将包括库,以及一些命令行实用程序。

pip install memory_profiler

它使用psutil库(或者可以使用tracemalloc或posix),以跨平台的方式访问进程信息,因此它可以在Windows、Mac和Linux上使用。

基本剖析

memory_profiler 是一套用于剖析Python程序内存使用情况的工具,文档对这些工具进行了很好的概述。提供最详细的工具是模块在剖析单个函数时将报告的逐行内存使用情况。你可以通过在命令行中针对一个python文件运行该模块来获得这个信息。它也可以通过 Juypyter/IPython magics 获得,或者在你自己的代码中。我将在本文中介绍所有这些选项。

我扩展了文档中的示例代码,展示了在Python代码中你可能看到的内存增长和回收的几种方式,以及在我的电脑上逐行输出的情况。使用下面保存在源文件中的示例代码 (performance_memory_profiler.py),你可以通过自己运行配置文件来跟随。

from functools import lru_cache

from memory_profiler import profile

import pandas as pd
import numpy as np

@profile
def simple_function():
    a = [1] * (10 ** 6)
    b = [2] * (2 * 10 ** 7)
    del b
    return a

@profile
def simple_function2():
    a = [1] * (10 ** 6)
    b = [2] * (2 * 10 ** 8)
    del b
    return a

@lru_cache
def caching_function(size):
    return np.ones(size)


@profile
def test_caching_function():
    for i in range(10_000):
        caching_function(i)

    for i in range(10_000,0,-1):
        caching_function(i)


if __name__ == '__main__':
    simple_function()
    simple_function()
    simple_function2()
    test_caching_function()

运行memory_profiler

为了提供逐行输出的结果,memory_profiler 需要用@profile 装饰器来装饰一个方法。只要把它添加到你想要剖析的方法中,我已经对上面的三个方法做了这个处理。然后,你需要一种方法来实际执行这些方法,比如一个命令行脚本。运行一个单元测试也可以,只要你能从命令行运行它。你可以通过运行memory_profiler 模块并提供驱动你的代码的Python脚本来做到这一点。你可以给它一个-h ,看看帮助。

$ python -m memory_profiler -h
usage: python -m memory_profiler script_file.py

positional arguments:
  program               python script or module followed by command line arguements to run

optional arguments:
  -h, --help            show this help message and exit
  --version             show program's version number and exit
  --pdb-mmem MAXMEM     step into the debugger when memory exceeds MAXMEM
  --precision PRECISION
                        precision of memory output in number of significant digits
  -o OUT_FILENAME       path to a file where results will be written
  --timestamp           print timestamp instead of memory measurement for decorated functions
  --include-children    also include memory used by child processes
  --backend {tracemalloc,psutil,posix}
                        backend using for getting memory info (one of the {tracemalloc, psutil, posix})

要查看样本程序的结果,只需用默认值运行它。由于我们用@profile 装饰器标记了其中的三个函数,所有三个调用都将被打印出来。对一个被多次调用的方法或函数进行剖析时要小心,它将为每次调用打印一个结果。下面是我电脑上的结果,下面我将详细解释运行的情况。对于每个函数,我们在左边得到源行号,在右边得到实际的Python源代码,以及每一行的三个指标。首先是执行该行代码时整个进程的内存使用量,该行的内存发生了多少增量(正数)或减量(负数),以及该行被执行了多少次。

$ python -m memory_profiler performance_memory_profiler.py
Filename: performance_memory_profiler.py

Line #    Mem usage    Increment  Occurences   Line Contents
============================================================
     8     67.2 MiB     67.2 MiB           1   @profile
     9                                         def simple_function():
    10     74.8 MiB      7.6 MiB           1       a = [1] * (10 ** 6)
    11    227.4 MiB    152.6 MiB           1       b = [2] * (2 * 10 ** 7)
    12    227.4 MiB      0.0 MiB           1       del b
    13    227.4 MiB      0.0 MiB           1       return a


Filename: performance_memory_profiler.py

Line #    Mem usage    Increment  Occurences   Line Contents
============================================================
     8    227.5 MiB    227.5 MiB           1   @profile
     9                                         def simple_function():
    10    235.1 MiB      7.6 MiB           1       a = [1] * (10 ** 6)
    11    235.1 MiB      0.0 MiB           1       b = [2] * (2 * 10 ** 7)
    12    235.1 MiB      0.0 MiB           1       del b
    13    235.1 MiB      0.0 MiB           1       return a


Filename: performance_memory_profiler.py

Line #    Mem usage    Increment  Occurences   Line Contents
============================================================
    15    235.1 MiB    235.1 MiB           1   @profile
    16                                         def simple_function2():
    17    235.1 MiB      0.0 MiB           1       a = [1] * (10 ** 6)
    18   1761.0 MiB   1525.9 MiB           1       b = [2] * (2 * 10 ** 8)
    19    235.1 MiB  -1525.9 MiB           1       del b
    20    235.1 MiB      0.0 MiB           1       return a


Filename: performance_memory_profiler.py

Line #    Mem usage    Increment  Occurences   Line Contents
============================================================
    27    235.1 MiB    235.1 MiB           1   @profile
    28                                         def test_caching_function():
    29    275.6 MiB      0.0 MiB       10001       for i in range(10_000):
    30    275.6 MiB     40.5 MiB       10000           caching_function(i)
    31
    32    280.6 MiB      0.0 MiB       10001       for i in range(10_000,0,-1):
    33    280.6 MiB      5.0 MiB       10000           caching_function(i)

解释结果

如果你查看官方文档,你会发现在他们的例子输出中,与我执行simple_function ,结果略有不同。例如,在我对函数的前两次调用中,del 似乎没有影响,而他们的例子显示内存被释放了。这是因为Python是一种垃圾收集语言,所以del 与在cc++ 这样的语言中释放内存是不同的。你可以看到,在第一次调用该方法时,内存激增,但在第二次调用时,第二次创建b ,不需要新的内存。为了澄清这一点,我又添加了一个方法,simple_function2 ,创建了一个更大的列表,这一次我们看到内存被释放了,垃圾收集器决定要回收这些内存。这只是一个例子,说明剖析代码可能需要用不同的输入数据进行多次运行,以获得你的代码的真实结果。还要考虑使用的硬件;生产问题可能与开发工作站不匹配。制作一个好的测试程序与解释结果和决定如何改进一样,可能需要很多时间。

从我的结果中需要注意的第二件事是对caching_function 。请注意,测试驱动程序运行了10,000个值的函数,但随后又反向运行了一遍。缓存将在前128次调用(functools.lru_cache 函数装饰器的默认大小)时被占用。我们看到,第二次的内存增长要少得多(这既是因为缓存被击中,也是因为垃圾收集器没有回收之前分配的内存)。一般来说,寻找持续的或大量的内存增量而没有减量。也要寻找每次调用函数时内存增长的情况,即使是较小的数量。

在常规代码中进行剖析

如果在你的代码中导入函数装饰器(如上所述)并正常运行,剖析数据会被发送到stdout。这可能是快速剖析单个方法的一种方便的方法。你可以注解任何函数,只需使用你通常使用的脚本运行你的代码。注意,你可以把这个输出发送到一个文件,或者使用logging 模块来记录它。详情请见文档。

Jupyter/IPython魔法

memory_profiler 项目还包括Jupyter/IPython magics,这可能很有用。需要注意的是,为了获得逐行输出(截至本文写作时的最新版本--v0.58),代码必须保存在本地Python源文件中,不能直接从笔记本或IPython解释器中读取,这一点非常重要。但是这些魔法对于调试内存问题仍然是有用的。要使用它们,请加载扩展。

%load_ext memory_profiler

mprun

%mprun 魔法类似于运行上述的函数,但你可以做一些更多的临时检查。首先,只要导入这些函数,然后运行它们。请注意,我发现它似乎并不能很好地与autoreload所以,在尝试修改代码和测试时,你的里程可能会有所不同,而不需要做一个完整的内核重启。

from performance_memory_profiler import test_caching_function, simple_function
%mprun -f simple_function simple_function()
Filename: /Users/mcw/projects/python_blogposts/performance/performance_memory_profiler.py

Line #    Mem usage    Increment  Occurences   Line Contents
============================================================
     8     76.4 MiB     76.4 MiB           1   @profile
     9                                         def simple_function():
    10     84.0 MiB      7.6 MiB           1       a = [1] * (10 ** 6)
    11    236.6 MiB    152.6 MiB           1       b = [2] * (2 * 10 ** 7)
    12    236.6 MiB      0.0 MiB           1       del b
    13    236.6 MiB      0.0 MiB           1       return a

memit

%memit%%memit 魔法有助于检查所执行的代码的内存峰值和内存增量是多少。你不会得到逐行的输出,但这可以允许交互式调试和测试。

%%memit
range(1000)
peak memory: 237.00 MiB, increment: 0.32 MiB

观察特定对象,不使用memory_profiler

让我们快速看看Numpy和pandas对象,以及我们如何能看到这些对象的内存使用情况。这两个库和它们的对象对于很多用例来说都很可能是大的。对于较新版本的库,你可以使用sys.get_size_of 来查看其内存使用情况。在引擎盖下,pandas对象会直接调用它们的memory_usage 方法,你也可以直接使用这个方法。注意,如果你还想看到pandas容器中的对象的内存使用情况,你需要指定deep=True

import sys

import numpy as np
import pandas as pd

def make_big_array():
    x = np.ones(int(1e7))
    return x

def make_big_string_array():
    x = np.array([str(i) for i in range(int(1e7))])
    return x

def make_big_series():
    return pd.Series(np.ones(int(1e7)))

def make_big_string_series():
    return pd.Series([str(i) for i in range(int(1e7))])

arr = make_big_array()
arr2 = make_big_string_array()
ser = make_big_series()
ser2 = make_big_string_series()

print("arr: ", sys.getsizeof(arr), arr.nbytes)
print("arr2: ", sys.getsizeof(arr2), arr2.nbytes)
print("ser: ", sys.getsizeof(ser))
print("ser2: ", sys.getsizeof(ser2))
print("ser: ", ser.memory_usage(), ser.memory_usage(deep=True))
print("ser2: ", ser2.memory_usage(), ser2.memory_usage(deep=True))
arr:  80000096 80000000
arr2:  280000096 280000000
ser:  80000144
ser2:  638889034
ser:  80000128 80000128
ser2:  80000128 638889018
%memit make_big_string_series()
peak memory: 1883.11 MiB, increment: 780.45 MiB
%%memit
x = make_big_string_series()
del x
peak memory: 1883.14 MiB, increment: 696.07 MiB

这里有两件事需要指出。int首先,你可以看到无论你是否使用deep=TrueSeries 的对象的大小都是一样的。对于字符串对象,其大小与int``Series 相同,但底层对象要大得多。你可以看到我们的由字符串对象组成的Series 是超过600MB的,使用%memit ,我们可以看到当我们调用函数时有一个增量。这个工具可以帮助你缩小哪些函数分配了最多的内存,应该用逐行分析的方法进一步调查。

进一步调查

memory_profile 项目也有一些工具用于调查运行时间较长的程序,看看内存是如何随时间增长的。请查看mprof命令,了解该功能。它还支持在多进程环境下跟踪分叉处理的内存。

总结

调试内存问题可能是一个非常困难和费力的过程,但是有一些工具可以帮助了解内存被分配的位置,这对推动调试过程非常有帮助。当与其他剖析工具一起使用时,例如line_profilerpy-spy,你可以更好地了解你的代码在哪里需要改进。

The postProfiling Python code with memory_profilerappeared first onwrighters.io.