【问题标题】:Why is list(x for x in a) faster for a=[0] than for a=[]?为什么 a=[0] 的 list(x for x in a) 比 a=[] 快?
【发布时间】:2021-02-02 06:44:29
【问题描述】:

我用三个不同的 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

【问题讨论】:

  • 不知道是不是和预分配有关。 IIRC,空列表生成一个列表对象,该对象具有为未来元素分配的默认空间量。对于非空列表,list分配保存迭代器元素所需的数量。元组,因为它们不能增长,总是只分配足够的空间来容纳来自迭代器的内容。
  • @chepner 你指的是哪个列表?原来的a 还是创建的浅拷贝? a 是在设置过程中创建的,list(...)tuple(...) 都无法提前知道结果的大小。刚试过,(x for x in []).__length_hint__() 报错(不像iter([]).__length_hint__(),返回0)。
  • 我无法重现您的结果(Python 3.7 64 位/Win 10 Pro)。有时[] 更快,有时[0]
  • @OcasoProtal 很奇怪,我看到 [] 在 Python 3.7 64 位 Win10 Pro 上花费的时间是 [0] 的两倍左右
  • @HeapOverflow 我也无法重现您的结果;作为参考,我获得了:[]: 463 ns ± 2.69 ns per loop (mean ± std. dev. of 15 runs, 10000000 loops each)[0]: 471 ns ± 3.4 ns per loop (mean ± std. dev. of 15 runs, 10000000 loops each)。您还应该检查 std.dev。以确保您的结果不会有太大差异。另请提供有关您如何获得这些 Python 发行版的详细信息。

标签: python performance cpython python-internals


【解决方案1】:

您观察到,pymalloc (Python memory manager) 比 C 运行时提供的内存管理器更快。

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

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

n = PyObject_LengthHint(iterable, 8);

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

迭代器耗尽后,列表尝试to shrink,因为只有0 或1 个元素(由于size-hint 太大而没有分配原始容量)。对于 1 个元素,这将导致(由于过度分配)4 个元素的容量。但是,对于 0 元素的情况有一个特殊处理:它将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 向下传递到pymalloc_alloc,如果为0 字节,则返回NULL

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

但是,如果pymalloc 返回NULL,则_PyObject_Malloc falls back 为“原始” malloc:

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,调整其他成员并返回。

让我们试试吧:

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;
    }
    // ...
}

通过此修复,空箱比a=[0] 箱快一点(大约 10%),正如预期的那样。


我的说法是,pymalloc 比 C 运行时内存管理器更快,可以使用 bytes 轻松测试:如果需要分配超过 512 个字节,pymalloc 将回退到简单的 @ 987654368@:

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)。注意:some compilers would optimizefree(malloc(size))out,但是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 的内置性能分析器一起使用。

a=[0]-version 需要 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%

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

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


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

【讨论】:

  • @ead 干得好,你也知道为什么pymalloc 更快吗?我正在阅读source code,但我不确定我是否理解正确。 pymalloc 是否只是为在性能测试循环的每次迭代期间创建的每个新的 listss 重复使用完全相同的内存?那么如果只运行一次迭代,这种性能差异会消失吗(从那时起就没有内存可以重用)?
  • @a_guest,我不知道为什么 pymalloc 更快(我真的不知道堆内存在 Windows 上是如何工作的,对 glibc 的实现也不太了解)。一件事可能是,pymalloc 不适用于多线程(谢谢,GIL),所以它不必锁定任何东西。然而,这只是一种猜测。但这并不让我感到惊讶:如果它不能胜过底层内存管理器,那么编写 pymalloc 会很奇怪。
  • 我觉得Py_SIZE(self) = 0;应该是Py_SET_SIZE(self, 0);,比如here
  • @HeapOverflow Py_SET_SIZE 是相当新的(3.9,docs.python.org/3/c-api/structures.html#c.Py_SET_SIZE)并且是以下工作的一部分bugs.python.org/issue39573。所以对于 Python>=3.9 Py_SET_SIZE 应该是首选,对于较旧的 Python 版本 Py_SIZE(self) 是要走的路。
猜你喜欢
  • 2012-09-10
  • 1970-01-01
  • 1970-01-01
  • 2016-06-02
  • 2014-06-01
  • 1970-01-01
  • 1970-01-01
  • 2011-04-14
  • 2015-01-18
相关资源
最近更新 更多