Как устроен CPython: байткод, интерпретатор и dis
Разбираем, во что компилятор CPython превращает исходник, как стековая виртуальная машина его исполняет и что показывает модуль dis на реальных примерах.

Возьмите строку a + b и спросите: сколько операций выполнит CPython, чтобы её посчитать? Ответ — четыре инструкции байткода, не одна. Между вашим исходником и работой процессора стоит промежуточный слой: компилятор превращает код в байткод, а стековая виртуальная машина его исполняет. Этот разбор — про то, что происходит внутри, и про модуль dis, который показывает эту кухню без единого запуска отладчика. Всё дальше — про CPython 3.12/3.13; байткод несовместим между минорными версиями, и это принципиально.
Что происходит между исходником и результатом
CPython — не чистый интерпретатор в том смысле, что он не читает и не исполняет текст построчно. Путь от файла до выполнения проходит несколько стадий:
- Токенизация: исходный текст разбивается на лексемы.
- Парсинг: из токенов строится AST — абстрактное синтаксическое дерево.
- Компиляция: AST превращается в объект кода (
code object), внутри которого лежит байткод. - Исполнение: виртуальная машина в цикле читает байткод и меняет состояние.
Результат компиляции кешируется в файлах .pyc внутри каталога __pycache__. Поэтому первый импорт модуля чуть медленнее второго: во второй раз готовый байткод берётся из кеша, если исходник не менялся.
Code object — неизменяемый объект, который держит байткод (co_code), константы (co_consts), имена переменных, номера строк и служебные поля. У любой функции он доступен через func.__code__.
Байткод — это инструкции для стековой машины
Виртуальная машина CPython — стековая. У неё нет регистров, как у процессора; вместо этого есть стек значений. Инструкции кладут значения на стек и снимают их. Посмотрим на простую функцию.
import dis
def add(a, b):
return a + b
dis.dis(add)
Вывод на CPython 3.12 выглядит так (числа слева — смещения байткода, справа — операнды):
1 0 RESUME 0
2 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+)
10 RETURN_VALUE
Читается это буквально по стеку:
RESUME— служебная инструкция, точка входа/возобновления кадра, появилась в 3.11;LOAD_FAST 0кладёт на стек значение локальной переменнойa;LOAD_FAST 1кладётb, теперь на стеке два значения;BINARY_OP 0снимает два верхних значения, применяет+, кладёт результат;RETURN_VALUEснимает результат со стека и возвращает его.
В версиях до 3.11 сложение было отдельной инструкцией BINARY_ADD. В 3.11 её заменили на обобщённую BINARY_OP с операндом-кодом операции. Это ровно тот случай, когда вывод dis зависит от версии — код, распечатанный из статьи трёхлетней давности, у вас не совпадёт.
LOAD_FAST против LOAD_GLOBAL
Локальные переменные функции лежат в массиве по индексу, поэтому доступ к ним — LOAD_FAST, обращение по номеру слота. Глобальные и встроенные имена ищутся по словарю: LOAD_GLOBAL. Разница видна в байткоде и объясняет старое правило «локальный доступ быстрее глобального» — это не суеверие, а разные инструкции с разной стоимостью.
import dis
GLOBAL_LIMIT = 100
def check(value):
return value < GLOBAL_LIMIT
dis.dis(check)
В выводе вы увидите LOAD_FAST для value и LOAD_GLOBAL для GLOBAL_LIMIT — две разные механики поиска в одной строке кода.
Цикл интерпретатора
Сердце CPython — функция вычислительного цикла в файле Python/ceval.c. Упрощённо это большой цикл, который читает очередной опкод, смотрит его аргумент и выполняет соответствующий обработчик. Каждый вызов функции создаёт кадр (frame) — структуру с локальными переменными, стеком значений и указателем на текущую инструкцию.
Начиная с 3.11 CPython применяет адаптивную специализацию (проект под названием Specializing Adaptive Interpreter). Инструкция вроде LOAD_ATTR, которая много раз обращается к атрибуту объектов одного типа, во время исполнения может замениться на специализированный, более быстрый вариант. Эти «горячие» формы опкодов обычно скрыты, но их можно увидеть.
Байткод, который вы видите вdis, и байткод, который реально крутится в цикле после прогрева, — не всегда одно и то же. Специализация происходит в рантайме, и обычный выводdisеё не показывает.
Как посмотреть специализированный байткод
Чтобы увидеть адаптивные инструкции, передайте adaptive=True. Специализация проявится только после того, как код прогрелся — то есть выполнился достаточное число раз.
import dis
def hot_loop(items):
total = 0
for item in items:
total += item
return total
# прогреваем функцию
for _ in range(1000):
hot_loop([1, 2, 3, 4, 5])
# показываем адаптивный байткод после прогрева
dis.dis(hot_loop, adaptive=True)
После прогрева часть инструкций может распечататься как специализированные варианты (например, суффиксы вроде _INT у арифметики). Точный набор форм зависит от версии — не заучивайте имена, смотрите на своём интерпретаторе.
dis не только про функции
Модуль умеет разбирать не только функции, но и строки исходника, скомпилированный code object и целые модули. Полезные точки входа:
| Вызов | Что делает |
|---|---|
dis.dis(obj) | Печатает байткод функции, метода, класса или строки кода |
dis.dis("a = b + 1") | Компилирует и дизассемблирует строку исходника напрямую |
dis.get_instructions(obj) | Отдаёт итератор объектов Instruction для программного анализа |
dis.code_info(obj) | Сводка по code object: имя, число аргументов, стек, флаги |
dis.show_code(obj) | То же, но сразу печатает в stdout |
Вариант get_instructions удобнее dis, когда вы хотите не читать вывод глазами, а посчитать что-то программно — например, сколько раз в функции встречается LOAD_GLOBAL.
import dis
from collections import Counter
def sample(x):
return len(str(x)) + abs(x)
opcodes = Counter(instr.opname for instr in dis.get_instructions(sample))
print(opcodes)
Зачем это на практике
Дизассемблер редко нужен в повседневном коде, но в трёх ситуациях он отвечает на вопросы, которые иначе решаются гаданием:
- Микрооптимизация горячего пути. Когда профилировщик указал на функцию,
disпоказывает, где рождаются лишниеLOAD_GLOBALили временные объекты. Но сначала профайлер, потом байткод — не наоборот. - Понимание неочевидного поведения. Почему f-строка быстрее конкатенации, во что разворачивается генератор списка, как компилятор сворачивает константы (
2 + 2превращается в4ещё на этапе компиляции) — всё это видно в байткоде. - Отладка расхождений между версиями. Если библиотека, зависящая от байткода, сломалась после апгрейда Python — сравнение вывода
disна двух версиях быстро покажет, какая инструкция изменилась.
Где это ломается
Несколько трезвых оговорок:
- Байткод — деталь реализации CPython, а не часть языка. В PyPy, GraalPy или Jython его нет или он совсем другой. Не стройте продовую логику на конкретных опкодах.
- Опкоды меняются каждый минорный релиз. Код, который парсит
co_codeвручную по числам, сломается на следующем Python. Используйтеdis.get_instructions, а не разбор сырых байтов. - Меньше инструкций не всегда значит быстрее: стоимость опкодов разная, а специализация в рантайме меняет реальную картину. Байткод показывает структуру, а не абсолютное время — время меряет профайлер и
timeit.
Что делать дальше
Откройте интерпретатор своей версии и прогоните через dis.dis три конструкции: генератор списка, конкатенацию строк в цикле и f-строку. Сравните число инструкций и типы опкодов — это лучший способ прочувствовать, что компилятор делает за вас. Дальше стоит посмотреть модуль dis в официальной документации (таблица всех опкодов с описанием стека) и, если нужна глубина, исходник Python/ceval.c в репозитории CPython. Для замеров реальной разницы берите timeit, а не счётчик инструкций.
Поиск источников: Реальные статьи и книги со ссылками, библиография по ГОСТ
Спросить за 15 ₽Источники: dis — Disassembler for Python bytecode (официальная документация), CPython — исходный код, репозиторий на GitHub
Частые вопросы
У меня Python 3.10, а в статье байткод из 3.12 — почему не совпадает?
Почему dis не показывает специализированные инструкции, о которых говорят?
Можно ли ускорить программу, переписав её под меньшее число опкодов?
Байткод одинаковый в CPython и PyPy?
Что делать, если мой код парсит co_code вручную и сломался после апгрейда?
Где физически хранится байткод и зачем нужен __pycache__?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.