Learning Platform
Глоссарий Troubleshooting
Урок 04.05 · 28 мин
Начальный
MeasurementtimeittracemallocProfiling

Зачем нужен правильный инструмент

В предыдущих уроках мы уже пользовались timeit, sys.getsizeof, tracemalloc. Сейчас разберёмся подробно — какой инструмент когда брать, как правильно его настроить, какие у него подводные камни. На этом arsenale построен весь курс — без правильных измерений ваши «он быстрее» — это голос интуиции, а не факт.

Инструменты по задачам

Каждый инструмент решает свою задачу. Использовать неправильный — получить неправильные числа.

timeitВремя короткой операции (наносекунды-секунды)Лучший для микробенчмарков. Не для долгих многопроходных пайплайнов.
time.perf_counter_nsВремя одного вызова, ручной replayКогда timeit не подходит — например, нужно измерить асимметричные операции.
sys.getsizeofРазмер ОДНОГО объекта shallowНе считает referenced объекты. Подвох! Использовать осторожно.
pympler.asizeofРазмер объекта рекурсивноСчитает все referenced объекты. Точно, но медленно.
tracemallocПиковая память, выделенная Python между snapshot-амиЛучший для 'сколько съел алгоритм'. Snake-build-in.
cProfileПрофилирование функция-по-функцииКогда нужно найти hot spot в большом коде.
perf (linux)Hardware counters: cache misses, branch mispredictionsДоступ к реальным метрикам CPU. Только Linux.

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")
TIP

Запустите этот код у себя. Сравните числа.

Типичный вывод:

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. Используйте правильный инструмент.

Шпаргалка: какой инструмент когда

Выбор инструмента

Запомните эту таблицу — она спасёт от неверных бенчмарков.

Микробенчмарк скоростиtimeit с repeat + minКороткие операции, статистически устойчиво
Длинный pipelinetime.perf_counter_nsОдин-два запуска, прямой замер
Размер объекта shallowsys.getsizeofБыстро, но без referenced
Размер рекурсивноpympler.asizeofТочно, медленно
Память за периодtracemallocPeak во время работы кода
Где тратится времяcProfileHot spots по функциям
Hardware metricsperf (Linux)Cache misses, branch mispredictions
Сравнение snapshotstracemalloc.compare_toНайти memory leaks

Главные правила измерений

  1. min() из repeat-серии, не среднее.
  2. Прогрейте кэш перед замером (1-2 dummy запуска).
  3. Закройте Slack/Chrome, ноут на зарядке.
  4. number в timeit подбирайте под единичную операцию.
  5. Не доверяйте sys.getsizeof для контейнеров — он не считает referenced.
  6. tracemalloc — главный инструмент для памяти алгоритма.
  7. Один замер ничего не значит — повторите 3-5 раз минимум.

На этой ноте мы заканчиваем фундаментальный модуль про память и железо. В следующем модуле — массивы и непрерывная память. С этого момента каждая структура данных будет разобрана с измерениями этими же инструментами.

cProfile и pstats: профилирование на уровне функций perf и flame graphs: профилирование на уровне CPU
Проверка знанийKnowledge check
Junior запускает timeit('sorted(data)', setup='data = list(range(1000000))', number=1, repeat=10) и видит, что первый замер 200ms, остальные 9 по 80ms. Почему такая разница?
ОтветAnswer
Это эффект "холодного" кэша при первом запуске. Когда мы выполняем sorted(data) первый раз, данные ещё не загружены в L1/L2 cache CPU, Python должен подтянуть list, его элементы int, всё с RAM. Это медленно. На втором запуске после первого данные уже в кэше — sort идёт быстрее. Решения: (1) игнорировать первый замер (использовать min — он автоматически отбросит slowest); (2) использовать number > 1 чтобы прогрев попал в одну итерацию; (3) сделать явный warmup пробежать функцию 1-2 раза перед замером. min() в этом примере вернёт 80ms — правильное значение для "тёплого" кэша, что обычно ближе к реальному production-сценарию.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Какой параметр timeit отвечает за стабильность статистики измерения?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5