为啥实习全局字符串值会导致每个多处理进程使用更少的内存?

Posted

技术标签:

【中文标题】为啥实习全局字符串值会导致每个多处理进程使用更少的内存?【英文标题】:Why does interning global string values result in less memory used per multiprocessing process?为什么实习全局字符串值会导致每个多处理进程使用更少的内存? 【发布时间】:2021-07-31 21:53:35 【问题描述】:

我有一个 Python 3.6 数据处理任务,该任务涉及预加载一个大字典,用于按 ID 查找日期,以供多处理模块管理的子进程池在后续步骤中使用。这个过程消耗了盒子上的大部分内存,所以我应用的一个优化是“实习”存储在字典中的字符串日期。正如我预期的那样,这将 dict 的内存占用减少了几 GB,但它也产生了另一个意想不到的效果。

在应用实习之前,子进程在执行时会逐渐消耗越来越多的内存,我认为这是因为他们不得不将字典从全局内存逐渐复制到子进程的单独分配内存(这在 Linux 上运行,因此受益于 fork()) 的写时复制行为。即使我没有更新子流程中的字典,它看起来像read-only access can still trigger copy-on-write through reference counting。

我只是希望实习可以减少 dict 的内存占用,但实际上它也阻止了内存使用量在子进程生命周期内逐渐增加

这是我能够构建的一个最小示例,它复制了该行为,尽管它需要一个大文件来加载并填充 dict,并且值中有足够的重复量以确保实习提供好处。

import multiprocessing
import sys

# initialise a large dict that will be visible to all processes
# that contains a lot of repeated values
global_map = dict()
with open(sys.argv[1], 'r', encoding='utf-8') as file:
  if len(sys.argv) > 2:
    print('interning is on')
  else:
    print('interning is off')
  for i, line in enumerate(file):
    if i > 30000000:
      break
    parts = line.split('|')
    if len(sys.argv) > 2:
      global_map[str(i)] = sys.intern(parts[2])
    else:
      global_map[str(i)] = parts[2]

def read_map():
  # do some nonsense processing with each value in the dict
  global global_map
  for i in range(30000000):
    x = global_map[str(i)]
  y = x + '_'
  return y

print("starting processes")
process_pool = multiprocessing.Pool(processes=10)

for _ in range(10):
  process_pool.apply_async(read_map)

process_pool.close()

process_pool.join()

我运行了这个脚本并监控了htop 以查看总内存使用情况。

interning? mem usage just after 'starting processes' printed peak mem usage after that
no 7.1GB 28.0GB
yes 5.5GB 5.6GB

虽然我很高兴这种优化似乎一次解决了我所有的内存问题,但我想更好地了解为什么它有效。如果子进程的内存使用量下降到写入时复制,那么如果我对字符串进行实习,为什么不会发生这种情况?

【问题讨论】:

潜在兴趣:Python Doc、Related SO answer。 【参考方案1】:

不是答案,但我认为提供不需要输入文件的 MWE 很有趣。关闭手动实习时,峰值内存使用率要高得多,我认为 HTF 正确解释了这一点。

from multiprocessing import Pool
from random import choice
from string import ascii_lowercase
# from sys import intern


def rand_str(length):
    return ''.join([choice(ascii_lowercase) for i in range(length)])


def read_map():
    for value in global_map.values():
        x = value
    y = x + '_'
    return y


global_map = dict()
for i in range(20_000_000):
    # global_map[str(i)] = intern(rand_str(4))
    global_map[str(i)] = rand_str(4)
print("starting processes")
if __name__ == '__main__':
    with Pool(processes=2) as process_pool:
        processes = [process_pool.apply_async(read_map)
                     for process in range(process_pool._processes)]
        for process in processes:
            process.wait()
            print(process.get())

【讨论】:

【参考方案2】:

CPython 实现将实习字符串存储在全局 object 中,这是一个常规 Python 字典,其中键和值都是字符串对象的指针

当一个新的子进程被创建时,它会获得父进程地址空间的副本,因此它们将使用带有内部字符串的精简数据字典。

我已经使用下面的补丁编译了 Python,如您所见,两个进程都可以访问带有内部字符串的表:

test.py:

import multiprocessing as mp
import sys
import _string


PROCS = 2
STRING = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"


def worker():
    proc = mp.current_process()
    interned = _string.interned()

    try:
        idx = interned.index(STRING)
    except ValueError:
        s = None
    else:
        s = interned[idx]

    print(f"proc: <s>")


def main():
    sys.intern(STRING)

    procs = []

    for _ in range(PROCS):
        p = mp.Process(target=worker)
        p.start()
        procs.append(p)

    for p in procs:
        p.join()


if __name__ == "__main__":
    main()

测试:

# python test.py 
<Process name='Process-1' parent=3917 started>: <https://www.youtube.com/watch?v=dQw4w9WgXcQ>
<Process name='Process-2' parent=3917 started>: <https://www.youtube.com/watch?v=dQw4w9WgXcQ>

补丁:

--- Objects/unicodeobject.c 2021-05-15 15:08:05.117433926 +0100
+++ Objects/unicodeobject.c.tmp 2021-05-15 23:48:35.236152366 +0100
@@ -16230,6 +16230,11 @@
     _PyUnicode_FiniEncodings(&tstate->interp->unicode.fs_codec);
 
 
+static PyObject *
+interned_impl(PyObject *module)
+
+    return PyDict_Values(interned);
+
 
 /* A _string module, to export formatter_parser and formatter_field_name_split
    to the string.Formatter class implemented in Python. */
@@ -16239,6 +16244,8 @@
      METH_O, PyDoc_STR("split the argument as a field name"),
     "formatter_parser", (PyCFunction) formatter_parser,
      METH_O, PyDoc_STR("parse the argument as a format string"),
+    "interned", (PyCFunction) interned_impl,
+     METH_NOARGS, PyDoc_STR("lookup interned strings"),
     NULL, NULL
 ;

您可能还想看看shared_memory 模块。

参考资料:

The internals of Python string interning

【讨论】:

“当一个新的子进程被创建时,它会得到一个父进程地址空间的副本,因此他们将使用带有内部字符串的缩减数据字典。”。我认为这是关键部分,所以只是为了澄清 - 在被复制到子进程的内存之后,内存使用量减少到内存中字典的较小大小(因为键和值是指针),还是有一些为什么操作系统永远不需要将实习生字典复制到子进程?这个 dict 是否特殊,因此不需要进行引用计数或以任何其他方式修改即可阅读? 当底层的fork调用返回时,新进程拥有虚拟内存的精确副本,但是任何后续更改(无论是父进程还是子进程)都将调用CoW机制。 refcount 仍然发生,但现在数据字典引用的值要少得多,因此需要为子进程创建的新内存页面更少。当我对3.000000e+07 项目(300 个唯一)进行测试时,内存使用量减少了 62%(从 3425.767MB 到 1280.022MB),而实习字典只有 607MB。我相信键不会受到影响,因为它们没有在子进程中被引用。 您可能还希望循环遍历dict.values() 而不是索引,它应该更快。 我已经对此进行了更多研究,但我仍然可以看到大量共享内存和少量唯一值。您可以设置赏金,以便其他人可以对此提供更好的解释。

以上是关于为啥实习全局字符串值会导致每个多处理进程使用更少的内存?的主要内容,如果未能解决你的问题,请参考以下文章

为啥 SIFT 使用更少的倍频程层会花费更多的时间?

为啥在这段代码中向量比指针使用更少的内存?

Python 多处理 - 为啥每个进程有这么多线程?

为啥在Python里推荐使用多进程而不是多线程

为啥尽管我在变量中使用 malloc 分配更多内存,但当我打印变量的大小时,它仍然显示更少的内存/字节? [复制]

调用垃圾回收会导致程序在java中使用更少的堆内存