【问题标题】:Why is shuffling list(range(n)) slower than shuffling [0]*n?为什么改组列表(范围(n))比改组[0] * n慢?
【发布时间】:2020-06-26 21:38:36
【问题描述】:

使用random.shuffle,我注意到改组list(range(n)) 比改组[0] * n 多花费25% 的时间。以下是大小 n 从 100 万到 200 万的时间:

为什么洗牌list(range(n)) 变慢了?与排序列表(需要查看对象)或复制列表(增加对象内部的引用计数器)不同,对象在这里应该无关紧要。这应该只是重新排列列表内的指针。

我还尝试了numpy.random.shuffle,其中改组list(range(n)) 比改组[0] * n 慢三倍(!):

我还尝试了第三种方法来重新排列列表中的元素,即list.reverse。正如预期的那样,这两个列表花费了同样长的时间:

为了以防洗牌顺序很重要,我在洗牌后也尝试了list.reverse。同样,正如预期的那样,这两个列表花费了同样长的时间,并且在没有事先改组的情况下也同样长:

那么有什么区别呢? shuffle 和 reversing 都只需要重新排列列表内的指针,为什么对象对 shuffle 很重要,而对 reversing 不重要?

我的基准代码生成时间:

import random
import numpy
from timeit import repeat, timeit
from collections import defaultdict

shufflers = {
    'random.shuffle(mylist)': random.shuffle,
    'numpy.random.shuffle(mylist)': numpy.random.shuffle,
    'list.reverse(mylist)': list.reverse,
    }

creators = {
    'list(range(n))': lambda n: list(range(n)),
    '[0] * n': lambda n: [0] * n,
    }

for shuffler in shufflers:
    print(shuffler)
    for creator in creators:
        print(creator)
        times = defaultdict(list)
        for _ in range(10):
            for i in range(10, 21):
                n = i * 100_000
                mylist = creators[creator](n)
                # Uncomment next line for pre-shuffling
                # numpy.random.shuffle(mylist)
                time = timeit(lambda: shufflers[shuffler](mylist), number=1)
                times[n].append(time)
                s = '%.6f ' * len(times[n])
        # Indent next line further to see intermediate results
        print([round(min(times[n]), 9) for n in sorted(times)])

【问题讨论】:

  • 也许改组通过交换条目来工作,代码检查值是否不同,如果它们相同则不交换?你试过看random.shuffle的源码吗?
  • @barny 它不会那样做。但你确实可以在那里找到大约 40% 的解释。
  • 好奇 PyPy3 上的图是什么样子的...

标签: python performance shuffle


【解决方案1】:

(注意:我没有时间完成这个答案,所以这是一个开始 - 这绝对不适合评论,希望它可以帮助其他人完成这个!)


这似乎是由于引用的局部性(也许是 cpython 实现细节——例如,我在 pypy 中看不到相同的结果)

在尝试解释之前的几个数据点:

random.shuffle 是在纯 python 中实现的,适用于任何可变序列类型——它不是专门用于列表的。

  • 这意味着每次交换都涉及__getitem__,增加项目的引用计数,__setitem__,减少项目的引用计数

list.reverse 用 C 实现,仅适用于 list(使用列表的实现细节)

  • 这意味着每次交换都不会调用__getitem__ 或更改引用计数。列表的内部项目直接重新排列

重要的一点是引用计数

在 cpython 中,the reference count is stored with the object itself,几乎所有对象都存储在堆中。为了调整引用计数(即使是暂时的),对ob_refcnt 的写入将在PyObject 结构中分页到缓存/内存/等。

(这是我没时间的地方——我可能会做一些内存故障分析来确认这个假设)

【讨论】:

  • 我现在添加了自己的答案,但我仍然对你提到的内存故障分析感兴趣,如果你能提供,我很乐意接受你的答案。我自己尝试过,了解了perffrom Victor Stinner,但是在我尝试过的机器上,它没有提供缓存统计信息:-(
【解决方案2】:

不同之处在于list.reverse 作为list 函数可以访问底层指针数组。所以它确实可以重新排列指针而无需以任何方式查看对象(source):

reverse_slice(PyObject **lo, PyObject **hi)
{
    assert(lo && hi);

    --hi;
    while (lo < hi) {
        PyObject *t = *lo;
        *lo = *hi;
        *hi = t;
        ++lo;
        --hi;
    }
}

另一方面,random.shufflenumpy.random.shuffle 函数只有一个局外人视图并通过列表的界面,这涉及到短暂加载对象以交换它们:

random.shuffle:

    def shuffle(self, x, random=None):
        ...
            for i in reversed(range(1, len(x))):
                # pick an element in x[:i+1] with which to exchange x[i]
                j = randbelow(i+1)
                x[i], x[j] = x[j], x[i]

numpy.random.shuffle:

    def shuffle(self, object x, axis=0):
          ...
                for i in reversed(range(1, n)):
                    j = random_interval(&self._bitgen, i)
                    x[i], x[j] = x[j], x[i]

所以至少有潜在很多缓存未命中。但是,让我们在 Python 中尝试一下reverse

    def my_reverse(x):
        lo = 0
        hi = len(x) - 1
        while lo < hi:
            x[lo], x[hi] = x[hi], x[lo]
            lo += 1
            hi -= 1

基准测试:

尽管加载了对象,但反转 list(range(n)) 与反转 [0] * n 一样快。原因是 Python 在内存中几乎按顺序创建对象。这是一个包含一百万个对象的测试。几乎所有都位于前一个之后 16 个字节:

>>> mylist = list(range(10**6))
>>> from collections import Counter
>>> ctr = Counter(id(b) - id(a) for a, b in zip(mylist, mylist[1:]))
>>> for distance, how_often in ctr.most_common():
        print(distance, how_often)

16 996056
48 3933
-1584548240 1
-3024 1
2416 1
-2240 1
2832 1
-304 1
-96 1
-45005904 1
6160432 1
38862896 1

难怪它很快,因为它对缓存非常友好。

但是现在让我们在 shuffled 列表上使用我们的 Python 反转(就像在list.reverse 的问题中一样,它没有任何区别):

很大的不同,现在my_reverse 从各处随机加载对象,这与缓存友好相反。

当然,shuffle 函数也是如此。虽然list(range(n)) 最初是缓存友好的,但改组选择随机索引j 进行交换,这对缓存非常不友好。虽然i 只是按顺序移动,但它会遇到很多已经随机交换的对象,所以这也是缓存不友好的。

【讨论】:

    猜你喜欢
    • 2020-03-19
    • 1970-01-01
    • 2019-02-28
    • 1970-01-01
    • 2015-05-04
    • 1970-01-01
    • 2020-01-10
    • 1970-01-01
    相关资源
    最近更新 更多