Почему копирование перетасованного списка происходит намного медленнее?

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на каждой итерации каждого из тестов. Так что на моей платформе порядок имеет значение.
барак манос
1
@vaultah Спасибо, но я прочитал это сейчас и не согласен. Когда я увидел там код, я сразу подумал о попаданиях / пропусках кеша целых чисел, что тоже является выводом автора. Но его код складывает числа, что требует их просмотра. Мой код этого не делает. Мне нужно только скопировать ссылки, а не получить доступ через них.
Стефан Почманн
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

Важная заметка:

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

Этот ответ основан на «официальной» реализации Python на CPython. Детали в других реализациях (Jython, PyPy, IronPython, ...) могут отличаться. Спасибо @ JörgWMittag за указание на это .

MSeifert
источник
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 раза). Очевидно, потому что это даже лучше для кеширования.
Стефан Почманн
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, поскольку я чувствую, что это может быть лучше для других. Хотя и за это проголосовали, спасибо.
Стефан Почманн
Также добавлю, что пентиоиды, атлумы и т. Д. Имеют в себе мистическую логику для обнаружения шаблонов адресов и начнут предварительную выборку данных, когда увидят шаблон. Что в данном случае может быть связано с предварительной выборкой данных (уменьшением промахов в кеше), когда числа в порядке. Этот эффект, конечно же, добавляется к увеличенному проценту попаданий с местности.
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

Перед перемешиванием, когда они размещены в куче, смежные объекты индекса являются смежными в памяти, и частота попаданий в память высока при доступе; после перемешивания объект соседнего индекса нового списка отсутствует в памяти. Соседний, процент попаданий очень низкий.

xws
источник