Deep Engineering

MEASUREMENT

bench/copy/cost.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/interview/python/shallow-vs-deep-copy
How to run it
python3.11 bench/copy/graph.py
python3.12 bench/copy/graph.py
python3.13 bench/copy/graph.py
python3.14 bench/copy/graph.py

python3.13 bench/copy/cost.py
python3.14 bench/copy/cost.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

Замеры для урока «Поверхностное и глубокое копирование»

Скрипт Что делает
graph.py наблюдения без времени: сколько уровней копируется, что остаётся общим, что возвращается как есть, вызывается ли __init__, где ломается рекурсия
cost.py цена: пять способов поверхностной копии между собой, от чего зависит цена глубокой, и пять способов получить «копию» одной структуры в одном замере
python3.11 bench/copy/graph.py
python3.12 bench/copy/graph.py
python3.13 bench/copy/graph.py
python3.14 bench/copy/graph.py

python3.13 bench/copy/cost.py
python3.14 bench/copy/cost.py

Практика урока

practice.py — источник ответов двух практических задач урока, а runs/practice.txt — дословная запись его прогона. Ответ задачи не сочиняется: сборка сверяет заявленное с этой записью (scripts/validate-practice.mjs) и не проходит, если они разошлись.

Прогон снят 30.08.2026 на CPython 3.13.7 (Clang 20.1.4). Абсолютные числа — этой машины; переносится кратность, и задача «во сколько раз» стоит именно на ней.

Кратность устойчива только потому, что формы меряются ВПЕРЕМЕЖКУ: в каждом круге меряются все, минимум для каждой берётся по кругам. Пока замеры шли подряд, просадка машины в окне одной формы целиком доставалась ей, и отношение гуляло в полтора раза от запуска к запуску (измерено на декораторах: 5,9 / 7,1 / 7,5 / 9,2). После перехода на чередование расхождение между прогонами не выходит за несколько процентов. Перезаписывать запись прогона имеет смысл только вместе с проверкой задачи: если после перезапуска ответ изменился, менять нужно задачу, а не файл.

Что здесь важно прочитать правильно

graph.py не меряет время. Он печатает ответы на вопрос «тот же это объект или другой» — то есть is, а не секунды. Такие утверждения от машины не зависят и обязаны совпасть у любого, кто запустит скрипт на той же версии; прогон по четырём версиям сделан ради того, чтобы это проверить.

cost.py сравнивает только строки внутри своего блока. Все способы в блоке меряются подряд, на одной и той же структуре. Абсолютные микросекунды с другой машины несравнимы, между версиями — тоже: сборки разных версий различаются компилятором, и эта разница больше измеряемой.

Третий блок cost.py нельзя читать без восьмого раздела graph.py. Он показывает, что pickle быстрее deepcopy в 5,8 раза, а руками — в 14,2. Но json при этом не переживает цикл и меняет типы ({1: (2, 3)} возвращается как {'1': [2, 3]}), а ручной способ верен ровно до первого изменения формы данных. Число «быстрее» само по себе тут ничего не советует.

Все пять строк третьего блока сняты в одном замере, включая поверхностную копию. Она стоит там не как «ещё один способ сделать глубокую копию», а чтобы отношение ×1800 считалось по числам из одного прогона, а не из двух разных.

Что получилось на 3.13.7:

что сравнивается результат
data[:], list(data), data.copy(), copy.copy(data) 2,3–2,5 мкс, разброс в пределах 7 %
[x for x in data] 13,7 мкс — в 5,8 раза дороже остальных четырёх
глубокая: 10 000 int против 10 000 списков по одному int 2,3 мс против 10 мс, ×4,3
поверхностная против глубокой на той же структуре 2,35 мкс против 4252 мкс, ×1809
pickle туда-обратно против deepcopy быстрее в 5,8 раза
json туда-обратно против deepcopy быстрее в 2,7 раза
руками, по известной форме быстрее в 14,2 раза

Числа последних четырёх строк — из одного запуска; от запуска к запуску отношения держатся в пределах 5,8–5,9, 2,7–2,8 и 13,7–14,2.

Наблюдения graph.py, одинаковые на 3.11, 3.12, 3.13 и 3.14.7 — вывод скрипта на 3.11 и на 3.13 совпадает побайтно:

  • у поверхностной копии верхний объект новый, вложенный — тот же;
  • глубокая копия сохраняет ОБЩНОСТЬ: если один объект лежал под двумя ключами, в копии он тоже один;
  • copy.copy возвращает как есть и frozenset, и slice, а deepcopy их копирует — потому что в Lib/copy.py два разных списка атомарных типов;
  • ни copy, ни deepcopy не вызывают __init__ — ноль вызовов на две копии;
  • deepcopy ломается на вложенности 498 при sys.getrecursionlimit() == 1000, то есть тратит два кадра стека на уровень.

Что этими числами доказать НЕЛЬЗЯ

Что deepcopy «медленный и его надо избегать». ×1800 получается на структуре из четырёх тысяч объектов; на структуре из десяти объектов та же операция стоит микросекунды, и вопрос выбора вообще не стоит. Число говорит одно: цена глубокой копии растёт по числу объектов внутри, поэтому её нельзя ставить в горячий путь, не посмотрев, сколько там объектов.

Script

121 lines
"""
Замер цены копирования для урока «Поверхностное и глубокое копирование».

ЗАПУСК (числа урока сняты на 3.13.7):

    python3.13 bench/copy/cost.py
    python3.14 bench/copy/cost.py

ЧТО МОЖНО СРАВНИВАТЬ. Только строки внутри одного блока одного запуска: они
меряются подряд, на одной и той же структуре данных, в одинаковых условиях.
Абсолютные микросекунды с другой машины сравнивать нельзя, между версиями —
тоже: 3.12 собран GCC, а 3.13 и 3.14 Clang.

ПОЧЕМУ min, А НЕ СРЕДНЕЕ. Всё, что мешает (планировщик, соседний процесс,
сборка мусора), делает замер только МЕДЛЕННЕЕ. Минимум из семи повторов —
ближайшая к правде оценка; среднее меряет ещё и шум машины.

ЧЕГО ЭТОТ СКРИПТ НЕ ГОВОРИТ. Он не говорит, что pickle «лучше» deepcopy.
Он говорит только про время. Что при этом теряется — общность объектов, циклы,
типы — печатает `graph.py`, и без него блок 3 читать нельзя.
"""

import copy
import json
import pickle
import sys
import timeit


def header(title: str) -> None:
    print(f"\n--- {title} ---")


def us(stmt: str, env: dict, number: int) -> float:
    """Микросекунды на одну операцию: минимум из семи повторов."""
    return min(timeit.repeat(stmt, globals=env, number=number, repeat=7)) / number * 1e6


def make_records(n: int) -> list:
    """Структура, похожая на то, что копируют в реальном коде: три уровня."""
    return [{"id": i, "tags": ["a", "b"], "meta": {"x": i}} for i in range(n)]


def bench_shallow() -> None:
    header("1. Пять способов поверхностной копии (список из 1000 записей)")

    data = make_records(1000)
    env = {"data": data, "copy": copy}
    rows = [
        ("data[:]", "data[:]"),
        ("list(data)", "list(data)"),
        ("data.copy()", "data.copy()"),
        ("copy.copy(data)", "copy.copy(data)"),
        ("[x for x in data]", "[x for x in data]"),
    ]
    results = [(name, us(stmt, env, 2000)) for name, stmt in rows]
    best = min(v for _, v in results)
    print(f"{'способ':<20} {'мкс':>8} {'к лучшему':>10}")
    for name, value in results:
        print(f"{name:<20} {value:>8.2f} {value / best:>9.2f}x")
    print("\nВсе пять делают одно и то же: новый список, те же объекты внутри.")


def bench_object_count() -> None:
    header("2. Цена глубокой копии — по числу объектов, а не байтов")

    flat = list(range(10000))
    nested = [[i] for i in range(10000)]
    env = {"flat": flat, "nested": nested, "copy": copy}

    a = us("copy.deepcopy(flat)", env, 20)
    b = us("copy.deepcopy(nested)", env, 20)
    print(f"{'10000 int в списке':<32} {a:>10.2f} мкс")
    print(f"{'10000 списков по одному int':<32} {b:>10.2f} мкс")
    print(f"\nразница: {b / a:.1f}x при одинаковом числе чисел")
    print("int — атомарный тип: deepcopy возвращает его как есть, не копируя.")


def bench_alternatives() -> None:
    """ВСЕ ПЯТЬ СТРОК — ОДИН ЗАМЕР, ОДНА СТРУКТУРА.

    Поверхностная копия стоит здесь же, а не в отдельном блоке, намеренно:
    иначе её микросекунды и микросекунды остальных пришли бы из разных
    прогонов, и отношение между ними считалось бы по числам, снятым в разное
    время. Читать её как «ещё один способ сделать глубокую копию» нельзя —
    она копирует только верхний уровень, и в таблице это помечено.
    """
    header("3. Одна структура, пять способов получить «копию» (1000 записей)")

    data = make_records(1000)
    env = {"data": data, "copy": copy, "pickle": pickle, "json": json}
    rows = [
        ("data[:] (не глубокая)", "data[:]", 2000),
        ("copy.deepcopy", "copy.deepcopy(data)", 20),
        ("pickle туда-обратно", "pickle.loads(pickle.dumps(data))", 20),
        ("json туда-обратно", "json.loads(json.dumps(data))", 20),
        (
            "руками, по форме",
            "[{'id': d['id'], 'tags': list(d['tags']), 'meta': dict(d['meta'])} for d in data]",
            20,
        ),
    ]
    results = [(name, us(stmt, env, number)) for name, stmt, number in rows]
    base = dict(results)["copy.deepcopy"]
    print(f"{'способ':<24} {'мкс':>10} {'к deepcopy':>12}")
    for name, value in results:
        print(f"{name:<24} {value:>10.2f} {base / value:>11.1f}x")
    print("\nЧитать вместе с graph.py, раздел 8: быстрее — не значит то же самое.")
    print("json теряет типы и падает на циклах, ручной способ знает форму данных.")


def main() -> None:
    print(f"Python {sys.version.split()[0]}")
    bench_shallow()
    bench_object_count()
    bench_alternatives()


if __name__ == "__main__":
    main()