Почему a.insert (0,0) намного медленнее, чем [0: 0] = [0]?

61

Использование insertфункции списка намного медленнее, чем достижение того же эффекта с помощью назначения срезов:

> python -m timeit -n 100000 -s "a=[]" "a.insert(0,0)"
100000 loops, best of 5: 19.2 usec per loop

> python -m timeit -n 100000 -s "a=[]" "a[0:0]=[0]"
100000 loops, best of 5: 6.78 usec per loop

(Обратите внимание, что a=[]это только настройка, поэтому aначинается с пустого значения, но затем увеличивается до 100 000 элементов.)

Сначала я подумал, что это может быть поиск атрибута или служебный вызов функции или около того, но вставка в конце показывает, что это незначительно:

> python -m timeit -n 100000 -s "a=[]" "a.insert(-1,0)"
100000 loops, best of 5: 79.1 nsec per loop

Почему предположительно более простая выделенная функция «вставка одного элемента» намного медленнее?

Я также могу воспроизвести его в repl.it :

from timeit import repeat

for _ in range(3):
  for stmt in 'a.insert(0,0)', 'a[0:0]=[0]', 'a.insert(-1,0)':
    t = min(repeat(stmt, 'a=[]', number=10**5))
    print('%.6f' % t, stmt)
  print()

# Example output:
#
# 4.803514 a.insert(0,0)
# 1.807832 a[0:0]=[0]
# 0.012533 a.insert(-1,0)
#
# 4.967313 a.insert(0,0)
# 1.821665 a[0:0]=[0]
# 0.012738 a.insert(-1,0)
#
# 5.694100 a.insert(0,0)
# 1.899940 a[0:0]=[0]
# 0.012664 a.insert(-1,0)

Я использую 32-битный Python 3.8.1 на 64-битной Windows 10.
repl.it использует 64-битный Python 3.8.1 в 64-битном Linux.

Переполнение кучи
источник
Интересно отметить, что a=[]; a[0:0]=[0]делает то же самое, что иa=[]; a[100:200]=[0]
smac89
Есть ли какая-то причина, почему вы тестируете это с пустым списком?
Мистер Мияги
@MisterMiyagi Ну, я должен начать с чего-то . Обратите внимание, что он пуст только перед первой вставкой и увеличивается до 100 000 элементов во время теста.
переполнение кучи
@ smac89 a=[1,2,3];a[100:200]=[4]является добавление 4к концу списка aинтересного.
Ch3steR
1
@ smac89 Хотя это и правда, это не имеет никакого отношения к вопросу, и я боюсь, что это может ввести кого-то в заблуждение и подумать, что я бенчмаркинга a=[]; a[0:0]=[0]или что он a[0:0]=[0]делает то же самое, что a[100:200]=[0]...
Переполнение кучи

Ответы:

57

Я думаю , что это, вероятно , только что они забыли использовать memmoveв list.insert. Если вы посмотрите на код, list.insert используемый для сдвига элементов, вы увидите, что это просто ручной цикл:

for (i = n; --i >= where; )
    items[i+1] = items[i];

в то время как list.__setitem__на пути назначения среза используетсяmemmove :

memmove(&item[ihigh+d], &item[ihigh],
    (k - ihigh)*sizeof(PyObject *));

memmove как правило, в него вкладывается много оптимизации, например, использование инструкций SSE / AVX.

user2357112 поддерживает Monica
источник
5
Спасибо. Создана проблема, ссылающаяся на это.
переполнение кучи
7
Если интерпретатор был построен с -O3включенной автоматической векторизацией, этот ручной цикл мог бы скомпилироваться эффективно. Но если компилятор не распознает цикл как memmove и не скомпилирует его в фактический вызов memmove, он может использовать только расширения набора команд, включенные во время компиляции. (Хорошо, если вы создаете свой собственный -march=native, не так уж много для дистрибутивов, основанных на базовой линии). И GCC не будет развертывать циклы по умолчанию, если вы не используете PGO ( -fprofile-generate/ run / ...-use)
Питер Кордес
@PeterCordes Правильно ли я вас понимаю, что если компилятор скомпилирует его в реальный memmoveвызов, то он сможет воспользоваться всеми расширениями, присутствующими во время выполнения?
Переполнение кучи
1
@HeapOverflow: Да. Например, в GNU / Linux glibc перегружает динамическое разрешение символов компоновщика функцией, которая выбирает лучшую рукописную версию memmove для этой машины на основе сохраненных результатов обнаружения ЦП. (например, на x86 используется функция инициализации glibc cpuid). То же самое для нескольких других функций mem / str. Таким образом, дистрибутивы могут компилироваться только -O2для создания исполняемых файлов в любом месте, но, по крайней мере, memcpy / memmove использует развернутый цикл AVX, загружающий / сохраняющий 32 байта на инструкцию. (Или даже AVX512 на нескольких процессорах, где это хорошая идея; я думаю, что только Xeon Phi.)
Питер Кордес
1
@HeapOverflow: Нет, memmoveв libc.so, общей библиотеке , есть несколько версий. Для каждой функции диспетчеризация происходит один раз, во время разрешения символа (раннее связывание или при первом вызове с традиционным отложенным связыванием). Как я уже сказал, он просто перегружает / перехватывает, как происходит динамическое связывание, а не упаковывая саму функцию. (в частности, с помощью механизма ifunc GCC: code.woboq.org/userspace/glibc/sysdeps/x86_64/multiarch/… ). Связанный: для memset обычный выбор на современных процессорах - __memset_avx2_unaligned_erms см. Этот Q & A
Питер Кордес