Как измерить время выполнения кода в Python: perf_counter, timeit и cProfile

Как измерить время выполнения кода в Python: perf_counter, timeit и cProfile Полезное

Тайминг в Python — это измерение того, сколько времени занимает выполнение фрагмента кода: засекли момент до, момент после и взяли разницу. Для разового замера берут time.perf_counter(), для сравнения двух маленьких вариантов кода — модуль timeit, а для поиска медленного места в программе — профилировщик cProfile.

Примеры проверены на Python 3.14.7 (Linux, Docker-образ python:3.14-slim) 23.09.2026. Числа времени у вас будут другими: они зависят от процессора, нагрузки и версии интерпретатора, совпадать должен только порядок величин и соотношения.

Три термина, которые дальше не смешиваются:

  • Время по стене (wall-clock) — сколько прошло реального времени, включая ожидание сети, диска и sleep.
  • Процессорное время (CPU time) — сколько процессор реально работал на ваш процесс; ожидание сюда не входит.
  • Монотонные часы — часы, которые никогда не идут назад, даже если систему перевели или синхронизировали по NTP.

Короткий ответ: замер через perf_counter

Минимальный полный пример — его можно скопировать и запустить:

import time

def work():
    return sum(i * i for i in range(1_000_000))

start = time.perf_counter()
result = work()
elapsed = time.perf_counter() - start

print(f"результат: {result}")
print(f"время: {elapsed:.3f} с")

В консоли появятся результат и время в секундах:

результат: 333332833333500000
время: 0.026 с

Разбор по строкам. time.perf_counter() возвращает число секунд с плавающей точкой от некоторой точки отсчета. Сама по себе эта точка ничего не значит — смысл имеет только разность двух вызовов. Поэтому значение perf_counter() нельзя печатать как «текущее время» и нельзя сравнивать между разными запусками программы.

Какие часы выбрать: time, monotonic, perf_counter, process_time

В модуле time несколько функций, похожих на «таймер». Они отвечают на разные вопросы:

Функция Что измеряет Может прыгнуть назад Когда брать
time.time() Секунды с начала эпохи Unix (1970-01-01 UTC) Да, при переводе часов и синхронизации Отметка «когда произошло событие», запись в лог
time.monotonic() Монотонное время по стене Нет Таймауты, «прошло ли 30 секунд» в долгоживущем процессе
time.perf_counter() Монотонное время по стене с наилучшим доступным разрешением Нет Замер длительности кода
time.process_time() Процессорное время текущего процесса (без sleep) Нет Сколько работал сам код, без ожиданий

У каждой функции есть вариант с суффиксом _ns (perf_counter_ns() и другие) — он возвращает целое число наносекунд и не теряет точность на округлении float.

Свойства часов на своей машине можно посмотреть, а не угадывать:

import time

for name in ("time", "monotonic", "perf_counter", "process_time"):
    info = time.get_clock_info(name)
    print(f"{name:13} monotonic={info.monotonic!s:5} "
          f"adjustable={info.adjustable!s:5} resolution={info.resolution}")

Вывод на Linux:

time          monotonic=False adjustable=True  resolution=1e-09
monotonic     monotonic=True  adjustable=False resolution=1e-09
perf_counter  monotonic=True  adjustable=False resolution=1e-09
process_time  monotonic=True  adjustable=False resolution=1e-09

Главное здесь — колонка adjustable=True у time.time(): эти часы может сдвинуть система. Если синхронизация сработает посреди замера, разность получится неверной, в худшем случае отрицательной. Значение resolution зависит от ОС: на macOS тот же код показывает другие числа. Это разрешение часов, а не гарантированная точность замера.

Время по стене и процессорное время на одном примере

Разницу проще всего увидеть, если код сначала ждет, а потом считает:

import time

wall0, cpu0 = time.perf_counter(), time.process_time()
time.sleep(1)                               # ждем, процессор свободен
sum(i * i for i in range(3_000_000))        # считаем, процессор занят
wall = time.perf_counter() - wall0
cpu = time.process_time() - cpu0

print(f"по стене (perf_counter): {wall:.2f} с")
print(f"процессор (process_time): {cpu:.2f} с")
по стене (perf_counter): 1.09 с
процессор (process_time): 0.09 с

Секунда sleep вошла только в первое число. Как выбирать: если пользователь ждет ответа (запрос к API, загрузка файла), смотрите время по стене. Если хотите понять, сколько «весит» сам алгоритм без ожиданий, смотрите process_time(). Учтите границу: process_time() суммирует время всех потоков процесса, а время дочерних процессов в него не входит.

Удобная обертка: контекстный менеджер и декоратор

Писать start = ... вокруг каждого фрагмента неудобно. Обычно делают маленький таймер, который можно вешать и на блок, и на функцию:

import time
from contextlib import contextmanager
from functools import wraps

@contextmanager
def timer(label):
    start = time.perf_counter()
    try:
        yield
    finally:
        print(f"{label}: {time.perf_counter() - start:.4f} с")

def timed(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        with timer(func.__name__):
            return func(*args, **kwargs)
    return wrapper

@timed
def build_list(n):
    return [str(i) for i in range(n)]

with timer("сортировка"):
    sorted(range(2_000_000, 0, -1))

build_list(500_000)
сортировка: 0.0360 с
build_list: 0.0178 с

Блок finally печатает время, даже если внутри случилось исключение, а functools.wraps сохраняет имя исходной функции. Такой таймер подходит для грубых замеров в скриптах. Для сравнения двух реализаций одного и того же он слишком шумный — для этого есть timeit.

timeit: сравнить варианты кода честно

Один замер маленького фрагмента почти ничего не говорит: он длится микросекунды, и любая фоновая активность системы искажает результат. Модуль timeit запускает код много раз подряд и повторяет серию несколько раз.

Самый быстрый способ — командная строка:

python -m timeit -s "data = list(range(10_000))" "[x * 2 for x in data]"
2000 loops, best of 5: 160 usec per loop

Флаг -s задает подготовку (setup), которая не входит в замер. timeit сам подобрал число повторов в серии (2000), сделал 5 серий и показал лучшую.

То же из кода, сразу для трех вариантов:

import timeit

setup = "data = list(range(10_000))"
variants = {
    "цикл for":      "r = []\nfor x in data:\n    r.append(x * 2)",
    "list comp":     "[x * 2 for x in data]",
    "map + lambda":  "list(map(lambda x: x * 2, data))",
}

for name, stmt in variants.items():
    times = timeit.repeat(stmt, setup=setup, repeat=5, number=200)
    best = min(times) / 200 * 1e6        # микросекунды на один запуск
    print(f"{name:13} {best:8.1f} мкс")
цикл for         164.0 мкс
list comp        150.7 мкс
map + lambda     295.2 мкс

Почему берут минимум, а не среднее: помехи (другие процессы, прерывания) только добавляют время, но не отнимают. Минимум ближе всего к стоимости самого кода. Это соглашение документации timeit, а не закон: для задач с разбросом по природе (сеть, диск) смотрят медиану и разброс.

Обратите внимание на соотношение: в этом прогоне цикл и list comprehension почти равны, а map с lambda вдвое медленнее. На другой версии Python разница может измениться — поэтому такие выводы проверяют замером на своей версии, а не берут из статей.

Частая ошибка: NameError внутри timeit

Неверно — передать строку с вызовом своей функции:

import timeit

def work():
    return sum(i * i for i in range(10_000))

print(timeit.timeit("work()", number=100))

Последние строки трейсбека:

  File "<timeit-src>", line 6, in inner
NameError: name 'work' is not defined

Строка выполняется в отдельном пространстве имен, где вашей функции нет. Исправление — передать globals=globals() или саму функцию без кавычек:

import timeit

def work():
    return sum(i * i for i in range(10_000))

t1 = timeit.timeit("work()", number=100, globals=globals())
t2 = timeit.timeit(work, number=100)
print(f"строка + globals: {t1 / 100 * 1000:.3f} мс на вызов")
print(f"функция напрямую: {t2 / 100 * 1000:.3f} мс на вызов")
строка + globals: 0.240 мс на вызов
функция напрямую: 0.238 мс на вызов

cProfile: найти, где программа тратит время

perf_counter и timeit отвечают на вопрос «сколько». Профилировщик отвечает на вопрос «где»: он считает вызовы каждой функции и время в ней. Для запуска всего скрипта достаточно команды python -m cProfile -s cumulative script.py. Ниже — вариант из кода, файл profile_demo.py:

import cProfile
import pstats

def parse(lines):
    return [line.split(",") for line in lines]

def slow_total(rows):
    total = 0
    for row in rows:
        total += sum(int(x) for x in row)
    return total

def main():
    lines = ["1,2,3,4,5"] * 200_000
    rows = parse(lines)
    return slow_total(rows)

with cProfile.Profile() as prof:
    main()

stats = pstats.Stats(prof)
stats.sort_stats("cumulative").print_stats(6)
         1600005 function calls in 0.275 seconds

   Ordered by: cumulative time
   List reduced from 8 to 6 due to restriction <6>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.275    0.275 /tmp/profile_demo.py:13(main)
        1    0.029    0.029    0.228    0.228 /tmp/profile_demo.py:7(slow_total)
   200000    0.087    0.000    0.199    0.000 {built-in method builtins.sum}
  1200000    0.112    0.000    0.112    0.000 /tmp/profile_demo.py:10(<genexpr>)
        1    0.025    0.025    0.046    0.046 /tmp/profile_demo.py:4(parse)
   200000    0.022    0.000    0.022    0.000 {method 'split' of 'str' objects}

Как читать: ncalls — число вызовов, tottime — время внутри самой функции без вложенных, cumtime — вместе с вложенными. Видно, что parse занимает 0.046 с, а slow_total — 0.228 с, и почти все это — миллион с лишним вызовов генератора с int(). Оптимизировать нужно именно это место.

Граница метода: cProfile сам добавляет накладные расходы на каждый вызов функции, поэтому абсолютные секунды в его отчете завышены. Им ищут горячие места, а итоговое ускорение подтверждают обычным замером без профилировщика.

Почему замеры скачут и как получить устойчивый результат

Пять одинаковых замеров подряд дают разные числа:

import time

def work():
    return sum(i * i for i in range(200_000))

for attempt in range(5):
    start = time.perf_counter()
    work()
    print(f"попытка {attempt + 1}: {(time.perf_counter() - start) * 1000:.2f} мс")
попытка 1: 5.38 мс
попытка 2: 5.49 мс
попытка 3: 5.37 мс
попытка 4: 5.37 мс
попытка 5: 5.60 мс

Здесь разброс около 4%, на ноутбуке с браузером и сменой частоты процессора он бывает заметно больше. Источники погрешности: фоновые процессы, изменение частоты процессора, сборщик мусора, прогрев кешей, первый импорт модулей.

Практические правила:

  • Повторяйте замер и смотрите минимум или медиану, а не одно число.
  • Сравнивайте варианты на одной машине, одной версии Python и одних данных.
  • Не держите print и логирование внутри измеряемого цикла — вывод медленнее многих вычислений.
  • Разницу в пределах разброса (здесь несколько процентов) не считайте результатом.

Если нужно не измерить, а подождать или запустить позже

Слово «таймер» в Python часто означает задержку, а не замер. Это другие инструменты:

Задача Инструмент Что важно знать
Приостановить текущий поток time.sleep(сек) Блокирует поток; в GUI замораживает окно
Вызвать функцию через N секунд, не останавливая программу threading.Timer(сек, func) Функция выполнится в отдельном потоке
Пауза в асинхронном коде await asyncio.sleep(сек) Не блокирует цикл событий, в отличие от time.sleep
Отложенный вызов в Tkinter widget.after(мс, func) Интервал в миллисекундах

Короткий пример на первые два пункта:

import threading
import time

start = time.perf_counter()

def remind():
    print(f"сработал через {time.perf_counter() - start:.1f} с")

t = threading.Timer(1.5, remind)   # вызвать remind через 1.5 с в отдельном потоке
t.start()
print("основной поток продолжает работу")
time.sleep(0.5)                     # пауза текущего потока
print(f"пауза закончилась через {time.perf_counter() - start:.1f} с")
t.join()
основной поток продолжает работу
пауза закончилась через 0.5 с
сработал через 1.5 с

sleep гарантирует паузу не меньше заданной (с оговоркой про сигналы), но не точно заданную: планировщик ОС может разбудить поток позже.

Выводы

  • Для замера длительности кода берите time.perf_counter(): часы монотонные и с лучшим доступным разрешением; time.time() может сдвинуться системой посреди замера.
  • Время по стене (perf_counter) включает ожидания, процессорное (process_time) — только работу процессора; выбор зависит от вопроса.
  • Для сравнения маленьких фрагментов используйте timeit: много запусков, несколько серий, минимум; свою функцию передавайте через globals или объектом.
  • Чтобы найти медленное место, запускайте cProfile и смотрите cumtime; ускорение подтверждайте замером без профилировщика.
  • Один замер — не результат: повторяйте, сравнивайте в одинаковых условиях и не принимайте разницу в пределах разброса за выигрыш.

Где применяется / связь с практикой

Замер времени — первый шаг любой оптимизации: сначала профилировщик показывает узкое место, потом timeit сравнивает варианты, потом обычный замер подтверждает выигрыш на реальных данных. Так работают с медленными API-обработчиками, ETL-скриптами, тестами производительности в CI.

Освойте тему на практике

Профилирование, асинхронный код, многопоточность и работа с производительностью Python разбираются на курсе Python Developer. Professional. Попробовать формат можно на бесплатных открытых уроках Otus.

FAQ

Чем perf_counter() отличается от monotonic()?
Обе функции монотонные и считают время по стене. perf_counter() использует часы с наилучшим доступным разрешением для коротких замеров; на части платформ это те же часы, что у monotonic(), что видно по time.get_clock_info().

Можно ли замерить время в Jupyter без кода обертки?
Да, в IPython и Jupyter есть магические команды %timeit для одной строки и %%timeit для ячейки. Они используют тот же подход, что модуль timeit, но это возможность IPython, а не обычного Python.

Как замерить асинхронную функцию?
Засеките perf_counter() до и после await внутри корутины. Результат будет временем по стене, включающим ожидание других задач в цикле событий, а не только работу самой функции.

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)