【问题标题】:Inconsistency with `sys.getsizeof`与`sys.getsizeof`不一致
【发布时间】:2018-12-24 12:27:58
【问题描述】:

为什么长度为 1 的 Python strsys.getsizeof() 比长度为 2 的字符串大? (对于长度 > 2,关系似乎按预期单调增加。)

例子:

>>> from string import ascii_lowercase
>>> import sys

>>> strings = [ascii_lowercase[:i] for i, _ in enumerate(ascii_lowercase, 1)]
>>> strings
['a',
 'ab',
 'abc',
 'abcd',
 'abcde',
 'abcdef',
 'abcdefg',
 # ...

>>> sizes = dict(enumerate(map(sys.getsizeof, strings), 1))
>>> sizes
{1: 58,   # <--- ??
 2: 51,
 3: 52,
 4: 53,
 5: 54,
 6: 55,
 7: 56,
 8: 57,
 9: 58,
 10: 59,
 11: 60,
 12: 61,
 13: 62,
 14: 63,
 15: 64,
 16: 65,
 # ...

它似乎与str.__sizeof__ 有关,但我对 C 的了解还不够深入,无法深入研究这种情况下发生的事情。


编辑

这似乎与 IPython 启动文件中的单个 Pandas 导入有关。

我也可以在纯 Python 会话中重现该行为:

 ~$ python
Python 3.6.6 |Anaconda, Inc.| (default, Jun 28 2018, 11:07:29) 
[GCC 4.2.1 Compatible Clang 4.0.1 (tags/RELEASE_401/final)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from string import ascii_lowercase
>>> import sys
>>> strings = [ascii_lowercase[:i] for i, _ in enumerate(ascii_lowercase, 1)]
>>> sizes = dict(enumerate(map(sys.getsizeof, strings), 1))
>>> sizes
{1: 50, 2: 51, 3: 52, 4: 53, 5: 54, 6: 55, 7: 56, 8: 57, 9: 58, 10: 59, 11: 60, 12: 61, 13: 62, 14: 63, 15: 64, 16: 65, 17: 66, 18: 67, 19: 68, 20: 69, 21: 70, 22: 71, 23: 72, 24: 73, 25: 74, 26: 75}
>>> import pandas as pd
>>> sizes = dict(enumerate(map(sys.getsizeof, strings), 1))
>>> sizes
{1: 58, 2: 51, 3: 52, 4: 53, 5: 54, 6: 55, 7: 56, 8: 57, 9: 58, 10: 59, 11: 60, 12: 61, 13: 62, 14: 63, 15: 64, 16: 65, 17: 66, 18: 67, 19: 68, 20: 69, 21: 70, 22: 71, 23: 72, 24: 73, 25: 74, 26: 75}
>>> pd.__version__
'0.23.2'

【问题讨论】:

  • 您使用的是哪个版本的 Python 3.x? (以及平台、32 位或 64 位、python.org 安装程序或其他一些东西等)因为我在每个 64 位 CPython 3.4、3.6 或 3.7 上获得50 的第一个字符串,我可以访问.
  • 我也有 50 个。
  • 第二个 abarnert,在 3.6.6 或 3.7 上没有复制。
  • Same on repl.it(尽管这并不奇怪,因为我怀疑它们运行的​​ 3.6.1 发行版包与我的两个 linux 容器之一运行的是相同的……)。
  • 有趣——我在 3.6.6,Anaconda 发行版,64 位。让我仔细检查一下问题代码。

标签: python string python-3.x pandas


【解决方案1】:

Python 3.3+'s str is quite a complicated structure,并且最终可以以多达三种不同的方式存储基础数据,具体取决于字符串使用了哪些 API 以及字符串表示的代码点。最常见的替代表示情况是缓存的 UTF-8 表示,但这仅适用于非 ASCII 字符串,因此不适用于此处。

在这种情况下,我怀疑单个字符串(作为实现细节,是一个单例)的使用方式触发了旧版 wchar_t* 表示的创建(使用 the legacy Py_UNICODE APIs 的扩展可能会导致这种情况),并且您的 Python 构建使用四字节 wchar_t,导致字符串比其他情况大 8 个字节(a 本身为 4 个字节,NUL 终止符为另外 4 个字节)。它是单例的事实意味着,即使您可能从未触发过这样的遗留 API 调用,任何检索到单例引用的扩展都会影响观察到的大小 everyone 通过将其与遗留API。

就我个人而言,我在我的 Linux 3.6.5 安装上根​​本没有重现(大小平滑增加),表明没有创建 wchar_t 表示,而在我的 Windows 3.6.3 安装上,'a' 只有 54字节,而不是 58(与 Windows 的原生两字节 wchar_t 匹配)。在这两种情况下,我都使用ipython;不同版本的 ipython 依赖项可能会导致您(和我)不一致的观察结果。

需要明确的是,这个额外的成本是相当无关紧要的;由于单个字符串是单例,因此使用的增量成本实际上只有 4-8 个字节(取决于指针宽度)。如果少数字符串最终与旧版 API 一起使用,您不会占用大量内存。

【讨论】:

  • 最常见的情况不是缓存的 UTF-8 表示,它是一个 1、2 或 4 字节的固定宽度字符串,可能也有也可能没有缓存的 UTF-8 表示(只有当它是 1 字节时,它才能与固定宽度相同)。
  • @abarnert:我措辞不好;我的意思是最常见的 alternate 表示(我已经对其进行了编辑以修复该语句)。并且固定宽度是1字节和latin-1不一样,只有1字节和ASCII。
  • 我有一个在启动时执行的startup.py 文件。当我对此发表评论时,这种不一致就消失了。它所做的唯一时髦的事情是将sys.stdout 临时重定向到os.devnull。但我不知道这是否是原因。无论如何,只要说您的回答很有帮助就足够了。
  • @BradSolomon:它是否使用 any 非内置 API?当然,我的复制设置只对prompt_toolkit 进行了一些调整(我认为ipython 依赖项不会改变任何东西)。我很乐意将其称为非默认设置之谜。
  • @miradulo 并不奇怪;该函数可以执行各种操作,并且使用最初为早期 2.x Python 编写的 C 代码以及可能尚未优先更新的代码(因为您显然从未在内部循环或任何东西中使用它),所以……
【解决方案2】:

当您import pandas 时,它会执行大量 NumPy 操作,包括在所有单 ASCII 字母字符串上调用 UNICODE_setitem,并且可能在其他地方对单 ASCII 数字字符串执行类似操作。

该 NumPy 函数调用已弃用的 C API PyUnicode_AsUnicode

当您在 CPython 3.3+ 中调用它时,它会将 wchar_t * 表示缓存在字符串的内部结构中,在其 wstr 成员中,作为两个 wchar_t 值 w'a''\0',占用 8 个字节Python 的 32 位wchar_t 构建。 str.__size__ 考虑到了这一点。

因此,所有用于 ASCII 字母和数字的单字符内部字符串(但仅此而已)最终增大了 8 个字节。


首先,我们知道这显然是在import pandas 上发生的事情(根据Brad Solomon's answer。)它可能发生在np.set_printoptions(precision=4, threshold=625, edgeitems=10) 上(miradulo 已发布,但随后被删除,在ShadowRanger's answer 上对此效果的评论),但绝对不是import numpy

其次,我们知道'a'会发生这种情况,但是其他单字符串呢?

为了验证前者并测试后者,我运行了以下代码:

import sys

strings = [chr(i) for i in (0, 10, 17, 32, 34, 47, 48, 57, 58, 64, 65, 90, 91, 96, 97, 102, 103, 122, 123, 130, 0x0222, 0x12345)]

sizes = {c: sys.getsizeof(c) for c in strings}
print(sizes)

import numpy as np
sizes = {c: sys.getsizeof(c) for c in strings}
print(sizes)

np.set_printoptions(precision=4, threshold=625, edgeitems=10)
sizes = {c: sys.getsizeof(c) for c in strings}
print(sizes)

import pandas
sizes = {c: sys.getsizeof(c) for c in strings}
print(sizes)

在多个 CPython 安装上(但在 Linux 或 macOS 上都是 64 位 CPython 3.4 或更高版本),我得到了相同的结果:

{'\x00': 50, '\n': 50, '\x11': 50, ' ': 50, '"': 50, '/': 50, '0': 50, '9': 50, ':': 50, '@': 50, 'A': 50, 'Z': 50, '[': 50, '`': 50, 'a': 50, 'f': 50, 'g': 50, 'z': 50, '{': 50, '\x82': 74, 'Ȣ': 76, '?': 80}
{'\x00': 50, '\n': 50, '\x11': 50, ' ': 50, '"': 50, '/': 50, '0': 50, '9': 50, ':': 50, '@': 50, 'A': 50, 'Z': 50, '[': 50, '`': 50, 'a': 50, 'f': 50, 'g': 50, 'z': 50, '{': 50, '\x82': 74, 'Ȣ': 76, '?': 80}
{'\x00': 50, '\n': 50, '\x11': 50, ' ': 50, '"': 50, '/': 50, '0': 50, '9': 50, ':': 50, '@': 50, 'A': 50, 'Z': 50, '[': 50, '`': 50, 'a': 50, 'f': 50, 'g': 50, 'z': 50, '{': 50, '\x82': 74, 'Ȣ': 76, '?': 80}
{'\x00': 50, '\n': 50, '\x11': 50, ' ': 50, '"': 50, '/': 50, '0': 58, '9': 58, ':': 50, '@': 50, 'A': 58, 'Z': 58, '[': 50, '`': 50, 'a': 58, 'f': 58, 'g': 58, 'z': 58, '{': 50, '\x82': 74, 'Ȣ': 76, '?': 80}

所以,import numpy 什么都没有改变,set_printoptions 也没有改变(大概是 miradulo 删除评论的原因……),但import pandas 确实如此。

它显然会影响 ASCII 数字和字母,但不会影响其他任何东西。

此外,如果您将所有 prints 更改为 print(sizes.values()),那么字符串永远不会被编码以输出,您会得到相同的结果,这意味着它不是关于缓存 UTF-8,或者它是的,但即使我们不强制,这种情况总是会发生。


显而易见的可能性是,无论 Pandas 调用什么,都使用 legacy PyUnicode API 之一为所有 ASCII 数字和字母生成单字符串。所以这些字符串最终不是紧凑的 ASCII 格式,而是遗留的格式,对吧? (有关这意味着什么的详细信息,请参阅the comments in the source。)

不。使用我的 superhackyinternals 中的代码,我们可以看到它仍然是 compact-ascii 格式:

import ctypes
import sys
from internals import PyUnicodeObject

s = 'a'
print(sys.getsizeof(s))
ps = PyUnicodeObject.from_address(s)
print(ps, ps.kind, ps.length, ps.interned, ps.ascii, ps.compact, ps.ready)
addr = id(s) + PyUnicodeObject.utf8_length.offset
buf = (ctypes.c_char * 2).from_address(addr)
print(addr, bytes(buf))

import pandas
print(sys.getsizeof(s))
s = 'a'
ps = PyUnicodeObject.from_address(s)
print(ps, ps.kind, ps.length, ps.interned, ps.ascii, ps.compact, ps.ready)
addr = id(s) + PyUnicodeObject.utf8_length.offset
buf = (ctypes.c_char * 2).from_address(addr)
print(addr, bytes(buf))

我们可以看到 Pandas 将大小从 50 更改为 58,但字段仍然是:

<__main__.PyUnicodeObject object at 0x101bbae18> 1 1 1 1 1 1

…换句话说,它是1BYTE_KIND,长度为 1,凡人实习,ASCII,紧凑,准备就绪。

但是,如果您查看ps.wstr,在 Pandas 之前它是一个空指针,而在 Pandas 之后它是一个指向 wchar_t 字符串 w"a\0" 的指针。 str.__sizeof__ 考虑了 wstr 的大小。


那么,问题是,你如何最终得到一个具有wstr 值的 ascii-compact 字符串?

简单:您在其上调用 PyUnicode_AsUnicode(或访问 3.2 样式本机 wchar_t * 内部存储的其他已弃用函数或宏之一。该本机内部存储实际上并不存在于 3.3+ 中。所以,为了向后兼容,这些调用是通过动态创建该存储,将其粘贴在wstr 成员上,并调用适当的PyUnicode_AsUCS[24] 函数来解码到该存储来处理的。(除非您正在处理一个紧凑的字符串,其kind 恰好匹配 wchar_t 的宽度,在这种情况下,wstr 毕竟只是一个指向本机存储的指针。)

您希望str.__sizeof__ 理想地包含该额外存储,而from the source,您可以看到它确实如此。

让我们验证一下:

import ctypes
import sys
s = 'a'
print(sys.getsizeof(s))
ctypes.pythonapi.PyUnicode_AsUnicode.argtypes = [ctypes.py_object]
ctypes.pythonapi.PyUnicode_AsUnicode.restype = ctypes.c_wchar_p
print(ctypes.pythonapi.PyUnicode_AsUnicode(s))
print(sys.getsizeof(s))

多田,我们的 50 到 58。


那么,您如何确定调用它的位置?

实际上,在 Pandas 和 Numpy 中,有大量对 PyUnicode_AsUnicodePyUnicode_AS_UNICODE 宏以及其他调用它们的函数的调用。所以我在 lldb 中运行 Python 并在PyUnicode_AsUnicode 上附加了一个断点,如果调用堆栈帧与上次相同,则使用脚本跳过。

前几个调用涉及日期时间格式。然后是一个只有一个字母的。堆栈帧是:

multiarray.cpython-36m-darwin.so`UNICODE_setitem + 296

... 及以上multiarray 一直到import pandas 都是纯Python。所以,如果你想知道 Pandas 在哪里调用这个函数,你需要在pdb 中调试,我还没有这样做。但我认为我们现在已经掌握了足够的信息。

【讨论】:

  • 关于我的评论,为大家的困惑道歉 - 混淆变量:)
猜你喜欢
  • 2019-09-12
  • 1970-01-01
  • 1970-01-01
  • 2012-05-09
  • 2013-10-04
  • 1970-01-01
  • 2017-04-17
  • 2013-04-02
  • 2014-07-21
相关资源
最近更新 更多