ЗАМЕР
bench/interpreter-loop/frame_materialization.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 фреймы в списках поколений сборщика не лежат.
Скрипт
224 строк"""Когда `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()