Deep Engineering

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()