Чому код Python працює у функції швидше?


833
def main():
    for i in xrange(10**8):
        pass
main()

Цей фрагмент коду в Python працює (Примітка. Позначення часу виконується за допомогою функції часу в BASH в Linux.)

real    0m1.841s
user    0m1.828s
sys     0m0.012s

Однак якщо цикл for не розміщений у функції,

for i in xrange(10**8):
    pass

тоді він працює набагато довше:

real    0m4.543s
user    0m4.524s
sys     0m0.012s

Чому це?


16
Як ви насправді зробили терміни?
Ендрю Жаффе

53
Просто інтуїція, не впевнений, чи це правда: я б припустив, що це через сфери дії. У випадку функції створюється нова область (тобто вид хеша зі змінними іменами, прив'язаними до їх значення). Без функції змінні знаходяться в глобальній області, коли ви можете знайти багато матеріалів, отже, сповільнюючи цикл.
Шаррон

4
@Scharron Це, здається, не так. Визначено 200k фіктивних змінних в області застосування, не помітно впливаючи на час роботи.
Діестан

2
Алекс Мартеллі написав добру відповідь щодо цього stackoverflow.com/a/1813167/174728
Джон Ла Рой

53
@Scharron ти наполовину правильний. Йдеться про сфери застосування, але причина, що це швидше у місцевих жителів, полягає в тому, що локальні області дії реалізуються як масиви замість словників (оскільки їх розмір відомий під час компіляції).
Катріель

Відповіді:


532

Ви можете запитати, чому швидше зберігати локальні змінні, ніж глобальні. Це деталь реалізації CPython.

Пам'ятайте, що CPython компілюється в байт-код, який запускає інтерпретатор. Коли компілюється функція, локальні змінні зберігаються у масиві фіксованого розміру ( не a dict), а імена змінних присвоюються індексам. Це можливо, тому що ви не можете динамічно додавати локальні змінні до функції. Тоді отримання локальної змінної - це буквально пошук покажчика у списку та збільшення відшкодування, на PyObjectяке тривіально.

Протиставляйте це глобальному пошуку ( LOAD_GLOBAL), який є істинним dictпошуком із залученням хешу тощо. Між іншим, саме тому вам потрібно вказати, global iчи хочете ви, щоб він був глобальним: якщо ви коли-небудь призначите змінну всередині області, компілятор видасть STORE_FASTs для доступу, якщо ви не скажете цього.

До речі, глобальні пошуки все ще досить оптимізовані. Пошуки атрибутів foo.bar- справді повільні!

Ось невелика ілюстрація про локальну змінну ефективність.


6
Це стосується і PyPy, аж до поточної версії (1,8 на момент написання цього запису.) Тестовий код з ОП працює у глобальному масштабі приблизно в чотири рази повільніше порівняно з внутрішньою функцією.
GDorn

4
@Walkerneo Вони не є, якщо ви не сказали це назад. Відповідно до того, що говорять katrielalex та ecatmur, глобальні пошукові пошукові системи проходять повільніше, ніж локальні пошукові пошукові системи через метод зберігання.
Джеремі Придеморе

2
@Walkerneo Основна розмова, що ведеться тут, - це порівняння між локальними пошуковими змінними в межах функції та глобальними пошуковими змінними, які визначені на рівні модуля. Якщо ви помітили у своєму первісному коментарі відповідь на цю відповідь, ви сказали, що "я б не вважав, що глобальні пошукові зміни змін швидші, ніж локальні пошукові властивості змінної". і їх немає. katrielalex сказав, що, хоча локальні пошукові зміни швидкіші, ніж глобальні, навіть глобальні досить оптимізовані та швидші, ніж пошукові атрибути (які різні). Мені не вистачає місця в цьому коментарі для отримання додаткової інформації.
Джеремі Придеморе

3
@Walkerneo foo.bar не є локальним доступом. Це атрибут об'єкта. (Вибачте за відсутність форматування) def foo_func: x = 5, xє локальною для функції. Доступ xлокальний. foo = SomeClass(), foo.bar- доступ до атрибутів. val = 5глобальний - глобальний. Щодо швидкого локального> глобального> атрибута відповідно до того, що я тут прочитав. Тож доступ xдо Інтернету foo_funcвідбувається найшвидше, за ним val- далі foo.bar. foo.attrне є локальним пошуком, оскільки в контексті цього конвоула ми говоримо про те, що локальні пошуки є пошуком змінної, яка належить до функції.
Джеремі Придеморе

3
@thedoctar подивіться на globals()функцію. Якщо ви хочете отримати більше інформації, ніж це, можливо, доведеться почати перегляд вихідного коду для Python. А CPython - це лише назва звичайної реалізації Python - так що ви, мабуть, вже використовуєте його!
Катріель

661

Всередині функції байт-код:

  2           0 SETUP_LOOP              20 (to 23)
              3 LOAD_GLOBAL              0 (xrange)
              6 LOAD_CONST               3 (100000000)
              9 CALL_FUNCTION            1
             12 GET_ITER            
        >>   13 FOR_ITER                 6 (to 22)
             16 STORE_FAST               0 (i)

  3          19 JUMP_ABSOLUTE           13
        >>   22 POP_BLOCK           
        >>   23 LOAD_CONST               0 (None)
             26 RETURN_VALUE        

На верхньому рівні байт-код:

  1           0 SETUP_LOOP              20 (to 23)
              3 LOAD_NAME                0 (xrange)
              6 LOAD_CONST               3 (100000000)
              9 CALL_FUNCTION            1
             12 GET_ITER            
        >>   13 FOR_ITER                 6 (to 22)
             16 STORE_NAME               1 (i)

  2          19 JUMP_ABSOLUTE           13
        >>   22 POP_BLOCK           
        >>   23 LOAD_CONST               2 (None)
             26 RETURN_VALUE        

Різниця в тому, що STORE_FASTшвидше (!) Ніж STORE_NAME. Це пояснюється тим, що функція iє локальною, але на рівні рівнів - глобальною.

Для вивчення байт-коду використовуйте disмодуль . Мені вдалося розібрати функцію безпосередньо, але для розбирання коду верхнього рівня довелося використовувати compileвбудований .


171
Підтверджено експериментом. Вставлення global iу mainфункцію робить час роботи еквівалентним.
Діестан

44
Це дає відповідь на питання, не відповідаючи на питання :) У випадку змінних локальних функцій CPython насправді зберігає їх у кортежі (який може змінюватися з коду С), поки не буде запитаний словник (наприклад, через locals()або inspect.getframe()тощо). Пошук елемента масиву постійним цілим числом набагато швидше, ніж пошук диктату.
дмв

3
Так само і з C / C ++, також використання глобальних змінних викликає значне уповільнення
codejammer

3
Це перший, який я бачив у байт-коді .. Як це на це дивиться, і що важливо знати?
Зак

4
@gkimsey Я згоден. Я просто хотів поділитися двома речами i) Ця поведінка відзначена в інших мовах програмування. Ii)
Збудником

41

Окрім локальних / глобальних змінних часом зберігання, прогнозування опкоду робить функцію швидшою.

Як пояснюються інші відповіді, функція використовує STORE_FASTопкод у циклі. Ось байт-код для циклу функції:

    >>   13 FOR_ITER                 6 (to 22)   # get next value from iterator
         16 STORE_FAST               0 (x)       # set local variable
         19 JUMP_ABSOLUTE           13           # back to FOR_ITER

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

У цьому випадку кожен раз, коли Python бачить FOR_ITER(верхню частину циклу), він "передбачить", що STORE_FASTце наступний опкод, який він повинен виконати. Потім Python заглядає на наступний код коду, і якщо прогноз був правильним, він переходить прямо до STORE_FAST. Це призводить до стискання двох кодів в єдиний код.

З іншого боку, STORE_NAMEопкод використовується в циклі на глобальному рівні. Python робить * не * робить подібні прогнози, коли бачить цей опкод. Натомість він повинен повернутися до вершини циклу оцінки, який має очевидні наслідки для швидкості, з якою виконується цикл.

Щоб детальніше ознайомитись з цією оптимізацією, ось цитата з ceval.cфайлу ("двигун" віртуальної машини Python):

Деякі опкоди, як правило, складаються парами, завдяки чому можна передбачити другий код при запуску першого. Наприклад, GET_ITERчасто супроводжується FOR_ITER. І FOR_ITERчасто супроводжуєтьсяSTORE_FAST або UNPACK_SEQUENCE.

Перевірка прогнозування коштує одиночного високошвидкісного тесту змінної регістру проти константи. Якщо спарювання було хорошим, то передбачення внутрішніх гілок процесора має велику ймовірність успіху, що призводить до майже нульового переходу до наступного коду. Успішне передбачення економить подорож по циклу eval, включаючи дві його непередбачувані гілки, HAS_ARGтест і перемикач. У поєднанні з передбаченням внутрішніх гілок процесора, успішний PREDICTефект призводить до того, що два опкоди запускаються так, ніби вони є єдиним новим кодом з об'єднаними тілами.

У вихідному коді для FOR_ITERопкоду ми можемо побачити , де саме STORE_FASTробиться прогноз :

case FOR_ITER:                         // the FOR_ITER opcode case
    v = TOP();
    x = (*v->ob_type->tp_iternext)(v); // x is the next value from iterator
    if (x != NULL) {                     
        PUSH(x);                       // put x on top of the stack
        PREDICT(STORE_FAST);           // predict STORE_FAST will follow - success!
        PREDICT(UNPACK_SEQUENCE);      // this and everything below is skipped
        continue;
    }
    // error-checking and more code for when the iterator ends normally                                     

PREDICTФункція розширюється, if (*next_instr == op) goto PRED_##opтобто ми просто перейти до початку прогнозованого опкода. У цьому випадку ми стрибаємо сюди:

PREDICTED_WITH_ARG(STORE_FAST);
case STORE_FAST:
    v = POP();                     // pop x back off the stack
    SETLOCAL(oparg, v);            // set it as the new local variable
    goto fast_next_opcode;

Локальна змінна тепер встановлена, і наступний опкод готовий до виконання. Python продовжується через ітерабельний, поки не досягне кінця, роблячи успішне передбачення кожного разу.

На вікі-сторінці Python є додаткова інформація про те, як працює віртуальна машина CPython.


Незначне оновлення: На CPython 3.6 економія від прогнозування трохи знизиться; замість двох непередбачуваних гілок існує лише одна. Зміна пов'язана з переходом від байтового коду до слово коду ; тепер усі "wordcodes" мають аргумент, це просто нуль, коли інструкція логічно не приймає аргумент. Таким чином, HAS_ARGтест ніколи не відбувається (за винятком випадків, коли відстеження низького рівня ввімкнено як під час компіляції, так і під час виконання, що не дає нормальної збірки), залишаючи лише один непередбачуваний стрибок.
ShadowRanger

Навіть цей непередбачуваний стрибок не відбувається в більшості складок CPython, через нову ( як на Python 3.1 , включену за замовчуванням у 3.2 ) обчислювану поведінку gotos; при використанні PREDICTмакрос повністю відключений; натомість більшість випадків закінчуються DISPATCHбезпосередньо в таких галузях. Але на процесорах передбачення гілок ефект подібний до ефекту PREDICT, оскільки розгалуження (та прогнозування) відбувається за кодом, збільшуючи шанси на успішне прогнозування гілок.
ShadowRanger
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.