发现应用程序正在耗尽内存是开发人员需要面对的棘手问题之一。内存问题通常很难诊断和修复,并且在Python中更难。Python的自动垃圾收集使它很容易上手并易学,但是它很善于避开障碍,以至于当它不能如预期的那样工作时,开发人员可能会对如何识别和修复问题感到困惑。
在文中,我将展示如何在EvalML中诊断和修复内存问题,EvalML是Alteryx创新实验室开发的开源AutoML库。没有解决内存问题的魔法配方,但我希望开发人员,特别是Python开发人员,能够了解在将来遇到这类问题时可以利用的工具。
读完这篇博文后,你应该遵循以下几点:
- 为什么查找和修复程序中的内存问题很重要,
- 什么是循环引用,为什么它们会导致Python中的内存泄漏,以及
- 了解Python的内存分析工具,以及可以用来识别内存问题原因的一些步骤。
设置舞台
EvalML团队在发布我们的包的新版本之前运行一套性能测试,以捕获任何性能回归。这些性能测试包括在各种数据集上运行我们的AutoML算法,测量我们的算法达到的分数以及运行时间,并将这些指标与我们之前发布的版本进行比较。
有一天我正在运行测试,突然应用程序崩溃了。发生了什么事?
Step 0 - 什么是内存,什么是泄漏?
任何编程语言最重要的功能之一就是它在计算机内存中存储信息的能力。每当程序创建一个新变量时,它就会分配一些内存来存储该变量的内容。
内核为程序定义了一个接口来访问计算机的cpu、内存、磁盘存储等等。每一种编程语言都提供了要求内核分配和释放内存块以供运行程序使用的方法。
当程序要求内核留出一块内存来使用时,就会发生内存泄漏,但是由于错误或崩溃,程序永远不会告诉内核它何时结束使用该内存。在这种情况下,内核将继续认为被遗忘的内存块仍在被正在运行的程序使用,而其他程序将无法访问这些内存块。
如果在运行一个程序时重复发生相同的泄漏,那么被遗忘的内存的总大小可能会变得非常大,从而消耗计算机内存的很大一部分!在这种情况下,如果程序试图请求更多内存,内核将引发一个“内存不足”错误,程序将停止运行,或者换句话说,“崩溃”。
因此,在您编写的程序中找到并修复内存泄漏非常重要,因为如果不这样做,您的程序可能最终耗尽内存并崩溃,或者可能导致其他程序崩溃。
Step 1: 确定这是一个内存问题
应用程序崩溃的原因有很多——可能是运行代码的服务器崩溃了,也可能是代码本身存在逻辑错误——所以确定当前的问题是内存问题是很重要的。
EvalML性能测试以一种诡异的安静方式崩溃。突然,服务器停止了记录进度,工作也悄然完成了。服务器日志将显示由编码错误引起的任何堆栈跟踪,因此我预感这个无声的崩溃是由使用了所有可用内存的作业引起的。
我再次运行了性能测试,但这一次启用了Python的内存分析器,以获得一段时间内内存使用情况的图表。测试再次崩溃,当我查看内存图时,我看到了这个:
我们的内存使用在一段时间内保持稳定,但随后它达到了8g !我知道我们的应用服务器有8g的RAM,所以这个配置文件证实了我们的内存即将耗尽。此外,当内存稳定时,我们将使用大约4 GB的内存,但是我们之前的
EvalML版本使用了大约2 GB的内存。因此,由于某些原因,当前版本使用的内存是正常版本的两倍。
现在我需要找出原因。
Step 2: 用一个最小的例子在本地产生内存问题
找出内存问题的原因需要大量的实验和迭代,因为答案通常并不明显。如果是的话,您可能就不会把它写进代码中了!出于这个原因,我认为用尽可能少的代码行重现这个问题是很重要的。这个最小的示例使您可以在修改代码的同时在分析器下快速运行它,以查看是否有进展。
在我的例子中,根据经验,我知道我们的应用程序运行一个150万行的出租车数据集时,我看到了一个大的峰值。我将我们的应用程序精简到只有运行这个数据集的部分。我看到了一个类似于我上面描述的峰值,但是这次,内存使用达到了10g !
在看到这个之后,我知道有一个足够好的最小的例子来深入研究。
出租车数据集上本地复制器的内存占用
Step 3:查找分配最多内存的代码行
一旦我们将问题隔离到尽可能小的代码块中,我们就可以看到程序在哪里分配了最多的内存。这可能是您重构代码和修复问题所需的确凿证据。
我认为filprofiler是一个很好的Python工具。它会在内存使用高峰时显示应用程序中每一行代码的内存分配。这是我本地示例的输出:
文件分析器根据内存分配对应用程序中的代码行(以及依赖项代码)进行排序。线路越长越红,分配的内存就越多。
分配最多内存的行是创建pandas数据帧(pandas/core/algorithms.py和pandas/core/internal/managers.py),总计4 gb的数据!我在这里截断了filprofiler的输出,但是它能够跟踪pandas代码到创建pandas数据帧的EvalML中的代码。
看到这一点有点令人困惑。是的,EvalML创建pandas数据帧,但是这些数据帧在AutoML算法中是短命的,当它们不再使用时就应该被释放。由于情况并非如此,而且这些数据帧在内存中仍然存在足够长的时间,我认为最新版本引入了内存泄漏。
Step 4:识别泄漏对象
在Python上下文中,泄漏对象是指Python的垃圾回收器在执行完回收后没有释放的对象。由于Python使用引用计数作为其主要的垃圾收集算法之一,这些泄漏对象通常是由于对象持有对它们的引用时间过长而导致的。
这些类型的对象很难找到,但是可以利用一些Python工具使方便搜索。第一个工具是gc。垃圾收集器的DEBUG_SAVEALL标志。通过设置这个标志,垃圾收集器将在gc中存储不可到达的对象:垃圾的列表。这将让您进一步研究这些对象。
第二个工具是objgraph库。一旦对象在gc垃圾列表,我们可以过滤这个列表到pandas数据帧,并使用objgraph来查看其他对象引用这些数据帧,并将它们保存在内存中。通过阅读O 'Reilly的这篇博客文章,我得到了这个方法的想法。
这是我在可视化其中一个数据帧时看到的对象图的子集:
pandas数据帧使用的内存图,显示导致内存泄漏的循环引用。
这就是我要找的确凿证据!数据帧通过一个叫做PandasTableAccessor的东西对自身进行引用,它创建了一个循环引用,因此这会将对象保存在内存中,直到Python的垃圾收集器运行并能够释放它。(你可以通过dict, PandasTableAccessor, dict, _dataframe跟踪循环。)这对于EvalML来说是有问题的,因为垃圾收集器将这些数据帧保存在内存中太长时间,导致了内存耗尽!
我能够跟踪PandasTableAccessor到Woodwork库,并将这个问题提交给维护者。他们在一个新版本中修复了这个问题,并将相关问题提交给了pandas库——这是开源生态系统中协作的一个很好的例子。
在Woodwork更新发布后,我可视化了相同数据帧的对象图,循环引用消失了!
Step 5: 验证修复效果
升级了EvalML中的Woodwork版本后,我测量了应用程序的内存占用。我很高兴地报告,内存使用现在不到以前的一半!
修复后性能测试的内存
结束语
正如我在这篇文章的开头说的,没有解决内存问题的魔法配方,但是这个案例研究提供了一个通用的框架和一组工具,如果你在将来遇到这种情况,你可以利用。我发现memory-profiler和filprofiler是调试Python内存泄漏利器。
我还想强调的是,Python中的循环引用会增加应用程序的内存占用。垃圾收集器最终将释放内存,但是,正如我们在本例中看到的,循环引用无法被回收随着时间推移,内存就会耗尽!
Python中很容易无意地引入循环引用,我能够在EvalML、scikit- optimization和scipy中找到这种情况。我鼓励你睁大研究仔细分析,看这些循环引用是否必须!