发布于 2026-01-06 0 阅读
0

优化 Python 应用程序的内存使用

优化 Python 应用程序的内存使用

谈到性能优化,人们通常只关注速度和 CPU 使用率。很少有人会关注内存消耗,除非内存不足。限制内存使用有很多好处,不仅仅是为了避免应用程序因内存不足而崩溃。

在本文中,我们将探讨如何查找 Python 应用程序中哪些部分消耗了过多的内存,分析其原因,并最终使用简单的技巧和内存高效的数据结构来减少内存消耗和占用空间。

何必呢?

但首先,为什么要节省内存呢?除了避免前面提到的内存不足错误/崩溃之外,真的还有其他理由节省内存吗?

一个简单的原因就是钱。资源——包括CPU和内存——都需要花钱,如果有什么方法可以减少内存占用,为什么还要浪费内存运行低效的应用程序呢?

另一个原因是“数据有质量”的概念,如果数据量很大,那么它的传输速度就会很慢。如果数据必须存储在磁盘上而不是内存或高速缓存中,那么加载和处理数据就会耗费时间,从而影响整体性能。因此,优化内存使用可能会带来提升应用程序运行速度的意外好处。

最后,在某些情况下,可以通过增加内存来提高性能(如果应用程序性能受限于内存),但如果机器上没有剩余内存,就无法做到这一点。

找到瓶颈

显然,我们有充分的理由减少 Python 应用程序的内存使用量,但在这样做之前,我们首先需要找到瓶颈或占用所有内存的代码部分。

我们将介绍的第一个工具是memory_profiler。该工具逐行测量特定函数的内存使用情况:

# https://github.com/pythonprofilers/memory_profiler
pip install memory_profiler psutil
# psutil is needed for better memory_profiler performance

python -m memory_profiler some-code.py
Filename: some-code.py

Line #    Mem usage    Increment  Occurrences   Line Contents
============================================================
    15   39.113 MiB   39.113 MiB            1   @profile
    16                                          def memory_intensive():
    17   46.539 MiB    7.426 MiB            1       small_list = [None] * 1000000
    18  122.852 MiB   76.312 MiB            1       big_list = [None] * 10000000
    19   46.766 MiB  -76.086 MiB            1       del big_list
    20   46.766 MiB    0.000 MiB            1       return small_list
Enter fullscreen mode Exit fullscreen mode

要开始使用它,我们需要安装它以及pip一个psutil能显著提升性能的软件包。此外,我们还需要使用@profile装饰器标记要进行基准测试的函数。最后,我们使用运行分析器来测试我们的代码python -m memory_profiler。这将逐行显示被装饰函数(在本例中memory_intensive,该函数会有意地创建和删除大型列表)的内存使用/分配情况。

既然我们已经知道如何缩小范围,找到增加内存消耗的具体代码行,接下来我们可能需要更深入地挖掘,看看每个变量究竟占用了多少内存。你可能sys.getsizeof之前见过用 `use` 函数来测量内存占用。但是,对于某些类型的数据结构,这个函数提供的信息可能并不准确。对于整数或字节数组,你会得到实际的字节大小;但对于像列表这样的容器,你只能得到容器本身的大小,而无法得到其内容的大小。

import sys
print(sys.getsizeof(1))
# 28
print(sys.getsizeof(2**30))
# 32
print(sys.getsizeof(2**60))
# 36

print(sys.getsizeof("a"))
# 50
print(sys.getsizeof("aa"))
# 51
print(sys.getsizeof("aaa"))
# 52

print(sys.getsizeof([]))
# 56
print(sys.getsizeof([1]))
# 64
print(sys.getsizeof([1, 2, 3, 4, 5]))
# 96, yet empty list is 56 and each value inside is 28.
Enter fullscreen mode Exit fullscreen mode

我们可以看到,对于普通整数,每次超过某个阈值,大小就会增加 4 个字节。类似地,对于普通字符串,每次添加一个字符,大小就会增加 1 个字节。然而,对于列表,情况并非如此——它不会sys.getsizeof遍历”数据结构,而只会返回父对象的大小,在本例中为 1 list

更好的方法是使用专门用于分析内存行为的工具。Pympler 就是这样一款工具它可以帮助您更真实地了解 Python 对象的大小:

# pip install pympler
from pympler import asizeof
print(asizeof.asizeof([1, 2, 3, 4, 5]))
# 256

print(asizeof.asized([1, 2, 3, 4, 5], detail=1).format())
# [1, 2, 3, 4, 5] size=256 flat=96
#     1 size=32 flat=32
#     2 size=32 flat=32
#     3 size=32 flat=32
#     4 size=32 flat=32
#     5 size=32 flat=32

print(asizeof.asized([1, 2, [3, 4], "string"], detail=1).format())
# [1, 2, [3, 4], 'string'] size=344 flat=88
#     [3, 4] size=136 flat=72
#     'string' size=56 flat=56
#     1 size=32 flat=32
#     2 size=32 flat=32
Enter fullscreen mode Exit fullscreen mode

Pympler 提供了asizeof一个同名模块,该模块的函数可以正确报告列表的大小及其包含的所有值。此外,该模块还提供了asized一个函数,可以进一步细分对象的各个组成部分的大小。

Pympler 还有许多其他功能,例如跟踪类实例识别内存泄漏。如果您的应用程序可能需要这些功能,我建议您查看文档中的相关教程。

节省内存

既然我们已经知道如何查找各种潜在的内存问题,接下来就需要找到解决它们的方法。最快捷简便的解决方案或许是切换到更节省内存的数据结构。

lists在存储值数组方面,Python是比较消耗内存的选择之一:

from memory_profiler import memory_usage

def allocate(size):
    some_var = [n for n in range(size)]

usage = memory_usage((allocate, (int(1e7),)))  # `1e7` is 10 to the power of 7
peak = max(usage)
print(f"Usage over time: {usage}")
# Usage over time: [38.85546875, 39.05859375, 204.33984375, 357.81640625, 39.71484375]
print(f"Peak usage: {peak}")
# Peak usage: 357.81640625
Enter fullscreen mode Exit fullscreen mode

上面的简单函数(allocatelist使用指定的参数创建了一组 Python 数字size。为了测量它占用的内存,我们可以使用memory_profiler前面提到的函数,该函数会显示函数执行期间每 0.2 秒的内存使用量。我们可以看到,生成list1000 万个数字需要超过 350MiB 的内存。嗯,对于这么多数字来说,这似乎太多了。我们能做得更好吗?

import array

def allocate(size):
    some_var = array.array('l', range(size))

usage = memory_usage((allocate, (int(1e7),)))
peak = max(usage)
print(f"Usage over time: {usage}")
# Usage over time: [39.71484375, 39.71484375, 55.34765625, 71.14453125, 86.54296875, 101.49609375, 39.73046875]
print(f"Peak usage: {peak}")
# Peak usage: 101.49609375
Enter fullscreen mode Exit fullscreen mode

在这个例子中,我们使用了 Python 的 ` arraystd::vector` 模块,它可以存储整数或字符等基本数据类型。我们可以看到,在这种情况下,内存使用峰值略高于 100MiB。这与之前相比,差异非常大list。您可以通过选择合适的精度来进一步降低内存使用量:

import array
help(array)

#  ...
#  |  Arrays represent basic values and behave very much like lists, except
#  |  the type of objects stored in them is constrained. The type is specified
#  |  at object creation time by using a type code, which is a single character.
#  |  The following type codes are defined:
#  |
#  |      Type code   C Type             Minimum size in bytes
#  |      'b'         signed integer     1
#  |      'B'         unsigned integer   1
#  |      'u'         Unicode character  2 (see note)
#  |      'h'         signed integer     2
#  |      'H'         unsigned integer   2
#  |      'i'         signed integer     2
#  |      'I'         unsigned integer   2
#  |      'l'         signed integer     4
#  |      'L'         unsigned integer   4
#  |      'q'         signed integer     8 (see note)
#  |      'Q'         unsigned integer   8 (see note)
#  |      'f'         floating point     4
#  |      'd'         floating point     8
Enter fullscreen mode Exit fullscreen mode

用作数据容器的一个主要缺点array是它支持的类型不多。

如果您计划对数据进行大量数学运算,那么最好使用 NumPy 数组:

import numpy as np

def allocate(size):
    some_var = np.arange(size)

usage = memory_usage((allocate, (int(1e7),)))
peak = max(usage)
print(f"Usage over time: {usage}")
# Usage over time: [52.0625, 52.25390625, ..., 97.28515625, 107.28515625, 115.28515625, 123.28515625, 52.0625]
print(f"Peak usage: {peak}")
# Peak usage: 123.28515625

# More type options with NumPy:
data = np.ones(int(1e7), np.complex128)
# Useful helper functions:
print(f"Size in bytes: {data.nbytes:,}, Size of array (value count): {data.size:,}")
# Size in bytes: 160,000,000, Size of array (value count): 10,000,000
Enter fullscreen mode Exit fullscreen mode

我们可以看到,NumPy 数组在内存使用方面也表现出色,峰值数组大小约为 123MiB。这比一些其他数组略大,array但使用 NumPy,您可以利用快速的数学函数以及一些array其他数组不支持的类型,例如复数。

上述优化有助于减小值数组的整体大小,但我们还可以对 Python 类定义的单个对象的大小进行一些改进。这可以通过__slots__类属性来实现,该属性用于显式声明类属性。__slots__在类上声明属性还有一个不错的附加效果,即阻止创建 ` __dict__and`__weakref__属性:

from pympler import asizeof

class Normal:
    pass

class Smaller:
    __slots__ = ()

print(asizeof.asized(Normal(), detail=1).format())
# <__main__.Normal object at 0x7f3c46c9ce50> size=152 flat=48
#     __dict__ size=104 flat=104
#     __class__ size=0 flat=0

print(asizeof.asized(Smaller(), detail=1).format())
# <__main__.Smaller object at 0x7f3c4266f780> size=32 flat=32
#     __class__ size=0 flat=0
Enter fullscreen mode Exit fullscreen mode

这里我们可以看到类实例的实际大小要小得多Smaller。省略某些__dict__参数可以从每个实例中节省整整 104 字节,这在实例化数百万个值时可以节省大量内存。

以上技巧对于处理数值和class对象都很有帮助。那么字符串呢?字符串的存储方式通常取决于你的用途。如果你要搜索大量的字符串值,那么——正如我们所见——使用 `std::string`list是非常糟糕的做法。set如果执行速度很重要,`std::string` 可能更合适一些,但可能会消耗更多内存。最佳选择可能是使用优化的数据结构,例如trie 树,尤其适用于静态数据集,例如用于查询的数据。Python 中有很多这样的库,包括 trie 树以及许多其他树状数据结构,你可以在https://github.com/pytries找到其中一些

完全不使用内存

节省内存最简单的方法就是一开始就不用它。当然,你不可能完全避免使用内存,但你可以避免一次性加载整个数据集,而是尽可能地增量式地处理数据。实现这一点的最简单方法是使用生成器,它返回一个惰性迭代器,可以按需计算元素,而不是一次性全部计算。

你可以利用更强大的工具——内存映射文件,它允许我们只从文件中加载部分数据。Python 标准库提供了mmap一个模块来实现这一点,该模块可以用来创建兼具文件和字节数组特性的内存映射文件。你可以将它们用于诸如 `get_file()` 或 `get_bytearray()` 之类的文件操作read以及字符串操作:seekwrite

import mmap

with open("some-data.txt" "r") as file:
    with mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_READ) as m:
        print(f"Read using 'read' method: {m.read(15)}")
        # Read using 'read' method: b'Lorem ipsum dol'
        m.seek(0)  # Rewind to start
        print(f"Read using slice method: {m[:15]}")
        # Read using slice method: b'Lorem ipsum dol'
Enter fullscreen mode Exit fullscreen mode

加载/读取内存映射文件非常简单。首先,我们像往常一样打开文件进行读取。然后,我们使用文件的文件描述符(file.fileno())从中创建内存映射文件。之后,我们可以使用文件操作(例如读取)read或字符串操作(例如切片)来访问其数据。

大多数情况下,您可能更感兴趣的是像上面那样读取文件,但也可以向内存映射文件写入数据:

import mmap
import re

with open("some-data.txt", "r+") as file:
    with mmap.mmap(file.fileno(), 0) as m:
        # Words starting with capital letter
        pattern = re.compile(rb'\b[A-Z].*?\b')

        for match in pattern.findall(m):
            print(match)
            # b'Lorem'
            # b'Morbi'
            # b'Nullam'
            # ...

        # Delete first 10 characters
        start = 0
        end = 10
        length = end - start
        size = len(m)
        new_size = size - length
        m.move(start, end, size - end)
        m.flush()
    file.truncate(new_size)
Enter fullscreen mode Exit fullscreen mode

你会注意到代码中的第一个区别是访问模式的改变r+,它表示同时支持读写操作。为了证明我们确实可以执行读写操作,我们首先从文件中读取数据,然后使用正则表达式搜索所有以大写字母开头的单词。之后,我们演示如何从文件中删除数据。这与读取和搜索操作不同,因为删除部分内容时需要调整文件大小。为此,我们使用模块move(dest, src, count)的 `copy()` 方法,该方法将数据从索引 `<index>`mmap复制到索引 `<index>` ,在本例中,这相当于删除前 10 个字节。size - endendstart

如果你在 NumPy 中进行计算,那么你可能会更喜欢它的memmap特性(文档),它适用于存储在二进制文件中的 NumPy 数组。

结语

优化应用程序通常是一个难题。它很大程度上取决于具体的任务以及数据类型。本文探讨了查找内存使用问题的常用方法以及一些解决方案。然而,还有许多其他方法可以减少应用程序的内存占用。例如,可以使用概率数据结构(如布隆过滤器HyperLogLog)来牺牲一些准确性以换取存储空间。另一种方法是使用树状数据结构,例如DAWGMarissa trie,它们在存储字符串数据方面非常高效。

文章来源:https://dev.to/martinheinz/optimizing-memory-usage-of-python-applications-2hh7