Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Генератор байткода и байткод генератора, Михаил...

Генератор байткода и байткод генератора, Михаил Ефимов, PythoNN

Avatar for Sobolev Nikita

Sobolev Nikita

September 21, 2025
Tweet

More Decks by Sobolev Nikita

Other Decks in Programming

Transcript

  1. Все примеры кода были запущены на Python 3.14.0rc2, собранном из

    исходников. Что такое Python-байткод? Это байтовое представление инструкций и их аргументов. И правила интерпретации этого байтового представления.
  2. Нам потребуется стандартный модуль dis (сокращение от disassembler). Будем использовать

    функции dis.dis и dis.show_code из него. В качестве аргумента dis.dis может принимать:  строки с исходных кодом;  функции, методы, классы, генераторы и прочие «запускаемые» объекты;  «сырой байткод».
  3. Вызовем dis.dis на минимальном примере кода - на строке, содержащей

    число 12345: >>> import dis >>> dis.dis('12345') 0 RESUME 0 1 LOAD_CONST 0 (12345) RETURN_VALUE 1. RESUME с параметром 0 обозначает начало обычной функции. 2. LOAD_CONST с параметром 0 помещает на стек данных виртуальной машины константу с индексом 0. В скобках указано её реальное значение 12345. 3. RETURN_VALUE берет значение из головы стека данных и возвращает это значение из функции. Числа слева от инструкций обозначают номера строк.
  4. Таким образом, при запуске dis.dis от константного выражения на выходе

    получается код функции, возвращающей это выражение. Действительно, аналогичный байткод можно получить при дизассемблировании функции без аргументов, возвращающей 12345: >>> dis.dis(lambda: 12345) 1 RESUME 0 LOAD_CONST 0 (12345) RETURN_VALUE Причина: dis.dis при запуске на строке исходного кода первым этапом компилирует код.
  5. Посмотрим на поведение compile: >>> code1 = compile('12345', filename='expr', mode='eval')

    >>> code2 = (lambda: 12345).__code__ >>> code1.co_code == code2.co_code True Иными словами, объекты code1 и code2 имеют одинаковый байткод. Такие объекты называются code objects и являются основой всех «запускаемых» сущностей.
  6. С помощью функции dis.show_code можно узнать немало о функции: аргументы,

    переменные, константы, флаги, необходимая длина стека и т.п. >>> x = lambda: 12345 >>> dis.show_code(x) Name: <lambda> Filename: <python-input-48> Argument count: 0 Positional-only arguments: 0 Kw-only arguments: 0 Number of locals: 0 Stack size: 1 Flags: OPTIMIZED, NEWLOCALS Constants: 0: 12345
  7. Все эти данные можно получить и непосредственно через code object,

    который хранится в атрибуте __code__: >>> x.__code__.co_consts (12345,) >>> x.__code__.co_argcount 0 >>> x.__code__.co_name '<lambda>' Для работы с флагами функций удобно пользоваться модулем inspect: >>> import inspect >>> x.__code__.co_flags 3 >>> inspect.CO_OPTIMIZED | inspect.CO_NEWLOCALS 3
  8. Посмотрим непосредственно на байткод этой функции: >>> type(x.__code__.co_code) <class 'bytes'>

    >>> [b for b in x.__code__.co_code] [128, 0, 82, 0, 35, 0] Каждой инструкции соответствует два байта, первый называется opcode, а второй oparg. До версии 3.6 число байт не было фиксировано и зависело от инструкции. В данном случае у нас три инструкции, потому байткод имеет 6 байт, все oparg равны 0. С помощью dis.opname убедимся, что это те самые инструкции: >>> [dis.opname[x] for x in (128, 82, 35)] ['RESUME', 'LOAD_CONST', 'RETURN_VALUE']
  9. В исходном коде CPython в файле Include/opcode_ids.h используются именно эти

    значения: #define RETURN_VALUE 35 ... #define LOAD_CONST 82 ... #define RESUME 128 Байткод Python часто меняется, и соответствие между числовыми значениями opcode и инструкциями тоже изменяется. Контролируется это соответствие при помощи "магического номера", который находится в Include/internal/pycore_magic_number.h: #define PYC_MAGIC_NUMBER 3626
  10. Рассмотрим другие простые примеры с использованием констант: >>> dis.dis("'42'") 0

    RESUME 0 1 LOAD_CONST 0 ('42') RETURN_VALUE >>> dis.dis("42") 0 RESUME 0 1 LOAD_SMALL_INT 42 RETURN_VALUE Неожиданно вместо инструкции LOAD_CONST появилась инструкция LOAD_SMALL_INT! Она используется с целью оптимизации для загрузки на стек маленьких чисел.
  11. >>> y = lambda: 42 >>> dis.dis(y) 1 RESUME 0

    LOAD_SMALL_INT 42 RETURN_VALUE >>> [b for b in y.__code__.co_code] [128, 0, 94, 42, 35, 0] У второй инструкции oparg равен 42. Косвенная адресация через кортеж констант y.__code__.co_consts не используется, значение вписано напрямую в oparg. Разумеется, это работает только с числами, которые помещаются в байт.
  12. Попробуем "заменить" это число напрямую в байткоде. Для этого создадим

    функцию z с измененным code object: >>> y_ba = bytearray(y.__code__.co_code) >>> y_ba[3] = 33 >>> z_code = y.__code__.replace(co_code=bytes(y_ba)) >>> import types >>> z = types.FunctionType(z_code, {}) >>> dis.dis(z) 1 RESUME 0 LOAD_SMALL_INT 33 RETURN_VALUE >>> z() 33 Прямое редактирование байткода может приводить к различным проблемам, включая аварийную остановку интерпретатора. Не используйте подобные приемы в production-коде!
  13. Байткод сложения >>> dis.dis('a+b') 0 RESUME 0 1 LOAD_NAME 0

    (a) LOAD_NAME 1 (b) BINARY_OP 0 (+) RETURN_VALUE Здесь используются не константы, а переменные. Логика простая: сначала с помощью LOAD_NAME положили на стек значение a и значение b, а потом инструкция BINARY_OP взяла два значения с головы стека и положила обратно результат сложения.
  14. Сложение переменной и константы: >>> dis.dis('a+2') 0 RESUME 0 1

    LOAD_NAME 0 (a) LOAD_SMALL_INT 2 BINARY_OP 0 (+) RETURN_VALUE
  15. Сложение двух констант: >>> dis.dis('40+2') 0 RESUME 0 1 LOAD_SMALL_INT

    42 RETURN_VALUE В байткоде никакого сложения уже не осталось, оно было выполнено на этапе компиляции!
  16. Константные выражения >>> dis.dis('1+2+3+4+5+6+7+8+9') 0 RESUME 0 1 LOAD_SMALL_INT 45

    RETURN_VALUE >>> dis.dis('1+2*3-4*5**6-7.8**9') 0 RESUME 0 1 LOAD_CONST 1 (-106931413.91328458) RETURN_VALUE
  17. Но если добавить вызов функции, то он останется в байткоде:

    >>> dis.dis(lambda: len("123")) 1 RESUME 0 LOAD_GLOBAL 1 (len + NULL) LOAD_CONST 0 ('123') CALL 1 RETURN_VALUE
  18. Аналогично с методами: >>> dis.dis(lambda: " ".join(("x", "y"))) 1 RESUME

    0 LOAD_CONST 0 (' ') LOAD_ATTR 1 (join + NULL|self) LOAD_CONST 1 (('x', 'y')) CALL 1 RETURN_VALUE Причина: функции и методы, в том числе встроенные, могут меняться.
  19. Аргументы функций Есть специальные инструкции для загрузки аргументов и локальных

    переменных: >>> dis.dis(lambda a: a + 2) 1 RESUME 0 LOAD_FAST_BORROW 0 (a) LOAD_SMALL_INT 2 BINARY_OP 0 (+) RETURN_VALUE >>> dis.dis(lambda a, b: a + b) 1 RESUME 0 LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b) BINARY_OP 0 (+) RETURN_VALUE
  20. Байткод "обычных" функций >>> def x(): return 42 ... >>>

    dis.dis(x) 1 RESUME 0 LOAD_SMALL_INT 42 RETURN_VALUE >>> y = lambda: 42 >>> x.__code__.co_code == y.__code__.co_code True Видно, что никакой разницы нет. Иными словами, def-нотация и lambda-нотация отличаются только синтаксически.
  21. Вложенные функции >>> def outer(): ... a = 0 ...

    def inner(): ... nonlocal a ... a += 1 ... return a ... return inner ... >>> x = outer() >>> x() 1 >>> x() 2 >>> y = outer() >>> y() 1
  22. Каждая из функций x и y хранит свое значение переменной

    a. Эти значения лежат в замыкании: >>> x.__closure__ (<cell at 0x7fc0b28dfb10: int object at 0x55f13bcddbd8>,) >>> y.__closure__ (<cell at 0x7fc0b2b1aad0: int object at 0x55f13bcddbb8>,) >>> x.__closure__[0].cell_contents 2 >>> y.__closure__[0].cell_contents 1
  23. Содержимое замыкания можно изменять непосредственно: >>> x.__closure__[0].cell_contents = 42 >>>

    x() 43 Замыкания можно сравнивать: >>> y.__closure__[0].cell_contents = 43 >>> x.__closure__ == y.__closure__ True При этом у каждой из функций x и y свое замыкание: >>> x.__closure__ is y.__closure__ False А code object общий: >>> x.__code__ is y.__code__ True
  24. В байткоде функции outer много интересных моментов, связанных с созданием

    функции и замыкания: >>> dis.dis(outer) -- MAKE_CELL 1 (a) 1 RESUME 0 2 LOAD_SMALL_INT 0 STORE_DEREF 1 (a) 3 LOAD_FAST_BORROW 1 (a) BUILD_TUPLE 1 LOAD_CONST 1 (<code object ...>) MAKE_FUNCTION SET_FUNCTION_ATTRIBUTE 8 (closure) STORE_FAST 0 (inner) 7 LOAD_FAST_BORROW 0 (inner) RETURN_VALUE
  25. Так выглядит функция inner: Disassembly of <code object ...>: --

    COPY_FREE_VARS 1 3 RESUME 0 5 LOAD_DEREF 0 (a) LOAD_SMALL_INT 1 BINARY_OP 13 (+=) STORE_DEREF 0 (a) 6 LOAD_DEREF 0 (a) RETURN_VALUE Сравните с кодом функции: ... def inner(): ... nonlocal a ... a += 1 ... return a
  26. Посмотрим на его поля: >>> g = gen() >>> g.gi_code

    is gen.__code__ True В атрибуте gi_code хранится code object генераторной функции. >>> g.gi_frame <frame at 0x7f43d808fa00, file '...', line 1, code gen> Внутри gi_frame лежит связанный с генератором фрейм исполнения.
  27. >>> dis.dis(g) 1 RETURN_GENERATOR POP_TOP L1: RESUME 0 LOAD_SMALL_INT 42

    YIELD_VALUE 0 RESUME 5 POP_TOP LOAD_CONST 1 (None) RETURN_VALUE -- L2: CALL_INTRINSIC_1 3 (...) RERAISE 1 ExceptionTable: L1 to L2 -> L2 [0] lasti По сравнению с обычными функциями появились инструкции RETURN_GENERATOR и YIELD_VALUE, добавилась таблица исключений. Инструкция RETURN_GENERATOR создает и возвращает объект генератора g. При этом текущий фрейм исполнения «отцепляется» и в дальнейшем хранится непосредственно в g.
  28. Генераторные выражения Такие выражения – альтернативный способ объявить генератор. У

    них есть своя генераторная функция, задаваемая неявно. >>> g = (x for x in range(5)) >>> list(g) [0, 1, 2, 3, 4] >>> g.gi_frame <frame at 0x7f43d7ce49f0, file '...', line 1, code <genexpr>> >>> g.gi_code <code object <genexpr> at 0x7f43d7c81690, file "...", line 1>
  29. >>> dis.dis(g) 1 RETURN_GENERATOR POP_TOP L1: RESUME 0 LOAD_FAST 0

    (.0) L2: FOR_ITER 6 (to L3) STORE_FAST_LOAD_FAST 17 (x, x) YIELD_VALUE 0 RESUME 5 POP_TOP JUMP_BACKWARD 8 (to L2) L3: END_FOR POP_ITER LOAD_CONST 0 (None) RETURN_VALUE -- L4: CALL_INTRINSIC_1 3 (...) RERAISE 1 ExceptionTable: L1 to L4 -> L4 [0] lasti Интересно, что вызова range(5) в этом байткоде нет.
  30. Но есть обращение к переменной с необычным именем .0 через

    инструкцию LOAD_FAST! Посмотрим на информацию о code object: >>> dis.show_code(g) Name: <genexpr> Filename: <python-input-467> Argument count: 1 Positional-only arguments: 0 Kw-only arguments: 0 Number of locals: 2 Stack size: 2 Flags: OPTIMIZED, NEWLOCALS, GENERATOR Constants: 0: None Variable names: 0: .0 1: x Действительно, есть переменная с таким именем. Кроме того, присутствует флаг GENERATOR.
  31. Именно в переменной .0 хранится «базовый» итератор, на основе которого

    строится генераторное выражение. Изменим его: >>> g = (x for x in range(5)) >>> type(g.gi_frame.f_locals) <class 'FrameLocalsProxy'> >>> g.gi_frame.f_locals['.0'] = iter(range(10)) >>> list(g) [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] Такой код будет работать только начиная с 3.13, в этой версии введен FrameLocalsProxy. На более старых версиях Python изменение в f_locals не приведет к реальному изменению генератора g. Потому что в качестве f_locals использовался словарь с копиями значений локальных переменных.
  32. Списочные выражения В языке есть конструкции, близкие к генераторным выражениям:

     list comprehensions;  set comprehensions;  dict comprehensions. Обрабатываются они сходным образом. Посмотрим на результат дизассемблирования списочного выражения.
  33. >>> dis.dis('[x for x in range(10)]') 0 RESUME 0 1

    LOAD_NAME 0 (range) PUSH_NULL LOAD_SMALL_INT 10 CALL 1 GET_ITER LOAD_FAST_AND_CLEAR 0 (x) SWAP 2 L1: BUILD_LIST 0 SWAP 2 L2: FOR_ITER 4 (to L3) STORE_FAST_LOAD_FAST 0 (x, x) LIST_APPEND 2 JUMP_BACKWARD 6 (to L2)
  34. L3: END_FOR POP_ITER L4: SWAP 2 STORE_FAST 0 (x) RETURN_VALUE

    -- L5: SWAP 2 POP_TOP 1 SWAP 2 STORE_FAST 0 (x) RERAISE 0 ExceptionTable: L1 to L4 -> L5 [2]
  35. В отличие от генераторного выражения, code object не объявляется. Причина:

    PEP 709 – «Inlined comprehensions», внедренный в Python 3.12. Создание отдельных code object убрали для list/set/dict comprehensions. Нет необходимости создавать промежуточный генератор. Можно обойтись итеративным наполнением списка/множества/словаря. С генераторными выражениями так не получится в силу их специфики. До полного завершения итерации генератора необходимо хранить фрейм исполнения.
  36. Фреймы исполнения При обычном вызове кода фреймы "вкладываются" друг в

    друга. Посмотрим на примере двух функций: >>> import inspect >>> f1 = lambda: inspect.currentframe() >>> f1() <frame at 0x7fc0b2d17a00, file '...', line 1, code <lambda>> >>> f1().f_code is f1.__code__ True >>> f2 = lambda: f1().f_back == inspect.currentframe() >>> f2() True Функция f1 возвращает свой фрейм исполнения. А функция f2 вызывает f1 и сравнивает поле f_back ответа с текущим фреймом, то есть с фреймом исполнения f2. Иными словами, каждый вызов функции порождает новый фрейм, и в поле f_back фрейма лежит ссылка на его "родителя".
  37. С другой стороны, у генераторов фреймы специальные, «отцепленные» от обычных:

    >>> g = (x for x in range(2)) >>> g.gi_frame <frame at 0x7fc0b2b96eb0, file '...', line 1, code <genexpr>> >>> g.gi_frame.f_back is None True Генераторный фрейм создается вместе с генератором инструкцией RETURN_GENERATOR и существует до тех пор, пока генератор не будет исчерпан. Код в составе этого фрейма исполняется не сразу, а по частям: каждая инструкция YIELD_VALUE останавливает исполнение и возвращает управление вызывающему фрейму. Этот механизм лежит в основе всей асинхронности современного Python. Причина: генератор, корутина и асинхронный генератор - по сути один и тот же объект.
  38. Посмотрим на код (Include/internal/pycore_interpframe_structs.h): #define _PyGenObject_HEAD(prefix) \ PyObject_HEAD \ ...

    \ PyObject *prefix##_name; \ PyObject *prefix##_qualname; \ _PyErr_StackItem prefix##_exc_state; \ ... \ int8_t prefix##_frame_state; \ _PyInterpreterFrame prefix##_iframe; \ struct _PyGenObject { _PyGenObject_HEAD(gi) }; struct _PyCoroObject { _PyGenObject_HEAD(cr) }; struct _PyAsyncGenObject { _PyGenObject_HEAD(ag) };
  39. С помощью одного макроса объявлены генератор _PyGenObject, корутина _PyCoroObject и

    асинхронный генератор _PyAsyncGenObject. Отличаются они только тем, какой префикс будут иметь поля:  у генератора – gi_name и gi_iframe;  у корутины – cr_name и cr_iframe;  у асинхронного генератора – ag_name и ag_iframe. Все три объекта позволяют запускать "отсоединенный" фрейм по частям. При этом разделение сделано для разграничения сценариев использования:  генератор применяется в качестве итератора;  корутина используется вместе с await;  асинхронный генератор – с async for.
  40. Генерация байткода Написана на Си и расположена в Python/codegen.c. Посмотрим

    на сокращенный пример генерации кода для цикла for: static int codegen_for(compiler *c, stmt_ty s) { ... VISIT(c, expr, s->v.For.iter); loc = LOC(s->v.For.iter); ADDOP(c, loc, GET_ITER); USE_LABEL(c, start); ADDOP_JUMP(c, loc, FOR_ITER, cleanup); ... } Аналогичным образом описывается кодогенерация для всех структур языка.
  41. Адаптивный интерпретатор Появился в версии 3.11 (PEP 659 – «Specializing

    Adaptive Interpreter»). Пример: >>> def sum_range(): ... s = 0 ... for x in range(1000): ... s += x ... return s ... >>> sum_range() 499500 Важно, чтобы функция sum_range была вызвана. Адаптация не выполняется после объявления функции. Она происходит в результате исполнения кода достаточное количество раз.
  42. >>> dis.dis(sum_range) 1 RESUME 0 2 LOAD_SMALL_INT 0 STORE_FAST 0

    (s) 3 LOAD_GLOBAL 1 (range + NULL) LOAD_CONST 1 (1000) CALL 1 GET_ITER L1: FOR_ITER 11 (to L2) STORE_FAST 1 (x) 4 LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (s, x) BINARY_OP 13 (+=) STORE_FAST 0 (s) JUMP_BACKWARD 13 (to L1) 3 L2: END_FOR POP_ITER 5 LOAD_FAST_BORROW 0 (s) RETURN_VALUE
  43. >>> dis.dis(sum_range, adaptive=True) 1 RESUME_CHECK 0 2 LOAD_SMALL_INT 0 STORE_FAST

    0 (s) 3 LOAD_GLOBAL 1 (range + NULL) LOAD_CONST_MORTAL 1 (1000) CALL 1 GET_ITER L1: FOR_ITER_RANGE 11 (to L2) STORE_FAST 1 (x) 4 LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (s, x) BINARY_OP_ADD_INT 13 (+=) STORE_FAST 0 (s) JUMP_BACKWARD_NO_JIT 13 (to L1) 3 L2: END_FOR POP_ITER 5 LOAD_FAST_BORROW 0 (s) RETURN_VALUE
  44. Базовые инструкции FOR_ITER и BINARY_OP были заменены на их специализированные

    варианты FOR_ITER_RANGE и BINARY_OP_ADD_INT. Адаптированный байткод лежит внутри того же code object: >>> s_code = sum_range.__code__ >>> s_code.co_code == s_code._co_code_adaptive False При этом размер в байтах совпадает: >>> len(s_code.co_code) == len(s_code._co_code_adaptive) True
  45. Domain Specific Language (DSL) для байткода Мы обсудили байткод генераторов

    и генерацию байткода. Оказывается, Си-код обработчика байткода также создан при помощи кодогенерации. Для этого используется инструмент под названием сases generator, написанный на Python. В исходном коде присутствует файл с названием Python/bytecodes.c. Но это не обычный файл с кодом на Си. Он содержит описания базовых и адаптированных инструкций.
  46. Рассмотрим пару примеров: pure op(_BINARY_OP_ADD_INT, (left, right -- res)) {

    PyObject *left_o = PyStackRef_AsPyObjectBorrow(left); PyObject *right_o = PyStackRef_AsPyObjectBorrow(right); assert(PyLong_CheckExact(left_o)); assert(PyLong_CheckExact(right_o)); STAT_INC(BINARY_OP, hit); PyObject *res_o = _PyLong_Add((PyLongObject *)left_o, (PyLongObject *)right_o); PyStackRef_CLOSE_SPECIALIZED(right, _PyLong_ExactDealloc); PyStackRef_CLOSE_SPECIALIZED(left, _PyLong_ExactDealloc); INPUTS_DEAD(); ERROR_IF(res_o == NULL); res = PyStackRef_FromPyObjectSteal(res_o); }
  47. replicate(4) inst(LOAD_SMALL_INT, (-- value)) { assert(oparg < _PY_NSMALLPOSINTS); PyObject *obj

    = (PyObject *)&_PyLong_SMALL_INTS[ _PY_NSMALLNEGINTS + oparg]; value = PyStackRef_FromPyObjectImmortal(obj); } Код в фигурных скобках - это обычный код на Си, а выражение до фигурных скобок задает параметры генерации: имя инструкции, входные аргументы на стеке и различные настройки.