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