Почему код Python работает быстрее в функции?

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

Этот фрагмент кода на Python выполняется (Примечание: синхронизация выполняется с помощью функции времени в BASH в Linux.)

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

Тем не менее, если цикл не помещается в функцию,

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

тогда он работает намного дольше:

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

Почему это?

thedoctar
источник
16
Как вы на самом деле сделали выбор времени?
Эндрю Джаффе
53
Просто интуиция, не уверенная, правда ли это: я предполагаю, что это из-за границ. В случае функции создается новая область видимости (то есть вид хэша с именами переменных, привязанными к их значению). Без функции переменные находятся в глобальной области видимости, когда вы можете найти много вещей, следовательно, замедляя цикл.
Шаррон
4
@Scharron Кажется, это не так. Определил фиктивные переменные 200 КБ в области видимости, без видимого влияния на время выполнения.
Дестан
2
Алекс Мартелли написал хороший ответ об этом stackoverflow.com/a/1813167/174728
Джон Ла Рой
53
@Scharron, ты наполовину прав. Речь идет о областях, но причина в том, что это быстрее в локальных системах, заключается в том, что локальные области фактически реализованы как массивы, а не словари (так как их размер известен во время компиляции).
Катриэль

Ответы:

532

Вы можете спросить, почему хранить локальные переменные быстрее, чем глобальные. Это деталь реализации CPython.

Помните, что CPython компилируется в байт-код, который запускает интерпретатор. Когда функция компилируется, локальные переменные сохраняются в массиве фиксированного размера ( не a dict), а имена переменных присваиваются индексам. Это возможно, потому что вы не можете динамически добавлять локальные переменные в функцию. Затем извлечение локальной переменной - это буквально поиск указателя в списке и увеличение количества ссылок на PyObjectтривиальное.

Сравните это с глобальным поиском ( LOAD_GLOBAL), который является истинным dictпоиском, включающим хэш и так далее. Кстати, именно поэтому вам нужно указать global i, хотите ли вы, чтобы оно было глобальным: если вы когда-либо назначите переменную внутри области видимости, компилятор выдаст STORE_FASTs для ее доступа, если вы не скажете этого не делать.

Кстати, глобальные поиски все еще довольно оптимизированы. Атрибут поиски foo.barявляются действительно медленный !

Вот небольшая иллюстрация эффективности локальной переменной.

Katriel
источник
6
Это также относится к PyPy, вплоть до текущей версии (1.8 на момент написания этой статьи). Тестовый код из OP работает примерно в четыре раза медленнее в глобальной области видимости, чем внутри функции.
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встроенный .

ecatmur
источник
171
Подтверждено экспериментом. Вставка global iв mainфункцию делает время выполнения эквивалентным.
Дестан
44
Это отвечает на вопрос, не отвечая на вопрос :). В случае локальных переменных функций CPython фактически сохраняет их в кортеже (который может изменяться из кода C) до тех пор, пока не будет запрошен словарь (например, через locals()или, и inspect.getframe()т. Д.). Поиск элемента массива по постоянному целому числу намного быстрее, чем поиск в dict.
dmw
3
То
3
Это первый байт-код, который я видел ... Как на это смотреть, и что важно знать?
Зак
4
@gkimsey Я согласен. Я просто хотел поделиться двумя вещами: i) Такое поведение отмечено в других языках программирования ii) Причинный агент - это скорее архитектурная сторона, а не сам язык в истинном смысле этого слова
codejammer
42

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

Как объясняют другие ответы, функция использует 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, экономия от прогнозирования немного снижается; вместо двух непредсказуемых ветвей остается только одна. Изменение связано с переходом с байт-кода на код слова ; теперь у всех "кодов слов" есть аргумент, он просто обнуляется, когда инструкция логически не принимает аргумент. Таким образом, HAS_ARGтест никогда не выполняется (за исключением случаев, когда трассировка низкого уровня включена как во время компиляции, так и во время выполнения, что не выполняется в обычной сборке), оставляя только один непредсказуемый переход.
ShadowRanger
Даже этот непредсказуемый переход не происходит в большинстве сборок CPython из-за нового ( начиная с Python 3.1 , включенного по умолчанию в 3.2 ) поведения gotos; при использовании PREDICTмакрос полностью отключается; вместо этого большинство случаев заканчиваются на DISPATCHтом, что ответвляется напрямую. Но на процессорах с предсказанием ветвлений эффект аналогичен таковому PREDICT, поскольку ветвление (и предсказание) выполняется для каждого кода операции, что увеличивает шансы успешного предсказания ветвления.
ShadowRanger