Deep Engineering

ЗАМЕР

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_FASTLOAD_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_FASTLOAD_FAST_BORROW, dictFrameLocalsProxy.

Что именно наблюдается

  • HAVE_ARGUMENT — граница, ниже которой опкоды идут без аргумента. Смещается от версии к версии; жёстко зашивать её в код нельзя.
  • LOAD_FASTLOAD_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()