与`sys.getsizeof`不一致

5

为什么Python中长度为1的str对象使用sys.getsizeof()函数时比长度为2的str对象返回的值更大?(当字符串长度超过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'

2
你使用的是哪个版本的Python 3.x?(以及平台、32位或64位、python.org安装程序或其他什么等等)因为在我可以访问的所有64位CPython 3.4、3.6或3.7上,第一个字符串都会返回50。 - abarnert
1
我也得到了50。 - user2357112
支持abarnert的说法,在3.6.6或3.7上没有复现。 - miradulo
全新推出,重新上线,并仅运行这4行代码。并且ipython版本为6.4.0。 - Brad Solomon
这就是我正在查看的@abarnert。 - Brad Solomon
显示剩余10条评论
2个回答

5
当你导入pandas时,它会执行大量的NumPy操作,包括调用UNICODE_setitem来处理所有单个ASCII字母字符串,在其他地方也可能对单个ASCII数字字符串执行类似的操作。
这个NumPy函数调用了已弃用的C API PyUnicode_AsUnicode
当你在CPython 3.3+中调用它时,它会将wchar_t *表示形式缓存在字符串内部结构的wstr成员中,作为两个wchar_t值w'a''\0',这在32位wchar_t构建的Python中占用8个字节。并且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安装中(但所有64位CPython 3.4或更高版本在Linux或macOS上),我得到了相同的结果:
{'\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 数字和字母,不影响其他字符。
另外,如果你把所有的 print 改成 print(sizes.values()),这样字符串就不会被编码输出,你会得到相同的结果,这意味着要么不是关于缓存 UTF-8,要么即使我们不强制使用它,它也总是发生。
明显的可能性是Pandas正在调用遗留的PyUnicode API之一,以生成所有ASCII数字和字母的单字符字符串。所以这些字符串最终不是以紧凑的ASCII格式,而是以遗留的格式呈现,对吗?(有关此含义的详细信息,请参见源代码中的注释。)
不是的。使用我superhackyinternals的代码,我们可以看到它仍然是紧凑的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 紧凑字符串?
简单:您调用 PyUnicode_AsUnicode(或者其他访问 3.2 风格本地 wchar_t * 内部存储的已弃用函数或宏)。该本地内部存储在 3.3+ 中实际上并不存在。因此,为了向后兼容,这些调用通过即时创建该存储空间、将其放置在 wstr 成员上,并调用相应的 PyUnicode_AsUCS[24] 函数对其进行解码来处理。(除非您处理的紧凑字符串的种类恰好与 wchar_t 宽度匹配,在这种情况下,wstr 只是指向本地存储的指针。)
您希望 str.__sizeof__ 最好包括该额外存储空间,而 从源代码中,您可以看到它确实包括。

让我们验证一下:

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中进行调试,这是我尚未完成的。但是我认为我们现在已经有足够的信息了。


对于我的评论,你说得很对,抱歉让大家感到困惑 - 混淆变量 :) - miradulo

4

Python 3.3及以上版本的str是一个相当复杂的结构,根据字符串使用的API和代码点所表示的内容,其底层数据可以以最多三种不同的方式存储。最常见的替代表示情形是缓存的UTF-8表示,但这仅适用于非ASCII字符串,因此在此不适用。

在本例中,我猜测单个字符的字符串(作为实现细节,是一个单例)被使用以触发传统的wchar_t*表示的创建(使用传统的Py_UNICODE API可能导致这种情况),而您的Python构建使用四个字节的wchar_t,使得字符串比它本来应该有的长度多八个字节(四个是因为字符a,另外四个是因为NUL终止符)。事实上,它是一个单例,这意味着即使您可能从未触发过这样的传统API调用,任何检索单例引用的扩展都将通过将其与遗留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
@abarnert:我表达得不太好,我的意思是最常见的替代表示法(我已经编辑过来修正这个陈述)。如果固定宽度为1字节和Latin-1,则与固定宽度不同,仅当固定宽度为1字节和ASCII时才相同。 - ShadowRanger
我有一个在启动时执行的 startup.py 文件。当我将其注释掉时,这种不一致性就消失了。它所做的唯一奇怪的事情是暂时将 sys.stdout 重定向到 os.devnull。但我不知道这是否是原因。总之,你的答案是有帮助的。 - Brad Solomon
@ShadowRanger,这就是为什么我说“可以……如果……”而不是“将会”或“当且仅当”(因为我不想在评论中详细说明所有细节)。我只是想指出,它的措辞有点误导,假设你能比我更好地修复它。 :) - abarnert
1
@miradulo 不足为奇;那个函数做了各种各样的事情,最初是用早期2.x Python编写的C代码,并且这段代码可能一直没有更新的优先级(因为显然你从未在内部循环或其他任何地方使用它),所以... - abarnert
显示剩余5条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接