シャッフルされたリストのコピーがはるかに遅いのはなぜですか?


89

シャッフルされたrange(10**6)リストを10回コピーすると、約0.18秒かかります(これらは5回の実行です)。

0.175597017661
0.173731403198
0.178601711594
0.180330912952
0.180811964451

シャッフルされていないリストを10回コピーすると、約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[:]が、結果は似ていました(つまり、速度の差が大きい)

なぜ速度差が大きいのですか?有名なものの速度の違いを知って理解しています。なぜ、ソートされていない配列よりもソートされた配列を処理する方が速いのですか?例ですが、ここでは私の処理に決定はありません。リスト内の参照を盲目的にコピーしているだけですよね?

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))


5
私に怒鳴らないでください、私はあなたを助けようとしていました!順序を変更した後0.25、各テストのほぼ反復ごとに取得します。私のプラットフォームでは、順序は重要です。
barak manos 2017

1
@vaultahありがとう、でも今は読んだので同意しません。そこでコードを見て、intのキャッシュヒット/ミスをすぐに考えました。これも著者の結論です。しかし、彼のコード数字を追加するため、それらを調べる必要があります。私のコードはしません。鉱山は参照をコピーするだけでよく、それらを介してアクセスする必要はありません。
Stefan Pochmann 2017

2
@vaultahのリンクに完全な回答があります(今のところ少し同意しません)。とにかく、私は低レベルの機能にpythonを使うべきではないと私はまだ思っているので、心配してください。とにかく、その話題は面白いです、ありがとう。
Nikolay Prokopyev 2017

1
@NikolayProkopyevええ、私はそれについて心配していません、何か他のことをしているときにこれに気づき、それを説明することができず、好奇心を持ちました。そして私は私が尋ねて、今答えを持っていることをうれしく思います:-)
ステファン・ポックマン2017

回答:


100

興味深い点は、整数が最初に作成される順序に依存するということです。たとえばshuffle、ランダムなシーケンスを作成する代わりにrandom.randint

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_INCREFlist_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

重要な注意点:

私はこれを自分で考えたことはありません。ほとんどの情報は、Ricky Stewartのブログ投稿にあります。

この回答は、Pythonの「公式」CPython実装に基づいています。他の実装(Jython、PyPy、IronPythonなど)の詳細は異なる場合があります。これを指摘してくれた@JörgWMittag 感謝ます。


6
@augurar参照のコピーは、オブジェクト内にある参照カウンタをインクリメントすることを意味します(したがって、オブジェクトへのアクセスは不可避です)
Leon

1
@StefanPochmannコピーを実行する関数はlist_slice453行目でPy_INCREF(v);、ヒープに割り当てられたオブジェクトにアクセスする必要がある呼び出しを確認できます。
MSeifert 2017

1
@MSeifertもう1つの優れた実験が使用a = [0] * 10**7されています(10 ** 6から増加しました。不安定すぎたためa = range(10**7))。使用するよりもさらに高速です(約1.25倍)。明らかにそれはキャッシングにさらに良いからです。
Stefan Pochmann 2017

1
Python 64ビットを搭載した64ビットコンピュータで32ビット整数を取得した理由を知りたがっていました。しかし、実際にはそれはキャッシングにも適しています:-)もと[0,1,2,3]*((10**6) // 4)同じくらい高速a = [0] * 10**6です。ただし、0〜255の整数には別の事実があります。これらはインターンされるため、Pythonの起動時に作成されるため、スクリプト内での作成順序は重要ではなくなります。
MSeifert 2017

2
現在存在する4つの本番環境対応のPython実装のうち、1つだけが参照カウントを使用することに注意してください。したがって、この分析は実際には単一の実装にのみ適用されます。
イェルクWミッターク

24

リストアイテムをシャッフルすると、参照の局所性が低下し、キャッシュのパフォーマンスが低下します。

リストをコピーしても、オブジェクトではなく参照のみがコピーされるため、ヒープ上のそれらの場所は重要ではないと考えるかもしれません。ただし、コピーでは、refcountを変更するために各オブジェクトにアクセスする必要があります。


これは私にとってはより良い答えかもしれません(少なくともMSeifertのような「証明」へのリンクがあった場合)。これは私が見逃していたすべてであり、非常に簡潔ですが、私はMSeifertに固執すると思う他の人にとってより良い。しかし、これにも賛成しました、ありがとう。
ステファンポックマン2017

また、ペンティオイド、アスラムなどには、アドレスパターンを検出するための神秘的なロジックがあり、パターンが見つかるとデータのプリフェッチを開始します。この場合、数値が適切なときにデータをプリフェッチする(キャッシュミスを減らす)ことができます。この効果はもちろん、局所性からのヒットの%の増加に追加されます。
greggo 2017

5

他の人が説明したように、これは参照をコピーするだけでなく、オブジェクト内の参照カウントも増やすため、オブジェクトアクセスされ、キャッシュが役割を果たします。

ここでは、さらに実験を追加したいと思います。シャッフルとシャッフル解除についてはそれほどではありません(1つの要素にアクセスするとキャッシュが失われる可能性がありますが、次の要素をキャッシュに入れてヒットするようにします)。ただし、繰り返し要素については、要素がまだキャッシュにあるため、後で同じ要素にアクセスするとキャッシュにヒットする可能性があります。

通常の範囲のテスト:

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

同じサイズのリストですが、要素が1つだけ繰り返されるため、常にキャッシュにヒットするため、処理速度が速くなります。

>>> 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]

興味深いことに、代わりに同じ2つまたは4つの要素を繰り返すと、さらに速くなります。

>>> 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]

同じカウンターが常に増加しているのが好きではないようです。たぶん、いくつかのパイプラインのストールは、それぞれ増加は、以前の増加の結果を待つ必要があるため、これは野生の推測です。

とにかく、これをさらに多くの繰り返し要素に試してみます。

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秒のままです。これは私のL2キャッシュを使用すると思います(4×256 KB、私はi7-6700を持っています)。

その後、数ステップで、時間は最大3.5秒になります。これも、L2キャッシュとL3キャッシュの混合(8 MB)を使用していると思います。

最後に、それは約3.5秒に留まります。私のキャッシュは繰り返しの要素をもう助けていないからでしょう。


0

シャッフル前は、ヒープに割り当てられている場合、隣接するインデックスオブジェクトはメモリ内で隣接しており、アクセス時のメモリヒット率は高くなります。シャッフル後、新しいリストの隣接するインデックスのオブジェクトはメモリにありません。隣接して、ヒット率は非常に悪いです。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.