Share
Embed
Transcript
52 slides
-
Обо мне Математик, к.ф.-м.н. В разработке с 2010 ML инженер
в X5 Tech CPython Contributor -
Все примеры кода были запущены на Python 3.14.0rc2, собранном из
исходников. Что такое Python-байткод? Это байтовое представление инструкций и их аргументов. И правила интерпретации этого байтового представления. -
Нам потребуется стандартный модуль dis (сокращение от disassembler). Будем использовать
функции dis.dis и dis.show_code из него. В качестве аргумента dis.dis может принимать: строки с исходных кодом; функции, методы, классы, генераторы и прочие «запускаемые» объекты; «сырой байткод». -
Вызовем 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 берет значение из головы стека данных и возвращает это значение из функции. Числа слева от инструкций обозначают номера строк. -
Таким образом, при запуске dis.dis от константного выражения на выходе
получается код функции, возвращающей это выражение. Действительно, аналогичный байткод можно получить при дизассемблировании функции без аргументов, возвращающей 12345: >>> dis.dis(lambda: 12345) 1 RESUME 0 LOAD_CONST 0 (12345) RETURN_VALUE Причина: dis.dis при запуске на строке исходного кода первым этапом компилирует код. -
Посмотрим на поведение compile: >>> code1 = compile('12345', filename='expr', mode='eval')
>>> code2 = (lambda: 12345).__code__ >>> code1.co_code == code2.co_code True Иными словами, объекты code1 и code2 имеют одинаковый байткод. Такие объекты называются code objects и являются основой всех «запускаемых» сущностей. -
С помощью функции 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 -
Все эти данные можно получить и непосредственно через 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 -
Посмотрим непосредственно на байткод этой функции: >>> 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'] -
В исходном коде 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 -
Рассмотрим другие простые примеры с использованием констант: >>> 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! Она используется с целью оптимизации для загрузки на стек маленьких чисел. -
>>> 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. Разумеется, это работает только с числами, которые помещаются в байт. -
Попробуем "заменить" это число напрямую в байткоде. Для этого создадим
функцию 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-коде! -
Байткод сложения >>> 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 взяла два значения с головы стека и положила обратно результат сложения. -
Сложение переменной и константы: >>> dis.dis('a+2') 0 RESUME 0 1
LOAD_NAME 0 (a) LOAD_SMALL_INT 2 BINARY_OP 0 (+) RETURN_VALUE -
Сложение двух констант: >>> dis.dis('40+2') 0 RESUME 0 1 LOAD_SMALL_INT
42 RETURN_VALUE В байткоде никакого сложения уже не осталось, оно было выполнено на этапе компиляции! -
Константные выражения >>> 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 -
Пример для кортежа из двух элементов: >>> dis.dis('((3+2j)**2, 12**2)') 0
RESUME 0 1 LOAD_CONST 1 (((5+12j), 144)) RETURN_VALUE -
Но если добавить вызов функции, то он останется в байткоде:
>>> dis.dis(lambda: len("123")) 1 RESUME 0 LOAD_GLOBAL 1 (len + NULL) LOAD_CONST 0 ('123') CALL 1 RETURN_VALUE -
Аналогично с методами: >>> 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 Причина: функции и методы, в том числе встроенные, могут меняться. -
Аргументы функций Есть специальные инструкции для загрузки аргументов и локальных
переменных: >>> 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 -
Байткод "обычных" функций >>> 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-нотация отличаются только синтаксически. -
Вложенные функции >>> def outer(): ... a = 0 ...
def inner(): ... nonlocal a ... a += 1 ... return a ... return inner ... >>> x = outer() >>> x() 1 >>> x() 2 >>> y = outer() >>> y() 1 -
Каждая из функций 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 -
Содержимое замыкания можно изменять непосредственно: >>> 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 -
В байткоде функции 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 -
Так выглядит функция 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 -
Байткод генератора Создадим генератор: >>> def gen(): yield 42 ...
>>> list(gen()) [42] -
Посмотрим на его поля: >>> 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 лежит связанный с генератором фрейм исполнения. -
>>> 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. -
Генераторные выражения Такие выражения – альтернативный способ объявить генератор. У
них есть своя генераторная функция, задаваемая неявно. >>> 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> -
>>> 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) в этом байткоде нет. -
Но есть обращение к переменной с необычным именем .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. -
Именно в переменной .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 использовался словарь с копиями значений локальных переменных. -
Списочные выражения В языке есть конструкции, близкие к генераторным выражениям:
list comprehensions; set comprehensions; dict comprehensions. Обрабатываются они сходным образом. Посмотрим на результат дизассемблирования списочного выражения. -
>>> 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) -
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] -
В отличие от генераторного выражения, code object не объявляется. Причина:
PEP 709 – «Inlined comprehensions», внедренный в Python 3.12. Создание отдельных code object убрали для list/set/dict comprehensions. Нет необходимости создавать промежуточный генератор. Можно обойтись итеративным наполнением списка/множества/словаря. С генераторными выражениями так не получится в силу их специфики. До полного завершения итерации генератора необходимо хранить фрейм исполнения. -
Фреймы исполнения При обычном вызове кода фреймы "вкладываются" друг в
друга. Посмотрим на примере двух функций: >>> 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 фрейма лежит ссылка на его "родителя". -
С другой стороны, у генераторов фреймы специальные, «отцепленные» от обычных:
>>> 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. Причина: генератор, корутина и асинхронный генератор - по сути один и тот же объект. -
Посмотрим на код (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) }; -
С помощью одного макроса объявлены генератор _PyGenObject, корутина _PyCoroObject и
асинхронный генератор _PyAsyncGenObject. Отличаются они только тем, какой префикс будут иметь поля: у генератора – gi_name и gi_iframe; у корутины – cr_name и cr_iframe; у асинхронного генератора – ag_name и ag_iframe. Все три объекта позволяют запускать "отсоединенный" фрейм по частям. При этом разделение сделано для разграничения сценариев использования: генератор применяется в качестве итератора; корутина используется вместе с await; асинхронный генератор – с async for. -
Генерация байткода Написана на Си и расположена в 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); ... } Аналогичным образом описывается кодогенерация для всех структур языка. -
Адаптивный интерпретатор Появился в версии 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 была вызвана. Адаптация не выполняется после объявления функции. Она происходит в результате исполнения кода достаточное количество раз. -
>>> 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 -
>>> 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 -
Базовые инструкции 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 -
Domain Specific Language (DSL) для байткода Мы обсудили байткод генераторов
и генерацию байткода. Оказывается, Си-код обработчика байткода также создан при помощи кодогенерации. Для этого используется инструмент под названием сases generator, написанный на Python. В исходном коде присутствует файл с названием Python/bytecodes.c. Но это не обычный файл с кодом на Си. Он содержит описания базовых и адаптированных инструкций. -
Рассмотрим пару примеров: 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); } -
replicate(4) inst(LOAD_SMALL_INT, (-- value)) { assert(oparg < _PY_NSMALLPOSINTS); PyObject *obj
= (PyObject *)&_PyLong_SMALL_INTS[ _PY_NSMALLNEGINTS + oparg]; value = PyStackRef_FromPyObjectImmortal(obj); } Код в фигурных скобках - это обычный код на Си, а выражение до фигурных скобок задает параметры генерации: имя инструкции, входные аргументы на стеке и различные настройки.