Почему код с промежуточными переменными быстрее, чем код без них?

Я столкнулся с этим странным поведением и не смог его объяснить. Это ориентиры:

py -3 -m timeit "tuple(range(2000)) == tuple(range(2000))"
10000 loops, best of 3: 97.7 usec per loop
py -3 -m timeit "a = tuple(range(2000));  b = tuple(range(2000)); a==b"
10000 loops, best of 3: 70.7 usec per loop

Почему сравнение с присваиванием переменных быстрее, чем использование одного вкладыша с временными переменными более чем на 27%?

Согласно документам Python, сборка мусора отключена во время timeit, поэтому этого не может быть. Это какая-то оптимизация?

Результаты также могут быть воспроизведены в Python 2.x, хотя и в меньшей степени.

Запуск Windows 7, CPython 3.5.1, Intel i7 3,40 ГГц, 64-разрядная ОС и Python. Похоже, что другая машина, которую я пытался запустить на Intel i7 3,60 ГГц с Python 3.5.0, не воспроизводит результаты.


Запуск с использованием того же процесса Python с timeit.timeit() @ 10000 циклов дал 0,703 и 0,804 соответственно. По-прежнему показывает, хотя и в меньшей степени. (~12,5%)


person Bharel    schedule 11.04.2016    source источник
comment
Я не могу воспроизвести это. Я получаю одинаковое время для обеих версий. Пробовал на Python 2 и Python 3.   -  person khelwood    schedule 11.04.2016
comment
Я не могу воспроизвести вашу проблему. Я получаю 146 usec per loop и 148 usec per loop за 1000 циклов для первой и второй строки соответственно. Они почти одинаковы.   -  person aluriak    schedule 11.04.2016
comment
Я могу воспроизвести это с помощью Python 3.4.3 на процессоре AMD с Windows 10. Интересно, зависит ли это от процессора или ОС? Вот мой результат.   -  person Aaron Christiansen    schedule 11.04.2016
comment
Я также могу воспроизвести вопрос о Fedora 21. Я также могу немного сузить его, либо заменив == на is, либо просто используя оператор запятой для создания кортежа с двумя значениями, что уменьшит время (в моем случае) 104 и 92 микросекунды до 68 и 56. Таким образом, это не проблема кэширования памяти при сравнении, а что-то, непосредственно связанное с созданием кортежей.   -  person Duncan    schedule 11.04.2016
comment
Сравните dis.dis("tuple(range(2000)) == tuple(range(2000))") с dis.dis("a = tuple(range(2000)); b = tuple(range(2000)); a==b"). В моей конфигурации второй фрагмент фактически содержит весь байт-код из первого и некоторые дополнительные инструкции. Трудно поверить, что большее количество инструкций байт-кода приводит к более быстрому выполнению. Может быть, это какая-то ошибка в конкретной версии Python?   -  person Łukasz Rogalski    schedule 11.04.2016
comment
Выход best of 3: …. Это важно, потому что если возвращаемое значение является лучшим из трех прогонов, то статистически оно ничего не значит для среднего времени. Используя скрипт, использующий timeit для функций, я получаю среднее время 0,17 для обоих.   -  person aluriak    schedule 11.04.2016
comment
@Rogalski это именно то, что я думал, но результаты говорят сами за себя   -  person Bharel    schedule 11.04.2016
comment
@Bharel Вы проверяли байт-коды на своей машине?   -  person Łukasz Rogalski    schedule 11.04.2016
comment
Еще одна вариация на тему: "a=b=1; a = tuple(range(2000)); b = tuple(range(2000)); a,b" 68µS, "a = tuple(range(2000)); b = tuple(range(2000)); a,b" 56µS. Также разница становится пропорционально больше, если вы увеличиваете числа до 20 000 или 200 000.   -  person Duncan    schedule 11.04.2016
comment
Если вы попытаетесь воспроизвести это, запустите тест несколько раз в разных порядках выполнения. – Независимо от результата и странности этого, я думаю, что вопрос не представляет особой ценности для SO.   -  person poke    schedule 11.04.2016
comment
Я думаю, это довольно интересно. @poke, вы должны помнить, что ответ на подобное явление теперь является наиболее популярным ответом в stackoverflow.   -  person Antti Haapala    schedule 11.04.2016
comment
Значительна ли разница между 97 и 70 микросекундами?   -  person aluriak    schedule 11.04.2016
comment
Кроме того, попробуйте запустить тест в одном процессе Python, используя модуль timeit напрямую. На сравнение двух отдельных процессов Python может повлиять планировщик задач операционной системы или другие эффекты.   -  person poke    schedule 11.04.2016
comment
3.4 разница гораздо заметнее чем 3.5 на том же компе   -  person Antti Haapala    schedule 11.04.2016
comment
@Duncan dis второго, кажется, содержит весь первый. Такой же как ты.   -  person Bharel    schedule 11.04.2016
comment
Я думаю, что главный урок здесь, независимо от того, что происходит, заключается в следующем: никогда не пытайтесь писать сложный код, избегая пары дополнительных переменных только потому, что вы думаете, что это потребует больше времени или ресурсов.   -  person jsbueno    schedule 13.04.2016
comment
@aluriak best of 3 означает лучшее из трех средних значений. Это делается потому, что некоторое среднее значение может включать, скажем, неожиданную остановку процесса. Взятие лучшего из средних позволяет избежать этого.   -  person Veedrac    schedule 16.04.2016


Ответы (2)


Мои результаты были аналогичны вашим: код с промежуточными переменными довольно стабильно был как минимум на 10-20% быстрее в Python 3.4. Однако, когда я использовал IPython в том же самом интерпретаторе Python 3.4, я получил следующие результаты:

In [1]: %timeit -n10000 -r20 tuple(range(2000)) == tuple(range(2000))
10000 loops, best of 20: 74.2 µs per loop

In [2]: %timeit -n10000 -r20 a = tuple(range(2000));  b = tuple(range(2000)); a==b
10000 loops, best of 20: 75.7 µs per loop

Примечательно, что мне никогда не удавалось приблизиться к 74,2 мкс для первого, когда я использовал -mtimeit из командной строки.

Так что этот Heisenbug оказался весьма интересным. Я решил запустить команду с strace и действительно происходит что-то подозрительное:

% strace -o withoutvars python3 -m timeit "tuple(range(2000)) == tuple(range(2000))"
10000 loops, best of 3: 134 usec per loop
% strace -o withvars python3 -mtimeit "a = tuple(range(2000));  b = tuple(range(2000)); a==b"
10000 loops, best of 3: 75.8 usec per loop
% grep mmap withvars|wc -l
46
% grep mmap withoutvars|wc -l
41149

Теперь это хорошая причина для разницы. Код, который не использует переменные, приводит к тому, что системный вызов mmap вызывается почти в 1000 раз чаще, чем тот, который использует промежуточные переменные.

withoutvars заполнено mmap/munmap для региона 256 КБ; одни и те же строки повторяются снова и снова:

mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f32e56de000
munmap(0x7f32e56de000, 262144)          = 0
mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f32e56de000
munmap(0x7f32e56de000, 262144)          = 0
mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f32e56de000
munmap(0x7f32e56de000, 262144)          = 0

Похоже, что вызов mmap исходит от функции _PyObject_ArenaMmap из Objects/obmalloc.c; obmalloc.c также содержит макрос ARENA_SIZE, который #defined должен быть (256 << 10) (то есть 262144); аналогично munmap соответствует _PyObject_ArenaMunmap из obmalloc.c.

obmalloc.c говорит, что

До Python 2.5 арены никогда не были free() отредактированы. Начиная с Python 2.5, мы пытаемся free() арены и используем некоторые мягкие эвристические стратегии, чтобы увеличить вероятность того, что арены в конечном итоге могут быть освобождены.

Таким образом, эта эвристика и тот факт, что распределитель объектов Python освобождает эти свободные арены, как только они опустошаются, приводят к python3 -mtimeit 'tuple(range(2000)) == tuple(range(2000))' запуску патологического поведения, когда одна область памяти размером 256 КБ многократно перераспределяется и освобождается; и это распределение происходит с mmap/munmap, что сравнительно дорого, поскольку это системные вызовы — более того, mmap с MAP_ANONYMOUS требует, чтобы вновь сопоставленные страницы были обнулены — хотя Python это не заботит.

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

for n in range(10000)
    a = tuple(range(2000))
    b = tuple(range(2000))
    a == b

Теперь поведение таково, что и a, и b останутся связанными до тех пор, пока они не будут *переназначены, поэтому во второй итерации tuple(range(2000)) выделит третий кортеж, а назначение a = tuple(...) уменьшит счетчик ссылок старого кортежа, в результате чего он будет выпущено и увеличить количество ссылок нового кортежа; то же самое происходит с b. Поэтому после первой итерации всегда есть как минимум 2 таких кортежа, если не 3, так что перебора не происходит.

В частности, нельзя гарантировать, что код, использующий промежуточные переменные, всегда будет быстрее — действительно, в некоторых установках может случиться так, что использование промежуточных переменных приведет к дополнительным вызовам mmap, тогда как код, который напрямую сравнивает возвращаемые значения, может подойти.


Кто-то спросил, почему так происходит, когда timeit отключает сборку мусора. Действительно, timeit делает это:

Примечание

По умолчанию timeit() временно отключает сборку мусора на время. Преимущество этого подхода в том, что он делает независимые тайминги более сопоставимыми. Этот недостаток заключается в том, что GC может быть важным компонентом выполнения измеряемой функции. Если это так, GC можно повторно включить в качестве первого оператора в строке установки. Например:

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

person Antti Haapala    schedule 11.04.2016
comment
Воха, это интересно. Разве сборщик мусора (который отключен на время) не должен заботиться об освобождении или, по крайней мере, должен заботиться об этом? И это поднимает другой вопрос: не являются ли эти повторные вызовы ошибкой? - person Bharel; 11.04.2016
comment
@Bharel больше похож на сломанный, как задумано - person Antti Haapala; 11.04.2016
comment
и последнее, но не менее важное: как это объясняет разницу между системами, даже с одной и той же ОС? - person Bharel; 11.04.2016
comment
@Bharel Это зависит от того, выделена ли новая область памяти; вполне возможно, что в других системах есть частично свободные арены, у которых достаточно свободной памяти в пулах, больше и не требуется. Даже одна и та же версия Python на внешне похожих системах может вести себя по-разному — такие вещи, как путь установки Python, количество пакетов в site-packages, переменные среды, текущий рабочий каталог — все они влияют на структуру памяти процесса. - person Antti Haapala; 11.04.2016
comment
@Bharel: сборщик мусора в CPython правильнее называть циклическим сборщиком мусора; он касается исключительно освобождения изолированных циклов ссылок, а не общей сборки мусора. Вся остальная очистка выполняется синхронно и по порядку; если освобождается последняя ссылка на последний объект в арене, объект немедленно удаляется, а арена немедленно освобождается, участие циклического сборщика мусора не требуется. Вот почему можно отключить gc; если бы он отключил общую очистку, у вас чертовски быстро закончилась бы память. - person ShadowRanger; 11.04.2016
comment
Я не могу воспроизвести это на Ubuntu. Разницы нет - person jfs; 27.12.2017
comment
@jfs, значит, вы думаете, что, когда я говорю, что его нужно запускать из командной строки, вы воспроизведете его с этими параметрами из блокнота IPython? - person Antti Haapala; 27.12.2017
comment
@AnttiHaapala тоже ничем не отличается от командной строки. - person jfs; 27.12.2017
comment
@jfs Ubuntu 17.10 с использованием Python 3.6. 124–126 мкс против 95–100 мкс. Недавно протестировано - person Antti Haapala; 27.12.2017
comment
Кажется, не запускается в Ubuntu 16.04 с Python 3.5.2. Но на Ubuntu 14.04 с 3.4.3... настройка немного чувствительна. - person Antti Haapala; 27.12.2017
comment
@jfs Интересно то, что OP заметил поведение в Windows, и его можно было воспроизвести в Linux. - person Antti Haapala; 27.12.2017
comment
@AnttiHaapala Я не могу это воспроизвести. Ubuntu 16.04, Python 3.5, Python 3.6. Количество вызовов mmap примерно одинаково в обоих случаях (~40-60). - person jfs; 27.12.2017
comment
@jfs у вас все еще есть Python 3.4? Я смог - несколько неожиданно - воспроизвести его в Python 3.4 16.04. Вы случайно установили из Deadsnakes? - person Antti Haapala; 27.12.2017
comment
@AnttiHaapala Я также попробовал это на компиляторе python3.7 из исходного кода. Результат тот же: никакой разницы. Мне удалось воспроизвести количество вызовов mmap с использованием python, скомпилированного pythonz (3.4.6, 3.6.3, 3.6.4) — ~ 4000 без варов, ~ 50 с варами. - person jfs; 27.12.2017
comment
Подводя итог: эффект в ответе не воспроизводим (нет существенной разницы в количестве вызовов mmap) по умолчанию /usr/bin/python3, распространяемый с Ubuntu 16.04 (пакет python3-minimal). Я также пробовал различные образы докеров, например, docker run --rm python:3.6.4 python -m timeit ... - безрезультатно (включая 3.4). Поведение в вашем ответе воспроизводимо, если python скомпилирован из исходного кода (например, 3.6.4-d48eceb, но не влияет на 3.7-e3256087) - person jfs; 29.12.2017
comment
@jfs: разница в поведении в основном связана с различиями в поведении при запуске; если запуск создает и загрязняет достаточно арен памяти (но не загрязняет их настолько, чтобы предотвратить непрерывное выделение tuples в том, что осталось), тогда эти арены никогда не будут освобождены и всегда будут доступны для повторного использования. python3 по умолчанию в вашей системе, скорее всего, по-прежнему поставляется как минимум с несколькими вещами в системном каталоге пакетов сайта, а неявный import site, выполненный Python, будет выполнять все виды работы, которые вызовут выделение/освобождение. - person ShadowRanger; 13.09.2019
comment
Например, я воспроизвожу наблюдаемое несоответствие производительности OP (~ 123 мкс без имен, ~ 96 мкс с именами), когда я запускаю с -E -S (который отключает все проверки для переменных среды Python и полностью блокирует импорт site), но без -S, время выполнения практически идентично (оба ~ 96 мкс). Запуск его в ipython приводит к аналогичному эффекту, потому что ipython при запуске сам делает массу вещей, которые создают кучу арен с изолированными долгоживущими объектами, чтобы сохранить их живыми для повторного использования. Дело в том, что проблема не устранена в ни в одной системе, она просто обычно маскируется. - person ShadowRanger; 13.09.2019

Первый вопрос здесь должен быть, это воспроизводимо? Для некоторых из нас, по крайней мере, это определенно так, хотя другие люди говорят, что не видят эффекта. Это в Fedora, когда проверка на равенство изменена на is, поскольку фактическое сравнение кажется нерелевантным для результата, а диапазон увеличен до 200 000, поскольку это, кажется, максимизирует эффект:

$ python3 -m timeit "a = tuple(range(200000));  b = tuple(range(200000)); a is b"
100 loops, best of 3: 7.03 msec per loop
$ python3 -m timeit "a = tuple(range(200000)) is tuple(range(200000))"
100 loops, best of 3: 10.2 msec per loop
$ python3 -m timeit "tuple(range(200000)) is tuple(range(200000))"
100 loops, best of 3: 10.2 msec per loop
$ python3 -m timeit "a = b = tuple(range(200000)) is tuple(range(200000))"
100 loops, best of 3: 9.99 msec per loop
$ python3 -m timeit "a = b = tuple(range(200000)) is tuple(range(200000))"
100 loops, best of 3: 10.2 msec per loop
$ python3 -m timeit "tuple(range(200000)) is tuple(range(200000))"
100 loops, best of 3: 10.1 msec per loop
$ python3 -m timeit "a = tuple(range(200000));  b = tuple(range(200000)); a is b"
100 loops, best of 3: 7 msec per loop
$ python3 -m timeit "a = tuple(range(200000));  b = tuple(range(200000)); a is b"
100 loops, best of 3: 7.02 msec per loop

Я отмечаю, что различия между запусками и порядком выполнения выражений очень мало влияют на результат.

Добавление присваиваний a и b в медленную версию не ускоряет ее. На самом деле, как и следовало ожидать, присваивание значения локальным переменным имеет незначительный эффект. Единственное, что ускоряет его, — это полное разделение выражения на две части. Единственная разница, которую это должно иметь значение, заключается в том, что она уменьшает максимальную глубину стека, используемую Python при вычислении выражения (с 4 до 3).

Это дает нам понять, что эффект связан с глубиной стека, возможно, дополнительный уровень перемещает стек на другую страницу памяти. Если это так, мы должны увидеть, что внесение других изменений, влияющих на стек, изменится (скорее всего, убьет эффект), и на самом деле это то, что мы видим:

$ python3 -m timeit -s "def foo():
   tuple(range(200000)) is tuple(range(200000))" "foo()"
100 loops, best of 3: 10 msec per loop
$ python3 -m timeit -s "def foo():
   tuple(range(200000)) is tuple(range(200000))" "foo()"
100 loops, best of 3: 10 msec per loop
$ python3 -m timeit -s "def foo():
   a = tuple(range(200000));  b = tuple(range(200000)); a is b" "foo()"
100 loops, best of 3: 9.97 msec per loop
$ python3 -m timeit -s "def foo():
   a = tuple(range(200000));  b = tuple(range(200000)); a is b" "foo()"
100 loops, best of 3: 10 msec per loop

Итак, я думаю, что эффект полностью связан с тем, сколько стека Python потребляется в процессе синхронизации. Хотя все равно странно.

person Duncan    schedule 11.04.2016
comment
Однако две машины с одинаковыми планками памяти и одинаковой ОС дают разные результаты. Глубина стека звучит как хорошая теория, но она не объясняет разницу между машинами. - person Bharel; 11.04.2016