Зачем нужен правильный инструмент
В предыдущих уроках мы уже пользовались timeit, sys.getsizeof, tracemalloc. Сейчас разберёмся подробно — какой инструмент когда брать, как правильно его настроить, какие у него подводные камни. На этом arsenale построен весь курс — без правильных измерений ваши «он быстрее» — это голос интуиции, а не факт.
Каждый инструмент решает свою задачу. Использовать неправильный — получить неправильные числа.
timeit правильно
timeit — stdlib-инструмент для микробенчмарков. Базовое использование:
import timeit
t = timeit.timeit(
stmt='lst.append(0)',
setup='lst = []',
number=100_000
)
print(f"100K appends: {t:.4f}s, per-op: {t / 100_000 * 1e9:.1f} ns")
Параметры:
- stmt — код, который мерим. Строка.
- setup — код подготовки. Выполняется один раз. В замер не входит.
- number — сколько раз подряд выполнить stmt в одном замере. По умолчанию 1M.
Когда какое number
number зависит от скорости одной операции:
- Если одна операция меньше 10 ns — нужно
number >= 100_000для измеримого времени. - Если ~1 us —
number = 10_000нормально. - Если ~1 ms —
number = 100хватит. - Если ~1 s —
number = 5-10, иначе долго ждать.
Эмпирическое правило: суммарное время одного замера должно быть 0.1-2 секунды. Меньше — шум, больше — нет смысла повторять.
timeit.repeat и почему min()
Один замер недостоверен — может попасть на GC, переключение контекста, что угодно. Решение — повторить несколько раз и взять минимум:
import timeit
times = timeit.repeat(
stmt='lst.append(0)',
setup='lst = []',
number=100_000,
repeat=5
)
best = min(times)
print(f"Best of 5 runs: {best:.4f}s, per-op: {best / 100_000 * 1e9:.1f} ns")
Почему min, а не среднее:
- Шум всегда замедляет, никогда не ускоряет (GC pause, context switch, throttling — всё это добавляет время).
- Минимум — «лучший случай без помех», стабильнее всего.
- Среднее тянет вверх длинные хвосты. На production бенчмарках лучше тоже min, либо медиана.
Подвох: timeit не учитывает прогрев кэша
# Плохо: первый запуск медленнее остальных
t = timeit.repeat(stmt='sorted(data)', setup='data = list(range(1000000))', number=1, repeat=10)
print(t)
# Видим: t[0] заметно больше t[1:] — кэш не был прогрет
Решение: либо игнорировать первый запуск, либо number > 1, чтобы кэш прогрелся внутри замера.
Подвох: timeit не учитывает GC
По умолчанию timeit отключает garbage collector на время замера. Это даёт более стабильные числа, но не отражает реальное поведение в production. Если хотите «с GC» — timeit.Timer(..., globals=globals()).timeit() с явным gc.enable().
time.perf_counter — ручной замер
Когда timeit неудобен (например, нужно измерить нечто, что меняет состояние, или нужны разные замеры внутри одной функции) — используем time.perf_counter_ns():
import time
def slow_dedupe(items):
seen = []
for x in items:
if x not in seen:
seen.append(x)
return seen
data = list(range(10_000))
start = time.perf_counter_ns()
result = slow_dedupe(data)
elapsed_ns = time.perf_counter_ns() - start
print(f"Elapsed: {elapsed_ns / 1e6:.2f} ms")
perf_counter — самый точный таймер в Python, монотонный (не сбивается NTP), с разрешением в наносекунды на современных системах.
Не используйте time.time() для бенчмарков — он привязан к wall-clock и может прыгать при синхронизации времени.
sys.getsizeof и его коварство
sys.getsizeof(obj) возвращает размер самого объекта в байтах. Подвох — только shallow, без referenced:
import sys
# list содержит указатели на int-ы
lst = [1, 2, 3]
print(sys.getsizeof(lst)) # 88
# Дополним int-ами — размер list не изменится (capacity достаточно)
lst.append(4)
print(sys.getsizeof(lst)) # ещё 88 (или вырос, если capacity исчерпан)
# Сами int-ы лежат отдельно — getsizeof их не видит
print(sys.getsizeof(42)) # 28 — сам int
print(sys.getsizeof('hello')) # 54 — сама строка
Когда это критично — посчитайте «реальный» размер вручную:
import sys
def shallow_size(obj):
return sys.getsizeof(obj)
def list_total_size(lst):
return sys.getsizeof(lst) + sum(sys.getsizeof(x) for x in lst)
lst = list(range(10000))
print(f"Shallow: {shallow_size(lst):>10} bytes")
print(f"With int-objects: {list_total_size(lst):>10} bytes")
Типичный вывод:
Shallow: 80056 bytes
With int-objects: 364056 bytes
Разница в 4.5x — потому что int-ы по 28 байт каждый, в shallow не входят. На больших списках это разница между «50 MB» и «220 MB» — критично для оценки memory budget.
pympler.asizeof — рекурсивный размер
pympler (внешняя библиотека, pip install pympler) даёт рекурсивный обход:
from pympler import asizeof
lst = list(range(10000))
print(asizeof.asizeof(lst)) # ~360000 — со всеми int-ами
big_dict = {f"key_{i}": [i, i+1, i+2] for i in range(10000)}
print(asizeof.asizeof(big_dict)) # реальный размер dict + ключей + values
asizeof умеет рекурсивно ходить по всем references — единственный надёжный способ узнать «сколько реально занимает структура».
Подвохи:
- Медленный —
asizeof.asizeof(huge_obj)может занять секунды. - Не считает shared объекты дважды — это правильно, но может вводить в заблуждение.
tracemalloc — пиковая память во времени
Чтобы измерить сколько памяти ваш алгоритм выделил во время работы — tracemalloc (stdlib):
import tracemalloc
def make_big_dict(n):
return {i: [i, i*2, i*3] for i in range(n)}
tracemalloc.start()
d = make_big_dict(100_000)
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"Currently allocated: {current / 1024 / 1024:.2f} MB")
print(f"Peak during call: {peak / 1024 / 1024:.2f} MB")
Типичный вывод:
Currently allocated: 18.50 MB
Peak during call: 18.50 MB
peak — максимум, который был выделен во время работы, включая временные. Часто peak больше current — например, если внутри функции создавался временный список такого же размера, потом отбрасывался.
Snapshots — что выделено и где
tracemalloc умеет делать snapshots и сравнивать:
import tracemalloc
tracemalloc.start()
# Базовый snapshot
snap1 = tracemalloc.take_snapshot()
# Что-то делаем
data = [list(range(1000)) for _ in range(1000)]
# Snapshot после
snap2 = tracemalloc.take_snapshot()
# Сравнение
top_stats = snap2.compare_to(snap1, 'lineno')
for stat in top_stats[:5]:
print(stat)
Это покажет, на каких строках аллоцировалось больше всего памяти. Полезно для отлова memory leaks.
cProfile — где код тратит время
Когда нужно найти узкое место в большом скрипте, не зная заранее, где оно:
import cProfile
import pstats
def slow_pipeline():
data = [i**2 for i in range(100_000)]
sorted_data = sorted(data, reverse=True)
unique = list(set(sorted_data))
return sum(unique)
profiler = cProfile.Profile()
profiler.enable()
slow_pipeline()
profiler.disable()
stats = pstats.Stats(profiler)
stats.sort_stats('cumulative').print_stats(10)
Вывод — таблица с временем по функциям. Помогает быстро увидеть, какая функция жрёт основной cumulative time.
Подвох: cProfile сам по себе добавляет overhead (~30-100% slowdown). Не для production. Только для разовых анализов.
perf — hardware counters (Linux only)
Серьёзный инструмент. perf (часть linux-tools) даёт доступ к реальным метрикам CPU: cache misses, branch mispredictions, IPC (instructions per cycle).
# Запустить с подсчётом hardware events
perf stat -e cache-references,cache-misses,branches,branch-misses python my_script.py
Типичный вывод:
Performance counter stats for 'python my_script.py':
310,512,403 cache-references
45,123,857 cache-misses # 14.53% of all cache refs
1,234,567,890 branches
12,345,678 branch-misses # 1.00% of all branches
5.234 seconds time elapsed
cache-misses 14% — много, есть простор для оптимизации. branch-misses 1% — нормально.
perf — единственный способ прямо увидеть, как ваш код взаимодействует с железом. Используется senior-инженерами при глубокой оптимизации.
Попробуй сам: измеряем все вместе
Маленький бенчмарк, использующий все ключевые инструменты:
import timeit
import sys
import tracemalloc
from pympler import asizeof
# Создаём данные
def make_dict_data(n):
return {f"key_{i}": i*2 for i in range(n)}
N = 100_000
# 1. Время создания
t = min(timeit.repeat(lambda: make_dict_data(N), number=3, repeat=3)) / 3
print(f"Time to create dict of {N}: {t*1000:.1f} ms")
# 2. sys.getsizeof — shallow
d = make_dict_data(N)
print(f"sys.getsizeof: {sys.getsizeof(d):>10} bytes")
# 3. pympler.asizeof — recursive
print(f"asizeof.asizeof: {asizeof.asizeof(d):>10} bytes")
# 4. tracemalloc — peak во время создания
tracemalloc.start()
d2 = make_dict_data(N)
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"tracemalloc peak: {peak:>10} bytes")
Запустите этот код у себя. Сравните числа.
Типичный вывод:
Time to create dict of 100000: 15.2 ms
sys.getsizeof: 4194392 bytes (4 MB)
asizeof.asizeof: 12734296 bytes (12 MB)
tracemalloc peak: 12500000 bytes (12 MB)asizeof и tracemalloc дают близкие числа (~12 MB), потому что они смотрят на полный размер с referenced объектами. sys.getsizeof говорит «4 MB» — только размер dict-структуры (массив корзин), без значений. Разница 3x — типичная для dict с small values. Используйте правильный инструмент.
Шпаргалка: какой инструмент когда
Запомните эту таблицу — она спасёт от неверных бенчмарков.
Главные правила измерений
- min() из repeat-серии, не среднее.
- Прогрейте кэш перед замером (1-2 dummy запуска).
- Закройте Slack/Chrome, ноут на зарядке.
- number в timeit подбирайте под единичную операцию.
- Не доверяйте sys.getsizeof для контейнеров — он не считает referenced.
- tracemalloc — главный инструмент для памяти алгоритма.
- Один замер ничего не значит — повторите 3-5 раз минимум.
На этой ноте мы заканчиваем фундаментальный модуль про память и железо. В следующем модуле — массивы и непрерывная память. С этого момента каждая структура данных будет разобрана с измерениями этими же инструментами.
cProfile и pstats: профилирование на уровне функций perf и flame graphs: профилирование на уровне CPU