【问题标题】:Why is copying a shuffled list much slower?为什么复制洗牌列表要慢得多?
【发布时间】:2017-06-25 17:39:22
【问题描述】:

复制一个洗牌的range(10**6) 列表十次需要我大约 0.18 秒:(这是五次运行)

0.175597017661
0.173731403198
0.178601711594
0.180330912952
0.180811964451

将未洗牌的列表复制十次大约需要 0.05 秒:

0.058402235973
0.0505464636856
0.0509734306934
0.0526022752744
0.0513324916184

这是我的测试代码:

from timeit import timeit
import random

a = range(10**6)
random.shuffle(a)    # Remove this for the second test.
a = list(a)          # Just an attempt to "normalize" the list.
for _ in range(5):
    print timeit(lambda: list(a), number=10)

我也试过用a[:]复制,结果差不多(就是速度差很大)

为什么速度差异很大?我知道并理解著名的Why is it faster to process a sorted array than an unsorted array? 示例中的速度差异,但在这里我的处理没有决定。只是盲目地复制列表中的引用,不是吗?

我在 Windows 10 上使用 Python 2.7.12。

编辑:现在也尝试了 Python 3.5.2,结果几乎相同(始终在 0.17 秒左右混洗,在 0.05 秒左右始终未混洗)。这是代码:

a = list(range(10**6))
random.shuffle(a)
a = list(a)
for _ in range(5):
    print(timeit(lambda: list(a), number=10))

【问题讨论】:

  • 请不要对我大喊大叫,我是来帮你的!更改顺序后,我在每个测试的每次迭代中得到大约 0.25。所以在我的平台上,顺序很重要。
  • @vaultah 谢谢,但我现在已经读过了,我不同意。当我看到那里的代码时,我立刻想到了ints的cache hits/misses,这也是作者的结论。但他的代码添加数字,这需要查看它们。我的代码没有。我的只需要复制引用,而不是通过它们访问。
  • @vaultah 的链接中有一个完整的答案(你现在有点不同意,我明白了)。但无论如何我仍然认为我们不应该将python用于低级功能,因此需要担心。不过这个话题还是很有趣的,谢谢。
  • @NikolayProkopyev 是的,我并不担心,只是在做其他事情时注意到这一点,无法解释,并且很好奇。我很高兴我现在提出并得到了答案:-)

标签: python python-internals


【解决方案1】:

有趣的是,它取决于整数首先创建的顺序。例如,用random.randint 代替shuffle 创建一个随机序列:

from timeit import timeit
import random

a = [random.randint(0, 10**6) for _ in range(10**6)]
for _ in range(5):
    print(timeit(lambda: list(a), number=10))

这与复制您的 list(range(10**6)) 一样快(第一个快速示例)。

但是,当您洗牌时,您的整数不再按照它们最初创建的顺序排列,这就是让它变慢的原因。

快速的间奏曲:

  • 所有 Python 对象都在堆上,因此每个对象都是一个指针。
  • 复制列表是一种浅层操作。
  • 然而 Python 使用引用计数,所以当一个对象被放入一个新容器时,它的引用计数必须增加 (Py_INCREF in list_slice),所以 Python 确实需要找到对象所在的位置。它不能只是复制引用。

因此,当您复制列表时,您会获取该列表的每个项目并将其“按原样”放入新列表中。当您的下一个项目在当前项目之后不久创建时,很有可能(不能保证!)它在堆中保存在它旁边。

假设您的计算机在缓存中加载项目时,它也会加载x 内存中的下一个项目(缓存位置)。然后你的计算机就可以对同一缓存上的x+1 项执行引用计数递增了!

使用打乱的序列,它仍然会加载内存中的下一个项目,但这些不是列表中的下一个。因此,如果不“真正”寻找下一项,它就无法执行引用计数递增。

TL;DR:实际速度取决于复制之前发生的事情:这些项目是按什么顺序创建的,以及它们在列表中的顺序。


您可以通过查看id 来验证这一点:

CPython 实现细节:这是对象在内存中的地址。

a = list(range(10**6, 10**6+100))
for item in a:
    print(id(item))

只是为了显示一个简短的摘录:

1496489995888
1496489995920  # +32
1496489995952  # +32
1496489995984  # +32
1496489996016  # +32
1496489996048  # +32
1496489996080  # +32
1496489996112
1496489996144
1496489996176
1496489996208
1496489996240
1496507297840
1496507297872
1496507297904
1496507297936
1496507297968
1496507298000
1496507298032
1496507298064
1496507298096
1496507298128
1496507298160
1496507298192

所以这些对象实际上是“在堆上彼此相邻”。 shuffle 他们不是:

import random
a = list(range(10**6, 100+10**6))
random.shuffle(a)
last = None
for item in a:
    if last is not None:
        print('diff', id(item) - id(last))
    last = item

这表明它们在内存中并没有真正相邻:

diff 736
diff -64
diff -17291008
diff -128
diff 288
diff -224
diff 17292032
diff -1312
diff 1088
diff -17292384
diff 17291072
diff 608
diff -17290848
diff 17289856
diff 928
diff -672
diff 864
diff -17290816
diff -128
diff -96
diff 17291552
diff -192
diff 96
diff -17291904
diff 17291680
diff -1152
diff 896
diff -17290528
diff 17290816
diff -992
diff 448

重要提示:

我自己没有想到这一点。大部分信息都可以在blogpost of Ricky Stewart找到。

此答案基于 Python 的“官方”CPython 实现。其他实现(Jython、PyPy、IronPython,...)中的细节可能不同。谢谢@JörgWMittag for pointing this out

【讨论】:

  • @augurar 复制引用意味着增加对象中的引用计数器(因此对象访问是不可避免的)
  • @StefanPochmann 进行复制的函数是 list_slice,在第 453 行,您可以看到需要访问堆分配对象的 Py_INCREF(v); 调用。
  • @MSeifert 另一个很好的实验是使用a = [0] * 10**7(从 10**6 上升,因为那太不稳定了),它甚至比使用a = range(10**7) 更快(大约 1.25 倍)。很明显,因为这对缓存更好。
  • 我只是想知道为什么我在 64 位计算机上使用 python 64 位得到 32 位整数。但实际上这对缓存也有好处 :-) 即使[0,1,2,3]*((10**6) // 4)a = [0] * 10**6 一样快。然而,对于 0-255 的整数,还有另一个事实:这些是被实习的,因此创建顺序(在您的脚本中)不再重要 - 因为它们是在您启动 python 时创建的。
  • 请注意,目前存在的四个生产就绪 Python 实现中,只有 一个 使用引用计数。因此,这种分析实际上只适用于单个实现。
【解决方案2】:

当你打乱列表项时,它们的引用局部性更差,导致缓存性能更差。

您可能认为复制列表只是复制引用,而不是对象,因此它们在堆上的位置无关紧要。但是,复制仍然涉及访问每个对象以修改引用计数。

【讨论】:

  • 这对 me 来说可能是一个更好的答案(至少如果它有一个链接到像 MSeifert 那样的“证明”),因为这就是我所缺少的,而且非常简洁,但我认为我会坚持使用 MSeifert,因为我觉得它可能对其他人更好。不过,也对此表示赞同,谢谢。
  • 还将添加 pentioids、athlums 等具有神秘逻辑来检测地址模式,并在他们看到模式时开始预取数据。在这种情况下,当数字有序时,可能会开始预取数据(减少缓存未命中)。当然,这种影响是增加了来自本地的命中率。
【解决方案3】:

正如其他人所解释的,它不仅仅是复制引用,还增加了对象内部的引用计数,因此对象被访问,缓存发挥了作用。

在这里我只想添加更多的实验。洗牌与非洗牌(访问一个元素可能会错过缓存但将以下元素放入缓存中以便它们被命中)并没有那么多。但是关于重复元素,以后对相同元素的访问可能会命中缓存,因为该元素仍在缓存中。

测试正常范围:

>>> from timeit import timeit
>>> a = range(10**7)
>>> [timeit(lambda: list(a), number=100) for _ in range(3)]
[5.1915339142808925, 5.1436351868889645, 5.18055115701749]

一个大小相同但只有一个元素反复重复的列表更快,因为它一直在缓存中:

>>> a = [0] * 10**7
>>> [timeit(lambda: list(a), number=100) for _ in range(3)]
[4.125743135926939, 4.128927210087596, 4.0941229388550795]

它是什么数字似乎并不重要:

>>> a = [1234567] * 10**7
>>> [timeit(lambda: list(a), number=100) for _ in range(3)]
[4.124106479141709, 4.156590225249886, 4.219242600790949]

有趣的是,当我改为重复相同的两个或四个元素时,它会变得更快:

>>> a = [0, 1] * (10**7 / 2)
>>> [timeit(lambda: list(a), number=100) for _ in range(3)]
[3.130586101607932, 3.1001001764957294, 3.1318465707127814]

>>> a = [0, 1, 2, 3] * (10**7 / 4)
>>> [timeit(lambda: list(a), number=100) for _ in range(3)]
[3.096105435911994, 3.127148431279352, 3.132872673690855]

我猜有些东西不喜欢同一个计数器一直在增加。可能有一些pipeline stall,因为每次增加都要等待上一次增加的结果,但这是一个疯狂的猜测。

无论如何,尝试对更多重复元素进行此操作:

from timeit import timeit
for e in range(26):
    n = 2**e
    a = range(n) * (2**25 / n)
    times = [timeit(lambda: list(a), number=20) for _ in range(3)]
    print '%8d ' % n, '  '.join('%.3f' % t for t in times), ' => ', sum(times) / 3

输出(第一列是不同元素的数量,每个我测试3次然后取平均值):

       1  2.871  2.828  2.835  =>  2.84446732686
       2  2.144  2.097  2.157  =>  2.13275338734
       4  2.129  2.297  2.247  =>  2.22436720645
       8  2.151  2.174  2.170  =>  2.16477771575
      16  2.164  2.159  2.167  =>  2.16328197911
      32  2.102  2.117  2.154  =>  2.12437970598
      64  2.145  2.133  2.126  =>  2.13462250728
     128  2.135  2.122  2.137  =>  2.13145065221
     256  2.136  2.124  2.140  =>  2.13336283943
     512  2.140  2.188  2.179  =>  2.1688431668
    1024  2.162  2.158  2.167  =>  2.16208440826
    2048  2.207  2.176  2.213  =>  2.19829998424
    4096  2.180  2.196  2.202  =>  2.19291917834
    8192  2.173  2.215  2.188  =>  2.19207065277
   16384  2.258  2.232  2.249  =>  2.24609975704
   32768  2.262  2.251  2.274  =>  2.26239771771
   65536  2.298  2.264  2.246  =>  2.26917420394
  131072  2.285  2.266  2.313  =>  2.28767871168
  262144  2.351  2.333  2.366  =>  2.35030805124
  524288  2.932  2.816  2.834  =>  2.86047313113
 1048576  3.312  3.343  3.326  =>  3.32721167007
 2097152  3.461  3.451  3.547  =>  3.48622758473
 4194304  3.479  3.503  3.547  =>  3.50964316455
 8388608  3.733  3.496  3.532  =>  3.58716466865
16777216  3.583  3.522  3.569  =>  3.55790996695
33554432  3.550  3.556  3.512  =>  3.53952594744

因此,从单个(重复)元素的大约 2.8 秒下降到 2、4、8、16 ......不同元素的大约 2.2 秒,并保持在大约 2.2 秒,直到数十万。我认为这使用了我的二级缓存(4 × 256 KB,我有一个i7-6700)。

然后经过几步,时间会增加到 3.5 秒。我认为这会混合使用我的 L2 缓存和 L3 缓存 (8 MB),直到“用尽”为止。

最后它停留在大约 3.5 秒,我猜是因为我的缓存不再有助于处理重复的元素。

【讨论】:

    【解决方案4】:

    在shuffle之前,在堆中分配时,相邻的索引对象在内存中是相邻的,访问时内存命中率高; shuffle后,新列表的相邻索引的对象不在内存中。相邻,命中率很差。

    【讨论】:

      猜你喜欢
      • 2016-08-02
      • 1970-01-01
      • 2015-10-11
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2016-01-15
      • 2011-04-29
      相关资源
      最近更新 更多