Deep Engineering

MEASUREMENT

bench/interpreter-loop/frame_materialization.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/python/runtime/interpreter-loop
How to run it
for v in 3.12 3.13 3.14; do python$v bench/interpreter-loop/frames.py; done

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Наблюдения для статьи «Цикл интерпретатора: фреймы и диспетчеризация»

Скрипт Что показывает
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 фреймы в списках поколений сборщика не лежат.

Script

224 lines
"""Когда `PyFrameObject` действительно появляется.

Статья говорит, что данные вызова лежат в `_PyInterpreterFrame`, а
объект-фрейм (`types.FrameType`) надстраивается лениво. Скрипт показывает
запуском, **когда именно** эта надстройка происходит и во что обходится:

  1. `sys._getframe()` — материализация по требованию, ровно один объект на
     вызов: второй `sys._getframe()` в том же кадре возвращает тот же объект;
  2. `f_back` / `sys._getframe(depth)` — материализация предков по цепочке;
  3. `sys.setprofile` и `sys.settrace` — объект-фрейм выдаётся на **каждый**
     вызов, даже если код о фреймах ничего не знает;
  4. исключение — фреймы всей цепочки материализуются в traceback;
  5. генератор — `gi_frame` материализуется при обращении и живёт с генератором.

Цена меряется разностями внутри одного прогона; абсолютные наносекунды
привязаны к машине.

    for v in 3.12 3.13 3.14; do python$v bench/interpreter-loop/frame_materialization.py; done
"""
import sys
import timeit
import types

REPEAT = 5
NUMBER = 200_000


def ns(fn, number=NUMBER, repeat=REPEAT):
    return min(timeit.repeat(fn, number=number, repeat=repeat)) / number * 1e9


# --- 1. материализация по требованию и кеширование --------------------------

def once_per_frame():
    a = sys._getframe()
    b = sys._getframe()
    return a is b, type(a).__name__


def parents_are_lazy():
    def inner():
        return (sys._getframe(1) is sys._getframe().f_back,
                type(sys._getframe().f_back).__name__)
    return inner()


# --- 2. цена ----------------------------------------------------------------

def bare():
    return 1


def with_getframe():
    sys._getframe()
    return 1


def with_getframe_twice():
    sys._getframe()
    sys._getframe()
    return 1


def with_f_back():
    sys._getframe().f_back
    return 1


def call_costs():
    """Разложение цены: пустой вызов, первый _getframe, второй, f_back."""
    t_bare = ns(bare)
    t_one = ns(with_getframe)
    t_two = ns(with_getframe_twice)
    t_back = ns(with_f_back)
    return {
        "пустой вызов": t_bare,
        "+ первый sys._getframe() (материализация)": t_one - t_bare,
        "+ второй sys._getframe() (объект уже есть)": t_two - t_one,
        "+ .f_back (вызывающий уже материализован)": t_back - t_one,
    }


# --- 3. хуки: фрейм выдаётся на каждый вызов --------------------------------

def hook_costs():
    seen_profile = set()
    seen_trace = set()

    def profiler(frame, event, arg):
        if event == "call":
            seen_profile.add(id(frame))

    def tracer(frame, event, arg):
        if event == "call":
            seen_trace.add(id(frame))
        return None                      # без построчной трассировки

    def tracer_lines(frame, event, arg):
        return tracer_lines                # с построчной

    n_small = 20_000
    t_plain = ns(bare, number=n_small)

    sys.setprofile(profiler)
    t_profile = ns(bare, number=n_small)
    sys.setprofile(None)

    sys.settrace(tracer)
    t_trace = ns(bare, number=n_small)
    sys.settrace(None)

    sys.settrace(tracer_lines)
    t_trace_lines = ns(bare, number=n_small)
    sys.settrace(None)

    return {
        "без хуков": t_plain,
        "sys.setprofile": t_profile,
        "sys.settrace (только call)": t_trace,
        "sys.settrace (+ построчно)": t_trace_lines,
    }, len(seen_profile), len(seen_trace)


def distinct_frames_per_call(n=5):
    """Сколько РАЗНЫХ объектов-фреймов профайлер получает на n вызовов.

    id() переиспользуется после освобождения объекта, поэтому фреймы
    удерживаются ссылкой — иначе счёт был бы занижен.
    """
    kept = []

    def profiler(frame, event, arg):
        if event == "call" and frame.f_code.co_name == "target":
            kept.append(frame)

    def target():
        return 1

    sys.setprofile(profiler)
    for _ in range(n):
        target()
    sys.setprofile(None)
    return len(kept), len({id(f) for f in kept})


# --- 4. исключение --------------------------------------------------------

def traceback_frames(depth=5):
    def deep(k):
        if k:
            return deep(k - 1)
        raise ValueError("проба")

    try:
        deep(depth)
    except ValueError as exc:
        tb = exc.__traceback__
        frames = []
        while tb is not None:
            frames.append(tb.tb_frame)
            tb = tb.tb_next
        return len(frames), all(isinstance(f, types.FrameType) for f in frames)


# --- 5. генератор ---------------------------------------------------------

def generator_frame():
    def gen():
        yield 1

    g = gen()
    first = g.gi_frame
    second = g.gi_frame
    kind = type(first).__name__
    list(g)
    return kind, first is second, g.gi_frame


def main():
    print("PY", sys.version.split()[0])
    print("Время сравнивается только между строками этого прогона.")
    print()

    same, kind = once_per_frame()
    print("1. Материализация по требованию")
    print(f"   тип, который возвращает sys._getframe()  : {kind}")
    print(f"   два вызова в одном кадре — один объект   : {same}")
    same_back, back_kind = parents_are_lazy()
    print(f"   sys._getframe(1) is _getframe().f_back   : {same_back} ({back_kind})")
    print()

    print("2. Из чего складывается цена, нс")
    for label, value in call_costs().items():
        print(f"   {label:<44} {value:8.1f}")
    print()

    print("3. Под хуками фрейм выдаётся на каждый вызов, нс на вызов")
    costs, n_profile, n_trace = hook_costs()
    base = costs["без хуков"]
    for label, value in costs.items():
        print(f"   {label:<30} {value:9.1f}   x{value / base:6.1f}")
    n_calls, n_distinct = distinct_frames_per_call()
    print(f"   вызовов target(): {n_calls}, разных объектов-фреймов у профайлера:"
          f" {n_distinct}")
    print("   Это и есть ответ «когда»: под профайлером и отладчиком ленивость")
    print("   кончается — объект строится на каждый вызов, включая те, где он не нужен.")
    print()

    n_tb, all_frames = traceback_frames()
    print("4. Исключение")
    print(f"   звеньев traceback на глубине 5+1        : {n_tb}")
    print(f"   каждое звено держит объект-фрейм        : {all_frames}")
    print()

    kind_g, same_g, after = generator_frame()
    print("5. Генератор")
    print(f"   gi_frame тип                            : {kind_g}")
    print(f"   два обращения — один объект             : {same_g}")
    print(f"   gi_frame после исчерпания               : {after}")


if __name__ == "__main__":
    main()