Чому копіювання перетасованого списку відбувається набагато повільніше?


89

Копіювання перетасованого 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[:], результати були схожі (тобто велика різниця в швидкості)

Чому велика різниця в швидкості? Я знаю і розумію різницю в швидкості у відомому Чому швидше обробляти відсортований масив, ніж невідсортований? Наприклад, але тут моя обробка не приймає рішень. Це просто сліпе копіювання посилань всередині списку, ні?

Я використовую Python 2.7.12 у Windows 10.

Редагувати: Спробував 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

1
@vaultah Дякую, але я прочитав це зараз і не погоджуюсь. Побачивши там код, я відразу згадав про потрапляння / промах кешів, що також є висновком автора. Але його код додає цифри, що вимагає їх перегляду. Мій код цього не робить. Моєму потрібно лише скопіювати посилання, а не доступ через них.
Stefan Pochmann

2
У посиланні @vaultah є повна відповідь (я бачу, зараз ви трохи не згодні). Але в будь-якому випадку я все ще вважаю, що ми не повинні використовувати python для низькорівневих функцій, і, отже, хвилюватися. Але ця тема все одно цікава, дякую.
Микола Прокоп’єв

1
@NikolayProkopyev Так, мене це не турбує, я просто помітив це, займаючись чимось іншим, не міг пояснити це, і мені стало цікаво. І я радий, що зараз запитав і маю відповідь :-)
Штефан Похманн,

Відповіді:


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

Важлива примітка:

Я сам цього не придумав. Більшість інформації можна знайти в блозі Рікі Стюарта .

Ця відповідь базується на "офіційній" реалізації CPython Python. Деталі в інших реалізаціях (Jython, PyPy, IronPython, ...) можуть бути різними. Дякую @ JörgWMittag, що вказав на це .


6
@augurar Копіювання посилання передбачає збільшення лічильника посилань, який знаходиться в об'єкті (отже, доступ до об'єкта неминучий)
Леон,

1
@StefanPochmann Функція, яка виконує копію, є, list_sliceі в рядку 453 ви можете побачити Py_INCREF(v);виклик, який потребує доступу до об'єкта, виділеного до купи.
MSeifert

1
@MSeifert Ще один хороший експеримент - використання a = [0] * 10**7(з 10 ** 6, оскільки це було занадто нестабільно), що навіть швидше, ніж використання a = range(10**7)(приблизно в 1,25 рази). Очевидно, тому що це навіть краще для кешування.
Stefan Pochmann

1
Мені просто цікаво, чому я отримав 32-бітові цілі числа на 64-бітному комп'ютері з 64-бітним python. Але насправді це добре і для кешування :-) Навіть [0,1,2,3]*((10**6) // 4)це так швидко, як a = [0] * 10**6. Однак з цілими числами від 0-255 є ще один факт: вони інтерновані, тому з ними порядок створення (усередині вашого сценарію) вже не важливий - оскільки вони створюються під час запуску python.
MSeifert

2
Зверніть увагу, що з існуючих на даний момент чотирьох реалізацій Python, готових до виробництва, лише одна використовує підрахунок посилань. Отже, цей аналіз насправді стосується лише однієї реалізації.
Jörg W Mittag

24

При перемішуванні елементів списку вони мають гіршу локалізацію посилань, що призводить до гіршої продуктивності кешу.

Вам може здатися, що копіювання списку просто копіює посилання, а не об'єкти, тому їх розташування в купі не повинно мати значення. Однак копіювання все одно передбачає доступ до кожного об'єкта з метою модифікації перерахунку.


Це може бути кращою відповіддю для мене (принаймні, якби воно мало посилання на "доказ", такий як MSeifert), оскільки це все, чого мені не вистачало, і це дуже лаконічно, але я думаю, що дотримуватимусь MSeifert, оскільки я вважаю, що це може бути краще для інших. Проголосував і за це, хоча, дякую.
Stefan Pochmann

Також додасть, що пентіоїди, атлуми тощо мають містичну логіку для виявлення шаблонів адрес, і почнуть попередньо отримувати дані, коли побачать шаблон. Що в цьому випадку може вдатися до попереднього отримання даних (зменшення помилок кешу), коли цифри в порядку. Цей ефект є додатковим, звичайно, до збільшеного% звернень з місцевості.
greggo

5

Як пояснили інші, це не просто копіювання посилань, а й збільшення кількості посилань всередині об’єктів, і таким чином здійснюється доступ до об’єктів , а кеш відіграє певну роль.

Тут я просто хочу додати більше експериментів. Не стільки про перетасовку в порівнянні з неперетасовкою (де доступ до одного елемента може пропустити кеш, але отримати наступні елементи в кеш, щоб вони потрапили). Але про повторювані елементи, де пізніше доступ до цього самого елемента може потрапити в кеш, оскільки елемент все ще знаходиться в кеші.

Тестування нормального діапазону:

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

Думаю, щось не подобається тому ж самому лічильнику, який постійно збільшується. Можливо, якийсь зрив трубопроводу, оскільки кожне збільшення повинно чекати результату попереднього збільшення, але це дика здогадка.

У будь-якому випадку, пробуючи це для ще більшої кількості повторюваних елементів:

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

Результат (перший стовпець - це кількість різних елементів, для кожного я тестую тричі, а потім беру середнє значення):

       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,2 секунди для 2, 4, 8, 16, ... різних елементів і залишається приблизно на 2,2 секунди до ста тисяч. Я думаю, що для цього використовується мій кеш L2 (4 × 256 КБ, у мене є i7-6700 ).

Потім за кілька кроків час сягає 3,5 секунд. Я думаю, що для цього використовується поєднання мого кешу L2 та мого кешу L3 (8 МБ), поки це також не "вичерпається".

В кінці він залишається приблизно на 3,5 секунди, я думаю, тому що мої кеші більше не допомагають з повторюваними елементами.


0

Перед перемішуванням, коли виділяються в купі, сусідні об'єкти індексу сусідять в пам'яті, і швидкість звернення до пам'яті висока при доступі; після перетасовки об'єкт сусіднього індексу нового списку відсутній у пам'яті. Сусідній рівень удару дуже низький.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.