Deep Engineering

MEASUREMENT

bench/memory/measure.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

226 lines
"""
Снимает реальные трассы для визуализаций статьи про управление памятью.

Ничего здесь не смоделировано: каждое число — то, что вернул сам
интерпретатор в момент прогона. Запуск:

    python3.13 bench/memory/measure.py > bench/memory/measurements.json
"""
import gc
import json
import sys

out = {
    "python": sys.version.split()[0],
    "implementation": sys.implementation.name,
    "gil_enabled": getattr(sys, "_is_gil_enabled", lambda: True)(),
    "maxsize_bits": sys.maxsize.bit_length() + 1,
}

# --- 1. Счётчик ссылок по шагам ------------------------------------------
# sys.getrefcount сам создаёт временную ссылку на аргумент, поэтому его
# ответ всегда на единицу больше «настоящего» числа. Вычитаем её явно, а не
# молча: расхождение на единицу — самая частая причина недоумения при
# первом знакомстве с refcount.
#
# И ровно поэтому здесь НЕ вынесена функция-помощник rc(obj). Проверено на
# 3.13.13: при вызове через функцию параметр obj — это ещё одна ссылка, и
# ответ становится больше уже на две, а не на одну. Замер счётчика ссылок
# сам меняет то, что замеряет, и размер помехи зависит от того, как именно
# вы замеряете. Поэтому выражение везде записано на месте.
trace = []


class Thing:
    __slots__ = ("name",)

    def __init__(self, name):
        self.name = name


a = Thing("payload")
trace.append({"code": 'a = Thing("payload")', "refcount": sys.getrefcount(a) - 1, "note": "одна ссылка: имя a"})

b = a
trace.append({"code": "b = a", "refcount": sys.getrefcount(a) - 1, "note": "второе имя на тот же объект"})

box = [a]
trace.append({"code": "box = [a]", "refcount": sys.getrefcount(a) - 1, "note": "список тоже держит ссылку"})

del b
trace.append({"code": "del b", "refcount": sys.getrefcount(a) - 1, "note": "имя удалено, объект жив"})

box.clear()
trace.append({"code": "box.clear()", "refcount": sys.getrefcount(a) - 1, "note": "список отпустил ссылку"})

out["refcount_trace"] = trace

# Насколько сам замер искажает результат: то же значение, снятое на месте и
# через функцию-помощник.
def _rc_via_helper(obj):
    return sys.getrefcount(obj) - 1


probe = Thing("probe")
out["measurement_overhead"] = {
    "inline": sys.getrefcount(probe) - 1,
    "via_helper": _rc_via_helper(probe),
}

# --- 2. Что происходит, когда счётчик доходит до нуля ---------------------
freed = []


class Loud:
    def __init__(self, name):
        self.name = name

    def __del__(self):
        freed.append(self.name)


x = Loud("сразу")
del x
out["deallocated_immediately"] = list(freed)

# --- 3. Цикл: почему одного счётчика мало --------------------------------
freed.clear()
gc.collect()


class Node:
    def __init__(self, name):
        self.name = name
        self.ref = None

    def __del__(self):
        freed.append(self.name)


left = Node("left")
right = Node("right")
left.ref = right
right.ref = left

cycle = {
    "left_refcount_after_link": sys.getrefcount(left) - 1,
    "right_refcount_after_link": sys.getrefcount(right) - 1,
}
del left
del right
cycle["freed_after_del"] = list(freed)
cycle["objects_in_gc_before_collect"] = sum(
    1 for o in gc.get_objects() if isinstance(o, Node)
)
collected = gc.collect()
cycle["gc_collect_returned"] = collected
cycle["freed_after_collect"] = list(freed)
out["cycle"] = cycle

# --- 4. Пороги и счётчики поколений --------------------------------------
gc.collect()
gc.enable()
out["thresholds"] = list(gc.get_threshold())

counts = [{"allocated": 0, "counts": list(gc.get_count())}]
holder = []
for i in range(1, 6):
    # Каждый объект в цикле отслеживается сборщиком, поэтому счётчик
    # нулевого поколения растёт предсказуемо.
    holder.append([None])
    if i in (1, 2, 3, 4, 5):
        counts.append({"allocated": i, "counts": list(gc.get_count())})
out["generation_counts_sample"] = counts
out["stats"] = gc.get_stats()

# --- 5. Раскладка pymalloc ------------------------------------------------
import contextlib
import io
import re

# Две ловушки подряд, обе молчаливые.
#
# Первая: sys._debugmallocstats() печатает в stderr, а не в stdout.
# Вторая, и более коварная: печатает он из C через fprintf, поэтому
# contextlib.redirect_stderr его не ловит — эта подмена работает на уровне
# объекта sys.stderr, а не файлового дескриптора. Перехват "срабатывает",
# возвращает пустую строку, разбор даёт пустой словарь, и измерение молча
# не измеряет ничего. Поэтому подменяем сам дескриптор 2.
import os
import tempfile

with tempfile.TemporaryFile(mode="w+", encoding="utf-8") as tmp:
    saved_fd = os.dup(2)
    try:
        sys.stderr.flush()
        os.dup2(tmp.fileno(), 2)
        sys._debugmallocstats()
        sys.stderr.flush()
    finally:
        os.dup2(saved_fd, 2)
        os.close(saved_fd)
    tmp.seek(0)
    raw = tmp.read()

arena = {}
m = re.search(r"(\d+) arenas \* (\d+) bytes/arena", raw)
if m:
    arena["arenas"] = int(m.group(1))
    arena["arena_bytes"] = int(m.group(2))
m = re.search(r"(\d+) unused pools \* (\d+) bytes", raw)
if m:
    arena["unused_pools"] = int(m.group(1))
    arena["pool_bytes"] = int(m.group(2))
m = re.search(r"Small block threshold = (\d+), in (\d+) size classes", raw)
if m:
    arena["small_request_threshold"] = int(m.group(1))
    arena["size_classes"] = int(m.group(2))
m = re.search(r"# bytes in allocated blocks\s+=\s+([\d,]+)", raw)
if m:
    arena["allocated_block_bytes"] = int(m.group(1).replace(",", ""))
m = re.search(r"# bytes lost to pool headers\s+=\s+([\d,]+)", raw)
if m:
    arena["pool_header_bytes"] = int(m.group(1).replace(",", ""))
m = re.search(r"# bytes lost to quantization\s+=\s+([\d,]+)", raw)
if m:
    arena["quantization_bytes"] = int(m.group(1).replace(",", ""))

# Занятость по классам размеров — это и есть содержимое пулов.
classes = []
for line in raw.splitlines():
    m = re.match(r"\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*$", line)
    if m:
        classes.append(
            {
                "class": int(m.group(1)),
                "size": int(m.group(2)),
                "pools": int(m.group(3)),
                "in_use": int(m.group(4)),
                "available": int(m.group(5)),
            }
        )
arena["size_class_table"] = classes
if arena.get("arena_bytes") and arena.get("pool_bytes"):
    arena["pools_per_arena"] = arena["arena_bytes"] // arena["pool_bytes"]
out["pymalloc"] = arena

# --- 6. Размеры объектов --------------------------------------------------
out["object_sizes"] = {
    "object()": sys.getsizeof(object()),
    "float": sys.getsizeof(1.5),
    "empty tuple": sys.getsizeof(()),
    "empty list": sys.getsizeof([]),
    "empty dict": sys.getsizeof({}),
}

# --- 7. Бессмертные объекты ----------------------------------------------
out["immortal_refcounts"] = {
    "None": sys.getrefcount(None),
    "True": sys.getrefcount(True),
    "0": sys.getrefcount(0),
    "256": sys.getrefcount(256),
    "257": sys.getrefcount(257),
}

json.dump(out, sys.stdout, ensure_ascii=False, indent=2)