是啥导致 [*a] 过度分配?

Posted

技术标签:

【中文标题】是啥导致 [*a] 过度分配?【英文标题】:What causes [*a] to overallocate?是什么导致 [*a] 过度分配? 【发布时间】:2020-06-18 08:25:19 【问题描述】:

显然list(a) 没有过度分配,[x for x in a] 在某些时候过度分配,而[*a] 总是过度分配

这里是从 0 到 12 的大小 n 以及这三种方法的结果大小(以字节为单位):

0 56 56 56
1 64 88 88
2 72 88 96
3 80 88 104
4 88 88 112
5 96 120 120
6 104 120 128
7 112 120 136
8 120 120 152
9 128 184 184
10 136 184 192
11 144 184 200
12 152 184 208

这样计算,reproducable at repl.it,使用 Python 3。8

from sys import getsizeof

for n in range(13):
    a = [None] * n
    print(n, getsizeof(list(a)),
             getsizeof([x for x in a]),
             getsizeof([*a]))

那么:这是如何工作的? [*a] 是如何过度分配的?实际上,它使用什么机制从给定的输入创建结果列表?它是否在a 上使用迭代器并使用list.append 之类的东西?源代码在哪里?

(生成图像的Colab with data and code。)

放大到更小的 n:

缩小到更大的n:

【问题讨论】:

Fwiw,扩展您的测试用例,似乎列表理解的行为类似于编写循环并将每个项目附加到列表中,而 [*a] 似乎表现为在空列表上使用 extend . 查看为每个生成的字节码可能会有所帮助。 list(a) 完全在 C 中运行;它可以在迭代a 时逐个节点分配内部缓冲区。 [x for x in a] 只是经常使用LIST_APPEND,所以它遵循正常列表的正常“过度分配一点,必要时重新分配”模式。 [*a] 使用 BUILD_LIST_UNPACK,它......我不知道那是什么,除了显然一直过度分配:) 另外,在 Python 3.7 中,list(a)[*a] 似乎是相同的,并且 both[x for x in a] 相比过度分配,所以...sys.getsizeof可能不适合在这里使用。 @chepner 我认为sys.getsizeof 是正确的工具,它只是表明list(a) 曾经过度分配。实际上What’s New In Python 3.8 提到了它:“列表构造函数没有过度分配[...]” @chepner: 那was a bug fixed in 3.8;构造函数不应该过度分配。 【参考方案1】:

[*a]is internally doing the C equivalent of:

    新建一个空list 致电newlist.extend(a) 返回list

因此,如果您将测试扩展到:

from sys import getsizeof

for n in range(13):
    a = [None] * n
    l = []
    l.extend(a)
    print(n, getsizeof(list(a)),
             getsizeof([x for x in a]),
             getsizeof([*a]),
             getsizeof(l))

Try it online!

您会看到getsizeof([*a])l = []; l.extend(a); getsizeof(l) 的结果相同。

这通常是正确的做法;当extending 您通常希望稍后添加更多内容时,同样对于通用解包,假设多个内容将一个接一个地添加。 [*a] 不是正常情况; Python 假设有多个项目或可迭代对象被添加到 list ([*a, b, c, *d]),因此过度分配可以节省常见情况下的工作。

相比之下,list 由单个预定义的可迭代对象(带有list())构造而成,在使用过程中可能不会增长或缩小,除非另有证明,否则过度分配还为时过早; Python recently fixed a bug that made the constructor overallocate even for inputs with known size.

至于list 理解,它们实际上等效于重复的appends,因此您在一次添加一个元素时看到了正常过度分配增长模式的最终结果。

需要明确的是,这些都不是语言保证。这就是 CPython 实现它的方式。 Python 语言规范通常不关心list 中的特定增长模式(除了从最后保证摊销O(1)appends 和pops)。如 cmets 中所述,具体实现在 3.9 中再次更改;虽然它不会影响[*a],但它可能会影响其他情况,以前“构建单个项目的临时tuple,然后使用extend 使用tuple”现在变成LIST_APPEND 的多个应用程序,当发生过度分配以及计算中的数字时,这可能会发生变化。

【讨论】:

@StefanPochmann:我以前读过代码(这就是我已经知道的原因)。 This is the byte code handler for BUILD_LIST_UNPACK,它使用_PyList_Extend 作为调用extend 的C 等效项(只是直接调用,而不是通过方法查找)。他们将它与构建tuple 的路径结合起来,并进行解包; tuples 不会为零碎的构建很好地过度分配,所以他们总是解压到 list(从过度分配中受益),并在最后转换为 tuple 请注意这个apparently changes in 3.9,其中的构造是使用单独的字节码完成的(BUILD_LISTLIST_EXTEND 用于解包的每个东西,LIST_APPEND 用于单个项目),而不是加载所有内容使用单字节代码指令构建整个 list 之前的堆栈(它允许编译器执行多合一指令不允许的优化,例如将 [*a, b, *c] 实现为 LIST_EXTENDLIST_APPENDLIST_EXTEND w/o 需要将b 包裹在一个tuple 中以满足BUILD_LIST_UNPACK 的要求。【参考方案2】:

发生了什么的全貌,建立在其他答案和 cmets 的基础上(尤其是 ShadowRanger's answer,这也解释了 为什么这样做)。

反汇编显示BUILD_LIST_UNPACK被使用:

>>> import dis
>>> dis.dis('[*a]')
  1           0 LOAD_NAME                0 (a)
              2 BUILD_LIST_UNPACK        1
              4 RETURN_VALUE

已处理in ceval.c,它构建一个空列表并扩展它(使用a):

        case TARGET(BUILD_LIST_UNPACK): 
            ...
            PyObject *sum = PyList_New(0);
              ...
                none_val = _PyList_Extend((PyListObject *)sum, PEEK(i));

_PyList_Extenduseslist_extend:

_PyList_Extend(PyListObject *self, PyObject *iterable)

    return list_extend(self, iterable);

哪个calls list_resize with the sum of the sizes:

list_extend(PyListObject *self, PyObject *iterable)
    ...
        n = PySequence_Fast_GET_SIZE(iterable);
        ...
        m = Py_SIZE(self);
        ...
        if (list_resize(self, m + n) < 0) 

那overallocates如下:

list_resize(PyListObject *self, Py_ssize_t newsize)

  ...
    new_allocated = (size_t)newsize + (newsize >> 3) + (newsize < 9 ? 3 : 6);

让我们检查一下。使用上面的公式计算预期的点数,并通过将其乘以 8(因为我在这里使用 64 位 Python)并添加一个空列表的字节大小(即列表对象的恒定开销)来计算预期的字节大小:

from sys import getsizeof
for n in range(13):
    a = [None] * n
    expected_spots = n + (n >> 3) + (3 if n < 9 else 6)
    expected_bytesize = getsizeof([]) + expected_spots * 8
    real_bytesize = getsizeof([*a])
    print(n,
          expected_bytesize,
          real_bytesize,
          real_bytesize == expected_bytesize)

输出:

0 80 56 False
1 88 88 True
2 96 96 True
3 104 104 True
4 112 112 True
5 120 120 True
6 128 128 True
7 136 136 True
8 152 152 True
9 184 184 True
10 192 192 True
11 200 200 True
12 208 208 True

匹配除了n = 0list_extend 实际上是shortcuts,所以实际上也匹配:

        if (n == 0) 
            ...
            Py_RETURN_NONE;
        
        ...
        if (list_resize(self, m + n) < 0) 

【讨论】:

【参考方案3】:

这些将是 CPython 解释器的实现细节,因此在其他解释器中可能不一致。

也就是说,您可以在这里看到理解和 list(a) 行为的位置:

https://github.com/python/cpython/blob/master/Objects/listobject.c#L36

专门用于理解:

 * The growth pattern is:  0, 4, 8, 16, 25, 35, 46, 58, 72, 88, ...
...

new_allocated = (size_t)newsize + (newsize >> 3) + (newsize < 9 ? 3 : 6);

就在这些行的下方,有list_preallocate_exact,用于调用list(a)

【讨论】:

[*a] 不会一次添加单个元素。它有自己的专用字节码,可以通过extend 进行批量插入。 知道了——我想我在这方面还不够深入。删除了[*a] 上的部分

以上是关于是啥导致 [*a] 过度分配?的主要内容,如果未能解决你的问题,请参考以下文章

当为“var”和“let”分配一个引发错误的函数的返回值时,是啥导致了它们之间的不同行为

读书:CFA_经济学

当内核使用过度使用内存时,是不是需要在分配内存后检查 NULL

通过过度分配内存在结构中内联可变长度数组是不是有效?

.NET 可用内存使用情况(如何防止过度分配/释放内存给操作系统)

在 Linux 上防止内存不足 (OOM) 冻结的最佳方法是啥?