在替换大型字符串列表中的元素时,避免 Python 中的大量内存使用

Avoid huge memory usage in Python when substituting elements in large list of strings

所以我正在尝试将大量具有逗号分隔值的字符串列表解析为(列表)列表。我正在尝试“就地”执行此操作,例如不复制内存中已经很大的对象。

现在,理想情况下,在解析期间和之后,唯一需要的额外内存是将原始字符串表示为字符串列表的开销。但实际发生的事情要糟糕得多。

例如此字符串列表占用约 1.36 GB 的内存:

import psutil

l = [f"[23873498uh3149ubn34, 59ubn23459un3459, un3459-un345, 9u3n45iu9n345, {i}]" for i in range(10_000_000)]
psutil.Process().memory_info().rss / 1024**3
>> 1.3626747131347656

所需的解析最终结果会占用更多空间 (~1.8 GB):

import psutil

l = [["23873498uh3149ubn34", "59ubn23459un3459", "un3459-un345", "9u3n45iu9n345", str(i)] for i in range(10_000_000)]
psutil.Process().memory_info().rss / 1024**3
1.7964096069335938

然而,实际解析原始字符串需要高达 5 GB 的内存,即比初始字符串加上最终列表的总和还要多:

import psutil

l = [f"[23873498uh3149ubn34, 59ubn23459un3459, un3459-un345, 9u3n45iu9n345, {i}]" for i in range(10_000_000)]

for i, val in enumerate(l):
    l[i] = val.split(", ")
    
psutil.Process().memory_info().rss / 1024**3
4.988628387451172

现在,我知道纯 Python 字符串和列表本身并不是非常有效(内存方面),但我不明白最终结果所需的内存之间的巨大差距(1.8 GB),以及在到达那里的过程中使用了什么 (5GB)。

谁能解释到底发生了什么,是否可以“就地”实际修改列表(同时实际释放替换值的内存),以及是否有更好的内存方式来做到这一点?

如果您不是一次使用整个列表,您可以使用生成器表达式

import psutil

data = (f"[23873498uh3149ubn34, 59ubn23459un3459, un3459-un345, 9u3n45iu9n345, {i}]" for i in range(10_000_000))
res = (x.split(', ') for x in data)
    
print(psutil.Process().memory_info().rss / 1024**3) # 0.08874893188476562

重新格式化以使其可读,您对它“应该占用”的内存的估计非常乐观:

l = [["23873498uh3149ubn34",
      "59ubn23459un3459",
      "un3459-un345",
      "9u3n45iu9n345",
      str(i)] for i in range(10_000_000)]

这太离谱了。例如,为 "9u3n45iu9n345" 创建的字符串对象只有一个,它在 1000 万个列表中 共享

>>> print(l[10][2] is l[56000][2]) # first indices are arbitrary
True

您的实际代码在每个索引位置创建了 1000 万个 distinct 个字符串对象。

尝试运行

sys._debugmallocstats()

不时获取有关内存用途的更多线索。

在 3.10.1 下,在 64 位 Windows 上,最后的一些摘要输出显示分配的 RAM 被非常有效地使用(几乎所有分配的块都在使用中):

# arenas allocated total           =                6,601
# arenas reclaimed                 =                1,688
# arenas highwater mark            =                4,913
# arenas allocated current         =                4,913
4913 arenas * 1048576 bytes/arena  =        5,151,653,888

# bytes in allocated blocks        =        5,129,947,088
# bytes in available blocks        =              701,584
53 unused pools * 16384 bytes      =              868,352
# bytes lost to pool headers       =           15,090,192
# bytes lost to quantization       =            5,046,672
# bytes lost to arena alignment    =                    0
Total                              =        5,151,653,888

arena map counts
# arena map mid nodes              =                    1
# arena map bot nodes              =                   20

# bytes lost to arena map root     =                8,192
# bytes lost to arena map mid      =                8,192
# bytes lost to arena map bot      =               40,960
Total                              =               57,344

注:节省约8亿字节的简单方法:

    l[i] = tuple(val.split(", ")) # use this
    # l[i] = val.split(", ")      # instead of this

列出“过度分配”,以便一系列追加在分摊的 O(1) 时间内运行。元组不会 - 它们只分配保留初始内容所需的数量(这也是它们的最终内容,因为元组是不可变的)。

测量 RSS 是错误的。 RSS 是当前驻留在物理 RAM 中的虚拟内存的一部分。例如,如果我只有 4 GB 的物理 RAM,我永远不会测量超过 4 GB。所以:如果您想保持较小的数量,请从您的计算机中移除 RAM 模块。

字符串中的数据约为 650 MB。

一个列表需要 56 个字节的内存。您正在创建其中的 10.000.000 个,因此这些列表占用 560 MB 的额外内存。

一个字符串在内存中占用 49 个字节,加上字符本身。将长字符串拆分后,您有 50.000.000 个字符串,因此它们又占 2450 MB。

另一个问题是垃圾回收。使用 PSUtils,您是在询问操​​作系统它为 Python 分配了多少内存。但是,Python 可能只是请求了它,因为它认识到您正在处理大量数据。但是,对于 Python.

,其中一些仍然可以被认为是免费的 内部

除此之外,我认为您应该进行真实世界的测试。一个好的 CSV 解析器不会将整个文件加载到内存中然后拆分字符串。相反,它将有一个解析器,可以流式传输数据并在找到数据时以逗号分隔。

为了获得 类 的开销,我使用了

import sys
print(sys.getsizeof([]))
print(sys.getsizeof(""))