Deep Engineering

ЗАМЕР

bench/memory/tracemalloc_vs_rss.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/python/memory/memory-management
Как запустить
python3.13 bench/memory/measure.py > bench/memory/measurements.json
python3.13 bench/memory/generations.py

Запись прогона

Замеры для статьи «Управление памятью»

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

Файл Что это
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, а не сами числа.

Скрипт

173 строк
"""Диагностическая рамка: `tracemalloc` против RSS.

Скрипт показывает три состояния, по которым различают причину роста памяти:

  1. RSS вырос, `tracemalloc` — нет (память взята мимо аллокатора объектов
     либо арена удержана процессом после освобождения объектов);
  2. `tracemalloc` вырос, RSS — нет (объекты легли в уже удержанные арены);
  3. оба выросли (обычный живой рост: объекты созданы и живы).

Ничего не моделируется: RSS читается из `/proc/self/status` (VmRSS),
`tracemalloc` опрашивается через `get_traced_memory()`. Скрипт работает только
на Linux — на других системах VmRSS не читается и он честно об этом скажет.

    python3.13 bench/memory/tracemalloc_vs_rss.py

Числа в килобайтах/мегабайтах привязаны к машине и к прогону; воспроизводится
не число, а **знак** каждой из трёх строк.
"""
import gc
import mmap
import sys
import tracemalloc

MiB = 1024 * 1024


def rss_bytes():
    """Резидентный размер процесса по данным ядра (Linux)."""
    try:
        with open("/proc/self/status", "r", encoding="ascii") as fh:
            for line in fh:
                if line.startswith("VmRSS:"):
                    return int(line.split()[1]) * 1024
    except OSError:
        pass
    return None


class Probe:
    """Снимок пары (RSS, tracemalloc.current) и разница между снимками."""

    def __init__(self):
        self.rss = rss_bytes()
        self.traced = tracemalloc.get_traced_memory()[0]

    def delta(self, other):
        drss = None if (self.rss is None or other.rss is None) else other.rss - self.rss
        return drss, other.traced - self.traced


def mb(n):
    if n is None:
        return "    н/д"
    return f"{n / MiB:+8.2f}"


def line(title, drss, dtraced):
    print(f"  {title:<44} RSS {mb(drss)} МиБ   tracemalloc {mb(dtraced)} МиБ")


def case_untraced_mapping(n_mib=64):
    """1a. Память, которой `tracemalloc` не видит вовсе.

    `mmap.mmap` берёт страницы у ядра напрямую, минуя `PyObject_Malloc`.
    `tracemalloc` перехватывает только домены аллокатора CPython, поэтому
    страницы, которые мы тут же тронем, попадут в RSS и не попадут в трассу.
    Ровно так же выглядят буферы, взятые сторонней C-библиотекой.
    """
    before = Probe()
    buf = mmap.mmap(-1, n_mib * MiB)
    for off in range(0, n_mib * MiB, 4096):   # тронуть каждую страницу
        buf[off] = 1
    after = Probe()
    drss, dtraced = before.delta(after)
    buf.close()
    return drss, dtraced


def case_arena_held(n_objects=400_000):
    """1b. Объекты созданы и удалены: `tracemalloc` вернулся, RSS — нет.

    Арена отдаётся системе только когда пусты все её пулы. После удаления
    списка `tracemalloc` показывает возврат к исходному, а RSS остаётся
    поднятым — это и есть «аллокатор держит арену», а не утечка.
    """
    before = Probe()
    data = [object() for _ in range(n_objects)]
    peak = Probe()
    del data
    gc.collect()
    after = Probe()
    return before.delta(peak), before.delta(after)


def case_reuse(n_objects=400_000):
    """2. Обратный случай: `tracemalloc` вырос ровно так же, RSS — меньше.

    Тот же цикл «создать — удалить» повторяется несколько раз подряд. Арены
    уже у процесса, новые объекты ложатся в них, и у ядра каждый следующий раз
    просят всё меньше. `tracemalloc` при этом каждый раз показывает одно и то
    же число: он считает запрошенные байты, а не страницы процесса.
    """
    rounds = []
    for _ in range(3):
        before = Probe()
        data = [object() for _ in range(n_objects)]
        peak = Probe()
        rounds.append(before.delta(peak))
        del data
        gc.collect()
    return rounds


def top_sources(limit=3):
    """Что именно показывает `tracemalloc` — строку исходника и байты."""
    snap = tracemalloc.take_snapshot()
    return snap.statistics("lineno")[:limit]


def main():
    print("PY", sys.version.split()[0])
    if rss_bytes() is None:
        print("VmRSS недоступен (не Linux) — колонка RSS будет пустой")
    print("gc.isenabled():", gc.isenabled())
    print()

    tracemalloc.start(1)
    gc.collect()

    print("1a. mmap на 64 МиБ, все страницы тронуты "
          "(аллокатор объектов не участвует):")
    drss, dtraced = case_untraced_mapping()
    line("после mmap + touch", drss, dtraced)
    print("    ожидаемый знак: RSS вырос, tracemalloc ~0")
    print()

    print("1b. 400 000 объектов созданы, затем удалены:")
    (drss_peak, dtr_peak), (drss_end, dtr_end) = case_arena_held()
    line("на пике (объекты живы)", drss_peak, dtr_peak)
    line("после del + gc.collect()", drss_end, dtr_end)
    print("    ожидаемый знак: tracemalloc вернулся к нулю, RSS остался поднят")
    print()

    print("2. Тот же цикл ещё три раза подряд, пик каждого:")
    for i, (drss_i, dtr_i) in enumerate(case_reuse(), start=1):
        line(f"круг {i}", drss_i, dtr_i)
    print("    ожидаемый знак: tracemalloc одинаков на всех кругах,")
    print("    RSS от круга к кругу растёт всё меньше — арены уже у процесса")
    print()

    print("3. Что tracemalloc показывает про источник (top-3 строки):")
    for stat in top_sources():
        frame = stat.traceback[0]
        print(f"  {frame.filename}:{frame.lineno}  "
              f"{stat.size / 1024:9.1f} КиБ в {stat.count} блоках")
    print()

    cur, peak = tracemalloc.get_traced_memory()
    print(f"итог tracemalloc: current {cur / MiB:.2f} МиБ, peak {peak / MiB:.2f} МиБ")
    print(f"итог RSS        : {rss_bytes() / MiB:.2f} МиБ"
          if rss_bytes() is not None else "итог RSS        : н/д")
    print()
    print("Чего tracemalloc не покажет никогда: память вне доменов аллокатора")
    print("CPython (mmap, буферы C-библиотек, стеки потоков) и фрагментацию —")
    print("он считает запрошенные байты, а не занятые процессом страницы.")
    print("Обратное тоже верно и видно в блоке 1b: RSS на пике больше traced,")
    print("потому что в RSS входит и служебная таблица самого tracemalloc.")
    tracemalloc.stop()


if __name__ == "__main__":
    main()