MEASUREMENT
bench/memory/generations.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/memory/memory-management
- How to run it
python3.13 bench/memory/measure.py > bench/memory/measurements.json python3.13 bench/memory/generations.py
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
Замеры для статьи «Управление памятью»
Ничего не смоделировано: каждое число — то, что вернул сам интерпретатор в момент прогона.
| Файл | Что это |
|---|---|
measure.py |
снимает трассы для визуализаций: счётчики ссылок, поколения сборщика, арены и пулы |
generations.py |
счётчики поколений: каскад по шагам, разница против числа созданных, что будит сборщика |
generations.json |
трасса последнего прогона generations.py |
measurements.json |
свод, из которого взяты числа статьи |
mem-3.12.json, mem-3.13.json, mem-3.14.7.json, mem-3.14.7t.json, mem-3.14.5.json |
сырые прогоны по версиям, включая свободнопоточную 3.14.7t |
python3.13 bench/memory/measure.py > bench/memory/measurements.json
python3.13 bench/memory/generations.py
Почему этот каталог появился поздно
Скрипт лежал в tmp/, а tmp/ стоит в .gitignore — то есть в репозитории
его не было, и числа статьи нельзя было перезапустить. Правило «числа в статьях
берутся отсюда, а не из головы» для уроков выполнялось, для статей — нет.
Перенос исправляет это.
Правила, которые здесь важнее обычного
Память меряется tracemalloc на батче, а не sys.getsizeof — это общее
правило замеров, и в статье про память оно центральное: getsizeof даёт ответ
с обратным знаком на __slots__.
Время между версиями не сравнивается. Файлы по версиям лежат рядом не для того, чтобы вычесть одно из другого, а чтобы показать, что́ в каждой из них интерпретатор сообщает о себе сам.
tracemalloc_vs_rss.py — диагностическая рамка «tracemalloc против RSS»
Добавлен 27.08.2026 для правки статьи: слова tracemalloc в статье про память
не было ни разу, при том что это штатный инструмент диагностики роста памяти.
Скрипт не меряет ни время, ни размеры объектов — он показывает три
состояния, по которым различают причину роста:
| что видно | что это значит |
|---|---|
RSS растёт, tracemalloc — нет |
память взята мимо аллокатора объектов (mmap, буфер C-библиотеки) или объекты уже освобождены, а арена удержана |
tracemalloc растёт, RSS — нет |
новые объекты легли в арены, которые процесс уже держит |
| растут оба | обычный живой рост: объекты созданы и живы |
python3.13 bench/memory/tracemalloc_vs_rss.py
RSS читается из /proc/self/status (VmRSS), поэтому скрипт работает только
на Linux; на других системах колонка RSS будет пустой, и он об этом скажет.
Запись прогона: 27.08.2026, Python 3.13.7, Linux, 2 vCPU
PY 3.13.7
gc.isenabled(): True
1a. mmap на 64 МиБ, все страницы тронуты (аллокатор объектов не участвует):
после mmap + touch RSS +64.19 МиБ tracemalloc +0.00 МиБ
ожидаемый знак: RSS вырос, tracemalloc ~0
1b. 400 000 объектов созданы, затем удалены:
на пике (объекты живы) RSS +47.55 МиБ tracemalloc +9.24 МиБ
после del + gc.collect() RSS +31.53 МиБ tracemalloc +0.00 МиБ
ожидаемый знак: tracemalloc вернулся к нулю, RSS остался поднят
2. Тот же цикл ещё три раза подряд, пик каждого:
круг 1 RSS +16.03 МиБ tracemalloc +9.24 МиБ
круг 2 RSS +5.00 МиБ tracemalloc +9.24 МиБ
круг 3 RSS +5.00 МиБ tracemalloc +9.24 МиБ
ожидаемый знак: tracemalloc одинаков на всех кругах,
RSS от круга к кругу растёт всё меньше — арены уже у процесса
3. Что tracemalloc показывает про источник (top-3 строки):
bench/memory/tracemalloc_vs_rss.py:30 0.6 КиБ в 11 блоках
bench/memory/tracemalloc_vs_rss.py:48 0.3 КиБ в 7 блоках
bench/memory/tracemalloc_vs_rss.py:47 0.1 КиБ в 4 блоках
итог tracemalloc: current 0.00 МиБ, peak 9.26 МиБ
итог RSS : 58.11 МиБ
Что этот прогон показал и чего он не показывает
Ключевая строка — блок 2. tracemalloc даёт 9,24 МиБ на каждом из трёх
кругов, одно и то же число; RSS за те же круги прибавляет 16,03 → 5,00 →
5,00 МиБ. Это и есть демонстрация того, что два счётчика меряют разное:
tracemalloc считает запрошенные байты, RSS — страницы, которые процесс
держит у ядра. Первый круг после освобождения ещё дозапрашивает у ядра, к
третьему — уже нет.
Блок 1a — самый чистый случай «RSS вырос, tracemalloc нет»: 64 МиБ через
mmap минуя PyObject_Malloc. tracemalloc перехватывает только домены
аллокатора CPython, поэтому в трассе этих байтов нет вовсе. Так же выглядит
любой буфер, взятый сторонней C-библиотекой.
Чего в прогоне нет. Числа блока 1b нельзя читать как «объекты весят 47 МиБ»:
в RSS входит и служебная таблица самого tracemalloc (по записи на
прослеживаемый блок). Именно поэтому RSS на пике впятеро больше traced. Для
измерения размера объектов есть measure.py; этот скрипт — про разность
показаний, а не про абсолютные величины.
Абсолютные мегабайты привязаны к машине и к прогону. Воспроизводится знак
каждой строки и постоянство колонки tracemalloc в блоке 2, а не сами числа.
Script
210 lines"""Счётчики поколений сборщика: три числа в трёх разных единицах.
ЗАЧЕМ ЭТОТ ЗАМЕР. В статье сказано, что `(2000, 10, 10)` — это не три
однородных порога: первое число считает ОБЪЕКТЫ, два других — СБОРКИ. И там же
сказано, что `threshold2 = 10` — условие необходимое, но не достаточное:
`gc_select_generation()` пропускает полную сборку, пока долгоживущих объектов
прибавилось меньше четверти. Оба утверждения взяты из исходника и до сих пор
ничем не подпёрты со стороны запуска.
Здесь они проверяются интерпретатором.
1. РАЗНИЦА, А НЕ СЧЁТ СОЗДАННЫХ. Первый порог сравнивают с разницей между
созданными и уничтоженными отслеживаемыми объектами. Поэтому мусор,
который умирает сразу, сборщик не будит вовсе — сколько бы его ни было.
Два прогона по одинаковому числу объектов, отличающиеся только тем,
удерживается ли объект.
2. КАСКАД. Счётчики снимаются по шагам на выживающих объектах: видно, где
счётчик нулевого поколения обнуляется и в этот же момент на единицу
подрастает счётчик первого.
3. ЧТО ЕГО БУДИТ. Одинаковая работа при растущей и при неизменной живой
части, и сколько полных сборок случилось против наивного «каждая сотая
сборка нулевого — полная».
ЧЕГО ЗДЕСЬ НЕТ. Времени. Утверждения статьи про поколения — про счётчики и
условия, а не про наносекунды.
ПОЧЕМУ ОТДЕЛЬНЫМ ФАЙЛОМ, А НЕ В measure.py. `measure.py` снимает трассы для
уже существующих картинок и печатает JSON целиком; здесь нужен свой прогон с
управляемым мусором, и мешать их в один вывод значило бы пересобирать все
трассы ради одного раздела.
python3.13 bench/memory/generations.py
"""
import collections
import gc
import json
import sys
class Node:
"""Отслеживаемый объект.
Без `__slots__` намеренно: у экземпляра есть `__dict__`, и только поэтому
сборщик за ним следит. С `__slots__` и без ссылок на контейнеры объект
не отслеживается вовсе, и счётчик поколения на него не реагирует —
проверяется здесь же, `gc.is_tracked`.
"""
def __init__(self, i):
self.i = i
def survives_vs_dies(objects: int) -> list:
"""Одинаковая работа, разная судьба объектов."""
out = []
for hold in (False, True):
gc.collect()
gc.freeze()
before = [s["collections"] for s in gc.get_stats()]
keep = []
for i in range(objects):
node = Node(i)
if hold:
keep.append(node)
after = [s["collections"] for s in gc.get_stats()]
peak = list(gc.get_count())
gc.unfreeze()
del keep
gc.collect()
out.append(
{
"hold": hold,
"objects": objects,
"collections": [after[i] - before[i] for i in range(len(after))],
"count_at_end": peak,
}
)
return out
def cascade(steps: int, batch: int) -> dict:
"""Счётчики поколений по шагам — на объектах, которые остаются жить."""
gc.collect()
gc.freeze()
keep = []
trace = []
resets = []
prev = gc.get_count()
for step in range(steps):
for i in range(batch):
keep.append(Node(i))
count = gc.get_count()
row = {"step": step, "alive": len(keep), "count": list(count)}
if count[0] < prev[0]:
row["collected"] = 0
resets.append(row)
trace.append(row)
prev = count
gc.unfreeze()
del keep
gc.collect()
return {"batch": batch, "trace": trace, "gen0_collections": len(resets)}
def full_collections(rounds: int, growing: bool) -> dict:
"""Что будит сборщика: оборот объектов или рост живой части.
Работа в обоих прогонах одинаковая — `rounds` × 200 отслеживаемых
объектов. Различие одно: в первом живая часть НЕ растёт (окно из
последних двадцати порций, всё старше выбрасывается), во втором каждая
третья порция остаётся жить навсегда.
Порог сравнивают с разницей между созданными и уничтоженными, поэтому
оборот сам по себе сборщика почти не будит, сколько бы объектов через
него ни прошло. А наивное «каждая сотая сборка нулевого — полная» на
растущем прогоне уже переоценивает: полных сборок меньше, чем оно обещает,
и виновата в этом строчка про long_lived_pending.
"""
pool = [Node(i) for i in range(200_000)]
gc.collect()
gc.collect() # чтобы набранное попало в long_lived_total
before = [s["collections"] for s in gc.get_stats()]
keep = []
ring = collections.deque(maxlen=20)
for i in range(rounds):
batch = [Node(j) for j in range(200)]
if growing and i % 3 == 0:
keep.append(batch)
else:
ring.append(batch)
after = [s["collections"] for s in gc.get_stats()]
per_gen = [after[i] - before[i] for i in range(len(after))]
created = rounds * 200
del keep, ring, pool
gc.collect()
return {
"growing": growing,
"created": created,
"long_lived_pool": 200_000,
"collections": per_gen,
"naive_full_prediction": per_gen[0] // 100,
"actual_full": per_gen[-1],
}
def main() -> None:
tracked = gc.is_tracked(Node(0))
out = {
"python": sys.version.split()[0],
"thresholds": list(gc.get_threshold()),
"node_is_tracked": tracked,
"survives_vs_dies": survives_vs_dies(objects=60_000),
"cascade": cascade(steps=40, batch=500),
"full": [
full_collections(rounds=9000, growing=False),
full_collections(rounds=9000, growing=True),
],
}
print(f"PY {out['python']} пороги {tuple(out['thresholds'])} "
f"объект отслеживается: {tracked}")
print()
print("1. Порог сравнивают с разницей, а не с числом созданных")
print("-" * 58)
for s in out["survives_vs_dies"]:
kind = "объект остаётся жить" if s["hold"] else "объект умирает сразу"
print(f" {kind:<22} {s['objects']} объектов "
f"сборок по поколениям {s['collections']} "
f"счётчики в конце {tuple(s['count_at_end'])}")
c = out["cascade"]
print()
print(f"2. Каскад: по {c['batch']} выживающих объектов на шаг")
print("-" * 58)
print(" шаг живых счётчики (gen0, gen1, gen2)")
for row in c["trace"][:14]:
mark = " <- собрано нулевое" if "collected" in row else ""
print(f" {row['step']:>3} {row['alive']:>6} {tuple(row['count'])}{mark}")
print(f" всего сборок нулевого поколения за прогон: {c['gen0_collections']}")
print()
print("3. Сборщика будит рост живой части, а не оборот")
print("-" * 58)
for f in out["full"]:
kind = "живая часть растёт" if f["growing"] else "только оборот"
print(f" {kind:<21} создано {f['created']} "
f"сборок по поколениям {f['collections']} "
f"наивно ждали полных {f['naive_full_prediction']}, "
f"было {f['actual_full']}")
with open("bench/memory/generations.json", "w", encoding="utf-8") as fh:
json.dump(out, fh, ensure_ascii=False, indent=1)
print()
print("трасса целиком: bench/memory/generations.json")
if __name__ == "__main__":
main()