ЗАМЕР
bench/interpreter-loop/frames.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/python/runtime/interpreter-loop
- Как запустить
for v in 3.12 3.13 3.14; do python$v bench/interpreter-loop/frames.py; done
Запись прогона
Наблюдения для статьи «Цикл интерпретатора: фреймы и диспетчеризация»
| Скрипт | Что показывает |
|---|---|
frames.py |
HAVE_ARGUMENT и число именованных опкодов по версиям; форму загрузки локальной (LOAD_FAST → LOAD_FAST_BORROW в 3.14); что f_locals у функции с 3.13 — write-through proxy, а не снимок (PEP 667) |
for v in 3.12 3.13 3.14; do python$v bench/interpreter-loop/frames.py; done
Что это и чего это НЕ
Это не бенчмарк: здесь ничего не измеряется во времени и версии
интерпретатора по скорости не сравниваются (по tail-call-интерпретатору 3.14
вообще нет ни PEP, ни воспроизводимого своими руками числа — только раздел
What's New). Это проба поведения: скрипт читает то, что любой процесс вправе
прочитать про себя (dis, opcode, sys._getframe), и печатает рядом с
пояснением, что каждая строка значит.
Почему числам можно верить
Каждое значение — вывод frames.py, снятый на соответствующей версии. Числа
привязаны к версии интерпретатора и на другой будут другими — это ожидаемо и
есть суть статьи. Воспроизводится не одно число, а направление изменения:
HAVE_ARGUMENT 90 → 44 → 43 (3.12 → 3.13 → 3.14), число именованных опкодов
140 → 150 → 238, LOAD_FAST → LOAD_FAST_BORROW, dict → FrameLocalsProxy.
Что именно наблюдается
HAVE_ARGUMENT— граница, ниже которой опкоды идут без аргумента. Смещается от версии к версии; жёстко зашивать её в код нельзя.LOAD_FAST→LOAD_FAST_BORROW(3.14) — загрузка локальной перестала трогать счётчик ссылок там, где значение и так живёт во фрейме. ПлюсLOAD_CONSTмелких целых сталLOAD_SMALL_INT.f_locals(PEP 667, 3.13) — у функции это больше не снимок-dict, аFrameLocalsProxy: запись в него доходит до самого фрейма (xстановится 99, а не остаётся 1).
frame_materialization.py — когда объект-фрейм действительно появляется
Добавлен 27.08.2026. Статья говорит, что PyFrameObject создаётся лениво,
но не показывает, когда ленивость кончается. Скрипт показывает это
запуском.
for v in 3.12 3.13 3.14; do python$v bench/interpreter-loop/frame_materialization.py; done
| блок | что наблюдается |
|---|---|
| 1 | sys._getframe() дважды в одном кадре возвращает один и тот же объект — материализация происходит один раз и кешируется в _PyInterpreterFrame |
| 2 | разложение цены: пустой вызов, первый _getframe (материализация), второй (объект уже есть), .f_back |
| 3 | под sys.setprofile / sys.settrace объект-фрейм выдаётся на каждый вызов; счёт разных объектов у профайлера равен числу вызовов |
| 4 | исключение материализует фреймы всей цепочки в traceback |
| 5 | генератор: gi_frame материализуется при обращении и обнуляется после исчерпания |
Запись прогонов: 27.08.2026, Xeon 2.80GHz, 2 vCPU
######## 3.12.3
1. тип sys._getframe(): frame | два вызова — один объект: True
sys._getframe(1) is _getframe().f_back : True (frame)
2. пустой вызов 18.3
+ первый sys._getframe() (материализация) 35.8
+ второй sys._getframe() (объект уже есть) 11.7
+ .f_back (вызывающий уже материализован) 10.9
3. без хуков 21.0 x 1.0
sys.setprofile 251.1 x 12.0
sys.settrace (только call) 285.6 x 13.6
sys.settrace (+ построчно) 437.3 x 20.8
вызовов target(): 5, разных объектов-фреймов у профайлера: 5
4. звеньев traceback на глубине 5+1: 7, каждое держит объект-фрейм: True
5. gi_frame: frame | два обращения — один объект: True | после исчерпания: None
######## 3.13.7
2. пустой вызов 19.4
+ первый sys._getframe() (материализация) 38.4
+ второй sys._getframe() (объект уже есть) 7.9
+ .f_back (вызывающий уже материализован) 0.2
3. без хуков 17.8 x 1.0
sys.setprofile 187.8 x 10.6
sys.settrace (только call) 181.3 x 10.2
sys.settrace (+ построчно) 262.0 x 14.7
вызовов target(): 5, разных объектов-фреймов у профайлера: 5
######## 3.14.7
2. пустой вызов 17.9
+ первый sys._getframe() (материализация) 32.1
+ второй sys._getframe() (объект уже есть) 12.9
+ .f_back (вызывающий уже материализован) 11.7
3. без хуков 16.3 x 1.0
sys.setprofile 197.3 x 12.1
sys.settrace (только call) 198.0 x 12.1
sys.settrace (+ построчно) 250.3 x 15.3
вызовов target(): 5, разных объектов-фреймов у профайлера: 5
Что показали прогоны
Ленивость подтверждается тождеством, а не косвенно. sys._getframe()
дважды в одном кадре — один и тот же объект на всех трёх версиях. То же с
предком: sys._getframe(1) is sys._getframe().f_back. То есть объект строится
по требованию и потом переиспользуется, а не создаётся на каждое обращение.
Первое обращение стоит дороже второго. На всех версиях первый
sys._getframe() добавляет к пустому вызову втрое-вчетверо больше, чем второй
(3.13: +38,4 против +7,9 нс). Разница и есть цена надстройки.
Ответ на «когда»: под профайлером и отладчиком ленивости нет. Хук получает
frame первым аргументом, значит объект обязан существовать к каждому событию
call. Счёт подтверждает: 5 вызовов — 5 разных объектов-фреймов. Цена вызова
при этом вырастает в 10–15 раз на всех трёх версиях. Это не «накладные расходы
профайлера вообще», а конкретно то, что описано в статье: механизм, который в
обычном режиме не платит за объект в куче, под трассировкой платит за него на
каждом вызове.
Построчная трассировка дороже вызовной. Отличие settrace с возвратом
None от settrace с возвратом себя — 181 против 262 нс на 3.13.
Оговорки
- Строка «
+ .f_back» — разность двух шумных замеров и потому самая неустойчивая: на 3.13 она вышла 0,2 нс, на 3.12 и 3.14 — около 11. Читать её как число нельзя; она показывает только, что вызывающий кадр к этому моменту уже материализован и второй раз за него не платят. - Время между версиями не сравнивается. Три блока стоят рядом ради
кратностей (
xв третьем блоке), одинаковых на всех версиях, а не ради вычитания наносекунд. - Скрипт не наблюдает аллокацию
PyFrameObjectнапрямую: из Python это не видно. Он наблюдает появление объекта, его тождество и его цену. Попытка считать фреймы черезgc.get_objects()не работает — начиная с 3.11 фреймы в списках поколений сборщика не лежат.
Скрипт
48 строк"""Наблюдения для статьи «Цикл интерпретатора: фреймы и диспетчеризация».
Скрипт читает только собственное окружение и печатает то, из чего сделаны
числа и примеры статьи. Запуск на нескольких версиях подряд:
for v in 3.12 3.13 3.14; do python$v bench/interpreter-loop/frames.py; done
"""
import sys, dis, opcode
def load_fast_shape():
"""Один и тот же код на 3.14 грузит переменную иначе: LOAD_FAST_BORROW."""
def one(a):
return a + 1
return [i.opname for i in dis.get_instructions(one)]
def f_locals_writethrough():
"""PEP 667 (3.13): f_locals у функции — не снимок, а write-through proxy."""
def g():
x = 1
loc = sys._getframe().f_locals
proxy_type = type(loc).__name__
loc["x"] = 99 # до 3.13 запись в снимок; с 3.13 — во фрейм
return x, proxy_type
return g()
def main():
print("PY", sys.version.split()[0])
print("HAVE_ARGUMENT :", opcode.HAVE_ARGUMENT)
print("именованных опкодов (<=255):",
sum(1 for n in opcode.opname if not n.startswith("<")))
print("recursionlimit :", sys.getrecursionlimit())
ops = load_fast_shape()
load = next((o for o in ops if o.startswith("LOAD_FAST")), "—")
print("one(a)->a+1, загрузка a :", load, "| все опкоды:", ops)
x, proxy = f_locals_writethrough()
print("f_locals тип у функции :", proxy)
print("после f_locals['x']=99, x :", x,
"(99 = write-through, 1 = снимок до 3.13)")
if __name__ == "__main__":
main()