为什么 list(x for x in a) 对于 a=[0] 比对于 a=[] 更快?

Why is list(x for x in a) faster for a=[0] than for a=[]?

我用三种不同的 CPython 版本测试了 list(x for x in a)。在 a = [0] 上它比在 a = [] 上快得多:

 3.9.0 64-bit       3.9.0 32-bit       3.7.8 64-bit
a = []  a = [0]    a = []  a = [0]    a = []  a = [0]

465 ns  412 ns     543 ns  515 ns     513 ns  457 ns   
450 ns  406 ns     544 ns  515 ns     506 ns  491 ns   
456 ns  408 ns     551 ns  513 ns     515 ns  487 ns   
455 ns  413 ns     548 ns  516 ns     513 ns  491 ns   
452 ns  404 ns     549 ns  511 ns     508 ns  486 ns   

使用 tuple 而不是 list,这是预期的相反方式:

 3.9.0 64-bit       3.9.0 32-bit       3.7.8 64-bit
a = []  a = [0]    a = []  a = [0]    a = []  a = [0]

354 ns  405 ns     467 ns  514 ns     421 ns  465 ns   
364 ns  407 ns     467 ns  527 ns     425 ns  464 ns   
353 ns  399 ns     490 ns  549 ns     419 ns  465 ns   
352 ns  400 ns     500 ns  556 ns     414 ns  474 ns   
354 ns  405 ns     494 ns  560 ns     420 ns  474 ns   

那么为什么 list 在它(和底层生成器迭代器)必须做更多事情时更快?

在 Windows 10 Pro 2004 64 位上测试。

基准代码:

from timeit import repeat

setups = 'a = []', 'a = [0]'
number = 10**6

print(*setups, sep='   ')
for _ in range(5):
    for setup in setups:
        t = min(repeat('list(x for x in a)', setup, number=number)) / number
        print('%d ns' % (t * 1e9), end='   ')
    print()

字节大小,表明它不会为输入 [] 过度分配,但会为输入 [0]:

>>> [].__sizeof__()
40
>>> list(x for x in []).__sizeof__()
40

>>> [0].__sizeof__()
48
>>> list(x for x in [0]).__sizeof__()
72

您观察到 pymalloc (Python memory manager) 比 C-runtime.

提供的内存管理器更快

在分析器中很容易看出,两个版本之间的主要区别在于 list_resize_PyObjectRealloc 需要更多时间来处理 a=[]-case。但是为什么?

当从可迭代对象创建新列表时,列表会尝试 to get a hint 迭代器中有多少元素:

n = PyObject_LengthHint(iterable, 8);

但是,此 doesn't work for generators 和提示是默认值 8

迭代器耗尽后,列表尝试to shrink, because there are only 0 or 1 element (and not the original capacity allocated due to a too large size-hint). For 1 element this would lead to (due to over-allocation) capacity of 4 elements. However, there is a special handling for the case of 0 elements: it will not be over-allocated:

// ...
if (newsize == 0)
        new_allocated = 0;
num_allocated_bytes = new_allocated * sizeof(PyObject *);
items = (PyObject **)PyMem_Realloc(self->ob_item, num_allocated_bytes);
// ...

所以在“空”的情况下,PyMem_Realloc 将被要求 0 个字节。此调用将通过 _PyObject_Malloc down to pymalloc_alloc 传递,在 0 字节的情况下 returns NULL:

if (UNLIKELY(nbytes == 0)) {
   return NULL;
}

但是,_PyObject_Malloc falls back 到“原始”malloc,如果 pymalloc returns NULL:

static void *
_PyObject_Malloc(void *ctx, size_t nbytes)
{
    void* ptr = pymalloc_alloc(ctx, nbytes);
    if (LIKELY(ptr != NULL)) {
        return ptr;
    }

    ptr = PyMem_RawMalloc(nbytes);
    if (ptr != NULL) {
        raw_allocated_blocks++;
    }
    return ptr;
}

definition of _PyMem_RawMalloc中很容易看出:

static void *
_PyMem_RawMalloc(void *ctx, size_t size)
{
    /* PyMem_RawMalloc(0) means malloc(1). Some systems would return NULL
       for malloc(0), which would be treated as an error. Some platforms would
       return a pointer with no memory behind it, which would break pymalloc.
       To solve these problems, allocate an extra byte. */
    if (size == 0)
        size = 1;
    return malloc(size);
}

因此,案例 a=[0] 将使用 pymalloc,而 a=[] 将使用底层 c-runtime 的内存管理器,这解释了观察到的差异。


现在,这一切都可以看作是错过了优化,因为对于 newsize=0,我们可以将 ob_item 设置为 NULL,调整其他成员和 return .

让我们试试看:

static int
list_resize(PyListObject *self, Py_ssize_t newsize)
{
    // ...
    if (newsize == 0) {
        PyMem_Del(self->ob_item);
        self->ob_item = NULL;
        Py_SIZE(self) = 0;
        self->allocated = 0;
        return 0;
    }
    // ...
}

通过此修复,empty-case 变得比 a=[0] 情况稍快(大约 10%),正如预期的那样。


我的主张,pymalloc 比 C-runtime 内存管理器更快,可以用 bytes 轻松测试:如果需要分配超过 512 个字节, pymalloc 将退回到简单的 malloc:

print(bytes(479).__sizeof__())   #  512
%timeit bytes(479)               # 189 ns ± 20.4 ns
print(bytes(480).__sizeof__())   #  513
%timeit bytes(480)               # 296 ns ± 24.8 ns

实际差异大于显示的 50%(这种跳跃不能仅用一个字节的大小变化来解释),因为至少有一部分时间用于字节对象的初始化等.

下面是在cython的帮助下更直接的比较:

%%cython
from libc.stdlib cimport malloc, free
from cpython cimport PyMem_Malloc, PyMem_Del

def with_pymalloc(int size):
    cdef int i
    for i in range(1000):
        PyMem_Del(PyMem_Malloc(size))
        
def with_cmalloc(int size):
    cdef int i
    for i in range(1000):
        free(malloc(size))  

现在

%timeit with_pymalloc(1)   #  15.8 µs ± 566 ns
%timeit with_cmalloc(1)    #  51.9 µs ± 2.17 µs

pymalloc 大约快 3 倍(或每次分配大约 35ns)。注: free(malloc(size)) out, but MSVC doesn't.

再举一个例子:前段时间我通过 pymalloc 为 c++ 的 std::map 替换了默认分配器,这导致了 a speed up of factor 4.


为了分析,使用了以下脚本:

a=[0] # or a=[]
for _ in range(10000000):
    list(x for x in a)

连同 VisualStudio 的 built-in 性能分析器 Release-mode。

a=[0]-版本需要 6.6 秒(在分析器中),而 a=[] 版本需要 6.9 秒(即大约慢 5%)。 “修复”后,a=[] 只需要 5.8 秒。

花在 list_resize_PyObject_Realloc 上的时间比例:

                     a=[0]          a=[]       a=[], fixed        
list_resize           3.5%          10.2%          3%
_PyObject_Realloc     3.2%           9.3%          1%

显然,与 运行 运行 存在差异,但 运行ning 时间的差异很显着,可以解释大部分观察到的时间差异。

注意:10^7 分配的 0.3 秒的差异是每个分配大约 30ns - 这个数字类似于我们得到的 pymalloc 和 c-runtime 之间的差异分配。


当用调试器验证上面的内容时,必须知道,在debug-mode Python中使用了调试版本的pymalloc,它会向所需内存追加额外的数据,因此pymalloc永远不会被要求在 debug-version 中分配 0 个字节,但是 0 bytes + debug-overhead 并且不会回退到 malloc。因此,应该在 debug-build 中切换到 realease-pymalloc 的发布模式进行调试(可能有一个选项 - 我只是不知道,代码中的相关部分是 here and here).