如何解决 Python 中的内存问题

在这篇博文中,将展示如何诊断和修复Python中中的内存问题,以EvalML为例,它是由 Alteryx 创新实验室开发的开源 AutoML 库。没有解决内存问题的神奇秘诀,但是可以了解他们将来遇到此类问题时可以利用的工具和最佳实践。
发现应用程序内存不足是开发人员最糟糕的认识之一。一般来说,内存问题很难诊断和修复,但我认为在 Python 中更难。Python 的自动垃圾收集功能让您可以轻松上手并使用该语言,但它非常擅长让路,以至于当它无法按预期工作时,开发人员可能会不知如何识别和解决问题。
阅读这篇博文后,您应该了解以下内容:

  1. 为什么在程序中查找和修复内存问题很重要,
  2. 什么是循环引用以及为什么它们会导致 Python 中的内存泄漏,以及
  3. 了解 Python 的内存分析工具以及可以用来确定内存问题原因的一些步骤。

 
第 1 步:确定这是内存问题
应用程序崩溃的原因有很多——也许运行代码的服务器崩溃了,也许代码本身存在逻辑错误——所以确定手头的问题是内存问题很重要。
EvalML 性能测试以一种异常安静的方式崩溃。突然,服务器停止记录进度,工作安静地完成了。服务器日志会显示由编码错误引起的任何堆栈跟踪,所以我有一种预感,这种无声的崩溃是由使用所有可用内存的作业引起的。我再次重新进行了性能测试,但这次启用了 Python 的内存分析器,以获取随时间推移的内存使用情况图。
我们的内存使用量随着时间的推移保持稳定,但随后达到 8 GB!我知道我们的应用程序服务器有 8 GB 的 RAM,因此此配置文件确认我们的内存不足。此外,当内存稳定时,我们使用了大约 4 GB 的内存,但我们之前版本的 EvalML 使用了大约 2 GB 的内存。因此,出于某种原因,当前版本使用的内存大约是正常版本的两倍。现在我需要找出原因。

 
第2步:用最小的例子在本地重现内存问题
查明内存问题的原因涉及大量的实验和迭代,因为答案通常并不明显。如果是这样,您可能不会将其写入代码!出于这个原因,我认为用尽可能少的代码行重现问题很重要。这个最小的示例使您可以在修改代码时在分析器下快速运行它以查看是否取得进展。
就我而言,我从经验中了解到,大约在我看到大峰值的时候,我们的应用程序运行了一个包含 150 万行的出租车数据集。我将我们的应用程序精简为仅运行此数据集的部分。我看到了一个类似于我上面描述的峰值,但这一次,内存使用量达到了 10 GB!  看到这个之后,我知道有一个足够好的最小例子来深入研究。
 
第 3 步:找到分配最多内存的代码行
一旦我们将问题隔离到尽可能小的代码块中,我们就可以看到程序在何处分配了最多的内存。这可能是您重构代码和解决问题所需的吸烟枪。
我认为filprofiler是一个很好的 Python 工具。它显示应用程序中每一行代码在内存使用高峰时的内存分配。
分配最多内存的行是创建 pandas 数据帧(pandas/core/algorithms.py 和 pandas/core/internal/managers.py),数据量达 4 GB!我在这里截断了 filprofiler 的输出,但它能够跟踪 Pandas 代码以在创建 Pandas 数据帧的 EvalML 中进行编码。
看到这里,有点不解。是的,EvalML 创建了 Pandas 数据帧,但这些数据帧在整个 AutoML 算法中都是短暂的,一旦不再使用就应该被释放。由于情况并非如此,而且这些数据帧在内存中的时间已经足够长了 EvalML 已经完成,我认为最新版本引入了内存泄漏。
  
第 4 步:识别泄漏对象
在 Python 的上下文中,泄漏对象是在使用完成后没有被 Python 的垃圾收集器释放的对象。由于 Python 使用引用计数作为其主要的垃圾收集算法之一,这些泄漏的对象通常是由对象持有对它们的引用的时间超过应有的时间造成的。
这些类型的对象很难找到,但是您可以利用一些 Python 工具来使搜索变得易于处理。
第一个工具是垃圾收集器的gc.DEBUG_SAVEALL标志。通过设置这个标志,垃圾收集器将无法访问的对象存储在 gc.garbage 列表中。这将让您进一步调查这些对象。
第二个工具是objgraph库。一旦对象在 gc.garbage 列表中,我们就可以将此列表过滤为 pandas 数据帧,并使用 objgraph 查看其他对象引用这些数据帧并将它们保存在内存中。
数据帧通过称为 PandasTableAccessor 的东西对自身进行引用,它创建一个循环引用,因此这会将对象保留在内存中,直到 Python 的垃圾收集器运行并能够释放它。(您可以通过 dict、PandasTableAccessor、dict、_dataframe 跟踪循环。)这对 EvalML 来说是有问题的,因为垃圾收集器将这些数据帧保存在内存中的时间太长,以至于我们耗尽了内存!
我能够将 PandasTableAccessor 追踪到Woodwork库并带来这个问题取决于维护者。他们能够在新版本中修复它并将相关问题提交到 pandas 存储库——这是开源生态系统中可能进行协作的一个很好的例子。  
Woodwork更新发布后,我可视化了同一个dataframe的object graph,循环消失了!
 
第 5 步:验证修复是否有效
在 EvalML 中升级 Woodwork 版本后,我测量了应用程序的内存占用。我很高兴地报告,内存使用量现在不到过去的一半!  
 
结束语
正如我在本文开头所说,没有解决内存问题的神奇秘诀,但此案例研究提供了一个通用框架和一组工具,您可以在将来遇到这种情况时加以利用。我发现 memory-profiler 和 filprofiler 是在 Python 中调试内存泄漏的有用工具。
我还想强调的是,Python 中的循环引用会增加应用程序的内存占用。垃圾收集器最终会释放内存,但是,正如我们在这种情况下看到的,也许直到为时已晚!  
在 Python 中意外引入循环引用非常容易。我能够在 EvalML、scikit-optimizescipy 中找到一个无意的Bug. 我鼓励您睁大眼睛,如果您在野外看到循环引用,请开始对话,看看它是否真的需要!