Что происходит с code object дальше: фрейм, стек значений, цикл вычисления, инлайн-кеши прямо внутри байткода, исключения без накладных расходов, проверка между инструкциями и завершение интерпретатора.
Зафиксируй вывод и причину до того, как откроешь ответ.
import dis
class P:
def __init__(self): self.x = 1
p = P()
def get(o): return o.x
dis.dis(get, adaptive=True)
for _ in range(200): get(p)
print("--- после 200 вызовов ---")
dis.dis(get, adaptive=True) 0 RESUME 0
2 LOAD_FAST 0 (o)
4 LOAD_ATTR 0 (x)
14 RETURN_VALUE
--- после 200 вызовов ---
0 RESUME_QUICK 0
2 LOAD_FAST 0 (o)
4 LOAD_ATTR_INSTANCE_VALUE 0 (x)
14 RETURN_VALUEИнструкции переписали сами себя по факту исполнения. И обрати внимание на смещения: между 4 и 14 десять байт, хотя инструкция одна.def get(o): return o.x
print(get.__code__.co_code.hex(" "))97 00 7c 00 6a 00 00 00 00 00 00 00 00 00 53 0097 00 — RESUME, 7c 00 — LOAD_FAST, 6a 00 — LOAD_ATTR, дальше восемь нулевых байт, и 53 00 — RETURN_VALUE. Нули не выравнивание: это четыре ячейки инлайн-кеша, лежащие прямо в потоке инструкций.import timeit
def with_try(n):
t = 0
for i in range(n):
try: t += i
except ValueError: pass
return t
def without(n):
t = 0
for i in range(n): t += i
return t
print(min(timeit.repeat(lambda: without(2_000_000), number=1, repeat=5)))
print(min(timeit.repeat(lambda: with_try(2_000_000), number=1, repeat=5)))
print(len(without.__code__.co_exceptiontable), len(with_try.__code__.co_exceptiontable))0.088
0.087
0 12Ноль накладных расходов. Вход в try не исполняет ни одной инструкции — вся информация об обработчике вынесена в отдельную таблицу из двенадцати байт.def f(data):
err = None
try:
int(data)
except ValueError as err:
pass
return err
print(f("abc"))UnboundLocalError: cannot access local variable 'err'
where it is not associated with a valueПеременная присвоена дважды — до try и в except, — и всё равно недоступна. Компилятор дописал DELETE_FAST err в конец блока except.Урок 22 закончился на code object: неизменяемой структуре с байткодом, константами, именами и посчитанной глубиной стека. Здесь начинается вторая половина — что делает с ней машина.
CPython — стековая виртуальная машина. Нет регистров: операнды кладутся на стек значений, инструкция снимает их и кладёт результат.
Исполнение — один цикл на весь процесс. _PyEval_EvalFrameDefault читает инструкцию, выполняет, переходит к следующей. Вызов Python-функции в 3.11 не входит в него рекурсивно — он подкладывает новый фрейм и продолжает тот же цикл.
Инструкции переписывают сами себя. Начиная с 3.11 машина заменяет общую инструкцию на специализированную по факту наблюдаемых типов и хранит наблюдения прямо в потоке байткода.
Фрейм — это всё, что нужно для одного активного вызова: массив локальных переменных, стек значений, ссылка на code object, указатель текущей инструкции f_lasti и ссылка на вызывающий фрейм.
В 3.11 устройство поменялось принципиально. Раньше каждый вызов создавал полноценный объект frame в куче — со счётчиком ссылок, заголовком и участием в сборке мусора. Теперь фреймы выделяются подряд в непрерывном куске памяти, своём на каждый поток (Include/internal/pycore_frame.h), а объект frame создаётся только если кто-то его попросил: трассировка исключения, sys._getframe(), отладчик, профилировщик.
import sys, timeit
s = '''
import sys
def plain(n):
t = 0
for _ in range(n): t += 1
return t
def with_frame(n):
t = 0
for _ in range(n):
sys._getframe(); t += 1
return t
plain(100); with_frame(100)
'''
print(min(timeit.repeat("plain(1_000_000)", s, number=1, repeat=5)))
print(min(timeit.repeat("with_frame(1_000_000)", s, number=1, repeat=5)))
# → 0.033
# → 0.057 ← +23 нс за материализацию объекта фрейма
Двадцать три наносекунды за каждый sys._getframe() — это и есть цена того, чего в обычном исполнении больше не платят. Отсюда практическое: библиотека логирования, определяющая вызывающего через фрейм, стоит дороже, чем кажется, и в горячем цикле это видно.
Урок 22, фаза 6: таблица символов расставила локальным переменным индексы. Поэтому LOAD_FAST 0 — чтение нулевого элемента массива, а не поиск по имени. Массив лежит в начале фрейма, сразу за ним — стек значений глубиной ровно co_stacksize, размер известен с компиляции.
Следствие, которое регулярно ловит людей в отладчике:
import sys
def f():
x = 1
sys._getframe().f_locals["x"] = 999
return x
print(f())
# → 1 ← запись не подействовала
f_locals у функции — снимок, собранный из массива по требованию, а не живой словарь. Записать через него нельзя. На уровне модуля всё иначе: там локальные и есть глобальные, и f_locals is globals() истинно.
Выражение раскладывается в постфиксную запись, как на калькуляторе с обратной польской нотацией:
import dis
def expr(a, b, c): return a + b * c
dis.dis(expr)
print(expr.__code__.co_stacksize)
# → 2 LOAD_FAST 0 (a)
# → 4 LOAD_FAST 1 (b)
# → 6 LOAD_FAST 2 (c)
# → 8 BINARY_OP 5 (*)
# → 12 BINARY_OP 0 (+)
# → 16 RETURN_VALUE
# → 3
Три значения на стеке в пике — ровно co_stacksize. Проверок переполнения при исполнении нет: место выделено заранее, потому что компилятор посчитал максимум статически.
Python/ceval.c, функция _PyEval_EvalFrameDefault — несколько тысяч строк, по ветке на инструкцию. Формат простой: каждая инструкция ровно два байта — опкод и однобайтовый аргумент. Аргументы больше 255 собираются из префиксов EXTENDED_ARG.
Переход к следующей инструкции — не switch, а computed goto: таблица адресов меток (Python/opcode_targets.h), и в конце каждой ветки прямой прыжок по адресу следующего опкода. Это даёт процессору отдельную точку предсказания перехода на каждую инструкцию вместо одной общей, и стоит порядка пятнадцати процентов скорости.
import sysconfig
print(sysconfig.get_config_var("USE_COMPUTED_GOTOS"))
# → 1
Замер пропускной способности на этой машине: цикл t += 1 на десять миллионов итераций — 0.325 с, то есть около шести наносекунд на инструкцию байткода. Для сравнения, вызов пустой функции — 27 нс, то есть примерно пять инструкций.
Это главное изменение 3.11 (PEP 659) и прямое следствие R2: типы неизвестны при компиляции, значит их надо узнавать при исполнении — и запоминать.
Фрагмент B показывает механику в байтах. У LOAD_ATTR четыре ячейки кеша, то есть восемь байт, лежащих внутри потока инструкций сразу за ней. Хранится там версия типа и смещение атрибута. Опкод CACHE имеет номер 0, и цикл вычисления его просто перешагивает.
import dis
print(dis.opmap["CACHE"], dis.opmap["LOAD_ATTR"])
# → 0 106
print(dis._inline_cache_entries[dis.opmap["LOAD_ATTR"]], "ячеек по 2 байта")
# → 4 ячеек по 2 байта
print(len(dis.opmap), "базовых опкодов,", len(dis._specialized_instructions), "специализированных")
# → 110 базовых опкодов, 71 специализированных
print(sum(1 for x in dis._inline_cache_entries if x), "опкодов носят кеш")
# → 11 опкодов носят кеш
Как это работает. Сначала стоит общая инструкция со счётчиком «прогрева». Когда счётчик срабатывает, инструкция превращается в *_ADAPTIVE, которая смотрит на фактические операнды и переписывает себя в узкоспециализированную: LOAD_ATTR_INSTANCE_VALUE, LOAD_ATTR_SLOT, LOAD_ATTR_MODULE, BINARY_OP_ADD_INT и так далее. Специализированная версия проверяет только своё предположение — одно сравнение версии типа — и делает работу напрямую.
Если предположение перестало выполняться, инструкция деоптимизируется обратно в общую. Отсюда результат из урока 21: однородная коллекция обходится в 1.4 раза быстрее смеси классов. Не потому, что смесь «сложнее», а потому, что кеш промахивается и специализация не удерживается.
Это не JIT: машинный код не генерируется, инструкции остаются байткодом. JIT появился в 3.13 и работает поверх этого механизма, а не вместо него.
До 3.11 вызов Python-функции означал рекурсивный заход в _PyEval_EvalFrameDefault: кадр C-стека на каждый кадр Python. Отсюда историческая связка «предел рекурсии 1000, иначе сегфолт».
В 3.11 инструкция CALL на Python-функцию просто подкладывает новый фрейм в чанк и продолжает тот же цикл. C-стек не растёт:
import sys
sys.setrecursionlimit(30000)
def rec(n): return 0 if n == 0 else rec(n - 1)
print(rec(20000))
# → 0 ← двадцать тысяч кадров, процесс жив
Но sys.setrecursionlimit по-прежнему опасен, и вот почему: рекурсия, проходящая через C, C-стек тратит. __repr__ вложенной структуры, __eq__ с рекурсивным вызовом, сравнение глубоко вложенных списков — всё это идёт через C-функции, и там предел защищает от настоящего исчерпания стека, а не от логической ошибки. Поднимать лимит можно, только если точно знаешь, что рекурсия чисто питоновская.
Фрагмент C: try, в котором ничего не бросается, стоит ровно ноль. До 3.11 вход в блок исполнял SETUP_FINALLY, которая клала обработчик на стек блоков — то есть за каждый проход платили, даже когда исключений не было.
Теперь информация об обработчиках вынесена в co_exceptiontable — таблицу, которая не исполняется, а читается только в момент броска:
import dis
def with_try(n):
t = 0
for i in range(n):
try: t += i
except ValueError: pass
return t
for e in dis._parse_exception_table(with_try.__code__):
print(e)
# → _ExceptionTableEntry(start=40, end=50, target=52, depth=1, lasti=False)
# → _ExceptionTableEntry(start=52, end=72, target=78, depth=2, lasti=True)
# → _ExceptionTableEntry(start=76, end=78, target=78, depth=2, lasti=True)
Читается так: «если исключение возникло на смещении от 40 до 50 — прыгай на 52, стек при этом раскрути до глубины 1». При броске интерпретатор берёт текущий f_lasti, ищет подходящую строку, и если её нет — снимает фрейм и ищет в вызывающем. Это и есть раскрутка стека.
| 300 тысяч операций | время |
|---|---|
try, ключ на месте | 0.0147 с |
if key in d, ключа нет | 0.0149 с |
try, ключа нет — исключение летит | 0.0559 с |
Пока не бросает — бесплатно; когда бросает — 154 наносекунды за штуку. Это точная формулировка того, когда EAFP выигрывает у LBYL, а когда проигрывает.
В цикле вычисления есть один флаг, который проверяется на границах инструкций, — eval breaker. Он объединяет всё, что должно случиться «не прямо сейчас, но скоро»: просьба отдать GIL (урок 19), доставленный сигнал ОС, отложенные вызовы.
Отсюда три вещи, которые обычно знают по отдельности:
x += 1 всё равно гонка: это несколько инструкций.Урок 13 сказал: генератор хранит приостановленный кадр. Здесь видно, что это буквально:
def gen():
a = 1
yield a
b = 2
yield a + b
g = gen()
print(g.gi_frame.f_lasti) # → 0 ещё не начинали
next(g)
print(g.gi_frame.f_lasti, g.gi_frame.f_locals) # → 12 {'a': 1}
next(g)
print(g.gi_frame.f_locals) # → {'a': 1, 'b': 2}
try: next(g)
except StopIteration: pass
print(g.gi_frame) # → None фрейм отпущен
yield не разрушает фрейм, а сохраняет f_lasti и отдаёт управление. Следующий next продолжает с той же инструкции. У генератора фрейм не может жить в общем чанке потока — он переживает вызов, — поэтому для него фрейм копируется в объект генератора.
На этом же механизме стоит await: корутина — тот же приостановленный фрейм, только с другим протоколом возобновления (урок 14).
Py_FinalizeEx из Python/pylifecycle.c разбирает интерпретатор в фиксированном порядке. Порядок наблюдаем:
import atexit
class Noisy:
def __init__(self, name): self.name = name
def __del__(self): print("__del__", self.name)
keep = Noisy("модульная переменная")
atexit.register(lambda: print("atexit сработал"))
def main():
local = Noisy("локальная")
print("main закончилась")
main()
print("конец модуля")
# → main закончилась
# → __del__ локальная ← счётчик ссылок, сразу на выходе из функции
# → конец модуля
# → atexit сработал ← до разбора модулей
# → __del__ модульная переменная
Локальная умирает по счётчику ссылок в момент выхода из функции (урок 09). atexit отрабатывает, пока модули ещё целы. Модульные глобальные обнуляются последними — и вот здесь начинается зона, где __del__ уже не гарантирован: модули частично разобраны, импортов больше нет, порядок не определён 🕳.
И совсем мимо всего этого проходит os._exit: он завершает процесс системным вызовом, не давая отработать ни atexit, ни деструкторам, ни сбросу буферов. Иногда это ровно то, что нужно после fork; во всех остальных случаях это способ потерять данные.
Всё в этой главе — 🔧 деталь реализации. Набор опкодов, их номера, наличие инлайн-кешей, формат таблицы исключений, устройство фрейма — ничто из этого не является частью языка и не гарантируется между версиями. Байткод 3.10 несовместим с 3.11 буквально: другие опкоды.
Границы уже сдвинулись. В 3.12 цикл вычисления генерируется из Python/bytecodes.c, добавлены новые инструкции и убраны старые. В 3.13 появился copy-and-patch JIT: часть горячих участков теперь всё-таки превращается в машинный код. В 3.13+ существует сборка без GIL, и eval breaker там устроен иначе.
Специализация не наблюдаема из языка. dis.dis(f, adaptive=True) показывает текущее состояние инструкций, но никакого API «сработала ли специализация» нет и не планируется. Единственный надёжный способ узнать, помогла ли однородность данных, — замер.
Предел рекурсии — счётчик, а не измерение стека. Для чисто питоновской рекурсии в 3.11 он защищает от бесконечного цикла; для рекурсии через C он защищает от настоящего исчерпания C-стека, и там запас нужен реальный. Одно число служит двум разным целям, и это компромисс, а не решение.
Глава показывает R2 · атрибут резолвится в рантайме с той стороны, где за него платят. Двадцать лет obj.x означало полноценный поиск при каждом обращении; PEP 659 не отменил динамику, а сделал ставку: в реальном коде тип на площадке обращения почти всегда один и тот же. Инлайн-кеш запоминает эту ставку и проверяет её одним сравнением.
Отсюда видно и обратное: там, где ставка не играет — смешанная коллекция, класс, меняющийся на лету, — специализация деоптимизируется, и цена возвращается. Динамика не бесплатна, она просто отложена до момента, когда ею действительно пользуются.
Второй корень здесь — R3. Eval breaker существует потому, что счётчик ссылок неатомарен и нужен GIL, а GIL надо кому-то отдавать. Проверка флага между инструкциями — цена решения о модели памяти, вынесенная в самый горячий цикл интерпретатора.
И третье, что видно только здесь: граница атомарности в Python — это граница инструкции байткода, а не строки кода. Всё, что урок 19 говорит про гонки, выводится из этого одного факта.
Стек вызовов в C и фреймы в Python — одно и то же, но с разной ценой.
В C вызов — инструкция процессора call: адрес возврата ложится на аппаратный стек, локальные переменные адресуются как смещения от указателя кадра. Всё это делает железо, стоимость — единицы наносекунд.
В Python вызов — 27 наносекунд, потому что интерпретатор делает то же самое руками: выделяет место под фрейм, копирует аргументы в массив локальных, ставит указатель инструкции. Идея идентична — локальная переменная это индекс, а не имя, — а разница ровно в том, что в C кадр раскладывает компилятор, а здесь его собирает программа.
Изменение 3.11 сделало сходство ещё ближе: раньше каждый фрейм был отдельным объектом в куче, теперь они лежат подряд в одном куске памяти, как кадры на настоящем стеке. Разница осталась одна, зато принципиальная: между инструкциями Python проверяет флаг. Процессор между инструкциями не проверяет ничего — ему не нужно ни отдавать GIL, ни доставлять сигналы в Python-обработчик.
Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.
Потому что менять было поздно, а выигрыш не настолько велик, чтобы оправдать слом.
Регистровая машина адресует операнды напрямую: ADD r3, r1, r2 вместо трёх загрузок на стек и сложения. Инструкций получается меньше, каждая толще, диспетчеризаций — тоже меньше. LuaJIT строит на этом один из самых быстрых динамических рантаймов.
Что мешает Python. Байткод — не внутреннее дело интерпретатора: на него смотрят dis, отладчики, профилировщики, покрытие, py-spy, инструменты трассировки, декомпиляторы, часть библиотек. Смена модели сломала бы весь этот слой разом, и выигрыш — по разным оценкам десятки процентов — этого не окупает. Это R9 в чистом виде.
Плюс у стековой модели есть реальные достоинства: компилятор проще (не нужно распределять регистры), байткод компактнее по числу байт на инструкцию, а генерация кода из AST почти механическая.
И главное — в 3.11 выбрали другой путь к той же цели. Вместо смены модели сделали специализацию: инструкция остаётся стековой, но переписывает себя под конкретные типы, а часть работы уезжает в инлайн-кеш. Выигрыш сопоставимый, ломать ничего не пришлось. JIT в 3.13 продолжает ту же линию — работает поверх байткода, а не вместо него.
Разница простая: JIT порождает машинный код, специализация — нет.
Специализация из 3.11 меняет какая инструкция байткода будет исполняться: вместо общей LOAD_ATTR ставится LOAD_ATTR_INSTANCE_VALUE. Исполняет её по-прежнему цикл вычисления, читая по два байта. Никакой генерации кода не происходит — просто ветка в ceval.c выбирается более узкая и делает меньше проверок.
JIT берёт последовательность инструкций и генерирует под неё нативный код, который процессор исполняет напрямую. Диспетчеризация исчезает совсем, а не сокращается.
| специализация (3.11) | JIT (3.13+) | |
|---|---|---|
| что на выходе | другой опкод | машинный код |
| кто исполняет | цикл вычисления | процессор |
| стоимость прогрева | счётчик и одна перезапись | компиляция участка |
| работает без | — | специализации не работает |
И они не альтернативы, а этажи. Copy-and-patch JIT в 3.13 берёт уже специализированные инструкции и склеивает для них заранее скомпилированные шаблоны машинного кода. Без специализации ему пришлось бы генерировать общий код с проверками типов — то есть тот же интерпретатор, только развёрнутый.
Поэтому PEP 659 был не «временным решением до JIT», а фундаментом под него.
Историческая величина, подобранная так, чтобы гарантированно не переполнить C-стек. Сегодня она защищает две разные вещи одним числом.
До 3.11 каждый вызов Python-функции означал рекурсивный заход в _PyEval_EvalFrameDefault, то есть кадр на C-стеке. Стек потока по умолчанию 8 МБ, кадр интерпретатора — несколько сотен байт, отсюда порядок в тысячу с запасом на глубокие кадры и вложенные вызовы C.
В 3.11 картина изменилась: вызов Python-функции больше не расходует C-стек, и чистая рекурсия упирается только в счётчик:
import sys
sys.setrecursionlimit(30000)
def rec(n): return 0 if n == 0 else rec(n - 1)
print(rec(20000)) # → 0 двадцать тысяч кадров, процесс жив
Но поднимать лимит по-прежнему опасно, и вот почему: рекурсия, проходящая через C, тратит настоящий стек. __repr__ вложенной структуры, __eq__ с рекурсивным вызовом, сравнение глубоко вложенных списков, сериализация — всё это уходит в C-функции. Там лимит защищает от реального переполнения, и превышение даёт не RecursionError, а сегфолт: процесс умирает молча, без трассировки.
Практически. Поднимать лимит можно, только если точно знаешь, что рекурсия чисто питоновская. Надёжнее — переписать в цикл с явным стеком: для обхода деревьев и графов это почти всегда возможно и заодно снимает вопрос о глубине. И стоит включить faulthandler.enable() — тогда при сегфолте будет хотя бы питоновская трассировка.
frame материализуется только по требованию и стоит 23 нс. Выражение раскладывается в постфиксную запись на стек. Цикл вычисления читает по два байта на инструкцию и прыгает computed goto — около шести наносекунд на инструкцию, 27 на вызов функции. Главное изменение 3.11: инструкции переписывают сами себя — 110 базовых опкодов, 71 специализированный, и наблюдения хранятся в инлайн-кешах прямо внутри потока байткода, восемь нулевых байт после LOAD_ATTR это они. Вызов Python-функции больше не тратит C-стек, поэтому двадцать тысяч кадров рекурсии не роняют процесс — но рекурсия через C тратит по-прежнему. try без броска стоит ровно ноль: обработчики вынесены в таблицу, читаемую только при исключении; сам бросок стоит 154 нс. Между инструкциями проверяется eval breaker — отсюда и переключение GIL, и то, что Ctrl-C не прерывает вызов в C, и что граница атомарности проходит по инструкции, а не по строке. Генератор — тот же фрейм, но с сохранённым f_lasti. На завершении локальные умирают по счётчику, потом atexit, и последними — модульные глобальные, уже в зоне, где __del__ не гарантирован. Всё перечисленное 🔧 целиком: в 3.12 цикл генерируется из другого файла, в 3.13 сверху появился JIT.JVM. Тоже стековая машина с байткодом, но с JIT с самого начала: HotSpot профилирует, компилирует горячие методы в машинный код и агрессивно инлайнит. Причина, по которой это оказалось проще: в Java тип известен статически, и компилятор может рассуждать о нём. В Python инлайн-кеш вынужден проверять предположение на каждом обращении — это уже не оптимизация, а страховка.
V8 в JavaScript. Ближайший родственник по задаче: динамические типы, объекты как словари. Решение похожее и появилось намного раньше — hidden classes и inline caches. PEP 659 в значительной мере переносит эту идею в CPython, с той разницей, что V8 генерирует машинный код, а CPython до 3.13 оставался чистым интерпретатором.
Lua. Тоже интерпретатор байткода, но регистровая машина вместо стековой: инструкции адресуют операнды напрямую, а не через стек. Инструкций получается меньше, каждая толще. LuaJIT на этом строит один из самых быстрых динамических рантаймов вообще. CPython остался стековым по причине совместимости — переход сломал бы все инструменты, работающие с байткодом. Это R9 в чистом виде.
Почему в CPython долго не было JIT. Не из-за нехватки желания: попытки шли двадцать лет (Psyco, Unladen Swallow, Pyston, Pyjion). Мешало то же самое, что мешает убрать GIL, — C API из урока 15. Расширение может получить указатель на любой объект и делать с ним что угодно, поэтому JIT не может считать доказанным почти ничего.
except удаляется после блокаПеременная присвоена дважды и всё равно недоступнаРазбор ответа внешнего сервиса. Ошибку хотим не терять, а вернуть наверх вместе с результатом.
def parse_amount(raw):
err = None
value = None
try:
value = int(raw)
except ValueError as err: # ← вот здесь
pass
return value, err
Что происходит. На успешном пути всё работает: err остаётся None. На пути с ошибкой — UnboundLocalError на строке return, потому что компилятор дописывает в конец блока except инструкцию DELETE_FAST err.
Зачем это сделано. Объект исключения держит ссылку на трассировку, трассировка — на фреймы, фреймы — на все локальные переменные. Оставить имя связанным значило бы создать цикл фрейм → исключение → трассировка → фрейм и продлить жизнь всему содержимому фрейма до следующего прохода сборщика (урок 10). Python 3 решил это радикально: имя удаляется на выходе из блока, всегда, безусловно — даже если оно существовало до try.
Почему проходит ревью. Инициализация err = None прямо над try выглядит как исчерпывающая защита. Тест на успешный путь зелёный. Тест на ошибку часто проверяет только что «вернулось None в value» и не смотрит на второй элемент.
Проверить:
import dis
print(any(i.opname == "DELETE_FAST" and i.argval == "err"
for i in dis.get_instructions(parse_amount)))
# → True
Как чинить. Переложить в другое имя внутри блока — единственный рабочий способ:
def parse_amount(raw):
err = None
value = None
try:
value = int(raw)
except ValueError as exc:
err = exc # exc умрёт, err останется
return value, err
Общее правило. Имя после as живёт ровно до конца блока except и не переживает его ни при каких условиях. Если объект исключения нужен дальше — присвоить его другому имени внутри блока.
try без броска бесплатен, с броском стоит 154 нсСпор «просить прощения или разрешения» обычно ведут стилистически. Он решается замером, и ответ не в пользу одной из сторон.
import timeit
def eafp(d, keys):
t = 0
for k in keys:
try: t += d[k]
except KeyError: t += 1
return t
def lbyl(d, keys):
t = 0
for k in keys:
if k in d: t += d[k]
else: t += 1
return t
| 300 тысяч обращений | EAFP (try) | LBYL (if in) |
|---|---|---|
| ключ всегда есть | 0.0147 с | 0.0149 с |
| ключа никогда нет | 0.0559 с | 0.0149 с |
Когда промахов нет — формы неразличимы, и try даже чуть быстрее, потому что LBYL делает лишнюю проверку вхождения. Когда промахи постоянны — try проигрывает в 3.8 раза, и вся разница уходит на создание объекта исключения, сборку трассировки и раскрутку.
Правило, выводимое из механизма: try дешевле, пока исключение — действительно исключение. Порог грубо на десяти процентах промахов: реже — EAFP выигрывает и читается лучше, чаще — это не обработка ошибки, а поток управления через исключения, и его надо переписать.
Где это ловится на практике. Разбор входных данных, где невалидных записей половина. Обход дерева с try: node.child except AttributeError на листьях. Кеш, у которого низкий процент попаданий. Во всех трёх случаях исключение — норма, а не исключение, и цена накапливается.
Чего этот замер не говорит. Что try надо избегать. Наоборот: раз он бесплатен в отсутствие броска, оборачивать им блоки, где ошибка редка, — правильно и ничего не стоит. Изменилось только то, что до 3.11 это было неправдой, и старые бенчмарки показывали накладные расходы даже на успешном пути.
Первый — увидеть, как инструкции переписывают себя, и на своём коде:
import dis
class P:
__slots__ = ("x",)
def __init__(self): self.x = 1
class Q:
def __init__(self): self.x = 1
def get(o): return o.x
p, q = P(), Q()
for _ in range(200): get(p)
dis.dis(get, adaptive=True) # какая специализация выбрана для __slots__?
for _ in range(200): get(q) # а теперь подмешали другой тип
dis.dis(get, adaptive=True) # что стало с инструкцией?
Вопрос: специализация под __slots__ и под обычный класс — одна и та же инструкция или разные? Что происходит, когда на одну площадку приходят оба типа?
Второй — кеши в байтах:
import dis
def f(o): return o.x
code = f.__code__.co_code
print(code.hex(" "))
print("длина", len(code), "байт")
for name in ("LOAD_ATTR", "CALL", "BINARY_OP", "LOAD_GLOBAL", "FOR_ITER"):
print(name, dis._inline_cache_entries[dis.opmap[name]], "ячеек")
Третий — таблица исключений и её отсутствие:
import dis
def clean(n):
t = 0
for i in range(n): t += i
return t
def guarded(n):
t = 0
for i in range(n):
try: t += i
except ValueError: pass
return t
print(len(clean.__code__.co_exceptiontable), len(guarded.__code__.co_exceptiontable))
for e in dis._parse_exception_table(guarded.__code__): print(e)
Четвёртый — где проходит граница рекурсии:
import sys
sys.setrecursionlimit(30000)
def pyrec(n): return 0 if n == 0 else pyrec(n - 1)
print(pyrec(20000)) # чистый Python — проходит
# А теперь рекурсия, которая идёт через C.
# Осторожно: при большой глубине это уронит процесс по-настоящему.
x = []; cur = x
for _ in range(200):
nxt = []; cur.append(nxt); cur = nxt
print(len(repr(x))) # repr рекурсивен и идёт через C
sys.setrecursionlimit(1000)
Вопрос к последнему: почему один и тот же лимит защищает две принципиально разные вещи, и что именно случится, если поднять его до миллиона?
Lib/dis.py. Кроме dis.dis: _parse_exception_table показывает таблицу из фазы 7 урока 22, _inline_cache_entries — сколько ячеек кеша у каждого опкода, _specialized_instructions — полный список специализаций. Всё с подчёркиванием, то есть без обещаний, но это единственный способ увидеть механизм.
sys.settrace и sys.setprofile. Крючки, на которых стоят pdb, coverage и профилировщики. Важное следствие этой главы: включённая трассировка заставляет материализовать объект фрейма на каждый вызов и отключает часть оптимизаций — отсюда десятикратное замедление под coverage, и отсюда же правило не мерить производительность под отладчиком.
faulthandler. Печатает питоновскую трассировку при сегфолте и по таймауту. Ровно тот инструмент, который нужен, когда процесс умирает молча из-за исчерпания C-стека или падения в расширении. Одна строка faulthandler.enable() в точке входа окупается на первом же инциденте.
Lib/traceback.py. Читается за двадцать минут и снимает вопросы про __cause__, __context__ и raise ... from. Полезно вместе с уроком 12.
py-spy (github.com/benfred/py-spy). Читает фреймы чужого процесса прямо из памяти, без его участия. Работает именно потому, что раскладка фрейма — фиксированная структура: py-spy знает её для каждой версии CPython и ходит по цепочке f_back снаружи.
Инструкция в байтах. Всегда два байта: опкод и аргумент. Аргументы больше 255 набираются префиксами EXTENDED_ARG, каждый добавляет восемь старших бит — до трёх префиксов, то есть до 32-битного аргумента.
import dis
code = compile("x = " + " + ".join(str(i) for i in range(300)), "<s>", "exec")
print(any(i.opname == "EXTENDED_ARG" for i in dis.get_instructions(code)))
# → False ← сумма трёхсот литералов свёрнута в одну константу ещё на фазе 5
big = compile("d = {" + ",".join(f"'k{i}': {i}" for i in range(400)) + "}", "<s>", "exec")
print(sum(1 for i in dis.get_instructions(big) if i.opname == "EXTENDED_ARG"))
# → 537 ← четыреста ключей не сворачиваются, индексы вылезли за 255
Первый результат — напоминание, что фаза 5 работает раньше: свернуть арифметику дешевле, чем генерировать для неё инструкции. Второй показывает, зачем префикс вообще нужен.
Инлайн-кеши по опкодам. Одиннадцать инструкций носят кеш; размер выбран под то, что нужно хранить:
| инструкция | ячеек (по 2 байта) | что хранится |
|---|---|---|
LOAD_ATTR | 4 | Версия типа и смещение атрибута |
LOAD_METHOD | 10 | Версия типа, сам метод, флаги связывания |
CALL | 4 | Форма вызываемого: функция, метод, C-функция |
BINARY_OP | 1 | Счётчик прогрева, дальше опкод меняется целиком |
LOAD_GLOBAL | 5 | Версии словарей модуля и builtins плюс индекс |
Ради этого dict получил счётчик версий: специализированная LOAD_GLOBAL проверяет, что глобальный словарь не менялся, одним сравнением целых вместо поиска по хешу. Это прямая связь с уроком 05.
Цена операций, замерено.
| операция | наносекунд |
|---|---|
| одна инструкция байткода | ≈ 6 |
| вызов функции Python | 27 |
| вызов метода (сверх вызова функции) | +5 |
материализация объекта фрейма (sys._getframe) | 23 |
| бросок и перехват исключения | 154 |
вход в try, если ничего не брошено | 0 |
Отсюда прикидка, полезная при чтении профиля: функция из десяти строк простого кода — это порядка сотни инструкций, то есть примерно 600 наносекунд плюс 27 на сам вызов. Если в профиле она стоит десять микросекунд, время уходит не на интерпретацию, а на что-то внутри неё.
Раскладка фрейма. В памяти подряд: заголовок (_PyInterpreterFrame: ссылка на code object, на предыдущий фрейм, на globals и builtins, указатель инструкции), затем массив локальных на co_nlocals элементов, затем стек значений на co_stacksize элементов. Ничего динамического — размер известен из code object, поэтому выделение фрейма это сдвиг указателя в чанке потока.
Почему это дало 3.11 ускорение. Старый объект frame был обычным объектом Python: заголовок со счётчиком ссылок, участие в сборке мусора, отдельное выделение и освобождение на каждый вызов. Новая схема убрала всё это с горячего пути и оставила только там, где объект действительно запросили.
| что | файл |
|---|---|
| цикл вычисления | Python/ceval.c — искать _PyEval_EvalFrameDefault |
| таблица переходов | Python/opcode_targets.h — сгенерированная, но посмотреть стоит |
| номера опкодов | Include/opcode.h |
| специализация | Python/specialize.c — здесь принимается решение, во что превратить инструкцию |
| формат кешей | Include/internal/pycore_code.h |
| фрейм | Include/internal/pycore_frame.h — вся раскладка в одной структуре |
| объект фрейма | Objects/frameobject.c — материализация и f_locals |
| вызовы | Objects/call.c — протоколы вызова, включая vectorcall |
| исключения | Python/errors.c, раскрутка — в ceval.c |
| eval breaker и GIL | Python/ceval_gil.h |
| генераторы | Objects/genobject.c — как фрейм переезжает в объект |
| завершение | Python/pylifecycle.c — Py_FinalizeEx |
С чего начать, если открывать один файл. Python/specialize.c: там в шапке изложена вся идея PEP 659 словами, а дальше по функции на семейство инструкций — видно, какие именно предположения делает машина про твой код.
Один приём для чтения ceval.c. Не читать подряд. Взять инструкцию из dis.dis своего кода, найти в файле TARGET(LOAD_ATTR) и прочитать один блок. Каждая ветка самодостаточна и укладывается в экран.
Что изменилось после 3.11. В 3.12 ceval.c больше не содержит тела инструкций — они переехали в Python/bytecodes.c, откуда генерируются и цикл, и таблицы. Структура читается иначе, идеи те же.
obj.x насквозь — процедура поиска атрибута; здесь видно, как её результат кешируется прямо в инструкции и что происходит, когда кеш промахивается.yield from и L14 · async/await — приостановленный фрейм из раздела 9 это их общий фундамент.try стал бесплатным, и почему имя после as удаляется.ceval.c, и продолжения серии закрывают фреймы, объекты и GIL.