Наиболее интересной частью каждого языка, компилируемого в байт-код является виртуальная машина (также известная как интерпретатор байт-кода), в которой происходит исполнение байт-кода. Поскольку это наиболее важная часть реализации языка, она должна быть крайне производительной. Даже если вы не занимаетесь разработкой компиляторов, знание внутреннего устройства ее реализации может дать вам возможность найти новые способы оптимизации вашего кода, что может быть полезно в других областях вашей работы. А если вы занимаетесь разработкой компиляторов, вам всегда стоит исследовать реализации в других языках, чтобы найти для себя новые трюки и потенциально упущенные детали.

В этой статье мы обсудим формат инструкций байт-кода CPython, а также реализацию цикла обработки байт-кода в интерпретаторе, где он и исполняется.

Устройство виртуальных машин

Когда дело касается компилируемых языков программирования, работа компилятора сводится к переводу написанного разработчиком кода в инструкции машины, на которой она будет запускаться. Эта машина может реальным железом — например, X86, Arm или Risc‑V, у которых есть предопределенный набор инструкций, которые они могут выполнять.

Генерация кода для разных архитектур железа — сложный процесс, но что более важно, создает проблемы с переносимостью программы — код, скомпилированный под определенную архитектуру будет исполняться только на машинах этой архитектуры и для выполнения на другой архитектуре код потребуется перекомпилировать.

Чтобы избежать данных трудностей многие языки программирования начали использовать виртуальные машины (VM). Виртуальная машина — эмулятор машины реализованный программно и поддерживает инструкции, схожие с настоящим железом. Работа компилятора сводится к превращению исходного кода в эти инструкции. Компиляция под подобные виртуальные машины значительно проще и решает проблему переносимости кода (после компиляции код может исполняться на любом железе, для которого реализована виртуальная машина).

Python также поставляется с подобной виртуальной машиной и компилятор Python генерирует инструкции (также известные как байт-код) для нее.

Типы виртуальных машин

Подобно реальному железу, есть два вида реализации виртуальной машины. Могут быть виртуальные машины, основанные на регистрах, где инструкции используют регистры для хранения операндов инструкций. Большая часть современных «железных» машин также основаны на использовании регистров. Такие машины обычно дают лучшую производительность, но сложны в реализации с точки зрения компиляторов, из‑за таких проблем, как выделение регистров.

Альтернативным подходом являются виртуальные машины на основе стека. Инструкции добавляют и убирают операнды на стеке для выполнения операций. Это гораздо более простой подход, а также такие инструкции гораздо проще генерировать компилятору.

В случае Python реализована виртуальная машина на основе стека. Другие примеры подобных машин включают в себя Ruby, JavaScript и Java.

Инструкции байт-кода

Давайте посмотрим, как выглядят инструкции для виртуальных машин и как они упаковываются в байт-код. Мы рассмотрим гипотетическую VM на основе стека, поддерживающую следующие 6 инструкций:

PUSH <value> # pushes the given value onto the stack
ADD # pops top two values from the stack, adds them and pushes result back onto the stack
SUB
MUL
DIV
HALT # Marks the end of the program and current stack top becomes return value

Для каждой из инструкций может быть назначен опкод, например:

PUSH: 0
ADD: 1
SUB: 2
MUL: 3
DIV: 4
HALT: 5

В процессе генерации кода для данной виртуальной машины, компилятор будет отправлять один из этих опкодов в зависимости от типа выполняемой операции. К примеру, если он компилирует такое выражение как «1 + 2», будет сгенерирована следующая последовательность опкодов:

PUSH 1
PUSH 2
ADD
HALT

2 инструкции PUSH переместят операнды на стек, после чего ADD заберет их со стека, сложит вместе и поместит результат на стек.

Но вместо генерации инструкций в текстовом формате, как показано выше, компилятор генерировал бы сами опкоды для каждой инструкции. Так что на самом деле это выглядело бы так:

0|0|1|6

0 это опкод для PUSH, 1 — для ADD, а 6 — для HALT.

Так как эта виртуальная машина поддерживает всего 6 инструкций, нам достаточно одного байта, чтобы закодировать каждую из них. Но инструкции PUSH необходим аргумент, который также необходимо учесть.

Мы можем добавить поддержку передачи аргумента для PUSH, увеличив его ширину до 2 байт — первый байт будет обозначать сам опкод, а второй — значение аргумента (использование одного байта означает, что мы можем передавать на стек только значения до 255, но для гипотетической игрушечной виртуальной машины это подойдет).

Подобная переменная ширина схемы инструкций сработала бы, но сделала бы реализацию VM более сложной. Мы можем упростить это еще сильнее, задав для всех инструкций ширину в 2 байта, где первый байт — сам опкод, а второй — опциональный аргумент, который может быть просто равен 0, если значения для него нет. Используя такую спецификацию байт-код программы выше будет выглядеть так:

0 1|0 2|1 0|6 0

(символ | используется для разделения инструкций)

Показанная выше последовательность байт называется байт-кодом. Задача виртуальной машины — извлечь инструкции из этой последовательности и исполнить их.

Исполнение байт-кода в виртуальной машине

Теперь давайте посмотрим, как виртуальная машина распаковывает и выполняет этот байт-код. Мы продолжим использовать в качестве примера нашу простую VM на основе стека из прошлого раздела, поддерживающую лишь 6 инструкций.

Приведенный ниже код показывают возможную реализацию обработки байт-кода:

class VirtualMachine():
    def __init__(self, bytecode):
        self.stack = []
        self.bytecode = bytecode

    def execute_bytecode(self):
        ip = 0
        while ip < len(self.bytecode) - 1:
            opcode = self.bytecode[ip]
            oparg = self.bytecode[ip + 1]
            ip += 2
            match opcode:
                case PUSH:
                    self.stack.append(oparg)
                case ADD:
                    lhs = self.stack.pop()
                    rhs = self.stack.pop()
                    result = lhs + rhs
                    self.stack.append(result)
                case SUB:
                    lhs = self.stack.pop()
                    rhs = self.stack.pop()
                    result = lhs - rhs
                    self.stack.append(result)
                case MUL:
                    lhs = self.stack.pop()
                    rhs = self.stack.pop()
                    result = lhs * rhs
                    self.stack.append(result)
                case DIV:
                    lhs = self.stack.pop()
                    rhs = self.stack.pop()
                    result = lhs / rhs
                    self.stack.append(result)
                case HALT:
                    return stack.pop()     

VM проходится по инструкциям байт-кода в цикле и выполняет нужную операцию в зависимости от значения.

Данный пример намеренно простой, чтобы показать основные принципы получения, декодирования и исполнения инструкций. К примеру, данная виртуальная машина может работать только с целочисленными значениями, полученными в качестве аргументов к инструкциям и добавленными на стек. Реальная виртуальная машина значительно сложнее и имеет гораздо больше таких деталей, как фреймы стека, указатели стека, указатели инструкций, контекст исполнения, которые были опущены тут. Мы обсудим их по мере погружения в детали реализации CPython VM.

Реализация виртуальной машины CPython

На данном этапе мы в целом имеем общее представление, как работает виртуальная машина байт-кода. Теперь мы готовы к обсуждению специфики устройства виртуальной машины CPython.

Инструкции байт-кода CPython

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

В CPython опкоды инструкций заданы в файле Include/opcode_ids.h. На изображении ниже показаны некоторые из них и соответствующие им id:

Частичный список id опкодов, поддерживаемый виртуальной машиной CPython
Частичный список id опкодов, поддерживаемый виртуальной машиной CPython

На момент написания статьи (Python 3.14 в разработке), в CPython есть 223 инструкции.

Примечание: Сам файл генерируется скриптом, сами инструкции определены в файле Python/bytecodes.c. На деле, большая часть реализации VM генерируется различными скриптами после парсинга заданных в этом файле инструкций.

Формат упаковки байт-кода CPython

Как и в примере игрушечной VM, обсуждаемой нами ранее, инструкции байт-кода CPython также имеют ширину в 2 байта. Первый байт используется для опкода, второй — значение аргумента для опкода. Если инструкция не ожидает какого‑либо аргумента, значение второго байта равно 0.

Однако, имея лишь 1 байт для аргумента, его значение не может быть больше 255. Для поддержки больших значений есть специальная инструкция EXTENDED_ARG. Если компилятор замечает, что инструкция не может поместиться в один байт, он отправляет инструкцию EXTENDED_ARG, используемой для предоставления дополнительного набора байт, требуемых для значения аргумента.

У инструкции может быть префикс, состоящий их максимум трех последовательных инструкций EXTENDED_ARG. Это значит, что максимальное значение аргумента ограничено 4мя байтами (1 байт в самой инструкции + 3 байта за счет инструкций EXTENDED_ARG).

Разбираемся с байт-кодом CPython на примере

Давайте рассмотрим следующий пример и проверим генерируемый байт-код для лучшего понимания происходящего.

>>> def add(a, b):
...     return a + b
...
>>> dis.dis(add)
  1           RESUME                   0

  2           LOAD_FAST_LOAD_FAST      1 (a, b)
              BINARY_OP                0 (+)
              RETURN_VALUE

У нас есть простая функция для сложения двух значений и мы можем увидеть генерируемые инструкции байт-кода используя модуль dis.

Мы также можем просмотреть сам байт-код функции. Каждый вызываемый объект в Python имеет поле __code__, содержащее поле _co_code_adaptive, в котором в свою очередь содержится скомпилированный байт-код объекта. Давайте взглянем на него.

>>> add_bytecode = add.__code__._co_code_adaptive
>>> len(add_bytecode)
10
>>> for i in range(0, len(add_bytecode) - 1, 2):
        print(f"opcode_value: {add_bytecode[i]}, oparg: add_bytecode[i+1]}")

opcode_value: 149, oparg: 0
opcode_value: 88, oparg: 1
opcode_value: 45, oparg: 0
opcode_value: 0, oparg: 0
opcode_value: 36, oparg: 0
>>>

Мы видим, что байт-код и правда является последовательностью байт. И мы вывели каждую из инструкций их аргументы на экран, перебирая пары байт в последовательности.

Обратите внимание, что вывод dis.dis() показывал 4 инструкции, что значит, что байт-код должен быть длиной в 8 байт. Несмотря на это, генерируемый байт-код имеет длину в 10 байт. Это происходит из‑за того, что есть дополнительная инструкция CACHE (опкод 0), генерируемая компилятором и не показываемая в стандартном выводе dis.dis(). Эта инструкция используется в качестве инлайн кэша данных для оптимизации некоторых инструкций виртуальной машиной. CPython профилирует и оптимизирует определенные инструкции до более специализированных для улучшения производительности. Мы погрузимся в то, как работает данный процесс в одной из будущих статей.

Мы также можем пойти дальше и декодировать значения опкодов, чтобы увидеть, что они совпадают с выводимыми модулем dis.

>>> import opcode
>>> for i in range(0, len(add_bytecode) - 1, 2):
...     opcode_value, oparg = add_bytecode[i], add_bytecode[i + 1]
...     print(f"opcode_value: {opcode_value}, opcode_name: {opcode.opname[opcode_value]}, oparg: {oparg}")
...
opcode_value: 149, opcode_name: RESUME, oparg: 0
opcode_value: 88, opcode_name: LOAD_FAST_LOAD_FAST, oparg: 1
opcode_value: 45, opcode_name: BINARY_OP, oparg: 0
opcode_value: 0, opcode_name: CACHE, oparg: 0
opcode_value: 36, opcode_name: RETURN_VALUE, oparg: 0
>>>
Названия байт-кодов для каждого байта опкода в байт-коде
Названия байт-кодов для каждого байта опкода в байт-коде

Для декодирования инструкций мы использовали модуль opcode, в котором содержатся соответствия id опкодов и их названий. Мы видим, что значения опкодов соотносятся с названиями инструкций. Также вы можете открыть файл opcode_ids.h и сопоставить названия опкодов с id в примере выше.

Внутренности виртуальной машины CPython

Теперь погрузимся в реализацию виртуальной машины CPython. Когда мы запускаем новую программу Python, много чего происходит до того, как виртуальная машина начинает выполнять байт-код, в том числе инициализация рантайма, парсинг и компиляция кода, подготовка фреймов стека и так далее.

Цикл обработки байт-кода сам по себе достаточно сложный, чтобы уместить его в эту статью, так что данная часть была вынесена в отдельную статью. Если вам интересны детали, вы можете прочесть их в статье CPython Internals: What Happens Before Bytecode Execution Starts.

В этой статье мы перейдем сразу к циклу обработки байт-кода, реализованного в функции _PyEval_EvalFrameDefault в файле ceval.c. Сигнатура функции показана на картинке ниже.

Сигнатура функции _PyEval_EvalFrameDefault в ceval.c. Она реализует цикл обработки байт-кода
Сигнатура функции _PyEval_EvalFrameDefault в ceval.c. Она реализует цикл обработки байт-кода

Эта функция получает фрейм стека блока кода Python, который будет выполнен виртуальной машиной. В фрейме содержится скомпилированный байт-код и необходимый для его исполнения контекст.

Функция извлекает байт-код из фрейма стека и начинает обрабатывать с использованием цикла обработки байт-кода. Но перед тем, как мы перейдем к телу функции, нужно разобраться, как работают фреймы стека и концепцию вычисленных goto, используемых CPython для реализации цикла обработки байт-кода.

Немного о фреймах стека

CPython VM исполняет байт-код по одному блоку кода за раз, где блоком кода может быть модуль, класс, функция, скрипт и т. д. Каждый блок имеет тонну ассоциируемого с ним контекста, необходимого интерпретатору для исполнения кода.

К примеру, интерпретатору нужен доступ к локальным, глобальным и встроенным объектам в области видимости блока, а также другие необходимые данные, такие как стек, адрес возврата, указатель стека и указатель инструкции. Также сам байт-код должен быть готов к исполнению.

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

На схеме ниже показана структура стека фрейма в CPython:

Определение структуры _PyInterpreterFrame  в CPython, которая описывает объект фрейма стека
Определение структуры _PyInterpreterFrame в CPython, которая описывает объект фрейма стека

Давайте обсудим некоторые ключевые поля:

  • f_executable содержит скомпилированный байт-код блока

  • previous — указатель на фрейм, из которого был вызван этот блок. Это реализует стек вызовов. Фреймы стека вызываемых в VM на данный момент функций соединены между собой с помощью указателя previous. Когда одна функция вызывает другую функцию, они соединяется вместе с помощью указателя на вызывающий фрейм. Это помогает VM возвращать результат выполнения вызванной функции.

  • globals, locals, и builtins — словари, содержащие глобальные, локальные и встроенные объекты в области видимости блока.

  • instr_ptr — указатель инструкции, используемый VM чтобы следить, какую инструкцию выполнять следующей. Каждый фрейм имеет свой указатель, чтобы VM могла запоминать, где остановилось выполнение фрейма во время вызова функции или в случае смены контекста на другой поток.

  • stacktop — отступ верха стека от localsplus

  • localsplus — интересное поле. Оно используется в качестве хранилища для локальных объектов, а также в качестве стека блока. Первые x (x — количество известных локальных объектов) элементов массива зарезервированы для использования под список локальных объектов, а место после них используется как стек.

В качестве примера, схема ниже отображает небольшую цепочку вызовов функций. foo() вызывает bar(), а bar() в свою очередь вызывает baz(). В правой части схемы мы можем видеть цепочку фреймов стека, созданных для выполнения функций. В первую очередь создается фрейм стека foo() и передается интерпретатору для обработки. Это ведет к созданию фрейма стека для bar(). И наконец, создается и передается на обработку фрейм стека для baz().

Мы также можем видеть, как фреймы стека соединяются между собой с помощью указателя previous. По завершении выполнения текущей функции, VM может убрать ее фрейм стека и вернуться к фрейму вызывающей функции и продолжить ее выполнение.

Пример вызовов функций в коде Python и того, как это приводит к созданию цепочки фреймов стека связанных друг с другом полем previous.
Пример вызовов функций в коде Python и того, как это приводит к созданию цепочки фреймов стека связанных друг с другом полем previous.

Computed Goto vs Switch Case

Еще одна вещь, с которой нам нужно познакомиться перед обсуждением кода VM CPython это концепция computed goto (вычисляемые goto).

Как мы уже видели в реализации игрушечной VM, цикл обработки байт-кода обычно реализуется с помощью switch case. В случае CPython это выглядит примерно так:

Реализация цикла обработки байт-кода с помощью switch case. Реальный код CPython использует макросы, это лишь упрощенная иллюстрация, чтобы показать как это потом превращается в код C.
Реализация цикла обработки байт-кода с помощью switch case. Реальный код CPython использует макросы, это лишь упрощенная иллюстрация, чтобы показать как это потом превращается в код C.

Заметьте, что настоящий код реализации цикла обработки байт-кода CPython использует макросы. Изображение выше просто показывает, как реализация на основе switch case выглядит после раскрытия макросов.

Реализация достаточно проста для понимания. Здесь нет явного цикла while или for. Вместо этого задается лейбл dispatch_opcode в начале блока switch. Каждый опкод переходит к этому лейблу после завершения его выполнения для обработки следующего, что образует собой цикл.

На схеме выше я показал код для инструкции LOAD_FAST чтобы показать цикл в действии. Эта инструкция используется для загрузки списка локальных объектов на стек. По ее завершении указатель инструкции увеличивается на 1 для чтения следующего значения опкода и переходит к началу switch case для его обработки.

И хотя такая реализация проста для понимания, она проблемна с точки зрения производительности. Большая часть CPU использует параллелизм на уровне инструкций (ILP), чтобы максимально быстро выполнять программы. Для этого они выполняют инструкции не по порядку для нахождения большего количества независимых инструкций. Когда они сталкиваются с ответвлением в коде, например, switch case, они обычно не знают его значение, поскольку оно еще обрабатывается в другой инструкции. Вместо ожидания результата обработки, процессор использует предсказатель ветвления, чтобы угадать цель ответвления и исполняет инструкции по угаданному адресу. В случае успешного предсказания это приводит к более быстрому исполнению кода, но если была угадана неправильная ветвь, это приводит к удару по производительности.

Предсказатели ветвления хорошо работают, когда ветвление следует паттерну, который может быть запомнен предсказателем. В нашем случае есть более 230 вариантов ветвлений и на каждой итерации переход может происходить к любому из вариантов, что сильно усложняет работу предсказателя. Чем больше возможных вариантов в switch case, тем сложнее предсказателю корректно угадать поведение программы. И чем больше ошибок совершает предсказатель, там хуже будет производительность интерпретатора Python.

Более эффективная реализация подобного цикла возможна с использованием вычисляемого конструкта goto. Вычисляемый goto это расширение компилятора C, поддерживаемое несколькими компиляторами, такими как clang и GCC. Оно позволяет использовать специальный оператор && для получения адреса лейбла в коде C.

Код реализации CPython VM задает уникальный лейбл для реализации каждого из опкодов и затем генерирует статическую таблицу переходов, используя адреса этих лейблов. На этапе обработки байт-кода VM просматривает эту таблицу используя id опкода и переходит к нужному адресу.

На схеме ниже показано, как CPython VM использует вычисленные goto для реализации цикла обработки байт-кода:

Реализация цикла выполнения байт-кода использующая вычисляемые goto. Таблица переходов генерируется статически используя адреса лейблов для реализации каждого опкода.
Реализация цикла выполнения байт-кода использующая вычисляемые goto. Таблица переходов генерируется статически используя адреса лейблов для реализации каждого опкода.

Такая реализация дает значительно лучшую производительность по сравнению с реализацией на switch case, поскольку предсказатель ветвления может работать гораздо точнее. Чтобы понять, почему так происходит, требуется понимание того, как работает предсказатель ветвления, мы рассмотрим это в одной из будущих статей.

Цикл выполнения байт-кода CPython

Из‑за лучшей производительности вычисленные goto являются более предпочтительной реализацией цикла выполнения байт-кода, но не все компиляторы поддерживают их. Как итог, реализация цикла в CPython сделана с помощью макросов препроцессора, которые могут генерировать код switch case или вычисленных goto в зависимости от того, поддерживает ли компилятор вычисленные goto.

Схема ниже показывает код из функции _PyEval_EvalFrameDefault и по сути это превращается в цикл обработки байт-кода. Он не выглядит как цикл из‑за магии макросов, но мы сейчас разберем это.

Реализация цикла обработки байт-кода CPython
Реализация цикла обработки байт-кода CPython

Чтобы лучше понимать этот код, мы разобьем его на две части. Первая часть объяснит код до цикла, а вторая разберет макросы, которые генерируют цикл.

Прелюдия к циклу

На схеме выше весь код до вызова DISPATCH() образует вступление к циклу обработки байт-кода, где происходит инициализация некоторых ключевых переменных и объектов для отслеживания состояния VM по мере выполнения байт-кода.

Разберем его:

  • Переменные opcode и oparg содержат следующую инструкцию байт-кода и её аргументы. Они будут обновляться при каждой итерации.

  • Далее инициализируется объект entry_frame и задается в качестве предыдущего фрейма к фрейму, который будет обрабатываться. Это делает entry_frame нижним фреймом интерпретатора. Он выполняется в самом конце, когда выполнение всего кода Python завершено. Мы посмотрим, как это работает после обсуждение цикла обработки байт-кода.

  • next_instr это указатель инструкции, который всегда указывает на следующую инструкцию, которая будет обработана. Он инициализируется, указывая на значение указателя текущего фрейма. Когда VM изменяет текущий активный фрейм (например, при вызове функции), эта переменная также обновляется для использования значения указателя нового фрейма.

После этого начинается тело цикла обработки байт-кода, которое сильно полагается на использование макросов. Давайте детально разберем его.

Макросы цикла

Цикл обработки байт-кода реализован абсолютно также, как мы видели в разделе computed goto vs switch case, но написан с помощью макросов, чтобы в случае, если компилятор не поддерживает вычисляемые goto, использовать switch case. В противном случае, макросы раскрываются в код на основе вычисляемых goto. Чтобы понимать реализацию цикла, нам нужно поговорить об используемых макросах.

Схема ниже показывает, как определены макросы. Эти макросы вместе раскрываются в код одной итерации цикла. Для каждой итерации цикла должно произойти следующее:

  • Нам необходимо получить опкод следующей инструкции, что достигается макросом NEXTOPARG().

  • После этого нам необходимо перейти к реализации опкода чтобы выполнить его. Это достигается макросом DISPATCH_GOTO(). Если вычисленные goto не используются, он раскрывается в прыжок к началу блока switch. В противном случае он раскрывается в поиск по таблице переходов opcode_targets и переход к нужной реализации опкода.

Оба шага объединяются в один макрос DISPATCH(). Так что когда вы видите вызов DISPATCH() в коде, это значит, что идет получение и исполнение следующего опкода.

Определение DISPATCH и связанных макросов, которые вместе реализуют цикл выполнения байткода
Определение DISPATCH и связанных макросов, которые вместе реализуют цикл выполнения байткода

Также обратите внимание на макрос TARGET(op). Он принимает в качестве аргумента опкод и раскрывается либо в TARGET_op:, либо в case op: TARGET_op в зависимости от того, используются вычисляемые goto или нет. Он также используется для начала тела реализации каждого из опкодов.

Тело цикла

Теперь мы готовы обсудить тело цикла. Код показан на следующей схеме.

Тело цикла выполнения байт-кода. В левой части показан код CPython, в правой части показан сгенерированный код, после раскрытия макросов с использованием computed goto и без.
Тело цикла выполнения байт-кода. В левой части показан код CPython, в правой части показан сгенерированный код, после раскрытия макросов с использованием computed goto и без.

Давайте разберемся, что тут происходит. Я буду объяснять каждый пронумерованный этап на схеме по очереди.

  • Вызов DISPATCH() получает первый опкод и переходит к его реализации. Если используются вычисляемые goto, идет переход к таблице переходов, в противном случае используется switch case.

  • Если вычисляемые goto не используются, генерируется начало блока switch и задает ему лейбл dispatch_opcode.

  • Затем включается файл generated_cases.c.h, где реализованы все опкоды. Схема показывает реализацию инструкции LOAD_FAST из этого файла в качестве примера. Как видите, она начинается с макроса TARGET, обсуждаемого ранее.

  • Если вычисляемые goto не используются, тогда вызов макроса раскрывается в TARGET(LOAD_FAST). Это образует начало блока case. Я показал результат генерации справа. Заметьте, что в конце блока case есть переход к началу блока switch с использованием goto. Это образует цикла, который продолжается, пока инструкции не закончатся.

  • Если используются вычисляемые goto, TARGET(LOAD_FAST) раскрываются в лейбл TARGET_LOAD_FAST и реализация опкода будет под этим лейблом. Генерируемый код находится справа. Опять же, заметьте, что в конце кода он получает опкод следующей инструкции и затем переходит к реализации с помощью таблицы переходов. Так и создается цикл в случае вычисляемых goto.

На схеме ниже показано сравнение генерируемого кода для обоих случаев.

Side-by-side взгляд на реализацию цикла выполнения байткода с использованием switch case и вычисляемых goto.
Side‑by‑side взгляд на реализацию цикла выполнения байткода с использованием switch case и вычисляемых goto.

Выполнение Python программы в CPython VM

Пока что мы видели реализацию цикла обработки байт-кода в VM CPython. Давайте завершим это, пройдясь по примеру программы на Python и разобрав, как она будет выполняться в виртуальной машине. Это должно помочь закрепить все, что обсуждалось в этой статье.

Мы будем использовать следующий код, чтобы пошагово пройтись по виртуальной машине.

>>> def add(a, b):
    return a + b

>>> add(2, 3)
>>> dis.dis("add(2,3)")
  0           RESUME                   0

  1           LOAD_NAME                0 (add)
              PUSH_NULL
              LOAD_CONST               0 (2)
              LOAD_CONST               1 (3)
              CALL                     2
              RETURN_VALUE

Программа состоит из одной строки кода Python, вызывающей функцию add с аргументами 2 и 3. Мы также можем видеть скомпилированные инструкции байт-кода для этого однострочника, которые будут выполнены в VM.

Для выполнения скомпилированного кода интерпретатором он будет обернут в фрейм стека и передан функции _PyEval_EvalFrameDefault для обработки.

Стоит вспомнить, что в _PyEval_EvalFrameDefault перед началом цикла обработки байт-кода создается объект entry_frame, который становится нижним фреймом стека интерпретатора. Это визуально показано на схеме ниже:

 Визуальное изображение активных фреймов стека в VM в начале выполнения данной программы на Python.
Визуальное изображение активных фреймов стека в VM в начале выполнения данной программы на Python.

Изначально указатель инструкции VM будет указывать на первую инструкцию LOAD_NAME. Затем интерпретатор войдет в цикл обработки байт-кода и последовательно обработает каждую из инструкций. Дело принимает интересный оборот на инструкции CALL.

Инструкция CALL выполняет вызов функции. Для этого он создает новый фрейм стека для вызванной функции и делает его активным фреймом. Также обновляется указатель инструкции, принимая значение первой инструкции в вызванной функции и переходит к началу цикла обработки байт-кода для начала выполнения кода вызванной функции. На схеме ниже показано, как это будет выглядеть после завершения инструкции CALL.

 Фреймы стека в VM после вызова функции add. Фрейм стека функции add становится верхним фреймом, а фрейм вызывающей функции связывается с ним через указатель на предыдущий фрейм.
Фреймы стека в VM после вызова функции add. Фрейм стека функции add становится верхним фреймом, а фрейм вызывающей функции связывается с ним через указатель на предыдущий фрейм.

Заметьте, что фрейм стека каждой функции имеет собственные значения приватного указателей инструкции и стека. На данном этапе указатель инструкции вызывавшей функции указывает на инструкцию RETURN_VALUE, куда VM вернется после завершения выполнения функции add.

Опять же, инструкции функции add будут выполняться последовательно. Когда выполнение достигнет инструкции RETURN_VALUE, её фрейм стека будет убран и управление вернется к вызывающей функции. На схеме ниже показан код инструкции RETURN_VALUE:

 Реализация инструкции RETURN_VALUE, которая удаляет фрейм стека вызываемой функции и возвращает управление обратно вызывающей функции.
Реализация инструкции RETURN_VALUE, которая удаляет фрейм стека вызываемой функции и возвращает управление обратно вызывающей функции.

Как видите, она принимает результат выполнения вызываемой функции, затем убирает ее фрейм со стека для активации фрейма вызывающей функции и, наконец, обновляет указатели стека и инструкции виртуальной машины на соответствующие значения фрейма вызывающей функции.

На схеме ниже показан статус фреймов стека в интерпретаторе после завершения обработки инструкции RETURN_VALUE.

 После возврата из функции add, фрейм вызывающей функции снова становится активным, и виртуальная машина готова выполнить следующую инструкцию
После возврата из функции add, фрейм вызывающей функции снова становится активным, и виртуальная машина готова выполнить следующую инструкцию

Управление вернется к предыдущему фрейму для обработки следующей инструкции, в данном случае это инструкция RETURN_VALUE. Произойдет схожий порядок действий. Этот фрейм будет убран со стека, а активным фреймом станет entry_frame.

 После инструкции RETURN_VALUE единственным оставшимся фреймом в VM для выполнения является entry_frame. Это знаменует конец выполнения программы на Python, и VM готова завершить работу.
После инструкции RETURN_VALUE единственным оставшимся фреймом в VM для выполнения является entry_frame. Это знаменует конец выполнения программы на Python, и VM готова завершить работу.

У entry_frame есть всего 2 инструкции. Инструкция NOP ничего не делает, так что указатель просто перейдет к инструкции INTERPRETER_EXIT, которая завершит цикл обработки байт-кода и завершит интерпретатор. Реализация показана на изображении ниже.

 Реализация инструкции INTERPRETER_EXIT, которая используется для завершения выполнения байт-кода на виртуальной машине.
Реализация инструкции INTERPRETER_EXIT, которая используется для завершения выполнения байт-кода на виртуальной машине.

После возврата из функции add, фрейм вызывающей функции снова становится активным, и виртуальная машина готова выполнить следующую инструкцию Заметьте, что в отличие от других инструкций, которые завершаются вызовом goto для продолжения цикла, INTERPRETER_EXIT завершается return. Это указывает VM выйти из цикла обработки байт-кода и вернуть результат _PyEval_EvalFrameDefault. Она также завершает выполнение программы Python, возвращаемое значение выполненного блока кода возвращается туда, откуда происходит вызов VM.

Итоги

Это приводит нас к логическому завершению статьи. Мы начали с общей дискуссии на тему виртуальных машин байт-кода, посмотрели на внутренности деталей реализации исполнения байт-кода в CPython и закончили разбором миниатюрной программы на Python, чтобы посмотреть, как это работает на практике. Ниже краткая сводка всего, чего мы касались в статье.

  • Байт-код и виртуальные машины: Мы начали с обсуждения того, как компилируемые языки используют виртуальные машины для выполнения байт-кода, делая код переносимым между различным железом.

  • Типы виртуальных машин: Мы посмотрели на два типа виртуальных машин — на основе регистров и на основе стека. Python использует VM на основе стека, также как Java и JavaScript

  • Формат байт-кода: Мы исследовали, как инструкции упаковываются на примере гипотетической VM на основе стека с несколькими простыми инструкциями.

  • Цикл обработки: Мы исследовали, как виртуальная машина (на примере нашей гипотетической VM) обрабатывает инструкции байт-кода.

  • Специфика CPython: Мы перешли к настоящей VM CPython, обсуждая ее инструкции байт-кода и как они организованы.

  • Фреймы стека: Мы разобрали, что такое фреймы стека и какую роль они играют в виртуальной машине, показывая как каждый вызов получает собственный фрейм, позволяя VM отслеживать состояние.

  • Вычисляемые goto: Мы объяснили, почему CPython использует их для улучшения производительности по сравнению с традиционными switch case в цикле обработки байт-кода.

  • Объединяя все вместе: Мы взяли пример реальной программы на Python и пошагово разобрали, как CPython VM выполняет ее, детально разбирая происходящее на каждом шаге.

Комментарии (1)


  1. unreal_undead2
    15.10.2024 07:23

    Такая реализация дает значительно лучшую производительность по сравнению с реализацией на switch case, поскольку предсказатель ветвления может работать гораздо точнее. Чтобы понять, почему так происходит, требуется понимание того, как работает предсказатель ветвления, мы рассмотрим это в одной из будущих статей.

    Вот это действительно интересно (учитывая, что switch/case при плотном наборе вариантов обычно компилируется в косвенный переход по табличке).