Почему копирование перетасованного списка происходит намного медленнее?
Копирование перетасованного списка 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))
Ответы
Ответ 1
Интересный бит заключается в том, что он зависит от порядка создания целых чисел first. Например, вместо 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))
(первый и быстрый пример).
Однако, когда вы перетасовываете - тогда ваши целые числа не в порядке, в котором они были впервые созданы, что делает его медленным.
Быстрое intermezzo:
- Все объекты Python находятся в куче, поэтому каждый объект является указателем.
- Копирование списка - это мелкая операция.
- Однако Python использует подсчет ссылок, поэтому, когда объект помещается в новый контейнер, счетчик ссылок должен быть увеличен (
Py_INCREF
в list_slice
), поэтому Python действительно должен пойти туда, где находится объект. Он не может просто скопировать ссылку.
Поэтому, когда вы копируете свой список, вы получаете каждый элемент этого списка и помещаете его "как есть" в новый список. Когда ваш следующий элемент был создан вскоре после текущего, есть хороший шанс (без гарантии!), Который он сохранил рядом с ним в куче.
Предположим, что всякий раз, когда ваш компьютер загружает элемент в кеш, он также загружает элементы x
next-in-memory (локальность кэша). Затем ваш компьютер может выполнить инкремент подсчета ссылок для 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
Важное примечание:
Я сам этого не думал. Большинство информации можно найти в блоге за то, что указали это.
Ответ 2
Когда вы перетасовываете элементы списка, у них худшая локальность ссылок, что приводит к ухудшению производительности кеша.
Вы можете подумать, что копирование списка просто копирует ссылки, а не объекты, поэтому их расположение в куче не должно иметь значения. Тем не менее, копирование по-прежнему связано с доступом к каждому объекту для модификации refcount.
Ответ 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]
Я думаю, что-то не нравится, что один и тот же счетчик постоянно увеличивается. Возможно, какой-то конвейерный столп, потому что каждое увеличение должно ждать результата предыдущего увеличения, но это дикое предположение.
В любом случае, попробуйте это для еще большего количества повторяющихся элементов:
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 секунд, я думаю, потому что мои кеши больше не помогают с повторяющимися элементами.