Чому код використовує проміжні змінні швидше, ніж код без?


76

Я зіткнувся з цією дивною поведінкою і не зміг пояснити. Ось еталони:

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, збір сміття вимкнено під час, тому це не може бути таким. Це якась оптимізація?

Результати також можуть бути відтворені в 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%)


6
Порівняйте dis.dis("tuple(range(2000)) == tuple(range(2000))")з dis.dis("a = tuple(range(2000)); b = tuple(range(2000)); a==b"). У моїй конфігурації другий фрагмент фактично містить весь байт-код першого та деякі додаткові інструкції. Важко повірити, що більше інструкцій байт-коду призводить до швидшого виконання. Можливо, це якась помилка у конкретній версії Python?
Лукаш Рогальський,

1
Якщо ви намагаєтеся відтворити це, будь ласка, запустіть тест кілька разів в різних порядках виконання. - Незалежно від результату та дивацтва цього, я думаю, що питання не є особливо цінним для SO.
тикати

3
Я думаю, це досить цікаво. @poke потрібно пам'ятати, що відповідь на подібне явище зараз є найбільш прихильною відповіддю у stackoverflow.
Antti Haapala,

3
Крім того, спробуйте запустити тест в одному процесі Python, використовуючи timeitмодуль безпосередньо. На порівняння двох окремих процесів Python може вплинути планувальник завдань операційної системи або інші ефекти.
тикати

1
@aluriak "найкраще з 3" означає найкраще з трьох середніх значень . Це робиться тому, що деякі середні показники можуть включати, скажімо, несподіваний зрив процесу. Беручи найкращі середні показники, цього уникає.
Veedrac

Відповіді:


107

Мої результати були схожі на ваші: код, що використовує проміжні змінні, був досить стабільним щонайменше на 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командний рядок.

Тож цей Гейзенбуг виявився чимось досить цікавим. Я вирішив запустити команду, 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для регіону 256k; ці самі рядки повторюються знову і знову:

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))буде виділено 3-й кортеж, і призначення a = tuple(...)зменшить кількість посилань старого кортежу, змусивши його звільнити, і збільшить лічильник посилань нового кортежу; то те саме відбувається b. Тому після першої ітерації цих кортежів завжди є принаймні 2, якщо не 3, тож обмолочення не відбувається.

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


Хтось запитав, чому так відбувається, коли timeitвідключається збір сміття. Це дійсно правда , що timeitробить це :

Примітка

За замовчуванням timeit()тимчасово вимикає збір сміття під час синхронізації. Перевага цього підходу полягає в тому, що він робить незалежні терміни більш порівнянними. Цей недолік полягає в тому, що ГХ може бути важливим компонентом роботи вимірюваної функції. Якщо так, то GC можна знову активувати як перший оператор у рядку налаштування. Наприклад:

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


1
Ого, це цікаво. Чи не повинен сміттєзбірник (який вчасно вимкнений) дбати про звільнення або, принаймні, про це? І це піднімає ще одне запитання: чи не є ці повторні виклики помилкою?
Bharel

6
@Bharel більше нагадує "зламаний, як задумано"
Антті Хаапала,

1
@Bharel Це залежить від того, чи виділено нову арену пам'яті чи ні ; цілком можливо, що інші системи мають частково вільні арени, які мають достатньо вільної пам'яті в пулах, що більше не потрібно. Навіть одна і та ж версія Python на зовні схожих системах може мати різну поведінку - такі речі, як шлях встановлення Python, кількість пакунків site-packages, змінні середовища, поточний робочий каталог - усі вони впливають на макет пам'яті процесу.
Антті Хаапала,

7
@Bharel: Збірщик сміття в CPython правильніше називати "циклічним збирачем сміття"; це стосується виключно звільнення ізольованих еталонних циклів, а не загального збору сміття. Всі інші очищення є синхронними та в порядку; якщо останнє посилання на останній об’єкт на арені звільнено, об’єкт негайно видаляється, і арена негайно звільняється, циклічне збирання сміття не вимагає участі. Ось чому законно відключити gc; якби це вимкнуло загальне очищення, у вас би досить швидко закінчилася пам'ять.
ShadowRanger

1
підсумовуючи: ефект у відповіді не відтворюється (немає суттєвої різниці у кількості викликів 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)
jfs

7

Перше питання тут має бути, чи можна це відтворити? Для деяких з нас, принаймні, це точно так, хоча інші люди кажуть, що не бачать ефекту. Це на 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 витрачається під час процесу синхронізації. Це все ще дивно, хоча.


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